Step 7 of 7
Continuous integration: keeping main green
Tests, clippy and rustfmt only protect a codebase if they run every time. Continuous integration (CI) is a server that checks out every pushed commit and pull request, runs the checks, and reports a green tick or a red cross. With branch protection turned on, a pull request can't be merged until CI is green, so the main branch always builds, passes its tests and follows the team's rules.
A GitHub Actions workflow
On GitHub, CI is a YAML file in .github/workflows/. A typical Rust one:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Formatting
run: cargo fmt --all --check
- name: Lints
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Tests
run: cargo test --all-features
- name: Docs
run: cargo doc --no-deps
env:
RUSTDOCFLAGS: -D warnings
on: when to run: pushes tomain, and every pull request.actions/checkoutgets the code;dtolnay/rust-toolchaininstalls Rust with clippy and rustfmt;Swatinem/rust-cachecaches compiled dependencies between runs (builds go from minutes to seconds).- Each
runstep is a shell command. A command that exits with a non-zero code fails the step, and the job stops there. -- -D warningsturns clippy's warnings into errors (-Dis "deny"), so a warning fails CI instead of scrolling past.--all-targetslints tests and examples too.RUSTDOCFLAGS: -D warningsfails on broken intra-doc links in the docs.
Exit codes are how every tool tells CI "pass" or "fail". Here's a tiny custom check, the kind teams add for their own rules, that finds forgotten debugging code:
fn main() {
let source = "fn main() {\n let x = dbg!(2 + 2);\n println!(\"{x}\");\n}\n";
let mut problems = 0;
for (i, line) in source.lines().enumerate() {
if line.contains("dbg!(") || line.contains("todo!(") {
println!("line {}: leftover debug code: {}", i + 1, line.trim());
problems += 1;
}
}
println!("{problems} problem(s)");
// A real check would end with: std::process::exit(if problems > 0 { 1 } else { 0 });
}
line 2: leftover debug code: let x = dbg!(2 + 2);
1 problem(s)
Beyond the basics
- A matrix runs the same job on several operating systems or Rust versions (
stable,beta, and your MSRV, the minimum supported Rust version declared asrust-versioninCargo.toml). cargo auditandcargo denycheck dependencies for known security advisories and unwanted licenses.- Run the same commands locally before pushing (
cargo fmt && cargo clippy --all-targets -- -D warnings && cargo test). Many teams put them in a script or a git pre-push hook, so CI is a formality.
Reading a red CI run
Open the failed step's log and read it like any compiler or test output: the first error is usually the real one. A failing fmt --check shows a diff of what rustfmt would change; a failing clippy step shows the lint; a failing test shows the panic message.
Common mistakes
- "Works on my machine": CI starts from a clean checkout, so a file you forgot to commit, or a test that depends on your local setup, fails there. That's CI doing its job.
- Disabling a failing check to merge faster. Fix it, or mark the one test
#[ignore]with an issue link if it's genuinely flaky. - Letting CI get slow. Cache dependencies, and split long jobs so feedback arrives in minutes.
Your task
This pull request's CI is red. The log says:
Run cargo clippy --all-targets -- -D warnings
error: unneeded `return` statement
--> src/lib.rs:22:5
|
22 | return Some(format!("{}.{}.{}", bumped.0, bumped.1, bumped.2));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Run cargo test
test tests::bumps_minor_and_resets_patch ... FAILED
assertion `left == right` failed
left: Some("1.5.2")
right: Some("1.5.0")
Make it green: fix the clippy error, fix the bug the test found (a minor bump must reset the patch number to 0), keep the existing test, and add tests until you have at least 3 and they catch every planted bug (in major and patch bumps, version parsing, and unknown parts).
Challenge 2
Get this library function ready for CI: the file has #![deny(missing_docs)], so document slugify (a summary line and an # Examples section) and the crate (a //! line). Then write at least 3 tests that catch every planted bug. slugify keeps ASCII letters and digits (lowercased) and turns every run of other characters into a single -, with no dash at the start or end: "Hello, World!" becomes hello-world.
Challenge 3
CI jobs often skip work when a pull request only touches documentation. should_run_ci decides: it returns true if any changed file is a Rust file (.rs) or is Cargo.toml or Cargo.lock, and false otherwise (including when nothing changed). Write the tests module and at least 3 tests that catch every planted bug.