NovuSpark
All articles

November 28, 2025 · NovuSpark Team

GitHub Actions 101: Your First CI Workflow

This is the second post in our GitHub and GitHub Actions series. Start with Git fundamentals if you're joining partway through.

We actually used GitHub Actions ourselves, in a real production deploy, documented in detail in an earlier case study — this post steps back to explain the underlying concepts that workflow was built from, from first principles.

The basic shape of a workflow

# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
 
      - name: Install dependencies
        run: npm ci
 
      - name: Run tests
        run: npm test

Every workflow breaks down into the same four concepts:

  • A workflow is the whole file — one automated process, triggered by something.
  • on defines the trigger. Here, any push to main, and any pull request targeting main — meaning this test suite runs both on direct pushes and, importantly, on every PR before it merges, catching a problem before it reaches main rather than after.
  • A job (test, here) is a set of steps that run together, on a single fresh virtual machine (runs-on: ubuntu-latest). A workflow can have multiple jobs, which by default run in parallel, not in sequence.
  • A step is one action within a job — either a reusable uses: action (like actions/checkout@v4, which pulls your repository's code onto the runner) or a plain run: shell command.

Every run starts from nothing

The single most important thing to internalize about a GitHub Actions runner: it's a brand new, empty virtual machine, every single time. Nothing persists between runs unless you explicitly arrange for it to. This is precisely why actions/checkout@v4 has to be the first step in nearly every job — without it, there's no code on the runner at all, just a blank Ubuntu machine.

Run npm test

> my-app@1.0.0 test
> jest

 PASS  src/auth.test.ts
 PASS  src/utils.test.ts

Test Suites: 2 passed, 2 total
Tests:       14 passed, 14 total

A job's overall status is determined by its steps: any step that exits with a non-zero status fails the entire job, and — by default — every step after it in that job is skipped, showing as a failed run in the GitHub UI, directly on the commit or pull request that triggered it.

Speeding up repeat runs with caching

Reinstalling every npm dependency from scratch, on every single run, is slow and — for a real project with real dependencies — genuinely wasteful. cache: "npm" on the setup-node action (already shown above) handles the common case automatically, but it's worth understanding what it's actually doing underneath:

      - name: Cache node_modules
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-npm-

The cache key is derived from a hash of package-lock.json — meaning the cache is reused whenever dependencies haven't changed, and automatically invalidated (a new key, a fresh cache) the moment they have. This is the general pattern behind effective CI caching everywhere, not just for npm: key the cache on whatever file actually determines whether the cached content is still valid.

Running jobs in parallel — and controlling dependencies between them

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint
 
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test
 
  build:
    needs: [lint, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build

lint and test here run simultaneously, on two separate runners — there's no dependency between them, so there's no reason to force one to wait for the other, and running them in parallel is what keeps overall CI time down as a project's test suite grows. build's needs: [lint, test] explicitly makes it wait for both to succeed first — the correct dependency here, since there's little value building an artifact from code that hasn't even passed linting or tests yet.

Viewing what actually happened

gh run list --limit 5
gh run watch <run-id>
gh run view <run-id> --log

The gh CLI (which we've used directly, in exactly this form, in our own deploy pipeline) gives you the same information as the GitHub web UI's Actions tab, without leaving the terminal — genuinely useful for scripting around CI status, or just for a faster feedback loop while actively debugging a failing workflow.

What to actually remember from this post

  • A runner starts completely empty, every single run — nothing persists unless you explicitly cache or restore it.
  • on: defines what triggers a workflow — pushes, pull requests, schedules, and several other event types all follow the same pattern.
  • Jobs run in parallel by default; needs: is how you express an actual dependency between them.
  • Cache keys should be derived from whatever file determines cache validity — a lockfile hash is the standard pattern for dependency caching specifically.

Next in the series: Building Reusable GitHub Actions Workflows and Composite Actions, where we cover how to avoid copy-pasting the same steps across every repository a team owns.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.