crefft Source 2026.10.4

CI/CD Guide

crefft > Process > CI/CD

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

  1. Fast feedback: MUST lint before test, fail fast
  2. Cache dependencies: SHOULD use language-specific caching
  3. Matrix testing: SHOULD test across OS/versions where relevant
  4. Security scanning: MUST run on every PR
  5. 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_TOKEN or 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: true wherever the project treats published coverage as required. The input defaults to false2.
  • Uploads SHOULD name the report with files and set disable_search: true, so the step uploads exactly that report rather than whatever a recursive search happens to find. Combined with the default handle_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

References

Footnotes

  1. GitHub Actions Documentation ↩

  2. codecov/codecov-action - v7.0.0; documents that tokenless uploading is unsupported outside fork PRs to public repositories, that fail_ci_if_error defaults to false, and that use_oidc mints an Actions ID token ↩ ↩2 ↩3 ↩4 ↩5

  3. Security hardening for GitHub Actions - pin third-party actions to a full-length commit SHA ↩

  4. rustsec/audit-check action metadata - declares token with required: true and no default ↩

  5. simplecov-cobertura - Cobertura XML formatter for SimpleCov ↩