CI/CD Setup
Intro
A good CI pipeline runs the same checks a developer would run locally, in
the same order, fast enough that contributors get feedback before they
context-switch. Lint first, then test, then build, then deploy — and never
let secrets or unpinned actions sneak in.
Overview
Pipeline stages
Order stages from cheapest to most expensive so failures surface early:
- Lint — format checking and static analysis (fastest feedback)
- Test — unit tests first, then integration tests
- Build — compile, package, create release artifacts
- Deploy — push to staging, then promote to production
GitHub Actions basics
Trigger on push to main and on pull_request. Pin action versions
explicitly (actions/checkout@v4, never @latest). Cache dependencies
(pip, npm, cargo) so reruns are fast. Set timeout-minutes on every job
so a hung step cannot eat your minutes budget.
Testing in CI
Run the same commands as local development — if make test works
locally, that is what CI should call. Use matrix builds for OS or
language-version combinations only when you actually need to support
them. Use fail-fast: true in matrix strategies so one failure cancels
the rest.
Security
Never put secrets in workflow files. Store them in GitHub Secrets and
inject through ${{ secrets.NAME }}. Use the permissions key on each
job to limit the GITHUB_TOKEN scope to the minimum needed. Pin
third-party actions to a commit SHA, not just a version tag, so an
attacker who compromises a tag cannot inject code into your build.
Best practices
- Keep PR pipelines under five minutes wherever possible.
- Make CI failures actionable — clear error messages, surfaced summaries.
- Require CI to pass before merge via branch protection rules.
- Run expensive checks (E2E suites, deploys) only on main, not on PRs.
Gotchas
Agent-specific failure modes — provider-neutral pause-and-self-check items:
- Pinning an action to a tag instead of a commit SHA. Tags are mutable — an attacker who compromises an action's tag can inject code into your build. Pin third-party actions to their full commit SHA, not just
@v4.
- Secrets leaking into build logs. Printing or echoing an environment variable that contains a secret exposes it in the CI log. Never
echo ${{ secrets.FOO }} — pass secrets only through inputs or masked env vars.
- Running expensive checks on every PR. E2E tests and deploy jobs on every pull request slow the pipeline and teach contributors to bypass CI. Gate slow jobs behind
if: github.ref == 'refs/heads/main' or a label.
- No
timeout-minutes on jobs. A hung step runs indefinitely and eats your minutes budget. Every job and every slow step needs a timeout — not just the slow ones you expect.
- CI passes but local fails (or vice versa). Environment divergence — OS, timezone, locale, file path casing, implicit tool versions — causes mismatch. Make CI reproduce local conditions explicitly; never assume the runner matches the developer's machine.
- One mega-job that lints, tests, builds, and deploys. Failures are hard to locate, reruns are expensive, and the job cannot parallelize. Split into separate jobs; each job should do one thing.
- Branch protection not enforcing required status checks. If CI is advisory rather than required for merge, it stops being trusted. Configure branch protection to require every status check that matters before merge.
Full reference
Sample workflow shape
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -e .[dev]
- run: ruff check .
test:
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -e .[dev]
- run: pytest
Caching tips by ecosystem
| Ecosystem |
Cache key target |
| Python (pip) |
setup-python with cache: pip and cache-dependency-path |
| Node (npm/yarn/pnpm) |
setup-node with cache: npm (or yarn/pnpm) |
| Rust (cargo) |
Swatinem/rust-cache action keyed on Cargo.lock |
| Go |
actions/setup-go with cache: true |
| Docker layers |
docker/build-push-action with cache-from/cache-to |
Anti-patterns
actions/checkout@latest — pin to a major version or SHA
- Putting tokens in
env: blocks at the top of a workflow — use job
or step scope so secrets are not over-shared
- Running E2E tests on every PR — gate them behind a label or run
only on main
- Skipping CI with
[skip ci] to "save time" — fix the slow step
instead; once CI is optional, it stops being trusted
- One mega-job that lints, tests, builds, deploys — split into
jobs so failures are localized and reruns are cheap
Branch protection checklist
- Require pull request reviews before merge
- Require status checks to pass:
lint, test, and any required builds
- Require branches to be up to date before merging
- Restrict who can push directly to
main
- Require signed commits if your team has the workflow for it
1---2name: ci-cd-setup-23description: CI/CD pipeline setup — GitHub Actions, testing, linting, deployment. Use when setting up CI from scratch, adding GitHub Actions, automating tests, wiring up deployment, or improving an existing build pipeline.4---56# CI/CD Setup78## Intro910A good CI pipeline runs the same checks a developer would run locally, in11the same order, fast enough that contributors get feedback before they12context-switch. Lint first, then test, then build, then deploy — and never13let secrets or unpinned actions sneak in.1415## Overview1617### Pipeline stages1819Order stages from cheapest to most expensive so failures surface early:20211. **Lint** — format checking and static analysis (fastest feedback)222. **Test** — unit tests first, then integration tests233. **Build** — compile, package, create release artifacts244. **Deploy** — push to staging, then promote to production2526### GitHub Actions basics2728Trigger on `push` to main and on `pull_request`. Pin action versions29explicitly (`actions/checkout@v4`, never `@latest`). Cache dependencies30(pip, npm, cargo) so reruns are fast. Set `timeout-minutes` on every job31so a hung step cannot eat your minutes budget.3233### Testing in CI3435Run the same commands as local development — if `make test` works36locally, that is what CI should call. Use matrix builds for OS or37language-version combinations only when you actually need to support38them. Use `fail-fast: true` in matrix strategies so one failure cancels39the rest.4041### Security4243Never put secrets in workflow files. Store them in GitHub Secrets and44inject through `${{ secrets.NAME }}`. Use the `permissions` key on each45job to limit the `GITHUB_TOKEN` scope to the minimum needed. Pin46third-party actions to a commit SHA, not just a version tag, so an47attacker who compromises a tag cannot inject code into your build.4849### Best practices5051- Keep PR pipelines under five minutes wherever possible.52- Make CI failures actionable — clear error messages, surfaced summaries.53- Require CI to pass before merge via branch protection rules.54- Run expensive checks (E2E suites, deploys) only on main, not on PRs.5556## Gotchas5758Agent-specific failure modes — provider-neutral pause-and-self-check items:5960- **Pinning an action to a tag instead of a commit SHA.** Tags are mutable — an attacker who compromises an action's tag can inject code into your build. Pin third-party actions to their full commit SHA, not just `@v4`.61- **Secrets leaking into build logs.** Printing or echoing an environment variable that contains a secret exposes it in the CI log. Never `echo ${{ secrets.FOO }}` — pass secrets only through inputs or masked env vars.62- **Running expensive checks on every PR.** E2E tests and deploy jobs on every pull request slow the pipeline and teach contributors to bypass CI. Gate slow jobs behind `if: github.ref == 'refs/heads/main'` or a label.63- **No `timeout-minutes` on jobs.** A hung step runs indefinitely and eats your minutes budget. Every job and every slow step needs a timeout — not just the slow ones you expect.64- **CI passes but local fails (or vice versa).** Environment divergence — OS, timezone, locale, file path casing, implicit tool versions — causes mismatch. Make CI reproduce local conditions explicitly; never assume the runner matches the developer's machine.65- **One mega-job that lints, tests, builds, and deploys.** Failures are hard to locate, reruns are expensive, and the job cannot parallelize. Split into separate jobs; each job should do one thing.66- **Branch protection not enforcing required status checks.** If CI is advisory rather than required for merge, it stops being trusted. Configure branch protection to require every status check that matters before merge.6768## Full reference6970### Sample workflow shape7172```yaml73name: ci74on:75 push:76 branches: [main]77 pull_request:78jobs:79 lint:80 runs-on: ubuntu-latest81 timeout-minutes: 582 permissions:83 contents: read84 steps:85 - uses: actions/checkout@v486 - uses: actions/setup-python@v587 with:88 python-version: "3.12"89 cache: pip90 - run: pip install -e .[dev]91 - run: ruff check .92 test:93 needs: lint94 runs-on: ubuntu-latest95 timeout-minutes: 1096 steps:97 - uses: actions/checkout@v498 - uses: actions/setup-python@v599 with:100 python-version: "3.12"101 cache: pip102 - run: pip install -e .[dev]103 - run: pytest104```105106### Caching tips by ecosystem107108| Ecosystem | Cache key target |109|---|---|110| Python (pip) | `setup-python` with `cache: pip` and `cache-dependency-path` |111| Node (npm/yarn/pnpm) | `setup-node` with `cache: npm` (or yarn/pnpm) |112| Rust (cargo) | `Swatinem/rust-cache` action keyed on `Cargo.lock` |113| Go | `actions/setup-go` with `cache: true` |114| Docker layers | `docker/build-push-action` with `cache-from`/`cache-to` |115116### Anti-patterns117118- **`actions/checkout@latest`** — pin to a major version or SHA119- **Putting tokens in `env:` blocks at the top of a workflow** — use job120 or step scope so secrets are not over-shared121- **Running E2E tests on every PR** — gate them behind a label or run122 only on main123- **Skipping CI with `[skip ci]` to "save time"** — fix the slow step124 instead; once CI is optional, it stops being trusted125- **One mega-job that lints, tests, builds, deploys** — split into126 jobs so failures are localized and reruns are cheap127128### Branch protection checklist129130- Require pull request reviews before merge131- Require status checks to pass: `lint`, `test`, and any required builds132- Require branches to be up to date before merging133- Restrict who can push directly to `main`134- Require signed commits if your team has the workflow for it