---
title: "GitHub Actions"
slug: "github-actions"
category: "tooling"
tags: ["tooling", "github", "ci", "automation", "devops", "yaml", "workflow"]
status: "stable"
last_updated: 2026-05-14
summary: "Workflow YAML structure, jobs vs steps, matrix builds, caching, secrets, concurrency guards, and reusable workflow patterns for reliable CI."
related: ["[[tooling/github]]", "[[tooling/github-secrets]]", "[[tooling/github-branch-protection]]", "[[tooling/github-releases]]", "[[coding/git]]", "[[ops/github-pages]]", "[[howto/deploy-quartz-site]]"]
---

## Overview

GitHub Actions is the CI layer for most projects on this site. A workflow file is YAML that declares triggers, jobs, and steps; Actions runs them on managed runners. The rules below cover structure, caching, secrets, concurrency, and reusable workflows. Read [[tooling/github]] for the branching and merge strategy that these workflows enforce.

## Structure workflow files by concern, one file per trigger

Each `.github/workflows/` file owns exactly one responsibility. `ci.yml` runs lint and tests on pull requests. `deploy.yml` runs build and publish on push to `main`. `release.yml` cuts a release on tag push. Mixing concerns in one file creates a workflow that always does the wrong subset of work.

Keep each file under 100 lines. When a job grows past that, extract a composite action in `.github/actions/<name>/action.yml` or promote it to a reusable workflow.

## Jobs are parallel; steps are sequential within a job

A job runs on a fresh runner. Steps inside a job share state: filesystem, environment, and tool outputs. Use multiple jobs when steps are independent and you want parallelism. Use `needs:` to express dependencies between jobs.

```yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test
```

A job that fails stops downstream jobs that `needs:` it. Use `if: always()` on a reporting job when you need it to run regardless.

## Matrix builds multiply a job across dimensions

Define a `strategy.matrix` when you need the same job on multiple values, such as Node versions or operating systems.

```yaml
strategy:
  matrix:
    node: [20, 22]
    os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
  - uses: actions/setup-node@v4
    with:
      node-version: ${{ matrix.node }}
```

Set `fail-fast: false` to let all matrix legs finish even when one fails. This gives you the full failure surface in one run rather than a partial report. Use `matrix.include` to add one-off combinations and `matrix.exclude` to drop invalid ones.

## Cache dependencies to cut run time

Uncached `npm ci` or `pip install` dominates a short workflow. Cache the dependency store and restore it on the next run.

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

The `key` must change when the lockfile changes; hash the lockfile directly. Use `restore-keys` as a prefix fallback so a partial cache is better than no cache. Cache invalidation happens automatically when the key changes.

## Scope `GITHUB_TOKEN` permissions to the minimum

The default `GITHUB_TOKEN` has write access to most resources. Declare only what the workflow needs at the top level and override per-job if necessary.

```yaml
permissions:
  contents: read
  pages: write
  id-token: write
```

For workflows that read code and run tests, `contents: read` is sufficient. For deployments to GitHub Pages, add `pages: write` and `id-token: write`. Store third-party credentials in Actions secrets and reference them as `${{ secrets.NAME }}`; see [[tooling/github-secrets]] for the full rules.

## Concurrency guards prevent redundant runs

A fast developer pushing twice in a row starts two deploy runs. The second one makes the first wasteful or harmful.

```yaml
concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true
```

For CI on pull requests, cancel in-progress runs for the same branch. For deployments to production, set `cancel-in-progress: false` to let the current deploy finish before starting the next. Use `github.ref` as the group key to isolate concurrency by branch.

## Reusable workflows eliminate copy-paste across repos

A workflow that takes inputs and secrets and is called from other workflows lives in `.github/workflows/` with `on: workflow_call`. Consuming repos reference it with `uses: org/repo/.github/workflows/build.yml@main`.

```yaml
# .github/workflows/build.yml
on:
  workflow_call:
    inputs:
      node-version:
        required: true
        type: string
    secrets:
      NPM_TOKEN:
        required: true
```

Reusable workflows enforce a single definition of "pass" across a fleet of repos without forking YAML. Pair them with [[tooling/github-branch-protection]] required status checks so every repo enforces the same bar.

## Related

- [[tooling/github]]
- [[tooling/github-secrets]]
- [[tooling/github-branch-protection]]
- [[tooling/github-releases]]
- [[tooling/github-cli]]
- [[coding/git]]
- [[ops/github-pages]]
- [[howto/deploy-quartz-site]]
