From 64d5829037fadd8a4dffcda1807e1407dd9fe13c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 2 Sep 2026 17:48:32 +0100 Subject: [PATCH] ci(maestro): cache nix store, trim CI hot paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `flake` job was the pipeline's critical path at ~9 minutes, all of it one cold `nix build`: with an empty /nix every run, it recompiled the whole release closure under `lto = "thin"` + `codegen-units = 1`. - Cache /nix across runs, keyed on the lockfiles the closure derives from, so crane's deps-only derivation survives and only maestro's own crates rebuild. Saved from pushes only, so a PR cannot write a partial store. - Build only `.#webFrontend` on PRs — that is the step that guards pnpmDeps.hash. The release binaries stay on main. - Replace the hand-rolled `target/` cache with Swatinem/rust-cache, which prunes to the current lockfile instead of accumulating stale artifacts behind a prefix restore-key. ~60s per run went into moving that entry. - Run tests under cargo-nextest, which parallelises across the 20 integration binaries. Capped at 4 threads in .config/nextest.toml: the tmux-backed tests starve each other at the num-cpus default and trip their 5s deadlines. - actions/setup-node instead of a second apt-get pass via nodesource. Co-Authored-By: Claude Opus 5 (1M context) --- .config/nextest.toml | 20 +++++++ .gitea/workflows/ci.yml | 123 +++++++++++++++++++++++++++++++++------- 2 files changed, 122 insertions(+), 21 deletions(-) create mode 100644 .config/nextest.toml diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..14f9b80 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,20 @@ +# The integration suites drive real tmux servers and real git repos, and most +# of their wall time is spent waiting on those subprocesses rather than on the +# CPU. nextest runs test binaries in parallel — which plain `cargo test` does +# not — but at its `num-cpus` default the tmux-backed tests starve each other +# and the tighter `wait_for_async` deadlines (5s) start timing out. Capping the +# concurrency keeps the cross-binary win without the contention. + +[profile.ci] +test-threads = 4 + +# Anything past this is reported as slow while it keeps running, so a genuine +# hang is visible in the log instead of only showing up as a job timeout. +slow-timeout = { period = "30s", terminate-after = 8 } + +failure-output = "immediate-final" +final-status-level = "slow" + +# nextest does not run doctests. There are none today (every ``` fence in the +# crates is `toml` or `text`), so nothing is lost; adding a real doctest means +# adding `cargo test --doc` to CI alongside this. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index fda3cdb..4980bc0 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -11,6 +11,10 @@ concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + jobs: rust: runs-on: ubuntu-latest @@ -30,23 +34,36 @@ jobs: --component rustfmt --component clippy echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - name: Install Node.js + pnpm + # `crates/maestro-web/build.rs` shells out to pnpm whenever + # MAESTRO_WEB_DIST is unset, so the Rust build needs node here too. + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Enable pnpm run: | - curl -fsSL https://deb.nodesource.com/setup_20.x | bash - - apt-get install -y --no-install-recommends nodejs corepack enable corepack prepare pnpm@9.15.4 --activate + # Caching all of `target/` under a prefix restore-key let every stale + # artifact a previous run dragged in survive forever, so the entry grew + # without bound and cost ~60s to move. rust-cache keys on the rustc + # version plus the lockfile and prunes target/ to what that lockfile + # actually needs. Only main writes the cache; PRs restore from it and + # skip the save, which also keeps concurrent PRs from thrashing it. - name: Cache cargo - uses: actions/cache@v4 + uses: Swatinem/rust-cache@v2 with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }} - restore-keys: | - ${{ runner.os }}-cargo- + save-if: ${{ github.ref == 'refs/heads/main' }} + + # nextest runs test *binaries* in parallel, which plain `cargo test` + # does not — this suite is 20 integration binaries that mostly sit + # waiting on tmux and git. Thread count is capped in + # `.config/nextest.toml`; see the comment there. + - name: Install cargo-nextest + run: | + curl -sSfL https://get.nexte.st/latest/linux \ + | tar zxf - -C "$HOME/.cargo/bin" - name: fmt run: cargo fmt --all -- --check @@ -55,7 +72,7 @@ jobs: run: cargo clippy --all-targets -- -D warnings - name: test - run: cargo test --all + run: cargo nextest run --all --profile ci # NAP-271: the umbrella `maestro` binary is feature-gated (cli / daemon / # tui). Verify each subset compiles and its tests pass on its own, and @@ -66,7 +83,8 @@ jobs: for f in cli daemon tui; do echo "::group::maestro --no-default-features --features $f" cargo build -p maestro --no-default-features --features "$f" - cargo test -p maestro --no-default-features --features "$f" + cargo nextest run -p maestro --no-default-features --features "$f" \ + --profile ci echo "::endgroup::" done echo "::group::maestro with no features must fail to compile" @@ -81,12 +99,12 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Node.js + pnpm + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Enable pnpm run: | - apt-get update - apt-get install -y --no-install-recommends curl ca-certificates - curl -fsSL https://deb.nodesource.com/setup_20.x | bash - - apt-get install -y --no-install-recommends nodejs corepack enable corepack prepare pnpm@9.15.4 --activate @@ -110,25 +128,88 @@ jobs: steps: - uses: actions/checkout@v4 + # A cold /nix means every run recompiles the entire release closure — + # every third-party crate under `lto = "thin"` + `codegen-units = 1`, + # which was ~8.5 minutes and the whole pipeline's critical path. With the + # store restored, crane's deps-only derivation is already built and only + # maestro's own crates recompile. + # + # The key is the lockfiles that closure derives from, not the sources: a + # source-only push is an exact hit, which makes actions/cache skip the + # (multi-GB) save. Only the cheap final crates rebuild in that case. + - name: Restore nix store + id: nix-cache + uses: actions/cache/restore@v4 + with: + path: /nix + key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', 'Cargo.lock', 'web/pnpm-lock.yaml', '**/*.nix') }} + restore-keys: | + nix-${{ runner.os }}- + # The runner container is root with no `nixbld` group, but the installer # writes `build-users-group = nixbld` anyway and then fails on it, so the # config goes in first. Flakes are off by default and `nix build .#…` # needs them. Written again afterwards in case the installer replaced it. + # + # A restored /nix already holds nix itself — the single-user installer + # puts its profile under /nix/var/nix/profiles/per-user/root, which is + # inside the cached path. Only $HOME (the ~/.nix-profile symlink, the + # PATH entry, NIX_SSL_CERT_FILE) lives outside it and has to be redone. - name: Install nix run: | + set -euo pipefail mkdir -p /etc/nix printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf - curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon --yes - printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf + profile=/nix/var/nix/profiles/per-user/root/profile + if [ -x "$profile/bin/nix" ]; then + ln -sfn "$profile" "$HOME/.nix-profile" + else + curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon --yes + printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf + fi echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH" + # Sourcing the installer's profile script is what normally sets this; + # without it a restored store cannot talk to cache.nixos.org. + if [ -z "${NIX_SSL_CERT_FILE:-}" ] && [ -r /etc/ssl/certs/ca-certificates.crt ]; then + echo "NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt" >> "$GITHUB_ENV" + fi + + # Fail here rather than 8 minutes into a build if a restored store is + # unusable. + - name: Check nix works + run: nix --extra-experimental-features "nix-command flakes" --version # The installer does not enable flakes, so `nix build .#…` needs the # features passed explicitly — a local machine that has them in # nix.conf will build fine while CI fails without this. - - name: build flake packages + # + # This is the step that guards pnpmDeps.hash, and it is cheap, so it runs + # on every event. + - name: build SPA package run: | nix --extra-experimental-features "nix-command flakes" build \ .#webFrontend \ + --no-link + + # The release binaries are a full LTO compile of the workspace. The rust + # job already builds, lints and tests the same sources, so the only extra + # signal here is that the *packaging* works — worth a few minutes on main, + # not on every PR push. Trade-off: a break that is specific to the Nix + # packaging surfaces on main instead of on the PR. + - name: build release packages + if: github.event_name == 'push' + run: | + nix --extra-experimental-features "nix-command flakes" build \ .#maestro \ .#maestro-web \ --no-link + + # Saved only from a push, so a PR that built webFrontend alone can never + # write a store missing the Rust closure and have a later run restore it + # through the prefix key. + - name: Save nix store + if: github.event_name == 'push' && steps.nix-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: /nix + key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', 'Cargo.lock', 'web/pnpm-lock.yaml', '**/*.nix') }} -- 2.47.3