RustArena

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

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

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

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.

Previous: Configuring clippy and rustfmt