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.