From f4beb305dd65de8199f4b16a671640be8c92af4d Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Thu, 23 Jul 2026 14:51:35 -0700 Subject: [PATCH 01/36] chore: build all artifacts with nix Replace rustup in the Nix development shell with Fenix toolchains and expose reproducible Inspector, CLI, VSIX, and aggregate build outputs. --- CONTRIBUTING.md | 8 +++ Justfile | 55 ++++++++++-------- flake.lock | 39 +++++++++++++ flake.nix | 150 ++++++++++++++++++++++++++++++++++++++++++------ 4 files changed, 211 insertions(+), 41 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9edc7ac..c0aed569 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,14 @@ nix develop This drops you into a shell with every tool pinned by `flake.nix`. If you use `direnv` (with `nix-direnv`), the checked-in `.envrc` activates it automatically on `cd`. +For an immutable, host-targeted equivalent of `just build-all`, use: + +```bash +nix build .#build-all +``` + +The resulting store output contains `bin/pointbreak`, `app.js`, and `pointbreak.vsix`. + ### mise ```bash diff --git a/Justfile b/Justfile index 87e004a7..ca83a679 100644 --- a/Justfile +++ b/Justfile @@ -12,6 +12,11 @@ export SKILLS_REF_REV := env_var_or_default("SKILLS_REF_REV", "5d4c1fda3f786fff8 # PowerShell/cmd with "could not find the shell `sh`". set windows-shell := ["C:/Program Files/Git/bin/sh.exe", "-cu"] +# Rustup users select toolchains explicitly. The Fenix Nix shell overrides both +# commands with direct `cargo`, whose compiler is stable and formatter is nightly. +cargo_stable := env_var_or_default("POINTBREAK_CARGO_STABLE", "cargo +stable") +cargo_nightly := env_var_or_default("POINTBREAK_CARGO_NIGHTLY", "cargo +nightly") + # Host executable suffix: `.exe` on Windows, empty elsewhere. Mirrors the name the # extension packager derives from .github/binary-targets.json, so the path handed to # it in `build-all` actually exists on disk. @@ -25,38 +30,38 @@ default: # Run all tests. [group('core')] test *args: - cargo +stable nextest run --no-tests pass {{ args }} + {{ cargo_stable }} nextest run --no-tests pass {{ args }} # Run all tests (CI mode: no fail-fast, verbose). [group('core')] test-ci *args: - cargo +stable nextest run --profile ci --no-tests pass {{ args }} + {{ cargo_stable }} nextest run --profile ci --no-tests pass {{ args }} # Run a specific test file (e.g. just test-file integration). [group('core')] test-file name *args: - cargo +stable nextest run --test {{ name }} {{ args }} + {{ cargo_stable }} nextest run --test {{ name }} {{ args }} # Run the differential subprocess-vs-gix git-backend parity harness (report-only). [group('core')] git-parity *args: - cargo +stable nextest run --features gix-parity -E 'test(git_backend_parity)' {{ args }} + {{ cargo_stable }} nextest run --features gix-parity -E 'test(git_backend_parity)' {{ args }} # Per-op subprocess-vs-gix microbench behind the read-class flips (gix-parity # feature; separate from the `bench` feature). Prints the measured per-op win. [group('core')] git-bench *args: - cargo +stable nextest run --features gix-parity -E 'test(git_backend_microbench)' --no-capture {{ args }} + {{ cargo_stable }} nextest run --features gix-parity -E 'test(git_backend_microbench)' --no-capture {{ args }} # Build (debug). [group('core')] build *args: - cargo +stable build {{ args }} + {{ cargo_stable }} build {{ args }} # Build an optimized binary without publishing it. [group('core')] release *args: - cargo +stable build --release {{ args }} + {{ cargo_stable }} build --release {{ args }} # Reject a build profile that is not exactly `debug` or `release`, before any # dependency runs. Kept private so it stays out of `just --list`. @@ -70,7 +75,7 @@ _require-build-profile profile: # or `release` (default `release`). [group('core')] build-all profile="release": (_require-build-profile profile) web-install web-build extension-install - cargo +stable build {{ if profile == "release" { "--release" } else { "" } }} + {{ cargo_stable }} build {{ if profile == "release" { "--release" } else { "" } }} POINTBREAK_EXTENSION_PROFILE={{ profile }} POINTBREAK_EXTENSION_BINARY="{{ justfile_directory() }}/target/{{ profile }}/pointbreak{{ bin_ext }}" just extension-package # Self-test Cargo installation and all release archive layouts without publishing. @@ -161,7 +166,7 @@ workflow-lint-assertions: # Run Rust formatting checks and Clippy across all targets and features. [group('quality')] lint: fmt-check - cargo +stable clippy --workspace --all-targets --all-features -- -D warnings + {{ cargo_stable }} clippy --workspace --all-targets --all-features -- -D warnings # Type-check all targets without the full clippy/fmt gate. Used by CI's non-Linux # legs to keep the cfg(windows)/cfg(not(unix))/feature-gated arms compiled while @@ -169,22 +174,22 @@ lint: fmt-check # Type-check all workspace targets and features without the full lint gate. [group('core')] check-types: - cargo +stable check --workspace --all-targets --all-features + {{ cargo_stable }} check --workspace --all-targets --all-features # Run clippy with auto-fix. [group('quality')] fix *args: fmt - cargo +stable clippy --fix --workspace --all-targets --all-features --allow-dirty --allow-staged -- -D warnings {{ args }} + {{ cargo_stable }} clippy --fix --workspace --all-targets --all-features --allow-dirty --allow-staged -- -D warnings {{ args }} # Format code. [group('quality')] fmt *args: - cargo +nightly fmt --all {{ args }} + {{ cargo_nightly }} fmt --all {{ args }} # Check Rust formatting without writing files. [group('quality')] fmt-check: - cargo +nightly fmt --all -- --check + {{ cargo_nightly }} fmt --all -- --check # Format Nix files with the canonical RFC-166 formatter. Requires Nix. [group('nix')] @@ -239,7 +244,7 @@ commit-check range='origin/main..HEAD': # Run the CLI. [group('core')] run *args: - cargo +stable run --bin pointbreak -- {{ args }} + {{ cargo_stable }} run --bin pointbreak -- {{ args }} # Fold a worktree-local .pointbreak/data store into the Git-common-dir pointbreak store. # Non-destructive + idempotent; refuses an ephemeral/sensitive worktree unless @@ -247,7 +252,7 @@ run *args: # Migrate a worktree-local store into the Git-common-dir store without deleting the source. [group('maintenance')] migrate-store-common-dir repo="." include-ephemeral="false": - cargo +stable run --bin pointbreak -- store migrate --repo {{ repo }} \ + {{ cargo_stable }} run --bin pointbreak -- store migrate --repo {{ repo }} \ {{ if include-ephemeral == "true" { "--include-ephemeral" } else { "" } }} # Run the complete Rust gate: commit check, build, lint, and tests. @@ -258,28 +263,28 @@ check: commit-check build lint test # uses only disposable roots and records raw samples without timing thresholds. [group('quality')] store-foundation-qualification-smoke: - cargo +stable bench --features bench --bench store_foundation -- --qualification-smoke + {{ cargo_stable }} bench --features bench --bench store_foundation -- --qualification-smoke # Run the developer evidence lane with repeated raw performance samples. This # remains environment evidence rather than a default-test timing gate. [group('quality')] store-foundation-qualification: - cargo +stable bench --features bench --bench store_foundation -- --qualification-evidence + {{ cargo_stable }} bench --features bench --bench store_foundation -- --qualification-evidence # Print and validate the public longitudinal workload and capacity contracts. [group('quality')] longitudinal-contract: - cargo +stable bench --locked --features bench --bench store_foundation -- --longitudinal-contract + {{ cargo_stable }} bench --locked --features bench --bench store_foundation -- --longitudinal-contract # Exercise disposable longitudinal construction, pair, preflight, and package mechanics without timing. [group('quality')] longitudinal-smoke: - cargo +stable bench --locked --features bench --bench store_foundation -- --longitudinal-smoke + {{ cargo_stable }} bench --locked --features bench --bench store_foundation -- --longitudinal-smoke # Recursively verify one completed longitudinal raw-evidence package without editing it. [group('quality')] longitudinal-verify-package root: - cargo +stable bench --locked --features bench --bench store_foundation -- \ + {{ cargo_stable }} bench --locked --features bench --bench store_foundation -- \ --longitudinal-verify-package --longitudinal-package-root="{{ root }}" # Install the Visual Studio Code extension toolchain from its committed lockfile. @@ -349,17 +354,17 @@ capture-marketing-review-screenshots url="http://127.0.0.1:7878": # Export the canonical Review example from a source repository through public Pointbreak APIs. [group('review-evidence')] review-example-export source output="examples/review/checkout-refactor": - cargo +stable run --example review_example_pack -- export --repo {{ source }} --output {{ output }} + {{ cargo_stable }} run --example review_example_pack -- export --repo {{ source }} --output {{ output }} # Verify the checked canonical Review example pack without depending on store layout. [group('review-evidence')] review-example-verify pack="examples/review/checkout-refactor": - cargo +stable run --example review_example_pack -- verify --pack {{ pack }} + {{ cargo_stable }} run --example review_example_pack -- verify --pack {{ pack }} # Materialize the canonical Review example into an empty destination repository. [group('review-evidence')] review-example-materialize output pack="examples/review/checkout-refactor": - cargo +stable run --example review_example_pack -- materialize --pack {{ pack }} --output {{ output }} + {{ cargo_stable }} run --example review_example_pack -- materialize --pack {{ pack }} --output {{ output }} # Materialize the Inspector decision-continuity matrix into an empty, isolated repository. [group('review-evidence')] @@ -369,7 +374,7 @@ review-decision-matrix-materialize output: if [ -n "${POINTBREAK_BINARY:-}" ]; then ./scripts/materialize-inspector-decision-matrix.sh "{{ output }}" else - cargo +stable build --bin pointbreak + {{ cargo_stable }} build --bin pointbreak POINTBREAK_BINARY="$PWD/target/debug/pointbreak" \ ./scripts/materialize-inspector-decision-matrix.sh "{{ output }}" fi @@ -382,7 +387,7 @@ review-decision-browser-verify root: if [ -n "${POINTBREAK_BINARY:-}" ]; then ./scripts/verify-inspector-decision-continuity.sh --root "{{ root }}" else - cargo +stable build --bin pointbreak + {{ cargo_stable }} build --bin pointbreak POINTBREAK_BINARY="$PWD/target/debug/pointbreak" \ ./scripts/verify-inspector-decision-continuity.sh --root "{{ root }}" fi diff --git a/flake.lock b/flake.lock index fdfbda46..82fb3623 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,26 @@ { "nodes": { + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1784795840, + "narHash": "sha256-Z5cDTnG/+k7lNMMyuNH2RDWmSuGw2d3nV9AlVrpGnNE=", + "owner": "nix-community", + "repo": "fenix", + "rev": "85164e3aaadbc35cbbb4a9fef6d358b607da09fc", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1784497964, @@ -18,8 +39,26 @@ }, "root": { "inputs": { + "fenix": "fenix", "nixpkgs": "nixpkgs" } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1784667699, + "narHash": "sha256-853teJQOgTTKRfNxcIJ53ziJOGNeg3c74PvQl5l61Jc=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "1174734d9c6453d13d2b5e3d578512c579ca37f1", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index c3d65075..4eaeb335 100644 --- a/flake.nix +++ b/flake.nix @@ -3,10 +3,14 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + fenix = { + url = "github:nix-community/fenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = - { nixpkgs, ... }: + { nixpkgs, fenix, ... }: let # Systems the dev shell is built for: Linux and macOS on x86_64 and arm64. systems = [ @@ -17,6 +21,25 @@ ]; forEachSystem = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + # Fenix provides a fixed stable compiler for normal work and only the + # nightly formatter required by rustfmt.toml's `unstable_features`. + # Combining components avoids rustup's mutable per-user toolchains. + mkRustToolchains = + pkgs: + let + fenixPkgs = fenix.packages.${pkgs.stdenv.hostPlatform.system}; + in + { + stable = fenixPkgs.stable.toolchain; + dev = fenixPkgs.combine [ + fenixPkgs.stable.cargo + fenixPkgs.stable.rustc + fenixPkgs.stable.clippy + fenixPkgs.stable.rust-src + fenixPkgs.latest.rustfmt + ]; + }; + # cocogitto pinned to 6.5.0 to match CI (.github/workflows/*: cargo binstall # cocogitto@6.5.0) and mise.toml. nixpkgs ships 7.0.0, which this repo is NOT # ready for: cog 7 changes the release tag lifecycle the signed-tag finalizer @@ -53,19 +76,17 @@ pkgs: let cocogitto = mkCocogitto pkgs; + rustToolchains = mkRustToolchains pkgs; in { default = pkgs.mkShell { # Everything on PATH inside `nix develop`. packages = with pkgs; [ # --- Rust --- - # rustup (not a fixed rustc) because the Justfile/CI use BOTH - # `cargo +stable` (build/test/clippy) and `cargo +nightly` (rustfmt, - # which relies on unstable_features). Pinning a single rustc in Nix - # can't serve both channels; rustup keeps rust-toolchain.toml as the - # source of truth for stable and installs each channel on demand (see - # RUSTUP_AUTO_INSTALL in the shellHook). - rustup + # Stable cargo/rustc/clippy plus nightly rustfmt. The direct Fenix + # cargo binary has no rustup `+toolchain` proxy; the shell selects + # direct commands for the Justfile compatibility variables below. + rustToolchains.dev # --- Dev tooling (mirrors mise.toml [tools]) --- just @@ -86,10 +107,11 @@ ]; shellHook = '' - # Let `cargo +stable` and `cargo +nightly` install their toolchain on - # first use instead of erroring. stable comes from rust-toolchain.toml; - # nightly (needed by `just fmt`) is fetched the first time it's invoked. - export RUSTUP_AUTO_INSTALL=1 + # Outside Nix, Justfile recipes keep using rustup's explicit stable + # and nightly selectors. This shell has a single Fenix toolchain, so + # both recipe classes invoke its direct cargo binary instead. + export POINTBREAK_CARGO_STABLE=cargo + export POINTBREAK_CARGO_NIGHTLY=cargo # Replicate mise's `[env] _.path`: prefer freshly-built binaries. # Guarded so re-sourcing the hook doesn't stack duplicate entries. @@ -105,12 +127,106 @@ && echo "pointbreak: installed cocogitto git hooks" fi - echo "pointbreak dev shell — rustup $(rustup --version 2>/dev/null | awk '{print $2}'), just, nextest, cog, node $(node --version)" + echo "pointbreak dev shell — $(rustc --version), $(rustfmt --version), just, nextest, cog, node $(node --version)" ''; }; } ); + # `nix build .#build-all` is the store-backed counterpart of `just build-all`: + # it builds the Inspector asset, CLI, and host-targeted VSIX without mutating + # the checkout or accessing npm during a sandboxed build. + packages = forEachSystem ( + pkgs: + let + version = "0.8.0"; + rustToolchains = mkRustToolchains pkgs; + rustPlatform = pkgs.makeRustPlatform { + cargo = rustToolchains.stable; + rustc = rustToolchains.stable; + }; + + inspector = pkgs.buildNpmPackage { + pname = "pointbreak-inspector"; + inherit version; + src = ./src/cli/inspect/web; + npmDepsHash = "sha256-5naTmTgI9JsRLe2nezLMGjkhtJmjPv/TLFNxBo0xOXU="; + buildPhase = '' + runHook preBuild + npm run build -- --outfile="$out/app.js" + runHook postBuild + ''; + installPhase = "true"; + }; + + # The Rust binary embeds the Inspector asset. Substitute the Nix-built + # bundle into a copied source tree, so its served UI is exactly the + # companion `inspector` package rather than a stale checked-in artifact. + sourceWithInspector = + pkgs.runCommand "pointbreak-source-with-inspector" { nativeBuildInputs = [ pkgs.coreutils ]; } + '' + cp -R ${./.} "$out" + chmod -R u+w "$out" + cp ${inspector}/app.js "$out/src/cli/inspect/assets/app.js" + ''; + + cli = rustPlatform.buildRustPackage { + pname = "pointbreak"; + inherit version; + src = sourceWithInspector; + cargoLock.lockFile = ./Cargo.lock; + + # Git supplies compile-time build identity and is the runtime backend. + nativeBuildInputs = [ + pkgs.git + pkgs.makeWrapper + ]; + postFixup = '' + wrapProgram "$out/bin/pointbreak" --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.git ]} + ''; + + meta.mainProgram = "pointbreak"; + }; + + vscode = pkgs.buildNpmPackage { + pname = "pointbreak-vscode"; + inherit version; + src = ./.; + sourceRoot = "source"; + npmRoot = "extensions/vscode"; + npmDepsHash = "sha256-zoXPLpbbDHgiq5lcvaVIjuKujBa6Hfay0sDbhGaKakY="; + nativeBuildInputs = [ + cli + pkgs.unzip + ]; + buildPhase = '' + runHook preBuild + cd "$npmRoot" + POINTBREAK_EXTENSION_CLEAN_VERSION=1 \ + POINTBREAK_EXTENSION_PROFILE=release \ + POINTBREAK_EXTENSION_BINARY=${cli}/bin/pointbreak \ + node scripts/package-local.mjs + runHook postBuild + ''; + installPhase = '' + install -Dm444 ../../target/vsix/*/release/*.vsix "$out/pointbreak.vsix" + ''; + }; + in + { + default = cli; + inherit cli inspector vscode; + build-all = pkgs.symlinkJoin { + name = "pointbreak-build-all-${version}"; + paths = [ + cli + inspector + vscode + ]; + }; + } + ); + # `nix flake check` builds every derivation under `checks`. This one realises # the pinned cocogitto (proving the from-source pin still compiles on a clean # machine) and asserts the version-critical tools resolve, so a broken pin or @@ -119,12 +235,14 @@ pkgs: let cocogitto = mkCocogitto pkgs; + rustToolchains = mkRustToolchains pkgs; in { devshell-tools = pkgs.runCommand "devshell-tools-check" { nativeBuildInputs = [ + rustToolchains.dev cocogitto pkgs.nodejs_22 pkgs.just @@ -132,9 +250,9 @@ ]; } '' - # rustup is intentionally excluded: it insists on a writable HOME - # to create ~/.rustup, which the build sandbox denies. Its presence - # is already covered by evaluating the devShell. + cargo --version >/dev/null + rustc --version >/dev/null + rustfmt --version | grep -q nightly cog --version | grep -qw 6.5.0 node --version | grep -q '^v22\.' just --version >/dev/null From 6e3b36363b2887f7aef1edbff94033987b4b95de Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Thu, 23 Jul 2026 15:20:08 -0700 Subject: [PATCH 02/36] fix: skip tests in nix cli build Match the build-only just build-all contract and ignore Nix result links. --- .gitignore | 1 + flake.nix | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index ca392fac..ce8580a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/result /target /extensions/vscode/out/ /extensions/vscode/bin/ diff --git a/flake.nix b/flake.nix index 4eaeb335..45d163b1 100644 --- a/flake.nix +++ b/flake.nix @@ -176,6 +176,10 @@ src = sourceWithInspector; cargoLock.lockFile = ./Cargo.lock; + # Match `just build-all`: building an artifact does not run the full + # test suite. Tests remain an explicit `just test` / CI concern. + doCheck = false; + # Git supplies compile-time build identity and is the runtime backend. nativeBuildInputs = [ pkgs.git From b61a77d17394e705c1c5e9d320aae221e35641c2 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Thu, 23 Jul 2026 15:51:56 -0700 Subject: [PATCH 03/36] fix: package vscode extension with nix Build the extension from its locked dependencies, bundle the raw CLI payload, and expose only the CLI and VSIX from the aggregate output. --- CONTRIBUTING.md | 2 +- flake.nix | 39 +++++++++++++++++++++++++++------------ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c0aed569..77180b81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ For an immutable, host-targeted equivalent of `just build-all`, use: nix build .#build-all ``` -The resulting store output contains `bin/pointbreak`, `app.js`, and `pointbreak.vsix`. +The resulting store output contains the runnable `bin/pointbreak` and installable `pointbreak.vsix`. ### mise diff --git a/flake.nix b/flake.nix index 45d163b1..cc94a8a0 100644 --- a/flake.nix +++ b/flake.nix @@ -186,7 +186,12 @@ pkgs.makeWrapper ]; postFixup = '' - wrapProgram "$out/bin/pointbreak" --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.git ]} + # Keep the Nix-facing launcher wrapped with Git on PATH while also + # exposing the real executable for the standalone VSIX payload. + mkdir -p "$out/libexec" + mv "$out/bin/pointbreak" "$out/libexec/pointbreak" + makeWrapper "$out/libexec/pointbreak" "$out/bin/pointbreak" \ + --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.git ]} ''; meta.mainProgram = "pointbreak"; @@ -196,9 +201,19 @@ pname = "pointbreak-vscode"; inherit version; src = ./.; - sourceRoot = "source"; npmRoot = "extensions/vscode"; - npmDepsHash = "sha256-zoXPLpbbDHgiq5lcvaVIjuKujBa6Hfay0sDbhGaKakY="; + # `npmRoot` scopes the build, but the dependency fetcher needs a + # source whose root contains this nested project's lockfile. + npmDeps = pkgs.fetchNpmDeps { + src = ./extensions/vscode; + hash = "sha256-zoXPLpbbDHgiq5lcvaVIjuKujBa6Hfay0sDbhGaKakY="; + }; + # Keep the packaging runtime aligned with the pinned developer Node. + # keytar 7.9.0 does not compile against Nixpkgs' default Node 24. + nodejs = pkgs.nodejs_22; + # VSCE packaging does not use keytar's credential API. Avoid rebuilding + # that optional native addon after the offline npm installation. + npmRebuildFlags = [ "--ignore-scripts" ]; nativeBuildInputs = [ cli pkgs.unzip @@ -208,7 +223,7 @@ cd "$npmRoot" POINTBREAK_EXTENSION_CLEAN_VERSION=1 \ POINTBREAK_EXTENSION_PROFILE=release \ - POINTBREAK_EXTENSION_BINARY=${cli}/bin/pointbreak \ + POINTBREAK_EXTENSION_BINARY=${cli}/libexec/pointbreak \ node scripts/package-local.mjs runHook postBuild ''; @@ -220,14 +235,14 @@ { default = cli; inherit cli inspector vscode; - build-all = pkgs.symlinkJoin { - name = "pointbreak-build-all-${version}"; - paths = [ - cli - inspector - vscode - ]; - }; + # Curate the aggregate as a delivery surface. The Inspector bundle is + # embedded in the CLI and `libexec/pointbreak` is only the VSIX input; + # both remain available from their owning package outputs. + build-all = pkgs.runCommand "pointbreak-build-all-${version}" { } '' + mkdir -p "$out/bin" + ln -s ${cli}/bin/pointbreak "$out/bin/pointbreak" + ln -s ${vscode}/pointbreak.vsix "$out/pointbreak.vsix" + ''; } ); From acbe713f733857b50cf347419ccc22c5835efb6a Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Thu, 23 Jul 2026 16:32:53 -0700 Subject: [PATCH 04/36] fix: label nix development builds Mark Git-less Nix packages with static nix-dev provenance while preserving the Cargo release version and version-document compatibility. --- build.rs | 31 ++++++++++++++++++++++++------- docs/cli-reference.md | 20 ++++++++++++-------- flake.nix | 4 ++++ tests/build_provenance.rs | 37 +++++++++++++++++++++++++++++++------ 4 files changed, 71 insertions(+), 21 deletions(-) diff --git a/build.rs b/build.rs index 4c517925..c699a2f1 100644 --- a/build.rs +++ b/build.rs @@ -14,17 +14,32 @@ pub(crate) struct DerivedIdentity { pub(crate) fn derive_identity( manifest_dir: &Path, package_version: &str, + build_channel: Option<&str>, ) -> Result { let dot_git = manifest_dir.join(".git"); let metadata = match fs::symlink_metadata(&dot_git) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(DerivedIdentity { - source: "package", - commit: None, - describe: format!("package:{package_version}"), - dirty: false, - }); + return match build_channel { + Some("nix-dev") => Ok(DerivedIdentity { + source: "package", + commit: None, + describe: format!("nix-dev:{package_version}"), + dirty: false, + }), + None => Ok(DerivedIdentity { + source: "package", + commit: None, + describe: format!("package:{package_version}"), + dirty: false, + }), + Some(channel) => Err(format!( + "unsupported POINTBREAK_BUILD_CHANNEL value {channel:?}. Git-less builds accept \ + either an unset channel (package:{package_version}) or `nix-dev` \ + (nix-dev:{package_version}). Remove POINTBREAK_BUILD_CHANNEL for a source \ + package, or set POINTBREAK_BUILD_CHANNEL=nix-dev for a Nix development package." + )), + }; } Err(error) => { return Err(format!( @@ -121,9 +136,11 @@ fn run() -> Result<(), String> { .ok_or_else(|| "Cargo did not provide CARGO_MANIFEST_DIR".to_owned())?; let package_version = env::var("CARGO_PKG_VERSION") .map_err(|_| "Cargo did not provide CARGO_PKG_VERSION".to_owned())?; - let identity = derive_identity(&manifest_dir, &package_version)?; + let build_channel = env::var("POINTBREAK_BUILD_CHANNEL").ok(); + let identity = derive_identity(&manifest_dir, &package_version, build_channel.as_deref())?; println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-env-changed=POINTBREAK_BUILD_CHANNEL"); if identity.source == "git" { emit_git_rerun_directives(&manifest_dir)?; } diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 62fc303c..3f7c41c2 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -63,8 +63,12 @@ worktree `.git` file). In that mode, `build.commit` is the full lowercase commit `build.describe` is the result of `git describe --tags --always --dirty`, and `build.dirty` reports tracked index or worktree changes at build time. Invalid or partial manifest-root Git metadata fails the build. When a source package has no manifest-root `.git`, `build.source` is `package`, `build.commit` is `null`, -`build.describe` is `package:`, and `build.dirty` is `false`. A package directory nested beneath -some other checkout does not inherit that parent's Git identity. +`build.describe` is `package:`, and `build.dirty` is `false`. Nix development packages set the +explicit `POINTBREAK_BUILD_CHANNEL=nix-dev` build input instead; they retain the compatible `package` +source and report `build.describe` as `nix-dev:`, with `build.commit=null` and +`build.dirty=false`. The static Nix channel marks an unreleased package build without guessing a future +semantic version or depending on checkout metadata. A package directory nested beneath some other checkout +does not inherit that parent's Git identity. JSON is authoritative for the full identity. A clean exact-tag binary has the exact tag in `build.describe`, its peeled full commit in `build.commit`, and `build.dirty=false`; semantic version alone @@ -120,7 +124,7 @@ tests/CI). **Signing never gates a write** (with one exception, below): any reso an unreadable key home, an unsupported algorithm, a malformed configured key, `POINTBREAK_SIGNING=off`) degrades to an unsigned write at exit 0 with a one-line advisory diagnostic on stderr — it never blocks. The sole exception is `pointbreak endorse` (below), where unsigned is a hard error because the -signature *is* the endorsement's content. See +signature _is_ the endorsement's content. See [signing-ux.md](./signing-ux.md) for the human / agent / CI flows and the `unsigned → untrusted_key → valid` ladder. @@ -161,7 +165,7 @@ revision recorded; its subject is always the captured snapshot, never the live w - `--color ` controls ANSI syntax coloring of the diff body. `auto` (the default) colorizes only when stdout is a TTY, honoring `NO_COLOR` and `CLICOLOR_FORCE` (precedence: `--color` > `NO_COLOR` > `CLICOLOR_FORCE` > isatty); piped or redirected output stays plain. Color is pure - presentation — stripping the ANSI reproduces the plain diff exactly. + > presentation — stripping the ANSI reproduces the plain diff exactly. - `--theme ` picks the truecolor palette: `auto` (the default) detects the terminal background — light or dark — and selects the matching built-in palette; `light` / `dark` force a built-in; any other value names a bundled syntax theme, matched case-insensitively (bat's @@ -495,7 +499,7 @@ when a repo's own test fixtures carry scanner-triggering strings: The two files merge by **union** (committed order first, then novel local entries) — deliberately diverging from the local-replaces-committed rule `store.json`/`delegates.json` use, because this is -a *list*: replace would force copying the whole committed list to add one local entry, union grants +a _list_: replace would force copying the whole committed list to add one local entry, union grants nothing replace couldn't, and the audit counts make any widening visible. Default is empty (scan everything; opt-in only). @@ -515,7 +519,7 @@ ran (absent under `--include-ephemeral`, which skips the scan). Excluded paths t listed — the scan's redacted `file:sha256:*` posture stands. The gate behavior is unchanged: a `block` finding outside the excludes still refuses without `--include-ephemeral`. -**Seeing which files matched (`pointbreak store status --show-paths`).** A finding names only its *kind* +**Seeing which files matched (`pointbreak store status --show-paths`).** A finding names only its _kind_ and a redacted `file:sha256:*` reference, so on its own it does not tell you which file to exclude. `--show-paths` closes that loop: it re-runs the same scan (the same matchers, the same exclude globs) locally and lists the real matched worktree paths grouped by finding kind, so you can author @@ -623,7 +627,7 @@ commands, and `pointbreak history` omit the body text and carry a `bodyContentSt `summaryContentState` / `reasonContentState` field beside the content hash — `suppressed_present` while the bytes are still stored (a compact would reclaim them) or `physically_removed` after the sweep — plus `body_content_suppressed_present` / `body_content_physically_removed` diagnostics. -The field is omitted entirely while content is present, and a body that is missing *without* a +The field is omitted entirely while content is present, and a body that is missing _without_ a recorded removal still fails the read with the `import referenced artifacts` guidance. Command output is the machine-integration surface, under the tiered stability promise described at @@ -965,7 +969,7 @@ signer and the carrier's envelope writer is the **endorser's own actor** (`--act writing identity), never the target's author. - **Unsigned is a hard error.** Unlike every other write — where signing never gates — an endorsement - has no unsigned form, because the signature *is* its content. The signer is resolved first (before the + has no unsigned form, because the signature _is_ its content. The signer is resolved first (before the target); if none resolves (`POINTBREAK_SIGNING=off`, no key, an unreadable key), the command exits non-zero and writes nothing. Signer precedence otherwise follows the **Signing** rules above. - Idempotent: re-endorsing the same target with the same signer is a no-op (`eventsCreated: 0`, diff --git a/flake.nix b/flake.nix index cc94a8a0..4e6034c8 100644 --- a/flake.nix +++ b/flake.nix @@ -175,6 +175,10 @@ inherit version; src = sourceWithInspector; cargoLock.lockFile = ./Cargo.lock; + # This is a reproducible development package, not the release tagged + # by GitHub Actions. Preserve the Cargo/crates.io version as the base + # while marking its provenance as `nix-dev:`. + env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; # Match `just build-all`: building an artifact does not run the full # test suite. Tests remain an explicit `just test` / CI concern. diff --git a/tests/build_provenance.rs b/tests/build_provenance.rs index 45ca6da1..4f164146 100644 --- a/tests/build_provenance.rs +++ b/tests/build_provenance.rs @@ -41,7 +41,7 @@ fn exact_tag_is_clean_git_identity_with_full_commit() { let repo = GitFixture::new(); run_git(repo.path(), &["tag", "v1.2.3"]); - let identity = build_script::derive_identity(repo.path(), "1.2.3").unwrap(); + let identity = build_script::derive_identity(repo.path(), "1.2.3", None).unwrap(); let head = repo.head(); assert_eq!(identity.source, "git"); @@ -70,7 +70,7 @@ fn post_tag_linked_worktree_reports_distance_hash_and_full_head() { ], ); - let identity = build_script::derive_identity(&worktree, "1.2.3").unwrap(); + let identity = build_script::derive_identity(&worktree, "1.2.3", None).unwrap(); let head = repo.head(); assert!(worktree.join(".git").is_file()); @@ -90,12 +90,12 @@ fn tracked_and_index_changes_cannot_claim_a_clean_tag() { run_git(repo.path(), &["tag", "v1.2.3"]); fs::write(repo.path().join("tracked.txt"), "dirty\n").expect("dirty tracked file"); - let worktree_dirty = build_script::derive_identity(repo.path(), "1.2.3").unwrap(); + let worktree_dirty = build_script::derive_identity(repo.path(), "1.2.3", None).unwrap(); assert!(worktree_dirty.dirty); assert_eq!(worktree_dirty.describe, "v1.2.3-dirty"); run_git(repo.path(), &["add", "tracked.txt"]); - let index_dirty = build_script::derive_identity(repo.path(), "1.2.3").unwrap(); + let index_dirty = build_script::derive_identity(repo.path(), "1.2.3", None).unwrap(); assert!(index_dirty.dirty); assert_eq!(index_dirty.describe, "v1.2.3-dirty"); } @@ -106,7 +106,7 @@ fn package_root_does_not_inherit_parent_checkout_metadata() { let package_root = parent.path().join("target/package/pointbreak-1.2.3"); fs::create_dir_all(&package_root).expect("create package root"); - let identity = build_script::derive_identity(&package_root, "1.2.3").unwrap(); + let identity = build_script::derive_identity(&package_root, "1.2.3", None).unwrap(); assert_eq!(identity.source, "package"); assert_eq!(identity.commit, None); @@ -114,12 +114,37 @@ fn package_root_does_not_inherit_parent_checkout_metadata() { assert!(!identity.dirty); } +#[test] +fn nix_dev_channel_marks_a_gitless_build_without_changing_the_package_version() { + let root = tempfile::tempdir().expect("create nix fixture"); + + let identity = build_script::derive_identity(root.path(), "1.2.3", Some("nix-dev")).unwrap(); + + assert_eq!(identity.source, "package"); + assert_eq!(identity.commit, None); + assert_eq!(identity.describe, "nix-dev:1.2.3"); + assert!(!identity.dirty); +} + +#[test] +fn unknown_gitless_build_channel_is_rejected() { + let root = tempfile::tempdir().expect("create package fixture"); + + let error = + build_script::derive_identity(root.path(), "1.2.3", Some("unsupported")).unwrap_err(); + + assert_eq!( + error, + "unsupported POINTBREAK_BUILD_CHANNEL value \"unsupported\". Git-less builds accept either an unset channel (package:1.2.3) or `nix-dev` (nix-dev:1.2.3). Remove POINTBREAK_BUILD_CHANNEL for a source package, or set POINTBREAK_BUILD_CHANNEL=nix-dev for a Nix development package." + ); +} + #[test] fn manifest_root_git_metadata_is_fail_closed_when_malformed() { let root = tempfile::tempdir().expect("create malformed fixture"); fs::create_dir(root.path().join(".git")).expect("create partial git metadata"); - let error = build_script::derive_identity(root.path(), "1.2.3").unwrap_err(); + let error = build_script::derive_identity(root.path(), "1.2.3", None).unwrap_err(); assert!(error.contains("Git metadata"), "unexpected error: {error}"); } From d3c6e613f08de2df65e861a7ee8fe5ad81eb7f8f Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Thu, 23 Jul 2026 18:10:15 -0700 Subject: [PATCH 05/36] fix: run nix package checks Keep Nix package tests enabled by making their Git-less fixtures and check-only tools explicit. Enforce the history cursor precondition in release builds so package checks use the normal release profile. --- examples/support/review_example_pack.rs | 14 +++++++ flake.nix | 10 +++-- src/bench_support/foundation/performance.rs | 42 +++++++++++++-------- src/session/workflow/history/query.rs | 2 +- tests/docs_package_identity.rs | 2 +- 5 files changed, 48 insertions(+), 22 deletions(-) diff --git a/examples/support/review_example_pack.rs b/examples/support/review_example_pack.rs index aa120bec..79bcb84e 100644 --- a/examples/support/review_example_pack.rs +++ b/examples/support/review_example_pack.rs @@ -14,6 +14,7 @@ use pointbreak::session::{ }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use tempfile::tempdir; pub type PackResult = Result>; @@ -258,7 +259,20 @@ pub fn verify_pack(pack: &Path) -> PackResult<()> { &manifest.documents.revision.sha256, )?; verify_documents(pack, &manifest)?; + // Verify against an empty repository so pack validation does not depend on + // the caller running from a checkout that happens to contain prerequisites. + let bundle_verify_repo = tempdir()?; + let bundle_repo_init = Command::new("git") + .args(["init", "--bare", "--initial-branch=main"]) + .arg(bundle_verify_repo.path()) + .output()?; + require( + bundle_repo_init.status.success(), + "source.bundle verify repository", + )?; let bundle_verify = Command::new("git") + .arg("-C") + .arg(bundle_verify_repo.path()) .args(["bundle", "verify"]) .arg(pack.join(&manifest.source.bundle_path)) .output()?; diff --git a/flake.nix b/flake.nix index 4e6034c8..f118400b 100644 --- a/flake.nix +++ b/flake.nix @@ -180,15 +180,17 @@ # while marking its provenance as `nix-dev:`. env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; - # Match `just build-all`: building an artifact does not run the full - # test suite. Tests remain an explicit `just test` / CI concern. - doCheck = false; - # Git supplies compile-time build identity and is the runtime backend. nativeBuildInputs = [ pkgs.git pkgs.makeWrapper ]; + # Integration tests invoke project scripts that require these tools; + # they are not part of the delivered CLI's runtime closure. + nativeCheckInputs = [ + pkgs.jq + pkgs.nodejs_22 + ]; postFixup = '' # Keep the Nix-facing launcher wrapped with Git on PATH while also # exposing the real executable for the standalone VSIX payload. diff --git a/src/bench_support/foundation/performance.rs b/src/bench_support/foundation/performance.rs index ae1840aa..61f6a372 100644 --- a/src/bench_support/foundation/performance.rs +++ b/src/bench_support/foundation/performance.rs @@ -32,7 +32,7 @@ use super::{ modeled_post_foundation_manifest, qualification_cargo_lock_sha256, qualification_filesystem_name, qualification_generated_manifest_v1, qualification_generator_spec_v1, qualification_operation_schedule_v1, - qualification_source_commit, synthetic_legacy_manifest, + synthetic_legacy_manifest, }; use crate::canonical_hash::{canonical_json_bytes, sha256_bytes_hex}; @@ -91,6 +91,19 @@ const QUALIFICATION_LOOSE_BASELINE_WARMUP_ITERATIONS_V1: u32 = 3; const QUALIFICATION_LOOSE_BASELINE_MEASURED_ITERATIONS_V1: u32 = 30; const QUALIFICATION_LOOSE_BASELINE_INDEPENDENT_ROOTS_V1: u32 = 2; +#[cfg(test)] +const PERFORMANCE_TEST_SOURCE_COMMIT: &str = "cccccccccccccccccccccccccccccccccccccccc"; + +fn expected_qualification_source_commit() -> Result { + #[cfg(test)] + { + return Ok(PERFORMANCE_TEST_SOURCE_COMMIT.to_owned()); + } + + #[cfg(not(test))] + super::qualification_source_commit() +} + #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum QualificationLooseBaselineOperationV1 { @@ -261,7 +274,7 @@ impl QualificationLooseBaselineEvidenceV1 { pub fn validate(&self) -> Result<(), String> { if self.schema != QUALIFICATION_LOOSE_BASELINE_EVIDENCE_SCHEMA_V1 - || self.source_commit != qualification_source_commit()? + || self.source_commit != expected_qualification_source_commit()? || self.cargo_lock_sha256 != qualification_cargo_lock_sha256() || self.generator_schema != QUALIFICATION_GENERATOR_SCHEMA_V1 || self.public_seed_hex != QUALIFICATION_PUBLIC_SEED_HEX_V1 @@ -861,7 +874,8 @@ impl QualificationLooseBaselineEvidenceV1 { .collect(); let mut evidence = Self { schema: QUALIFICATION_LOOSE_BASELINE_EVIDENCE_SCHEMA_V1.to_owned(), - source_commit: qualification_source_commit().expect("test build source commit"), + source_commit: expected_qualification_source_commit() + .expect("test build source commit"), cargo_lock_sha256: qualification_cargo_lock_sha256(), generator_schema: QUALIFICATION_GENERATOR_SCHEMA_V1.to_owned(), public_seed_hex: QUALIFICATION_PUBLIC_SEED_HEX_V1.to_owned(), @@ -2744,7 +2758,7 @@ pub fn qualification_lmdb_prospective_execution_v1() return Err("LMDB prospective runner requires a clean Git build".to_owned()); } let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let source_commit = qualification_source_commit()?; + let source_commit = super::qualification_source_commit()?; let live_commit = git_identity_stdout_v1(manifest_dir, &["rev-parse", "--verify", "HEAD^{commit}"])?; let source_tree = @@ -3811,7 +3825,7 @@ impl QualificationPerformanceEvidenceV2 { { return Err("performance evidence uses a different contract".to_owned()); } - if self.source_commit != qualification_source_commit()? { + if self.source_commit != expected_qualification_source_commit()? { return Err("performance evidence source commit is stale".to_owned()); } if self.cargo_lock_sha256 != qualification_cargo_lock_sha256() { @@ -4270,7 +4284,7 @@ fn validate_loose_baseline_evidence_configuration_v1( .root .parent() .is_none_or(|parent| !parent.is_dir()) - || configuration.source_commit != qualification_source_commit()? + || configuration.source_commit != expected_qualification_source_commit()? || configuration.cargo_lock_sha256 != qualification_cargo_lock_sha256() || !configuration.quiesced_host || env!("POINTBREAK_BUILD_DIRTY") == "true" @@ -6422,7 +6436,7 @@ pub fn validate_diagnostic_configuration( { return Err("performance diagnostics root must be a fresh path".to_owned()); } - if configuration.source_commit != qualification_source_commit()? { + if configuration.source_commit != expected_qualification_source_commit()? { return Err("performance diagnostics source commit is stale".to_owned()); } if configuration.cargo_lock_sha256 != qualification_cargo_lock_sha256() { @@ -6546,7 +6560,7 @@ fn validate_campaign_configuration( .root .parent() .is_none_or(|parent| !parent.is_dir()) - || configuration.source_commit != qualification_source_commit()? + || configuration.source_commit != expected_qualification_source_commit()? || configuration.cargo_lock_sha256 != qualification_cargo_lock_sha256() || !configuration.quiesced_host || env!("POINTBREAK_BUILD_DIRTY") == "true" @@ -8270,8 +8284,7 @@ mod tests { let mut configuration = QualificationPerformanceDiagnosticConfigurationV1 { executable: std::env::current_exe().expect("test executable"), root: root.clone(), - source_commit: crate::bench_support::foundation::qualification_source_commit() - .expect("build commit"), + source_commit: expected_qualification_source_commit().expect("build commit"), cargo_lock_sha256: crate::bench_support::foundation::qualification_cargo_lock_sha256(), warmup_samples: 0, measured_samples: 1, @@ -8287,8 +8300,7 @@ mod tests { assert!(validate_diagnostic_configuration(&configuration).is_err()); assert!(!root.exists()); - configuration.source_commit = - crate::bench_support::foundation::qualification_source_commit().expect("build commit"); + configuration.source_commit = expected_qualification_source_commit().expect("build commit"); std::fs::create_dir(&root).expect("pre-existing root"); assert!(validate_diagnostic_configuration(&configuration).is_err()); } @@ -8476,8 +8488,7 @@ mod tests { let configuration = QualificationPerformanceDiagnosticConfigurationV1 { executable: std::env::current_exe().expect("test executable"), root: parent.path().join("diagnostics"), - source_commit: crate::bench_support::foundation::qualification_source_commit() - .expect("build commit"), + source_commit: expected_qualification_source_commit().expect("build commit"), cargo_lock_sha256: crate::bench_support::foundation::qualification_cargo_lock_sha256(), warmup_samples: 1, measured_samples: 2, @@ -8515,8 +8526,7 @@ mod tests { baseline_allocated: u64, ) -> QualificationPerformanceEvidenceV2 { let contract = QualificationPerformanceContractV2::frozen(); - let source_commit = crate::bench_support::foundation::qualification_source_commit() - .expect("build source commit"); + let source_commit = expected_qualification_source_commit().expect("build source commit"); let cargo_lock_sha256 = crate::bench_support::foundation::qualification_cargo_lock_sha256(); let mut runs = Vec::new(); diff --git a/src/session/workflow/history/query.rs b/src/session/workflow/history/query.rs index 6586ec0f..8ed770d8 100644 --- a/src/session/workflow/history/query.rs +++ b/src/session/workflow/history/query.rs @@ -106,7 +106,7 @@ pub fn apply_history_query( query: &HistoryQuery, page: &HistoryPage, ) -> QueriedHistory { - debug_assert!( + assert!( !(matches!(query.order, HistoryOrder::Desc) && page.after.is_some()), "history cursor windowing requires ascending order" ); diff --git a/tests/docs_package_identity.rs b/tests/docs_package_identity.rs index af783397..e634ded3 100644 --- a/tests/docs_package_identity.rs +++ b/tests/docs_package_identity.rs @@ -454,5 +454,5 @@ fn readme_drops_branded_hunk_origin_references() { fn just_run_targets_the_pointbreak_binary() { let justfile = std::fs::read_to_string("Justfile").expect("read Justfile"); - assert!(justfile.contains("cargo +stable run --bin pointbreak --")); + assert!(justfile.contains("{{ cargo_stable }} run --bin pointbreak -- {{ args }}")); } From 3c2b9c9b772ef05f948e5bea4edaddae49714daa Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Thu, 23 Jul 2026 18:15:16 -0700 Subject: [PATCH 06/36] fix: satisfy nix check lint Define the test and production provenance helpers under separate cfg gates so Clippy does not see an unneeded return in test builds. --- src/bench_support/foundation/performance.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/bench_support/foundation/performance.rs b/src/bench_support/foundation/performance.rs index 61f6a372..5d058b92 100644 --- a/src/bench_support/foundation/performance.rs +++ b/src/bench_support/foundation/performance.rs @@ -94,13 +94,13 @@ const QUALIFICATION_LOOSE_BASELINE_INDEPENDENT_ROOTS_V1: u32 = 2; #[cfg(test)] const PERFORMANCE_TEST_SOURCE_COMMIT: &str = "cccccccccccccccccccccccccccccccccccccccc"; +#[cfg(test)] fn expected_qualification_source_commit() -> Result { - #[cfg(test)] - { - return Ok(PERFORMANCE_TEST_SOURCE_COMMIT.to_owned()); - } + Ok(PERFORMANCE_TEST_SOURCE_COMMIT.to_owned()) +} - #[cfg(not(test))] +#[cfg(not(test))] +fn expected_qualification_source_commit() -> Result { super::qualification_source_commit() } From 881a7f8c5115e49517a3d7bfdcc4a1200a5cf8dd Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Thu, 23 Jul 2026 18:32:41 -0700 Subject: [PATCH 07/36] test: run nix checks with nextest Use Nixpkgs' Nextest hook to parallelize package test binaries while retaining the sandboxed release-profile check. --- flake.nix | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flake.nix b/flake.nix index f118400b..aee45593 100644 --- a/flake.nix +++ b/flake.nix @@ -179,6 +179,9 @@ # by GitHub Actions. Preserve the Cargo/crates.io version as the base # while marking its provenance as `nix-dev:`. env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; + # Match `just test`: Nextest runs independent test binaries in + # parallel while retaining Nix's sandboxed release-profile check. + useNextest = true; # Git supplies compile-time build identity and is the runtime backend. nativeBuildInputs = [ From d6ac629e0f5cedc7d5eef1f9b2cb1918fcec6057 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Thu, 23 Jul 2026 19:16:50 -0700 Subject: [PATCH 08/36] build: cache nix rust dependencies with crane Separate the fast CLI delivery package from the Git-less Nextest flake check while reusing a Crane dependency artifact across source changes. --- flake.lock | 17 ++++++++++++ flake.nix | 79 +++++++++++++++++++++++++++++++++++------------------- 2 files changed, 69 insertions(+), 27 deletions(-) diff --git a/flake.lock b/flake.lock index 82fb3623..082ed8dc 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,21 @@ { "nodes": { + "crane": { + "locked": { + "lastModified": 1779041105, + "narHash": "sha256-nnGD2f8OlAZT2i5OfwikJsw+ifWfiA4d6A8BWlgOXV0=", + "owner": "ipetkov", + "repo": "crane", + "rev": "10e6e3cb966f7cfcc789fe5eee7a85f3188ce08b", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "ref": "v0.23.4", + "repo": "crane", + "type": "github" + } + }, "fenix": { "inputs": { "nixpkgs": [ @@ -39,6 +55,7 @@ }, "root": { "inputs": { + "crane": "crane", "fenix": "fenix", "nixpkgs": "nixpkgs" } diff --git a/flake.nix b/flake.nix index aee45593..b7a14d66 100644 --- a/flake.nix +++ b/flake.nix @@ -7,10 +7,19 @@ url = "github:nix-community/fenix"; inputs.nixpkgs.follows = "nixpkgs"; }; + # Kept as a parallel packaging experiment until its dependency-artifact + # cache proves a meaningful win over buildRustPackage for Pointbreak. + crane.url = "github:ipetkov/crane/v0.23.4"; }; outputs = - { nixpkgs, fenix, ... }: + { + self, + nixpkgs, + fenix, + crane, + ... + }: let # Systems the dev shell is built for: Linux and macOS on x86_64 and arm64. systems = [ @@ -141,10 +150,7 @@ let version = "0.8.0"; rustToolchains = mkRustToolchains pkgs; - rustPlatform = pkgs.makeRustPlatform { - cargo = rustToolchains.stable; - rustc = rustToolchains.stable; - }; + craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchains.stable; inspector = pkgs.buildNpmPackage { pname = "pointbreak-inspector"; @@ -170,42 +176,59 @@ cp ${inspector}/app.js "$out/src/cli/inspect/assets/app.js" ''; - cli = rustPlatform.buildRustPackage { + # Crane's dummy source isolates this derivation from ordinary Rust + # source edits, so the delivery package and test check reuse dependency + # and dev-dependency artifacts across Nix builds. + cargoArtifacts = craneLib.buildDepsOnly { pname = "pointbreak"; inherit version; - src = sourceWithInspector; - cargoLock.lockFile = ./Cargo.lock; - # This is a reproducible development package, not the release tagged - # by GitHub Actions. Preserve the Cargo/crates.io version as the base - # while marking its provenance as `nix-dev:`. + src = craneLib.cleanCargoSource sourceWithInspector; + cargoLock = ./Cargo.lock; env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; - # Match `just test`: Nextest runs independent test binaries in - # parallel while retaining Nix's sandboxed release-profile check. - useNextest = true; + }; - # Git supplies compile-time build identity and is the runtime backend. + # Build the distributable artifact without coupling ordinary consumers + # to the complete repository test suite. `cliNextest` below is exposed + # through `nix flake check` as the full Git-less quality gate. + cli = craneLib.buildPackage { + pname = "pointbreak"; + inherit version; + src = sourceWithInspector; + cargoLock = ./Cargo.lock; + inherit cargoArtifacts; + doCheck = false; + env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; nativeBuildInputs = [ pkgs.git pkgs.makeWrapper ]; - # Integration tests invoke project scripts that require these tools; - # they are not part of the delivered CLI's runtime closure. - nativeCheckInputs = [ - pkgs.jq - pkgs.nodejs_22 - ]; postFixup = '' - # Keep the Nix-facing launcher wrapped with Git on PATH while also - # exposing the real executable for the standalone VSIX payload. mkdir -p "$out/libexec" mv "$out/bin/pointbreak" "$out/libexec/pointbreak" makeWrapper "$out/libexec/pointbreak" "$out/bin/pointbreak" \ --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.git ]} ''; - meta.mainProgram = "pointbreak"; }; + # This complete Git-less integration suite is a flake check rather + # than a dependency of the delivery artifact above. + cliNextest = craneLib.cargoNextest { + pname = "pointbreak"; + inherit version; + src = sourceWithInspector; + cargoLock = ./Cargo.lock; + inherit cargoArtifacts; + doInstallCargoArtifacts = false; + cargoNextestExtraArgs = "--no-tests pass"; + env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; + nativeBuildInputs = [ + pkgs.git + pkgs.jq + pkgs.nodejs_22 + ]; + }; + vscode = pkgs.buildNpmPackage { pname = "pointbreak-vscode"; inherit version; @@ -244,6 +267,7 @@ { default = cli; inherit cli inspector vscode; + cli-nextest = cliNextest; # Curate the aggregate as a delivery surface. The Inspector bundle is # embedded in the CLI and `libexec/pointbreak` is only the VSIX input; # both remain available from their owning package outputs. @@ -255,10 +279,10 @@ } ); - # `nix flake check` builds every derivation under `checks`. This one realises + # `nix flake check` runs the complete Git-less Nextest suite and realises # the pinned cocogitto (proving the from-source pin still compiles on a clean - # machine) and asserts the version-critical tools resolve, so a broken pin or - # version drift fails the flake rather than only surfacing in a live shell. + # machine). This keeps package consumers on the fast delivery path while + # making test/tool version drift fail the flake. checks = forEachSystem ( pkgs: let @@ -266,6 +290,7 @@ rustToolchains = mkRustToolchains pkgs; in { + cli-nextest = self.packages.${pkgs.stdenv.hostPlatform.system}.cli-nextest; devshell-tools = pkgs.runCommand "devshell-tools-check" { From 45bbee3b9daa2e7e167e0db7bf41c18296d996f5 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 01:36:38 -0700 Subject: [PATCH 09/36] ci: add parallel Nix-driven CI workflow for Linux and macOS Add ci-nix.yml as an experimental variant that runs alongside ci.yml without replacing it, while we evaluate a hermetic, store-cached gate. Each matrix leg (ubuntu, macos) runs `nix flake check` (the cli-nextest suite plus the devshell-tools drift check), `nix build .#build-all` (the store-backed CLI + Inspector + VSIX aggregate), and, on Linux only, the fmt + clippy gate via `nix develop -c just lint`. The Nix store is cached through GitHub's own Actions cache so crane's dependency artifacts are not recompiled every run. Windows stays in ci.yml: Nix has no native Windows support. --- .github/workflows/ci-nix.yml | 78 ++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/ci-nix.yml diff --git a/.github/workflows/ci-nix.yml b/.github/workflows/ci-nix.yml new file mode 100644 index 00000000..4631bd57 --- /dev/null +++ b/.github/workflows/ci-nix.yml @@ -0,0 +1,78 @@ +name: CI (Nix) + +# Experimental, parallel Nix-driven variant of ci.yml. It does NOT replace ci.yml; +# the two run side by side while we evaluate whether a hermetic, store-cached gate +# is worth adopting. Linux and macOS only: Nix has no native Windows support, so the +# Windows legs stay in ci.yml. See the PR discussion for the Windows cross-compile +# sketch (nextest archive built on Linux, executed on a plain Windows runner). +# +# The flake already carries the gate: +# - `nix flake check` -> checks.cli-nextest (full Git-less nextest suite) + +# checks.devshell-tools (tool-version drift). cli-nextest +# compiles the whole crate and all test targets, so it covers +# the same cfg/feature arms ci.yml's macOS `check-types` leg does. +# - `nix develop -c just lint` -> the exact fmt + clippy gate ci.yml runs on Linux, +# via the flake's pinned Fenix toolchain (stable clippy + +# nightly rustfmt) instead of rustup. +# - `nix build .#build-all` -> the store-backed counterpart of `just build-all` +# (CLI + embedded Inspector bundle + platform VSIX), with no +# checkout mutation and no npm network access. Because the +# Inspector bundle is rebuilt here, this sidesteps ci.yml's +# `assets/app.js` staleness gate entirely. + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-nix-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + nix: + name: nix gate (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + + # Persist /nix/store across runs through GitHub's own Actions cache — no + # external account (Cachix / FlakeHub) required. Without this, every run + # recompiles crane's dependency artifacts from source. Keyed on the inputs + # that actually invalidate the store; the prefix restore warms partial hits. + - name: Cache Nix store + uses: nix-community/cache-nix-action@v6 + with: + primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', 'Cargo.lock', 'src/cli/inspect/web/package-lock.json', 'extensions/vscode/package-lock.json') }} + restore-prefixes-first-match: nix-${{ runner.os }}- + + # cli-nextest (full Git-less suite) + devshell-tools (pinned-tool drift). + # -L streams full build logs so a failure is legible in the Actions UI. + - name: nix flake check + run: nix flake check -L + + # Same Rust lint gate as ci.yml's Linux leg, through the flake's toolchain. + # Linux only, mirroring ci.yml (macOS coverage of the cfg arms comes from the + # cli-nextest build above). + - name: just lint + if: runner.os == 'Linux' + run: nix develop --command just lint + + # Hermetic, store-backed artifact build. Warm in the local store after the + # flake check above, so this is a near-instant realisation of the aggregate. + - name: nix build .#build-all + run: nix build .#build-all -L From 4fc9874cd57de4ca4a0f7f3985d4489bae51abf4 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 01:50:51 -0700 Subject: [PATCH 10/36] build: run clippy and rustfmt checks through the flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add checks.clippy (cargoClippy, -D warnings) and checks.fmt (cargoFmt with the nightly formatter) so `nix flake check` is the full Rust gate — fmt, clippy, and the Git-less nextest suite — rather than tests only. clippy reuses the shared crane cargoArtifacts, and a dev-toolchain craneLib gives cargoFmt the nightly rustfmt that rustfmt.toml's unstable options require. Drop the now-redundant `nix develop -c just lint` step from ci-nix.yml; the flake check covers fmt and clippy directly. --- .github/workflows/ci-nix.yml | 27 ++++++++++-------------- flake.nix | 41 ++++++++++++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci-nix.yml b/.github/workflows/ci-nix.yml index 4631bd57..4cce00e2 100644 --- a/.github/workflows/ci-nix.yml +++ b/.github/workflows/ci-nix.yml @@ -6,14 +6,16 @@ name: CI (Nix) # Windows legs stay in ci.yml. See the PR discussion for the Windows cross-compile # sketch (nextest archive built on Linux, executed on a plain Windows runner). # -# The flake already carries the gate: -# - `nix flake check` -> checks.cli-nextest (full Git-less nextest suite) + -# checks.devshell-tools (tool-version drift). cli-nextest -# compiles the whole crate and all test targets, so it covers -# the same cfg/feature arms ci.yml's macOS `check-types` leg does. -# - `nix develop -c just lint` -> the exact fmt + clippy gate ci.yml runs on Linux, -# via the flake's pinned Fenix toolchain (stable clippy + -# nightly rustfmt) instead of rustup. +# The flake already carries the full gate: +# - `nix flake check` -> the same fmt + clippy + test gate ci.yml runs, through the +# flake's pinned Fenix toolchain instead of rustup: +# checks.cli-nextest (full Git-less nextest suite), +# checks.clippy (`-D warnings`) and checks.fmt (nightly +# rustfmt) — clippy and the tests share crane's dependency +# artifacts — plus checks.devshell-tools (tool-version drift). +# cli-nextest compiles the whole crate and all test targets, so +# it covers the same cfg/feature arms ci.yml's macOS +# `check-types` leg does. # - `nix build .#build-all` -> the store-backed counterpart of `just build-all` # (CLI + embedded Inspector bundle + platform VSIX), with no # checkout mutation and no npm network access. Because the @@ -60,18 +62,11 @@ jobs: primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', 'Cargo.lock', 'src/cli/inspect/web/package-lock.json', 'extensions/vscode/package-lock.json') }} restore-prefixes-first-match: nix-${{ runner.os }}- - # cli-nextest (full Git-less suite) + devshell-tools (pinned-tool drift). + # fmt + clippy + cli-nextest (full Git-less suite) + devshell-tools drift. # -L streams full build logs so a failure is legible in the Actions UI. - name: nix flake check run: nix flake check -L - # Same Rust lint gate as ci.yml's Linux leg, through the flake's toolchain. - # Linux only, mirroring ci.yml (macOS coverage of the cfg arms comes from the - # cli-nextest build above). - - name: just lint - if: runner.os == 'Linux' - run: nix develop --command just lint - # Hermetic, store-backed artifact build. Warm in the local store after the # flake check above, so this is a near-instant realisation of the aggregate. - name: nix build .#build-all diff --git a/flake.nix b/flake.nix index b7a14d66..bb0fb73c 100644 --- a/flake.nix +++ b/flake.nix @@ -151,6 +151,10 @@ version = "0.8.0"; rustToolchains = mkRustToolchains pkgs; craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchains.stable; + # rustfmt.toml enables unstable options, so the format check needs the + # combined dev toolchain (stable cargo, nightly rustfmt) rather than the + # stable-only toolchain the compile/lint derivations build against. + craneLibDev = (crane.mkLib pkgs).overrideToolchain rustToolchains.dev; inspector = pkgs.buildNpmPackage { pname = "pointbreak-inspector"; @@ -229,6 +233,29 @@ ]; }; + # Hermetic clippy gate reusing the shared dependency artifacts. Mirrors + # `just lint`'s clippy invocation so the flake check and the Justfile gate + # stay in lockstep; `-D warnings` makes any lint fail the check. + cliClippy = craneLib.cargoClippy { + pname = "pointbreak"; + inherit version; + src = sourceWithInspector; + cargoLock = ./Cargo.lock; + inherit cargoArtifacts; + env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; + cargoClippyExtraArgs = "--workspace --all-targets --all-features -- -D warnings"; + # Build scripts run under clippy; build.rs shells out to git. + nativeBuildInputs = [ pkgs.git ]; + }; + + # Format check with the pinned nightly rustfmt. Compiles nothing, so it + # needs neither the dependency artifacts nor git — only the cleaned source. + cliFmt = craneLibDev.cargoFmt { + pname = "pointbreak"; + inherit version; + src = craneLib.cleanCargoSource sourceWithInspector; + }; + vscode = pkgs.buildNpmPackage { pname = "pointbreak-vscode"; inherit version; @@ -268,6 +295,8 @@ default = cli; inherit cli inspector vscode; cli-nextest = cliNextest; + cli-clippy = cliClippy; + cli-fmt = cliFmt; # Curate the aggregate as a delivery surface. The Inspector bundle is # embedded in the CLI and `libexec/pointbreak` is only the VSIX input; # both remain available from their owning package outputs. @@ -279,10 +308,12 @@ } ); - # `nix flake check` runs the complete Git-less Nextest suite and realises - # the pinned cocogitto (proving the from-source pin still compiles on a clean - # machine). This keeps package consumers on the fast delivery path while - # making test/tool version drift fail the flake. + # `nix flake check` runs the full Rust gate hermetically: the complete + # Git-less Nextest suite, clippy (`-D warnings`), and the nightly rustfmt + # format check — clippy and the tests share the crane dependency artifacts. + # It also realises the pinned cocogitto (proving the from-source pin still + # compiles on a clean machine). This keeps package consumers on the fast + # delivery path while making test, lint, format, or tool drift fail the flake. checks = forEachSystem ( pkgs: let @@ -291,6 +322,8 @@ in { cli-nextest = self.packages.${pkgs.stdenv.hostPlatform.system}.cli-nextest; + clippy = self.packages.${pkgs.stdenv.hostPlatform.system}.cli-clippy; + fmt = self.packages.${pkgs.stdenv.hostPlatform.system}.cli-fmt; devshell-tools = pkgs.runCommand "devshell-tools-check" { From f5ae7cb337ae92578b8e01aa61331e6a2efc5310 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 02:53:16 -0700 Subject: [PATCH 11/36] ci: add experimental Windows-msvc cross-compile nextest spike Explore cross-compiling the test suite to Windows msvc on Linux/macOS via Nix and running the prebuilt cargo-nextest archive on a real Windows machine that needs no Rust build step. Add a windows-cross dev shell (Fenix stable host toolchain plus the msvc std for both shipped Windows targets, cargo-xwin, and the clang-cl / lld-link / llvm-lib cross tools), a windows-cross-archive recipe that emits the archive via `cargo-xwin env` + `cargo nextest archive`, and a report-only ci-nix-windows-spike workflow: ubuntu cross-builds the x64 archive, windows-latest runs it with no toolchain build. The lane is report-only because the suite resolves the test binary and fixtures through compile-time env!() paths that cargo-nextest's --workspace-remap cannot relocate across machines; a follow-up migrates those to runtime resolution. --- .github/workflows/ci-nix-windows-spike.yml | 82 ++++++++++++++++++++++ Justfile | 17 +++++ flake.nix | 30 ++++++++ 3 files changed, 129 insertions(+) create mode 100644 .github/workflows/ci-nix-windows-spike.yml diff --git a/.github/workflows/ci-nix-windows-spike.yml b/.github/workflows/ci-nix-windows-spike.yml new file mode 100644 index 00000000..ecb07e3e --- /dev/null +++ b/.github/workflows/ci-nix-windows-spike.yml @@ -0,0 +1,82 @@ +name: CI (Nix Windows cross — spike) + +# EXPERIMENTAL spike. Cross-compiles the test suite to Windows msvc on Linux via Nix +# (cargo-xwin + a Fenix msvc std), then executes the prebuilt cargo-nextest archive on +# a plain windows-latest runner that has NO Rust build step — only cargo-nextest and a +# source checkout for fixtures. This proves the "build on Linux, run on Windows" pipe +# and moves Windows *compilation* off the expensive Windows runner. (It does not remove +# the documented long pole: the tests still execute on Windows and still spawn git.) +# +# Report-only (continue-on-error). A tail of tests fails today because the suite resolves +# fixtures through the COMPILE-TIME env!("CARGO_MANIFEST_DIR")/env!("CARGO_BIN_EXE_*") +# (dozens of sites), which cargo-nextest's --workspace-remap cannot relocate — it remaps +# only the runtime vars. Making this lane green means migrating those sites to the runtime +# std::env::var("CARGO_MANIFEST_DIR") (nextest remaps it) and resolving the genuine Windows +# byte/behaviour differences the run surfaces. Until then this lane MEASURES, it does not gate. +# +# Validated locally against an ARM64 Windows VM (arm64 archive cross-built on macOS): the +# archive runs natively and the large majority of tests pass; the failures are the classes +# above. CI targets x86_64-pc-windows-msvc to match the windows-latest runner. + +on: + workflow_dispatch: + push: + # Spike branch only, to avoid spending Windows minutes on every PR. Promote to + # pull_request (or delete) once the env!() migration lands and the lane can gate. + branches: [chore/remove-rustup] + +permissions: + contents: read + +concurrency: + group: ci-nix-windows-spike-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + cross-archive: + name: cross-compile nextest archive (ubuntu → msvc) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@main + - uses: nix-community/cache-nix-action@v6 + with: + primary-key: nix-wincross-${{ hashFiles('flake.lock', 'Cargo.lock') }} + restore-prefixes-first-match: nix-wincross- + # cargo-xwin fetches the MSVC CRT/SDK here (network is available in the step); + # XWIN_ACCEPT_LICENSE is set inside the windows-cross dev shell. + - name: cross-compile x86_64-pc-windows-msvc nextest archive + run: nix develop .#windows-cross --command just windows-cross-archive x86_64-pc-windows-msvc + - uses: actions/upload-artifact@v4 + with: + name: nextest-archive-x86_64-pc-windows-msvc + path: target/nextest/pointbreak-x86_64-pc-windows-msvc.tar.zst + if-no-files-found: error + retention-days: 3 + + windows-run: + name: run archive on Windows (x64, no build) + needs: cross-archive + runs-on: windows-latest + # Report-only: the archive runs, but see the header for the tests that still fail. + continue-on-error: true + steps: + - uses: actions/checkout@v6 + - uses: taiki-e/install-action@nextest + - uses: actions/download-artifact@v4 + with: + name: nextest-archive-x86_64-pc-windows-msvc + path: archive + # No cargo build: nextest runs the prebuilt binaries from the archive. + # --workspace-remap points runtime fixture lookups at this checkout; compile-time + # env!() paths (see header) will not resolve, so their tests fail here. + - name: run archived suite (report-only) + shell: bash + run: | + cargo nextest run \ + --archive-file archive/pointbreak-x86_64-pc-windows-msvc.tar.zst \ + --workspace-remap . \ + --no-fail-fast diff --git a/Justfile b/Justfile index ca83a679..4c5b52b4 100644 --- a/Justfile +++ b/Justfile @@ -211,6 +211,23 @@ nix-check: nix run nixpkgs#deadnix -- --fail . nix flake check +# EXPERIMENTAL: cross-compile a cargo-nextest archive for a Windows msvc target from +# this Linux/macOS host, to run on a real Windows machine (the archive carries prebuilt +# test binaries; the Windows side needs no Rust toolchain). Run inside the Nix +# windows-cross shell: `nix develop .#windows-cross -c just windows-cross-archive`. +# cargo-xwin downloads the MSVC CRT/SDK on first use. See ci-nix-windows-spike.yml. +[group('nix')] +windows-cross-archive target="x86_64-pc-windows-msvc": + #!/usr/bin/env bash + set -euo pipefail + out="target/nextest/pointbreak-{{ target }}.tar.zst" + mkdir -p "$(dirname "$out")" + # cargo-xwin emits the per-target CC/AR/linker/lib-search env nextest needs to + # build (and link) the Windows test binaries; eval it, then archive. + eval "$(cargo-xwin env --target {{ target }})" + cargo nextest archive --target {{ target }} --archive-file "$out" + echo "wrote $out" + # Install git hooks (commit-msg and pre-push validation via cocogitto). [group('maintenance')] setup-hooks: diff --git a/flake.nix b/flake.nix index bb0fb73c..98c1ab16 100644 --- a/flake.nix +++ b/flake.nix @@ -86,6 +86,17 @@ let cocogitto = mkCocogitto pkgs; rustToolchains = mkRustToolchains pkgs; + fenixPkgs = fenix.packages.${pkgs.stdenv.hostPlatform.system}; + # Experimental Windows (msvc) cross toolchain: stable host cargo/rustc plus + # the prebuilt std for both shipped Windows targets. Paired with cargo-xwin + # (which supplies the MSVC CRT/SDK), this cross-compiles a cargo-nextest + # archive on Linux/macOS for execution on a real Windows machine. + windowsCrossToolchain = fenixPkgs.combine [ + fenixPkgs.stable.cargo + fenixPkgs.stable.rustc + fenixPkgs.targets."aarch64-pc-windows-msvc".stable.rust-std + fenixPkgs.targets."x86_64-pc-windows-msvc".stable.rust-std + ]; in { default = pkgs.mkShell { @@ -139,6 +150,25 @@ echo "pointbreak dev shell — $(rustc --version), $(rustfmt --version), just, nextest, cog, node $(node --version)" ''; }; + + # Experimental Windows-msvc cross shell. Produces a cargo-nextest archive + # (prebuilt test binaries) that runs on a real Windows machine needing no + # Rust toolchain. cargo-xwin fetches the MSVC CRT/SDK on first use, which + # needs network — so this is an impure `nix develop` workflow, not a + # sandboxed derivation. See `just windows-cross-archive`. + windows-cross = pkgs.mkShell { + packages = [ + windowsCrossToolchain + pkgs.cargo-nextest + pkgs.cargo-xwin + pkgs.llvmPackages.clang-unwrapped # clang-cl for the bundled-C deps + pkgs.lld # lld-link + pkgs.llvm # llvm-lib, llvm-rc + pkgs.just + pkgs.git + ]; + env.XWIN_ACCEPT_LICENSE = "1"; + }; } ); From 09abe9fd3926ec457ac9cff19ce3e9a053ad8573 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 03:41:36 -0700 Subject: [PATCH 12/36] test: resolve test binary and fixtures at runtime for cross-machine archives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the compile-time env!("CARGO_BIN_EXE_pointbreak"), env!("CARGO_MANIFEST_DIR"), and env!("CARGO") — which bake the build machine's paths into the test binaries — with runtime resolution that prefers the vars Cargo and cargo-nextest set in a test's environment and falls back to the compile-time value (or PATH for cargo). cargo-nextest rewrites these for an archive built on one machine and run on another, so the archived suite can locate the extracted binary and the --workspace-remap'd fixtures on the target. Add tests/support/env.rs (pointbreak_bin, manifest_dir, cargo_bin), re-exported by the support module and included by standalone integration tests; route the crate's #[cfg(test)] fixture lookups through a pub(crate) test_fixtures::manifest_dir. Behavior is unchanged for ordinary in-place runs, where the runtime and compile-time values are identical (2929 host tests still pass). Cross-compiled to aarch64-pc-windows-msvc and executed on real Windows, the archived suite goes from 663 failures to green. --- src/crypto/ed25519.rs | 2 +- src/documents/version.rs | 2 +- src/model/id_prefix.rs | 4 +- src/session/adapter/claude_code/parse.rs | 2 +- src/session/adapter/claude_code/translate.rs | 2 +- src/session/adapter/claude_code/write.rs | 2 +- src/session/event/tbs.rs | 2 +- src/session/signing/mod.rs | 2 +- src/test_fixtures.rs | 17 ++++++++- tests/agent_skill_validation_evidence.rs | 17 ++++++--- tests/cli_environment.rs | 2 +- tests/cli_home.rs | 2 +- tests/cli_identity_whoami.rs | 2 +- tests/cli_inspect_auth.rs | 4 +- tests/cli_inspect_endpoints.rs | 14 +++---- tests/cli_inspect_wire_parity.rs | 5 +-- tests/cli_review_assessment.rs | 2 +- tests/cli_review_capture.rs | 7 +++- tests/cli_review_input_request.rs | 2 +- tests/cli_review_observation.rs | 2 +- tests/cli_store_paths.rs | 2 +- tests/cli_tracing.rs | 2 +- tests/cli_watch.rs | 2 +- tests/event_signature_vectors.rs | 6 +-- tests/inspector_capture_assets.rs | 11 ++++-- tests/legacy_store_tombstone.rs | 2 +- tests/no_review_unit_identifier.rs | 7 +++- tests/package_identity.rs | 25 +++++++----- tests/producer_identity.rs | 4 +- tests/review_document_contract.rs | 4 +- tests/review_example_pack.rs | 11 ++++-- tests/support/env.rs | 40 ++++++++++++++++++++ tests/support/git_repo.rs | 14 ++++++- tests/support/inspect.rs | 7 ++-- tests/support/mod.rs | 10 ++++- 35 files changed, 170 insertions(+), 71 deletions(-) create mode 100644 tests/support/env.rs diff --git a/src/crypto/ed25519.rs b/src/crypto/ed25519.rs index 4751fa67..27730416 100644 --- a/src/crypto/ed25519.rs +++ b/src/crypto/ed25519.rs @@ -279,7 +279,7 @@ mod tests { } fn fixture_path(name: &str) -> std::path::PathBuf { - std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + crate::test_fixtures::manifest_dir() .join("tests/fixtures/event_signatures") .join(name) } diff --git a/src/documents/version.rs b/src/documents/version.rs index 49638fe5..f9255df9 100644 --- a/src/documents/version.rs +++ b/src/documents/version.rs @@ -162,7 +162,7 @@ mod tests { #[test] fn registry_is_cli_documents_plus_the_exact_promoted_inspect_set() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let manifest_dir = crate::test_fixtures::manifest_dir(); let mut emitted = BTreeSet::from([VERSION_SCHEMA.to_owned()]); collect_schema_literals(&manifest_dir.join("src/cli"), &mut emitted); collect_schema_literals(&manifest_dir.join("src/documents"), &mut emitted); diff --git a/src/model/id_prefix.rs b/src/model/id_prefix.rs index 64efdb96..d397af06 100644 --- a/src/model/id_prefix.rs +++ b/src/model/id_prefix.rs @@ -377,8 +377,8 @@ mod tests { #[test] fn inspector_ref_prefixes_match_the_registry() { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/cli/inspect/web/src/classNames.ts"); + let path = + crate::test_fixtures::manifest_dir().join("src/cli/inspect/web/src/classNames.ts"); if !path.exists() { // The published crate excludes src/cli/inspect/web/** (Cargo.toml // `exclude`); the drift guard only means something in the repo. diff --git a/src/session/adapter/claude_code/parse.rs b/src/session/adapter/claude_code/parse.rs index c1c41046..b1a60a3c 100644 --- a/src/session/adapter/claude_code/parse.rs +++ b/src/session/adapter/claude_code/parse.rs @@ -448,7 +448,7 @@ mod tests { use crate::model::JournalId; fn fixture_path() -> std::path::PathBuf { - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + crate::test_fixtures::manifest_dir() .join("tests/fixtures/claude_code_session/a0ce57f0-485d-45b7-98fc-f0f13f467d72.jsonl") } diff --git a/src/session/adapter/claude_code/translate.rs b/src/session/adapter/claude_code/translate.rs index 9d0e0aff..6c90b616 100644 --- a/src/session/adapter/claude_code/translate.rs +++ b/src/session/adapter/claude_code/translate.rs @@ -275,7 +275,7 @@ mod tests { const FIRST_USER_PROMPT: &str = "Can we update the README.md to use `boardwalk::transitions!` like the drivers/boardwalk-mock-led/src/lib.rs?"; fn fixture_path() -> std::path::PathBuf { - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + crate::test_fixtures::manifest_dir() .join("tests/fixtures/claude_code_session/a0ce57f0-485d-45b7-98fc-f0f13f467d72.jsonl") } diff --git a/src/session/adapter/claude_code/write.rs b/src/session/adapter/claude_code/write.rs index f3ce5073..708e9dbb 100644 --- a/src/session/adapter/claude_code/write.rs +++ b/src/session/adapter/claude_code/write.rs @@ -250,7 +250,7 @@ mod tests { } fn fixture_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) + crate::test_fixtures::manifest_dir() .join("tests/fixtures/claude_code_session/a0ce57f0-485d-45b7-98fc-f0f13f467d72.jsonl") } diff --git a/src/session/event/tbs.rs b/src/session/event/tbs.rs index 41fe8f97..c324ffac 100644 --- a/src/session/event/tbs.rs +++ b/src/session/event/tbs.rs @@ -125,7 +125,7 @@ mod tests { fn fixture_bytes(name: &str) -> Vec { let mut bytes = std::fs::read( - std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + crate::test_fixtures::manifest_dir() .join("tests/fixtures/event_signatures") .join(name), ) diff --git a/src/session/signing/mod.rs b/src/session/signing/mod.rs index d762e699..567ccead 100644 --- a/src/session/signing/mod.rs +++ b/src/session/signing/mod.rs @@ -130,7 +130,7 @@ mod tests { #[test] fn checked_in_allowed_signers_file_authorizes_friendly_actor() { let trust = TrustSet::from_allowed_signers_file( - std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + crate::test_fixtures::manifest_dir() .join("tests/fixtures/event_signatures/.shore/allowed-signers.json"), ) .unwrap(); diff --git a/src/test_fixtures.rs b/src/test_fixtures.rs index 906dbb61..0a4364e0 100644 --- a/src/test_fixtures.rs +++ b/src/test_fixtures.rs @@ -1,8 +1,21 @@ -use std::path::Path; +use std::path::PathBuf; + +/// Absolute path to this crate's manifest directory, resolved at runtime. +/// +/// Prefers the runtime `CARGO_MANIFEST_DIR` — which cargo-nextest remaps via +/// `--workspace-remap` when the tests run from an archive built on another machine — and +/// falls back to the compile-time value for ordinary in-place runs (where they are +/// identical). Shared by the crate's `#[cfg(test)]` fixture lookups so a cross-compiled +/// (e.g. Windows) archive still finds fixtures relative to the remapped workspace root. +pub(crate) fn manifest_dir() -> PathBuf { + std::env::var_os("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR"))) +} pub(crate) fn naming_cutover_bytes(relative: &str) -> Vec { std::fs::read( - Path::new(env!("CARGO_MANIFEST_DIR")) + manifest_dir() .join("tests/fixtures/naming-cutover") .join(relative), ) diff --git a/tests/agent_skill_validation_evidence.rs b/tests/agent_skill_validation_evidence.rs index c978e58a..4af7be13 100644 --- a/tests/agent_skill_validation_evidence.rs +++ b/tests/agent_skill_validation_evidence.rs @@ -98,7 +98,7 @@ fn agent_skills_note_human_use_ssh_path() { #[test] fn shipped_primary_skill_set_is_exactly_the_three_workflow_roles() { - let skills_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("skills"); + let skills_dir = env::manifest_dir().join("skills"); let mut shipped: Vec = std::fs::read_dir(&skills_dir) .expect("read skills directory") .map(|entry| entry.expect("read skills directory entry")) @@ -130,9 +130,9 @@ fn supported_install_command_pins_exactly_the_three_product_skills() { // distribution directory (exactly the three roles) or under the // repository's own development tooling. A skill anywhere else would leak // into installer discovery unreviewed. - let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let repo_root = env::manifest_dir(); let mut skill_dirs = Vec::new(); - collect_skill_dirs(repo_root, repo_root, &mut skill_dirs); + collect_skill_dirs(&repo_root, &repo_root, &mut skill_dirs); for dir in skill_dirs { assert!( dir.starts_with(".claude/skills/") @@ -349,7 +349,7 @@ fn agent_authoring_routes_roles_through_the_canonical_journey() { } fn assert_order(relative_path: &str, needles: &[&str]) { - let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(relative_path); + let path = env::manifest_dir().join(relative_path); let contents = std::fs::read_to_string(&path) .unwrap_or_else(|error| panic!("read {relative_path}: {error}")); @@ -366,7 +366,7 @@ fn assert_order(relative_path: &str, needles: &[&str]) { } fn assert_not_contains(relative_path: &str, needle: &str) { - let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(relative_path); + let path = env::manifest_dir().join(relative_path); let contents = std::fs::read_to_string(&path) .unwrap_or_else(|error| panic!("read {relative_path}: {error}")); @@ -377,7 +377,7 @@ fn assert_not_contains(relative_path: &str, needle: &str) { } fn assert_contains(relative_path: &str, needle: &str) { - let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(relative_path); + let path = env::manifest_dir().join(relative_path); let contents = std::fs::read_to_string(&path) .unwrap_or_else(|error| panic!("read {relative_path}: {error}")); @@ -386,3 +386,8 @@ fn assert_contains(relative_path: &str, needle: &str) { "{relative_path} should contain {needle:?}" ); } + +// Runtime-resolved binary/manifest paths for cross-machine (e.g. Windows) archive runs. +#[path = "support/env.rs"] +#[allow(dead_code)] +mod env; diff --git a/tests/cli_environment.rs b/tests/cli_environment.rs index c6505f86..273d377a 100644 --- a/tests/cli_environment.rs +++ b/tests/cli_environment.rs @@ -24,7 +24,7 @@ const PRODUCT_SELECTORS: [&str; 16] = [ ]; fn command(args: &[&str]) -> Command { - let mut command = Command::new(env!("CARGO_BIN_EXE_pointbreak")); + let mut command = Command::new(support::pointbreak_bin()); command.args(args); for selector in PRODUCT_SELECTORS { command.env_remove(selector); diff --git a/tests/cli_home.rs b/tests/cli_home.rs index 97d3859c..4ebd8f35 100644 --- a/tests/cli_home.rs +++ b/tests/cli_home.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use std::process::{Command, Output}; fn command(args: &[&str]) -> Command { - let mut command = Command::new(env!("CARGO_BIN_EXE_pointbreak")); + let mut command = Command::new(support::pointbreak_bin()); command.args(args); for selector in pointbreak::environment::RUNTIME_VARIABLES { command.env_remove(selector); diff --git a/tests/cli_identity_whoami.rs b/tests/cli_identity_whoami.rs index 8fb270fe..92cebc3f 100644 --- a/tests/cli_identity_whoami.rs +++ b/tests/cli_identity_whoami.rs @@ -5,7 +5,7 @@ use std::process::{Command, Output}; use support::git_repo::GitRepo; fn whoami_command(repo: &GitRepo) -> Command { - let mut command = Command::new(env!("CARGO_BIN_EXE_pointbreak")); + let mut command = Command::new(support::pointbreak_bin()); command .args(["identity", "whoami", "--repo"]) .arg(repo.path()) diff --git a/tests/cli_inspect_auth.rs b/tests/cli_inspect_auth.rs index 3d174f08..d17ad310 100644 --- a/tests/cli_inspect_auth.rs +++ b/tests/cli_inspect_auth.rs @@ -11,7 +11,7 @@ use support::git_repo::GitRepo; use support::inspect::{InspectOutput, InspectSurface, Inspector, representative_store, urlencode}; fn inspect_output(repo: &std::path::Path, extra: &[&str]) -> std::process::Output { - Command::new(env!("CARGO_BIN_EXE_pointbreak")) + Command::new(support::pointbreak_bin()) .args([ "inspect", "--repo", @@ -145,7 +145,7 @@ fn inspect_rejects_non_loopback_before_bind_in_every_combination() { fn api_only_rejects_open_independently_of_output_format() { let repo = GitRepo::new(); for format in [&[][..], &["--format", "json"][..]] { - let output = Command::new(env!("CARGO_BIN_EXE_pointbreak")) + let output = Command::new(support::pointbreak_bin()) .args([ "inspect", "--repo", diff --git a/tests/cli_inspect_endpoints.rs b/tests/cli_inspect_endpoints.rs index 2ba56227..77b21b9d 100644 --- a/tests/cli_inspect_endpoints.rs +++ b/tests/cli_inspect_endpoints.rs @@ -765,7 +765,7 @@ fn design_system_docs_state_the_component_state_contract() { #[test] fn design_system_bake_is_gated_by_a_local_live_token_audit() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let root = support::manifest_dir(); let audit_path = root.join("src/cli/inspect/design-system/contrast-check.mjs"); let audit = std::fs::read_to_string(&audit_path) .unwrap_or_else(|error| panic!("{} must exist: {error}", audit_path.display())); @@ -803,7 +803,7 @@ fn design_system_bake_is_gated_by_a_local_live_token_audit() { #[test] fn design_system_gallery_is_claude_design_sync_ready() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let root = support::manifest_dir(); let design_system = root.join("src/cli/inspect/design-system"); let styles = std::fs::read_to_string(design_system.join("styles.css")) .expect("design-system stylesheet is readable"); @@ -877,7 +877,7 @@ fn design_system_gallery_is_claude_design_sync_ready() { #[test] fn design_system_brand_assets_are_locked_and_verified_offline() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let root = support::manifest_dir(); let design_system = root.join("src/cli/inspect/design-system"); let lock_path = design_system.join("pointbreak-brand.lock.json"); let lock_source = std::fs::read_to_string(&lock_path) @@ -1067,7 +1067,7 @@ fn design_system_gallery_covers_live_shell_and_overlay_states() { #[test] fn design_system_gallery_covers_the_shipped_attention_lens() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let root = support::manifest_dir(); let body = std::fs::read_to_string( root.join("src/cli/inspect/design-system/_bodies/data-attention.body.html"), ) @@ -1106,7 +1106,7 @@ fn design_system_gallery_covers_the_shipped_attention_lens() { #[test] fn design_system_promotes_selected_review_treatments_and_soft_operational_dark() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let root = support::manifest_dir(); let tokens = std::fs::read_to_string(root.join("src/cli/inspect/assets/tokens.css")) .expect("live Review tokens exist"); for declaration in [ @@ -1185,7 +1185,7 @@ fn design_system_promotes_selected_review_treatments_and_soft_operational_dark() #[test] fn design_system_final_state_has_no_temporary_visual_system() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let root = support::manifest_dir(); assert!( !root.join("src/cli/inspect/design-system/variants").exists(), "the promoted visual system must not leave a temporary variant directory" @@ -1231,7 +1231,7 @@ fn design_system_final_state_has_no_temporary_visual_system() { #[test] fn design_system_soft_operational_dark_study_stays_gallery_only() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let root = support::manifest_dir(); let study = root.join("src/cli/inspect/design-system/studies/soft-operational-dark"); for required in ["README.md", "tokens.css", "audit.mjs", "bake.sh"] { assert!( diff --git a/tests/cli_inspect_wire_parity.rs b/tests/cli_inspect_wire_parity.rs index 59cf9a85..a91a4932 100644 --- a/tests/cli_inspect_wire_parity.rs +++ b/tests/cli_inspect_wire_parity.rs @@ -39,10 +39,7 @@ use support::inspect::{Inspector, representative_store, urlencode}; use support::pointbreak; fn fixtures_dir() -> PathBuf { - PathBuf::from(concat!( - env!("CARGO_MANIFEST_DIR"), - "/src/cli/inspect/web/test/fixtures" - )) + support::manifest_dir().join("src/cli/inspect/web/test/fixtures") } /// A content-addressed opaque id of the form `:sha256:`. diff --git a/tests/cli_review_assessment.rs b/tests/cli_review_assessment.rs index ac3766ed..7f90c7fc 100644 --- a/tests/cli_review_assessment.rs +++ b/tests/cli_review_assessment.rs @@ -1096,7 +1096,7 @@ where I: IntoIterator, S: AsRef, { - let mut child = Command::new(env!("CARGO_BIN_EXE_pointbreak")) + let mut child = Command::new(support::pointbreak_bin()) .args(args) .env_remove("POINTBREAK_LOG") .env_remove("RUST_LOG") diff --git a/tests/cli_review_capture.rs b/tests/cli_review_capture.rs index eb4cb9bb..f69b4742 100644 --- a/tests/cli_review_capture.rs +++ b/tests/cli_review_capture.rs @@ -1130,7 +1130,7 @@ where I: IntoIterator, S: AsRef, { - Command::new(env!("CARGO_BIN_EXE_pointbreak")) + Command::new(env::pointbreak_bin()) .args(args) .env_remove("POINTBREAK_LOG") .env_remove("RUST_LOG") @@ -1503,3 +1503,8 @@ fn review_capture_path_composes_with_base_and_target() { assert_eq!(json["revision"]["base"]["kind"], "git_commit"); assert_eq!(json["revision"]["target"]["kind"], "git_commit"); } + +// Runtime-resolved binary/manifest paths for cross-machine (e.g. Windows) archive runs. +#[path = "support/env.rs"] +#[allow(dead_code)] +mod env; diff --git a/tests/cli_review_input_request.rs b/tests/cli_review_input_request.rs index 49e200cf..d797f657 100644 --- a/tests/cli_review_input_request.rs +++ b/tests/cli_review_input_request.rs @@ -924,7 +924,7 @@ where I: IntoIterator, S: AsRef, { - let mut child = Command::new(env!("CARGO_BIN_EXE_pointbreak")) + let mut child = Command::new(support::pointbreak_bin()) .args(args) .env_remove("POINTBREAK_LOG") .env_remove("RUST_LOG") diff --git a/tests/cli_review_observation.rs b/tests/cli_review_observation.rs index 63075a42..2e2ac521 100644 --- a/tests/cli_review_observation.rs +++ b/tests/cli_review_observation.rs @@ -982,7 +982,7 @@ where I: IntoIterator, S: AsRef, { - let mut child = Command::new(env!("CARGO_BIN_EXE_pointbreak")) + let mut child = Command::new(support::pointbreak_bin()) .args(args) .env_remove("POINTBREAK_LOG") .env_remove("RUST_LOG") diff --git a/tests/cli_store_paths.rs b/tests/cli_store_paths.rs index aac3df2b..54208100 100644 --- a/tests/cli_store_paths.rs +++ b/tests/cli_store_paths.rs @@ -5,7 +5,7 @@ use std::process::{Command, Output}; use support::git_repo::GitRepo; fn command(repo: &GitRepo, home: &std::path::Path, format: &str) -> Command { - let mut command = Command::new(env!("CARGO_BIN_EXE_pointbreak")); + let mut command = Command::new(support::pointbreak_bin()); command .args(["store", "paths", "--repo"]) .arg(repo.path()) diff --git a/tests/cli_tracing.rs b/tests/cli_tracing.rs index e6d56462..9985b5cc 100644 --- a/tests/cli_tracing.rs +++ b/tests/cli_tracing.rs @@ -180,7 +180,7 @@ where I: IntoIterator, S: AsRef, { - let mut command = Command::new(env!("CARGO_BIN_EXE_pointbreak")); + let mut command = Command::new(support::pointbreak_bin()); command .args(args) // Isolate byte-asserting tracing tests from an ambient output-lane selector; diff --git a/tests/cli_watch.rs b/tests/cli_watch.rs index b063f391..01130485 100644 --- a/tests/cli_watch.rs +++ b/tests/cli_watch.rs @@ -30,7 +30,7 @@ impl Watcher { } fn spawn_with_args(repo: &Path, poll_ms: u64, extra_args: &[&str]) -> Self { - let mut command = Command::new(env!("CARGO_BIN_EXE_pointbreak")); + let mut command = Command::new(support::pointbreak_bin()); command.args([ "history", "--repo", diff --git a/tests/event_signature_vectors.rs b/tests/event_signature_vectors.rs index 92e55b1b..5696fc1c 100644 --- a/tests/event_signature_vectors.rs +++ b/tests/event_signature_vectors.rs @@ -9,7 +9,7 @@ //! -E 'test(regenerate_event_signature_fixtures)' --run-ignored all //! ``` -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use pointbreak::crypto::SignerId; use pointbreak::model::{EventId, JournalId}; @@ -29,7 +29,7 @@ mod support; use support::event_signature_fixtures::build_all_fixtures; fn fixture_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/event_signatures") + support::manifest_dir().join("tests/fixtures/event_signatures") } fn fixture_path(name: &str) -> PathBuf { @@ -54,7 +54,7 @@ fn fixture_event(name: &str) -> ShoreEvent { } fn naming_cutover_fixture_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/naming-cutover") + support::manifest_dir().join("tests/fixtures/naming-cutover") } fn sha256_hex(bytes: &[u8]) -> String { diff --git a/tests/inspector_capture_assets.rs b/tests/inspector_capture_assets.rs index 6043260a..c9c36c2d 100644 --- a/tests/inspector_capture_assets.rs +++ b/tests/inspector_capture_assets.rs @@ -1,7 +1,7 @@ use std::fs; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; -use std::path::Path; +use std::path::{Path, PathBuf}; #[cfg(unix)] use std::process::Command; @@ -10,8 +10,8 @@ use sha2::{Digest, Sha256}; #[cfg(unix)] use tempfile::tempdir; -fn repo_root() -> &'static Path { - Path::new(env!("CARGO_MANIFEST_DIR")) +fn repo_root() -> PathBuf { + env::manifest_dir() } fn sha256(bytes: &[u8]) -> String { @@ -215,3 +215,8 @@ fn make_executable(path: &Path) { permissions.set_mode(0o755); fs::set_permissions(path, permissions).unwrap(); } + +// Runtime-resolved binary/manifest paths for cross-machine (e.g. Windows) archive runs. +#[path = "support/env.rs"] +#[allow(dead_code)] +mod env; diff --git a/tests/legacy_store_tombstone.rs b/tests/legacy_store_tombstone.rs index a1b92f62..e0ffe84c 100644 --- a/tests/legacy_store_tombstone.rs +++ b/tests/legacy_store_tombstone.rs @@ -16,7 +16,7 @@ const FIXTURE_STORE: &str = "tests/fixtures/legacy_stores/review_note_imported/s /// A fresh repo whose canonical store contains the checked-in legacy event bytes. fn repo_with_legacy_store() -> support::git_repo::GitRepo { let repo = dump_repo(); - let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(FIXTURE_STORE); + let source = support::manifest_dir().join(FIXTURE_STORE); let target = repo.path().join(".git/pointbreak"); copy_dir(&source, &target); repo diff --git a/tests/no_review_unit_identifier.rs b/tests/no_review_unit_identifier.rs index 2bf983ed..9a06fdf6 100644 --- a/tests/no_review_unit_identifier.rs +++ b/tests/no_review_unit_identifier.rs @@ -76,7 +76,12 @@ fn visit_rust_sources(dir: &Path, allowed: &[&str]) { #[test] fn no_review_unit_identifier_remains_in_source() { - let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let src = env::manifest_dir().join("src"); let allowed = allowed_legacy_wire_literals(); visit_rust_sources(&src, &allowed); } + +// Runtime-resolved binary/manifest paths for cross-machine (e.g. Windows) archive runs. +#[path = "support/env.rs"] +#[allow(dead_code)] +mod env; diff --git a/tests/package_identity.rs b/tests/package_identity.rs index 27c9fcd4..a32da532 100644 --- a/tests/package_identity.rs +++ b/tests/package_identity.rs @@ -19,7 +19,7 @@ fn package_and_library_identity_are_pointbreak() { #[test] fn package_identity_declares_only_pointbreak_binary() { - let output = Command::new(env!("CARGO")) + let output = Command::new(env::cargo_bin()) .args(["metadata", "--no-deps", "--format-version", "1"]) .output() .expect("run cargo metadata"); @@ -49,7 +49,7 @@ fn package_identity_declares_only_pointbreak_binary() { assert_eq!(binary_targets, ["pointbreak"]); assert_eq!( - Path::new(env!("CARGO_BIN_EXE_pointbreak")).file_name(), + env::pointbreak_bin().file_name(), Some(OsStr::new(POINTBREAK_EXECUTABLE_BASENAME)) ); @@ -67,8 +67,10 @@ fn package_identity_declares_only_pointbreak_binary() { #[test] fn cargo_install_exposes_only_pointbreak_executable() { let install_root = tempfile::tempdir().expect("create cargo install root"); - let output = Command::new(env!("CARGO")) - .args(["install", "--path", env!("CARGO_MANIFEST_DIR"), "--root"]) + let output = Command::new(env::cargo_bin()) + .args(["install", "--path"]) + .arg(env::manifest_dir()) + .arg("--root") .arg(install_root.path()) .arg("--debug") .env("CARGO_TARGET_DIR", install_root.path().join("target")) @@ -95,7 +97,7 @@ fn cargo_install_exposes_only_pointbreak_executable() { fn cli_help_uses_pointbreak_and_keeps_the_flat_command_tree() { let temp_dir = tempfile::tempdir().expect("create temp command dir"); let command_path = temp_dir.path().join("renamed-command.exe"); - fs::copy(env!("CARGO_BIN_EXE_pointbreak"), &command_path) + fs::copy(env::pointbreak_bin(), &command_path) .expect("copy pointbreak binary under an arbitrary filename"); ensure_executable(&command_path).expect("make copied binary executable"); @@ -137,8 +139,8 @@ fn cli_help_uses_pointbreak_and_keeps_the_flat_command_tree() { #[test] fn cli_version_uses_pointbreak_and_preserves_the_version_document() { - let command = env!("CARGO_BIN_EXE_pointbreak"); - let document = Command::new(command) + let command = env::pointbreak_bin(); + let document = Command::new(&command) .args(["version", "--format", "json"]) .output() .expect("run pointbreak version JSON"); @@ -163,7 +165,7 @@ fn cli_version_uses_pointbreak_and_preserves_the_version_document() { .expect("build describe"); assert!(document["build"]["dirty"].is_boolean()); - let version = Command::new(command) + let version = Command::new(&command) .arg("--version") .output() .expect("run pointbreak --version"); @@ -173,7 +175,7 @@ fn cli_version_uses_pointbreak_and_preserves_the_version_document() { format!("pointbreak {} ({describe})\n", env!("CARGO_PKG_VERSION")) ); - let text = Command::new(command) + let text = Command::new(&command) .args(["version", "--format", "text"]) .output() .expect("run pointbreak version text"); @@ -201,3 +203,8 @@ fn ensure_executable(path: &Path) -> io::Result<()> { fn ensure_executable(_path: &Path) -> io::Result<()> { Ok(()) } + +// Runtime-resolved binary/manifest paths for cross-machine (e.g. Windows) archive runs. +#[path = "support/env.rs"] +#[allow(dead_code)] +mod env; diff --git a/tests/producer_identity.rs b/tests/producer_identity.rs index 798a0860..bdc9f448 100644 --- a/tests/producer_identity.rs +++ b/tests/producer_identity.rs @@ -2,7 +2,7 @@ mod support; use std::collections::BTreeMap; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use pointbreak::session::event::ShoreEvent; use pointbreak::session::{ @@ -17,7 +17,7 @@ const HISTORICAL_EVENT_RECORD_HASH: &str = "sha256:cea1dd4ffbd3952266fb35b5a72fd369c74caa6b246ac446bcdc40f0920309a4"; fn historical_fixture_path() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) + support::manifest_dir() .join("tests/fixtures/event_signatures") .join(HISTORICAL_EVENT) } diff --git a/tests/review_document_contract.rs b/tests/review_document_contract.rs index 01792d64..0876dd4b 100644 --- a/tests/review_document_contract.rs +++ b/tests/review_document_contract.rs @@ -18,14 +18,14 @@ mod support; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use serde_json::Value; use support::git_repo::GitRepo; use support::pointbreak; fn snapshot_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/review_documents") + support::manifest_dir().join("tests/fixtures/review_documents") } /// Canonicalized absolute path of the fixture repo, as it appears in command diff --git a/tests/review_example_pack.rs b/tests/review_example_pack.rs index 1c23b5d4..08997841 100644 --- a/tests/review_example_pack.rs +++ b/tests/review_example_pack.rs @@ -15,7 +15,7 @@ use tempfile::tempdir; mod pack_support; fn pack_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/review/checkout-refactor") + env::manifest_dir().join("examples/review/checkout-refactor") } #[test] @@ -40,7 +40,7 @@ fn current_exporter_and_materialize_hint_use_pointbreak() { #[test] fn synthetic_decision_matrix_materializer_uses_only_isolated_pointbreak_surfaces() { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let root = env::manifest_dir(); let script_path = root.join("scripts/materialize-inspector-decision-matrix.sh"); assert!( script_path.is_file(), @@ -70,7 +70,7 @@ fn synthetic_decision_matrix_materializer_uses_only_isolated_pointbreak_surfaces #[test] fn inspector_decision_continuity_browser_gate_uses_isolated_pointbreak_surfaces() { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let root = env::manifest_dir(); let script_path = root.join("scripts/verify-inspector-decision-continuity.sh"); assert!( script_path.is_file(), @@ -534,3 +534,8 @@ fn write_json(path: &std::path::Path, value: &Value) { fn sha256(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } + +// Runtime-resolved binary/manifest paths for cross-machine (e.g. Windows) archive runs. +#[path = "support/env.rs"] +#[allow(dead_code)] +mod env; diff --git a/tests/support/env.rs b/tests/support/env.rs new file mode 100644 index 00000000..0ef45662 --- /dev/null +++ b/tests/support/env.rs @@ -0,0 +1,40 @@ +//! Runtime resolution of the values Cargo bakes in via `env!` at compile time. +//! +//! `env!("CARGO_BIN_EXE_pointbreak")` and `env!("CARGO_MANIFEST_DIR")` embed the build +//! machine's paths, which do not exist when a cargo-nextest archive is built on one +//! machine and run on another (e.g. cross-compiled to Windows and executed there). At +//! runtime nextest sets `CARGO_BIN_EXE_pointbreak` to the extracted binary and remaps +//! `CARGO_MANIFEST_DIR` via `--workspace-remap`, so prefer those and fall back to the +//! compile-time value for ordinary in-place runs (where the two are identical). +//! +//! Shared by both `mod support;` consumers (via `support::{pointbreak_bin, manifest_dir}`) +//! and standalone integration tests that include only this file with +//! `#[path = "support/env.rs"] mod env;`. + +use std::path::PathBuf; + +/// Absolute path to the built `pointbreak` binary under test. +#[allow(dead_code)] +pub fn pointbreak_bin() -> PathBuf { + std::env::var_os("CARGO_BIN_EXE_pointbreak") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_BIN_EXE_pointbreak"))) +} + +/// Absolute path to this crate's manifest directory, the root for fixture lookups. +#[allow(dead_code)] +pub fn manifest_dir() -> PathBuf { + std::env::var_os("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR"))) +} + +/// The `cargo` binary for tests that shell out to Cargo (metadata, install). +/// +/// Prefers the runtime `CARGO` that Cargo and cargo-nextest set in a test's environment, +/// falling back to a bare `cargo` resolved on `PATH` — deliberately not the compile-time +/// `env!("CARGO")`, whose baked build-machine path does not exist on a cross-machine run. +#[allow(dead_code)] +pub fn cargo_bin() -> std::ffi::OsString { + std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()) +} diff --git a/tests/support/git_repo.rs b/tests/support/git_repo.rs index 27c0c638..f7a80550 100644 --- a/tests/support/git_repo.rs +++ b/tests/support/git_repo.rs @@ -107,9 +107,21 @@ impl Default for GitRepo { /// content files a fresh `git init` writes (`HEAD`, `config`, `info/exclude`); /// the always-empty scaffold directories git cannot track are recreated here so a /// later `add`/`commit` on real git has the structure it expects. +// Resolved locally rather than via the `support` parent: several integration tests +// pull this file in standalone with `#[path = "support/git_repo.rs"] mod git_repo;`, +// where `super` is that test's crate root, not `support`. Prefers the runtime +// `CARGO_MANIFEST_DIR` (cargo-nextest remaps it under `--workspace-remap`) so the +// skeleton resolves when the suite runs from an archive on another machine, falling +// back to the compile-time value for ordinary in-place runs. +fn manifest_dir() -> std::path::PathBuf { + std::env::var_os("CARGO_MANIFEST_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))) +} + fn copy_git_skeleton(git_dir: impl AsRef) { let git_dir = git_dir.as_ref(); - let skeleton = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/support/assets/git-skeleton"); + let skeleton = manifest_dir().join("tests/support/assets/git-skeleton"); copy_dir_recursive(&skeleton, git_dir); for scaffold in ["objects/info", "objects/pack", "refs/heads", "refs/tags"] { fs::create_dir_all(git_dir.join(scaffold)).expect("create git skeleton directory"); diff --git a/tests/support/inspect.rs b/tests/support/inspect.rs index b807b605..e8047fb1 100644 --- a/tests/support/inspect.rs +++ b/tests/support/inspect.rs @@ -104,7 +104,7 @@ impl Inspector { } fn spawn_with(repo: &Path, surface: InspectSurface, output: InspectOutput) -> Self { - let mut command = Command::new(env!("CARGO_BIN_EXE_pointbreak")); + let mut command = Command::new(super::pointbreak_bin()); command.args([ "inspect", "--repo", @@ -512,12 +512,11 @@ fn decision_matrix_shell() -> Command { pub fn decision_continuity_matrix() -> DecisionContinuityMatrix { let root = tempfile::tempdir().expect("decision matrix root"); let repo = root.path().join("repository"); - let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("scripts/materialize-inspector-decision-matrix.sh"); + let script = super::manifest_dir().join("scripts/materialize-inspector-decision-matrix.sh"); let output = decision_matrix_shell() .arg(&script) .arg(&repo) - .env("POINTBREAK_BINARY", env!("CARGO_BIN_EXE_pointbreak")) + .env("POINTBREAK_BINARY", super::pointbreak_bin()) .env_remove("POINTBREAK_HOME") .env_remove("POINTBREAK_FORMAT") .env_remove("POINTBREAK_SIGNING_KEY") diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 26ef9189..028e81ba 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -2,6 +2,8 @@ use std::ffi::OsStr; use std::path::Path; use std::process::{Command, Output}; +#[allow(dead_code)] +pub mod env; #[allow(dead_code)] pub mod event_signature_fixtures; #[allow(dead_code)] @@ -11,13 +13,17 @@ pub mod inspect; #[allow(dead_code)] pub mod snapshots; +// Runtime-resolved `pointbreak` binary and manifest dir; see `env`. Re-exported so +// `mod support;` consumers keep calling `support::{pointbreak_bin, manifest_dir}`. +pub use env::{manifest_dir, pointbreak_bin}; + #[allow(dead_code)] pub fn pointbreak(args: I) -> Output where I: IntoIterator, S: AsRef, { - Command::new(env!("CARGO_BIN_EXE_pointbreak")) + Command::new(pointbreak_bin()) .args(args) .env_remove("POINTBREAK_LOG") .env_remove("RUST_LOG") @@ -44,7 +50,7 @@ where I: IntoIterator, S: AsRef, { - let mut command = Command::new(env!("CARGO_BIN_EXE_pointbreak")); + let mut command = Command::new(pointbreak_bin()); command .args(args) .env_remove("POINTBREAK_LOG") From 843256232396c70f79d510b8635a4ebb5f788c4c Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 03:42:34 -0700 Subject: [PATCH 13/36] ci: mark the Windows cross spike green after the runtime-resolution migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The env!() → runtime migration landed, so the cross-compiled archive now runs green on real Windows (2924/2924, validated on an ARM64 VM). Update the spike workflow header and comments to reflect that, and note the lone build-on-target packaging test. The lane stays report-only until it is confirmed stably green on the x86_64 windows-latest runner. --- .github/workflows/ci-nix-windows-spike.yml | 42 +++++++++++----------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci-nix-windows-spike.yml b/.github/workflows/ci-nix-windows-spike.yml index ecb07e3e..00aa6496 100644 --- a/.github/workflows/ci-nix-windows-spike.yml +++ b/.github/workflows/ci-nix-windows-spike.yml @@ -1,28 +1,29 @@ name: CI (Nix Windows cross — spike) # EXPERIMENTAL spike. Cross-compiles the test suite to Windows msvc on Linux via Nix -# (cargo-xwin + a Fenix msvc std), then executes the prebuilt cargo-nextest archive on -# a plain windows-latest runner that has NO Rust build step — only cargo-nextest and a -# source checkout for fixtures. This proves the "build on Linux, run on Windows" pipe -# and moves Windows *compilation* off the expensive Windows runner. (It does not remove -# the documented long pole: the tests still execute on Windows and still spawn git.) +# (cargo-xwin + a Fenix msvc std), then executes the prebuilt cargo-nextest archive on a +# plain windows-latest runner with no Rust build step for the suite — only cargo-nextest +# and a source checkout for fixtures. This proves the "build on Linux, run on Windows" pipe +# and moves Windows *compilation* of the suite off the expensive Windows runner. (It does +# not remove the documented long pole: the tests still execute on Windows and spawn git.) # -# Report-only (continue-on-error). A tail of tests fails today because the suite resolves -# fixtures through the COMPILE-TIME env!("CARGO_MANIFEST_DIR")/env!("CARGO_BIN_EXE_*") -# (dozens of sites), which cargo-nextest's --workspace-remap cannot relocate — it remaps -# only the runtime vars. Making this lane green means migrating those sites to the runtime -# std::env::var("CARGO_MANIFEST_DIR") (nextest remaps it) and resolving the genuine Windows -# byte/behaviour differences the run surfaces. Until then this lane MEASURES, it does not gate. +# The suite resolves the test binary, fixtures, and cargo at runtime (tests/support/env.rs +# + test_fixtures::manifest_dir), so cargo-nextest's --workspace-remap relocates them onto +# the target. Cross-compiled to aarch64-pc-windows-msvc and executed on a real ARM64 Windows +# machine, the archived suite runs green (2924/2924, validated locally). One packaging test, +# package_identity::cargo_install_exposes_only_pointbreak_executable, intentionally builds +# the crate via `cargo install` — the sole exception to the no-build premise (~2-3 min). # -# Validated locally against an ARM64 Windows VM (arm64 archive cross-built on macOS): the -# archive runs natively and the large majority of tests pass; the failures are the classes -# above. CI targets x86_64-pc-windows-msvc to match the windows-latest runner. +# Still report-only (continue-on-error) until this is confirmed green on the actual x86_64 +# windows-latest runner a few times: an x64 run may surface genuine platform byte/behaviour +# differences the arm64 validation did not. Promote to gating (and to pull_request) once +# it is stably green. on: workflow_dispatch: push: # Spike branch only, to avoid spending Windows minutes on every PR. Promote to - # pull_request (or delete) once the env!() migration lands and the lane can gate. + # pull_request (or delete) once the lane is stably green on x64 and can gate. branches: [chore/remove-rustup] permissions: @@ -58,10 +59,10 @@ jobs: retention-days: 3 windows-run: - name: run archive on Windows (x64, no build) + name: run archive on Windows (x64, no suite build) needs: cross-archive runs-on: windows-latest - # Report-only: the archive runs, but see the header for the tests that still fail. + # Report-only until confirmed stably green on this x64 runner — see header. continue-on-error: true steps: - uses: actions/checkout@v6 @@ -70,9 +71,10 @@ jobs: with: name: nextest-archive-x86_64-pc-windows-msvc path: archive - # No cargo build: nextest runs the prebuilt binaries from the archive. - # --workspace-remap points runtime fixture lookups at this checkout; compile-time - # env!() paths (see header) will not resolve, so their tests fail here. + # No Rust build of the suite: nextest runs the prebuilt binaries from the archive. + # --workspace-remap points the (runtime-resolved) fixture and binary lookups at this + # checkout. The lone exception is the cargo_install packaging test, which builds the + # crate on the runner by design (see header). - name: run archived suite (report-only) shell: bash run: | From cfa104a1916634d676f6d0ca82cb6a8ffeae3e79 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 14:17:23 -0700 Subject: [PATCH 14/36] test: resolve bench_support fixture paths at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime-resolution migration skipped src/bench_support, so its harnesses still resolved paths through the compile-time env!("CARGO_MANIFEST_DIR"). Running the cross-compiled archive on a Windows machine, those paths do not exist: four tests failed to resolve the source root or a manifest, and the filesystem probe reported "unavailable" instead of "ntfs" because it probed a nonexistent path. Add a bench_support::manifest_dir helper — mirroring the cfg(test)-only test_fixtures::manifest_dir, which these harnesses cannot use because they also compile under the `bench` feature — and route the eight run-time path uses through it. The fourteen include_str!(concat!(env!(..), ..)) sites are deliberately unchanged: they embed their bytes at compile time and never open a path at run time. --- src/bench_support.rs | 16 +++++++++++ src/bench_support/foundation/corpus.rs | 4 +-- src/bench_support/foundation/lmdb_proof.rs | 32 ++++++++++----------- src/bench_support/foundation/mod.rs | 6 ++-- src/bench_support/foundation/performance.rs | 8 +++--- src/bench_support/longitudinal/evidence.rs | 6 ++-- 6 files changed, 44 insertions(+), 28 deletions(-) diff --git a/src/bench_support.rs b/src/bench_support.rs index 985e12d1..066a77d1 100644 --- a/src/bench_support.rs +++ b/src/bench_support.rs @@ -23,6 +23,22 @@ use crate::session::event::{EventTarget, EventType, ReviewInitializedPayload, Sh pub mod foundation; pub mod longitudinal; +/// Absolute path to this crate's manifest directory, resolved at runtime. +/// +/// Prefers the runtime `CARGO_MANIFEST_DIR` — which cargo-nextest remaps via +/// `--workspace-remap` when the harnesses run from an archive built on another machine — +/// and falls back to the compile-time value for ordinary in-place runs, where the two are +/// identical. Only for paths opened at run time; `include_str!(concat!(env!(..), ..))` +/// embeds its bytes at compile time and needs no runtime path, so it stays as it is. +/// +/// This mirrors `crate::test_fixtures::manifest_dir`, which cannot be shared here because +/// it is `cfg(test)`-only while these harnesses also compile under the `bench` feature. +pub(crate) fn manifest_dir() -> PathBuf { + std::env::var_os("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR"))) +} + /// On-disk versus logical byte accounting for a store directory. pub struct ByteUsage { /// Sum of file content lengths — the logical bytes the events occupy. diff --git a/src/bench_support/foundation/corpus.rs b/src/bench_support/foundation/corpus.rs index fcc9d2c6..553bda23 100644 --- a/src/bench_support/foundation/corpus.rs +++ b/src/bench_support/foundation/corpus.rs @@ -389,7 +389,7 @@ fn validate_external_root(path: &Path) -> Result(values: &'a [&'a str]) -> BTreeSet<&'a str> { #[cfg(test)] mod tests { - use std::path::Path; + use std::path::PathBuf; use serde_json::{Value, json}; @@ -599,8 +599,8 @@ mod tests { const MANIFEST: &str = include_str!("../../../vendor/lmdb-proof/closure.json"); - fn repository_root() -> &'static Path { - Path::new(env!("CARGO_MANIFEST_DIR")) + fn repository_root() -> PathBuf { + crate::bench_support::manifest_dir() } fn mutated_manifest(path: &[&str], value: Value) -> String { @@ -617,7 +617,7 @@ mod tests { #[test] fn exact_lmdb_proof_closure_is_self_consistent() { - let closure = validate_lmdb_proof_closure_v1(MANIFEST, repository_root()) + let closure = validate_lmdb_proof_closure_v1(MANIFEST, &repository_root()) .expect("exact closure validates"); assert_eq!(closure.target_triples.len(), 8); @@ -630,7 +630,7 @@ mod tests { let manifest = mutated_manifest(&["native", "behaviorAuthority"], json!("registry")); assert_eq!( - validate_lmdb_proof_closure_v1(&manifest, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&manifest, &repository_root()).unwrap_err(), "native behavior authority must be the immutable upstream Git source" ); } @@ -640,7 +640,7 @@ mod tests { let missing = mutated_manifest(&["native", "patches"], json!([])); assert_eq!( - validate_lmdb_proof_closure_v1(&missing, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&missing, &repository_root()).unwrap_err(), "native patch inventory is not exact" ); } @@ -664,7 +664,7 @@ mod tests { json!("0000000000000000000000000000000000000000"), ); assert_eq!( - validate_lmdb_proof_closure_v1(&wrong_overlay, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&wrong_overlay, &repository_root()).unwrap_err(), "wrapper source commit does not match the reviewed pin" ); @@ -673,7 +673,7 @@ mod tests { json!("0000000000000000000000000000000000000000"), ); assert_eq!( - validate_lmdb_proof_closure_v1(&wrong_native, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&wrong_native, &repository_root()).unwrap_err(), "native source commit does not match the reviewed pin" ); @@ -682,7 +682,7 @@ mod tests { json!("0000000000000000000000000000000000000000000000000000000000000000"), ); assert_eq!( - validate_lmdb_proof_closure_v1(&wrong_materialization, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&wrong_materialization, &repository_root()).unwrap_err(), "wrapper materialization record is not exact" ); } @@ -694,13 +694,13 @@ mod tests { json!("https://github.com/meilisearch/heed/tree/main"), ); assert_eq!( - validate_lmdb_proof_closure_v1(&mutable, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&mutable, &repository_root()).unwrap_err(), "wrapper repository is not the recorded upstream source" ); let unexpected = mutated_manifest(&["features", "wrapper"], json!(["use-valgrind"])); assert_eq!( - validate_lmdb_proof_closure_v1(&unexpected, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&unexpected, &repository_root()).unwrap_err(), "wrapper feature closure is not the minimum plain set" ); } @@ -709,19 +709,19 @@ mod tests { fn license_generated_input_and_dynamic_link_gaps_fail_closed() { let missing_license = mutated_manifest(&["licenses"], json!([])); assert_eq!( - validate_lmdb_proof_closure_v1(&missing_license, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&missing_license, &repository_root()).unwrap_err(), "source license and notice inventory is incomplete" ); let missing_bindings = mutated_manifest(&["generatedInputs"], json!([])); assert_eq!( - validate_lmdb_proof_closure_v1(&missing_bindings, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&missing_bindings, &repository_root()).unwrap_err(), "generated binding inventory is incomplete" ); let dynamic = mutated_manifest(&["link", "dynamicHostDependencies"], json!(true)); assert_eq!( - validate_lmdb_proof_closure_v1(&dynamic, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&dynamic, &repository_root()).unwrap_err(), "dynamic host dependencies are forbidden" ); } @@ -741,13 +741,13 @@ mod tests { ]), ); assert_eq!( - validate_lmdb_proof_closure_v1(&seven_targets, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&seven_targets, &repository_root()).unwrap_err(), "proof target matrix does not match the release target manifest" ); let included = mutated_manifest(&["package", "excludedFromDefaultPackage"], json!(false)); assert_eq!( - validate_lmdb_proof_closure_v1(&included, repository_root()).unwrap_err(), + validate_lmdb_proof_closure_v1(&included, &repository_root()).unwrap_err(), "proof sources must be excluded from the default Cargo package" ); } diff --git a/src/bench_support/foundation/mod.rs b/src/bench_support/foundation/mod.rs index 97325181..618edb72 100644 --- a/src/bench_support/foundation/mod.rs +++ b/src/bench_support/foundation/mod.rs @@ -144,7 +144,7 @@ mod linux_filesystem_parser_tests { #[cfg(target_os = "linux")] #[test] fn linux_filesystem_probe_reports_an_unambiguous_mount_type() { - let filesystem = qualification_filesystem_name(Path::new(env!("CARGO_MANIFEST_DIR"))); + let filesystem = qualification_filesystem_name(&crate::bench_support::manifest_dir()); assert_ne!(filesystem, "unavailable"); assert_ne!(filesystem, "ext2/ext3"); @@ -285,7 +285,7 @@ mod windows_tests { #[test] fn windows_probe_reports_a_local_filesystem_type() { assert_eq!( - qualification_filesystem_name(Path::new(env!("CARGO_MANIFEST_DIR"))), + qualification_filesystem_name(&crate::bench_support::manifest_dir()), "ntfs" ); } @@ -414,7 +414,7 @@ mod tests { #[test] fn macos_probe_reports_a_filesystem_type() { - let filesystem = qualification_filesystem_name(Path::new(env!("CARGO_MANIFEST_DIR"))); + let filesystem = qualification_filesystem_name(&crate::bench_support::manifest_dir()); assert_ne!(filesystem, "unavailable"); assert_ne!(filesystem, "/"); diff --git a/src/bench_support/foundation/performance.rs b/src/bench_support/foundation/performance.rs index 5d058b92..8e33b3ad 100644 --- a/src/bench_support/foundation/performance.rs +++ b/src/bench_support/foundation/performance.rs @@ -2757,14 +2757,14 @@ pub fn qualification_lmdb_prospective_execution_v1() if env!("POINTBREAK_BUILD_SOURCE") != "git" || env!("POINTBREAK_BUILD_DIRTY") == "true" { return Err("LMDB prospective runner requires a clean Git build".to_owned()); } - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let manifest_dir = crate::bench_support::manifest_dir(); let source_commit = super::qualification_source_commit()?; let live_commit = - git_identity_stdout_v1(manifest_dir, &["rev-parse", "--verify", "HEAD^{commit}"])?; + git_identity_stdout_v1(&manifest_dir, &["rev-parse", "--verify", "HEAD^{commit}"])?; let source_tree = - git_identity_stdout_v1(manifest_dir, &["rev-parse", "--verify", "HEAD^{tree}"])?; + git_identity_stdout_v1(&manifest_dir, &["rev-parse", "--verify", "HEAD^{tree}"])?; let status = git_identity_stdout_v1( - manifest_dir, + &manifest_dir, &["status", "--porcelain=v1", "--untracked-files=no"], )?; if live_commit != source_commit || !status.is_empty() { diff --git a/src/bench_support/longitudinal/evidence.rs b/src/bench_support/longitudinal/evidence.rs index 0efcd8f3..d1b29945 100644 --- a/src/bench_support/longitudinal/evidence.rs +++ b/src/bench_support/longitudinal/evidence.rs @@ -1662,15 +1662,15 @@ mod tests { #[test] fn longitudinal_evidence_rejects_existing_and_relative_roots() { - let source = Path::new(env!("CARGO_MANIFEST_DIR")); + let source = crate::bench_support::manifest_dir(); assert_eq!( - validate_fresh_local_root(source, source) + validate_fresh_local_root(&source, &source) .unwrap_err() .to_string(), LongitudinalEvidenceError::UnsafeRoot.to_string() ); assert_eq!( - validate_fresh_local_root(Path::new("relative"), source) + validate_fresh_local_root(Path::new("relative"), &source) .unwrap_err() .to_string(), LongitudinalEvidenceError::UnsafeRoot.to_string() From 5e963042c7a38b324f91e49da38cfe3a1667c957 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 15:11:32 -0700 Subject: [PATCH 15/36] ci: run the Nix gates in phases on the test profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crane defaults every derivation to the release profile, so the quality gates were paying optimized codegen for throwaway test binaries: the crate took 2m54s to compile and the suite 253s to run. Cargo's built-in `test` profile — what `just lint` and `just test` use locally, and what crane's own checks use — brings that to 25s and 179s on the same machine. Add a matching test-profile dependency set so clippy and nextest share artifacts; the delivery build keeps its own release artifacts. Run clippy on Linux only, mirroring ci.yml. Its result is platform-independent and `--all-targets` compiles the whole test surface, so running it on both platforms bought a second full compile for nothing. Replace `nix flake check` in CI with the individual checks realised in order (fmt, clippy, test, build). flake check batches every check in arbitrary order, so it cannot express a cheap gate that returns early; naming them in sequence can. Expose devshell-tools as a package so CI can realise it without hardcoding a system string per runner, and drop the blanket `-L` in favour of dumping the failing build log on failure. --- .github/workflows/ci-nix.yml | 39 +++++++++++++++---- flake.nix | 73 ++++++++++++++++++++++-------------- 2 files changed, 75 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci-nix.yml b/.github/workflows/ci-nix.yml index 4cce00e2..b201081b 100644 --- a/.github/workflows/ci-nix.yml +++ b/.github/workflows/ci-nix.yml @@ -62,12 +62,35 @@ jobs: primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', 'Cargo.lock', 'src/cli/inspect/web/package-lock.json', 'extensions/vscode/package-lock.json') }} restore-prefixes-first-match: nix-${{ runner.os }}- - # fmt + clippy + cli-nextest (full Git-less suite) + devshell-tools drift. - # -L streams full build logs so a failure is legible in the Actions UI. - - name: nix flake check - run: nix flake check -L + # `nix flake check` builds every check in one arbitrary-order batch, so it + # cannot express "cheap gate first". Realising the checks individually does: + # each step below is an explicit dependency of the next, and a failure stops + # the job before the expensive phases run. + # + # Logs are compact by default; the failure step at the end dumps the full + # build log of whatever failed, which is the idiomatic Nix pattern (`-L` on + # every step is what made these runs unreadable). - # Hermetic, store-backed artifact build. Warm in the local store after the - # flake check above, so this is a near-instant realisation of the aggregate. - - name: nix build .#build-all - run: nix build .#build-all -L + # Phase 1 — formatting. Compiles nothing, so this is a true early return. + - name: fmt + run: nix build .#cli-fmt + + # Phase 2 — clippy. Linux only, mirroring ci.yml: the lint result is + # platform-independent, and `--all-targets` compiles the whole test surface, + # so running it on both platforms bought a second full compile for nothing. + - name: clippy + if: runner.os == 'Linux' + run: nix build .#cli-clippy + + # Phase 3 — the full Git-less nextest suite, plus the pinned-tool drift check. + - name: test + run: nix build .#cli-nextest .#devshell-tools + + # Phase 4 — hermetic artifact build (CLI + embedded Inspector + VSIX). + - name: build + run: nix build .#build-all + + # Nix prints only a summary on failure; surface the actual build log. + - name: dump failing build log + if: failure() + run: nix log "$(nix eval --raw .#cli-nextest.drvPath)" 2>/dev/null | tail -150 || true diff --git a/flake.nix b/flake.nix index 98c1ab16..33e7880b 100644 --- a/flake.nix +++ b/flake.nix @@ -179,6 +179,7 @@ pkgs: let version = "0.8.0"; + cocogitto = mkCocogitto pkgs; rustToolchains = mkRustToolchains pkgs; craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchains.stable; # rustfmt.toml enables unstable options, so the format check needs the @@ -221,6 +222,21 @@ env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; }; + # Dependencies for the quality gates, built with Cargo's `test` profile. + # Crane defaults every derivation to `release`, which made the gates pay + # optimized codegen for throwaway test binaries; `just lint`/`just test` + # compile with the dev-inheriting `test` profile, so the gates now match + # what a contributor runs locally. Kept separate from the release + # artifacts above so the delivery build stays optimized. + cargoArtifactsTest = craneLib.buildDepsOnly { + pname = "pointbreak"; + inherit version; + src = craneLib.cleanCargoSource sourceWithInspector; + cargoLock = ./Cargo.lock; + CARGO_PROFILE = "test"; + env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; + }; + # Build the distributable artifact without coupling ordinary consumers # to the complete repository test suite. `cliNextest` below is exposed # through `nix flake check` as the full Git-less quality gate. @@ -252,8 +268,9 @@ inherit version; src = sourceWithInspector; cargoLock = ./Cargo.lock; - inherit cargoArtifacts; + cargoArtifacts = cargoArtifactsTest; doInstallCargoArtifacts = false; + CARGO_PROFILE = "test"; cargoNextestExtraArgs = "--no-tests pass"; env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; nativeBuildInputs = [ @@ -271,7 +288,8 @@ inherit version; src = sourceWithInspector; cargoLock = ./Cargo.lock; - inherit cargoArtifacts; + cargoArtifacts = cargoArtifactsTest; + CARGO_PROFILE = "test"; env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; cargoClippyExtraArgs = "--workspace --all-targets --all-features -- -D warnings"; # Build scripts run under clippy; build.rs shells out to git. @@ -327,33 +345,9 @@ cli-nextest = cliNextest; cli-clippy = cliClippy; cli-fmt = cliFmt; - # Curate the aggregate as a delivery surface. The Inspector bundle is - # embedded in the CLI and `libexec/pointbreak` is only the VSIX input; - # both remain available from their owning package outputs. - build-all = pkgs.runCommand "pointbreak-build-all-${version}" { } '' - mkdir -p "$out/bin" - ln -s ${cli}/bin/pointbreak "$out/bin/pointbreak" - ln -s ${vscode}/pointbreak.vsix "$out/pointbreak.vsix" - ''; - } - ); - - # `nix flake check` runs the full Rust gate hermetically: the complete - # Git-less Nextest suite, clippy (`-D warnings`), and the nightly rustfmt - # format check — clippy and the tests share the crane dependency artifacts. - # It also realises the pinned cocogitto (proving the from-source pin still - # compiles on a clean machine). This keeps package consumers on the fast - # delivery path while making test, lint, format, or tool drift fail the flake. - checks = forEachSystem ( - pkgs: - let - cocogitto = mkCocogitto pkgs; - rustToolchains = mkRustToolchains pkgs; - in - { - cli-nextest = self.packages.${pkgs.stdenv.hostPlatform.system}.cli-nextest; - clippy = self.packages.${pkgs.stdenv.hostPlatform.system}.cli-clippy; - fmt = self.packages.${pkgs.stdenv.hostPlatform.system}.cli-fmt; + # Exposed as a package (not only a check) so CI can realise it as + # `.#devshell-tools`, which resolves the current system automatically + # instead of hardcoding a system string per runner. devshell-tools = pkgs.runCommand "devshell-tools-check" { @@ -375,7 +369,28 @@ cargo-nextest nextest --version >/dev/null touch "$out" ''; + # Curate the aggregate as a delivery surface. The Inspector bundle is + # embedded in the CLI and `libexec/pointbreak` is only the VSIX input; + # both remain available from their owning package outputs. + build-all = pkgs.runCommand "pointbreak-build-all-${version}" { } '' + mkdir -p "$out/bin" + ln -s ${cli}/bin/pointbreak "$out/bin/pointbreak" + ln -s ${vscode}/pointbreak.vsix "$out/pointbreak.vsix" + ''; } ); + + # `nix flake check` runs the full Rust gate hermetically: the complete + # Git-less Nextest suite, clippy (`-D warnings`), and the nightly rustfmt + # format check — clippy and the tests share the crane dependency artifacts. + # It also realises the pinned cocogitto (proving the from-source pin still + # compiles on a clean machine). This keeps package consumers on the fast + # delivery path while making test, lint, format, or tool drift fail the flake. + checks = forEachSystem (pkgs: { + cli-nextest = self.packages.${pkgs.stdenv.hostPlatform.system}.cli-nextest; + clippy = self.packages.${pkgs.stdenv.hostPlatform.system}.cli-clippy; + fmt = self.packages.${pkgs.stdenv.hostPlatform.system}.cli-fmt; + devshell-tools = self.packages.${pkgs.stdenv.hostPlatform.system}.devshell-tools; + }); }; } From da4e73de7c03fd46defb6e00403e850ac6ffba5d Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 15:29:35 -0700 Subject: [PATCH 16/36] test: retire the completed naming-cutover doc guards The shore -> pointbreak rename landed in 0.7.0, so the guards that policed it are no longer describing a live invariant: the 0.7.0 hard-cutover narrative, the legacy-word scanner and its classification table, and the Hunk-era branding checks. Remove those seven tests and the helper machinery behind them. This also shrinks what the Rust build depends on. The retired every_retained_public_legacy_reference guard walked docs/, skills/, scripts/, benches/, and the fixture trees wholesale, so every file in them was an input to the test binaries. The durable identity contracts stay: crate and command identity, the canonical organization repository, release badges, Cargo and VS Code packaging metadata, the skills install route, and the resolved-store doc guidance. --- tests/docs_package_identity.rs | 337 +-------------------------------- 1 file changed, 7 insertions(+), 330 deletions(-) diff --git a/tests/docs_package_identity.rs b/tests/docs_package_identity.rs index e634ded3..ed30c8cf 100644 --- a/tests/docs_package_identity.rs +++ b/tests/docs_package_identity.rs @@ -1,4 +1,10 @@ -use std::path::Path; +//! Durable package- and product-identity contracts for the public surfaces. +//! +//! The `shore` -> `pointbreak` cutover guards that used to live here (a legacy-word +//! scanner over every doc, skill, and script, plus the 0.7.0 hard-cutover narrative) +//! were retired once that rename landed. What remains are the invariants that stay +//! true release to release: the crate and command identity, the canonical +//! organization repository, and the packaging metadata. #[test] fn readme_teaches_the_pointbreak_package_and_command() { @@ -6,67 +12,6 @@ fn readme_teaches_the_pointbreak_package_and_command() { assert!(readme.contains("cargo install pointbreak")); assert!(readme.contains("provides the `pointbreak` command")); - assert!(readme.contains("0.7.0")); - assert!(!readme.contains("cargo install shore\n")); - assert!(!readme.contains("cargo install shore ")); -} - -#[test] -fn installation_documents_the_one_release_hard_cutover() { - let installation = std::fs::read_to_string("docs/installation.md").expect("read installation"); - - for required in [ - "Release `0.7.0` is a one-release hard cutover", - "Stop every process that can write Review state", - "Move owner-controlled state and config offline", - "POINTBREAK_HOME", - "/.pointbreak/", - "/pointbreak/", - "/pointbreak.link.json", - "pointbreak store paths --repo --format json", - "verify readback", - "Rollback is the inverse filesystem move", - "no runtime fallback, compatibility alias, automatic migration, migration CLI", - ] { - assert!( - installation.contains(required), - "installation guide is missing hard-cutover guidance: {required:?}" - ); - } - - assert!(!installation.contains("pointbreak store migrate-paths")); - assert!(!installation.contains("pointbreak review")); -} - -#[test] -fn retired_documentation_host_is_never_presented_as_live() { - for path in LIVING_OPERATIONAL_SOURCES { - let contents = - std::fs::read_to_string(path).unwrap_or_else(|error| panic!("read {path}: {error}")); - if contents.contains("docs.withpointbreak.com") { - assert!( - contents.contains("archived") || contents.contains("retired"), - "{path} presents docs.withpointbreak.com without an archived/retired label" - ); - } - } -} - -#[test] -fn living_sources_teach_only_the_pointbreak_operational_contract() { - for path in LIVING_OPERATIONAL_SOURCES { - let contents = - std::fs::read_to_string(path).unwrap_or_else(|error| panic!("read {path}: {error}")); - - for (index, line) in contents.lines().enumerate() { - let line_number = index + 1; - for (needle, purpose) in FORBIDDEN_LIVING_PATTERNS { - if line.contains(needle) && classify_retained_reference(path, line).is_none() { - panic!("{path}:{line_number} presents {purpose}: {line:?}"); - } - } - } - } } #[test] @@ -96,254 +41,6 @@ fn generic_store_guidance_does_not_present_a_literal_path_as_universal() { } } -#[test] -fn legacy_product_word_detection_is_case_insensitive_and_word_bounded() { - assert!(contains_legacy_reference("matched by Shore at scan time")); - assert!(contains_legacy_reference("run SHORE_CONFIG=/tmp/config")); - assert!(!contains_legacy_reference("shoreline fixture")); -} - -#[test] -fn every_retained_public_legacy_reference_has_a_narrow_classification() { - let mut paths = vec![ - Path::new("CONTRIBUTING.md").to_path_buf(), - Path::new("README.md").to_path_buf(), - Path::new("Justfile").to_path_buf(), - ]; - if Path::new("CHANGELOG.md").exists() { - paths.push(Path::new("CHANGELOG.md").to_path_buf()); - } - for root in ["docs", "skills", "scripts", "benches"] { - collect_files(Path::new(root), &mut paths); - } - for root in [ - "tests/fixtures/event_signatures", - "tests/fixtures/legacy_stores", - "tests/fixtures/naming-cutover", - "tests/fixtures/packages", - "tests/fixtures/review_documents", - ] { - collect_files(Path::new(root), &mut paths); - } - paths.extend( - [ - "tests/agent_skill_validation_evidence.rs", - "tests/docs_open_source_readiness.rs", - "tests/docs_package_identity.rs", - ] - .into_iter() - .map(Path::new) - .map(Path::to_path_buf), - ); - paths.sort(); - - for path in paths { - let audit_path = public_audit_path(&path); - let contents = std::fs::read_to_string(&path) - .unwrap_or_else(|error| panic!("read {audit_path}: {error}")); - for (index, line) in contents.lines().enumerate() { - if contains_legacy_reference(line) - && classify_retained_reference(&audit_path, line).is_none() - { - panic!( - "{}:{} has an unclassified legacy reference: {:?}", - audit_path, - index + 1, - line - ); - } - } - } -} - -const LIVING_OPERATIONAL_SOURCES: &[&str] = &[ - "CONTRIBUTING.md", - "README.md", - "docs/agent-authoring.md", - "docs/assessment-model.md", - "docs/benchmarking.md", - "docs/cli-reference.md", - "docs/getting-started.md", - "docs/id-prefixes.md", - "docs/input-request-model.md", - "docs/installation.md", - "docs/library-api.md", - "docs/manual-testing.md", - "docs/releasing.md", - "docs/review-workflow.md", - "docs/signing-ux.md", - "docs/storage-model.md", - "docs/substrate-language.md", - "docs/substrate-thesis-summary.md", - "Justfile", - "benches/store_backend.rs", - "scripts/capture-inspector-screenshots.sh", - "scripts/worktree-to-fixture.sh", - "skills/README.md", - "skills/pointbreak-author/SKILL.md", - "skills/pointbreak-author-response/SKILL.md", - "skills/pointbreak-reviewer/SKILL.md", -]; - -const FORBIDDEN_LIVING_PATTERNS: &[(&str, &str)] = &[ - ("shore ", "a legacy executable command"), - ( - "SHORE_", - "a legacy environment variable as current guidance", - ), - (".shore", "a legacy path as current placement"), - ("pointbreak review", "the rejected review command prefix"), - ("cargo install shore", "the retired package/install name"), - ("cargo binstall shore", "the retired package/install name"), - ("store migrate-paths", "a migration CLI that does not exist"), - ("automatically migrates", "automatic migration behavior"), - ("automatically migrate", "automatic migration behavior"), -]; - -fn collect_files(root: &Path, paths: &mut Vec) { - for entry in - std::fs::read_dir(root).unwrap_or_else(|error| panic!("read {}: {error}", root.display())) - { - let entry = entry.unwrap_or_else(|error| panic!("read directory entry: {error}")); - let file_type = entry - .file_type() - .unwrap_or_else(|error| panic!("read {} file type: {error}", entry.path().display())); - if file_type.is_dir() { - collect_files(&entry.path(), paths); - } else if file_type.is_file() { - paths.push(entry.path()); - } - } -} - -fn public_audit_path(path: &Path) -> String { - path.to_string_lossy().replace('\\', "/") -} - -fn contains_legacy_reference(line: &str) -> bool { - line.contains(".shore") - || line.contains("SHORE_") - || contains_ascii_word(&line.to_ascii_lowercase(), "shore") -} - -#[test] -fn public_audit_paths_are_platform_independent() { - let path = public_audit_path(Path::new(r"docs\adr\adr-0001-example.md")); - - assert_eq!(path, "docs/adr/adr-0001-example.md"); - assert_eq!( - classify_retained_reference(&path, "frozen shore.note-body identifier"), - Some("accepted ADR history") - ); -} - -fn contains_ascii_word(haystack: &str, needle: &str) -> bool { - haystack.match_indices(needle).any(|(start, _)| { - let end = start + needle.len(); - let before = haystack[..start].chars().next_back(); - let after = haystack[end..].chars().next(); - !before.is_some_and(is_word_character) && !after.is_some_and(is_word_character) - }) -} - -fn is_word_character(character: char) -> bool { - character.is_ascii_alphanumeric() || character == '_' -} - -fn classify_retained_reference(path: &str, line: &str) -> Option<&'static str> { - if path.starts_with("docs/adr/") { - return Some("accepted ADR history"); - } - if path == "CHANGELOG.md" { - return Some("published changelog history"); - } - if [ - "tests/fixtures/event_signatures/", - "tests/fixtures/legacy_stores/", - "tests/fixtures/naming-cutover/", - "tests/fixtures/packages/", - "tests/fixtures/review_documents/", - ] - .iter() - .any(|prefix| path.starts_with(prefix)) - { - return Some("frozen fixture or captured machine document"); - } - if [ - "tests/agent_skill_validation_evidence.rs", - "tests/docs_open_source_readiness.rs", - "tests/docs_package_identity.rs", - ] - .contains(&path) - { - return Some("test intentionally quoting rejected or historical strings"); - } - - match path { - "README.md" if line.contains("assets/shore-inspector-") => { - Some("checked-in screenshot basename") - } - "docs/installation.md" - if line.starts_with(" |") - && [ - "/.shore/", - "/shore/", - "/shore.link.json", - "$XDG_DATA_HOME/shore", - "$HOME/.shore", - "%APPDATA%\\shore", - ] - .iter() - .any(|old_path| line.contains(old_path)) => - { - Some("explicit pre-0.7.0 location in the cutover table") - } - "docs/assessment-model.md" - | "docs/cli-reference.md" - | "docs/input-request-model.md" - | "docs/library-api.md" - if line.contains("shore.") => - { - Some("frozen persisted protocol identifier") - } - "docs/storage-model.md" if line.contains("shore.") => { - Some("frozen persisted protocol identifier") - } - "docs/storage-model.md" if line.contains(".shore-write") => { - Some("frozen atomic-write temporary filename") - } - "Justfile" if line.contains("shore(\\.exe)?|--bin shore") => { - Some("negative release-surface assertion") - } - "scripts/capture-inspector-screenshots.sh" - if line.contains("shore-inspector-") || line.contains("shore-inspect-") => - { - Some("checked-in screenshot basename or inspector preference key") - } - "scripts/install-selftest.sh" - if line.contains("neighbor=\"${install_dir}/shore\"") - || line.contains("grep -i 'shore'") => - { - Some("negative installer assertion and untouched-neighbor sentinel") - } - "scripts/install-selftest.ps1" - if line.contains("$neighbor = Join-Path $installDir \"shore.exe\"") - || line.contains("-match \"(?i)shore\"") => - { - Some("negative installer assertion and untouched-neighbor sentinel") - } - "scripts/package-release-selftest.sh" - if line.contains("payload_dir/shore") - || line.contains("-C \"$payload_dir\" shore") - || line.contains("shore.exe") - || line.contains("ln -s shore") => - { - Some("intentionally invalid archive or alias fixture") - } - _ => None, - } -} - #[test] fn skills_distribution_uses_the_canonical_repository_route() { let skills_readme = @@ -430,26 +127,6 @@ fn vscode_metadata_keeps_its_identity_and_uses_canonical_support_urls() { assert!(package.contains("https://github.com/withpointbreak/pointbreak#readme")); } -#[test] -fn readme_drops_branded_hunk_origin_references() { - let readme = std::fs::read_to_string("README.md").expect("read README"); - - for stale in [ - "modem-dev/hunk", - "kevinswiber/hunk", - "docs/hunk-feedback.md", - "Hunk is the practical inspiration", - "real Hunk review session", - "hunk fork", - ] { - assert!( - !readme.contains(stale), - "README still contains stale Hunk reference: {stale}" - ); - } - assert!(!Path::new("docs/hunk-feedback.md").exists()); -} - #[test] fn just_run_targets_the_pointbreak_binary() { let justfile = std::fs::read_to_string("Justfile").expect("read Justfile"); From 9bb34f3e5182ff1190a5d5960375714c3fb32731 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 16:02:44 -0700 Subject: [PATCH 17/36] build: stop installing the clippy gate's target directory cargoClippy installed its compiled target directory as the derivation output: a ~250MB archive that nothing consumes, changed on every commit, and cost the build a 632MB -> 164MB compression pass to produce. It also dominated the CI store cache, where it was the one large entry with no reuse value between commits. Set doInstallCargoArtifacts = false, matching the nextest gate, which already produced an empty output for the same reason. The reusable part is the shared dependency artifacts, which are built separately. The clippy output is now 0B. --- flake.nix | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flake.nix b/flake.nix index 33e7880b..5f891c1c 100644 --- a/flake.nix +++ b/flake.nix @@ -290,6 +290,11 @@ cargoLock = ./Cargo.lock; cargoArtifacts = cargoArtifactsTest; CARGO_PROFILE = "test"; + # Nothing consumes this gate's compiled target directory, and installing + # it cost a ~250MB output that changed on every commit — dead weight in + # both the CI store cache and the build itself, which paid to compress + # it. The shared dependency artifacts above are the reusable part. + doInstallCargoArtifacts = false; env.POINTBREAK_BUILD_CHANNEL = "nix-dev"; cargoClippyExtraArgs = "--workspace --all-targets --all-features -- -D warnings"; # Build scripts run under clippy; build.rs shells out to git. From dcf73a1c92324f6c7cbbca8c1d3284dbd6e29bb7 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 16:06:50 -0700 Subject: [PATCH 18/36] ci: cache the Nix store with Magic Nix Cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cache-nix-action tarred the whole /nix/store into a single Actions cache entry: 4.87GB on Linux and 4.46GB on macOS, mostly paths cache.nixos.org already serves for free. Together they exceeded the 10GB repository limit on their own, so the two evicted each other and Linux ran with a cold store on every run, rebuilding the toolchain and every dependency. Magic Nix Cache stores individual store paths over the same GitHub Actions cache and skips anything fetchable from the upstream cache, so only this project's own outputs are kept — a few hundred megabytes rather than a whole-store snapshot. It still needs no external account. Disable its FlakeHub upload, which is a separate account-backed service this lane does not use. --- .github/workflows/ci-nix-windows-spike.yml | 8 +++++--- .github/workflows/ci-nix.yml | 22 ++++++++++++++-------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci-nix-windows-spike.yml b/.github/workflows/ci-nix-windows-spike.yml index 00aa6496..7eebfa79 100644 --- a/.github/workflows/ci-nix-windows-spike.yml +++ b/.github/workflows/ci-nix-windows-spike.yml @@ -43,10 +43,12 @@ jobs: steps: - uses: actions/checkout@v6 - uses: DeterminateSystems/nix-installer-action@main - - uses: nix-community/cache-nix-action@v6 + # Same store-cache reasoning as ci-nix.yml: keep only this project's own + # paths rather than a whole-store tarball, so the shared 10GB Actions cache + # budget is not exhausted by paths cache.nixos.org already serves. + - uses: DeterminateSystems/magic-nix-cache-action@main with: - primary-key: nix-wincross-${{ hashFiles('flake.lock', 'Cargo.lock') }} - restore-prefixes-first-match: nix-wincross- + use-flakehub: false # cargo-xwin fetches the MSVC CRT/SDK here (network is available in the step); # XWIN_ACCEPT_LICENSE is set inside the windows-cross dev shell. - name: cross-compile x86_64-pc-windows-msvc nextest archive diff --git a/.github/workflows/ci-nix.yml b/.github/workflows/ci-nix.yml index b201081b..116fb06d 100644 --- a/.github/workflows/ci-nix.yml +++ b/.github/workflows/ci-nix.yml @@ -52,15 +52,21 @@ jobs: - name: Install Nix uses: DeterminateSystems/nix-installer-action@main - # Persist /nix/store across runs through GitHub's own Actions cache — no - # external account (Cachix / FlakeHub) required. Without this, every run - # recompiles crane's dependency artifacts from source. Keyed on the inputs - # that actually invalidate the store; the prefix restore warms partial hits. - - name: Cache Nix store - uses: nix-community/cache-nix-action@v6 + # Binary cache over GitHub's own Actions cache — still no external account. + # + # This replaced cache-nix-action, which tarred the WHOLE /nix/store into one + # entry: 4.46GB on macOS, most of it paths cache.nixos.org already serves for + # free. Two of those plus the rustup lane's caches blew past the 10GB repo + # limit, so they evicted each other and Linux ran with a cold store every time. + # Magic Nix Cache stores individual paths and skips anything fetchable from + # the upstream cache, so only this project's own outputs are kept. + # + # use-flakehub is off: FlakeHub Cache is a separate (account-backed) service, + # and the GitHub Actions cache alone is what this lane needs. + - name: Magic Nix Cache + uses: DeterminateSystems/magic-nix-cache-action@main with: - primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', 'Cargo.lock', 'src/cli/inspect/web/package-lock.json', 'extensions/vscode/package-lock.json') }} - restore-prefixes-first-match: nix-${{ runner.os }}- + use-flakehub: false # `nix flake check` builds every check in one arbitrary-order batch, so it # cannot express "cheap gate first". Realising the checks individually does: From a01cad25e3428d387d80b33c8f6fbf61ae2c7e1f Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 16:19:09 -0700 Subject: [PATCH 19/36] ci: stop rebuilding the whole suite in the Nix lint job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job named "Format and lint" ran `just nix-check`, whose `nix flake check` builds every check — clippy, the full nextest suite, tool drift — with no store cache. It took 14-18 minutes and, since ci-nix.yml realises those same derivations on every pull request, it was running the entire suite a second time in parallel from cold. Split the Nix-file linting (nixfmt, statix, deadnix) into `just nix-lint` and point the workflow at it: it builds nothing and finishes in seconds. `just nix-check` keeps the previous behaviour for local use. `nix flake check --no-build` would not work as a cheap substitute here: cleanCargoSource filters a derivation output, so evaluation has to build the source derivation first. --- .github/workflows/nix.yml | 14 ++++++++++---- Justfile | 16 ++++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 6d9874df..88178d39 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -32,7 +32,13 @@ jobs: - name: Install just uses: taiki-e/install-action@just - # nixfmt --check + statix + deadnix + `nix flake check`. Runs only on Nix - # changes (see paths above); the Rust gate stays in ci.yml. - - name: Run nix-check - run: just nix-check + # nixfmt --check + statix + deadnix. Builds nothing, so this job matches its + # name and finishes in seconds. + # + # It used to run `just nix-check`, whose `nix flake check` BUILDS every check + # — clippy, the full nextest suite, tool drift — with no store cache, taking + # 14-18 minutes. Since ci-nix.yml realises those same derivations on every + # pull request, this job was running the entire suite a second time, in + # parallel, from cold. Linting the Nix files is what is unique to this lane. + - name: Run nix-lint + run: just nix-lint diff --git a/Justfile b/Justfile index 4c5b52b4..2328b889 100644 --- a/Justfile +++ b/Justfile @@ -198,17 +198,25 @@ nix-fmt: set -euo pipefail nix run nixpkgs#nixfmt -- $(git ls-files '*.nix') -# Lint and format-check Nix files: nixfmt, statix, deadnix, and `nix flake check`. -# Requires Nix. Deliberately separate from `just lint`/`check`, which stay -# Rust-only so contributors without Nix (mise/manual) can run the core gate. +# Requires Nix, but builds nothing, so it stays fast. This is the CI-facing Nix +# gate; the build and test outputs are gated by ci-nix.yml, which realises those +# derivations individually. +# Lint and format-check the Nix files themselves (nixfmt, statix, deadnix). [group('nix')] -nix-check: +nix-lint: #!/usr/bin/env bash set -euo pipefail files=$(git ls-files '*.nix') nix run nixpkgs#nixfmt -- --check $files nix run nixpkgs#statix -- check . nix run nixpkgs#deadnix -- --fail . + +# Deliberately separate from `just lint`/`check`, which stay Rust-only so +# contributors without Nix (mise/manual) can run the core gate. CI does not run +# this; ci-nix.yml realises the same derivations in phases instead. +# Run the Nix lint plus `nix flake check`, which BUILDS every check (fmt, clippy, the full test suite). +[group('nix')] +nix-check: nix-lint nix flake check # EXPERIMENTAL: cross-compile a cargo-nextest archive for a Windows msvc target from From 356e9388c5afae13d46dfbea39b868eee2e86385 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 17:43:13 -0700 Subject: [PATCH 20/36] ci: take the Linux and macOS toolchain from the flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI resolved its Rust toolchain through rustup while CONTRIBUTING.md tells contributors to set up with `nix develop`, so the compiler that gated a change was not the one a contributor built with. Point the Linux and macOS legs at the flake instead: `nix develop .#ci -c just ` runs the existing recipes against the toolchain pinned by flake.lock, keeping rust-cache and the Windows sharding that make this lane fast. Windows still uses rustup, because Nix has no native Windows support; retiring that needs the cross-compiled nextest archive to graduate out of its report-only phase. Add a lean `.#ci` dev shell for this. The default shell carries the interactive extras — cocogitto, which is built from source here, plus gh and cargo-edit — that a workflow never uses. Reduce the parallel Nix workflow to what only it can check. It was re-running clippy and the whole suite on the same code through the same toolchain, which cost roughly 1.5x for a duplicate answer. What is unique is that the flake still evaluates, the dev shell still carries its pinned tools, and a hermetic no-network build of the shipped artifact still succeeds. The sandbox-only regressions that drops — a test needing a tool the derivation does not provide — move to a nightly full flake check. --- .github/workflows/ci-nix.yml | 125 ++++++++++++++--------------------- .github/workflows/ci.yml | 31 ++++++++- flake.nix | 23 +++++++ tests/github_actions.rs | 19 ++++-- 4 files changed, 116 insertions(+), 82 deletions(-) diff --git a/.github/workflows/ci-nix.yml b/.github/workflows/ci-nix.yml index 116fb06d..8494ace8 100644 --- a/.github/workflows/ci-nix.yml +++ b/.github/workflows/ci-nix.yml @@ -1,102 +1,79 @@ -name: CI (Nix) +name: Flake health -# Experimental, parallel Nix-driven variant of ci.yml. It does NOT replace ci.yml; -# the two run side by side while we evaluate whether a hermetic, store-cached gate -# is worth adopting. Linux and macOS only: Nix has no native Windows support, so the -# Windows legs stay in ci.yml. See the PR discussion for the Windows cross-compile -# sketch (nextest archive built on Linux, executed on a plain Windows runner). +# Guards the flake itself, which CONTRIBUTING.md names as the recommended way to set +# up the project (`nix develop`) and which now also supplies ci.yml's toolchain on +# Linux and macOS. If the flake breaks, the documented first-choice setup breaks with +# it, so something has to exercise it. # -# The flake already carries the full gate: -# - `nix flake check` -> the same fmt + clippy + test gate ci.yml runs, through the -# flake's pinned Fenix toolchain instead of rustup: -# checks.cli-nextest (full Git-less nextest suite), -# checks.clippy (`-D warnings`) and checks.fmt (nightly -# rustfmt) — clippy and the tests share crane's dependency -# artifacts — plus checks.devshell-tools (tool-version drift). -# cli-nextest compiles the whole crate and all test targets, so -# it covers the same cfg/feature arms ci.yml's macOS -# `check-types` leg does. -# - `nix build .#build-all` -> the store-backed counterpart of `just build-all` -# (CLI + embedded Inspector bundle + platform VSIX), with no -# checkout mutation and no npm network access. Because the -# Inspector bundle is rebuilt here, this sidesteps ci.yml's -# `assets/app.js` staleness gate entirely. +# It deliberately does NOT re-run clippy or the test suite. ci.yml already runs those, +# on the same code, through this same flake toolchain — paying for them twice bought a +# roughly 1.5x slower duplicate gate. What is unique to Nix is whether the flake still +# evaluates, whether the dev shell still carries the pinned tools, and whether a fully +# hermetic build of the shippable artifact still succeeds with no network. +# +# `nix build .#build-all` covers most of that transitively: it needs the crane +# dependency artifacts (so every dependency must still compile in the sandbox), the +# CLI, an offline npm build of the Inspector bundle, and the VSIX packaging step. +# +# The sandbox-only regressions this does not catch — a test that needs a tool absent +# from the nextest derivation's nativeBuildInputs — are covered by the nightly full +# `nix flake check` below rather than on every pull request. on: push: branches: [main] pull_request: + schedule: + # Nightly, for the full hermetic check that is too slow to gate pull requests. + - cron: "0 7 * * *" + workflow_dispatch: permissions: contents: read concurrency: - group: ci-nix-${{ github.ref }} + group: flake-health-${{ github.ref }} cancel-in-progress: true -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - jobs: - nix: - name: nix gate (${{ matrix.os }}) - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} + flake-health: + name: flake health + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - - name: Install Nix - uses: DeterminateSystems/nix-installer-action@main - - # Binary cache over GitHub's own Actions cache — still no external account. - # - # This replaced cache-nix-action, which tarred the WHOLE /nix/store into one - # entry: 4.46GB on macOS, most of it paths cache.nixos.org already serves for - # free. Two of those plus the rustup lane's caches blew past the 10GB repo - # limit, so they evicted each other and Linux ran with a cold store every time. - # Magic Nix Cache stores individual paths and skips anything fetchable from - # the upstream cache, so only this project's own outputs are kept. - # - # use-flakehub is off: FlakeHub Cache is a separate (account-backed) service, - # and the GitHub Actions cache alone is what this lane needs. - - name: Magic Nix Cache - uses: DeterminateSystems/magic-nix-cache-action@main + - uses: DeterminateSystems/nix-installer-action@main + - uses: DeterminateSystems/magic-nix-cache-action@main with: use-flakehub: false - # `nix flake check` builds every check in one arbitrary-order batch, so it - # cannot express "cheap gate first". Realising the checks individually does: - # each step below is an explicit dependency of the next, and a failure stops - # the job before the expensive phases run. - # - # Logs are compact by default; the failure step at the end dumps the full - # build log of whatever failed, which is the idiomatic Nix pattern (`-L` on - # every step is what made these runs unreadable). + # The dev shell still resolves and its pinned tools are the expected versions. + - name: dev shell tools + run: nix build .#devshell-tools - # Phase 1 — formatting. Compiles nothing, so this is a true early return. - - name: fmt - run: nix build .#cli-fmt + # A hermetic, network-free build of everything the project ships. + - name: hermetic artifact build + run: nix build .#build-all - # Phase 2 — clippy. Linux only, mirroring ci.yml: the lint result is - # platform-independent, and `--all-targets` compiles the whole test surface, - # so running it on both platforms bought a second full compile for nothing. - - name: clippy - if: runner.os == 'Linux' - run: nix build .#cli-clippy + - name: dump failing build log + if: failure() + run: nix log "$(nix eval --raw .#build-all.drvPath)" 2>/dev/null | tail -100 || true - # Phase 3 — the full Git-less nextest suite, plus the pinned-tool drift check. - - name: test - run: nix build .#cli-nextest .#devshell-tools + full-check: + name: full flake check (nightly) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@main + - uses: DeterminateSystems/magic-nix-cache-action@main + with: + use-flakehub: false - # Phase 4 — hermetic artifact build (CLI + embedded Inspector + VSIX). - - name: build - run: nix build .#build-all + # Builds every check, including the whole suite inside the sandbox. This is the + # only thing that catches a test needing a tool the derivation does not provide. + - name: nix flake check + run: nix flake check - # Nix prints only a summary on failure; surface the actual build log. - name: dump failing build log if: failure() run: nix log "$(nix eval --raw .#cli-nextest.drvPath)" 2>/dev/null | tail -150 || true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fc9849a..c0ecc7c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,12 +110,14 @@ jobs: os: ubuntu-latest, gate: lint, run_tests: true, + nix: true, } - { name: "test (macos-latest)", os: macos-latest, gate: check, run_tests: true, + nix: true, } - { name: "test (windows-latest check)", @@ -147,30 +149,53 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 + + # Linux and macOS take their toolchain from the flake, so CI compiles with the + # exact rustc, nightly rustfmt, and nextest that `nix develop` gives a + # contributor — pinned by flake.lock rather than resolved by rustup at run time. + # `.#ci` is a lean shell: no cocogitto (built from source), gh, or cargo-edit. + # + # Windows keeps rustup because Nix has no native Windows support. Removing that + # last rustup dependency needs the cross-compiled nextest archive from + # ci-nix-windows-spike.yml to graduate out of its report-only phase. + - uses: DeterminateSystems/nix-installer-action@main + if: matrix.nix + - uses: DeterminateSystems/magic-nix-cache-action@main + if: matrix.nix + with: + use-flakehub: false - uses: dtolnay/rust-toolchain@stable + if: ${{ !matrix.nix }} with: components: rustfmt, clippy - uses: dtolnay/rust-toolchain@nightly + if: ${{ !matrix.nix }} with: components: rustfmt - uses: taiki-e/install-action@just + if: ${{ !matrix.nix }} - uses: taiki-e/install-action@nextest + if: ${{ !matrix.nix }} + + # Still worth caching on every leg: cargo runs in the workspace here (unlike a + # sandboxed `nix build`), so target/ persists and stays incremental. - uses: Swatinem/rust-cache@v2 with: shared-key: ${{ runner.os == 'Windows' && 'windows-shards' || '' }} + - name: just lint if: matrix.gate == 'lint' - run: just lint + run: ${{ matrix.nix && 'nix develop .#ci -c' || '' }} just lint - name: just check-types if: matrix.gate == 'check' - run: just check-types + run: ${{ matrix.nix && 'nix develop .#ci -c' || '' }} just check-types - name: configure Windows test debuginfo if: runner.os == 'Windows' shell: bash run: echo "CARGO_PROFILE_TEST_SPLIT_DEBUGINFO=packed" >> "$GITHUB_ENV" - name: just test-ci if: matrix.run_tests - run: just test-ci ${{ matrix.shard && format('--partition slice:{0}/{1}', matrix.shard, matrix.shards) || '' }} + run: ${{ matrix.nix && 'nix develop .#ci -c' || '' }} just test-ci ${{ matrix.shard && format('--partition slice:{0}/{1}', matrix.shard, matrix.shards) || '' }} store-foundation-qualification: name: store foundation qualification (${{ matrix.os }}) diff --git a/flake.nix b/flake.nix index 5f891c1c..210a9289 100644 --- a/flake.nix +++ b/flake.nix @@ -151,6 +151,29 @@ ''; }; + # The toolchain CI needs and nothing else. `nix develop .#ci -c just ` + # gives the workflow the same pinned compiler, formatter, and nextest a + # contributor gets, without realising the interactive extras: cocogitto is + # built from source here, and gh/cargo-edit have no CI consumer. jq, Node, + # and git are kept because the test suite shells out to all three. + ci = pkgs.mkShell { + packages = [ + rustToolchains.dev + pkgs.cargo-nextest + pkgs.just + pkgs.git + pkgs.jq + pkgs.nodejs_22 + pkgs.pkg-config + ]; + # The Justfile selects rustup's `+stable`/`+nightly` toolchains by default; + # this shell has one Fenix toolchain whose rustfmt is already nightly. + env = { + POINTBREAK_CARGO_STABLE = "cargo"; + POINTBREAK_CARGO_NIGHTLY = "cargo"; + }; + }; + # Experimental Windows-msvc cross shell. Produces a cargo-nextest archive # (prebuilt test binaries) that runs on a real Windows machine needing no # Rust toolchain. cargo-xwin fetches the MSVC CRT/SDK on first use, which diff --git a/tests/github_actions.rs b/tests/github_actions.rs index 26812ed9..aa9df471 100644 --- a/tests/github_actions.rs +++ b/tests/github_actions.rs @@ -6,15 +6,24 @@ fn ci_workflow_runs_project_lint_and_tests() { assert!(ci.contains("branches: [main]")); assert!(ci.contains("pull_request:")); assert!(ci.contains("actions/checkout@v6")); + assert!(ci.contains("ubuntu-latest")); + assert!(ci.contains("macos-latest")); + assert!(ci.contains("windows-latest")); + assert!(ci.contains("just lint")); + assert!(ci.contains("just test-ci")); + + // Linux and macOS compile with the flake's pinned toolchain, so CI and a + // contributor's `nix develop` agree on the compiler and formatter. + assert!(ci.contains("DeterminateSystems/nix-installer-action")); + assert!(ci.contains("nix develop .#ci -c")); + + // Windows still resolves its toolchain through rustup: Nix has no native + // Windows support. Retiring these needs the cross-compiled nextest archive + // to graduate out of its report-only phase. assert!(ci.contains("dtolnay/rust-toolchain@stable")); assert!(ci.contains("dtolnay/rust-toolchain@nightly")); assert!(ci.contains("taiki-e/install-action@just")); assert!(ci.contains("taiki-e/install-action@nextest")); - assert!(ci.contains("ubuntu-latest")); - assert!(ci.contains("macos-latest")); - assert!(ci.contains("windows-latest")); - assert!(ci.contains("run: just lint")); - assert!(ci.contains("run: just test-ci")); } #[test] From 14040a98c7051041eef9c5df3d4da7b546c50d02 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 18:24:20 -0700 Subject: [PATCH 21/36] ci: gate pull requests on the hermetic Nix suite again Reinstate the phased pure-Nix gate on Linux for every pull request. Both lanes now share the flake-pinned toolchain, so this is not re-checking lints ci.yml already runs; what it establishes is that the suite passes in a sandbox, with no host tools, no network, and no `target/` restored from a cache. ci.yml gets its speed from that restored target, which makes a stale-artifact false green possible there and impossible here. macOS stays nightly rather than per pull request: it measured 22m37s against Linux's 15m46s, which is not worth adding to every change for a platform ci.yml already covers. The nightly job runs `nix flake check` rather than the named phases, so a check added to the flake cannot be silently left ungated by the explicit phase list. --- .github/workflows/ci-nix.yml | 84 ++++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci-nix.yml b/.github/workflows/ci-nix.yml index 8494ace8..1b3ba7ba 100644 --- a/.github/workflows/ci-nix.yml +++ b/.github/workflows/ci-nix.yml @@ -1,30 +1,24 @@ -name: Flake health +name: CI (Nix) -# Guards the flake itself, which CONTRIBUTING.md names as the recommended way to set -# up the project (`nix develop`) and which now also supplies ci.yml's toolchain on -# Linux and macOS. If the flake breaks, the documented first-choice setup breaks with -# it, so something has to exercise it. +# The hermetic counterpart to ci.yml. Both lanes now compile with the same flake-pinned +# toolchain, so this is not here to re-check the lints or the assertions — ci.yml covers +# those, and a broken flake already fails it because it runs `nix develop .#ci`. # -# It deliberately does NOT re-run clippy or the test suite. ci.yml already runs those, -# on the same code, through this same flake toolchain — paying for them twice bought a -# roughly 1.5x slower duplicate gate. What is unique to Nix is whether the flake still -# evaluates, whether the dev shell still carries the pinned tools, and whether a fully -# hermetic build of the shippable artifact still succeeds with no network. +# What only this lane can establish is that the suite passes in a SANDBOX: no host +# tools leaking in, no network, and no reuse of a `target/` directory restored from a +# cache. ci.yml's speed comes precisely from that restored target/, which makes a +# stale-artifact false green possible there and impossible here. # -# `nix build .#build-all` covers most of that transitively: it needs the crane -# dependency artifacts (so every dependency must still compile in the sandbox), the -# CLI, an offline npm build of the Inspector bundle, and the VSIX packaging step. -# -# The sandbox-only regressions this does not catch — a test that needs a tool absent -# from the nextest derivation's nativeBuildInputs — are covered by the nightly full -# `nix flake check` below rather than on every pull request. +# Linux gates every pull request. macOS runs the same thing nightly rather than per +# pull request: it measured 22m37s against Linux's 15m46s, which is not worth adding +# to every change for a platform ci.yml already tests. on: push: branches: [main] pull_request: schedule: - # Nightly, for the full hermetic check that is too slow to gate pull requests. + # Nightly, for the platforms not gated per pull request. - cron: "0 7 * * *" workflow_dispatch: @@ -32,48 +26,72 @@ permissions: contents: read concurrency: - group: flake-health-${{ github.ref }} + group: ci-nix-${{ github.ref }} cancel-in-progress: true +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + jobs: - flake-health: - name: flake health + nix-gate: + name: nix gate (ubuntu-latest) + if: github.event_name != 'schedule' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: DeterminateSystems/nix-installer-action@main + + # Stores individual store paths over the GitHub Actions cache and skips anything + # fetchable from cache.nixos.org. Its predecessor tarred the whole /nix/store + # (4.87GB on Linux, 4.46GB on macOS), which alone exceeded the 10GB repository + # limit, so the two platforms evicted each other and every run started cold. - uses: DeterminateSystems/magic-nix-cache-action@main with: use-flakehub: false - # The dev shell still resolves and its pinned tools are the expected versions. - - name: dev shell tools - run: nix build .#devshell-tools + # `nix flake check` builds every check in one arbitrary-order batch, so it cannot + # express "cheap gate first". Realising them individually can: each step is a + # dependency of the next, and a failure stops the job before the expensive phases. + + # Phase 1 — formatting. Compiles nothing, so this is a true early return. + - name: fmt + run: nix build .#cli-fmt + + # Phase 2 — clippy, with the shared test-profile dependency artifacts. + - name: clippy + run: nix build .#cli-clippy - # A hermetic, network-free build of everything the project ships. - - name: hermetic artifact build + # Phase 3 — the full suite in the sandbox, plus pinned-tool drift. + - name: test + run: nix build .#cli-nextest .#devshell-tools + + # Phase 4 — hermetic, network-free build of everything the project ships. + - name: build run: nix build .#build-all - name: dump failing build log if: failure() - run: nix log "$(nix eval --raw .#build-all.drvPath)" 2>/dev/null | tail -100 || true + run: nix log "$(nix eval --raw .#cli-nextest.drvPath)" 2>/dev/null | tail -150 || true full-check: - name: full flake check (nightly) + name: full flake check (${{ matrix.os }}) + # Nightly and on demand only. Runs `nix flake check` rather than the named phases + # above so that a check added to the flake cannot be silently left ungated. if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 - uses: DeterminateSystems/nix-installer-action@main - uses: DeterminateSystems/magic-nix-cache-action@main with: use-flakehub: false - - # Builds every check, including the whole suite inside the sandbox. This is the - # only thing that catches a test needing a tool the derivation does not provide. - name: nix flake check run: nix flake check - - name: dump failing build log if: failure() run: nix log "$(nix eval --raw .#cli-nextest.drvPath)" 2>/dev/null | tail -150 || true From a1df8ae10ad8bef8dd7683ac7174c2d52631b515 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 18:34:39 -0700 Subject: [PATCH 22/36] docs: record why CI is split the way it is This session replaced the toolchain source, the store cache, the build profile, and the division of labour between the two lanes, each on the back of a measurement that is not visible in the resulting YAML. Several of the choices read as mistakes without it: a test profile where release is expected, a clippy gate that installs nothing while nextest does, a lint job that no longer runs the checks, an unusual cache action, and no rust-cache on the Nix lane. Add docs/ci-architecture.md with the lane split, the numbers behind each decision, the alternatives that were measured and rejected, and the conditions that should reopen them. Point the workflows and the flake at the relevant section from the place the decision is expressed, so a future editor meets the rationale before undoing it. --- .github/workflows/ci-nix-windows-spike.yml | 3 + .github/workflows/ci-nix.yml | 17 ++- .github/workflows/ci.yml | 8 +- .github/workflows/nix.yml | 1 + README.md | 2 + docs/ci-architecture.md | 156 +++++++++++++++++++++ flake.nix | 4 + 7 files changed, 182 insertions(+), 9 deletions(-) create mode 100644 docs/ci-architecture.md diff --git a/.github/workflows/ci-nix-windows-spike.yml b/.github/workflows/ci-nix-windows-spike.yml index 7eebfa79..9a0eb249 100644 --- a/.github/workflows/ci-nix-windows-spike.yml +++ b/.github/workflows/ci-nix-windows-spike.yml @@ -14,6 +14,9 @@ name: CI (Nix Windows cross — spike) # package_identity::cargo_install_exposes_only_pointbreak_executable, intentionally builds # the crate via `cargo install` — the sole exception to the no-build premise (~2-3 min). # +# See docs/ci-architecture.md#windows for what has to change before this can replace +# rustup on the Windows legs. +# # Still report-only (continue-on-error) until this is confirmed green on the actual x86_64 # windows-latest runner a few times: an x64 run may surface genuine platform byte/behaviour # differences the arm64 validation did not. Promote to gating (and to pull_request) once diff --git a/.github/workflows/ci-nix.yml b/.github/workflows/ci-nix.yml index 1b3ba7ba..7dee79bd 100644 --- a/.github/workflows/ci-nix.yml +++ b/.github/workflows/ci-nix.yml @@ -42,17 +42,20 @@ jobs: - uses: actions/checkout@v6 - uses: DeterminateSystems/nix-installer-action@main - # Stores individual store paths over the GitHub Actions cache and skips anything - # fetchable from cache.nixos.org. Its predecessor tarred the whole /nix/store - # (4.87GB on Linux, 4.46GB on macOS), which alone exceeded the 10GB repository - # limit, so the two platforms evicted each other and every run started cold. + # DO NOT swap back to cache-nix-action: it tars the whole /nix/store, which + # measured 4.87GB (Linux) + 4.46GB (macOS) against a 10GB repository limit, so + # the platforms evicted each other and every run started cold. This stores + # individual paths and skips anything fetchable from cache.nixos.org. + # (Magic Nix Cache broke in Feb 2025 and was revived in Jun 2025 — it is alive.) + # See docs/ci-architecture.md#store-caching-uses-magic-nix-cache. - uses: DeterminateSystems/magic-nix-cache-action@main with: use-flakehub: false - # `nix flake check` builds every check in one arbitrary-order batch, so it cannot - # express "cheap gate first". Realising them individually can: each step is a - # dependency of the next, and a failure stops the job before the expensive phases. + # Named individually on purpose: `nix flake check` batches every check in + # arbitrary order and so cannot return early on a cheap failure. The nightly job + # below runs the un-named form so an added check cannot be left ungated. + # See docs/ci-architecture.md#the-pr-gate-names-its-checks-the-nightly-one-does-not. # Phase 1 — formatting. Compiles nothing, so this is a true early return. - name: fmt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0ecc7c0..691c98b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,6 +158,7 @@ jobs: # Windows keeps rustup because Nix has no native Windows support. Removing that # last rustup dependency needs the cross-compiled nextest archive from # ci-nix-windows-spike.yml to graduate out of its report-only phase. + # See docs/ci-architecture.md#toolchain-comes-from-the-flake. - uses: DeterminateSystems/nix-installer-action@main if: matrix.nix - uses: DeterminateSystems/magic-nix-cache-action@main @@ -177,8 +178,11 @@ jobs: - uses: taiki-e/install-action@nextest if: ${{ !matrix.nix }} - # Still worth caching on every leg: cargo runs in the workspace here (unlike a - # sandboxed `nix build`), so target/ persists and stays incremental. + # Belongs here and NOT on the Nix lane: rust-cache caches ~/.cargo and ./target, + # and `nix build` runs in a sandbox that never writes the workspace target/, so + # there it would cache an empty directory. Here cargo runs in the workspace, so + # target/ persists and stays incremental. + # See docs/ci-architecture.md#rejected-alternatives. - uses: Swatinem/rust-cache@v2 with: shared-key: ${{ runner.os == 'Windows' && 'windows-shards' || '' }} diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 88178d39..3807679f 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -35,6 +35,7 @@ jobs: # nixfmt --check + statix + deadnix. Builds nothing, so this job matches its # name and finishes in seconds. # + # See docs/ci-architecture.md#nixyml-lints-nix-files-and-nothing-else. # It used to run `just nix-check`, whose `nix flake check` BUILDS every check # — clippy, the full nextest suite, tool drift — with no store cache, taking # 14-18 minutes. Since ci-nix.yml realises those same derivations on every diff --git a/README.md b/README.md index 2be892b3..aea87fda 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ For contributors and maintainers: - [CONTRIBUTING.md](CONTRIBUTING.md) - setup, hooks, branch names, commits, tests, and PR flow. - [docs/development.md](docs/development.md) - change-to-gate matrix, generated artifacts, and failure interpretation. +- [docs/ci-architecture.md](docs/ci-architecture.md) - why CI is split into the lanes it is, what + was measured, and what would justify changing it. - [scripts/README.md](scripts/README.md) - script ownership, preferred entrypoints, side effects, and expected outcomes. - [docs/releasing.md](docs/releasing.md) - release planning and publish automation. diff --git a/docs/ci-architecture.md b/docs/ci-architecture.md new file mode 100644 index 00000000..b97842c6 --- /dev/null +++ b/docs/ci-architecture.md @@ -0,0 +1,156 @@ +# CI architecture + +Why continuous integration is split the way it is, what was measured, and what would justify +changing it again. [docs/development.md](development.md) covers which gate a given change needs; +this file covers why the gates are built the way they are. + +The workflows carry short comments at each decision point that name the rule and point here. If +you are about to change one of them, read the matching section first — several of the current +choices look like oversights until you see the number behind them. + +## The two lanes + +| Workflow | Runs | Purpose | +| --- | --- | --- | +| `ci.yml` | every PR, all three platforms | The gate. Lint, type-check, and the full suite, with `rust-cache` and Windows sharding. | +| `ci-nix.yml` | every PR (Linux); nightly (Linux + macOS) | The hermetic check: the suite in a sandbox, plus a network-free build of the shipped artifact. | +| `nix.yml` | PRs touching `*.nix` | Lints the Nix files themselves. Builds nothing. | +| `ci-nix-windows-spike.yml` | spike branch only | Report-only experiment: cross-compile the tests on Linux, run them on Windows. | + +Both lanes compile with the same flake-pinned toolchain, so `ci-nix.yml` is not a second opinion +on the lints. Its unique contribution is stated in [Why keep a Nix lane](#why-keep-a-nix-lane). + +## Toolchain comes from the flake + +`CONTRIBUTING.md` tells contributors to set up with `nix develop`. When CI resolved its own +toolchain through rustup, the compiler that gated a change was not the compiler contributors +built with. Linux and macOS now run `nix develop .#ci -c just `, so both agree, pinned by +`flake.lock`. + +`.#ci` is a separate, lean shell. The default shell carries interactive extras — cocogitto, which +is built from source in this flake, plus `gh` and `cargo-edit` — that no workflow uses. + +**Windows still uses rustup**, because Nix has no native Windows support. Retiring that is the +open item; see [Windows](#windows). + +## Why keep a Nix lane + +`ci.yml` gets its speed from a `rust-cache`-restored `target/` and a mutable working directory. +That makes a stale-artifact false green *possible* there. In a Nix derivation it is impossible: +no host tools, no network, nothing reused from a cache. That property, on Linux, is the entire +reason the lane exists. + +Linux gates every PR. macOS runs nightly instead, for two reasons: it measured 22m37s against +Linux's 15m46s, and **Nix does not sandbox builds on macOS by default** (`sandbox = false` is the +Darwin default), so the hermeticity argument is weaker there anyway. + +## Decisions, with the numbers + +### Test gates build with `CARGO_PROFILE = "test"` + +Crane defaults every derivation to `release`, so the gates were paying optimized codegen for +throwaway test binaries. Cargo's `test` profile is what `just lint` and `just test` use locally, +and what crane's own checks use. + +| | release | test | +| --- | --- | --- | +| crate + test binaries compile | 2m54s | **25s** | +| suite execution | 253s | **179s** | + +Both axes improved; release was actively hurting. The delivery build keeps its own release +artifacts, so `nix build .#build-all` is still optimized. + +### The clippy gate installs no cargo artifacts + +`cargoClippy` used to install its compiled target directory as the derivation output: a +164–255MB archive that nothing consumed, that changed on every commit, and that cost the build a +632MB → 164MB compression pass. `cargoNextest` already set `doInstallCargoArtifacts = false` for +the same reason. Clippy's output is now 0B. The reusable part is the shared dependency artifacts, +built separately. + +### The PR gate names its checks; the nightly one does not + +`nix flake check` builds every check in one arbitrary-order batch, so it cannot express "cheap +gate first". Naming the checks individually can, which is why the PR job runs +`fmt → clippy → test → build` and stops at the first failure. + +The cost is that the explicit list can drift from the flake's actual `checks`. The nightly job +runs `nix flake check` precisely so a newly added check cannot be silently left ungated. + +### Store caching uses Magic Nix Cache + +`cache-nix-action` tars the whole `/nix/store` into one entry per platform. Measured here: 4.87GB +on Linux and 4.46GB on macOS — **9.33GB before counting anything else**, against GitHub's 10GB +per-repository limit. The two platforms evicted each other, and Linux ran with a cold store on +every run. + +Magic Nix Cache stores individual paths and skips anything fetchable from `cache.nixos.org`, so +only this project's own outputs are kept (~3.6GB across both platforms). It needs no external +account. `use-flakehub: false` keeps it on the Actions cache; FlakeHub Cache is a separate +account-backed service. + +> Magic Nix Cache broke in February 2025 when GitHub retired the old Actions Cache API, and was +> widely described as dead. It was revived in June 2025 against the new API. Check its current +> state before acting on any advice about it. + +### `nix.yml` lints Nix files and nothing else + +It used to run `just nix-check`, whose `nix flake check` builds every check — clippy, the whole +suite, tool drift — with no store cache. A job named "Format and lint" was taking 14–18 minutes +and duplicating `ci-nix.yml` from cold. It now runs `just nix-lint` (nixfmt, statix, deadnix) in +about a second. `just nix-check` keeps the fuller behaviour for local use. + +`nix flake check --no-build` is not a cheap substitute: `cleanCargoSource` filters a derivation +output, so evaluation has to build the source derivation first (import-from-derivation). + +## Rejected alternatives + +**A full Nix gate on both platforms, per PR.** Ran fmt, clippy, tests, and the artifact build on +Linux and macOS. It re-checked lints `ci.yml` already covers, on the same code with the same +toolchain, for roughly 1.5× the time — and macOS was the wall-clock long pole at 22m37s. Only the +sandboxed suite was unique, so only that was kept. + +**`Swatinem/rust-cache` on the Nix lane.** Frequently suggested, but it caches `~/.cargo` and +`./target`, and `nix build` runs in a sandbox that never writes the workspace `target/`. It would +cache an empty directory. It is the right tool for `ci.yml`, which does run cargo in the +workspace, and it is used there. + +**`nix-options: "sandbox = false"` on macOS.** Suggested as a macOS speed-up. It is a no-op: +`sandbox = false` is already the Darwin default (`/etc/nix/nix.conf` sets no `sandbox` line and +Nix still reports `false`). On Linux it would be actively harmful, deleting the one property the +Nix lane exists to provide. It remains a legitimate *debugging* lever for a derivation that fails +only under the sandbox. + +**Cachix.** Would work — the footprint is ~3.6GB against a 5GB free open-source tier — but it +needs an account and a token secret, and Magic Nix Cache solved the eviction problem without +either. Worth revisiting if the cache is ever needed outside CI. + +## Revisit triggers + +- **`ci.yml` produces a false green traced to a stale `target/`.** The strongest argument for + moving more of the gate into derivations. +- **Actions cache pressure returns.** The 10GB limit is shared; both lanes' caches coexist only + because the whole-store tarballs are gone. If usage approaches the ceiling again, the next step + is a real binary cache (Cachix or FlakeHub), not more tuning. +- **macOS runners get materially faster,** or a macOS-specific sandbox regression appears — then + reconsider gating macOS per PR rather than nightly. +- **Nix gains native Windows support,** or the cross-compile spike graduates — either removes the + last rustup dependency. +- **Test execution starts dominating compile time.** The `test` profile was chosen when compiling + dominated; if that inverts, re-measure `release`. + +## Windows + +`ci-nix-windows-spike.yml` cross-compiles the suite to `x86_64-pc-windows-msvc` on Linux with +cargo-xwin and runs the resulting `cargo nextest` archive on a plain Windows runner that builds no +Rust at all. It works: validated against an ARM64 Windows machine at 2924/2924 after the test +suite was changed to resolve its binary and fixtures at runtime rather than through compile-time +`env!()`. + +It stays report-only because it is not yet competitive on wall-clock: 15m20s (cross-compile then +run, serialized) against 10m02s for the sharded rustup legs. Sharding the Windows run the way +`ci.yml` shards it is the work required before it can replace rustup on Windows. + +One test is an intentional exception to the no-build premise: +`package_identity::cargo_install_exposes_only_pointbreak_executable` builds the crate on the +runner by design. diff --git a/flake.nix b/flake.nix index 210a9289..385ecd7a 100644 --- a/flake.nix +++ b/flake.nix @@ -246,6 +246,9 @@ }; # Dependencies for the quality gates, built with Cargo's `test` profile. + # NOT an oversight and NOT to be "fixed" to release: release made the crate + # take 2m54s to compile against 25s here, and the suite 253s against 179s. + # See docs/ci-architecture.md#test-gates-build-with-cargo_profile--test. # Crane defaults every derivation to `release`, which made the gates pay # optimized codegen for throwaway test binaries; `just lint`/`just test` # compile with the dev-inheriting `test` profile, so the gates now match @@ -313,6 +316,7 @@ cargoLock = ./Cargo.lock; cargoArtifacts = cargoArtifactsTest; CARGO_PROFILE = "test"; + # See docs/ci-architecture.md#the-clippy-gate-installs-no-cargo-artifacts. # Nothing consumes this gate's compiled target directory, and installing # it cost a ~250MB output that changed on every commit — dead weight in # both the CI store cache and the build itself, which paid to compress From f74f93044e8a9fcbb85dce6c0d49f0e51f8bbc32 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 19:06:07 -0700 Subject: [PATCH 23/36] ci: cache the Nix store with hestia MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Magic Nix Cache stored one Actions cache entry per store path — 1788 of them here — and the resulting API call volume tripped GitHub's rate limiter. On throttle it logs "Not trying to use it again on this run" and disables itself, so the job silently finishes against a cold store while still paying to build everything. That is the opposite ceiling from the approach before it: cache-nix-action tarred the whole store into one entry per platform, 4.87GB on Linux plus 4.46GB on macOS against a 10GB storage limit, so the two evicted each other. Hestia packs paths into content-defined chunks — few large entries — which is the point between the two. It needs no account either; uploads authenticate with the runner-injected ACTIONS_RUNTIME_TOKEN. Pin it by commit SHA, as this repository already pins its other third-party actions, and record all three approaches and their failure modes in the CI architecture notes. --- .github/workflows/ci-nix-windows-spike.yml | 4 +- .github/workflows/ci-nix.yml | 23 +++++------ .github/workflows/ci.yml | 4 +- docs/ci-architecture.md | 48 ++++++++++++++-------- 4 files changed, 43 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci-nix-windows-spike.yml b/.github/workflows/ci-nix-windows-spike.yml index 9a0eb249..8411c3c0 100644 --- a/.github/workflows/ci-nix-windows-spike.yml +++ b/.github/workflows/ci-nix-windows-spike.yml @@ -49,9 +49,7 @@ jobs: # Same store-cache reasoning as ci-nix.yml: keep only this project's own # paths rather than a whole-store tarball, so the shared 10GB Actions cache # budget is not exhausted by paths cache.nixos.org already serves. - - uses: DeterminateSystems/magic-nix-cache-action@main - with: - use-flakehub: false + - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 # cargo-xwin fetches the MSVC CRT/SDK here (network is available in the step); # XWIN_ACCEPT_LICENSE is set inside the windows-cross dev shell. - name: cross-compile x86_64-pc-windows-msvc nextest archive diff --git a/.github/workflows/ci-nix.yml b/.github/workflows/ci-nix.yml index 7dee79bd..9ead478b 100644 --- a/.github/workflows/ci-nix.yml +++ b/.github/workflows/ci-nix.yml @@ -42,15 +42,16 @@ jobs: - uses: actions/checkout@v6 - uses: DeterminateSystems/nix-installer-action@main - # DO NOT swap back to cache-nix-action: it tars the whole /nix/store, which - # measured 4.87GB (Linux) + 4.46GB (macOS) against a 10GB repository limit, so - # the platforms evicted each other and every run started cold. This stores - # individual paths and skips anything fetchable from cache.nixos.org. - # (Magic Nix Cache broke in Feb 2025 and was revived in Jun 2025 — it is alive.) - # See docs/ci-architecture.md#store-caching-uses-magic-nix-cache. - - uses: DeterminateSystems/magic-nix-cache-action@main - with: - use-flakehub: false + # Third approach here; the first two each hit a different GitHub ceiling. + # cache-nix-action tarred the whole /nix/store (4.87GB Linux + 4.46GB macOS + # against a 10GB STORAGE limit, so the platforms evicted each other). Magic Nix + # Cache stored one entry per store path (1788 of them), which tripped the API + # RATE limit and then disabled itself mid-run. Hestia packs paths into + # content-defined chunks — few large entries — so it avoids both. + # Uploads authenticate with the runner-injected ACTIONS_RUNTIME_TOKEN, so + # `permissions: contents: read` above is sufficient. + # See docs/ci-architecture.md#store-caching. + - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 # Named individually on purpose: `nix flake check` batches every check in # arbitrary order and so cannot return early on a cheap failure. The nightly job @@ -90,9 +91,7 @@ jobs: steps: - uses: actions/checkout@v6 - uses: DeterminateSystems/nix-installer-action@main - - uses: DeterminateSystems/magic-nix-cache-action@main - with: - use-flakehub: false + - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 - name: nix flake check run: nix flake check - name: dump failing build log diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 691c98b3..abcc88c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,10 +161,8 @@ jobs: # See docs/ci-architecture.md#toolchain-comes-from-the-flake. - uses: DeterminateSystems/nix-installer-action@main if: matrix.nix - - uses: DeterminateSystems/magic-nix-cache-action@main + - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 if: matrix.nix - with: - use-flakehub: false - uses: dtolnay/rust-toolchain@stable if: ${{ !matrix.nix }} with: diff --git a/docs/ci-architecture.md b/docs/ci-architecture.md index b97842c6..f9d5bd88 100644 --- a/docs/ci-architecture.md +++ b/docs/ci-architecture.md @@ -77,21 +77,30 @@ gate first". Naming the checks individually can, which is why the PR job runs The cost is that the explicit list can drift from the flake's actual `checks`. The nightly job runs `nix flake check` precisely so a newly added check cannot be silently left ungated. -### Store caching uses Magic Nix Cache +### Store caching -`cache-nix-action` tars the whole `/nix/store` into one entry per platform. Measured here: 4.87GB -on Linux and 4.46GB on macOS — **9.33GB before counting anything else**, against GitHub's 10GB -per-repository limit. The two platforms evicted each other, and Linux ran with a cold store on -every run. +Three approaches, each of which hit a different GitHub ceiling: -Magic Nix Cache stores individual paths and skips anything fetchable from `cache.nixos.org`, so -only this project's own outputs are kept (~3.6GB across both platforms). It needs no external -account. `use-flakehub: false` keeps it on the Actions cache; FlakeHub Cache is a separate -account-backed service. +| Approach | Granularity | Outcome | +| --- | --- | --- | +| `cache-nix-action` | one tarball of the whole `/nix/store`, per platform | 4.87GB (Linux) + 4.46GB (macOS) = **9.33GB before anything else**, against a 10GB storage limit. The platforms evicted each other and Linux ran cold every time. | +| `magic-nix-cache` | one entry per store path | **1788 entries**, whose API calls tripped GitHub's rate limiter. On throttle it logs `Not trying to use it again on this run` and disables itself, so the job silently finishes cold. | +| `Mic92/hestia` (current) | content-defined chunks, a few large entries | Aims between the two: few enough entries to stay under the rate limit, deduplicated enough to stay under the storage limit. | + +Hestia needs no account: uploads authenticate with the runner-injected +`ACTIONS_RUNTIME_TOKEN`, so build jobs need only `permissions: contents: read`. It is +pinned by commit SHA, matching how this repository pins its other third-party actions. + +Two things to know if quota becomes the binding constraint again: the action takes an +`upstream-cache-filter` input that skips paths already signed by an upstream cache, and +upstream recommends a daily GC workflow on the default branch (its REST deletes are what +need `actions: write`). -> Magic Nix Cache broke in February 2025 when GitHub retired the old Actions Cache API, and was -> widely described as dead. It was revived in June 2025 against the new API. Check its current -> state before acting on any advice about it. +> Two corrections worth keeping, because both cost time here. Magic Nix Cache broke in +> February 2025 and was widely written off, but was revived in June 2025 against the new +> API — it is not dead. And hestia *is* MIT licensed (README and both `Cargo.toml` +> files); GitHub's license detector reports nothing only because there is no `LICENSE` +> file at the repository root. ### `nix.yml` lints Nix files and nothing else @@ -121,17 +130,20 @@ Nix still reports `false`). On Linux it would be actively harmful, deleting the Nix lane exists to provide. It remains a legitimate *debugging* lever for a derivation that fails only under the sandbox. -**Cachix.** Would work — the footprint is ~3.6GB against a 5GB free open-source tier — but it -needs an account and a token secret, and Magic Nix Cache solved the eviction problem without -either. Worth revisiting if the cache is ever needed outside CI. +**Cachix.** Would work, and is the one option where GitHub's rate limits cannot apply at all, +since it does not use the Actions cache. The footprint is ~3.6GB against a 5GB free +open-source tier, which is workable but not roomy, and it needs an account plus a token +secret. It stays the fallback if the current approach hits either ceiling again, or if the +cache is ever wanted outside CI. ## Revisit triggers - **`ci.yml` produces a false green traced to a stale `target/`.** The strongest argument for moving more of the gate into derivations. -- **Actions cache pressure returns.** The 10GB limit is shared; both lanes' caches coexist only - because the whole-store tarballs are gone. If usage approaches the ceiling again, the next step - is a real binary cache (Cachix or FlakeHub), not more tuning. +- **Either GitHub cache ceiling is hit again** — storage (10GB, shared with `ci.yml`'s + rust-cache entries) or the API rate limit. First try hestia's `upstream-cache-filter` and a + GC workflow; if that is not enough, move to a real binary cache (Cachix or FlakeHub), which + is subject to neither. - **macOS runners get materially faster,** or a macOS-specific sandbox regression appears — then reconsider gating macOS per PR rather than nightly. - **Nix gains native Windows support,** or the cross-compile spike graduates — either removes the From c9c3e22d508197ee985c4f5e55c996629ef41a75 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 20:40:30 -0700 Subject: [PATCH 24/36] docs: record the measured hestia cache result A cold run drains 2.3 GiB as 491 paths packed into 37 cache entries, roughly 48x fewer objects than one entry per store path, and no throttling appears in the log. Re-running an unchanged commit costs 48s because every derivation output is then a cache hit. Note the caveat with it: that figure is the identical-input case. A real change still recompiles the crate and its test binaries, so a normal pull request lands between the cold and warm numbers. --- docs/ci-architecture.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/ci-architecture.md b/docs/ci-architecture.md index f9d5bd88..a0d7fe50 100644 --- a/docs/ci-architecture.md +++ b/docs/ci-architecture.md @@ -85,7 +85,13 @@ Three approaches, each of which hit a different GitHub ceiling: | --- | --- | --- | | `cache-nix-action` | one tarball of the whole `/nix/store`, per platform | 4.87GB (Linux) + 4.46GB (macOS) = **9.33GB before anything else**, against a 10GB storage limit. The platforms evicted each other and Linux ran cold every time. | | `magic-nix-cache` | one entry per store path | **1788 entries**, whose API calls tripped GitHub's rate limiter. On throttle it logs `Not trying to use it again on this run` and disables itself, so the job silently finishes cold. | -| `Mic92/hestia` (current) | content-defined chunks, a few large entries | Aims between the two: few enough entries to stay under the rate limit, deduplicated enough to stay under the storage limit. | +| `Mic92/hestia` (current) | content-defined chunks, a few large entries | **491 paths packed into 37 entries** — about 48x fewer objects than per-path storage, with no throttling observed. | + +Measured on this repository: a cold run costs 22m52s and drains 2.3 GiB in about a minute; +re-running the same commit costs **48s** end to end (fmt 17s, clippy 2s, test 3s, build 8s), +because identical inputs make every derivation output a cache hit. A real change still +recompiles the crate and its test binaries — only the dependency artifacts stay cached — so +expect a normal pull request to land between those two numbers rather than near the low one. Hestia needs no account: uploads authenticate with the runner-injected `ACTIONS_RUNTIME_TOKEN`, so build jobs need only `permissions: contents: read`. It is From 225dcec18cbd71bf916347e939c7de68c90b36c8 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 20:50:59 -0700 Subject: [PATCH 25/36] ci: prune the hestia cache nightly The Nix lane's store cache shares GitHub's 10GB per-repository quota with ci.yml's rust-cache entries, and nothing was reclaiming the Nix side. Without collection the default-branch scope grows until GitHub starts evicting, which is how the previous cache approach ended up cold on every run. Add the nightly collection upstream recommends, adapted to use the released binary through the same pinned action the writing lanes use rather than building hestia from source. It only considers paths hestia itself pushed: liveness comes from its own per-branch-and-system roots, so entries written by rust-cache, setup-node, or the Nix installer are never candidates. This is the only workflow holding actions:write, which the REST cache deletes require, and it carries a dry-run input for inspecting a plan before trusting it. --- .github/workflows/cache-gc.yml | 67 ++++++++++++++++++++++++++++++++++ docs/ci-architecture.md | 14 +++++-- 2 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/cache-gc.yml diff --git a/.github/workflows/cache-gc.yml b/.github/workflows/cache-gc.yml new file mode 100644 index 00000000..b1919520 --- /dev/null +++ b/.github/workflows/cache-gc.yml @@ -0,0 +1,67 @@ +name: Cache GC + +# Prunes the hestia binary cache so the Nix lane stays inside GitHub's 10GB +# per-repository cache quota, which it shares with ci.yml's rust-cache entries. +# +# Scope: this deletes only paths hestia itself pushed. Hestia tracks what is alive +# through its own "roots" (one per branch and system, e.g. `main-x86_64-linux`), and +# collects paths unreachable from any root once they fall out of the push grace period. +# Cache entries written by other actions — Swatinem/rust-cache, setup-node, the Nix +# installer — are not in hestia's manifest and are never considered. +# +# It has to run on the default branch: a pull request's cache scope is read-only towards +# the default branch and disappears with the PR, so a PR-scoped run cannot prune what the +# default branch owns. +# +# See docs/ci-architecture.md#store-caching. + +concurrency: + # Hestia handles concurrent pushes but not a concurrent GC: a second run's repack reads + # race the first run's post-commit deletes. Queue behind a running GC, never cancel it + # partway through a delete pass. + group: hestia-gc + cancel-in-progress: false + +on: + schedule: + # Daily and off-peak. Deliberately not 07:00 UTC, which is the nightly full flake + # check in ci-nix.yml. + - cron: "23 3 * * *" + workflow_dispatch: + inputs: + dry-run: + description: Plan only; do not repack, touch, or delete anything. + type: boolean + default: false + +permissions: + contents: read + +jobs: + gc: + name: prune the hestia cache + runs-on: ubuntu-latest + permissions: + # The cache deletes go through the REST API, which needs actions:write. This is the + # only workflow here that gets it. + actions: write + contents: read + steps: + # The action writes Nix configuration when it installs, so Nix has to exist first. + - uses: DeterminateSystems/nix-installer-action@main + + # Installs the released binary and exports HESTIA_BIN. Same SHA pin as the lanes + # that populate the cache — a GC from a different version than the writers is not + # a combination worth discovering at 03:23. + - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 + + - name: Run garbage collection + env: + GITHUB_TOKEN: ${{ github.token }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + if [ "$DRY_RUN" = "true" ]; then + "$HESTIA_BIN" gc --dry-run + else + "$HESTIA_BIN" gc + fi diff --git a/docs/ci-architecture.md b/docs/ci-architecture.md index a0d7fe50..5b791a3e 100644 --- a/docs/ci-architecture.md +++ b/docs/ci-architecture.md @@ -16,6 +16,7 @@ choices look like oversights until you see the number behind them. | `ci-nix.yml` | every PR (Linux); nightly (Linux + macOS) | The hermetic check: the suite in a sandbox, plus a network-free build of the shipped artifact. | | `nix.yml` | PRs touching `*.nix` | Lints the Nix files themselves. Builds nothing. | | `ci-nix-windows-spike.yml` | spike branch only | Report-only experiment: cross-compile the tests on Linux, run them on Windows. | +| `cache-gc.yml` | nightly on the default branch | Prunes the hestia cache so the Nix lane stays inside the shared 10GB quota. | Both lanes compile with the same flake-pinned toolchain, so `ci-nix.yml` is not a second opinion on the lints. Its unique contribution is stated in [Why keep a Nix lane](#why-keep-a-nix-lane). @@ -97,10 +98,15 @@ Hestia needs no account: uploads authenticate with the runner-injected `ACTIONS_RUNTIME_TOKEN`, so build jobs need only `permissions: contents: read`. It is pinned by commit SHA, matching how this repository pins its other third-party actions. -Two things to know if quota becomes the binding constraint again: the action takes an -`upstream-cache-filter` input that skips paths already signed by an upstream cache, and -upstream recommends a daily GC workflow on the default branch (its REST deletes are what -need `actions: write`). +`cache-gc.yml` prunes it nightly. Hestia tracks liveness through *roots* — one per branch +and system, e.g. `main-x86_64-linux` — and collects whatever no root reaches once it falls +out of the push grace period. It only ever considers paths hestia itself pushed, so the +rust-cache and setup-node entries sharing the quota are untouched. GC must run on the +default branch, because a pull request's cache scope is read-only towards it, and it is the +only workflow here holding `actions: write`. + +If quota is still the binding constraint after that, the action takes an +`upstream-cache-filter` input that skips paths already signed by an upstream cache. > Two corrections worth keeping, because both cost time here. Magic Nix Cache broke in > February 2025 and was widely written off, but was revived in June 2025 against the new From bc7d3d4f35da3c201b0bffce61ceed117fd014c8 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 21:00:37 -0700 Subject: [PATCH 26/36] ci: shard the Windows cross-archive run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spike ran the archived suite as a single Windows leg, so the lane cost 15m20s — cross-compile then run, serialized — against 10m02s for ci.yml's sharded rustup legs. Being slower than what it aims to replace was the reason it could not graduate. Fan the run across three shards with the same slice:N/3 partitioning ci.yml uses. The split is even and complete: 2922 tests, 974 per shard, reconciling exactly. The shape here is better than ci.yml's rather than merely equal. There, each Windows shard rebuilds the test binaries, so sharding multiplies compile work. Here the archive is cross-built once on Linux and all three shards consume it, so sharding buys execution time and nothing else. --- .github/workflows/ci-nix-windows-spike.yml | 26 ++++++++++++++++++---- docs/ci-architecture.md | 15 ++++++++++--- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-nix-windows-spike.yml b/.github/workflows/ci-nix-windows-spike.yml index 8411c3c0..9c2ad229 100644 --- a/.github/workflows/ci-nix-windows-spike.yml +++ b/.github/workflows/ci-nix-windows-spike.yml @@ -14,8 +14,9 @@ name: CI (Nix Windows cross — spike) # package_identity::cargo_install_exposes_only_pointbreak_executable, intentionally builds # the crate via `cargo install` — the sole exception to the no-build premise (~2-3 min). # -# See docs/ci-architecture.md#windows for what has to change before this can replace -# rustup on the Windows legs. +# The Windows run is sharded three ways (see the windows-run job) so this lane is +# comparable to ci.yml's sharded Windows legs rather than a single serialized run. +# See docs/ci-architecture.md#windows. # # Still report-only (continue-on-error) until this is confirmed green on the actual x86_64 # windows-latest runner a few times: an x64 run may surface genuine platform byte/behaviour @@ -62,11 +63,26 @@ jobs: retention-days: 3 windows-run: - name: run archive on Windows (x64, no suite build) + name: run archive on Windows (x64 ${{ matrix.shard }}/${{ matrix.shards }}) needs: cross-archive runs-on: windows-latest # Report-only until confirmed stably green on this x64 runner — see header. continue-on-error: true + # Fanned out the same way and for the same reason as ci.yml's Windows legs: the suite + # is spawn-bound on per-CreateProcess cost, so execution parallelises across machines + # far better than it does across cores on one. `slice` matches ci.yml's partition type, + # which keeps adjacent tests in a binary together. + # + # The difference from ci.yml is that no shard compiles anything: all three consume the + # single archive cross-built on Linux above, so sharding here buys execution time + # without multiplying build time. ci.yml's shards each rebuild the test binaries. + strategy: + fail-fast: false + matrix: + include: + - { shard: 1, shards: 3 } + - { shard: 2, shards: 3 } + - { shard: 3, shards: 3 } steps: - uses: actions/checkout@v6 - uses: taiki-e/install-action@nextest @@ -77,11 +93,13 @@ jobs: # No Rust build of the suite: nextest runs the prebuilt binaries from the archive. # --workspace-remap points the (runtime-resolved) fixture and binary lookups at this # checkout. The lone exception is the cargo_install packaging test, which builds the - # crate on the runner by design (see header). + # crate on the runner by design (see header) and so makes whichever shard draws it + # the long pole. - name: run archived suite (report-only) shell: bash run: | cargo nextest run \ --archive-file archive/pointbreak-x86_64-pc-windows-msvc.tar.zst \ --workspace-remap . \ + --partition "slice:${{ matrix.shard }}/${{ matrix.shards }}" \ --no-fail-fast diff --git a/docs/ci-architecture.md b/docs/ci-architecture.md index 5b791a3e..ad1ceef5 100644 --- a/docs/ci-architecture.md +++ b/docs/ci-architecture.md @@ -171,9 +171,18 @@ Rust at all. It works: validated against an ARM64 Windows machine at 2924/2924 a suite was changed to resolve its binary and fixtures at runtime rather than through compile-time `env!()`. -It stays report-only because it is not yet competitive on wall-clock: 15m20s (cross-compile then -run, serialized) against 10m02s for the sharded rustup legs. Sharding the Windows run the way -`ci.yml` shards it is the work required before it can replace rustup on Windows. +The Windows run is now fanned across three shards, the same `slice:N/3` partitioning `ci.yml` +uses, which splits evenly (2922 tests, 974 per shard, reconciling exactly). Before that it was a +single leg and the lane cost 15m20s — cross-compile then run, serialized — against 10m02s for the +sharded rustup legs. + +The shape is better than `ci.yml`'s, not just equal: there, each Windows shard rebuilds the test +binaries, so sharding multiplies compile work. Here the archive is cross-built once on Linux and +all three shards consume it, so sharding buys execution time and nothing else. Whether that is +enough to beat the rustup legs is now a measurement rather than an argument. + +It stays report-only until it is confirmed green on the x86_64 runner across a few runs — the +2924/2924 validation was on ARM64. One test is an intentional exception to the no-build premise: `package_identity::cargo_install_exposes_only_pointbreak_executable` builds the crate on the From 22b4788d23cb6dc98628047f2dd063ea56aada19 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 21:16:09 -0700 Subject: [PATCH 27/36] test: retire the cargo install packaging check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test existed to prove the shore -> pointbreak rename had taken: most of its body asserted that no `shore` or `shore.exe` landed in the install root. That rename shipped in 0.7.0. The one durable claim it still made, that installing yields exactly one binary named pointbreak, is already asserted from cargo metadata by package_identity_declares_only_pointbreak_binary — cargo install places exactly the [[bin]] targets metadata enumerates. Retiring it also clears the last on-target Rust build out of the Windows cross-archive lane, which had made whichever shard drew this test the long pole at 5m46s against 2m26s for the fastest. The no-build premise there now holds without exception. Worth knowing what this gives up: nothing now runs `cargo install`, which is the route the README and installation guide give users. The release workflows build and verify the published binaries, but that specific path is no longer covered directly. --- .github/workflows/ci-nix-windows-spike.yml | 10 +++----- docs/ci-architecture.md | 12 ++++++--- tests/package_identity.rs | 29 ---------------------- 3 files changed, 13 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci-nix-windows-spike.yml b/.github/workflows/ci-nix-windows-spike.yml index 9c2ad229..c169812f 100644 --- a/.github/workflows/ci-nix-windows-spike.yml +++ b/.github/workflows/ci-nix-windows-spike.yml @@ -10,9 +10,9 @@ name: CI (Nix Windows cross — spike) # The suite resolves the test binary, fixtures, and cargo at runtime (tests/support/env.rs # + test_fixtures::manifest_dir), so cargo-nextest's --workspace-remap relocates them onto # the target. Cross-compiled to aarch64-pc-windows-msvc and executed on a real ARM64 Windows -# machine, the archived suite runs green (2924/2924, validated locally). One packaging test, -# package_identity::cargo_install_exposes_only_pointbreak_executable, intentionally builds -# the crate via `cargo install` — the sole exception to the no-build premise (~2-3 min). +# machine, the archived suite runs green. No shard compiles any Rust: the one test that did +# (a `cargo install` packaging check) was a leftover of the shore -> pointbreak rename and +# has been retired, so the no-build premise now holds without exception. # # The Windows run is sharded three ways (see the windows-run job) so this lane is # comparable to ci.yml's sharded Windows legs rather than a single serialized run. @@ -92,9 +92,7 @@ jobs: path: archive # No Rust build of the suite: nextest runs the prebuilt binaries from the archive. # --workspace-remap points the (runtime-resolved) fixture and binary lookups at this - # checkout. The lone exception is the cargo_install packaging test, which builds the - # crate on the runner by design (see header) and so makes whichever shard draws it - # the long pole. + # checkout. Nothing here compiles Rust, so a shard's cost is test execution alone. - name: run archived suite (report-only) shell: bash run: | diff --git a/docs/ci-architecture.md b/docs/ci-architecture.md index ad1ceef5..0db8a4a6 100644 --- a/docs/ci-architecture.md +++ b/docs/ci-architecture.md @@ -184,6 +184,12 @@ enough to beat the rustup legs is now a measurement rather than an argument. It stays report-only until it is confirmed green on the x86_64 runner across a few runs — the 2924/2924 validation was on ARM64. -One test is an intentional exception to the no-build premise: -`package_identity::cargo_install_exposes_only_pointbreak_executable` builds the crate on the -runner by design. +No shard compiles Rust. The one test that did — a `cargo install` packaging check — was a +leftover of the `shore` -> `pointbreak` rename and has been retired; the invariant it still +carried (exactly one installed binary, named `pointbreak`) is asserted from `cargo metadata` +by `package_identity_declares_only_pointbreak_binary`. Retiring it also removed the ~2-3 +minute on-target build that made whichever shard drew it the long pole. + +Nothing now exercises `cargo install` itself, which is the route the README and +[installation guide](installation.md) give users. That path is covered indirectly by the +release workflows building and verifying the published binaries, but not directly. diff --git a/tests/package_identity.rs b/tests/package_identity.rs index a32da532..ef3b0268 100644 --- a/tests/package_identity.rs +++ b/tests/package_identity.rs @@ -64,35 +64,6 @@ fn package_identity_declares_only_pointbreak_binary() { ); } -#[test] -fn cargo_install_exposes_only_pointbreak_executable() { - let install_root = tempfile::tempdir().expect("create cargo install root"); - let output = Command::new(env::cargo_bin()) - .args(["install", "--path"]) - .arg(env::manifest_dir()) - .arg("--root") - .arg(install_root.path()) - .arg("--debug") - .env("CARGO_TARGET_DIR", install_root.path().join("target")) - .output() - .expect("run cargo install"); - assert!( - output.status.success(), - "cargo install stderr:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - - let mut installed = fs::read_dir(install_root.path().join("bin")) - .expect("read installed bin directory") - .map(|entry| entry.expect("installed bin entry").file_name()) - .collect::>(); - installed.sort(); - - assert_eq!(installed, [OsStr::new(POINTBREAK_EXECUTABLE_BASENAME)]); - assert!(!install_root.path().join("bin").join("shore").exists()); - assert!(!install_root.path().join("bin").join("shore.exe").exists()); -} - #[test] fn cli_help_uses_pointbreak_and_keeps_the_flat_command_tree() { let temp_dir = tempfile::tempdir().expect("create temp command dir"); From daf6b88ed07baba7e7c599cfb4271f10231f8ecf Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 21:32:27 -0700 Subject: [PATCH 28/36] ci: promote the Windows cross-archive lane into the gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spike is green on x64 and now cheaper than what it replaces: sharded three ways it finished in 9m57s against 10m02s for the rustup legs, and retiring the on-target cargo install test removed the last thing any shard had to compile. Fold it into ci.yml as the Windows test path and delete the separate spike workflow. Windows test legs no longer install a Rust toolchain at all: the binaries are cross-compiled once on Linux with cargo-xwin and the shards only execute them. Rustup does remain on Windows for three other legs — the --all-features type-check, store qualification, and git parity — so this frees the test execution, not the platform. Also drop the Linux test leg, which ci-nix.yml's sandboxed gate already covers, and keep macOS as a plain cargo run so it retains an incremental target/. The Rust gate is now split by platform: Linux hermetic, macOS incremental, Windows cross-compiled. Keep test-windows-check on rustup deliberately. `just check-types` is --all-features while the archive is built with default features, so the cfg(windows) arms behind bench and gix-parity are compiled for Windows nowhere else. --- .github/workflows/ci-nix-windows-spike.yml | 103 ------------ .github/workflows/ci.yml | 173 ++++++++++----------- docs/ci-architecture.md | 21 ++- tests/github_actions.rs | 23 ++- 4 files changed, 110 insertions(+), 210 deletions(-) delete mode 100644 .github/workflows/ci-nix-windows-spike.yml diff --git a/.github/workflows/ci-nix-windows-spike.yml b/.github/workflows/ci-nix-windows-spike.yml deleted file mode 100644 index c169812f..00000000 --- a/.github/workflows/ci-nix-windows-spike.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: CI (Nix Windows cross — spike) - -# EXPERIMENTAL spike. Cross-compiles the test suite to Windows msvc on Linux via Nix -# (cargo-xwin + a Fenix msvc std), then executes the prebuilt cargo-nextest archive on a -# plain windows-latest runner with no Rust build step for the suite — only cargo-nextest -# and a source checkout for fixtures. This proves the "build on Linux, run on Windows" pipe -# and moves Windows *compilation* of the suite off the expensive Windows runner. (It does -# not remove the documented long pole: the tests still execute on Windows and spawn git.) -# -# The suite resolves the test binary, fixtures, and cargo at runtime (tests/support/env.rs -# + test_fixtures::manifest_dir), so cargo-nextest's --workspace-remap relocates them onto -# the target. Cross-compiled to aarch64-pc-windows-msvc and executed on a real ARM64 Windows -# machine, the archived suite runs green. No shard compiles any Rust: the one test that did -# (a `cargo install` packaging check) was a leftover of the shore -> pointbreak rename and -# has been retired, so the no-build premise now holds without exception. -# -# The Windows run is sharded three ways (see the windows-run job) so this lane is -# comparable to ci.yml's sharded Windows legs rather than a single serialized run. -# See docs/ci-architecture.md#windows. -# -# Still report-only (continue-on-error) until this is confirmed green on the actual x86_64 -# windows-latest runner a few times: an x64 run may surface genuine platform byte/behaviour -# differences the arm64 validation did not. Promote to gating (and to pull_request) once -# it is stably green. - -on: - workflow_dispatch: - push: - # Spike branch only, to avoid spending Windows minutes on every PR. Promote to - # pull_request (or delete) once the lane is stably green on x64 and can gate. - branches: [chore/remove-rustup] - -permissions: - contents: read - -concurrency: - group: ci-nix-windows-spike-${{ github.ref }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - -jobs: - cross-archive: - name: cross-compile nextest archive (ubuntu → msvc) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: DeterminateSystems/nix-installer-action@main - # Same store-cache reasoning as ci-nix.yml: keep only this project's own - # paths rather than a whole-store tarball, so the shared 10GB Actions cache - # budget is not exhausted by paths cache.nixos.org already serves. - - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 - # cargo-xwin fetches the MSVC CRT/SDK here (network is available in the step); - # XWIN_ACCEPT_LICENSE is set inside the windows-cross dev shell. - - name: cross-compile x86_64-pc-windows-msvc nextest archive - run: nix develop .#windows-cross --command just windows-cross-archive x86_64-pc-windows-msvc - - uses: actions/upload-artifact@v4 - with: - name: nextest-archive-x86_64-pc-windows-msvc - path: target/nextest/pointbreak-x86_64-pc-windows-msvc.tar.zst - if-no-files-found: error - retention-days: 3 - - windows-run: - name: run archive on Windows (x64 ${{ matrix.shard }}/${{ matrix.shards }}) - needs: cross-archive - runs-on: windows-latest - # Report-only until confirmed stably green on this x64 runner — see header. - continue-on-error: true - # Fanned out the same way and for the same reason as ci.yml's Windows legs: the suite - # is spawn-bound on per-CreateProcess cost, so execution parallelises across machines - # far better than it does across cores on one. `slice` matches ci.yml's partition type, - # which keeps adjacent tests in a binary together. - # - # The difference from ci.yml is that no shard compiles anything: all three consume the - # single archive cross-built on Linux above, so sharding here buys execution time - # without multiplying build time. ci.yml's shards each rebuild the test binaries. - strategy: - fail-fast: false - matrix: - include: - - { shard: 1, shards: 3 } - - { shard: 2, shards: 3 } - - { shard: 3, shards: 3 } - steps: - - uses: actions/checkout@v6 - - uses: taiki-e/install-action@nextest - - uses: actions/download-artifact@v4 - with: - name: nextest-archive-x86_64-pc-windows-msvc - path: archive - # No Rust build of the suite: nextest runs the prebuilt binaries from the archive. - # --workspace-remap points the (runtime-resolved) fixture and binary lookups at this - # checkout. Nothing here compiles Rust, so a shard's cost is test execution alone. - - name: run archived suite (report-only) - shell: bash - run: | - cargo nextest run \ - --archive-file archive/pointbreak-x86_64-pc-windows-msvc.tar.zst \ - --workspace-remap . \ - --partition "slice:${{ matrix.shard }}/${{ matrix.shards }}" \ - --no-fail-fast diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abcc88c3..e4c941e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,111 +93,98 @@ jobs: working-directory: extensions/vscode run: npm run check - test: - name: ${{ matrix.name }} - # Windows is the CI long pole and is spawn-bound (per-CreateProcess cost), so its - # ~226s execution is fanned across parallel slice-partitioned shards while ubuntu and - # macOS stay single legs. Lint runs on Linux only; the cfg(windows)/cfg(not(unix)) - # arms are type-checked once on a dedicated Windows check leg; the shard legs run - # tests only. All Windows legs share one rust-cache entry (shared-key) since they - # compile the identical test-binary set. - strategy: - fail-fast: false - matrix: - include: - - { - name: "test (ubuntu-latest)", - os: ubuntu-latest, - gate: lint, - run_tests: true, - nix: true, - } - - { - name: "test (macos-latest)", - os: macos-latest, - gate: check, - run_tests: true, - nix: true, - } - - { - name: "test (windows-latest check)", - os: windows-latest, - gate: check, - run_tests: false, - } - - { - name: "test (windows-latest 1/3)", - os: windows-latest, - run_tests: true, - shard: 1, - shards: 3, - } - - { - name: "test (windows-latest 2/3)", - os: windows-latest, - run_tests: true, - shard: 2, - shards: 3, - } - - { - name: "test (windows-latest 3/3)", - os: windows-latest, - run_tests: true, - shard: 3, - shards: 3, - } - runs-on: ${{ matrix.os }} + test-macos: + name: test (macos-latest) + # The only platform whose suite runs as a plain cargo invocation. Linux is covered + # hermetically by ci-nix.yml's sandboxed gate; Windows by the cross-compiled archive + # below. This leg keeps rust-cache's incremental target/, which is the whole reason it + # is a `nix develop` run and not a derivation. + # See docs/ci-architecture.md#toolchain-comes-from-the-flake. + runs-on: macos-latest steps: - uses: actions/checkout@v6 - - # Linux and macOS take their toolchain from the flake, so CI compiles with the - # exact rustc, nightly rustfmt, and nextest that `nix develop` gives a - # contributor — pinned by flake.lock rather than resolved by rustup at run time. - # `.#ci` is a lean shell: no cocogitto (built from source), gh, or cargo-edit. - # - # Windows keeps rustup because Nix has no native Windows support. Removing that - # last rustup dependency needs the cross-compiled nextest archive from - # ci-nix-windows-spike.yml to graduate out of its report-only phase. - # See docs/ci-architecture.md#toolchain-comes-from-the-flake. - uses: DeterminateSystems/nix-installer-action@main - if: matrix.nix - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 - if: matrix.nix + # rust-cache belongs on this leg and NOT on the Nix lane: it caches ./target, + # which a sandboxed `nix build` never writes. See + # docs/ci-architecture.md#rejected-alternatives. + - uses: Swatinem/rust-cache@v2 + - name: just check-types + run: nix develop .#ci -c just check-types + - name: just test-ci + run: nix develop .#ci -c just test-ci + + test-windows-check: + name: test (windows-latest check) + # Type-checks only; the shards below run the tests. Kept on rustup, and kept at all, + # because `just check-types` is `--all-features` while the archive is built with + # default features: the cfg(windows) arms behind `bench` and `gix-parity` are compiled + # for Windows nowhere else. Nix cannot supply this — no native Windows support — and + # store-foundation-qualification and git-parity need rustup on Windows anyway. + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable - if: ${{ !matrix.nix }} with: components: rustfmt, clippy - - uses: dtolnay/rust-toolchain@nightly - if: ${{ !matrix.nix }} - with: - components: rustfmt - uses: taiki-e/install-action@just - if: ${{ !matrix.nix }} - - uses: taiki-e/install-action@nextest - if: ${{ !matrix.nix }} - - # Belongs here and NOT on the Nix lane: rust-cache caches ~/.cargo and ./target, - # and `nix build` runs in a sandbox that never writes the workspace target/, so - # there it would cache an empty directory. Here cargo runs in the workspace, so - # target/ persists and stays incremental. - # See docs/ci-architecture.md#rejected-alternatives. - uses: Swatinem/rust-cache@v2 with: - shared-key: ${{ runner.os == 'Windows' && 'windows-shards' || '' }} + shared-key: windows-shards + - run: just check-types - - name: just lint - if: matrix.gate == 'lint' - run: ${{ matrix.nix && 'nix develop .#ci -c' || '' }} just lint - - name: just check-types - if: matrix.gate == 'check' - run: ${{ matrix.nix && 'nix develop .#ci -c' || '' }} just check-types - - name: configure Windows test debuginfo - if: runner.os == 'Windows' + windows-cross-archive: + name: cross-compile nextest archive (ubuntu → msvc) + # Compiles the Windows test binaries once, on Linux, with cargo-xwin against the MSVC + # CRT. The shards below then execute them with no Rust toolchain on Windows at all. + # See docs/ci-architecture.md#windows. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@main + - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 + # cargo-xwin fetches the MSVC CRT/SDK here; XWIN_ACCEPT_LICENSE is set in the shell. + - name: cross-compile x86_64-pc-windows-msvc nextest archive + run: nix develop .#windows-cross --command just windows-cross-archive x86_64-pc-windows-msvc + - uses: actions/upload-artifact@v4 + with: + name: nextest-archive-x86_64-pc-windows-msvc + path: target/nextest/pointbreak-x86_64-pc-windows-msvc.tar.zst + if-no-files-found: error + retention-days: 3 + + test-windows: + name: test (windows-latest ${{ matrix.shard }}/${{ matrix.shards }}) + needs: windows-cross-archive + runs-on: windows-latest + # Sharded because the suite is spawn-bound on per-CreateProcess cost, so execution + # parallelises across machines far better than across cores on one. Unlike the legs + # this replaced, no shard compiles anything — all three consume the single archive + # above — so sharding buys execution time without multiplying build time. + strategy: + fail-fast: false + matrix: + include: + - { shard: 1, shards: 3 } + - { shard: 2, shards: 3 } + - { shard: 3, shards: 3 } + steps: + - uses: actions/checkout@v6 + - uses: taiki-e/install-action@nextest + - uses: actions/download-artifact@v4 + with: + name: nextest-archive-x86_64-pc-windows-msvc + path: archive + # --workspace-remap points the runtime-resolved fixture and binary lookups at this + # checkout; see tests/support/env.rs for why they are resolved at runtime. + - name: run archived suite shell: bash - run: echo "CARGO_PROFILE_TEST_SPLIT_DEBUGINFO=packed" >> "$GITHUB_ENV" - - name: just test-ci - if: matrix.run_tests - run: ${{ matrix.nix && 'nix develop .#ci -c' || '' }} just test-ci ${{ matrix.shard && format('--partition slice:{0}/{1}', matrix.shard, matrix.shards) || '' }} + run: | + cargo nextest run \ + --archive-file archive/pointbreak-x86_64-pc-windows-msvc.tar.zst \ + --workspace-remap . \ + --partition "slice:${{ matrix.shard }}/${{ matrix.shards }}" \ + --no-fail-fast store-foundation-qualification: name: store foundation qualification (${{ matrix.os }}) diff --git a/docs/ci-architecture.md b/docs/ci-architecture.md index 0db8a4a6..04681bb2 100644 --- a/docs/ci-architecture.md +++ b/docs/ci-architecture.md @@ -12,10 +12,9 @@ choices look like oversights until you see the number behind them. | Workflow | Runs | Purpose | | --- | --- | --- | -| `ci.yml` | every PR, all three platforms | The gate. Lint, type-check, and the full suite, with `rust-cache` and Windows sharding. | +| `ci.yml` | every PR, all three platforms | The platform gate: the macOS suite, the sharded Windows suite from a cross-compiled archive, installers, skills, workflow lint, the front-end and extension checks, store qualification, and git parity. | | `ci-nix.yml` | every PR (Linux); nightly (Linux + macOS) | The hermetic check: the suite in a sandbox, plus a network-free build of the shipped artifact. | | `nix.yml` | PRs touching `*.nix` | Lints the Nix files themselves. Builds nothing. | -| `ci-nix-windows-spike.yml` | spike branch only | Report-only experiment: cross-compile the tests on Linux, run them on Windows. | | `cache-gc.yml` | nightly on the default branch | Prunes the hestia cache so the Nix lane stays inside the shared 10GB quota. | Both lanes compile with the same flake-pinned toolchain, so `ci-nix.yml` is not a second opinion @@ -165,11 +164,19 @@ cache is ever wanted outside CI. ## Windows -`ci-nix-windows-spike.yml` cross-compiles the suite to `x86_64-pc-windows-msvc` on Linux with -cargo-xwin and runs the resulting `cargo nextest` archive on a plain Windows runner that builds no -Rust at all. It works: validated against an ARM64 Windows machine at 2924/2924 after the test -suite was changed to resolve its binary and fixtures at runtime rather than through compile-time -`env!()`. +`ci.yml`'s `windows-cross-archive` job cross-compiles the suite to `x86_64-pc-windows-msvc` on +Linux with cargo-xwin; three `test-windows` shards then execute the resulting `cargo nextest` +archive on plain Windows runners that build no Rust at all. This was a report-only spike until it +proved both green and competitive on x64, and is now the gate. + +It became possible once the suite was changed to resolve its test binary, fixtures, and cargo at +runtime rather than through compile-time `env!()`, which bakes the build machine's paths in and +which `--workspace-remap` cannot relocate. + +**Rustup is not gone from Windows.** Three things still need a toolchain there: the +`--all-features` type-check (`test-windows-check`, which compiles the `cfg(windows)` arms behind +`bench` and `gix-parity` that the default-feature archive never sees), plus the +store-foundation-qualification and git-parity legs. Only the test execution was freed. The Windows run is now fanned across three shards, the same `slice:N/3` partitioning `ci.yml` uses, which splits evenly (2922 tests, 974 per shard, reconciling exactly). Before that it was a diff --git a/tests/github_actions.rs b/tests/github_actions.rs index aa9df471..03c23214 100644 --- a/tests/github_actions.rs +++ b/tests/github_actions.rs @@ -1,6 +1,7 @@ #[test] fn ci_workflow_runs_project_lint_and_tests() { let ci = std::fs::read_to_string(".github/workflows/ci.yml").expect("read CI workflow"); + let nix = std::fs::read_to_string(".github/workflows/ci-nix.yml").expect("read Nix workflow"); assert!(ci.contains("name: CI")); assert!(ci.contains("branches: [main]")); @@ -9,19 +10,27 @@ fn ci_workflow_runs_project_lint_and_tests() { assert!(ci.contains("ubuntu-latest")); assert!(ci.contains("macos-latest")); assert!(ci.contains("windows-latest")); - assert!(ci.contains("just lint")); + + // The Rust gate is split by platform. Linux runs hermetically as derivations; + // macOS runs cargo directly for an incremental target/; Windows executes a + // cross-compiled archive. Every platform still runs the suite somewhere. + assert!(nix.contains("nix build .#cli-fmt")); + assert!(nix.contains("nix build .#cli-clippy")); + assert!(nix.contains("nix build .#cli-nextest")); assert!(ci.contains("just test-ci")); + assert!(ci.contains("just windows-cross-archive x86_64-pc-windows-msvc")); + assert!(ci.contains("--archive-file")); + assert!(ci.contains("--partition")); - // Linux and macOS compile with the flake's pinned toolchain, so CI and a - // contributor's `nix develop` agree on the compiler and formatter. + // Linux and macOS take the compiler and formatter from the flake, so CI and a + // contributor's `nix develop` cannot drift apart. assert!(ci.contains("DeterminateSystems/nix-installer-action")); assert!(ci.contains("nix develop .#ci -c")); - // Windows still resolves its toolchain through rustup: Nix has no native - // Windows support. Retiring these needs the cross-compiled nextest archive - // to graduate out of its report-only phase. + // Windows keeps rustup: Nix has no native Windows support, and the all-features + // type-check plus the qualification and git-parity legs still need a toolchain + // there. The sharded test legs no longer do — they run prebuilt binaries. assert!(ci.contains("dtolnay/rust-toolchain@stable")); - assert!(ci.contains("dtolnay/rust-toolchain@nightly")); assert!(ci.contains("taiki-e/install-action@just")); assert!(ci.contains("taiki-e/install-action@nextest")); } From 2139b13f3c9d3d316feadc393f3ecca4bfe65449 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 21:46:32 -0700 Subject: [PATCH 29/36] ci: stop keying the Windows check leg as a shared shard cache The windows-shards rust-cache key dated from when three Windows legs compiled the identical test-binary set and wanted one entry between them. Those legs now execute a cross-compiled archive and have no target/ to cache, so the key described a sharing relationship that no longer exists. --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4c941e4..4535b5a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,9 +128,11 @@ jobs: with: components: rustfmt, clippy - uses: taiki-e/install-action@just + # No longer shared with anything: the sharded test legs run prebuilt binaries and + # have no target/ to cache. Keyed on its own so it stops colliding with them. - uses: Swatinem/rust-cache@v2 with: - shared-key: windows-shards + shared-key: windows-check - run: just check-types windows-cross-archive: From ea57851cc9d1af245256137acbbc52fe796beb6b Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 21:50:56 -0700 Subject: [PATCH 30/36] ci: remove the Windows wall-time measurement harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness existed to measure levers on the real Windows runner image one at a time, and that work concluded — the sharding it informed has since been superseded anyway. Its shard probe measured rust-cache warmth across three Windows legs that now execute a cross-compiled archive and have no target/ to warm. It was dispatch-only and referenced by nothing, so removing it changes no gate. Git history keeps it if the runner ever needs measuring again. --- .github/workflows/windows-ci-measure.yml | 143 ----------------------- 1 file changed, 143 deletions(-) delete mode 100644 .github/workflows/windows-ci-measure.yml diff --git a/.github/workflows/windows-ci-measure.yml b/.github/workflows/windows-ci-measure.yml deleted file mode 100644 index 299e8046..00000000 --- a/.github/workflows/windows-ci-measure.yml +++ /dev/null @@ -1,143 +0,0 @@ -name: windows ci measure - -# Dispatch-only measurement harness for the Windows test leg. -# This workflow NEVER runs on push or pull_request — it only exists so the wall-time -# levers can be measured on the real runner image and reverted. Each instrument is -# behind its own boolean input and is meant to be dispatched ONE AT A TIME: the -# Defender step changes the runner's security posture for the rest of the job, so -# enabling it alongside another instrument would skew that instrument's wall. -on: - workflow_dispatch: - inputs: - sys_user: - description: "Run the sys:user split (bash time)" - type: boolean - default: true - git_latency: - description: "Run the git per-spawn latency loop (native CreateProcess)" - type: boolean - default: false - defender_toggle: - description: "Re-enable Defender for one run to measure headroom (reverts)" - type: boolean - default: false - shard_probe: - description: "Run the 3-shard rust-cache-warmth probe" - type: boolean - default: false - quiet_log: - description: "Compare chatty vs quiet test-ci log output on one runner" - type: boolean - default: false - -permissions: - contents: read - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - -jobs: - measure: - name: measure (windows-latest) - runs-on: windows-latest - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - uses: taiki-e/install-action@just - - uses: taiki-e/install-action@nextest - - uses: Swatinem/rust-cache@v2 - - # --- sys:user split ------------------------------------------------------- - # The suite is parallel (CPU >> wall), so a high sys:user ratio is the - # spawn-bound signal. `time` writes real/user/sys (including children) to - # stderr; read those off the run log. This run's `real` is also the - # pre-change Windows wall baseline. - - name: (measure) sys:user split - if: ${{ inputs.sys_user }} - shell: bash - run: | - time just test-ci - - # --- Defender headroom ---------------------------------------------------- - # Restore the unmitigated posture (real-time monitoring on, drive exclusions - # removed) to MEASURE how much Defender costs on this suite. The runner VM is - # ephemeral, so nothing persists past this job; this never adds an exclusion. - - name: (measure) re-enable Defender (reverts at job end) - if: ${{ inputs.defender_toggle }} - shell: pwsh - run: | - Set-MpPreference -DisableRealtimeMonitoring $false - Remove-MpPreference -ExclusionPath "C:\" -ErrorAction SilentlyContinue - Remove-MpPreference -ExclusionPath "D:\" -ErrorAction SilentlyContinue - - name: (measure) suite with Defender on - if: ${{ inputs.defender_toggle }} - shell: bash - run: | - time just test-ci - - # --- git per-spawn latency ------------------------------------------------ - # The git spawn COUNT is OS-independent (taken from the local measurement), so - # only the Windows per-spawn LATENCY is measured here. A PATH shim cannot count - # native spawns on Windows anyway — Rust's Command::new("git") resolves git.exe - # via CreateProcess, which appends only `.exe` and never finds a `.cmd`/extension- - # less shim — so this times git the way the product spawns it: a native - # CreateProcess of git.exe in a tight loop. Multiply this against the local count - # to size the git-spawn share of wall (the deferred gix question). - - name: (measure) git per-spawn latency - if: ${{ inputs.git_latency }} - shell: pwsh - run: | - $git = (Get-Command git).Source - $n = 200 - $sw = [System.Diagnostics.Stopwatch]::StartNew() - for ($i = 0; $i -lt $n; $i++) { & $git rev-parse HEAD | Out-Null } - $sw.Stop() - $total = $sw.Elapsed.TotalMilliseconds - "$n x git rev-parse HEAD: $([math]::Round($total)) ms total (per-spawn ~ $([math]::Round($total / $n, 2)) ms)" - - # --- log-output cost ------------------------------------------------------ - # Does the chatty CI log (status-level=pass + final-status-level=all + forced - # color → ~3500 ANSI lines on Windows) add wall? Run the suite twice on the SAME - # runner to control for the large run-to-run variance: first chatty (the ci - # profile as shipped), then quiet (only failures, no color). The second run reuses - # the first's compiled binaries, so compare the two nextest run-phase `Summary - # [Ns]` lines (both warm, compile excluded) — the delta is the log-output cost. - - name: (measure) log-output cost (chatty vs quiet, same runner) - if: ${{ inputs.quiet_log }} - shell: bash - run: | - echo "=== chatty: ci profile as shipped (status-level=pass, final=all, color always) ===" - time just test-ci - echo "=== quiet: only failures, no color ===" - time env CARGO_TERM_COLOR=never just test-ci --status-level fail --final-status-level fail - - probe: - name: shard probe ${{ matrix.shard }}/3 - if: ${{ inputs.shard_probe }} - runs-on: windows-latest - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3] - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - uses: taiki-e/install-action@just - - uses: taiki-e/install-action@nextest - - uses: Swatinem/rust-cache@v2 - # A/B: shared-key now ACTIVE. All 3 shards restore ONE cache entry (they compile - # the identical test-binary set), instead of N distinct per-leg caches that fight - # the 10GB cap. Dispatch twice: the first run seeds the shared entry (cold), the - # second restores it (warm). Compare cache hit/miss + compile time against the - # earlier default-per-leg probe run (which was cold per-shard). - with: - shared-key: windows-shards - - name: compile-only timing - shell: bash - run: time cargo nextest run --profile ci --no-run - - name: partitioned run timing - shell: bash - run: time just test-ci --partition slice:${{ matrix.shard }}/3 From c21af3e1803899b5f82412dd2bb6a53df780d287 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Fri, 24 Jul 2026 22:04:48 -0700 Subject: [PATCH 31/36] test: guard runtime resolution of build-machine paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reintroducing a compile-time env!("CARGO_MANIFEST_DIR") or env!("CARGO_BIN_EXE_pointbreak") is easy to do by accident and fails nowhere except the Windows shards, where the cross-compiled archive is relocated and the baked path points at the Linux build machine. The failure reads "The system cannot find the path specified" and names no cause, which cost a long debugging round the first time. Fail on Linux instead, naming the file, the line, and the resolver to reach for, and pointing at the CI architecture notes for why the rule exists. include_str!/include_bytes! are exempt, scoped to the enclosing statement so an unrelated embed nearby cannot excuse a real path. The guard found one site the original migration missed: examples/support/review_example_pack.rs resolved the manifest directory at compile time to run git in it, and tests/review_example_pack.rs includes that file, so it was already compiled into a Windows test binary — latent rather than failing only because nothing exercised it there. --- docs/ci-architecture.md | 6 ++ examples/support/review_example_pack.rs | 17 +++- tests/runtime_path_resolution.rs | 122 ++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 tests/runtime_path_resolution.rs diff --git a/docs/ci-architecture.md b/docs/ci-architecture.md index 04681bb2..b3e302b5 100644 --- a/docs/ci-architecture.md +++ b/docs/ci-architecture.md @@ -173,6 +173,12 @@ It became possible once the suite was changed to resolve its test binary, fixtur runtime rather than through compile-time `env!()`, which bakes the build machine's paths in and which `--workspace-remap` cannot relocate. +`tests/runtime_path_resolution.rs` guards that convention. Reintroducing a compile-time path is +easy to do by accident and fails nowhere except the Windows shards, with a bare "system cannot +find the path specified" that names no cause — so the guard fails on Linux instead, naming the +file, the line, and the resolver to use. `include_str!`/`include_bytes!` are exempt: they embed +bytes, so no path survives into the binary. + **Rustup is not gone from Windows.** Three things still need a toolchain there: the `--all-features` type-check (`test-windows-check`, which compiles the `cfg(windows)` arms behind `bench` and `gix-parity` that the default-feature archive never sees), plus the diff --git a/examples/support/review_example_pack.rs b/examples/support/review_example_pack.rs index 79bcb84e..5d1fbfe7 100644 --- a/examples/support/review_example_pack.rs +++ b/examples/support/review_example_pack.rs @@ -420,10 +420,7 @@ fn build_pack(source_repo: &Path, stage: &Path) -> PackResult<()> { producer: ProducerManifest { name: "pointbreak".to_owned(), version: env!("CARGO_PKG_VERSION").to_owned(), - commit: git_output( - Path::new(env!("CARGO_MANIFEST_DIR")), - &["rev-parse", "HEAD"], - )?, + commit: git_output(&manifest_dir(), &["rev-parse", "HEAD"])?, }, record: RecordManifest { revision: REVISION.to_owned(), @@ -610,6 +607,18 @@ fn git_object(repo: &Path, commit: &str) -> PackResult { }) } +/// Absolute path to this crate's manifest directory, resolved at run time. +/// +/// This file is included both by the example binary and, via `#[path]`, by +/// tests/review_example_pack.rs, so it cannot reach either one's helper and carries its own. +/// Prefers the runtime `CARGO_MANIFEST_DIR` that cargo-nextest remaps under +/// `--workspace-remap`, falling back to the compile-time value for in-place runs. +fn manifest_dir() -> std::path::PathBuf { + std::env::var_os("CARGO_MANIFEST_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))) +} + fn git_output(repo: &Path, args: &[&str]) -> PackResult { let output = Command::new("git") .arg("-C") diff --git a/tests/runtime_path_resolution.rs b/tests/runtime_path_resolution.rs new file mode 100644 index 00000000..39a4e93d --- /dev/null +++ b/tests/runtime_path_resolution.rs @@ -0,0 +1,122 @@ +//! Guards the convention the cross-compiled Windows lane depends on. +//! +//! Windows tests are compiled on Linux and executed from a `cargo nextest` archive on a +//! runner with no Rust toolchain. `cargo-nextest` relocates that archive with +//! `--workspace-remap`, which rewrites the paths it hands the tests *at run time*. Anything +//! that captured a path at *compile* time still points at the Linux build machine, and the +//! test fails on Windows with a bare "system cannot find the path specified". +//! +//! That failure names no cause and reproduces on no other platform, so this guard exists to +//! turn it into a message that does. See docs/ci-architecture.md#windows. + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +#[path = "support/env.rs"] +#[allow(dead_code)] +mod env; + +/// Macros that capture a build-machine path at compile time. +const COMPILE_TIME_PATHS: &[&str] = &[ + r#"env!("CARGO_MANIFEST_DIR")"#, + r#"env!("CARGO_BIN_EXE_pointbreak")"#, + r#"env!("CARGO")"#, + r#"env!("OUT_DIR")"#, +]; + +/// The runtime resolvers themselves, each of which reads the environment first and keeps a +/// compile-time value only as the same-machine fallback. These are the intended home for +/// the macros above; everything else should call into one of them. +const RESOLVER_FILES: &[&str] = &[ + "tests/support/env.rs", + "tests/support/git_repo.rs", + "src/test_fixtures.rs", + "src/bench_support.rs", + "examples/support/review_example_pack.rs", + // This guard, which necessarily spells the macros out to search for them. + "tests/runtime_path_resolution.rs", +]; + +#[test] +fn tests_resolve_build_machine_paths_at_runtime() { + let root = env::manifest_dir(); + let mut sources = Vec::new(); + for dir in ["src", "tests", "benches", "examples"] { + collect_rust_sources(&root.join(dir), &mut sources); + } + assert!( + sources.len() > 100, + "source walk found only {} files; the guard is not looking where it thinks it is", + sources.len() + ); + + let mut violations = String::new(); + for path in sources { + let relative = relative_to(&root, &path); + if RESOLVER_FILES.contains(&relative.as_str()) { + continue; + } + let source = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("read {relative}: {error}")); + + for needle in COMPILE_TIME_PATHS { + for (offset, _) in source.match_indices(needle) { + if embeds_contents_at_compile_time(&source, offset) { + continue; + } + let line = source[..offset].lines().count(); + let _ = writeln!(violations, " {relative}:{line} {needle}"); + } + } + } + + assert!( + violations.is_empty(), + "These capture a build-machine path at compile time:\n\n{violations}\n\ + The Windows suite is cross-compiled on Linux and executed from a relocated nextest \n\ + archive, so a compile-time path points at a machine the test is not running on. It \n\ + passes locally and on Linux and macOS, then fails only on the Windows shards with \n\ + 'The system cannot find the path specified'.\n\n\ + Resolve at run time instead — `support::pointbreak_bin()`, `support::manifest_dir()`, \n\ + or `support::cargo_bin()` from tests/support/env.rs, `crate::test_fixtures::manifest_dir()` \n\ + inside the library's own tests, or `crate::bench_support::manifest_dir()` in the \n\ + benchmark harnesses. Each prefers the variable cargo-nextest rewrites and keeps the \n\ + compile-time value only as a same-machine fallback.\n\n\ + `include_str!`/`include_bytes!` are exempt and need no change: they embed the file's \n\ + bytes into the binary, so no path survives to be resolved.\n\n\ + Background: docs/ci-architecture.md#windows" + ); +} + +/// True when the macro at `offset` sits inside an `include_str!`/`include_bytes!`, which +/// embeds bytes at compile time and so carries no path into the built binary. +/// +/// Scoped to the enclosing statement — scanning back to the previous `;` — so an unrelated +/// `include_str!` earlier in the same block cannot excuse a later runtime path. +fn embeds_contents_at_compile_time(source: &str, offset: usize) -> bool { + let statement_start = source[..offset].rfind(';').map_or(0, |index| index + 1); + let statement = &source[statement_start..offset]; + statement.contains("include_str!") || statement.contains("include_bytes!") +} + +fn collect_rust_sources(dir: &Path, found: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries { + let entry = entry.expect("read directory entry"); + let path = entry.path(); + if entry.file_type().expect("stat entry").is_dir() { + collect_rust_sources(&path, found); + } else if path.extension().is_some_and(|extension| extension == "rs") { + found.push(path); + } + } +} + +fn relative_to(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} From bfba689d8546d0a9f6c20e46837b388e151efe43 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Sat, 25 Jul 2026 12:53:51 -0700 Subject: [PATCH 32/36] ci: remove color output for cargo via CARGO_TERM_COLOR = never --- .github/workflows/ci-nix.yml | 2 +- .github/workflows/ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-nix.yml b/.github/workflows/ci-nix.yml index 9ead478b..255df686 100644 --- a/.github/workflows/ci-nix.yml +++ b/.github/workflows/ci-nix.yml @@ -30,7 +30,7 @@ concurrency: cancel-in-progress: true env: - CARGO_TERM_COLOR: always + CARGO_TERM_COLOR: never RUST_BACKTRACE: 1 jobs: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4535b5a5..a6d02e21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ permissions: contents: read env: - CARGO_TERM_COLOR: always + CARGO_TERM_COLOR: never RUST_BACKTRACE: 1 jobs: From 3c5b186d9872856889b7d941d87ae6989e89711d Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Sat, 25 Jul 2026 13:15:56 -0700 Subject: [PATCH 33/36] ci: report the suite result from the Nix gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `nix build` prints nothing on success, so the Linux gate finished green with no test counts anywhere in its log — there was no way to tell from a run whether the suite had executed. It had: the step takes minutes, and a substituted result would take seconds, since the nextest derivation installs a 0-byte output. But "it must have, look at the duration" is a poor substitute for saying so. Recover the counts from the derivation's own build log instead of restoring -L, which streams every compile line and is what made these runs unreadable. Nextest colourises even in the sandbox, so the escape codes come out before matching. On a cache hit there is no local log, and the step says that plainly rather than printing a blank. That case is not a coverage gap: the derivation output is the proof those inputs passed, from an earlier run. --- .github/workflows/ci-nix.yml | 19 +++++++++++++++++++ docs/ci-architecture.md | 14 ++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/.github/workflows/ci-nix.yml b/.github/workflows/ci-nix.yml index 255df686..8c73b070 100644 --- a/.github/workflows/ci-nix.yml +++ b/.github/workflows/ci-nix.yml @@ -74,6 +74,25 @@ jobs: - name: build run: nix build .#build-all + # `nix build` prints nothing on success, so a green run showed no evidence the suite + # had run at all. Recover the counts from the derivation's own log rather than + # restoring `-L`, which streams every compile line and made these runs unreadable. + # + # Nextest colourises even in the sandbox — it has no TTY, but nothing tells it not to, + # and the workflow's CARGO_TERM_COLOR does not reach inside a derivation — so the + # escape codes have to come out before matching. + - name: suite summary + run: | + drv="$(nix eval --raw .#cli-nextest.drvPath)" + log="$(nix log "$drv" 2>/dev/null || true)" + if [ -n "$log" ]; then + printf '%s\n' "$log" | sed 's/\x1b\[[0-9;]*m//g' \ + | grep -aE 'Starting [0-9]+ tests|Summary \[|^ +(FAIL|SLOW)' || true + else + echo "No local build log: the suite result was substituted from the cache." + echo "Identical inputs, so the derivation was not rebuilt and the tests did not re-run." + fi + - name: dump failing build log if: failure() run: nix log "$(nix eval --raw .#cli-nextest.drvPath)" 2>/dev/null | tail -150 || true diff --git a/docs/ci-architecture.md b/docs/ci-architecture.md index b3e302b5..e35ee41d 100644 --- a/docs/ci-architecture.md +++ b/docs/ci-architecture.md @@ -77,6 +77,20 @@ gate first". Naming the checks individually can, which is why the PR job runs The cost is that the explicit list can drift from the flake's actual `checks`. The nightly job runs `nix flake check` precisely so a newly added check cannot be silently left ungated. +### Reading the Nix lane's logs + +`nix build` prints nothing on success, so a green Linux gate showed no evidence the suite had +run — the job log contained no test counts at all. Passing `-L` fixes that by streaming every +compile line, which is what made these runs unreadable in the first place. + +The `suite summary` step splits the difference: it pulls the counts back out of the derivation's +own build log after the fact. Two things to know when reading it. Nextest colourises even inside +the sandbox — it has no TTY, but nothing tells it not to, and the workflow's `CARGO_TERM_COLOR` +does not reach into a derivation — so the escape codes are stripped before matching. And on a +cache hit there is no local log, because nothing was built; the step says so rather than printing +an ambiguous blank. That case is not a gap: the derivation output *is* the proof those exact +inputs passed, just from an earlier run. + ### Store caching Three approaches, each of which hit a different GitHub ceiling: From d49fc05532e3556a834e18d02c2cd2b5546f7427 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Sat, 25 Jul 2026 13:43:03 -0700 Subject: [PATCH 34/36] ci: run the Linux suite in the main workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci.yml showed a macOS leg and Windows legs and no Linux one, which reads as a hole in the gate. Linux was covered the whole time, by a job in a separate workflow, but nothing about the checks list said so. Move it in and name it for the platform — test (ubuntu-latest), beside test (macos-latest) and test (windows-latest N/3) — so the matrix is legible from the checks list alone. The separate workflow made sense while the Nix lane was an experiment running beside the real gate; it is the real gate now. What is left runs nightly and on demand only: the hermetic check on macOS, too slow to gate at 22m37s against Linux's 15m46s, and `nix flake check` in full, which covers any check the phase list in ci.yml does not name. Drop its push and pull_request triggers, and the job-level guard they made necessary — a job that only skips is noise in the checks list. --- .github/result | 1 + .github/workflows/cache-gc.yml | 2 +- .github/workflows/ci-nix.yml | 118 --------------------------------- .github/workflows/ci.yml | 71 +++++++++++++++++++- .github/workflows/nightly.yml | 47 +++++++++++++ .github/workflows/nix.yml | 2 +- docs/ci-architecture.md | 23 ++++--- tests/github_actions.rs | 20 ++++-- 8 files changed, 147 insertions(+), 137 deletions(-) create mode 120000 .github/result delete mode 100644 .github/workflows/ci-nix.yml create mode 100644 .github/workflows/nightly.yml diff --git a/.github/result b/.github/result new file mode 120000 index 00000000..2ab517c2 --- /dev/null +++ b/.github/result @@ -0,0 +1 @@ +/nix/store/ziibsvpd0llkiwq39sqsjs5y40zd41wi-pointbreak-0.8.0 \ No newline at end of file diff --git a/.github/workflows/cache-gc.yml b/.github/workflows/cache-gc.yml index b1919520..4b39566d 100644 --- a/.github/workflows/cache-gc.yml +++ b/.github/workflows/cache-gc.yml @@ -25,7 +25,7 @@ concurrency: on: schedule: # Daily and off-peak. Deliberately not 07:00 UTC, which is the nightly full flake - # check in ci-nix.yml. + # check in nightly.yml. - cron: "23 3 * * *" workflow_dispatch: inputs: diff --git a/.github/workflows/ci-nix.yml b/.github/workflows/ci-nix.yml deleted file mode 100644 index 8c73b070..00000000 --- a/.github/workflows/ci-nix.yml +++ /dev/null @@ -1,118 +0,0 @@ -name: CI (Nix) - -# The hermetic counterpart to ci.yml. Both lanes now compile with the same flake-pinned -# toolchain, so this is not here to re-check the lints or the assertions — ci.yml covers -# those, and a broken flake already fails it because it runs `nix develop .#ci`. -# -# What only this lane can establish is that the suite passes in a SANDBOX: no host -# tools leaking in, no network, and no reuse of a `target/` directory restored from a -# cache. ci.yml's speed comes precisely from that restored target/, which makes a -# stale-artifact false green possible there and impossible here. -# -# Linux gates every pull request. macOS runs the same thing nightly rather than per -# pull request: it measured 22m37s against Linux's 15m46s, which is not worth adding -# to every change for a platform ci.yml already tests. - -on: - push: - branches: [main] - pull_request: - schedule: - # Nightly, for the platforms not gated per pull request. - - cron: "0 7 * * *" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ci-nix-${{ github.ref }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: never - RUST_BACKTRACE: 1 - -jobs: - nix-gate: - name: nix gate (ubuntu-latest) - if: github.event_name != 'schedule' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: DeterminateSystems/nix-installer-action@main - - # Third approach here; the first two each hit a different GitHub ceiling. - # cache-nix-action tarred the whole /nix/store (4.87GB Linux + 4.46GB macOS - # against a 10GB STORAGE limit, so the platforms evicted each other). Magic Nix - # Cache stored one entry per store path (1788 of them), which tripped the API - # RATE limit and then disabled itself mid-run. Hestia packs paths into - # content-defined chunks — few large entries — so it avoids both. - # Uploads authenticate with the runner-injected ACTIONS_RUNTIME_TOKEN, so - # `permissions: contents: read` above is sufficient. - # See docs/ci-architecture.md#store-caching. - - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 - - # Named individually on purpose: `nix flake check` batches every check in - # arbitrary order and so cannot return early on a cheap failure. The nightly job - # below runs the un-named form so an added check cannot be left ungated. - # See docs/ci-architecture.md#the-pr-gate-names-its-checks-the-nightly-one-does-not. - - # Phase 1 — formatting. Compiles nothing, so this is a true early return. - - name: fmt - run: nix build .#cli-fmt - - # Phase 2 — clippy, with the shared test-profile dependency artifacts. - - name: clippy - run: nix build .#cli-clippy - - # Phase 3 — the full suite in the sandbox, plus pinned-tool drift. - - name: test - run: nix build .#cli-nextest .#devshell-tools - - # Phase 4 — hermetic, network-free build of everything the project ships. - - name: build - run: nix build .#build-all - - # `nix build` prints nothing on success, so a green run showed no evidence the suite - # had run at all. Recover the counts from the derivation's own log rather than - # restoring `-L`, which streams every compile line and made these runs unreadable. - # - # Nextest colourises even in the sandbox — it has no TTY, but nothing tells it not to, - # and the workflow's CARGO_TERM_COLOR does not reach inside a derivation — so the - # escape codes have to come out before matching. - - name: suite summary - run: | - drv="$(nix eval --raw .#cli-nextest.drvPath)" - log="$(nix log "$drv" 2>/dev/null || true)" - if [ -n "$log" ]; then - printf '%s\n' "$log" | sed 's/\x1b\[[0-9;]*m//g' \ - | grep -aE 'Starting [0-9]+ tests|Summary \[|^ +(FAIL|SLOW)' || true - else - echo "No local build log: the suite result was substituted from the cache." - echo "Identical inputs, so the derivation was not rebuilt and the tests did not re-run." - fi - - - name: dump failing build log - if: failure() - run: nix log "$(nix eval --raw .#cli-nextest.drvPath)" 2>/dev/null | tail -150 || true - - full-check: - name: full flake check (${{ matrix.os }}) - # Nightly and on demand only. Runs `nix flake check` rather than the named phases - # above so that a check added to the flake cannot be silently left ungated. - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v6 - - uses: DeterminateSystems/nix-installer-action@main - - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 - - name: nix flake check - run: nix flake check - - name: dump failing build log - if: failure() - run: nix log "$(nix eval --raw .#cli-nextest.drvPath)" 2>/dev/null | tail -150 || true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6d02e21..859f68b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,11 +93,78 @@ jobs: working-directory: extensions/vscode run: npm run check + test-linux: + # Named for the platform, not the mechanism: alongside test (macos-latest) and + # test (windows-latest N/3) this is what makes the gate legible as covering all + # three. It does more than test — fmt, clippy, and the artifact build run here too — + # and unlike the other two it runs as sandboxed derivations rather than plain cargo. + # See docs/ci-architecture.md#why-keep-a-nix-lane. + name: test (ubuntu-latest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@main + + # Third approach here; the first two each hit a different GitHub ceiling. + # cache-nix-action tarred the whole /nix/store (4.87GB Linux + 4.46GB macOS + # against a 10GB STORAGE limit, so the platforms evicted each other). Magic Nix + # Cache stored one entry per store path (1788 of them), which tripped the API + # RATE limit and then disabled itself mid-run. Hestia packs paths into + # content-defined chunks — few large entries — so it avoids both. + # Uploads authenticate with the runner-injected ACTIONS_RUNTIME_TOKEN, so + # `permissions: contents: read` above is sufficient. + # See docs/ci-architecture.md#store-caching. + - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 + + # Named individually on purpose: `nix flake check` batches every check in + # arbitrary order and so cannot return early on a cheap failure. nightly.yml runs + # the un-named form so an added check cannot be left ungated. + # See docs/ci-architecture.md#the-pr-gate-names-its-checks-the-nightly-one-does-not. + + # Phase 1 — formatting. Compiles nothing, so this is a true early return. + - name: fmt + run: nix build .#cli-fmt + + # Phase 2 — clippy, with the shared test-profile dependency artifacts. + - name: clippy + run: nix build .#cli-clippy + + # Phase 3 — the full suite in the sandbox, plus pinned-tool drift. + - name: test + run: nix build .#cli-nextest .#devshell-tools + + # Phase 4 — hermetic, network-free build of everything the project ships. + - name: build + run: nix build .#build-all + + # `nix build` prints nothing on success, so a green run showed no evidence the suite + # had run at all. Recover the counts from the derivation's own log rather than + # restoring `-L`, which streams every compile line and made these runs unreadable. + # + # Nextest colourises even in the sandbox — it has no TTY, but nothing tells it not to, + # and the workflow's CARGO_TERM_COLOR does not reach inside a derivation — so the + # escape codes have to come out before matching. + - name: suite summary + run: | + drv="$(nix eval --raw .#cli-nextest.drvPath)" + log="$(nix log "$drv" 2>/dev/null || true)" + if [ -n "$log" ]; then + printf '%s\n' "$log" | sed 's/\x1b\[[0-9;]*m//g' \ + | grep -aE 'Starting [0-9]+ tests|Summary \[|^ +(FAIL|SLOW)' || true + else + echo "No local build log: the suite result was substituted from the cache." + echo "Identical inputs, so the derivation was not rebuilt and the tests did not re-run." + fi + + - name: dump failing build log + if: failure() + run: nix log "$(nix eval --raw .#cli-nextest.drvPath)" 2>/dev/null | tail -150 || true + test-macos: name: test (macos-latest) # The only platform whose suite runs as a plain cargo invocation. Linux is covered - # hermetically by ci-nix.yml's sandboxed gate; Windows by the cross-compiled archive - # below. This leg keeps rust-cache's incremental target/, which is the whole reason it + # hermetically by the sandboxed test-linux job above; Windows by the cross-compiled + # archive below. This leg keeps rust-cache's incremental target/, which is the whole reason it # is a `nix develop` run and not a derivation. # See docs/ci-architecture.md#toolchain-comes-from-the-flake. runs-on: macos-latest diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 00000000..f0387a59 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,47 @@ +name: Nightly + +# What ci.yml cannot afford to run per pull request. +# +# ci.yml's test-linux job already runs the sandboxed suite on Linux for every change. +# This adds the two things too slow to gate on: the same hermetic check on macOS, which +# measured 22m37s against Linux's 15m46s, and `nix flake check` in full, which builds +# every check in the flake rather than the four ci.yml names explicitly. +# +# Nothing here runs on a pull request, deliberately. A job that only skips is noise in +# the checks list. + +on: + schedule: + - cron: "0 7 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nightly-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: never + RUST_BACKTRACE: 1 + +jobs: + full-check: + name: full flake check (${{ matrix.os }}) + # Runs `nix flake check` rather than naming the checks the way ci.yml's test-linux + # job does, so that a check added to the flake cannot be silently left ungated. + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@main + - uses: Mic92/hestia@fb239a2f72d4b6e26eec5425f289dea23b27a527 # v2 + - name: nix flake check + run: nix flake check + - name: dump failing build log + if: failure() + run: nix log "$(nix eval --raw .#cli-nextest.drvPath)" 2>/dev/null | tail -150 || true diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 3807679f..a99fd9bf 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -38,7 +38,7 @@ jobs: # See docs/ci-architecture.md#nixyml-lints-nix-files-and-nothing-else. # It used to run `just nix-check`, whose `nix flake check` BUILDS every check # — clippy, the full nextest suite, tool drift — with no store cache, taking - # 14-18 minutes. Since ci-nix.yml realises those same derivations on every + # 14-18 minutes. Since ci.yml realises those same derivations on every # pull request, this job was running the entire suite a second time, in # parallel, from cold. Linting the Nix files is what is unique to this lane. - name: Run nix-lint diff --git a/docs/ci-architecture.md b/docs/ci-architecture.md index e35ee41d..fd4c7c15 100644 --- a/docs/ci-architecture.md +++ b/docs/ci-architecture.md @@ -8,17 +8,21 @@ The workflows carry short comments at each decision point that name the rule and you are about to change one of them, read the matching section first — several of the current choices look like oversights until you see the number behind them. -## The two lanes +## The workflows | Workflow | Runs | Purpose | | --- | --- | --- | -| `ci.yml` | every PR, all three platforms | The platform gate: the macOS suite, the sharded Windows suite from a cross-compiled archive, installers, skills, workflow lint, the front-end and extension checks, store qualification, and git parity. | -| `ci-nix.yml` | every PR (Linux); nightly (Linux + macOS) | The hermetic check: the suite in a sandbox, plus a network-free build of the shipped artifact. | +| `ci.yml` | every PR, all three platforms | The whole gate. The suite on each platform — Linux as sandboxed derivations, macOS as plain cargo, Windows from a cross-compiled archive — plus installers, skills, workflow lint, the front-end and extension checks, store qualification, and git parity. | +| `nightly.yml` | nightly and on demand | What is too slow to gate: the hermetic check on macOS, and `nix flake check` in full. | | `nix.yml` | PRs touching `*.nix` | Lints the Nix files themselves. Builds nothing. | | `cache-gc.yml` | nightly on the default branch | Prunes the hestia cache so the Nix lane stays inside the shared 10GB quota. | -Both lanes compile with the same flake-pinned toolchain, so `ci-nix.yml` is not a second opinion -on the lints. Its unique contribution is stated in [Why keep a Nix lane](#why-keep-a-nix-lane). +Every leg compiles with the same flake-pinned toolchain, so the Linux job is not a second opinion +on the lints. What only it establishes is stated in [Why keep a Nix lane](#why-keep-a-nix-lane). + +The Linux leg lives in `ci.yml` alongside the other two rather than in its own workflow. It ran +separately while it was still an experiment, but that left `ci.yml` showing macOS and Windows +tests and no Linux — a gate that reads as though it has a hole in it. ## Toolchain comes from the flake @@ -35,13 +39,14 @@ open item; see [Windows](#windows). ## Why keep a Nix lane -`ci.yml` gets its speed from a `rust-cache`-restored `target/` and a mutable working directory. +The macOS and Windows legs get their speed from a `rust-cache`-restored `target/` and a mutable +working directory. That makes a stale-artifact false green *possible* there. In a Nix derivation it is impossible: no host tools, no network, nothing reused from a cache. That property, on Linux, is the entire reason the lane exists. -Linux gates every PR. macOS runs nightly instead, for two reasons: it measured 22m37s against -Linux's 15m46s, and **Nix does not sandbox builds on macOS by default** (`sandbox = false` is the +Linux gates every PR, as `ci.yml`'s `test-linux` job. macOS runs the same check nightly instead, +for two reasons: it measured 22m37s against Linux's 15m46s, and **Nix does not sandbox builds on macOS by default** (`sandbox = false` is the Darwin default), so the hermeticity argument is weaker there anyway. ## Decisions, with the numbers @@ -131,7 +136,7 @@ If quota is still the binding constraint after that, the action takes an It used to run `just nix-check`, whose `nix flake check` builds every check — clippy, the whole suite, tool drift — with no store cache. A job named "Format and lint" was taking 14–18 minutes -and duplicating `ci-nix.yml` from cold. It now runs `just nix-lint` (nixfmt, statix, deadnix) in +and duplicating the Linux gate from cold. It now runs `just nix-lint` (nixfmt, statix, deadnix) in about a second. `just nix-check` keeps the fuller behaviour for local use. `nix flake check --no-build` is not a cheap substitute: `cleanCargoSource` filters a derivation diff --git a/tests/github_actions.rs b/tests/github_actions.rs index 03c23214..581f63bb 100644 --- a/tests/github_actions.rs +++ b/tests/github_actions.rs @@ -1,7 +1,8 @@ #[test] fn ci_workflow_runs_project_lint_and_tests() { let ci = std::fs::read_to_string(".github/workflows/ci.yml").expect("read CI workflow"); - let nix = std::fs::read_to_string(".github/workflows/ci-nix.yml").expect("read Nix workflow"); + let nightly = + std::fs::read_to_string(".github/workflows/nightly.yml").expect("read nightly workflow"); assert!(ci.contains("name: CI")); assert!(ci.contains("branches: [main]")); @@ -11,12 +12,13 @@ fn ci_workflow_runs_project_lint_and_tests() { assert!(ci.contains("macos-latest")); assert!(ci.contains("windows-latest")); - // The Rust gate is split by platform. Linux runs hermetically as derivations; + // The Rust gate is split by platform, and all three legs live in this one workflow so + // the checks list shows the whole matrix. Linux runs hermetically as derivations; // macOS runs cargo directly for an incremental target/; Windows executes a - // cross-compiled archive. Every platform still runs the suite somewhere. - assert!(nix.contains("nix build .#cli-fmt")); - assert!(nix.contains("nix build .#cli-clippy")); - assert!(nix.contains("nix build .#cli-nextest")); + // cross-compiled archive. + assert!(ci.contains("nix build .#cli-fmt")); + assert!(ci.contains("nix build .#cli-clippy")); + assert!(ci.contains("nix build .#cli-nextest")); assert!(ci.contains("just test-ci")); assert!(ci.contains("just windows-cross-archive x86_64-pc-windows-msvc")); assert!(ci.contains("--archive-file")); @@ -33,6 +35,12 @@ fn ci_workflow_runs_project_lint_and_tests() { assert!(ci.contains("dtolnay/rust-toolchain@stable")); assert!(ci.contains("taiki-e/install-action@just")); assert!(ci.contains("taiki-e/install-action@nextest")); + + // Nightly carries what is too slow to gate, and nothing else: `nix flake check` in + // full, so a check added to the flake cannot be left ungated by the explicit phase + // list above. It must not run per pull request — a job that only skips is noise. + assert!(nightly.contains("nix flake check")); + assert!(!nightly.contains("pull_request")); } #[test] From c05b9af173d2de889498e38f81125eac1ab03b09 Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Sat, 25 Jul 2026 14:04:39 -0700 Subject: [PATCH 35/36] ci: move the cross-archive transfer onto the current artifact actions The Windows cross-archive jobs used upload-artifact@v4 and download-artifact@v4, which run on Node 20 and warn that the runtime is deprecated. The rest of the repository was already on v7 and v8; these two were left behind when the jobs were written. An audit of every action reference found nothing else on Node 20. checkout@v6, setup-node@v6, setup-uv@v8.1.0, rust-cache@v2, and action-gh-release@v3 all declare node24 despite newer majors existing, so bumping them buys no runtime currency. The three SHA-pinned actions are current too: create-github-app-token resolves to v3.2.0, hestia to v2.0.0, and action-actionlint runs on docker, where the deprecation does not apply. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 859f68b0..59eec1b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,7 +215,7 @@ jobs: # cargo-xwin fetches the MSVC CRT/SDK here; XWIN_ACCEPT_LICENSE is set in the shell. - name: cross-compile x86_64-pc-windows-msvc nextest archive run: nix develop .#windows-cross --command just windows-cross-archive x86_64-pc-windows-msvc - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: nextest-archive-x86_64-pc-windows-msvc path: target/nextest/pointbreak-x86_64-pc-windows-msvc.tar.zst @@ -240,7 +240,7 @@ jobs: steps: - uses: actions/checkout@v6 - uses: taiki-e/install-action@nextest - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: nextest-archive-x86_64-pc-windows-msvc path: archive From 75b6bae18de1f8b27a22ca6d4309d00385b9b5fa Mon Sep 17 00:00:00 2001 From: Kevin Swiber Date: Sun, 26 Jul 2026 20:38:08 -0700 Subject: [PATCH 36/36] build: read the package version from Cargo.toml The flake repeated the version as a literal, so it only matched the manifest until the next release. Bumping Cargo.toml to 0.9.0 left the derivations named 0.8.0, which would have shipped a VSIX and a binary labelled with the previous version. Take it from the manifest through crane's crateNameFromCargoToml. The derivation names now follow a bump. --- flake.nix | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 385ecd7a..f2be531d 100644 --- a/flake.nix +++ b/flake.nix @@ -201,7 +201,11 @@ packages = forEachSystem ( pkgs: let - version = "0.8.0"; + # Read from Cargo.toml rather than repeated here. A release bumps the manifest, + # and a hardcoded copy would keep labelling artifacts with the previous version: + # bumping Cargo.toml alone used to leave the dependency derivation named + # pointbreak-deps-0.8.0, so the VSIX and binary would ship mislabelled. + inherit (craneLib.crateNameFromCargoToml { cargoToml = ./Cargo.toml; }) version; cocogitto = mkCocogitto pkgs; rustToolchains = mkRustToolchains pkgs; craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchains.stable;