OFFSET偏移
← All articlesOFFSET / FIELD NOTESNO. 107

Configuring CI with GitHub Actions

Design CI from local quality checks and connect stable check statuses to repository merge rules.

English translation · Code and commands follow the Chinese original.

IN THESE READING PATHSGitHub Actions and CI ↗
About 18 min readStart reading

Prerequisite reading: 04 Actions Fundamentals and the Execution Model. This article assumes you already understand the execution primitives of Workflows, Events, Jobs, Runners, Steps, Matrices, Caches, and Artifacts, and focuses on how to turn local quality gates into maintainable CI.

Continuous Integration (CI) involves more than moving commands into YAML. A good CI contract specifies which changes trigger it, which environments it runs in, which checks determine success, what evidence is retained on failure, and which results block merging. Start with local commands the project already recognizes, then gradually add parallelism, caching, and artifacts, rather than building a complex workflow first.

1. From Local Quality Gates to a CI Contract#

1.1 Define Local Quality Gates First

Before writing a workflow, express the project's quality gates as commands that other people can copy and run. The minimum set usually includes:

  1. Dependency installation: use the lockfile and the runtime version declared by the project.
  2. Static checks: formatting checks, linting, type checking, or Nix evaluation checks.
  3. Tests: unit tests, integration tests, or the smallest test suite the project can reliably automate.
  4. Building/packaging: confirm that the final artifacts can be generated, rather than merely checking that the source can be parsed.
  5. Contract checks: validate consumer-facing interfaces such as exported files, package metadata, CLI help, or the ABI.

CI should call the same commands used locally whenever possible. For example, if the project specifies npm test locally, the workflow should call npm test, rather than maintaining a separate set of hidden CI scripts. This makes failures reproducible on developers' machines and prevents CI from becoming the only source of quality checks.

1.2 Express Quality Gates as a CI Contract

Contract component Question to answer Configuration location Design principle
Trigger scope Which pushes, PRs, or manual runs need checking? on Cover the merge path first, then reduce unnecessary duplicate runs
Runtime environment Which operating system, runtime, and tool versions? runs-on、with Pin critical versions to avoid implicit dependencies on the runner image
Set of checks Which commands must succeed? jobs、steps Every command must be reproducible locally and exit nonzero on failure
Evidence What should be retained after a failure? upload-artifact Upload only diagnostics and useful build artifacts
Merge gate Which status blocks merging? Ruleset / branch protection Use stable check names
Resource policy How can outdated runs be prevented from wasting concurrency? concurrency、matrix Cancel stale runs on the same branch while retaining necessary dimensions

1.3 Basic Push/PR CI for a Typical Project

The following example assumes a Node.js repository with a committed package-lock.json and a package.json containing a test script. It can run once the Action placeholders are replaced with the full commit SHAs of actual released versions. It deliberately uses a single job with no cache, matrix, or artifacts, making it suitable as an initial CI setup.

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - name: Check out source
        uses: actions/checkout@<full-commit-sha>

      - name: Set up Node.js
        uses: actions/setup-node@<full-commit-sha>
        with:
          node-version: '22.x' # 教学版本选择,按项目支持范围定期更新

      - name: Install dependencies
        run: npm ci

      - name: Run lint when defined
        run: npm run lint --if-present

      - name: Run tests
        run: npm test

A line-by-line look at the key fields:

  • name is the workflow's display name on the Actions page and in the checks list. Renaming it can make history harder to follow and may affect an already selected required check.
  • on.push.branches: [main] performs one verification after code actually enters main, without adding duplicate runs for every temporary branch that has no PR.
  • on.pull_request.branches: [main] runs before merging PRs that target main. For PR events, branches matches the target (base) branch, not the head branch.
  • permissions.contents: read is the minimum repository permission needed by this read-only CI. This article does not add write permissions to test jobs.
  • jobs.test is the stable job ID; name: Test is the readable name shown to people and Rulesets.
  • runs-on selects a GitHub-hosted runner. Here, ubuntu-latest makes the example easy to run, but critical projects can use an explicit operating-system label and verify it periodically.
  • uses references a reusable Action; <full-commit-sha> is a placeholder that must be replaced, not a fake SHA that can be committed as-is.
  • node-version pins the Node major version so that runner-image upgrades do not give the same commit a completely different runtime.
  • npm ci installs a clean set of dependencies from the lockfile. It is more suitable for quality gates than running npm install in CI, which can rewrite the lockfile.
  • npm run lint --if-present lets simple repositories without a lint script reuse the template. Once linting becomes part of the project's contract, remove --if-present so a missing script causes an immediate failure.
  • npm test is the actual merge gate. The test command must signal failure with a nonzero exit code.

If the project already has a unified entry point such as make check, just ci, or nix flake check, prefer wrapping the local quality gates in that entry point and calling a single command from CI. This reduces YAML logic and gives developers and CI the same contract.

2. Triggers, Branch Filters, and Path Filters#

The official reference is Workflow syntax for on, jobs, and steps. When designing triggers, first ask which events must gate merging, then which runs can be omitted. Common choices are:

Trigger Purpose Caveats
pull_request Check changes entering the target branch Centers on the PR's base branch and merge result
push Verify commits already on the default branch, or branches without PRs Covering all branches alongside pull_request may produce duplicate runs
workflow_dispatch Manual reproduction, ad hoc verification, or re-uploading diagnostics Should not replace automatic merge gates
schedule Nightly or periodic checks Better suited to tasks such as drift detection and dependency updates that are not needed for every PR

2.1 Branch Filters

  • Pushes to the default branch usually provide enough post-merge regression feedback.
  • For PRs, branches filters only target branches, such as main and release/**.
  • Do not mistakenly put head-branch names in a PR's branches filter. If source branches need to be restricted, use job conditions or repository rules, recognizing the additional maintenance cost.
  • When supporting multiple long-lived maintenance branches, use an explicit list or glob, and review the Ruleset whenever branches are created or deleted.

2.2 Path Filters

Use paths when the repository is a monorepo or certain files genuinely do not affect a particular check:

on:
  pull_request:
    branches: [main]
    paths:
      - 'src/**'
      - 'package.json'
      - 'package-lock.json'
      - '.github/workflows/**'

paths and paths-ignore cannot both be used for the same event. The order of inclusion and exclusion patterns also matters: a negative ! pattern can exclude previously included paths, and a later positive pattern can include them again. The path list should therefore cover dependency declarations, build scripts, and the workflow itself; omitting them can leave source changes without the checks they require.

2.3 The Pending Trap for Required Checks

If a Ruleset marks a workflow as required but its paths or branches filters do not match the changes, GitHub may not create a successful check run. The PR page then shows the required check as Pending, blocking the merge even if the change theoretically does not need that workflow. This differs from a successful if branch inside a job; the two behaviors should not be conflated.

There are three ways to handle this:

  1. Design the required entry-point workflow without path filters. Determine the scope of changes inside the job, and explicitly exit successfully for irrelevant changes.
  2. Keep path-filtered workflows advisory, without making their checks required.
  3. Use a lightweight gate that always triggers as the required check. It aggregates the results of dependent jobs, using needs to wait for path-specific checks before deciding whether the necessary checks succeeded.

Path filtering should save resources, rather than make merging depend on a status that sometimes does not exist. When unsure, start with a basic workflow without path filters, observe its run volume, and split it later.

3. From a Single Job to a Matrix: Parallelize Only Useful Dimensions#

A single job starts quickly, keeps logs together, and gives the required check a simple name. Add a matrix only when multiple environments must all pass or when failures need to be isolated for diagnosis. Common dimensions include language runtime versions, operating systems, database versions, and build features.

3.1 Basic Matrix Decisions

  • Each added dimension usually means more runner startups, dependency installations, and queue usage. First confirm that the dimension will catch a real risk.
  • fail-fast: false suits compatibility matrices: when one version fails, the others continue running, so the complete compatibility results are available together.
  • max-parallel limits peak concurrency. It controls queue pressure without reducing the total number of jobs.
  • Use include for a few special combinations and exclude to remove explicitly unsupported combinations. Do not duplicate an entire workflow for one special combination.
  • Matrix job check names may include dimension values. Selecting those names directly as required checks makes adding or removing dimensions a governance change.

See the official guide, Running variations of jobs with matrix.

3.2 A Matrix, Cache, Artifact, and Concurrency Example

The complete example below assumes a repository with a requirements.txt that includes test dependencies, pytest tests, and a test command that generates test-results.xml. It uses a Python-version matrix, distinguishes the pip download cache from test-report artifacts, and cancels outdated runs on the same ref. All Action references still use <full-commit-sha> placeholders; the configuration becomes executable only after they are replaced.

name: Matrix CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: matrix-ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    name: Test Python ${{ matrix.python-version }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python-version: ['3.11', '3.12', '3.13']
    steps:
      - name: Check out source
        uses: actions/checkout@<full-commit-sha>

      - name: Set up Python
        uses: actions/setup-python@<full-commit-sha>
        with:
          python-version: ${{ matrix.python-version }}

      - name: Resolve pip cache directory
        id: pip-cache
        shell: bash
        run: echo "dir=$(python -m pip cache dir)" >> "$GITHUB_OUTPUT"

      - name: Cache pip downloads
        uses: actions/cache@<full-commit-sha>
        with:
          path: ${{ steps.pip-cache.outputs.dir }}
          key: pip-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/requirements*.txt', '**/pyproject.toml', '**/poetry.lock') }}
          restore-keys: |
            pip-${{ runner.os }}-${{ matrix.python-version }}-

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          python -m pip install -r requirements.txt

      - name: Run tests
        run: python -m pytest --junitxml=test-results.xml

      - name: Upload test report
        if: ${{ always() }}
        uses: actions/upload-artifact@<full-commit-sha>
        with:
          name: test-report-py${{ matrix.python-version }}
          path: test-results.xml
          if-no-files-found: warn
          retention-days: 14

  required:
    name: Required CI
    if: ${{ always() }}
    needs: [test]
    runs-on: ubuntu-latest
    steps:
      - name: Require matrix success
        run: test "${{ needs.test.result }}" = success

The key fields and tradeoffs in this YAML are:

  • workflow_dispatch provides a manual rerun entry point without changing automatic push/PR coverage.
  • concurrency.group distinguishes runs by workflow and ref. A new commit on the same PR can cancel an older run without canceling checks on another branch.
  • cancel-in-progress: true suits PR CI because the old commit can no longer become the final merged content. Do not blindly reuse this setting for releases or migrations that must not be interrupted.
  • strategy.matrix.python-version creates three independent jobs, so an incompatibility with any version is reported on the PR.
  • fail-fast: false preserves results for every version instead of showing only the first failure.
  • id: pip-cache lets subsequent steps use a dynamically determined cache directory through steps.pip-cache.outputs.dir, avoiding a hard-coded user directory.
  • actions/cache caches downloaded content. A cache is not evidence that tests passed, and dependencies must still be fully installable when there is no cache hit.
  • key includes the operating system, Python version, and a lockfile-related hash to prevent different runtimes from incorrectly sharing a dependency cache.
  • restore-keys allows an older cache for the same system and runtime to serve as an approximate starting point. After a lockfile change, a separate cache is still saved under a new key.
  • python -m pytest --junitxml=... runs tests and generates an uploadable diagnostic file.
  • if: ${{ always() }} ensures that uploading the report is still attempted when tests fail; if-no-files-found: warn prevents a missing test report from masking the original failure.
  • retention-days controls how long reports are kept. Diagnostic artifacts should not grow without limit; adjust the duration to the project's current policy.
  • needs: [test] makes required wait for every matrix instance to finish. For a matrix job, needs.test.result is success only when the whole matrix succeeds.
  • Required CI is a possible name for a stable aggregate check. The Ruleset should require this aggregate result rather than treating every version-suffixed matrix instance as a long-term API.

3.3 Choosing a Single Job, a Matrix, or Separate Workflows

Make the decision in this order:

  1. One environment is enough to express the contract: use a single job.
  2. Multiple runtimes must all pass, with essentially identical steps: use a matrix.
  3. Different systems need completely different installations or permissions: split them into separate jobs and, if necessary, separate workflows.
  4. Expensive compatibility checks are needed only overnight: move them to a scheduled or manual workflow so they do not block ordinary PRs.

Do not split six small checks across six runners just to look professional. Matrix parallelism reduces wall-clock time but increases runner count, dependency downloads, and troubleshooting noise. Use actual queue times and failure rates to decide whether it is worthwhile.

4. Dependency Caches and Artifacts: Manage Speed and Evidence Separately#

Caches and Artifacts solve different problems: a cache makes later runs faster, while an Artifact makes one run's results downloadable and reviewable. The official references are Caching dependencies to speed up workflows and Storing workflow data as artifacts.

4.1 Designing Caches

  • Cache dependency download directories, compiler downloads, or clearly rebuildable intermediate results. Do not cache secrets or opaque final state.
  • At a minimum, the key should include the operating system, runtime/toolchain version, and lockfile hash.
  • A cache miss must be a normal execution path. A first run, cache eviction, or key change must not make CI fail.
  • Do not treat restore-keys as a correctness guarantee. After restoring an old cache, the package manager must still validate dependencies against the lockfile and install anything missing.
  • A cache is not a build-artifact distribution channel. Files that must be downloaded, reviewed, or consumed by downstream jobs should be uploaded as artifacts.
  • For a Nix project, first understand its substituters, store, and project caching policy. Caching the entire /nix/store as an ordinary directory is usually both expensive and unreliable.

4.2 Designing Artifacts

  • Good candidates include test reports, coverage data, logs, reviewable archives, and build outputs.
  • Use stable names that include versions or matrix dimensions to prevent jobs from overwriting one another's artifacts.
  • Logs from failure paths can be uploaded with if: always(), but do not archive every temporary directory.
  • Set a finite retention period. If the project needs long-term distribution, use formal Releases or a package repository instead of treating short-lived artifacts as permanent storage.
  • Before uploading, ensure artifacts contain no tokens, configuration files, private keys, or user data. The deeper security boundaries around credentials and third-party Actions are covered in 06 Actions Security and Troubleshooting.

5. Connecting Required Checks and Rulesets#

Passing CI does not mean the repository necessarily prevents bad commits from being merged. Merge gates must explicitly require Pull Requests, reviews, and stable status checks in the repository's Ruleset or branch protection. Platform interfaces and names change, so confirm the actual check names on the target repository's rules page when configuring them.

5.1 Choose Stable Check Names First

  • Give both workflows and jobs clear name values instead of relying on automatically generated defaults.
  • Avoid frequently renaming workflows, jobs, or matrix dimensions. These names are the interfaces that governance configuration depends on.
  • A matrix creates multiple checks with dimension values. If only one merge gate is needed, use an aggregate job with needs and if: always(), such as Required CI above.
  • The aggregate job must actually inspect dependency results. Unconditionally exiting with 0 disguises failures as success.
  • Before selecting a check in a Ruleset, inspect the Checks page of a real PR and confirm that its full name and originating workflow are consistent with the default branch.

5.2 The Boundary Between Rulesets and Workflows

Let rules determine whether merging is allowed, and workflows determine how changes are verified:

Responsibility Ruleset / branch protection Workflow
Who can merge Yes No
Whether PRs and reviews are mandatory Yes No
Which branches are protected Yes on determines only when the workflow runs
Commands, runtimes, and tests No Yes
Whether a status check is required Yes The job produces the status
Failure logs and test reports No Yes

A rule that requires a nonexistent or renamed check can make an otherwise valid PR impossible to merge. Review the Ruleset whenever changing a job ID, name, matrix dimension, or workflow filename. Conversely, before adding a required check, confirm that runs on the default branch and past PRs can produce it.

5.3 Combining Path Filters with Required Checks

Path filters are particularly prone to conflicts with required checks. A low-risk approach is:

  1. Always trigger a fast Required CI entry point without path filters.
  2. Have it depend on the necessary checks, or run a lightweight check and finish successfully for unrelated changes.
  3. Resource-intensive workflows can use path filters without serving directly as required checks.

Do not treat manual reruns as a reliable way around trigger filters: an event that created no run has nothing to rerun, and a check on another manually selected ref does not necessarily satisfy the PR's required status. If path filters must be retained, explicitly design an always-triggered aggregate check or another mechanism that produces a status for the target PR SHA, and record evidence that it works.

6. When to Use Reusable Workflows and Composite Actions#

Reuse should address actual duplication, rather than prematurely turning a workflow into a framework. See the official guide, Reusing workflows.

6.1 Reusable Workflows: Reusing a Complete Job Graph

A reusable workflow using on: workflow_call is suitable when:

  • Multiple repositories in an organization share the same build, test, and aggregation jobs;
  • Runners, matrices, permission boundaries, and artifact conventions need to be standardized;
  • Callers should supply only a few inputs, such as the runtime version and working directory.

Its unit of reuse is a workflow/job graph, not an individual shell command. Callers should still be able to understand the inputs, outputs, failure statuses, and their relationship to required checks. Version reusable workflows so that every repository does not silently follow changes to the default branch.

A reusable workflow is unsuitable when there is only one caller, the steps are still changing rapidly, or the abstraction needs many boolean inputs to represent project differences. Keep the YAML local initially, and extract it when a second real caller appears and the structure is stable.

6.2 Composite Actions: Reusing a Sequence of Steps Within a Job

A composite action is suitable for putting repeated post-checkout setup, tool installation, or test preparation steps in .github/actions/<name>/action.yml. It creates no new runner, job, or matrix; it simply exposes multiple commands as a single step.

Signs that a composite action is appropriate:

  • The repeated unit is a sequence of steps, not a complete job graph;
  • The caller still needs to control runs-on, permissions, needs, and artifacts;
  • There are few inputs and outputs, and the local action's version evolves with the repository.

Do not create an action for a one-off shell fragment of two or three lines. Extract it once it recurs and has clear boundaries. Nor should a composite action conceal dependencies across jobs; those belong in the workflow's needs.

7. Example: Nix CI for a Theme Project#

The following is a teaching configuration for an anonymized theme project. It preserves the design of the evaluation, build, and output checks without reporting actual runs from anyone's personal repository. Assume .github/workflows/nix.yml has the following structure:

Example configuration Purpose
Neither on: push nor pull_request has branch filters Branch/tag pushes and the default PR activity types trigger the workflow, avoiding the path/branch Pending trap at the outset
Only the flake job exists There is currently no matrix parallelism; all steps run sequentially on one runner
runs-on: ubuntu-latest Uses one GitHub-hosted Ubuntu runner
permissions: contents: read Repository permissions needed for checkout and read-only checks
nix flake check --all-systems --no-build Checks the flake first, explicitly without building derivations
Builds six theme variants with one nix build --no-link both-blur, light-blur, dark-blur, both-solid, light-solid, and dark-solid must all succeed
nix build --no-link .#checks.x86_64-linux.variants Builds and validates the variants output contract
No workflow-level matrix/cache/artifact/concurrency The current configuration has no matrix split, Actions cache, report uploads, or cancellation of stale runs

This example workflow has a clear quality-gate sequence: validate the flake structure, then the six outputs, and finally the variants check. --no-build does not mean everything has been built; it serves a different purpose from the two subsequent nix build steps. A single job also means that if any variant fails, the next run repeats the entire job.

7.1 Optional Enhancements, Not Features of the Example

The following are design options, not features the example already supports:

  • Matrix: split by genuinely needed systems or build features, but add only dimensions that available runners and project inputs can represent reliably. The example's --all-systems performs one flake check; it does not mean GitHub has created a multi-runner matrix.
  • Cache: investigate Nix substituters or a project-approved caching approach, measuring cold/warm build times and cache hit rates first. The example nix.yml has no actions/cache configuration.
  • Artifact: upload logs, exported theme archives, or failure reports when they need review. The example workflow has no upload-artifact and declares no artifacts to retain.
  • Concurrency: cancel stale runs by PR ref to reduce wasted queue capacity after successive pushes. First confirm that the project's cancellation policy will not lead to misinterpretation of the Required Check. The example workflow has no concurrency.
  • Paths: add filters after confirming which documentation and source files affect Nix outputs. Required-check scenarios must address the Pending trap first. The example workflow does not use paths.

Prioritize enhancements using runtime, failure-localization, and billing/quota data rather than copying matrix, cache, and artifact templates. Splitting six variants immediately adds six environment initializations. If a single build is already fast enough, retaining one job is easier to review.

8. Maintenance Checklist and Cost Control#

Whenever CI changes, perform a small review using this checklist:

  • Local commands and CI commands match, and failures propagate through nonzero exit codes.
  • Triggers cover the default-branch merge path without unintended duplicate push/PR runs.
  • Branch filters match the intended base branches; path filters cover source files, lockfiles, build scripts, and workflows.
  • Required-check workflow/job names remain stable, and matrix changes do not silently alter the merge gate.
  • Every matrix dimension corresponds to a real compatibility commitment, rather than parallelism for its own sake.
  • Cache keys include the runtime and lockfile, and builds still complete successfully on a cache miss.
  • Artifacts contain only logs, reports, or explicitly needed outputs, with an appropriate retention period.
  • Concurrency cancels only work that a new commit can still replace, not releases or migrations that cannot safely be re-entered.
  • Action references use reviewed versions and full commit SHAs; placeholders must not remain in production workflows.
  • Permissions are minimal, and sensitive inputs and untrusted PR boundaries follow 06 Actions Security and Troubleshooting.
  • When runtimes, queue times, cache hits, or failure types change, reassess whether the added complexity is still worthwhile.

GitHub runners, Actions, plans, and quotas change over time and vary by repository visibility and organization plan. Do not turn concurrency, minute, or storage limits from one point in time into permanent rules. For budgeting or capacity decisions, consult the current repository/organization interface and official documentation, and record the verification date in operational notes.

Official Documentation

References & further reading

  1. Workflow syntax for on, jobs, and steps ↗
  2. Running variations of jobs with matrix ↗
  3. Caching dependencies to speed up workflows ↗
  4. Storing workflow data as artifacts ↗
  5. Reusing workflows ↗
  6. Using concurrency ↗
  7. GitHub Actions secure-use reference ↗
  8. GitHub Actions event reference ↗

Adapted from technical notes. Examples use generic names.

Continue along a reading path

GitHub Actions and CI

  1. 01The GitHub Actions execution model
  2. 02Configuring CI with GitHub Actions
  3. 03GitHub Actions security and troubleshooting
  4. 04Nix CI: evaluation, builds, and output contracts
CONTINUE READING / NEXT ARTICLEGitHub Actions security and troubleshooting

Locate CI risks and failures across event, permission, credential, runner, and supply-chain boundaries.