The push that breaks everything
You finish a feature branch. The tests pass on your machine. You open a pull request and hit merge. Three hours later, a maintainer pings you: the build is failing on Linux arm64, and the documentation generator is choking on a missing dependency. The fix takes twenty minutes, but the trust it costs takes longer to rebuild.
Continuous integration and continuous delivery exist to catch these gaps before they reach the main branch. For Rust open source projects, CI/CD is not just about running cargo test. It is about guaranteeing that your code compiles, passes the borrow checker, satisfies clippy, formats correctly, and builds documentation across the environments your users actually run.
What CI/CD actually does for Rust
Think of a CI pipeline as a standardized inspection line. Every commit travels through the same gauges, regardless of who wrote it or what operating system they use. The gauges check different layers of quality.
The first gauge verifies compilation. Rust's compiler is strict. A missing dependency or a subtle lifetime mismatch will halt the build immediately. The second gauge runs the test suite. It catches logic errors that the type system cannot see. The third gauge runs linters. clippy catches idiomatic mistakes. rustfmt enforces consistent styling. The fourth gauge builds documentation and checks for broken links.
Open source projects face an extra challenge: contributors use different machines, different toolchains, and different package managers. A local success means nothing if the pipeline cannot reproduce it. The workflow file bridges that gap by declaring exactly which toolchain to install, which dependencies to fetch, and which commands to run in a clean environment.
Treat the workflow file as part of your public API. Contributors read it to understand how to run tests locally. Maintainers rely on it to gate merges.
A minimal workflow that works
GitHub Actions is the default choice for most Rust open source projects. The configuration lives in .github/workflows/ci.yml. The file declares triggers, jobs, and steps. Each step runs in a fresh virtual machine.
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Fetch the repository code into the runner
- name: Install Rust toolchain
run: |
rustup toolchain install stable --profile minimal
rustup default stable
# GitHub runners do not ship Rust by default
- name: Run tests
run: cargo test
# Compiles and executes the test suite
The workflow triggers on every push and pull request. The test job spins up an Ubuntu runner. The first step checks out the code. The second step installs the stable toolchain with a minimal profile. The minimal profile skips unnecessary components like rust-src and rust-docs, which speeds up the installation. The third step runs cargo test.
When you push a commit, GitHub creates a runner, executes the steps in order, and reports the result. If cargo test exits with a non-zero code, the job fails and the pull request shows a red checkmark. If it succeeds, the job passes.
Keep the first pipeline simple. Verify that compilation and tests work before adding caching, matrix testing, or documentation checks. A broken simple pipeline teaches you more than a complex one that silently fails.
Building a production-ready pipeline
Open source projects need more than a basic test run. Contributors expect feedback on formatting, linting, and cross-platform compatibility. The workflow below adds caching, clippy, rustfmt, and a matrix that tests multiple operating systems.
name: CI
on: [push, pull_request]
env:
# Pin the toolchain to avoid surprise breaking changes
RUST_TOOLCHAIN: stable
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
run: |
rustup toolchain install ${{ env.RUST_TOOLCHAIN }} --profile minimal
rustup default ${{ env.RUST_TOOLCHAIN }}
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
# Reuse downloaded crates and build artifacts
- name: Check formatting
run: cargo fmt --check
# Fails if the code is not formatted with rustfmt
- name: Run clippy
run: cargo clippy -- -D warnings
# Treats all clippy warnings as hard errors
- name: Run tests
run: cargo test
- name: Build documentation
run: cargo doc --no-deps
# Generates docs without pulling in external crate docs
The env block pins the toolchain. Pinning prevents a contributor from accidentally upgrading to a nightly release that breaks the build. The cache step stores the Cargo registry and the target directory. The cache key depends on the operating system and the hash of Cargo.lock. When the lockfile changes, the cache invalidates automatically. When it stays the same, subsequent runs skip downloading crates and skip recompiling unchanged dependencies.
The cargo fmt --check step runs the formatter in read-only mode. It exits with an error if any file differs from the canonical format. The cargo clippy -- -D warnings step runs the linter and promotes warnings to errors. The cargo doc --no-deps step builds documentation for your crate only, which keeps the build fast and avoids pulling in documentation for every transitive dependency.
Community convention favors actions/checkout@v4 and actions/cache@v4 over @master or @v3. Pinned major versions prevent breaking changes from sneaking into your pipeline. The --no-deps flag for cargo doc is also standard practice in open source. It keeps the documentation build focused on your public API.
Run the pipeline locally with act or nektos/act if you want to debug steps before pushing. A local dry run saves you from waiting on remote runners.
When the pipeline fails
CI failures fall into three categories: environment mismatches, dependency resolution errors, and test flakiness. Each one has a predictable pattern.
Environment mismatches happen when the runner lacks a required tool. The logs show rustup: command not found or cargo: command not found. The fix is to add the rustup toolchain install step before any cargo command. GitHub runners do not include Rust by default. You must declare it explicitly.
Dependency resolution errors appear as E0463 (can't find crate) or E0277 (trait bound not satisfied) when a feature flag is missing or a platform-specific dependency is not available. The compiler prints the exact crate name and the missing feature. Add the feature to Cargo.toml or wrap the import in a #[cfg(feature = "...")] attribute. CI catches these because it builds from a clean state without your local target directory masking missing artifacts.
Test flakiness shows up as intermittent failures. The same commit passes on Tuesday and fails on Wednesday. Flaky tests usually rely on timing, network access, or global state. Isolate the test. Mock external services. Use serial_test to enforce ordering when necessary. A pipeline that fails randomly erodes trust faster than a pipeline that fails consistently.
Read the CI logs from top to bottom. The first error is usually the root cause. Subsequent errors are often cascade failures from a missing type or an unresolved import. Fix the first one and re-run. Do not patch around symptoms.
Treat the CI log as a contract. If it passes, the code meets the project's standards. If it fails, the code does not.
Choosing your CI platform
Use GitHub Actions when your repository already lives on GitHub and you want zero-configuration hosting. Use GitLab CI when your team prefers a single platform for source control, issue tracking, and deployment, or when you need built-in container registry integration. Use self-hosted runners when you require specific hardware, proprietary toolchains, or strict data residency policies that cloud providers cannot guarantee. Reach for cloud runners for most open source projects; the maintenance overhead of self-hosting rarely pays off unless you are compiling large monorepos or testing embedded targets.