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:
- Dependency installation: use the lockfile and the runtime version declared by the project.
- Static checks: formatting checks, linting, type checking, or Nix evaluation checks.
- Tests: unit tests, integration tests, or the smallest test suite the project can reliably automate.
- Building/packaging: confirm that the final artifacts can be generated, rather than merely checking that the source can be parsed.
- 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 testA line-by-line look at the key fields:
nameis 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 entersmain, without adding duplicate runs for every temporary branch that has no PR.on.pull_request.branches: [main]runs before merging PRs that targetmain. For PR events,branchesmatches the target (base) branch, not the head branch.permissions.contents: readis the minimum repository permission needed by this read-only CI. This article does not add write permissions to test jobs.jobs.testis the stable job ID;name: Testis the readable name shown to people and Rulesets.runs-onselects a GitHub-hosted runner. Here,ubuntu-latestmakes the example easy to run, but critical projects can use an explicit operating-system label and verify it periodically.usesreferences a reusable Action;<full-commit-sha>is a placeholder that must be replaced, not a fake SHA that can be committed as-is.node-versionpins the Node major version so that runner-image upgrades do not give the same commit a completely different runtime.npm ciinstalls a clean set of dependencies from the lockfile. It is more suitable for quality gates than runningnpm installin CI, which can rewrite the lockfile.npm run lint --if-presentlets simple repositories without a lint script reuse the template. Once linting becomes part of the project's contract, remove--if-presentso a missing script causes an immediate failure.npm testis 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,
branchesfilters only target branches, such asmainandrelease/**. - Do not mistakenly put head-branch names in a PR's
branchesfilter. 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:
- Design the required entry-point workflow without path filters. Determine the scope of changes inside the job, and explicitly exit successfully for irrelevant changes.
- Keep path-filtered workflows advisory, without making their checks required.
- Use a lightweight gate that always triggers as the required check. It aggregates the results of dependent jobs, using
needsto 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: falsesuits compatibility matrices: when one version fails, the others continue running, so the complete compatibility results are available together.max-parallellimits peak concurrency. It controls queue pressure without reducing the total number of jobs.- Use
includefor a few special combinations andexcludeto 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 }}" = successThe key fields and tradeoffs in this YAML are:
workflow_dispatchprovides a manual rerun entry point without changing automatic push/PR coverage.concurrency.groupdistinguishes 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: truesuits 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-versioncreates three independent jobs, so an incompatibility with any version is reported on the PR.fail-fast: falsepreserves results for every version instead of showing only the first failure.id: pip-cachelets subsequent steps use a dynamically determined cache directory throughsteps.pip-cache.outputs.dir, avoiding a hard-coded user directory.actions/cachecaches downloaded content. A cache is not evidence that tests passed, and dependencies must still be fully installable when there is no cache hit.keyincludes the operating system, Python version, and a lockfile-related hash to prevent different runtimes from incorrectly sharing a dependency cache.restore-keysallows 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: warnprevents a missing test report from masking the original failure.retention-dayscontrols how long reports are kept. Diagnostic artifacts should not grow without limit; adjust the duration to the project's current policy.needs: [test]makesrequiredwait for every matrix instance to finish. For a matrix job,needs.test.resultissuccessonly when the whole matrix succeeds.Required CIis 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:
- One environment is enough to express the contract: use a single job.
- Multiple runtimes must all pass, with essentially identical steps: use a matrix.
- Different systems need completely different installations or permissions: split them into separate jobs and, if necessary, separate workflows.
- 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-keysas 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/storeas 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
namevalues 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
needsandif: always(), such asRequired CIabove. - 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:
- Always trigger a fast
Required CIentry point without path filters. - Have it depend on the necessary checks, or run a lightweight check and finish successfully for unrelated changes.
- 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-systemsperforms 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.ymlhas noactions/cacheconfiguration. - Artifact: upload logs, exported theme archives, or failure reports when they need review. The example workflow has no
upload-artifactand 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
- Workflow syntax for GitHub Actions
- Using a matrix for your jobs
- Caching dependencies to speed up workflows
- Storing workflow data as artifacts
- Using concurrency
- Reusing workflows
References & further reading
- Workflow syntax for on, jobs, and steps ↗
- Running variations of jobs with matrix ↗
- Caching dependencies to speed up workflows ↗
- Storing workflow data as artifacts ↗
- Reusing workflows ↗
- Using concurrency ↗
- GitHub Actions secure-use reference ↗
- GitHub Actions event reference ↗
Adapted from technical notes. Examples use generic names.