How to Set Up CI for Multi-Platform Rust Builds

Configure GitHub Actions to run tests, linting, and documentation checks for a multi-platform Rust book project using mdbook and cargo.

When your laptop lies to you

You write a feature. It compiles on your machine. You merge the pull request. An hour later, a user reports a crash on Windows. The path separator was wrong. Or the build fails entirely because a dependency assumes a glibc system, and the release target is musl. Your laptop is a comfortable bubble. It hides the differences between operating systems, CPU architectures, and library versions.

Continuous Integration exists to pop that bubble. It runs your build, tests, and lints on fresh machines that match your users' environments. Every time you push code, the CI pipeline executes your workflow. If anything breaks, the merge is blocked. You catch the failure before it reaches production. For Rust, the setup is straightforward once you understand how the toolchain installs itself and how to structure the workflow for speed and reliability.

The concept: toolchains, runners, and matrices

Rust does not require a system-wide compiler installation. The rustup tool manages the entire toolchain. In CI, you never install Rust via apt, brew, or choco. You invoke rustup to drop a specific toolchain into the runner's environment. This gives you precise control over the compiler version, the standard library, and auxiliary components like clippy or rustfmt.

GitHub Actions provides the infrastructure. The configuration lives in a YAML file under .github/workflows. The file defines jobs. Each job runs on a runner. A runner is a virtual machine with a specific operating system. GitHub offers ubuntu-latest, windows-latest, and macos-latest. You can also define a matrix. A matrix expands a single job into multiple runs, one for each combination of variables. This lets you test on Linux, Windows, and macOS with a single block of YAML.

The workflow follows a pattern. Steps check out the code, install the toolchain, cache build artifacts, and run commands. Caching is the secret to fast Rust CI. The compiler caches object files in the target directory. Without caching, every run recompiles everything. The build time explodes. You cache the target directory and the cargo registry. The cache key depends on the OS, the toolchain version, and the Cargo.lock file. If Cargo.lock changes, the cache invalidates. This ensures correctness when dependencies update.

Treat CI as the gatekeeper. If the pipeline fails, the code does not merge.

Minimal example: the skeleton

Start with a workflow that checks out the code, installs Rust, and runs tests. This skeleton works for any Rust project. It uses the dtolnay/rust-toolchain action, which is the community standard for installing Rust in GitHub Actions. This action handles toolchain installation and caching in one step.

name: CI

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

jobs:
  test:
    # Run on the latest Ubuntu runner.
    runs-on: ubuntu-latest
    steps:
      # Check out the repository code.
      - uses: actions/checkout@v4

      # Install Rust stable. The action caches the toolchain automatically.
      - name: Install Rust
        uses: dtolnay/rust-toolchain@stable

      # Run the test suite.
      - name: Run tests
        run: cargo test

The on key triggers the workflow on pushes to main and on pull requests. The test job runs on ubuntu-latest. The checkout step fetches the code. The rust-toolchain action installs the stable compiler. The cargo test command runs the tests. This workflow is enough to catch basic compilation errors and test failures. It does not check formatting, run lints, or test on other platforms. Those require additional jobs and steps.

Keep the skeleton simple. Add complexity only when you need it.

Walkthrough: what happens under the hood

When you push a commit, GitHub creates a workflow run. The runner boots up. It is a clean machine. No previous build artifacts exist. The first step checks out the code. The second step installs Rust. The rust-toolchain action downloads the compiler if it is not in the cache. If the cache hits, the action restores the toolchain instantly. This saves minutes of download time.

The action also sets up the CARGO_HOME environment variable. This points to the directory where cargo stores the registry and the global cache. The action caches this directory based on the lockfile. When cargo test runs, cargo checks the cache. If the dependencies are cached, cargo skips downloading crates. It compiles the code using the cached object files. The build completes quickly.

The rust-toolchain action supports components. You can request clippy, rustfmt, or rust-docs by adding them to the action configuration. This ensures the runner has the tools you need for linting and documentation. The action also supports pinning a specific version. If you use 1.75.0, the action installs that exact version. This prevents breaking changes from new compiler releases.

Convention aside: the community prefers dtolnay/rust-toolchain over raw rustup commands. Older tutorials show rustup toolchain install in a run step. That approach works, but it requires manual caching configuration. The action abstracts the boilerplate. It is the convention for modern Rust CI.

Cache aggressively. A slow CI is a CI nobody trusts.

Realistic example: multi-platform, linting, and docs

Real projects need more than a basic test run. You want to test on multiple operating systems. You want to run clippy to catch idiomatic issues. You want to check formatting. You might have documentation built with mdbook that needs validation. The workflow below expands the skeleton into a production-ready pipeline. It uses a matrix for platforms and separates linting from testing.

name: CI

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

env:
  # Pin the mdbook version for reproducibility.
  MDBOOK_VERSION: 0.4.36

jobs:
  # Test on multiple platforms using a matrix.
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        toolchain: [stable, beta]
      # Fail fast is disabled so all combinations run.
      fail-fast: false
    steps:
      - uses: actions/checkout@v4

      # Install the toolchain specified by the matrix.
      - name: Install Rust
        uses: dtolnay/rust-toolchain@master
        with:
          toolchain: ${{ matrix.toolchain }}
          components: rustfmt

      # Run tests. Use --all-features to ensure feature flags work.
      - name: Run tests
        run: cargo test --all-features

  # Linting runs once on Linux. It does not need a matrix.
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      # Check formatting. Fail if the code is not formatted.
      - name: Check formatting
        run: cargo fmt --check

      # Run clippy. Treat warnings as errors.
      - name: Run clippy
        run: cargo clippy -- -D warnings

      # Install external linting tools for scripts and docs.
      - name: Install shellcheck and aspell
        run: sudo apt-get install -y shellcheck aspell

      # Lint shell scripts in the repository.
      - name: Shellcheck
        run: find . -name '*.sh' -print0 | xargs -0 shellcheck

      # Build mdbook and run doctests.
      - name: Install mdbook
        run: |
          mkdir bin
          curl -sSL https://github.com/rust-lang/mdBook/releases/download/v${MDBOOK_VERSION}/mdbook-v${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz | tar -xz --directory=bin
          echo "$(pwd)/bin" >> "${GITHUB_PATH}"

      - name: Run mdbook tests
        run: mdbook test

      # Spellcheck documentation.
      - name: Spellcheck
        run: bash ci/spellcheck.sh list

The test job uses a matrix. It runs on ubuntu-latest, windows-latest, and macos-latest. It also runs on stable and beta toolchains. This creates six combinations. The fail-fast: false setting ensures all combinations run even if one fails. This gives you a complete picture of the build health. The cargo test --all-features command ensures that optional features do not break the build.

The lint job runs once on Linux. Linting does not depend on the OS. Running it on multiple platforms wastes minutes. The job installs clippy and rustfmt. It runs cargo fmt --check to verify formatting. It runs cargo clippy -- -D warnings to treat warnings as errors. This enforces code quality. The job also installs shellcheck and aspell. It runs shellcheck on bash scripts. It runs mdbook test to validate documentation. It runs a custom spellcheck script.

Convention aside: cargo clippy -- -D warnings is the standard for CI. Locally, you can ignore warnings. In CI, warnings must be errors. This prevents the codebase from accumulating technical debt. The -D flag denies warnings. The -- separates cargo arguments from rustc arguments.

Run clippy in CI. Your local warnings are CI's errors.

Pitfalls and compiler errors

CI reveals problems that local development hides. The most common pitfall is cross-compilation confusion. Running cargo build --target x86_64-pc-windows-msvc on a Linux runner fails. The Linux runner lacks the Windows linker. You have two options. Use a Windows runner to build natively. Or use the cross crate to set up the cross-compilation toolchain. Native runners are simpler. cross is necessary when you need to target architectures the runner does not support, like ARM64 on an x86 runner.

Feature flag drift is another trap. Your local build enables a feature that CI misses. The compiler rejects the code with E0599 (method not found in this scope). CI enforces the exact feature set defined in the workflow. If a test requires a feature, the workflow must enable it. Use --all-features or specify the features explicitly.

Caching invalidation causes silent failures. If you change Cargo.lock, the cache key must change. If the key does not change, CI restores the old cache. The build uses stale dependencies. The result is unpredictable behavior. The rust-toolchain action handles this automatically. If you use manual caching, ensure the key includes a hash of Cargo.lock.

Timeouts kill long builds. Debug builds are slow. Tests that allocate large amounts of memory can trigger OOM kills on runners with limited RAM. Use --release for heavy integration tests. Monitor the runner resource usage. If a job consistently times out, split it into smaller jobs.

Treat the cache key as a contract. If the inputs change, the key must change.

Decision: when to use this vs alternatives

Use GitHub Actions when you want free minutes and tight integration with GitHub. The platform provides generous limits for public repositories. The marketplace offers actions for every tool. The YAML syntax is standard.

Use GitLab CI when your repository lives on GitLab and you need the built-in registry. GitLab provides runners and caching out of the box. The .gitlab-ci.yml syntax is similar to GitHub Actions.

Use cross when you need to target architectures that the CI runner does not support natively. cross sets up a Docker container with the cross-compilation toolchain. It handles linker installation and dependency management. It is essential for building for ARM, RISC-V, or exotic targets.

Use self-hosted runners when you have expensive hardware or strict data residency requirements. Self-hosted runners let you use your own machines. You control the environment. You manage the maintenance. This is useful for GPU workloads or proprietary build tools.

Use cargo deny when you need to audit dependencies for security vulnerabilities and license compliance. The tool checks the dependency graph against a database of known issues. It fails the build if a crate is banned or has a forbidden license.

Pick the tool that matches your constraints. Do not over-engineer the pipeline.

Where to go next