NovuSpark
All articles
CI/CD & DevOpsNovember 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.
workflow: CI (triggered by on:)job: test (one runner)step: checkoutstep: setup-nodestep: npm cistep: npm testjob: lint (separate runner)runs in parallel with "test" —no dependency between them
Fig. 1 — a workflow's jobs run in parallel by default; each job's own steps run in sequence on one fresh runner

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.

Beyond push and pull_request: other useful triggers

on: supports far more than the two events shown above — worth knowing what's available before reaching for a workaround that a built-in trigger already handles:

on:
  schedule:
    - cron: "0 3 * * *"     # every day at 03:00 UTC
  workflow_dispatch:         # a manual "Run workflow" button in the GitHub UI
  release:
    types: [published]       # runs when a GitHub Release is published

schedule is what powers the "rebuild and rescan nightly" pattern covered for Docker images earlier in this blog. workflow_dispatch adds a manual trigger button directly in the GitHub Actions UI — useful for a deploy you want to trigger deliberately rather than automatically on every push, and it's what we used for exactly that purpose in our own CI/CD case study. Multiple triggers can coexist in one workflow's on: block, each independently able to start a run.

Passing data between steps

Steps within the same job share a filesystem, but passing a computed value (not a file) from one step to a later one uses a dedicated mechanism:

      - name: Get short commit SHA
        id: vars
        run: echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
 
      - name: Use it in a later step
        run: echo "Deploying commit ${{ steps.vars.outputs.sha_short }}"

id: vars names the step so a later one can reference it; writing to the special $GITHUB_OUTPUT file is what makes a value available as steps.<id>.outputs.<name> anywhere later in the same job. This is the mechanism behind passing a build's version number, a computed artifact path, or any other runtime-determined value between otherwise-independent steps.

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.

Matrix builds: running the same job across multiple configurations

Testing an application against several Node versions, or several operating systems, without duplicating the entire job definition per combination, is exactly what a build matrix is for:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        node-version: ["18", "20", "22"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

This single job definition runs six times — every combination of two operating systems and three Node versions — with ${{ matrix.os }} and ${{ matrix.node-version }} resolving to the specific combination for each individual run. This is the same "test across the platforms and configurations you actually need to support" principle covered for Molecule scenarios in our Ansible series, applied here to a JavaScript project's compatibility matrix instead of a role's target operating systems.

Conditional steps within a job

A step doesn't always need to run on every single trigger — if: lets a specific step run conditionally, based on the event that triggered the workflow or the outcome of an earlier step:

      - name: Notify on failure
        if: failure()
        run: curl -X POST $SLACK_WEBHOOK -d '{"text": "Build failed on main"}'
 
      - name: Deploy
        if: github.ref == 'refs/heads/main' && success()
        run: ./deploy.sh

if: failure() runs a step only when a previous step in the same job has failed — genuinely useful for a notification step that should fire specifically on failure, not on every run regardless of outcome. Combining github.ref checks with success/failure conditions is how a single workflow file safely handles different behavior for different branches and different outcomes, without needing entirely separate workflow files for each case.

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, manual dispatch, and release events all follow the same pattern.
  • Jobs run in parallel by default; needs: is how you express an actual dependency between them.
  • $GITHUB_OUTPUT passes computed values between steps within the same job — the mechanism behind referencing a build's own version or commit SHA later in a workflow.
  • 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.