| name | ci-pipelines |
| description | Design continuous integration for an open-source repository. Use when setting up GitHub Actions from scratch, when CI is slow or flaky, when adding a build matrix across OS and language versions, when configuring caching, or when handling PRs from forks that need secrets. Covers workflow structure, concurrency and cancellation, caching strategy, required checks and branch protection, self-hosted runner risks, and keeping CI cheap. Also use for "our CI takes 40 minutes" or "why is CI failing on forks". |
CI Pipelines
CI in open source has a constraint that internal CI does not: most PRs come from
forks, by people who cannot debug your pipeline and will not wait for it. Design for
that.
Targets
| Metric | Target | Why |
|---|
| PR feedback time | < 10 min | Beyond this, contributors context-switch away |
| Lint/format feedback | < 2 min | Fail fast on the cheap stuff |
| Flake rate | < 1% | Above this, red CI gets ignored (see testing-strategy) |
| Fork PR success | 100% of non-secret jobs | A contributor must be able to get green |
The last one is the one projects get wrong. If a fork PR always shows a red X because
a deploy job cannot access secrets, every contributor's first experience is failure.
Workflow structure
Split by purpose and speed, not into one giant workflow:
.github/workflows/
├── ci.yml # lint + test on PR and push to main — the required check
├── release.yml # tag-triggered publish (see release-engineering)
├── codeql.yml # scheduled security scanning
├── nightly.yml # slow: full matrix, fuzzing, benchmarks, downstream tests
└── docs.yml # deploy docs on merge to main
A minimal, correct ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: 'npm' }
- run: npm ci
- run: npm run lint
- run: npm run typecheck
[, , ]
[, ]
{ , }
Details that matter and are usually missing:
concurrency with cancel-in-progress — a contributor pushing five times in ten
minutes should not queue five full matrices.
fail-fast: false — otherwise one Windows failure hides three others.
permissions: contents: read at the top level, elevated only in the job that
needs it. Default-permissive tokens are the main blast radius in a compromised action.
needs: lint — cheap gate first.
- Pin third-party actions to a SHA, not a tag (see
supply-chain-security).
First-party actions/* by major tag is a defensible compromise; anything else, pin.
Matrix sizing
A full cartesian product is the default and it is almost always wasteful. 3 OS × 4 versions × 2 configs = 24 jobs for a project whose bugs are all on one axis.
Test the corners exhaustively and the middle sparsely:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
python: ['3.10', '3.13']
include:
- { os: macos-latest, python: '3.13' }
- { os: windows-latest, python: '3.13' }
- { os: ubuntu-latest, python: '3.12' }
Move the exhaustive matrix to nightly.yml. PRs get fast signal; the full grid still
runs daily and you learn about the rare cell within 24 hours.
Caching
Cache the dependency store, never node_modules/.venv themselves — restoring a
partially valid install directory produces confusing failures.
- uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
Rules:
- Key on the lockfile hash. Keying on anything vaguer serves stale caches.
- Include the OS and language version in the key, or macOS restores Linux binaries.
restore-keys for partial hits — a near-miss cache still saves most of the time.
- Language-specific setup actions already do this (
cache: 'npm', cache: 'pip').
Use them before hand-rolling.
- Never cache anything that affects correctness — build outputs keyed loosely
produce "works in CI, broken for users", the worst failure mode available.
- GitHub evicts caches after 7 days of no use, 10 GB per repo. Do not architect
around a cache being present.
Fork PRs and secrets
The core security constraint: pull_request from a fork gets a read-only token and
no secrets. This is correct and you should not fight it.
Required checks and branch protection
Configure on main:
- Require status checks to pass, and require branches be up to date only if your
merge volume is low — on a busy repo that setting causes an update-rebase treadmill.
Prefer a merge queue.
- Require one approving review, dismiss stale approvals on new commits.
- Require conversation resolution.
- Include administrators. If you exempt yourself, the rules are advisory.
- Do not require jobs that cannot run on forks — this is the most common way a
repo becomes unmergeable for outside contributors.
Name required checks stably. Renaming a job silently makes the old required check
unsatisfiable and blocks every PR until someone notices.
Keeping CI fast
In order of impact:
- Cancel superseded runs (
concurrency). Often halves total minutes.
- Fail fast on cheap jobs. Lint before matrix.
- Cache dependencies. Usually the single largest per-job cost.
- Shrink the PR matrix, move the rest to nightly.
- Parallelize the test suite (
pytest -n auto, cargo nextest, sharded jest).
- Only run what changed —
paths-filter for monorepos, or a task runner with a
dependency graph (Nx, Turborepo, Bazel).
- Skip docs-only changes:
on:
pull_request:
paths-ignore: ['**.md', 'docs/**']
Careful: if the workflow is a required check, paths-ignore makes it never run and
never satisfy the requirement. Use a "skip job that reports success" pattern instead.
Public repos get free standard-runner minutes on GitHub-hosted runners; private repos
do not. Either way, a 40-minute pipeline costs contributor attention, which is the
scarcer resource.
Making CI failures debuggable by strangers
A contributor who cannot understand the failure will abandon the PR.
Anti-patterns
- No
concurrency block. Wasted minutes and contributor waiting time.
pull_request_target + checkout of PR head. Full compromise; treat as critical.
- Secrets in a job that fork PRs run. They won't work, and the red X is on the
contributor.
- Required checks that forks cannot satisfy.
- Unpinned third-party actions.
@v1 is a moving target someone else controls.
- A 30-job matrix on every PR.
- Caching build output keyed on the branch name.
- Auto-merge on green with no review. CI checks what you thought to check.
- Self-hosted runners on public repos, non-ephemeral.
- Muting a flaky job instead of fixing it. You just disabled the test suite.