GitHub Actions CI/CD for Rust

4 min read Original article ↗

I am currently between contracts and with lots of free time, I've been tidying some tools and scripts I have on GitHub.

Over time, I made a couple of Rust tools that I use myself. I've never put too much thought in setting up a proper CI/CD pipeline for these, as they were learning projects. Now that I can write Rust confidently, it's time to fix that.

The CI part

For being a major programming language, setting up a CI for Rust was rather unintuitive.

I used actions-rust-lang/setup-rust-toolchain as base. When I realized it handles caching automatically, I threw out the caching steps:

steps:
  - uses: actions/checkout@v4
  - uses: actions-rust-lang/setup-rust-toolchain@v1
  - run: cargo test

Coverage

Getting coverage requires installing LLVM and a few extra commands.

The LLVM version must match the version rustc was compiled with. A simpl command let me know it was version 18:

rustc --version --verbose

I tried a couple of actions to install LLVM 18, but they didn't work. I went for the official installer, and that worked on the second try:

- name: Install LLVM 18
  run: wget https://apt.llvm.org/llvm.sh && chmod u+x llvm.sh && sudo ./llvm.sh 18

The next step is adding instrument coverage to RUSTFLAGS:

env:
  RUSTFLAGS: '-C instrument-coverage -D warnings'

Then get the target for which we want the coverage and save it as environment variable, so we can use it in the last step:

- name: Get the coverage target
  run: |
    cargo test --tests --no-run --message-format=json | jq -r "select(.profile.test == true) | .filenames[]" | grep lib | xargs -I {} echo "COV_TARGET={}" >> $GITHUB_ENV

I have only one target, so I could just grep lib to filter the results of jq. If you have more targets, you can find a more exhaustive command in the official docs

Merge the profiles:

- run: llvm-profdata-18 merge -sparse default_*.profraw -o json5format.profdata

To print the report, ignoring dependencies and standard library:

- run: |
  llvm-cov-18 report \
  --use-color \
  --ignore-filename-regex='/.cargo/registry' \
  --ignore-filename-regex='rustc/*' \
  --ignore-filename-regex='target/debug/build/*' \
  --instr-profile=json5format.profdata \
  --object "$COV_TARGET"

To generate an html report, we can use the show command. I used the rust demangler, but it's optional.

- name: install rustfilt
  run: cargo install rustfilt

Installing rustfilt adds 30 seconds to a 65 seconds pipeline, and all it does is some of the notices of the report human-readable. I was conflicted about it, but in the end I decided to keep it.

Then to generate the html report:

- run: |
  llvm-cov-18 show \
  --use-color \
  --ignore-filename-regex='/.cargo/registry' \
  --ignore-filename-regex='rustc/*' \
  --ignore-filename-regex='target/debug/build/*' \
  --instr-profile=json5format.profdata \
  --show-instantiations \
  --show-line-counts-or-regions \
  --Xdemangler=rustfilt \
  --format=html \
  --object "$COV_TARGET" > report.html

Which can be saved as artifact:

- uses: actions/upload-artifact@v4
  with:
    name: 'coverage-report-${{ github.ref_name }}.html'
    path: report.html

Here's the full configuration:

on: 
  push:
    branches:
      - main
name: test
jobs:
  test:
    runs-on: ubuntu-latest
    env:
      RUSTFLAGS: '-C instrument-coverage -D warnings'
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions-rust-lang/setup-rust-toolchain@v1

      - name: install rustfilt
        run: cargo install rustfilt

      - name: Install LLVM 18
        run: wget https://apt.llvm.org/llvm.sh && chmod u+x llvm.sh && sudo ./llvm.sh 18

      - run: cargo test

      - name: Get the coverage target
        run: |
          cargo test --tests --no-run --message-format=json | jq -r "select(.profile.test == true) | .filenames[]" | grep lib | xargs -I {} echo "COV_TARGET={}" >> $GITHUB_ENV

      - run: llvm-profdata-18 merge -sparse default_*.profraw -o json5format.profdata

      - run: |
          llvm-cov-18 report \
          --use-color \
          --ignore-filename-regex='/.cargo/registry' \
          --ignore-filename-regex='rustc/*' \
          --ignore-filename-regex='target/debug/build/*' \
          --instr-profile=json5format.profdata \
          --object "$COV_TARGET"

      - run: |
          llvm-cov-18 show \
          --use-color \
          --ignore-filename-regex='/.cargo/registry' \
          --ignore-filename-regex='rustc/*' \
          --ignore-filename-regex='target/debug/build/*' \
          --instr-profile=json5format.profdata \
          --show-instantiations \
          --show-line-counts-or-regions \
          --Xdemangler=rustfilt \
          --format=html \
          --object "$COV_TARGET" > report.html

      - uses: actions/upload-artifact@v4
        with:
          name: 'coverage-report-${{ github.ref_name }}.html'
          path: report.html

The CD part

In my case the CD part is straightforward. It's a CLI script, so all I need to do is build the release and save it:

on:
  release:
    types: [published]
name: release
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions-rust-lang/setup-rust-toolchain@v1
      - run: cargo build --release
      - uses: actions/upload-artifact@v4
        with:
          name: '<MYSCRIPT>-${{ github.ref_name }}'
          path: target/release/<MYSCRIPT>

Replace <MYSCRIPT> with your script name and you're good to go.