Logo
Logo

Atharva Pandey/Lesson 3: CI/CD for Rust — GitHub Actions, caching, cargo-nextest

Created Thu, 24 Apr 2025 11:30:00 +0000 Modified Thu, 24 Apr 2025 11:30:00 +0000

My first Rust CI pipeline took 45 minutes. Forty-five. Every push triggered a full dependency build, tests ran sequentially, and clippy ran as a separate job that also built everything from scratch. I was burning through GitHub Actions minutes like they were free — which they are for open source, but my patience certainly wasn’t.

Getting Rust CI right is mostly about caching. The compilation model means a clean build downloads and compiles hundreds of crates, and without caching, every single CI run pays that cost. Let me show you the pipeline I’ve landed on after iterating through dozens of projects.

The Foundation: A Complete GitHub Actions Workflow

Here’s the workflow I use for most Rust projects. I’ll explain each piece after:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  CARGO_TERM_COLOR: always
  RUSTFLAGS: "-Dwarnings"

jobs:
  check:
    name: Check & Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy, rustfmt

      - uses: Swatinem/rust-cache@v2

      - name: Check formatting
        run: cargo fmt --all -- --check

      - name: Run clippy
        run: cargo clippy --all-targets --all-features

      - name: Check compilation
        run: cargo check --all-targets --all-features

  test:
    name: Test
    runs-on: ubuntu-latest
    needs: check
    steps:
      - uses: actions/checkout@v4

      - uses: dtolnay/rust-toolchain@stable

      - uses: Swatinem/rust-cache@v2

      - uses: taiki-e/install-action@nextest

      - name: Run tests
        run: cargo nextest run --all-features

      - name: Run doc tests
        run: cargo test --doc --all-features

  build:
    name: Build Release
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4

      - uses: dtolnay/rust-toolchain@stable

      - uses: Swatinem/rust-cache@v2

      - name: Build release binary
        run: cargo build --release

      - name: Upload binary
        uses: actions/upload-artifact@v4
        with:
          name: myservice
          path: target/release/myservice

Let’s break down the decisions here.

Toolchain Management: dtolnay/rust-toolchain

I use dtolnay/rust-toolchain instead of actions-rs/toolchain — the latter is unmaintained and doesn’t support newer action runner features. David Tolnay’s action is minimal, fast, and well-maintained:

- uses: dtolnay/rust-toolchain@stable
  with:
    components: clippy, rustfmt

Pin to stable for most projects. If you need a specific version:

- uses: dtolnay/rust-toolchain@1.82.0

For nightly (say, for benchmarks or feature-gated tests):

- uses: dtolnay/rust-toolchain@nightly
  with:
    components: rustfmt

Caching: The Make-or-Break Factor

Swatinem/rust-cache is the standard caching action for Rust. Without it, every job rebuilds every dependency from source. With it, incremental builds drop from 5-10 minutes to 30-60 seconds:

- uses: Swatinem/rust-cache@v2

It caches ~/.cargo/registry, ~/.cargo/git, and the target directory. The cache key is based on Cargo.lock, so it invalidates when dependencies change — which is exactly what you want.

A few tips on caching:

Share caches across jobs carefully. Each job has its own cache, and different feature flags or targets produce different compilation artifacts. Don’t try to share a cache between a --all-features job and a --no-default-features job — the artifacts aren’t compatible, and you’ll get confusing compilation errors.

Cache size matters. GitHub limits action caches to 10 GB per repository. Rust’s target directory can easily exceed this for large workspaces. rust-cache handles cleanup, but if you have many branches with separate caches, you might hit the limit. The action prunes old entries automatically, but be aware of it.

Set a shared key for related jobs:

- uses: Swatinem/rust-cache@v2
  with:
    shared-key: "main-build"

This lets the test job benefit from artifacts built in the check job.

cargo-nextest: Better Test Runner

cargo test works fine, but cargo-nextest is better in every way that matters for CI:

- uses: taiki-e/install-action@nextest

- name: Run tests
  run: cargo nextest run --all-features

Why nextest?

Parallel execution. nextest runs each test in its own process, enabling true parallelism. cargo test runs tests within a binary in separate threads, but different test binaries run sequentially by default. nextest runs everything in parallel.

Better output. When a test fails, nextest shows you exactly which test failed with its output isolated. No scrolling through interleaved stdout from 200 passing tests to find the one failure.

Retries. Flaky tests are a reality. nextest can retry failures:

- name: Run tests
  run: cargo nextest run --all-features --retries 2

JUnit output for CI integration:

- name: Run tests
  run: cargo nextest run --all-features --profile ci

# .config/nextest.toml
[profile.ci]
failure-output = "immediate-final"
status-level = "skip"

[profile.ci.junit]
path = "target/nextest/ci/junit.xml"

One caveat: nextest doesn’t run doc tests. Doc tests use a fundamentally different execution model (they’re compiled as separate binaries), so you need a separate step:

- name: Run doc tests
  run: cargo test --doc --all-features

RUSTFLAGS and Lint Enforcement

Setting RUSTFLAGS: "-Dwarnings" at the workflow level turns all warnings into errors. This is critical for CI — you don’t want warnings accumulating silently. If it compiles with warnings locally, it should fail in CI:

env:
  RUSTFLAGS: "-Dwarnings"

Pair this with a clippy.toml or #![deny(...)] in your lib.rs for project-specific lint rules:

// src/lib.rs
#![deny(clippy::unwrap_used)]
#![deny(clippy::expect_used)]
#![deny(missing_docs)]

Docker Build in CI

If you’re building Docker images (from Lesson 1), integrate it into your pipeline:

  docker:
    name: Build & Push Docker Image
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

The cache-from and cache-to directives use GitHub Actions’ built-in cache for Docker layers. This means your multi-stage build’s dependency layer (from Lesson 1) is cached across CI runs — same as your Cargo caches.

Cross-Compilation in CI

Need to build for multiple targets? Add a matrix:

  cross-build:
    name: Build (${{ matrix.target }})
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        include:
          - target: x86_64-unknown-linux-musl
            os: ubuntu-latest
          - target: aarch64-unknown-linux-musl
            os: ubuntu-latest
          - target: x86_64-apple-darwin
            os: macos-latest
          - target: aarch64-apple-darwin
            os: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}
      - uses: Swatinem/rust-cache@v2
        with:
          key: ${{ matrix.target }}

      - name: Install musl tools
        if: contains(matrix.target, 'musl')
        run: sudo apt-get update && sudo apt-get install -y musl-tools

      - name: Build
        run: cargo build --release --target ${{ matrix.target }}

      - uses: actions/upload-artifact@v4
        with:
          name: binary-${{ matrix.target }}
          path: target/${{ matrix.target }}/release/myservice

Note the key: ${{ matrix.target }} on the cache action — each target needs its own cache since the compiled artifacts are architecture-specific.

Security Scanning

Add cargo-audit to catch known vulnerabilities in your dependency tree:

  security:
    name: Security Audit
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: taiki-e/install-action@cargo-audit
      - name: Audit dependencies
        run: cargo audit

Run this on a schedule too, not just on push — new vulnerabilities are disclosed regularly:

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: '0 6 * * 1'  # Every Monday at 6 AM

Optimizing Total Pipeline Time

A few more tricks I’ve picked up:

Run fmt check first and fail fast. Formatting is the cheapest check. If it fails, don’t bother compiling anything.

Use cargo check before cargo build. cargo check is significantly faster because it skips code generation. Use it for the lint/check job, and only do a full build for the release artifact.

Separate concerns into jobs with dependencies. The needs keyword prevents wasteful builds:

jobs:
  check:    # Fast: fmt + clippy + check
  test:     # Medium: compile + run tests
    needs: check
  build:    # Slow: release build + Docker
    needs: test

If formatting fails, tests never run. If tests fail, the release build never starts. This saves minutes and Actions billing.

Consider sccache for large monorepos. For projects with many contributors, a shared remote compilation cache can dramatically reduce build times:

- name: Configure sccache
  uses: mozilla-actions/sccache-action@v0.0.4

- name: Build
  env:
    RUSTC_WRAPPER: sccache
  run: cargo build --release

My Actual Pipeline Times

On a mid-size project (~50 crates in a workspace, ~200 dependencies):

  • Cold build (no cache): 8 minutes
  • Warm build (cached, no source changes): 40 seconds
  • Warm build (cached, source changes): 1.5 minutes
  • Full pipeline (check → test → build): 4 minutes with warm caches

That’s fast enough that I never feel the urge to skip CI. Which is the whole point.

Next Up

With CI producing our artifacts, we need to make sure what’s running in production is actually observable. The next lesson covers tracing, metrics, and OpenTelemetry — because a binary that runs but tells you nothing about what it’s doing is only slightly better than one that doesn’t run at all.