CI/CD Guide
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Recommended GitHub Actions1 configurations per language/framework.
General Principles
- Fast feedback: MUST lint before test, fail fast
- Cache dependencies: SHOULD use language-specific caching
- Matrix testing: SHOULD test across OS/versions where relevant
- Security scanning: MUST run on every PR
- Artifact preservation: SHOULD save coverage reports, binaries
Action and Image Pins
Every uses: reference, including a reusable workflow, MUST name a reviewed
40-character commit SHA with the upstream version in a trailing comment. Container
images MUST use a registry-verified SHA-256 digest or an exact release tag with
a verification note. Shared action pins are recorded in
configs/tool-versions.json.
Why: A moving action tag or image alias changes executable code without a reviewed change to the workflow. Dependabot can propose action SHA updates while retaining the version comments.
Coverage Upload
Every workflow below uploads coverage with the same step. Only the report path changes between languages.
- Uploads MUST authenticate with
CODECOV_TOKENor with OIDC. Codecov v4 and later removed ordinary tokenless uploading2. - References MUST name a reviewed full commit SHA, because a moving major tag can be repointed at new code3.
- Uploads MUST set
fail_ci_if_error: truewherever the project treats published coverage as required. The input defaults tofalse2. - Uploads SHOULD name the report with
filesand setdisable_search: true, so the step uploads exactly that report rather than whatever a recursive search happens to find. Combined with the defaulthandle_no_reports_found: false, a renamed or missing report then fails the job2.
Why
Codecov rejects an unauthenticated upload, but fail_ci_if_error defaults to
false, so the step still succeeds. The result is a green build whose
coverage was never published — the failure mode that coverage gating exists
to prevent. Authenticating and failing closed makes a missing report visible
at the point it is produced.
Do
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: coverage.xml
disable_search: true
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
Don’t
# Unauthenticated, moving tag, and fails open: publishes nothing, stays green
# crefft: illustrative -- Negative example demonstrates a moving action tag.
- uses: codecov/codecov-action@v4
with:
files: coverage.xml
OIDC Instead of a Token
Repositories that prefer not to store an upload secret MAY authenticate
with OIDC. The action mints the token through actions/github-script, so the
job MUST grant id-token: write2.
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
# ... checkout, build, test ...
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
use_oidc: true
files: coverage.xml
disable_search: true
fail_ci_if_error: true
Pull requests raised from a fork of a public repository upload without
credentials, so secrets.CODECOV_TOKEN being unavailable to forks does not
break them2. Private repositories that accept fork pull requests MUST
guard the step with if: github.event.pull_request.head.repo.fork != true
rather than weakening fail_ci_if_error.
Where the test runner writes into a generated subdirectory, as .NET’s
--results-directory does, set directory instead of files and leave
search enabled.
Python
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
- run: uv sync
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy src/
test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
- run: uv sync
- run: uv run pytest -n auto --cov=src --cov-report=xml
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: coverage.xml
disable_search: true
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
- run: uv sync
- run: uv run pip-audit
- run: uv run semgrep --config=p/python --config=p/security-audit src/
Django
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
# postgres:16; registry manifest digest.
image: postgres@sha256:ca0bd484cb98bf4b24eb1010e73fb3fcbd6714d240fbc1a10eea5b7dbecb641d
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
- run: uv sync
- run: uv run ruff check .
- run: uv run mypy .
- run: uv run pytest --cov --reuse-db
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
Go
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.23'
- uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6.5.2
with:
version: latest
test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.23'
- run: go test -race -coverprofile=coverage.out ./...
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: coverage.out
disable_search: true
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.23'
- run: go install golang.org/x/vuln/cmd/govulncheck@v1.9.0
- run: govulncheck ./...
Rust
Rust-specific lanes — MSRV, feature matrix, cross-target checks, and fuzzing — are in the Rust Style Guide. Actions are pinned to full commit SHAs; see Pinning Actions.
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1
with:
toolchain: stable
components: rustfmt, clippy
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- run: cargo fmt --check
- run: cargo clippy --all-targets --all-features -- -D warnings
test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- run: cargo test --locked --all-features
- run: cargo install --locked cargo-llvm-cov@0.9.1
- run: cargo llvm-cov --locked --all-features --workspace
--lcov --output-path lcov.info
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: lcov.info
disable_search: true
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
security:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1
with:
toolchain: stable
- run: cargo install --locked cargo-audit@0.22.2
- run: cargo audit --file Cargo.lock
Rust security jobs MUST invoke cargo audit directly rather than through
rustsec/audit-check. That action declares token as a required input with
no default, so a step written without with.token aborts before the scan
runs with Input required and not supplied: token4.
Why
cargo audit needs no GitHub token, so it works unchanged on pull requests
from forks, where the workflow token is read-only. It exits non-zero when the
advisory database matches a locked crate, which fails the job without extra
wiring. Add --deny warnings to fail on unmaintained, unsound, or yanked
crates as well.
Because no token is needed, the job also needs no checks: write or
issues: write; contents: read is sufficient, and every action above is
pinned to a full commit SHA so the workflow cannot change under a moving tag.
Don’t
# Aborts before scanning: `token` is a required input with no default
- uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0
Ruby
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
with:
ruby-version: '3.3'
bundler-cache: true
- run: bundle exec standardrb
test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
with:
ruby-version: '3.3'
bundler-cache: true
- run: bundle exec rspec
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: coverage/coverage.xml
disable_search: true
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
with:
ruby-version: '3.3'
bundler-cache: true
- run: bundle exec bundler-audit check --update
SimpleCov’s default output is coverage/.resultset.json, which Codecov’s
uploader does not recognize as a coverage report. RSpec jobs MUST
therefore add the simplecov-cobertura gem5 and register its formatter so
the run also writes coverage/coverage.xml.
# spec/spec_helper.rb
require 'simplecov'
require 'simplecov-cobertura'
SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([
SimpleCov::Formatter::HTMLFormatter,
SimpleCov::Formatter::CoberturaFormatter
])
SimpleCov.start do
add_filter '/spec/'
enable_coverage :branch
end
Rails
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
# postgres:16; registry manifest digest.
image: postgres@sha256:ca0bd484cb98bf4b24eb1010e73fb3fcbd6714d240fbc1a10eea5b7dbecb641d
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
with:
ruby-version: '3.3'
bundler-cache: true
- run: bundle exec standardrb
- run: bundle exec brakeman --exit-on-warn --no-pager
- run: bundle exec rails db:setup
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
- run: bundle exec rspec
TypeScript / Node.js
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npx @biomejs/biome@2.5.12 ci .
- run: npx --package=typescript@6.0.3 tsc --noEmit
test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npx vitest@5.0.0 run --coverage --coverage.reporter=lcov
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: coverage/lcov.info
disable_search: true
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm audit --audit-level=high
C# / .NET
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '8.0.x'
- run: dotnet restore
- run: dotnet build --no-restore /p:TreatWarningsAsErrors=true
- run: dotnet format --verify-no-changes
test:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '8.0.x'
- run: dotnet test --collect:"XPlat Code Coverage" --results-directory ./coverage
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
directory: ./coverage
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '8.0.x'
- run: dotnet list package --vulnerable --include-transitive
Multi-Platform Matrix
For libraries/tools requiring cross-platform support:
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
# Add language version matrix as needed
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# ... rest of steps
Release Workflow
Release automation MUST classify the project first. Libraries in every
ecosystem use Semantic Versioning 2.0.0 bumps from breaking, feature, or fix
changes. Everything else uses the next YYYY.M.N from the release date
and released versions, not commit types. Breaking changes MUST
be flagged under ### Breaking, first in the changelog release section.
The Versioning Guide defines both calculations and treats
projects with commands and importable packages as libraries.
The workflow below checks an already prepared release;
it does not select a version or publish anything.
Why
A default commit-driven release action infers compatibility bumps for libraries but does not implement calendar numbering for other projects.
# .github/workflows/release.yml
name: Release checks
on:
workflow_dispatch:
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check the prepared release
run: make check
- name: Report the prepared version
run: printf 'Release candidate v%s\n' "$(cat VERSION)"
Do: select SemVer for libraries or CalVer for anything else, then validate the prepared release. Don’t: use a calendar number for a library or let commit types select a numeric component for an application.
Dependabot
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
groups:
python-deps:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
Branch Protection
Configure in repository settings:
- MUST require status checks to pass before merging
- SHOULD require branches to be up to date
- SHOULD require review from code owners
- MUST NOT allow bypassing the above settings
See Also
- Testing Guide - Testing strategy and best practices
- Versioning Guide - Library SemVer, CalVer, and release automation
- GitHub Templates - Issue and PR templates
References
Footnotes
-
codecov/codecov-action - v7.0.0; documents that tokenless uploading is unsupported outside fork PRs to public repositories, that
fail_ci_if_errordefaults tofalse, and thatuse_oidcmints an Actions ID token ↩ ↩2 ↩3 ↩4 ↩5 -
Security hardening for GitHub Actions - pin third-party actions to a full-length commit SHA ↩
-
rustsec/audit-check action metadata - declares
tokenwithrequired: trueand no default ↩ -
simplecov-cobertura - Cobertura XML formatter for SimpleCov ↩