Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

GitHub Actions

GitHub Actions is the CI/CD platform built into GitHub. It launched in 2019 and has since become a popular CI system for open-source Rust projects, largely because it is free for public repositories and deeply integrated with pull requests and issue tracking. Workflows are defined as YAML files in the .github/workflows/ directory of your repository, and they run in response to events like pushes, pull request updates, or cron schedules.

This chapter focuses on the practical side: how GitHub Actions works, how to set up effective CI for a Rust project, and how to avoid common pitfalls. Because this is the point in the book where all the individual tools come together, this chapter cross-references the checks, testing, building, and releasing chapters extensively.

Mental Model

A workflow is a YAML file in .github/workflows/. Each workflow is triggered by one or more events and contains one or more jobs. Each job runs on a fresh virtual machine (called a runner), and GitHub provides hosted runners for Linux (Ubuntu), macOS, and Windows. Jobs run in parallel by default, but you can create dependencies between them using the needs: keyword so that a job is skipped if an earlier job fails.

Each job consists of a sequence of steps. A step is either a shell command (run:) or an invocation of an action (uses:). Actions are reusable units of CI logic published as GitHub repositories. For example, actions/checkout@v4 checks out your repository, and dtolnay/rust-toolchain@stable installs a Rust toolchain.

Because each job starts from a clean VM, nothing persists between jobs unless you explicitly pass data through artifacts or caches. The upside is full isolation; the downside is that you pay the cost of setup (toolchain installation, dependency download, compilation) on every run unless you configure caching.

The most common triggers are push and pull_request (for checking code on every change), schedule (for running expensive checks periodically using cron syntax), and workflow_dispatch (which adds a manual “Run workflow” button in the GitHub UI). You can scope triggers to specific branches or file paths, and use if: conditions to skip individual jobs or steps based on context. Larger projects often split workflows across multiple YAML files (one for CI checks, one for scheduled audits, one for releases), and organizations with many Rust repositories can share workflow logic across repos using reusable workflows via the workflow_call trigger. The GitHub Actions documentation covers all of these features in detail.

name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo test

This minimal workflow checks out the code, installs Rust, and runs cargo test on every push and pull request.

Patterns

The following is a collection of patterns commonly used when writing GitHub Actions workflows for Rust projects. Most of what GitHub Actions offers is not Rust-specific and is well-documented elsewhere, so this section focuses on the parts where Rust’s compile times, toolchain ecosystem, or cargo conventions require special attention.

Toolchain Installation

The standard approach is dtolnay/rust-toolchain, which installs a specific Rust toolchain and is faster and more cacheable than calling rustup directly:

- uses: dtolnay/rust-toolchain@stable

You can also pin to a specific version, install nightly, or add components:

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

For projects that need to test across multiple Rust versions (stable, beta, nightly, and their MSRV), this pairs well with the matrix strategy covered below.

To install additional Cargo subcommands like cargo-nextest, cargo-hack, or cargo-audit, the taiki-e/install-action provides pre-built binaries for many common tools, which is significantly faster than building them from source with cargo install:

- uses: taiki-e/install-action@v2
  with:
    tool: cargo-nextest,cargo-hack

Caching

Rust projects are notorious for slow CI builds because the compilation step dominates. A fresh cargo build of a moderately-sized project can take 10-20 minutes. Caching the build artifacts between runs is essential.

The Swatinem/rust-cache action is the standard solution. It caches ~/.cargo/registry, ~/.cargo/git, and the target/ directory, with automatic cache key generation based on your Cargo.lock, toolchain version, and job name:

- uses: Swatinem/rust-cache@v2

This single line typically cuts subsequent build times by 50-80%. You can also configure it to cache additional directories or share caches between jobs.

For larger projects, consider sccache as a compilation cache that operates at the object-file level. The mozilla-actions/sccache-action makes this easy to set up and can share cached artifacts across different workflow runs and even across different CI jobs.

One thing to watch out for is stale caches. When dependencies change or the Rust compiler is updated, cached build artifacts can become invalid and cause mysterious compilation failures that do not reproduce locally. If a CI run fails in a way you cannot explain, try deleting the cache via the GitHub Actions UI or API before spending time debugging.

Concurrency Control

Because Rust CI runs are dominated by compilation, they tend to be long. When you force-push to a PR branch, GitHub starts a new workflow run while the previous one is still compiling. For a language with fast builds this is barely noticeable, but for Rust you can easily end up with 20+ minutes of runner time wasted on a run whose results you no longer care about. The concurrency: key solves this by automatically cancelling in-progress runs:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

This cancels duplicate runs on PR branches but never cancels runs on main (where you want every push to complete).

Matrix Strategy

A matrix lets you run the same job across multiple configurations. This is how you test across Rust versions, operating systems, or feature flag combinations:

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        rust: [stable, beta, nightly]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@master
        with:
          toolchain: ${{ matrix.rust }}
      - uses: Swatinem/rust-cache@v2
      - run: cargo test

This generates 9 jobs (3 operating systems times 3 toolchains). You can use include: to add specific combinations (like testing your MSRV on Ubuntu only) and exclude: to skip combinations that are not relevant.

For projects that claim cross-platform support, testing on Windows is important. Windows does some unusual things regarding path separators, line endings, filesystem case sensitivity, and symlink behavior. These issues do not surface on Linux-only CI. For more on this topic, see the Cross-Compiling chapter.

Rust-Specific Environment Variables

A few environment variables are worth setting globally for CI jobs:

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

CARGO_INCREMENTAL=0 disables incremental compilation. Incremental compilation speeds up rebuilds on a developer’s machine by caching intermediate artifacts, but in CI every build starts from a clean state (or a cache that may be stale), so incremental compilation just wastes disk space and can occasionally cause spurious failures.

RUSTFLAGS="-D warnings" promotes all warnings to errors. This ensures that CI fails on warnings without requiring developers to set #![deny(warnings)] in their code, which would also affect downstream users of the crate. Setting it as an environment variable keeps the strictness scoped to CI.

CARGO_TERM_COLOR=always forces colored output. GitHub Actions renders ANSI colors in its log viewer, and colored compiler output is significantly easier to read.

Note that RUSTFLAGS only affects rustc. For cargo doc, you need to set RUSTDOCFLAGS="-D warnings" separately to turn documentation warnings (broken intra-doc links, missing code examples, etc.) into errors. This is typically set on the doc job rather than globally, since not every job runs rustdoc.

What to Run

The What to Run section in the CI overview chapter covers which checks to run and how to organize them into a fast tier (every pull request) and a thorough tier (on merge or on schedule). The example workflow at the end of this chapter demonstrates how to implement both tiers in GitHub Actions.

Release Workflows

CI is not just about checks. Many Rust projects use GitHub Actions to build release binaries and publish crates. A common pattern is a workflow triggered by Git tags:

on:
  push:
    tags: ["v*"]

This workflow can use a matrix with cross (see Cross-Compiling) to build binaries for multiple platforms, upload them as GitHub release assets, and optionally publish the crate to crates.io. The Changelog chapter covers how to automate changelog generation as part of this process.

For publishing to crates.io, the traditional approach is to store a CARGO_REGISTRY_TOKEN as a repository secret. A newer and more secure alternative is trusted publishing, which uses GitHub’s OpenID Connect (OIDC) tokens to authenticate directly with crates.io without any stored secrets. You configure crates.io to trust publishes from a specific repository and workflow, and GitHub provides a short-lived token at runtime. This eliminates the risk of a leaked or stale API token and removes the need to rotate secrets.

GitHub Pages

GitHub Pages can host static content generated by your CI workflows, similar to GitLab Pages. This is useful for publishing API documentation, coverage reports, and book documentation.

By default, GitHub Pages publishes to a domain based on your username and repository name. For example, if your repository is at github.com/yourname/reponame, the pages will be at yourname.github.io/reponame/. You can configure a custom domain in Settings > Pages.

GitHub Pages requires a dedicated deployment workflow using the actions/upload-pages-artifact and actions/deploy-pages actions. You also need to configure the repository to deploy from GitHub Actions (Settings > Pages > Source > GitHub Actions).

name: Pages

on:
  push:
    branches: [main]

permissions:
  pages: write
  id-token: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: taiki-e/install-action@v2
        with:
          tool: mdbook
      # Build rustdoc and mdBook, then assemble into a single directory.
      - run: cargo doc --no-deps --all-features
      - run: mdbook build
      - run: mkdir site && mv book site/book && mv target/doc site/code
      # Replace your_crate_name with your crate name (hyphens become
      # underscores in rustdoc output).
      - run:
          echo '<meta http-equiv="refresh" content="0;url=book/">' >
          site/index.html
      - uses: actions/upload-pages-artifact@v3
        with:
          path: site

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v4

You can add more things to publish (coverage reports, nightly binaries) by adding them to the build job.

Common Actions

Several community-maintained actions are commonly used in Rust CI workflows:

ActionDescription
dtolnay/rust-toolchainInstalls and configures Rust toolchains. Replaces the unmaintained actions-rs/toolchain.
Swatinem/rust-cacheCaches Cargo registry, git checkouts, and build artifacts.
taiki-e/install-actionInstalls pre-built binaries of common Cargo subcommands (nextest, hack, audit, and more) without compiling from source.
mozilla-actions/sccache-actionSets up sccache for shared compilation caching.
EmbarkStudios/cargo-deny-actionRuns cargo-deny to check licenses, advisories, and banned dependencies.
actions-rust-lang/auditRuns cargo audit to check for known vulnerabilities. Replaces the unmaintained actions-rs/audit-check.
bencherdev/bencherTracks benchmark results over time, useful for detecting performance regressions.
crate-ci/typosChecks for spelling mistakes in source code, comments, and documentation.
crate-ci/committedChecks that commit messages follow conventional commit formatting.

Note that the actions-rs family of actions (toolchain, cargo, audit-check, clippy-check) is unmaintained. If you encounter them in existing workflows, consider migrating to the alternatives listed above.

Reproducibility

The Reproducibility section in the CI overview covers the platform-agnostic techniques: pinning the Rust toolchain with rust-toolchain.toml, pinning dependencies with --locked, pinning tool versions, and using Nix. This section covers the GitHub-specific concerns.

Pinning Actions

GitHub Actions references like actions/checkout@v4 point to a mutable Git tag. A maintainer can push new code under an existing tag at any time, which is both a security risk (covered in the Security section below) and a reproducibility problem: an action update can change CI behavior without any change to your code. Pinning to a full commit SHA eliminates this:

- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

Tools like Dependabot and Renovate can keep SHA pins up to date automatically, giving you both pinning and freshness.

Pinning Tool Versions

When using taiki-e/install-action, you can pin tool versions explicitly:

- uses: taiki-e/install-action@v2
  with:
    tool: cargo-nextest@0.9.81,cargo-hack@0.6.33

Without version pins, each CI run installs whatever the latest release is, which can introduce new warnings or behavior changes unrelated to your code.

Pinning Runner Images

GitHub’s ubuntu-latest label is convenient, but it periodically moves to a newer Ubuntu release. When it does, system libraries, default compiler versions, and other host dependencies change. For most Rust projects this is harmless, but if your build depends on system packages (OpenSSL, SQLite, protoc), the version jump can break things. Pinning to a specific image avoids this:

runs-on: ubuntu-24.04 # instead of ubuntu-latest

The same applies to Docker base images. Using rust:latest in a Dockerfile means the Rust version can change at any time. Pin to a specific version instead: rust:1.82.0.

Nix as a Reproducibility Layer

For projects that already use Nix, running CI inside a Nix development shell achieves all of the above in one step. The Nix flake lockfile pins the Rust toolchain, all system dependencies, and all auxiliary tools to exact versions. A workflow using Nix looks like this:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: DeterminateSystems/nix-installer-action@main
      - uses: DeterminateSystems/magic-nix-cache-action@main
      - run: nix develop --command cargo test

The DeterminateSystems/nix-installer-action installs Nix on the runner, and magic-nix-cache-action transparently caches Nix store paths using GitHub Actions’ built-in cache, so Nix does not rebuild everything from source on each run. No external accounts or secrets are needed. For projects that need to share a binary cache across multiple repositories or CI systems, Cachix is a hosted Nix binary cache service that integrates with GitHub Actions via cachix/cachix-action. With either approach, the only mutable input is the runner image itself, and even that has minimal impact because Nix provides its own toolchain and libraries.

The tradeoff is adoption cost. Nix has a steep learning curve, and adding it to a project solely for CI reproducibility is rarely worth it. But for projects that already have a flake.nix, using it in CI is a natural extension that eliminates most of the pinning concerns described above.

The advantage of using Nix in CI is that it extends the reproducibility to local development environments. You can run the CI checks locally and be confident that the outcome is the same as in the CI environment.

Security

CI workflows often have access to secrets: registry tokens for publishing crates, deployment credentials, API keys. This makes them an attractive attack surface.

The most common vector is action supply-chain attacks. Because action references like actions/checkout@v4 resolve to a mutable Git tag, a compromised or malicious action maintainer can push new code under an existing tag. Every workflow using that tag will then execute the attacker’s code on its next run, with access to whatever secrets the job has. This has happened in practice. The mitigation is SHA pinning, as described in the Reproducibility section above. For workflows that handle secrets, SHA pinning is not optional.

The second vector is pull_request_target. Unlike pull_request, this event runs in the context of the base branch, which means it has access to repository secrets. If the workflow checks out and executes the PR’s code, an attacker can submit a malicious pull request that exfiltrates those secrets. The safe pattern is to use pull_request_target only for steps that do not run untrusted code (like labeling or commenting), and never to check out github.event.pull_request.head.ref in a workflow that has access to secrets.

A third concern is overly broad secret scopes. GitHub allows scoping secrets to specific environments and requiring approval for deployments. Use these features to limit which jobs can access which secrets, rather than making all secrets available to all workflows.

Example

The following workflow puts together the patterns from this chapter into a realistic CI setup. It demonstrates the fast-tier checks on every PR, a thorough-tier audit on merge to main, cross-platform testing, and several practical patterns explained in inline comments.

name: CI

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

# Cancel in-progress runs on the same branch. Never cancel runs on main,
# where every push should complete.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

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

jobs:
  # Formatting is the cheapest check. Other jobs depend on it via `needs:`
  # so that if formatting fails, everything else is skipped immediately.
  format:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # Nightly is used because some rustfmt options (like
      # imports_granularity) are only available on nightly.
      - uses: dtolnay/rust-toolchain@nightly
        with:
          components: rustfmt
      - run: cargo fmt --check

  lint:
    runs-on: ubuntu-latest
    needs: format
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy
      - uses: Swatinem/rust-cache@v2
      - run: cargo clippy --all-targets -- -D warnings

  test:
    needs: format
    strategy:
      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
      # --locked ensures CI uses exactly the dependency versions in
      # Cargo.lock, catching forgotten lock file updates.
      - run: cargo test --all-features --locked

  doc:
    runs-on: ubuntu-latest
    needs: format
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo doc --no-deps --all-features --locked
        env:
          RUSTDOCFLAGS: "-D warnings"

  # The audit job only runs on pushes to main, not on PRs. Advisory
  # databases change independently of your code, so you don't want PR
  # builds failing for reasons outside the contributor's control.
  audit:
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: taiki-e/install-action@v2
        with:
          tool: cargo-audit
      - run: cargo audit

Reading

Shows you how to get started with GitHub Actions.

GitHub Actions Feels Bad by Amos Wenger

The history and design of GitHub Actions, and why they are perhaps not designed in an ideal way.

Continuous Integration by The Cargo Book

The official Cargo documentation on setting up CI, with examples for both GitHub Actions and GitLab CI.

A practical walkthrough of setting up cross-compilation in GitHub Actions, covering toolchain setup, target installation, and common pitfalls.