Skip to content
DevOps devops cicd 6 min read

Writing a GitHub Actions Workflow

A GitHub Actions workflow is a YAML file that tells GitHub what to do automatically whenever something happens in your repository — for example, when you push code or open a pull request. CI (Continuous Integration — the practice of merging and automatically testing code changes often) lives in these files. In this page you will build a real, copy-pasteable workflow that installs dependencies, runs your tests, and builds your project, using a Node.js example. By the end you will understand jobs, steps, marketplace actions, caching, and matrix builds.

Where the workflow file lives

GitHub looks for workflow files in a special folder inside your repository: .github/workflows/. Any .yml or .yaml file you put there becomes a workflow. You can have many workflow files (one for tests, one for deploys, and so on).

Create the folder and an empty file from your project root on Ubuntu:

mkdir -p .github/workflows
touch .github/workflows/ci.yml

Output:

$ ls -R .github
.github:
workflows

.github/workflows:
ci.yml

The path must be exactly .github/workflows/. A typo like .github/workflow/ (no “s”) means GitHub silently ignores your file and nothing runs. This is the single most common beginner mistake.

The building blocks: workflow, jobs, steps

Three words appear everywhere, so define them once:

TermWhat it meansAnalogy
WorkflowThe whole YAML file and everything it doesA recipe
JobA named group of steps that runs on one fresh machine (a “runner”)One stage of cooking
StepA single command or action inside a jobOne instruction in that stage
ActionA reusable, pre-packaged step you pull from the MarketplaceA kitchen gadget you didn’t build yourself

A runner is a clean virtual machine GitHub gives you for free (Ubuntu, Windows, or macOS) to run each job. Each job starts on its own brand-new runner, so jobs do not share files unless you explicitly pass things between them.

A complete CI workflow

Here is a full, working workflow. Save this as .github/workflows/ci.yml. It triggers on pushes and pull requests to main, then installs, lints, tests, and builds a Node.js project.

name: CI

# When should this run?
on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

jobs:
  build-and-test:
    name: Build and test
    runs-on: ubuntu-24.04   # the runner OS
    steps:
      # 1. Get your code onto the runner
      - name: Check out code
        uses: actions/checkout@v4

      # 2. Install the Node.js version you want
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"      # cache the npm download cache

      # 3. Install dependencies exactly as locked
      - name: Install dependencies
        run: npm ci

      # 4. Run your linter
      - name: Lint
        run: npm run lint

      # 5. Run your tests
      - name: Run tests
        run: npm test

      # 6. Build the project
      - name: Build
        run: npm run build

Once you commit and push this file, open the Actions tab on GitHub. You will see the workflow running with each step expandable so you can read its logs.

What each piece does

  • name: is just the label shown in the GitHub UI.
  • on: lists the triggers (events that start the workflow). Here, any push or pull request targeting main.
  • runs-on: picks the runner OS. Pin a specific version like ubuntu-24.04 rather than ubuntu-latest so your builds do not silently change when GitHub upgrades the default.
  • uses: pulls in a Marketplace action. run: runs a raw shell command instead.

When to use npm ci vs npm install: Use npm ci in CI. It installs the exact versions from package-lock.json and deletes node_modules first, giving a clean, reproducible install. Use npm install only locally when you actually want to add or update packages.

Marketplace actions and pinning versions

The GitHub Marketplace is a library of ready-made actions other people published. Two you will use constantly:

  • actions/checkout — copies your repository onto the runner. Almost every job needs it first.
  • actions/setup-node — installs a chosen Node.js version and sets up caching.

The @v4 after the action name is the version tag. You should always pin a version. Three ways, from convenient to most secure:

Pin styleExampleWhen to use
Major tagactions/checkout@v4Most projects — gets safe minor updates automatically
Exact tagactions/[email protected]When you need fully repeatable builds
Commit SHAactions/checkout@b4ffde6...Security-sensitive repos — a tag can be moved, a SHA cannot

Never use @main or @master for an action you do not own. The author could push new code at any moment and it would run with access to your repository and secrets. Pin to a tag or SHA so you control exactly what executes.

Caching dependencies

Downloading dependencies on every run wastes minutes. Caching saves a folder after one run and restores it on the next, so unchanged dependencies download once and are reused.

The easy way is the cache: "npm" line already in setup-node above — it caches npm’s download cache and keys it off your package-lock.json. When the lock file changes, the cache automatically refreshes.

If you need finer control (for example, caching node_modules itself), use the cache action directly:

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

The key is the exact name of the cache. hashFiles(...) turns your lock file into a fingerprint, so a new lock file produces a new key and a fresh install. restore-keys is a fallback prefix used when no exact match exists.

Matrix builds — test on many versions at once

A matrix runs the same job several times with different inputs. The classic use is testing across multiple Node.js versions to catch version-specific bugs before users do.

jobs:
  test:
    name: Test on Node ${{ matrix.node }}
    runs-on: ubuntu-24.04
    strategy:
      fail-fast: false      # let all versions finish even if one fails
      matrix:
        node: ["20", "22", "24"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: "npm"
      - run: npm ci
      - run: npm test

This launches three parallel jobs — Node 20, 22, and 24 — each on its own runner. Note every matrix value is quoted as a string; node-version: 20 (unquoted) can be misread as a number and pull the wrong version.

When to use a matrix: Use it for libraries and tools that must support several runtimes or operating systems. Skip it for a simple app deployed on one fixed Node version — it just burns build minutes for no benefit.

Best Practices

  • Pin every action to a version tag or commit SHA; never trust a floating branch.
  • Use npm ci (not npm install) so CI installs exactly what your lock file specifies.
  • Enable dependency caching to keep runs fast and cut your billed minutes.
  • Pin runs-on: to a specific OS version (ubuntu-24.04) for reproducible builds.
  • Trigger on pull_request so problems are caught before code merges into main.
  • Keep secrets out of YAML — reference them with ${{ secrets.NAME }}, never paste tokens.
  • Give jobs and steps clear name: labels so failing logs are easy to scan.
Last updated June 15, 2026
Was this helpful?