Skip to content
Fran Gonzalez
← Back to blog
·Clanker·12 min read

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.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

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):

StepBaseline Native Mise (Warm)With APT Caching (Warm)Difference
Install desktop sysdeps56s8s−48s (−85%)
Runner disk reclaim16s13s−3s
Toolchain install (mise)15s15s0s
Restore & import mbx closure39s35s−4s
Clippy225s223s−2s
Test27s28s+1s
Build10s10s0s
Export & save mbx closure60s57s−3s
Total Rust checks job7m 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:

  1. rust-lint: runs formatting and cargo clippy --workspace --all-targets -- -D warnings.
  2. 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:

CommandPhase MeasuredWall-Clock TimeUser TimeSystem Time
cargo test --locked --workspaceTest binaries (107 tests across 8 binaries)5.93s1.10s0.11s
Doc tests (deepblock-cli, desktop, server)1.06s0.86s0.36s
Total standard cargo test6.99s1.96s0.47s
cargo nextest run --locked --workspaceNextest process-level execution (107 tests)5.10s1.33s0.70s
cargo test --doc --locked --workspaceCompanion doc test pass1.06s0.86s0.36s
Total nextest + doc pass7.23s2.19s1.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:

  1. Two CLI commands instead of one: cargo test is 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 run followed by cargo test --doc). Each command inspects the workspace targets, adding roughly 0.35s of Cargo metadata check overhead (~0.7s total).
  2. Process spawning versus threads: Standard cargo test executes tests as lightweight threads inside 8 binary processes. cargo nextest spawns 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:

PhasePast CI (cargo test)Current CI (nextest + doc)Difference
Tool install0s (builtin)1s (taiki-e/install-action)+1s
Unit and integration tests312s (5m 12s)245s (4m 05s)−67s
Documentation tests(included in above)27s+27s
Total Test Phase312s (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:

  1. Compilation-to-execution pipeline scheduling: On a constrained 2-vCPU runner, Nextest compiles test binaries with --no-run and 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.
  2. Elimination of output lock contention: Standard cargo test synchronizes 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)
JobBaseline Sequential CI (Run 36998820842)Parallel + nextest CI (Run 37009857541)Difference
Workflow lint19s21s+2s
Desktop UI4m 44s4m 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 Time9m 46s (586s)5m 21s (321s)−4m 25s (−45%)

Breaking down the 265-second win by source reveals the real distribution:

OptimizationMechanismCI ImpactShare of Total Gain
Parallelizing Clippy and TestsRemoved 222s of Clippy execution from the sequential critical path~225s85%
Test Step OptimizationBaseline cargo test (312s) vs cargo nextest run (245s) + cargo test --doc (27s)40s15%
Total CI Critical Path ReductionLongest path dropped from 9m 46s to 5m 21s265s (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

  1. Resource-constrained CI runners: On 2-vCPU VMs, eliminating cross-thread output lock contention and coordinating process scheduling saved 40 seconds in DeepBlock.
  2. 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.
  3. Flaky or crashing tests: Standard cargo test terminates 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.
  4. 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.
  5. 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

  1. 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.
  2. Doc-test heavy projects: Nextest omits documentation tests. If a workflow forgets cargo test --doc, documentation examples lose verification.

Lessons learned

  1. Runner-level parallelism beats runner-level micro-optimizations. Breaking the serial Clippy and test sequence delivered 85% of DeepBlock’s 4m 25s speedup.
  2. 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-get consumed nearly a full minute on every single ephemeral runner.
  3. Know what your tools accelerate. cargo-nextest provides 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.
  4. 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 .deb archives preserves cryptographic package verification and guards against cache poisoning.

References

This post was written with AI assistance.