What CI buys you
You push a fix, the tests pass on your laptop, you merge. A week later, a colleague discovers the change broke a feature flag combination you never tested. The problem is universal in software, but it's especially painful in Rust, where compile-time checks lull you into thinking "if it compiles it probably works."
CI is the answer that everyone agrees on. Run the build and the tests on a clean machine, every time anyone pushes. GitLab CI is convenient if you're already on GitLab: the runners are bundled, the YAML config lives next to your code, and there's nothing extra to install.
The shape of a Rust pipeline is roughly the same on every system. Pull a Rust toolchain, fetch dependencies, run cargo check, run cargo test, run cargo clippy, run cargo fmt --check. The interesting bits are caching, choosing a base image, and getting fast feedback when things break.
A minimum viable pipeline
# .gitlab-ci.yml — lives at the repo root.
stages:
- test
test:
stage: test
# Official Rust image. Pin a version: "rust:1.83" or similar.
# Avoid `latest` so your builds are reproducible.
image: rust:1.83
script:
# Show toolchain info once per run; helps when debugging.
- rustc --version && cargo --version
# The actual work. cargo test compiles in dev profile by default,
# which is fine for CI (faster than --release).
- cargo test --all-features --workspace
That's enough to give you a green or red badge on every push. It's slow, because it doesn't cache anything, but it works.
Caching the target/ and ~/.cargo directories
Rust's compile times are the killer. Without caching, every CI run downloads every dependency from crates.io and recompiles them from scratch. With caching, you can usually keep that under thirty seconds.
# Reusable defaults. Every job that 'extends: .rust' inherits these.
.rust:
image: rust:1.83
variables:
# Cargo's home is normally /usr/local/cargo in the official image.
# Move it inside the project so the cache: paths can pick it up.
CARGO_HOME: $CI_PROJECT_DIR/.cargo
before_script:
- rustc --version
cache:
# Per-branch cache. Switch to "$CI_COMMIT_REF_SLUG" for branch-isolated
# caches, or "default" if every branch should share a single cache.
key:
files:
- Cargo.lock
paths:
- .cargo/
- target/
policy: pull-push
stages:
- check
- test
# `cargo check` is much faster than `cargo build`. Run it as the first job
# so a syntax error fails the pipeline within seconds.
check:
extends: .rust
stage: check
script:
- cargo check --workspace --all-features
test:
extends: .rust
stage: test
needs: ["check"]
script:
- cargo test --workspace --all-features
Two things worth flagging:
- The cache key is
Cargo.lock. If a dependency changes, the cache invalidates and CI rebuilds. IfCargo.lockis unchanged, you reuse the sametarget/from the previous run. This is the right tradeoff for most projects. policy: pull-pushmeans the cache is pulled at the start of the job and pushed at the end. If you have a slow uploader and want only key jobs to push, setpolicy: pullon the others.
Adding clippy and rustfmt
Lints and formatting checks are cheap to run and worth their weight in saved review time.
lint:
extends: .rust
stage: check
script:
# Install components on the fly. The official rust:* image already has
# cargo and rustc; clippy and rustfmt are bundled in modern releases.
- rustup component add clippy rustfmt
# -- separates cargo's args from clippy's args.
# -D warnings turns every clippy warning into an error.
- cargo clippy --workspace --all-features --all-targets -- -D warnings
- cargo fmt --all -- --check
If you want clippy and the test job to run in parallel, give them the same stage and drop the needs:. GitLab's default is to run jobs in a stage in parallel.
Pinning a toolchain
The rust:1.83 image is fine for a quick start, but real projects want their toolchain version under source control. Use a rust-toolchain.toml at the repo root:
# rust-toolchain.toml — committed to the repo. rustup respects this
# and switches to the listed channel automatically.
[toolchain]
channel = "1.83.0"
components = ["clippy", "rustfmt"]
In CI, switch the base image to a slimmer one and let rustup handle the rest:
.rust:
# Slim image, no preinstalled rustc. Lets rust-toolchain.toml drive.
image: rustlang/rust:nightly-slim
variables:
CARGO_HOME: $CI_PROJECT_DIR/.cargo
before_script:
# Reads rust-toolchain.toml and installs the right channel + components.
- rustup show
Now bumping rustc is a one-line change in rust-toolchain.toml, applied uniformly to every developer's machine and to CI.
Producing release artefacts
When CI finishes a build on a tagged commit, you usually want the binary as a downloadable artefact.
release:
extends: .rust
stage: test
rules:
# Only on tag pushes that look like vX.Y.Z.
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
script:
- cargo build --release --workspace
artifacts:
name: "$CI_PROJECT_NAME-$CI_COMMIT_TAG"
paths:
- target/release/my-binary
expire_in: 30 days
GitLab will attach the binary to the pipeline page. Combined with release rules, you can also automatically create a Git release with that artefact attached.
Speeding up further
A few tricks once your pipeline is the bottleneck:
- Use
cargo nextestinstead ofcargo test. It runs tests in parallel processes (rather than threads) and is noticeably faster on large suites. - Add
sccachewith a shared S3 backend so caches survive across branches and machines. - Split jobs by feature flag set or workspace member to parallelise. GitLab's
parallel:andparallel:matrix:keywords are good for this. - Avoid
--releasebuilds in CI unless you're producing artefacts. Dev profile is faster to compile and almost always good enough for tests.
Common failures and what they mean
error: failed to run custom build command for `openssl-sys`
Your dependency needs system libraries that aren't in the base image. Add apt-get install -y libssl-dev pkg-config to before_script, or switch to a base image that already has them.
error: linker `cc` not found
The slim Rust images don't include a C linker. Add apt-get install -y build-essential.
warning: unused import: ...
Combined with -D warnings, this fails clippy. Either fix the warning or be pragmatic and use -W warnings for non-blocking advice. Most teams find that "warnings are errors" pays off long term.