Skip to content
rusthacks.dev
DevOps

Continuous Integration for Rust With GitHub Actions

A GitHub Actions workflow for Rust that formats, lints, tests and builds, with a pinned toolchain, dependency caching, a matrix and the failure modes to avoid.

3 min read
Summarize in AI
Flat illustration for the post “Continuous Integration for Rust With GitHub Actions”, composed around deploy.

The first CI for a Rust project fails in the same three ways: the compiler version moves, nothing is cached so every run compiles the dependency tree, and warnings accumulate because nothing denies them. A short workflow fixes all three. This post gives one that runs in a few minutes for a typical service. It is the CI step of shipping Rust to production.

GitHub Actions, Rust 1.90.

The workflow

.github/workflows/ci.ymlYAML
name: ci

on:
  push:
    branches: [main]
  pull_request:

env:
  CARGO_TERM_COLOR: always
  RUSTFLAGS: "-D warnings"

jobs:
  fmt:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/[email protected]
        with:
          components: rustfmt
      - run: cargo fmt --all -- --check

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/[email protected]
        with:
          components: clippy
      - uses: Swatinem/rust-cache@v2
      - run: cargo clippy --all-targets --all-features --locked -- -D warnings
      - run: cargo test --all-features --locked
      - run: cargo build --release --locked

fmt is a separate job: it needs no dependencies, so it fails in seconds. dtolnay/[email protected] installs that exact version; the ref of the action is the toolchain. RUSTFLAGS: "-D warnings" turns compiler warnings into errors for the whole run. Note that setting RUSTFLAGS changes the cache key, and builds with different flags do not share artifacts.

What the cache does

Swatinem/rust-cache stores ~/.cargo and target between runs, keyed by Cargo.lock, the compiler version and the job. The first run is cold; later runs reuse the compiled dependencies, and only your crate rebuilds. The details and the limits are in caching Cargo builds in CI. Do not cache on pull requests from forks with secrets, and prune: caches that grow without limit slow the restore.

A matrix for portability

YAML
  matrix:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo test --locked

Use it for libraries and tools that ship on several platforms. Services that only run on Linux do not need it. fail-fast: false lets the other platforms finish, which shows whether the failure is specific to one.

Checking the minimum Rust version

A library that promises rust-version = "1.80" should test it:

YAML
      - uses: dtolnay/[email protected]
      - run: cargo check --locked --all-features

See declaring and testing the MSRV.

Extras worth adding

  • cargo deny check and cargo audit on a schedule, as in Cargo Deny
  • cargo doc --no-deps with RUSTDOCFLAGS="-D warnings" to catch broken links
  • A scheduled run on stable and beta so a compiler change shows up before you need to upgrade

Pitfalls

  • Using actions-rs/*, which is unmaintained; the dtolnay/rust-toolchain action replaces its toolchain step
  • Tests that need a database: start it as a service container and wait for readiness
  • Secrets in pull requests from forks, which are not available, so tests that need them fail
  • Floating @stable toolchains in the main job

Limits

CI time is dominated by compilation, so the next gains come from a faster linker and fewer features, not YAML changes. The actions are documented in their repositories: dtolnay/rust-toolchain and Swatinem/rust-cache.

Also inTesting
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.