Rust CI with GitHub Actions and cargo test

The most surprising thing about setting up CI for Rust projects is how often the simplest configuration reveals the deepest assumptions about your project’s environment.

Let’s get a basic GitHub Actions workflow running for your Rust project. We’ll focus on cargo test as the core validation step.

First, create a .github/workflows directory in your project’s root. Inside that, create a file named rust.yml (or any name you prefer, ending in .yml or .yaml).

name: Rust CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v4
    - name: Set up Rust
      uses: dtolnay/rust-toolchain@stable
    - name: Build
      run: cargo build --verbose
    - name: Run tests
      run: cargo test --verbose

This workflow does a few things:

  1. name: Rust CI: Gives your workflow a human-readable name.
  2. on: [push, pull_request]: Triggers the workflow when you push to your repository or open a pull request.
  3. jobs:: Defines one or more jobs that will run. We have a single job named build.
  4. runs-on: ubuntu-latest: Specifies the operating system environment for the job. ubuntu-latest is a common and well-supported choice.
  5. steps:: A sequence of tasks to perform within the job.
    • uses: actions/checkout@v4: This action checks out your repository’s code so the workflow can access it.
    • name: Set up Rust: This uses a popular community action (dtolnay/rust-toolchain) to install a specific Rust toolchain. stable will install the latest stable release. You could also specify 1.70.0 or beta etc.
    • name: Build: This runs cargo build --verbose. --verbose makes the output more detailed, which can be helpful for debugging. This step checks if your code compiles.
    • name: Run tests: This executes cargo test --verbose. If any of your tests fail, this step will fail, and the entire workflow will be marked as failed.

Commit this file to your repository. Now, whenever you push or open a PR, you’ll see a "Rust CI" status check.

Let’s imagine your project has a dependency that needs a specific system library, and cargo test starts failing with an error like:

error: failed to run custom build command for `openssl-sys v0.9.78`

Caused by:
  process didn't exit successfully: `.../target/release/build/openssl-sys-xxxx/build-script-main` (exit code: 1)
  --- stdout
  ...
  --- stderr
  /usr/bin/ld: cannot find -lssl
  /usr/bin/ld: cannot find -lcrypto
  ...

This is a common scenario where a Rust crate depends on underlying C libraries. The openssl-sys crate, for example, needs the OpenSSL development libraries installed on the system. GitHub Actions runners are minimal by default. To fix this, you need to install the required development headers and libraries. For Debian/Ubuntu based runners like ubuntu-latest, you’d use apt-get.

You can add a step to install these packages before building or testing:

name: Rust CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v4
    - name: Set up Rust
      uses: dtolnay/rust-toolchain@stable
    - name: Install OpenSSL dev headers
      run: sudo apt-get update && sudo apt-get install -y libssl-dev
    - name: Build
      run: cargo build --verbose
    - name: Run tests
      run: cargo test --verbose

The added step name: Install OpenSSL dev headers uses apt-get to install libssl-dev. This provides the necessary libssl.so and libcrypto.so files, along with their header files, that openssl-sys needs to link against during its build process. The sudo apt-get update is crucial to ensure the package list is current before attempting installation.

The cargo test --verbose command, when it passes, not only confirms your code compiles and your tests pass but also verifies that any external C libraries required by your Rust dependencies are correctly installed and accessible in the CI environment.

A subtle but important aspect of cargo test is its handling of test output. By default, cargo test runs tests in parallel. While this speeds up execution, it can sometimes interleave the output from different tests, making it hard to read. If you encounter a test failure where the output is garbled or hard to follow, adding the --no-fail-fast and -- --test-threads=1 flags to your cargo test command in the workflow can help. The --no-fail-fast flag ensures all tests run to completion even if one fails, and -- --test-threads=1 (the -- passes arguments to the test binary itself) serializes test execution, producing cleaner, sequential output.

    - name: Run tests
      run: cargo test --verbose -- --test-threads=1

This change makes the output of each test run sequentially, which is invaluable for debugging intermittent test failures or understanding complex error messages.

The next logical step is to start caching Rust dependencies to speed up subsequent CI runs.

Want structured learning?

Take the full Cargo course →