Putting It All Together β A Production CI/CD Pipeline π‘
What you'll learn:
- Structuring a multi-stage GitHub Actions CI workflow (check β test β coverage β security β cross β release)
- Caching strategies with
rust-cacheandsave-iftuning- Running Miri and sanitizers on a nightly schedule
- Task automation with
Makefile.tomland pre-commit hooks- Automated releases with
cargo-distCross-references: Build Scripts Β· Cross-Compilation Β· Benchmarking Β· Coverage Β· Miri/Sanitizers Β· Dependencies Β· Release Profiles Β· Compile-Time Tools Β·
no_stdΒ· Windows
Individual tools are useful. A pipeline that orchestrates them automatically on every push is transformative. This chapter assembles the tools from chapters 1β10 into a cohesive CI/CD workflow.
The Complete GitHub Actions Workflow
A single workflow file that runs all verification stages in parallel:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
CARGO_ENCODED_RUSTFLAGS: "-Dwarnings" # Treat warnings as errors (top-level crate only)
# NOTE: Unlike RUSTFLAGS, CARGO_ENCODED_RUSTFLAGS does not affect build scripts
# or proc-macros, which avoids false failures from third-party warnings.
# Use RUSTFLAGS="-Dwarnings" instead if you want to enforce on build scripts too.
jobs:
# βββ Stage 1: Fast feedback (< 2 min) βββ
check:
name: Check + Clippy + Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2 # Cache dependencies
- name: Check compilation
run: cargo check --workspace --all-targets --all-features
- name: Clippy lints
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: Formatting
run: cargo fmt --all -- --check
# βββ Stage 2: Tests (< 5 min) βββ
test:
name: Test (${{ matrix.os }})
needs: check
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test --workspace
- name: Run doc tests
run: cargo test --workspace --doc
# βββ Stage 3: Cross-compilation (< 10 min) βββ
cross:
name: Cross (${{ matrix.target }})
needs: check
strategy:
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
use_cross: true
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install musl-tools
if: contains(matrix.target, 'musl')
run: sudo apt-get install -y musl-tools
- name: Install cross
if: matrix.use_cross
uses: taiki-e/install-action@cross
- name: Build (native)
if: "!matrix.use_cross"
run: cargo build --release --target ${{ matrix.target }}
- name: Build (cross)
if: matrix.use_cross
run: cross build --release --target ${{ matrix.target }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.target }}
path: target/${{ matrix.target }}/release/diag_tool
# βββ Stage 4: Coverage (< 10 min) βββ
coverage:
name: Code Coverage
needs: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- uses: taiki-e/install-action@cargo-llvm-cov
- name: Generate coverage
run: cargo llvm-cov --workspace --lcov --output-path lcov.info
- name: Enforce minimum coverage
run: cargo llvm-cov --workspace --fail-under-lines 75
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
# βββ Stage 5: Safety verification (< 15 min) βββ
miri:
name: Miri
needs: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
with:
components: miri
- name: Run Miri
run: cargo miri test --workspace
env:
MIRIFLAGS: "-Zmiri-backtrace=full"
# βββ Stage 6: Benchmarks (PR only, < 10 min) βββ
bench:
name: Benchmarks
if: github.event_name == 'pull_request'
needs: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Run benchmarks
run: cargo bench -- --output-format bencher | tee bench.txt
- name: Compare with baseline
uses: benchmark-action/github-action-benchmark@v1
with:
tool: 'cargo'
output-file-path: bench.txt
github-token: ${{ secrets.GITHUB_TOKEN }}
alert-threshold: '115%'
comment-on-alert: true
Pipeline execution flow:
βββββββββββ
β check β β clippy + fmt + cargo check (2 min)
ββββββ¬βββββ
βββββββββββ¬βββ΄βββ¬βββββββββββ¬βββββββββββ
βΌ βΌ βΌ βΌ βΌ
ββββββββ ββββββββ ββββββββββ ββββββββ ββββββββ
β test β βcross β βcoverageβ β miri β βbench β
β (2Γ) β β (2Γ) β β β β β β(PR) β
ββββββββ ββββββββ ββββββββββ ββββββββ ββββββββ
3 min 8 min 8 min 12 min 5 min
Total wall-clock: ~14 min (parallel after check gate)
CI Caching Strategies
Swatinem/rust-cache@v2 is the
standard Rust CI cache action. It caches ~/.cargo and target/ between
runs, but large workspaces need tuning:
# Basic (what we use above)
- uses: Swatinem/rust-cache@v2
# Tuned for a large workspace:
- uses: Swatinem/rust-cache@v2
with:
# Separate caches per job β prevents test artifacts bloating build cache
prefix-key: "v1-rust"
key: ${{ matrix.os }}-${{ matrix.target || 'default' }}
# Only save cache on main branch (PRs read but don't write)
save-if: ${{ github.ref == 'refs/heads/main' }}
# Cache Cargo registry + git checkouts + target dir
cache-targets: true
cache-all-crates: true
Cache invalidation gotchas:
| Problem | Fix |
|---|---|
| Cache grows unbounded (>5 GB) | Set prefix-key: "v2-rust" to force fresh cache |
| Different features pollute cache | Use key: ${{ hashFiles('**/Cargo.lock') }} |
| PR cache overwrites main | Set save-if: ${{ github.ref == 'refs/heads/main' }} |
| Cross-compilation targets bloat | Use separate key per target triple |
Sharing cache between jobs:
The check job saves the cache; downstream jobs (test, cross, coverage)
read it. With save-if on main only, PR runs get the benefit of cached
dependencies without writing stale caches.
Measured impact on large-scale workspace: Cold build ~4 min β cached build ~45 sec. The cache action alone saves ~25 min of CI time per pipeline run (across all parallel jobs).
Makefile.toml with cargo-make
cargo-make provides a portable
task runner that works across platforms (unlike make/Makefile):
# Install
cargo install cargo-make
# Makefile.toml β at workspace root
[config]
default_to_workspace = false
# βββ Developer workflows βββ
[tasks.dev]
description = "Full local verification (same checks as CI)"
dependencies = ["check", "test", "clippy", "fmt-check"]
[tasks.check]
command = "cargo"
args = ["check", "--workspace", "--all-targets"]
[tasks.test]
command = "cargo"
args = ["test", "--workspace"]
[tasks.clippy]
command = "cargo"
args = ["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]
[tasks.fmt]
command = "cargo"
args = ["fmt", "--all"]
[tasks.fmt-check]
command = "cargo"
args = ["fmt", "--all", "--", "--check"]
# βββ Coverage βββ
[tasks.coverage]
description = "Generate HTML coverage report"
install_crate = "cargo-llvm-cov"
command = "cargo"
args = ["llvm-cov", "--workspace", "--html", "--open"]
[tasks.coverage-ci]
description = "Generate LCOV for CI upload"
install_crate = "cargo-llvm-cov"
command = "cargo"
args = ["llvm-cov", "--workspace", "--lcov", "--output-path", "lcov.info"]
# βββ Benchmarks βββ
[tasks.bench]
description = "Run all benchmarks"
command = "cargo"
args = ["bench"]
# βββ Cross-compilation βββ
[tasks.build-musl]
description = "Build static binary (musl)"
command = "cargo"
args = ["build", "--release", "--target", "x86_64-unknown-linux-musl"]
[tasks.build-arm]
description = "Build for aarch64 (requires cross)"
command = "cross"
args = ["build", "--release", "--target", "aarch64-unknown-linux-gnu"]
[tasks.build-all]
description = "Build for all deployment targets"
dependencies = ["build-musl", "build-arm"]
# βββ Safety verification βββ
[tasks.miri]
description = "Run Miri on all tests"
toolchain = "nightly"
command = "cargo"
args = ["miri", "test", "--workspace"]
[tasks.audit]
description = "Check for known vulnerabilities"
install_crate = "cargo-audit"
command = "cargo"
args = ["audit"]
# βββ Release βββ
[tasks.release-dry]
description = "Preview what cargo-release would do"
install_crate = "cargo-release"
command = "cargo"
args = ["release", "--workspace", "--dry-run"]
Usage:
# Equivalent of CI pipeline, locally
cargo make dev
# Generate and view coverage
cargo make coverage
# Build for all targets
cargo make build-all
# Run safety checks
cargo make miri
# Check for vulnerabilities
cargo make audit
Pre-Commit Hooks: Custom Scripts and cargo-husky
Catch issues before they reach CI. The recommended approach is a custom git hook β it's simple, transparent, and has no external dependencies:
#!/bin/sh
# .githooks/pre-commit
set -e
echo "=== Pre-commit checks ==="
# Fast checks first
echo "β cargo fmt --check"
cargo fmt --all -- --check
echo "β cargo check"
cargo check --workspace --all-targets
echo "β cargo clippy"
cargo clippy --workspace --all-targets -- -D warnings
echo "β cargo test (lib only, fast)"
cargo test --workspace --lib
echo "=== All checks passed ==="
# Install the hook
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit
Alternative: cargo-husky (auto-installs hooks via build script):
β οΈ Note:
cargo-huskyhas not been updated since 2022. It still works but is effectively unmaintained. Consider the custom hook approach above for new projects.
cargo install cargo-husky
# Cargo.toml β add to dev-dependencies of root crate
[dev-dependencies]
cargo-husky = { version = "1", default-features = false, features = [
"precommit-hook",
"run-cargo-check",
"run-cargo-clippy",
"run-cargo-fmt",
"run-cargo-test",
] }
Release Workflow: cargo-release and cargo-dist
cargo-release β automates version bumping, tagging, and publishing:
# Install
cargo install cargo-release
# release.toml β at workspace root
[workspace]
consolidate-commits = true
pre-release-commit-message = "chore: release {{version}}"
tag-message = "v{{version}}"
tag-name = "v{{version}}"
# Don't publish internal crates
[[package]]
name = "core_lib"
release = false
[[package]]
name = "diag_framework"
release = false
# Only publish the main binary
[[package]]
name = "diag_tool"
release = true
# Preview release
cargo release patch --dry-run
# Execute release (bumps version, commits, tags, optionally publishes)
cargo release patch --execute
# 0.1.0 β 0.1.1
cargo release minor --execute
# 0.1.1 β 0.2.0
cargo-dist β generates downloadable release binaries for GitHub Releases:
# Install
cargo install cargo-dist
# Initialize (creates CI workflow + metadata)
cargo dist init
# Preview what would be built
cargo dist plan
# Generate the release (usually done by CI on tag push)
cargo dist build
# Cargo.toml additions from `cargo dist init`
[workspace.metadata.dist]
cargo-dist-version = "0.28.0"
ci = "github"
targets = [
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
]
install-path = "CARGO_HOME"
This generates a GitHub Actions workflow that, on tag push:
- Builds the binary for all target platforms
- Creates a GitHub Release with downloadable
.tar.gz/.ziparchives - Generates shell/PowerShell installer scripts
- Publishes to crates.io (if configured)
Try It Yourself β Capstone Exercise
This exercise ties together every chapter. You will build a complete engineering pipeline for a fresh Rust workspace:
-
Create a new workspace with two crates: a library (
core_lib) and a binary (cli). Add abuild.rsthat embeds the git hash and build timestamp usingSOURCE_DATE_EPOCH(ch01). -
Set up cross-compilation for
x86_64-unknown-linux-muslandaarch64-unknown-linux-gnu. Verify both targets build withcargo zigbuildorcross(ch02). -
Add a benchmark using Criterion or Divan for a function in
core_lib. Run it locally and record a baseline (ch03). -
Measure code coverage with
cargo llvm-cov. Set a minimum threshold of 80% and verify it passes (ch04). -
Run
cargo +nightly careful testandcargo miri test. Add a test that exercisesunsafecode if you have any (ch05). -
Configure
cargo-denywith adeny.tomlthat bansopenssland enforces MIT/Apache-2.0 licensing (ch06). -
Optimize the release profile with
lto = "thin",strip = true, andcodegen-units = 1. Measure binary size before/after withcargo bloat(ch07). -
Add
cargo hack --each-featureverification. Create a feature flag for an optional dependency and ensure it compiles alone (ch09). -
Write the GitHub Actions workflow (this chapter) with all 6 stages. Add
Swatinem/rust-cache@v2withsave-iftuning.
Success criteria: Push to GitHub β all CI stages green β cargo dist plan
shows your release targets. You now have a production-grade Rust pipeline.
CI Pipeline Architecture
flowchart LR
subgraph "Stage 1 β Fast Feedback < 2 min"
CHECK["cargo check\ncargo clippy\ncargo fmt"]
end
subgraph "Stage 2 β Tests < 5 min"
TEST["cargo nextest\ncargo test --doc"]
end
subgraph "Stage 3 β Coverage"
COV["cargo llvm-cov\nfail-under 80%"]
end
subgraph "Stage 4 β Security"
SEC["cargo audit\ncargo deny check"]
end
subgraph "Stage 5 β Cross-Build"
CROSS["musl static\naarch64 + x86_64"]
end
subgraph "Stage 6 β Release (tag only)"
REL["cargo dist\nGitHub Release"]
end
CHECK --> TEST --> COV --> SEC --> CROSS --> REL
style CHECK fill:#91e5a3,color:#000
style TEST fill:#91e5a3,color:#000
style COV fill:#e3f2fd,color:#000
style SEC fill:#ffd43b,color:#000
style CROSS fill:#e3f2fd,color:#000
style REL fill:#b39ddb,color:#000
Key Takeaways
- Structure CI as parallel stages: fast checks first, expensive jobs behind gates
Swatinem/rust-cache@v2withsave-if: ${{ github.ref == 'refs/heads/main' }}prevents PR cache thrashing- Run Miri and heavier sanitizers on a nightly
schedule:trigger, not on every push Makefile.toml(cargo make) bundles multi-tool workflows into a single command for local devcargo-distautomates cross-platform release builds β stop writing platform matrix YAML by hand