Cutting Tauri CI in half: the APT scaffolding tax, clippy metadata, and cargo-nextest
How caching desktop sysdeps saved 48 seconds in Shitty, and why parallelizing Clippy with cargo-nextest cut DeepBlock's CI from 10 minutes to 5.
Tauri applications carry an expensive continuous integration footprint. They combine a web frontend toolchain, desktop operating system libraries like WebKitGTK and GTK-3, and a multi-crate Rust workspace. Across two projects (frangonf/shitty and frangonf/deepblock), I profiled where runner minutes were actually spent and cut the turnaround time in half.
Note
Tested with Rust 1.98.1, Tauri 2.11, cargo-nextest 0.9.146, and Ubuntu 24.04 on GitHub Actions on 2026-10-02.
The one-minute APT scaffolding tax
On GitHub-hosted Linux runners, an ephemeral virtual machine starts clean on every job. Before compiling a Tauri desktop crate, the runner needs platform headers and shared objects: libwebkit2gtk-4.1-dev, libgtk-3-dev, libayatana-appindicator3-dev, librsvg2-dev, and patchelf.
Running sudo apt-get update and downloading those packages took 56 to 58 seconds on every single commit.
In frangonf/shitty, the entire warm Rust check phase (Clippy, tests, and binary build) takes 4m 22s. Paying nearly a full minute just to install system dependencies meant that 43% of the warm job’s wall-clock time was consumed by runner scaffolding and archive transport.
I replaced the manual apt-get commands with awalsh128/cache-apt-pkgs-action:
# .github/workflows/ci.yml
- name: Install Linux desktop dependencies
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: >-
libayatana-appindicator3-dev
libgtk-3-dev
librsvg2-dev
libssl-dev
libwebkit2gtk-4.1-dev
patchelf
pkg-config
version: 1.0
- name: Refresh shared library cache
run: sudo ldconfig
On a cache hit, the action downloads the cached tarball from GitHub’s cache store and extracts it directly into the filesystem root in 8 seconds. Running sudo ldconfig takes less than 0.1s to refresh the dynamic linker bindings.
The result in shitty (Run 37006028611):
| Step | Baseline Native Mise (Warm) | With APT Caching (Warm) | Difference |
|---|---|---|---|
| Install desktop sysdeps | 56s | 8s | −48s (−85%) |
| Runner disk reclaim | 16s | 13s | −3s |
| Toolchain install (mise) | 15s | 15s | 0s |
| Restore & import mbx closure | 39s | 35s | −4s |
| Clippy | 225s | 223s | −2s |
| Test | 27s | 28s | +1s |
| Build | 10s | 10s | 0s |
| Export & save mbx closure | 60s | 57s | −3s |
| Total Rust checks job | 7m 36s (456s) | 6m 34s (394s) | −62s (−14%) |
Why DeepBlock keeps archive verification
After seeing the 48-second drop in shitty, I inspected frangonf/deepblock to apply the same change.
deepblock already had an APT caching mechanism in place: a composite action named setup-apt backed by a shell script that preserves .deb archives in /var/cache/apt/archives/.
In deepblock, setup-apt takes 25 seconds: 1.5 seconds to restore a 72 MB archive cache, and 23.5 seconds for dpkg to unpack and configure packages. Switching to direct file extraction would save about 17 seconds.
I decided against that substitution. deepblock produces cryptographically signed end-user releases (Debian packages, AppImages, macOS bundles). The maintainer explicitly documented why raw files are never untarred directly into system directories:
# scripts/ci-apt.sh
# Names, not local .deb paths: APT checks cached archives against signed
# repository metadata. Do not cache installed files or compiler outputs.
run_privileged apt-get install -y --no-install-recommends \
libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libxdo-dev libssl-dev \
libayatana-appindicator3-dev librsvg2-dev patchelf
Caching downloaded .deb files preserves Debian’s GPG verification against signed repository metadata (Release.gpg). Restoring unverified files into /usr skips cryptographic checks and exposes release builds to cache poisoning from unreviewed pull requests. For a production release pipeline, 17 seconds is a fair price to pay for supply-chain integrity.
The Clippy metadata trap
The primary bottleneck in deepblock came from sequential execution. In Run 36998820842, the Rust workspace job took 9 minutes and 46 seconds.
Looking into the step breakdown showed that 91.2% of the runtime (8m 54s) was spent sequentially in two steps:
rust-workspace (9m 46s serial)
├── Setup overhead (46s)
├── Format check (0s)
├── Clippy (222s / 3m 42s)
└── Cargo test (312s / 5m 12s)
Many projects run cargo clippy and cargo test sequentially in a single job under the assumption that they share compilation artifacts in target/.
In practice, cargo clippy executes a check pass that emits .rmeta (crate interface metadata) files rather than full executable binaries. When cargo test runs immediately afterwards, it cannot reuse those check artifacts to build the test binaries. It compiles the workspace dependencies from scratch.
Running them sequentially in one job bought zero compilation caching while forcing every pull request to wait for both passes one after the other.
Parallelizing lint and tests
Because clippy and test build different artifact targets, they are independent workloads.
I split rust-workspace into two concurrent jobs in .github/workflows/test.yml:
rust-lint: runs formatting andcargo clippy --workspace --all-targets -- -D warnings.rust-test: compiles test binaries and runs the test suite.
For the test runner, I integrated cargo-nextest using taiki-e/install-action, which downloads pre-built nextest binaries in 1 second:
# .github/workflows/test.yml
rust-test:
name: Rust tests
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install desktop development dependencies
timeout-minutes: 8
uses: frangonf/deepblock/.github/actions/setup-apt@968275eaa7c369a6579fb465eaacbe8fbfbdfb06
with:
profile: desktop
- name: Set up mise
uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5
with:
version: 2026.9.15
install: false
cache: true
- name: Install locked Rust toolchain
run: mise install --locked rust mr-boxington
- name: Install cargo-nextest
uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
with:
tool: cargo-nextest@0.9.146
- name: Restore Rust cache
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Test
run: mise exec -- cargo nextest run --locked --workspace
- name: Doc tests
run: mise exec -- cargo test --doc --locked --workspace
Because nextest focuses exclusively on unit and integration tests, I added a companion cargo test --doc step to ensure library documentation examples remain verified.
Benchmarking cargo-nextest in isolation
It is tempting to look at a workflow that dropped from 9m 46s to 5m 21s and credit the new test runner for the speedup.
To measure what cargo-nextest actually contributed to execution speed, I isolated the test runner from compilation. cargo-nextest delegates compilation to cargo test --no-run under the hood. In a multi-crate Tauri workspace, compiler passes consume the vast majority of runner time.
On a clean checkout of deepblock (6d5f307) with pre-compiled test binaries across all 107 tests (8 test binaries across 3 crates), I benchmarked warm execution times:
| Command | Phase Measured | Wall-Clock Time | User Time | System Time |
|---|---|---|---|---|
cargo test --locked --workspace | Test binaries (107 tests across 8 binaries) | 5.93s | 1.10s | 0.11s |
Doc tests (deepblock-cli, desktop, server) | 1.06s | 0.86s | 0.36s | |
| Total standard cargo test | 6.99s | 1.96s | 0.47s | |
cargo nextest run --locked --workspace | Nextest process-level execution (107 tests) | 5.10s | 1.33s | 0.70s |
cargo test --doc --locked --workspace | Companion doc test pass | 1.06s | 0.86s | 0.36s |
| Total nextest + doc pass | 7.23s | 2.19s | 1.06s |
On a warm local run, standard cargo test finished 0.24 seconds faster overall (6.99s vs 7.23s).
Two structural reasons explain that result:
- Two CLI commands instead of one:
cargo testis a single command that runs unit tests, integration tests, and doc tests in one process lifecycle. Because Nextest omits documentation tests by design, testing with Nextest requires two sequential commands (cargo nextest runfollowed bycargo test --doc). Each command inspects the workspace targets, adding roughly 0.35s of Cargo metadata check overhead (~0.7s total). - Process spawning versus threads: Standard
cargo testexecutes tests as lightweight threads inside 8 binary processes.cargo nextestspawns an individual OS process for every test case to guarantee process isolation. On 107 tests, system call time rose from 0.47s to 1.06s.
Despite the process spawning overhead, Nextest still executed the test binaries 0.83 seconds faster (5.10s vs 5.93s).
One integration test in deepblock-server (aw::tests::availability_check_has_a_bounded_request_deadline) contains a 5.0-second timeout deadline. Standard cargo test executes integration test binaries sequentially, even though tests inside a single binary run on parallel threads. Nextest schedules all 107 test cases across all 8 binaries concurrently in isolated process slots, allowing the 5.0-second sleep to overlap with all other tests in the workspace.
Comparing past CI with current CI
While warm local execution showed a modest 0.8-second execution difference, CI on GitHub Actions presented a different picture.
Comparing past CI (Run 36998820842) with current CI (Run 37009857541) on deepblock isolates the exact step differences on cold runners:
| Phase | Past CI (cargo test) | Current CI (nextest + doc) | Difference |
|---|---|---|---|
| Tool install | 0s (builtin) | 1s (taiki-e/install-action) | +1s |
| Unit and integration tests | 312s (5m 12s) | 245s (4m 05s) | −67s |
| Documentation tests | (included in above) | 27s | +27s |
| Total Test Phase | 312s (5m 12s) | 272s (4m 32s) | −40s (−13%) |
In CI, the test phase dropped from 312 seconds to 272 seconds, saving 40 seconds.
Two factors drove that 40-second CI improvement:
- Compilation-to-execution pipeline scheduling: On a constrained 2-vCPU runner, Nextest compiles test binaries with
--no-runand coordinates execution through a dedicated slot scheduler. Cargo’s default test runner executes binaries sequentially under its internal thread pool, creating CPU stalls between binary boundaries. - Elimination of output lock contention: Standard
cargo testsynchronizes stdout and stderr across threads into shared memory buffers. Under heavy CPU load on two cores, cross-thread lock contention and output flushing introduce latency. Nextest captures each process pipe independently, avoiding cross-thread locks.
Attributing DeepBlock’s 4m 25s CI reduction
The overall workflow runtime dropped from 9 minutes 46 seconds to 5 minutes 21 seconds (a 265-second drop):
PAST CI (Serial Lane: 9m 46s wall-clock)
[ Runner Setup: 46s ] ──► [ Clippy: 222s (3m 42s) ] ──► [ Cargo Test: 312s (5m 12s) ]
│
Total: 9m 46s (586s)
CURRENT CI (Parallel Lanes: 5m 21s wall-clock)
Runner 1: [ Setup: 70s ] ──► [ Clippy: 223s (3m 43s) ] ──────────────► Finished in 4m 55s
Runner 2: [ Setup: 48s ] ──► [ Nextest: 245s ] ──► [ Doc: 27s ] ──────► Finished in 5m 21s
│
Total: 5m 21s (321s)
| Job | Baseline Sequential CI (Run 36998820842) | Parallel + nextest CI (Run 37009857541) | Difference |
|---|---|---|---|
| Workflow lint | 19s | 21s | +2s |
| Desktop UI | 4m 44s | 4m 25s | −19s |
| Rust workspace (serial) | 9m 46s (586s) | Split into parallel jobs below | (replaced) |
| ├── Rust lint (fmt + clippy) | (part of serial) | 4m 55s (295s) | Parallel |
| └── Rust tests (nextest + doc) | (part of serial) | 5m 21s (321s) | Parallel |
| Total CI Wall-Clock Time | 9m 46s (586s) | 5m 21s (321s) | −4m 25s (−45%) |
Breaking down the 265-second win by source reveals the real distribution:
| Optimization | Mechanism | CI Impact | Share of Total Gain |
|---|---|---|---|
| Parallelizing Clippy and Tests | Removed 222s of Clippy execution from the sequential critical path | ~225s | 85% |
| Test Step Optimization | Baseline cargo test (312s) vs cargo nextest run (245s) + cargo test --doc (27s) | 40s | 15% |
| Total CI Critical Path Reduction | Longest path dropped from 9m 46s to 5m 21s | 265s (4m 25s) | 100% |
Runner-level parallelism delivered 85% of the win. Moving Clippy to a concurrent runner allowed both 4-minute compilation passes to execute at the same time.
When is cargo-nextest worth it?
The local and CI measurements clarify when cargo-nextest delivers genuine value:
When to adopt cargo-nextest
- Resource-constrained CI runners: On 2-vCPU VMs, eliminating cross-thread output lock contention and coordinating process scheduling saved 40 seconds in DeepBlock.
- Multi-binary suites with slow or I/O-bound tests: When tests include timeouts, network mocks, or disk access, Nextest schedules them across all binaries concurrently. A 5-second sleep in one crate overlaps with tests in other crates.
- Flaky or crashing tests: Standard
cargo testterminates an entire test binary if one test crashes with a segmentation fault or abort. Nextest isolates every test in its own process, so a crash only fails the offending test while the rest of the suite completes. - Flakiness control and timeouts: Nextest provides per-test retry rules and per-test timeout cancellation to prevent a single hung socket from consuming all runner minutes.
- Machine-readable CI reports: Nextest generates standard JUnit XML reports natively, enabling GitHub Actions job summaries without third-party regex log parsers.
When standard cargo test is sufficient
- Small local suites with fast in-memory unit tests: For suites under 200 unit tests that finish in milliseconds, thread creation is faster than spawning 100+ OS processes, and running one command avoids the two-command Cargo metadata check.
- Doc-test heavy projects: Nextest omits documentation tests. If a workflow forgets
cargo test --doc, documentation examples lose verification.
Lessons learned
- Runner-level parallelism beats runner-level micro-optimizations. Breaking the serial Clippy and test sequence delivered 85% of DeepBlock’s 4m 25s speedup.
- Profile runner scaffolding alongside compiler flags. When optimizing CI, it is easy to focus exclusively on compiler caches and linker flags. In practice, downloading desktop packages via
apt-getconsumed nearly a full minute on every single ephemeral runner. - Know what your tools accelerate.
cargo-nextestprovides process isolation, per-test timeouts, and clean output formatting. It does not recompile Rust crates faster. When compilation dominates 95% of a job’s time, compiler caching and concurrency drive the real gains. - Choose caching models based on the threat model. For developer PR workflows, untarring desktop packages directly into the runner environment cuts overhead safely. For release jobs building signed packages, caching
.debarchives preserves cryptographic package verification and guards against cache poisoning.
References
- cargo-nextest: next-generation test runner for Rust projects
- taiki-e/install-action: fast tool installer for GitHub Actions
- cache-apt-pkgs-action: composite action for caching APT packages
- Swatinem/rust-cache: GitHub Actions cache action for Rust projects
- frangonf/deepblock PR 89: parallel Rust lint and tests implementation
- frangonf/shitty PR 16: APT caching integration
This post was written with AI assistance.