From 0d180a7cd2792e28cb2866e8114e76b889e1e183 Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:54:24 +0100 Subject: [PATCH 1/3] fix(anvil): reduce installer token exposure with cached source installs Use source-first CI setup for ordinary Rust tools, retain anonymous binary installation for native tools, and strip inherited installer credentials. Cache impact's cargo-delta installation before setup saves and validate warm binary presence. Keep explicit local installers and checked source fallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .anvil.lock | 8 +- .github/actions/anvil-impact/action.yml | 11 +- .github/actions/anvil-setup/action.yml | 65 ++-- crates/cargo-anvil/docs/design/README.md | 6 +- crates/cargo-anvil/docs/design/github.md | 67 +++- crates/cargo-anvil/docs/design/local.md | 41 ++- .../cargo-anvil/src/anvil/artifacts/github.rs | 80 ++++- .../src/anvil/artifacts/justfile.rs | 8 +- .../templates/github/impact-action.yml | 11 +- .../templates/github/setup-action.yml | 65 ++-- .../templates/justfiles/anvil/tools.just | 62 ++-- crates/cargo-anvil/tests/recipe_contracts.rs | 310 +++++++++++++++++- .../snapshots/snapshots__ado_backend.snap | 62 ++-- .../snapshots/snapshots__github_backend.snap | 138 ++++---- .../snapshots/snapshots__local_only.snap | 62 ++-- justfiles/anvil/tools.just | 62 ++-- 16 files changed, 752 insertions(+), 306 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index 006007753..2b8cc5d27 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,7 +1,7 @@ version = 1 tool = "anvil" tool_version = "0.9.0" -catalog_checksum = "sha256:8daaee8ac9a6ceb2c71b002ff6eb85d49057ae8df7c56f2c4c9a2805de3288bb" +catalog_checksum = "sha256:055a098e00f5a16a10818774fbb1cdc704ae6f93f2accb174cd82fb6e40c9513" [[file]] path = ".anvil/container/Dockerfile.dockerignore" @@ -9,7 +9,7 @@ checksum = "sha256:641cb14e197a1fd9c8e179b7e992aede15d80291841b82830d7283e1b751d [[file]] path = ".github/actions/anvil-impact/action.yml" -checksum = "sha256:76be01852dc1f7e01905c4a8ad384ca57eefc15e2ed74c5c00f57dddefae9b6f" +checksum = "sha256:9c9b4fabcaa073e9a1e8a55fbb13bdfff9017afb0520ac51b9caf90a092c9b5d" [[file]] path = ".github/actions/anvil-report-status/action.yml" @@ -21,7 +21,7 @@ checksum = "sha256:d564a0ce424cda58c8f5b2476cf0c8b36e759afbdf8227608bd1f3fc74913 [[file]] path = ".github/actions/anvil-setup/action.yml" -checksum = "sha256:08bdc66a6ee94447aa1a60ece93c73fcbc930b7e331de85b2e9b78b5bc3d2e01" +checksum = "sha256:e29ccdb511ac5232ca12e33d26f11c4c985a3d666fdcce96b25353f61cd29f51" [[file]] path = ".github/actions/anvil-setup/just-problem-matcher.json" @@ -245,7 +245,7 @@ checksum = "sha256:00453a12cbb34811ee6a2c083dade5f6198575e3b0610f49e4743366326cd [[file]] path = "justfiles/anvil/tools.just" -checksum = "sha256:77329a0cb72de82742e7f48b6c8c06874e13b5c1622dc8080ce003e0c5ece251" +checksum = "sha256:16f45dfd4549728d445235f28daa8db3dae0a3033c617bc9e7b6e56c39344a4a" [[file]] path = "justfiles/anvil/versions.just" diff --git a/.github/actions/anvil-impact/action.yml b/.github/actions/anvil-impact/action.yml index d5f41291b..8a7b8da0d 100644 --- a/.github/actions/anvil-impact/action.yml +++ b/.github/actions/anvil-impact/action.yml @@ -23,17 +23,12 @@ description: | runs: using: composite steps: - # anvil-setup with group=none bootstraps the rust toolchain + - # just + binstall + cache, but skips the full catalog install. - # We follow it with just the cargo-delta install (the only tool - # this composite needs). This keeps the impact stage lean -- it's + # Install only cargo-delta inside setup, after restore and before save. + # This keeps the impact stage lean -- it's # the critical-path gating dep for every PR-tier group job. - uses: ./.github/actions/anvil-setup with: - group: none - - name: Install cargo-delta - shell: bash - run: just anvil-tool-cargo-delta-install binstall + group: impact - name: Compute impact shell: bash # Run the shared anvil-impact recipe -- the same impact building block diff --git a/.github/actions/anvil-setup/action.yml b/.github/actions/anvil-setup/action.yml index e9b9baff6..d10a379ee 100644 --- a/.github/actions/anvil-setup/action.yml +++ b/.github/actions/anvil-setup/action.yml @@ -14,8 +14,8 @@ inputs: `just anvil-setup` -- use for local "give me everything" flows. - "none": skip tool installation entirely; just restore the - Cargo cache and bootstrap just + binstall. Used by - anvil-impact, which installs only cargo-delta afterwards. + Cargo cache and bootstrap just. + - "impact": install only cargo-delta, before saving the cache. - a name containing only lowercase letters, digits, and hyphens: install only that group's prerequisites via `just anvil--setup`. @@ -89,9 +89,9 @@ runs: # one key. The prefix shares a completed cache across sibling jobs. # There is deliberately no fallback across toolchain/catalog changes: # those inputs are the cache-pruning boundary. - key: anvil-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}-${{ github.job }} + key: anvil-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just', 'justfiles/anvil/tools.just', '.github/actions/anvil-setup/action.yml') }}-${{ github.job }} restore-keys: | - anvil-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}- + anvil-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just', 'justfiles/anvil/tools.just', '.github/actions/anvil-setup/action.yml') }}- # `.crates.toml` and `.crates2.json` track which cargo-installed # tools and versions live in ~/.cargo/bin/. Without them in the # cache, `cargo install --list` and (downstream) the @@ -107,13 +107,15 @@ runs: ~/.cargo/.crates.toml ~/.cargo/.crates2.json - # cargo-binstall keeps the cold bootstrap path fast. - - name: Install cargo-binstall - uses: cargo-bins/cargo-binstall@v1.21.0 # pinned by tag: this release is an immutable release (GitHub locks the tag to a commit) - - name: Install just shell: pwsh -NoProfile -Command ". '{0}'" run: | + foreach ($key in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN')) { + Remove-Item "Env:$key" -ErrorAction SilentlyContinue + } + foreach ($entry in @(Get-ChildItem Env: | Where-Object Name -Match '^CARGO_REGISTRIES_.*_TOKEN$')) { + Remove-Item "Env:$($entry.Name)" + } $minimum = [version]'1.46.0' $installed = if (Get-Command just -ErrorAction SilentlyContinue) { [version]((& just --version) -replace '^just\s+', '') @@ -122,11 +124,8 @@ runs: } if ($null -eq $installed -or $installed -lt $minimum) { Write-Host "Installing just >= $minimum (installed: $(if ($installed) { $installed } else { 'not found' }))" - & cargo binstall --no-confirm --locked --version ">=$minimum" just - if ($LASTEXITCODE -ne 0) { - & cargo install --locked --version ">=$minimum" just - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } + & cargo install --locked --version "=$minimum" just + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } $installed = [version]((& just --version) -replace '^just\s+', '') if ($installed -lt $minimum) { @@ -147,17 +146,12 @@ runs: env: ANVIL_GROUP: ${{ inputs.group }} # When `group` is empty (the default), installs the full catalog - # via `just anvil-setup binstall`. When `group` is "none", - # skips tool installation entirely (used by anvil-impact, which - # only needs cargo-delta and installs it itself afterwards). When + # via `just anvil-setup ci`. When `group` is "none", + # skips tool installation entirely. When it is "impact", installs + # cargo-delta here so the cache save includes it. When # `group` is anything else, installs only what that group needs - # via `just anvil--setup binstall`. - # - # binstall path downloads prebuilt tool binaries from each tool's - # GitHub Releases when available (~1 min cold, vs ~30 min for - # source builds). cargo-binstall has unresolved compliance issues - # for ADO pipelines, so the ADO backend uses the default `install` - # path; GH uses `binstall`. + # via `just anvil--setup ci`. Ordinary Rust tools compile + # without release discovery; native tools prefer anonymous binaries. run: | if [[ -n "$ANVIL_GROUP" && "$ANVIL_GROUP" != "none" && ! "$ANVIL_GROUP" =~ ^[a-z0-9-]+$ ]]; then echo "::error::Invalid Anvil group; expected lowercase letters, digits, and hyphens." @@ -167,27 +161,16 @@ runs: case "$ANVIL_GROUP" in none) echo "anvil-setup: group=none, skipping tool install" ;; - "") just anvil-setup binstall ;; - *) just "anvil-$ANVIL_GROUP-setup" binstall ;; + impact) just anvil-tool-cargo-delta-install ci ;; + "") just anvil-setup ci ;; + *) just "anvil-$ANVIL_GROUP-setup" ci ;; esac - # Save the cache as the LAST step of setup, regardless of whether - # any earlier install step partially failed. `actions/cache` - # used to support this via `save-always: true`, but that knob is - # deprecated as of 2025 with the explicit message "does not work - # as intended" — failing runs simply don't save. The supported - # replacement is to call `actions/cache/save` directly as its - # own step with `if: always()`. - # - # Without this, a single catalog issue that fails the install - # step locks the cache empty forever (chicken-and-egg: failed - # run -> no save -> next run cold-starts -> still fails -> still - # no save). Tool binaries installed by anvil-tools-install are - # immutable once on disk, so partial state is strictly better - # than nothing — and subsequent runs accumulate into the cache - # until the catalog is complete. + # Preserve GitHub's ref-scoped cache trust boundary: PR writes are confined + # to their merge ref, never the default branch. Save only complete setup; + # failures must not seal an incomplete immutable key. - name: Save cargo cache - if: always() && steps.cargo-cache.outputs.cache-hit != 'true' + if: success() && steps.cargo-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.cargo-cache.outputs.cache-primary-key }} diff --git a/crates/cargo-anvil/docs/design/README.md b/crates/cargo-anvil/docs/design/README.md index 4140f15ff..1428a5536 100644 --- a/crates/cargo-anvil/docs/design/README.md +++ b/crates/cargo-anvil/docs/design/README.md @@ -388,8 +388,10 @@ outstanding owned-file proposals and managed-region refusals. - Generated GH composite actions and ADO step templates do nothing privileged on their own; they just invoke `just` recipes. The user's workflow / pipeline file controls permissions and secrets. -- All cargo-tool installs done by the setup building blocks use `--locked`. No - `cargo-binstall`. +- All cargo-tool installs use `--locked`. GitHub setup uses source-first installation; + native tools can use anonymous binary downloads with a checked source fallback. + Installers remove inherited GitHub and Cargo registry token environment variables. + API checks retain their separate token scope; this is not a build-script sandbox. - The tool never sources or executes content from any user-edited file at runtime; everything executable in the repo is plain `just` recipes the user can read. - Recommended user-workflow shape: `permissions: contents: read` on PR workflows; grant diff --git a/crates/cargo-anvil/docs/design/github.md b/crates/cargo-anvil/docs/design/github.md index 2da4d4b24..afbeae953 100644 --- a/crates/cargo-anvil/docs/design/github.md +++ b/crates/cargo-anvil/docs/design/github.md @@ -772,16 +772,17 @@ result, and log link without check-suite attribution. ### `anvil-setup` `anvil-setup` is a composite action that restores Cargo home, bootstraps -cargo-binstall and Just, then invokes the requested catalog setup recipe, whose +Just, then invokes the requested catalog setup recipe, whose prerequisites provision the selected compiler and tools. Its `group` input controls which recipes run: -- empty (default): runs `just anvil-setup binstall` -- the full catalog. Use +- empty (default): runs `just anvil-setup ci` -- the full catalog. Use for local "give me everything" flows. -- `none`: skips the group/full tool fan-out. Used by `anvil-impact`, which only - needs `cargo-delta` and installs it itself afterwards. +- `none`: restores the cache and bootstraps Just without installing catalog tools. +- `impact`: source-installs only cargo-delta, inside setup before the cache save. + Neither `none` nor `impact` needs cargo-binstall. - any other value (e.g. `pr-fast`, `scheduled-advisories`): runs - `just anvil--setup binstall` -- only the tools, components, and + `just anvil--setup ci` -- only the tools, components, and toolchains that group actually needs. Ordinary group names contain only lowercase letters, digits, and hyphens. `anvil-run-group` passes its group input here, so a `pr-fast` matrix leg never installs cargo-mutants. @@ -798,6 +799,21 @@ The action expects the rustup proxies on `PATH` and installs a missing selected toolchain (see §7). `anvil-impact` is described in §6 below. +Setup does not forward `github.token` to installers. Just installs from source at +exactly 1.46.0 on a cold runner. Catalog tools use exact-pin, locked source installs, +except cargo-spellcheck, whose native prerequisites justify trying anonymous binaries +first. A binary miss runs the existing libclang prerequisite check before compilation; +missing prerequisites remain a visible failure with platform-specific install hints. +Setup does not eagerly install system packages. +The installer source-builds pinned cargo-binstall 1.21.0 only if a binary path +needs it and it is absent. Warm tool hits do not bootstrap or invoke cargo-binstall. + +Install recipes strip inherited GitHub and Cargo registry token environment variables. +Binary installation disables credential discovery from GitHub CLI and Git configuration. +The separate run-group step still supplies `GITHUB_TOKEN` for API checks. Permissions +remain caller-owned. This is reduced installer exposure, not a token-free workflow or +a build-script sandbox. + Its optional `free-disk-space` input defaults to `false`. When enabled on a GitHub-hosted runner, it removes pre-installed toolchains that anvil's Rust checks do not use: Android, Haskell/GHC, Swift and browser drivers on Linux; Android and @@ -813,13 +829,12 @@ Other groups retain the action's disabled default. `anvil-impact` recipe — the same impact building block adopters run locally (see [local.md §4](./local.md#4-impact-scoping-via-the-anvil-impact-recipe)). It: -1. `./.github/actions/anvil-setup` with `group: none` (bootstrap rust + just + - cache; no catalog tools). -2. `just anvil-tool-cargo-delta-install binstall` -- the only tool this composite - needs. **This is the only job that runs cargo-delta to compute the impact +1. `./.github/actions/anvil-setup` with `group: impact` restores Cargo home, + bootstraps Just, and runs `just anvil-tool-cargo-delta-install ci` before saving. + **This is the only job that runs cargo-delta to compute the impact set.** (Group setup jobs also install cargo-delta as a prerequisite, but in `consume` mode they never run it -- they read the downloaded impact cache.) -3. `just anvil-impact`, which resolves the base ref (`_anvil-base-ref`), snapshots the +2. `just anvil-impact`, which resolves the base ref (`_anvil-base-ref`), snapshots the base ref (in a throwaway worktree) and the working tree, runs `cargo delta impact`, and writes the durable cache under `target/anvil/impact/`: the per-tier `include_.txt` lists (via `_anvil-impact-format`), `impact.json`, @@ -891,7 +906,7 @@ toolchain file or replays options from a file suppressed by `RUSTUP_TOOLCHAIN`. Because file selection remains native, rustup applies its normal lookup from each Cargo or Rust command's working directory. -The setup action restores Cargo home, bootstraps cargo-binstall and Just, and +The setup action restores Cargo home, bootstraps Just, and then invokes the selected catalog setup recipe. Setup ensures the selected compiler is available before stable Cargo or Rust runs. GH-hosted runners provide the rustup proxy used to install a missing public MSRV or process a @@ -924,7 +939,10 @@ compiler. The `anvil-setup` composite action computes a cache key from runner OS and architecture plus hashes of `.cargo/config.toml`, either supported repository -toolchain file, and `versions.just`, followed by the workflow job ID. +toolchain file, `versions.just`, `tools.just`, and the setup action itself, followed +by the workflow job ID. The `anvil-v3` namespace separates source-first installations +from previous generations. Installer policy, bootstrap versions, catalog versions, +and source-install options (including any feature arguments) participate in the hash. Toolchain-file or catalog changes deliberately start a fresh cache generation to bound registry growth; there is no restore fallback across those boundaries. Routine `Cargo.toml`, `Cargo.lock`, and compiler-version changes do not @@ -932,6 +950,24 @@ invalidate standalone cached tools. Job discrimination prevents concurrent jobs from racing to save one key, while the fingerprint prefix shares prior installs across jobs within the same cache generation. +This is an installed-binary cache, not an exact compiler-artifact cache. An unchanged +toolchain channel resolving to a new patch release does not invalidate compatible +binaries. Environment-only compiler, target, or build-flag overrides do not enter the +key; runners that require distinct binaries must declare those inputs in the hashed +configuration, or customize the cache key. Default-feature source installs do not +share compilation artifacts between tools. + +Cache restore precedes Just and cargo-delta installation. Impact's delta installation +now precedes save, so a successful cache producer can retain that binary too. +Only successful setup saves executable caches. GitHub's existing cache ref scope +remains the trust boundary: PR caches belong to their merge ref, and cannot overwrite +or populate default-branch caches. PR reruns can reuse their own caches; sibling PRs +can restore default-branch caches, not each other's merge-ref caches. No +`pull_request_target` execution or permission increase is introduced. Failed setup +cannot seal a partial immutable key. New generations compile ordinary tools on +cold misses. Full-cache hits +query install metadata and executable presence without reinstalling tools. + The cache covers: - The `cargo install`-ed tools installed by the catalog setup recipes (`~/.cargo/bin/` @@ -939,6 +975,9 @@ The cache covers: registry (`~/.cargo/registry/`). The key includes `${{ github.job }}`, so a `pr-test` cache hit doesn't have to wait on a `pr-fast` cache miss. +Only registry archives/indexes, executables, and install ledgers are cached. Cargo +credential files, Git credential files, and GitHub CLI configuration are excluded. + The `target/` build directory is deliberately **not** cached. A per-job, per-OS, per-arch `target/` is large, and the many multi-GB entries would evict the high-value tool caches under the Actions 10 GB per-repo cache limit (LRU) — so caching it is a net loss here, @@ -990,8 +1029,8 @@ than inconsistent. Actions whose publisher has enabled GitHub [immutable releases][immutable] are pinned by tag. In the generated workflows that is, at the time of writing, -`codecov/codecov-action@v7.0.0`, `marocchino/sticky-pull-request-comment@v3.0.5` and -`cargo-bins/cargo-binstall@v1.21.0`; a repository's own hand-maintained workflows apply +`codecov/codecov-action@v7.0.0` and `marocchino/sticky-pull-request-comment@v3.0.5`; +a repository's own hand-maintained workflows apply the same rule to the actions they use, so the list a reader sees there may be longer. An immutable release locks its Git tag to one commit: the tag cannot be moved, and cannot be deleted while the release exists. The tag name cannot be reused even after diff --git a/crates/cargo-anvil/docs/design/local.md b/crates/cargo-anvil/docs/design/local.md index eae31ad6c..109a48fca 100644 --- a/crates/cargo-anvil/docs/design/local.md +++ b/crates/cargo-anvil/docs/design/local.md @@ -229,11 +229,10 @@ catalog edit (changing a variable in `versions.just`), not an upstream-release-t surprise. `cargo-binstall` and `just` are bootstrap utilities rather than catalog checks. -When absent, setup installs the latest compatible release available at that time; -when present, setup accepts it. They are intentionally outside the catalog's exact -installation guarantee so the bootstrap does not recursively require a versioned -installer. Their versions can therefore vary across cold environments, while every -tool that determines a catalog check's verdict remains catalog-controlled. +GitHub setup source-installs Just 1.46.0 when the installed version is too old or +absent. It accepts newer installed versions. The binary-installer bootstrap +source-installs exactly 1.21.0 when a binary path needs it and it is absent; +it accepts an existing binary installer. ### 3.2 Detecting installed versions @@ -287,7 +286,7 @@ Mirror `*-validate-prereqs` recipes exist at every composition layer (`anvil--validate-prereqs`), so it's possible to verify a group's prerequisites without installing them. -The atomic installs are fully idempotent (early-skip on installed >= pin), so calling +The atomic installs are idempotent (early-skip on installed >= pin with an executable on PATH), so calling any composition layer on every cloud-workflow run is cheap on a cache hit. There is intentionally no separate "install-missing" variant: every install recipe IS the install-missing recipe. @@ -299,17 +298,29 @@ The `installer` argument: Slow on a cold runner (~30 min for the full catalog) because every tool re-compiles common deps (`clap`, `syn`, `quote`, ...) from scratch independently. - `binstall` -- `cargo binstall --no-confirm --locked --version '='`. - This selects an ordered strategy, not a binary-only backend. Anvil first asks - cargo-binstall to install the exact pin. Tools without a source prerequisite retain - cargo-binstall's compile strategy. For tools that declare a source prerequisite, - Anvil disables that compile strategy so compilation cannot bypass the check. Any + Anvil disables credential discovery and compilation in cargo-binstall. Any nonzero binstall result then falls back to Anvil's exact-pin `cargo install`; the declared prerequisite, when present, runs immediately before that fallback. - A successful binary path cuts the cold-runner install phase from ~30 min to ~1 min. - `cargo-binstall` itself needs to be on PATH; the GH setup composite arranges this. - -The GitHub composite setup action calls `just anvil--setup binstall` -(or just `anvil-setup binstall` when no group is scoped). The ADO setup step + Successful downloads avoid source compilation. +- `ci` -- source-first installation for ordinary Rust tools, including cargo-delta. + Only tools with a native source prerequisite (currently cargo-spellcheck) try + anonymous binary installation first. This avoids release discovery for ordinary + tools without forcing native dependency installation on binary cache hits. + +All three modes remove GitHub and Cargo registry token environment variables in +the installer process, before probing or installing. Binary installation also +uses `--no-discover-github-token` to prevent reading GitHub CLI or Git credentials. +Parent processes retain their environment for later API checks. Private authenticated +installation is intentionally not supported through these helpers. This reduces +ambient credential exposure; it does not sandbox build scripts or remove credentials +from files on the runner. + +An install metadata query failure stops setup. Missing executable files, old +versions, and unparseable versions trigger installation rather than a false cache hit. +When a ledger entry exists, either installer uses `--force` to repair stale metadata. + +The GitHub composite setup action calls `just anvil--setup ci` +(or just `anvil-setup ci` when no group is scoped). The ADO setup step template uses the default `install` backend because cargo-binstall has unresolved compliance issues for internal ADO pipelines (the binary registry it pulls from isn't on the standard allow-list), so the slower pure-cargo path is the diff --git a/crates/cargo-anvil/src/anvil/artifacts/github.rs b/crates/cargo-anvil/src/anvil/artifacts/github.rs index 78433a6ab..56fe8f42b 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/github.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/github.rs @@ -186,7 +186,7 @@ mod tests { #[test] fn setup_action_takes_group_input_and_dispatches() { assert!(SETUP_ACTION.contains("group:")); - assert!(SETUP_ACTION.contains("just anvil-setup binstall")); + assert!(SETUP_ACTION.contains("just anvil-setup ci")); assert!(!SETUP_ACTION.contains("_anvil-resolve-stable")); assert!(!SETUP_ACTION.contains("just anvil-toolchain-stable-install")); assert!(!SETUP_ACTION.contains("rustc-version")); @@ -208,13 +208,81 @@ mod tests { assert!(SETUP_ACTION.contains("$minimum = [version]'1.46.0'")); assert!(SETUP_ACTION.contains("cargo-anvil requires just >= $minimum")); assert!(SETUP_ACTION.contains("ANVIL_GROUP: ${{ inputs.group }}")); - assert!(SETUP_ACTION.contains("just \"anvil-$ANVIL_GROUP-setup\" binstall")); + assert!(SETUP_ACTION.contains("just \"anvil-$ANVIL_GROUP-setup\" ci")); assert!(SETUP_ACTION.contains(r"^[a-z0-9-]+$")); assert!(SETUP_ACTION.contains("::error::Invalid Anvil group;")); assert!(!SETUP_ACTION.contains("::error::Invalid Anvil group '$ANVIL_GROUP'")); assert!(SETUP_ACTION.contains("none)")); } + #[test] + fn setup_keeps_installation_anonymous_and_caches_impact_tools() { + assert!(!SETUP_ACTION.contains("${{ github.token }}")); + assert!(!IMPACT_ACTION.contains("${{ github.token }}")); + let bootstrap = SETUP_ACTION + .split_once(" - name: Install just\n") + .unwrap() + .1 + .split("\n - name:") + .next() + .unwrap(); + assert!(!bootstrap.contains("cargo binstall")); + assert!(bootstrap.contains("cargo install --locked --version \"=$minimum\" just")); + let restore = SETUP_ACTION.find(" - name: Restore cargo cache").unwrap(); + let install = SETUP_ACTION.find("impact) just anvil-tool-cargo-delta-install ci").unwrap(); + let save = SETUP_ACTION.find(" - name: Save cargo cache").unwrap(); + assert!(restore < install && install < save); + assert!(!IMPACT_ACTION.contains("run: just anvil-tool-cargo-delta-install")); + let cache_save = &SETUP_ACTION[save..]; + assert!(cache_save.contains("if: success() && steps.cargo-cache.outputs.cache-hit != 'true'")); + assert!(!SETUP_ACTION.contains("uses: cargo-bins/cargo-binstall")); + assert!(!PR_ROOT_WORKFLOW.contains("pull_request_target")); + assert_eq!(SETUP_ACTION.matches("uses: actions/cache/restore@").count(), 1); + assert_eq!(SETUP_ACTION.matches("uses: actions/cache/save@").count(), 1); + let cache_restore = &SETUP_ACTION[restore..SETUP_ACTION.find(" - name: Install just").unwrap()]; + let primary_key = cache_restore.lines().find(|line| line.trim_start().starts_with("key:")).unwrap(); + let restore_key = cache_restore + .lines() + .find(|line| line.trim_start().starts_with("anvil-v3-")) + .unwrap(); + for key in [ + "runner.os", + "runner.arch", + "'.cargo/config.toml'", + "'rust-toolchain'", + "'rust-toolchain.toml'", + "'justfiles/anvil/versions.just'", + "'justfiles/anvil/tools.just'", + "'.github/actions/anvil-setup/action.yml'", + ] { + assert!( + primary_key.contains(key) && restore_key.contains(key), + "both cache keys must include {key}" + ); + } + for cache_step in [cache_restore, cache_save] { + let paths = cache_step + .split_once(" path: |\n") + .unwrap() + .1 + .lines() + .take_while(|line| line.starts_with(" ")) + .map(str::trim) + .collect::>(); + assert_eq!( + paths, + [ + "~/.cargo/registry/cache/", + "~/.cargo/registry/index/", + "~/.cargo/bin/", + "~/.cargo/.crates.toml", + "~/.cargo/.crates2.json" + ], + "cache only archives/indexes, executables and install ledgers; never credentials or target" + ); + } + } + #[test] fn aggregate_gate_depends_on_every_validation_job() { let jobs = PR_IMPL_WORKFLOW.split_once("\njobs:\n").expect("PR workflow defines jobs").1; @@ -416,14 +484,14 @@ export -f just } #[test] - fn impact_action_uses_group_none_and_runs_the_shared_recipe() { - // The impact action reuses anvil-setup (group=none) + the cargo-delta + fn impact_action_installs_delta_inside_setup_and_runs_the_shared_recipe() { + // The impact action reuses anvil-setup (group=impact) for cargo-delta // install, then runs the same `just anvil-impact` recipe adopters run // locally and uploads the whole cache as a per-OS artifact. The include // lists reach group jobs through that downloaded cache, never job // outputs, so CI and local execution stay identical by construction. - assert!(IMPACT_ACTION.contains("group: none")); - assert!(IMPACT_ACTION.contains("just anvil-tool-cargo-delta-install binstall")); + assert!(IMPACT_ACTION.contains("group: impact")); + assert!(SETUP_ACTION.contains("just anvil-tool-cargo-delta-install ci")); assert!(IMPACT_ACTION.contains("run: just anvil-impact")); assert!(IMPACT_ACTION.contains("uses: actions/upload-artifact")); assert!(IMPACT_ACTION.contains("name: anvil-impact-${{ runner.os }}")); diff --git a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs index dc4777691..9a1270c42 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs @@ -379,11 +379,9 @@ mod tests { #[test] fn spellcheck_checks_source_prerequisites_before_source_builds() { assert!( - TOOLS_JUST.contains("if ($sourcePrereq)"), - "binstall compile strategy must only be disabled for tools with source prerequisites" - ); - assert!( - TOOLS_JUST.contains("$binstallArgs += @('--disable-strategies', 'compile')"), + TOOLS_JUST.contains( + "$binstallArgs = @('binstall', '--no-confirm', '--locked', '--disable-strategies', 'compile', '--no-discover-github-token')" + ), "binstall must not compile before Anvil checks source prerequisites" ); assert!( diff --git a/crates/cargo-anvil/templates/github/impact-action.yml b/crates/cargo-anvil/templates/github/impact-action.yml index d5f41291b..8a7b8da0d 100644 --- a/crates/cargo-anvil/templates/github/impact-action.yml +++ b/crates/cargo-anvil/templates/github/impact-action.yml @@ -23,17 +23,12 @@ description: | runs: using: composite steps: - # anvil-setup with group=none bootstraps the rust toolchain + - # just + binstall + cache, but skips the full catalog install. - # We follow it with just the cargo-delta install (the only tool - # this composite needs). This keeps the impact stage lean -- it's + # Install only cargo-delta inside setup, after restore and before save. + # This keeps the impact stage lean -- it's # the critical-path gating dep for every PR-tier group job. - uses: ./.github/actions/anvil-setup with: - group: none - - name: Install cargo-delta - shell: bash - run: just anvil-tool-cargo-delta-install binstall + group: impact - name: Compute impact shell: bash # Run the shared anvil-impact recipe -- the same impact building block diff --git a/crates/cargo-anvil/templates/github/setup-action.yml b/crates/cargo-anvil/templates/github/setup-action.yml index e9b9baff6..d10a379ee 100644 --- a/crates/cargo-anvil/templates/github/setup-action.yml +++ b/crates/cargo-anvil/templates/github/setup-action.yml @@ -14,8 +14,8 @@ inputs: `just anvil-setup` -- use for local "give me everything" flows. - "none": skip tool installation entirely; just restore the - Cargo cache and bootstrap just + binstall. Used by - anvil-impact, which installs only cargo-delta afterwards. + Cargo cache and bootstrap just. + - "impact": install only cargo-delta, before saving the cache. - a name containing only lowercase letters, digits, and hyphens: install only that group's prerequisites via `just anvil--setup`. @@ -89,9 +89,9 @@ runs: # one key. The prefix shares a completed cache across sibling jobs. # There is deliberately no fallback across toolchain/catalog changes: # those inputs are the cache-pruning boundary. - key: anvil-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}-${{ github.job }} + key: anvil-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just', 'justfiles/anvil/tools.just', '.github/actions/anvil-setup/action.yml') }}-${{ github.job }} restore-keys: | - anvil-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}- + anvil-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just', 'justfiles/anvil/tools.just', '.github/actions/anvil-setup/action.yml') }}- # `.crates.toml` and `.crates2.json` track which cargo-installed # tools and versions live in ~/.cargo/bin/. Without them in the # cache, `cargo install --list` and (downstream) the @@ -107,13 +107,15 @@ runs: ~/.cargo/.crates.toml ~/.cargo/.crates2.json - # cargo-binstall keeps the cold bootstrap path fast. - - name: Install cargo-binstall - uses: cargo-bins/cargo-binstall@v1.21.0 # pinned by tag: this release is an immutable release (GitHub locks the tag to a commit) - - name: Install just shell: pwsh -NoProfile -Command ". '{0}'" run: | + foreach ($key in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN')) { + Remove-Item "Env:$key" -ErrorAction SilentlyContinue + } + foreach ($entry in @(Get-ChildItem Env: | Where-Object Name -Match '^CARGO_REGISTRIES_.*_TOKEN$')) { + Remove-Item "Env:$($entry.Name)" + } $minimum = [version]'1.46.0' $installed = if (Get-Command just -ErrorAction SilentlyContinue) { [version]((& just --version) -replace '^just\s+', '') @@ -122,11 +124,8 @@ runs: } if ($null -eq $installed -or $installed -lt $minimum) { Write-Host "Installing just >= $minimum (installed: $(if ($installed) { $installed } else { 'not found' }))" - & cargo binstall --no-confirm --locked --version ">=$minimum" just - if ($LASTEXITCODE -ne 0) { - & cargo install --locked --version ">=$minimum" just - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } + & cargo install --locked --version "=$minimum" just + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } $installed = [version]((& just --version) -replace '^just\s+', '') if ($installed -lt $minimum) { @@ -147,17 +146,12 @@ runs: env: ANVIL_GROUP: ${{ inputs.group }} # When `group` is empty (the default), installs the full catalog - # via `just anvil-setup binstall`. When `group` is "none", - # skips tool installation entirely (used by anvil-impact, which - # only needs cargo-delta and installs it itself afterwards). When + # via `just anvil-setup ci`. When `group` is "none", + # skips tool installation entirely. When it is "impact", installs + # cargo-delta here so the cache save includes it. When # `group` is anything else, installs only what that group needs - # via `just anvil--setup binstall`. - # - # binstall path downloads prebuilt tool binaries from each tool's - # GitHub Releases when available (~1 min cold, vs ~30 min for - # source builds). cargo-binstall has unresolved compliance issues - # for ADO pipelines, so the ADO backend uses the default `install` - # path; GH uses `binstall`. + # via `just anvil--setup ci`. Ordinary Rust tools compile + # without release discovery; native tools prefer anonymous binaries. run: | if [[ -n "$ANVIL_GROUP" && "$ANVIL_GROUP" != "none" && ! "$ANVIL_GROUP" =~ ^[a-z0-9-]+$ ]]; then echo "::error::Invalid Anvil group; expected lowercase letters, digits, and hyphens." @@ -167,27 +161,16 @@ runs: case "$ANVIL_GROUP" in none) echo "anvil-setup: group=none, skipping tool install" ;; - "") just anvil-setup binstall ;; - *) just "anvil-$ANVIL_GROUP-setup" binstall ;; + impact) just anvil-tool-cargo-delta-install ci ;; + "") just anvil-setup ci ;; + *) just "anvil-$ANVIL_GROUP-setup" ci ;; esac - # Save the cache as the LAST step of setup, regardless of whether - # any earlier install step partially failed. `actions/cache` - # used to support this via `save-always: true`, but that knob is - # deprecated as of 2025 with the explicit message "does not work - # as intended" — failing runs simply don't save. The supported - # replacement is to call `actions/cache/save` directly as its - # own step with `if: always()`. - # - # Without this, a single catalog issue that fails the install - # step locks the cache empty forever (chicken-and-egg: failed - # run -> no save -> next run cold-starts -> still fails -> still - # no save). Tool binaries installed by anvil-tools-install are - # immutable once on disk, so partial state is strictly better - # than nothing — and subsequent runs accumulate into the cache - # until the catalog is complete. + # Preserve GitHub's ref-scoped cache trust boundary: PR writes are confined + # to their merge ref, never the default branch. Save only complete setup; + # failures must not seal an incomplete immutable key. - name: Save cargo cache - if: always() && steps.cargo-cache.outputs.cache-hit != 'true' + if: success() && steps.cargo-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.cargo-cache.outputs.cache-primary-key }} diff --git a/crates/cargo-anvil/templates/justfiles/anvil/tools.just b/crates/cargo-anvil/templates/justfiles/anvil/tools.just index ec0008055..85b441024 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/tools.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/tools.just @@ -411,9 +411,10 @@ anvil-toolchain-stable-install: (_anvil-resolve-stable "install") # or no-op if it is already installed at or above that version. The # `installer` parameter selects between: # - "install" (cargo install --locked, pure-source). Default. -# - "binstall" (cargo binstall --no-confirm --locked, with a controlled -# cargo install fallback for tools that declare source -# prerequisites). Bootstraps cargo-binstall itself if not on PATH. +# - "binstall" (anonymous binary download, with a checked source fallback). +# - "ci" (source install, except tools with native source prerequisites, +# which try anonymous binaries first). Avoids release discovery for +# ordinary Rust tools even when prebuilt binaries exist. # `source_prereq`, when set, runs immediately before a source installation, # including a binstall fallback. [script("pwsh", "-NoProfile")] @@ -424,11 +425,23 @@ _install-tool-core name version installer source_prereq="": $installer = '{{installer}}' $sourcePrereq = '{{source_prereq}}' - if ($installer -ne 'install' -and $installer -ne 'binstall') { - Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install' or 'binstall')" + if ($installer -notin @('install', 'binstall', 'ci')) { + Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install', 'binstall', or 'ci')" exit 2 } + # This recipe runs in its own process: API checks in the parent retain + # their credentials, but installers and third-party build scripts do not. + foreach ($key in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN')) { + Remove-Item "Env:$key" -ErrorAction SilentlyContinue + } + foreach ($entry in @(Get-ChildItem Env: | Where-Object Name -Match '^CARGO_REGISTRIES_.*_TOKEN$')) { + Remove-Item "Env:$($entry.Name)" + } + if ($installer -eq 'ci') { + $installer = if ($sourcePrereq) { 'binstall' } else { 'install' } + } + # Already at or above the pin: skip. We don't downgrade tools the # user upgraded for their own reasons; the validate side uses # `installed >= pin`, so newer is fine. The early-exit is also what @@ -437,17 +450,22 @@ _install-tool-core name version installer source_prereq="": # cached binaries and fail with "binary already exists in destination". $installed = $null $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' - foreach ($line in (& cargo {{_anvil_stable_toolchain_args}} install --list 2>$null)) { + $listOutput = @(& cargo {{_anvil_stable_toolchain_args}} install --list) + if ($LASTEXITCODE -ne 0) { + throw "_install-tool-core: cargo install --list failed for '$name'" + } + foreach ($line in $listOutput) { if ($line -match $pattern) { $installed = $Matches[1]; break } } if ($installed) { - try { - if (([version]$installed) -ge ([version]$version)) { - Write-Host "$name >= $version (already satisfied; installed=$installed)" - exit 0 - } - } catch { - # Fall through to reinstall when versions don't parse as [version]. + $installedVersion = $null + $pinVersion = $null + if ([version]::TryParse($installed, [ref]$installedVersion) -and + [version]::TryParse($version, [ref]$pinVersion) -and + $installedVersion -ge $pinVersion -and + (Get-Command $name -ErrorAction SilentlyContinue)) { + Write-Host "$name >= $version (already satisfied; installed=$installed)" + exit 0 } } @@ -455,20 +473,17 @@ _install-tool-core name version installer source_prereq="": if ($installer -eq 'binstall') { if (-not (Get-Command cargo-binstall -ErrorAction SilentlyContinue)) { Write-Host ' Bootstrapping cargo-binstall' - & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall + & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall --version '=1.21.0' if ($LASTEXITCODE -ne 0) { Write-Error 'cargo-binstall bootstrap failed' exit $LASTEXITCODE } } - # Keep source builds behind Anvil's prerequisite check instead of - # allowing binstall to compile before that check can run. Tools with - # no source prerequisite may still use binstall's compile strategy. - $binstallArgs = @('binstall', '--no-confirm', '--locked') - if ($sourcePrereq) { - $binstallArgs += @('--disable-strategies', 'compile') - } + # No credential discovery from gh/git configuration, and no implicit + # compilation before the source-prerequisite check below. + $binstallArgs = @('binstall', '--no-confirm', '--locked', '--disable-strategies', 'compile', '--no-discover-github-token') $binstallArgs += @($name, '--version', "=$version") + if ($installed) { $binstallArgs += '--force' } & cargo {{_anvil_stable_toolchain_args}} @binstallArgs if ($LASTEXITCODE -eq 0) { exit 0 } Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow @@ -477,7 +492,10 @@ _install-tool-core name version installer source_prereq="": & "{{just_executable()}}" $sourcePrereq if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } - & cargo {{_anvil_stable_toolchain_args}} install --locked $name --version "=$version" + $installArgs = @('install', '--locked', $name, '--version', "=$version") + # Repair stale install metadata as well as old versions. + if ($installed) { $installArgs += '--force' } + & cargo {{_anvil_stable_toolchain_args}} @installArgs if ($LASTEXITCODE -ne 0) { Write-Error "$name install FAILED" exit $LASTEXITCODE diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index be7d85a44..92809827c 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -37,6 +37,7 @@ const README: &str = include_str!("../templates/justfiles/anvil/checks/readme-ch const SEMVER: &str = include_str!("../templates/justfiles/anvil/checks/semver-check.just"); const EXTERNAL_TYPES: &str = include_str!("../templates/justfiles/anvil/checks/external-types.just"); const TOOLS: &str = include_str!("../templates/justfiles/anvil/tools.just"); +const SETUP: &str = include_str!("../templates/github/setup-action.yml"); const APRZ: &str = include_str!("../templates/justfiles/anvil/checks/aprz.just"); const MUTANTS_DIFF: &str = include_str!("../templates/justfiles/anvil/checks/mutants-diff.just"); const VERSIONS: &str = include_str!("../templates/justfiles/anvil/versions.just"); @@ -118,6 +119,13 @@ if ($env:FAKE_CARGO_TOOLCHAIN_LOG) { if ($env:FAKE_CARGO_AUTO_INSTALL_LOG) { Add-Content -LiteralPath $env:FAKE_CARGO_AUTO_INSTALL_LOG -Value $env:RUSTUP_AUTO_INSTALL } +if ($env:FAKE_INSTALL_CREDENTIALS_LOG) { + $present = @(Get-ChildItem Env: | Where-Object { + $_.Name -in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN') -or + $_.Name -match '^CARGO_REGISTRIES_.*_TOKEN$' + } | ForEach-Object Name) + Add-Content -LiteralPath $env:FAKE_INSTALL_CREDENTIALS_LOG -Value "$joined|$($present -join ',')" +} if ($args -contains 'each') { exit [int]$env:FAKE_EACH_EXIT } @@ -493,10 +501,23 @@ fn just_command(root: &Path, arguments: &[&str], environment: &[(&str, &OsStr)]) command.env_remove("GITHUB_ACTIONS"); command.env_remove("TF_BUILD"); for key in std::env::vars_os().map(|(key, _)| key) { - if key.to_string_lossy().starts_with("ANVIL_INCLUDE_") { + let name = key.to_string_lossy(); + if name.starts_with("ANVIL_INCLUDE_") || name.starts_with("FAKE_") { command.env_remove(key); } } + command.env("FAKE_WORKSPACE_ROOT", root); + for key in [ + "RUSTUP_TOOLCHAIN", + "ANVIL_MSRV_TOOLCHAIN", + "GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "GH_ENTERPRISE_TOKEN", + "CARGO_REGISTRY_TOKEN", + ] { + command.env_remove(key); + } for &(key, value) in environment { command.env(key, value); } @@ -1738,11 +1759,294 @@ source-prereq: .find(|line| line.contains("binstall --no-confirm --locked")) .expect("ordinary tool must attempt binstall"); assert!( - !ordinary_binstall.contains("--disable-strategies compile"), - "tools without source prerequisites retain binstall's compile strategy" + ordinary_binstall.contains("--disable-strategies compile --no-discover-github-token"), + "all binary installs use anonymous discovery and controlled source fallback" ); } +#[test] +fn install_tool_ci_cold_warm_and_stale_cache_paths() { + assert!(tools_available(), "real Just and PowerShell are required for installer contracts"); + for (installed, binary, should_install) in [ + ("", false, true), + ("1.2.3", true, false), + ("1.3.0", true, false), + ("1.2.2", true, true), + ("1.2.3", false, true), + ("unparseable", true, true), + ] { + let tmp = installer_fixture(); + if binary { + write(&tmp.path().join("fake-bin/cargo-fixture.ps1"), "exit 0\n"); + } + let log = tmp.path().join("cargo.log"); + let ledger = format!("cargo-fixture v{installed}:\n cargo-fixture"); + let output = run_just( + tmp.path(), + &["_install-tool", "cargo-fixture", "1.2.3", "ci"], + &[ + ("FAKE_CARGO_LOG", log.as_os_str()), + ("FAKE_INSTALL_LIST_OUTPUT", OsStr::new(&ledger)), + ], + ); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let calls = fs::read_to_string(log).unwrap(); + assert!(!calls.contains("binstall"), "ordinary CI tools must not perform release discovery"); + assert_eq!(calls.contains("install --locked cargo-fixture --version =1.2.3"), should_install); + if should_install && !installed.is_empty() { + assert!(calls.contains("--force"), "stale metadata must not suppress repair"); + } + if !should_install { + assert_eq!(calls.lines().count(), 1, "a warm hit must only query install metadata"); + } + } +} + +#[test] +fn native_tool_cache_hit_skips_binstall_and_missing_binary_forces_repair() { + assert!(tools_available(), "real Just and PowerShell are required for installer contracts"); + for binary in [true, false] { + let tmp = installer_fixture(); + if binary { + write(&tmp.path().join("fake-bin/cargo-fixture.ps1"), "exit 0\n"); + } + let log = tmp.path().join("cargo.log"); + let output = run_just( + tmp.path(), + &["_install-tool", "cargo-fixture", "1.2.3", "ci", "must-not-run"], + &[ + ("FAKE_CARGO_LOG", log.as_os_str()), + ("FAKE_INSTALL_LIST_OUTPUT", OsStr::new("cargo-fixture v1.2.3:\n cargo-fixture")), + ], + ); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let calls = fs::read_to_string(log).unwrap(); + if binary { + assert_eq!( + calls.trim(), + "install --list", + "native cache hit must avoid installers and prerequisites" + ); + } else { + assert!( + calls.contains("cargo-fixture --version =1.2.3 --force"), + "binstall must not trust stale metadata: {calls}" + ); + } + assert!(!calls.contains("install --locked cargo-fixture")); + } +} + +#[test] +fn installers_drop_inherited_tokens_without_changing_the_parent() { + assert!(tools_available(), "real Just and PowerShell are required for installer contracts"); + for installer in ["install", "binstall", "ci"] { + let tmp = installer_fixture(); + let log = tmp.path().join("credentials.log"); + let justfile_path = tmp.path().join("Justfile"); + let mut justfile = fs::read_to_string(&justfile_path).unwrap(); + justfile.push_str( + r#" +[script("pwsh", "-NoProfile")] +parent installer: + & "{{just_executable()}}" _install-tool cargo-fixture 1.2.3 '{{installer}}' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($env:GITHUB_TOKEN -ne 'fixture-only' -or $env:GH_TOKEN -ne 'fixture-only') { exit 91 } +"#, + ); + write(&justfile_path, &justfile); + let output = run_just( + tmp.path(), + &["parent", installer], + &[ + ("FAKE_INSTALL_CREDENTIALS_LOG", log.as_os_str()), + ("FAKE_BINSTALL_EXIT", OsStr::new("7")), + ("GITHUB_TOKEN", OsStr::new("fixture-only")), + ("GH_TOKEN", OsStr::new("fixture-only")), + ("GITHUB_ENTERPRISE_TOKEN", OsStr::new("fixture-only")), + ("GH_ENTERPRISE_TOKEN", OsStr::new("fixture-only")), + ("CARGO_REGISTRY_TOKEN", OsStr::new("fixture-only")), + ("CARGO_REGISTRIES_FIXTURE_TOKEN", OsStr::new("fixture-only")), + ], + ); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let calls = fs::read_to_string(log).unwrap(); + assert!(calls.contains("install --locked cargo-fixture --version =1.2.3|")); + assert!( + calls.lines().all(|line| line.ends_with('|')), + "install subprocesses received tokens: {calls}" + ); + if installer == "binstall" { + assert!(calls.contains("--no-discover-github-token")); + } + } +} + +#[test] +fn ci_native_binary_hit_skips_source_prerequisites_but_miss_checks_them() { + assert!(tools_available(), "real Just and PowerShell are required for installer contracts"); + for (binary_exit, prerequisite_exit, source_exit, success, source_runs) in [ + ("0", "9", "23", true, false), + ("7", "0", "0", true, true), + ("7", "9", "0", false, false), + ("7", "0", "23", false, true), + ] { + let tmp = installer_fixture(); + let justfile_path = tmp.path().join("Justfile"); + let mut justfile = fs::read_to_string(&justfile_path).unwrap(); + justfile.push_str( + r#" +[script("pwsh", "-NoProfile")] +source-prereq: + if ($env:GITHUB_TOKEN -or $env:GH_TOKEN) { exit 92 } + Add-Content -LiteralPath $env:FAKE_CARGO_LOG -Value 'source-prereq' + exit [int]$env:FAKE_PREREQ_EXIT +"#, + ); + write(&justfile_path, &justfile); + let log = tmp.path().join("cargo.log"); + let output = run_just( + tmp.path(), + &["_install-tool", "cargo-fixture", "1.2.3", "ci", "source-prereq"], + &[ + ("FAKE_CARGO_LOG", log.as_os_str()), + ("FAKE_BINSTALL_EXIT", OsStr::new(binary_exit)), + ("FAKE_PREREQ_EXIT", OsStr::new(prerequisite_exit)), + ("FAKE_INSTALL_EXIT", OsStr::new(source_exit)), + ("GITHUB_TOKEN", OsStr::new("fixture-only")), + ("GH_TOKEN", OsStr::new("fixture-only")), + ], + ); + assert_eq!(output.status.success(), success, "{}", String::from_utf8_lossy(&output.stderr)); + let calls = fs::read_to_string(log).unwrap(); + assert!(calls.contains("binstall --no-confirm --locked --disable-strategies compile --no-discover-github-token")); + assert_eq!(calls.contains("source-prereq"), binary_exit != "0"); + assert_eq!(calls.contains("install --locked cargo-fixture --version =1.2.3"), source_runs); + if source_runs { + assert!(calls.find("source-prereq").unwrap() < calls.find("install --locked cargo-fixture").unwrap()); + } + } +} + +#[test] +fn installer_metadata_failure_does_not_start_a_build() { + assert!(tools_available(), "real Just and PowerShell are required for installer contracts"); + let tmp = installer_fixture(); + let log = tmp.path().join("cargo.log"); + let output = run_just( + tmp.path(), + &["_install-tool", "cargo-fixture", "1.2.3", "ci"], + &[("FAKE_CARGO_LOG", log.as_os_str()), ("FAKE_INSTALL_LIST_EXIT", OsStr::new("23"))], + ); + assert_failed(&output, "installer metadata failure"); + assert!(String::from_utf8_lossy(&output.stderr).contains("cargo install --list failed")); + assert_eq!(fs::read_to_string(log).unwrap().trim(), "install --list"); +} + +fn installer_fixture() -> TempDir { + let tmp = fixture( + &[("versions.just", VERSIONS), ("tools.just", TOOLS)], + &["anvil-toolchain-stable-install"], + ); + write(&tmp.path().join("rust-toolchain.toml"), "[toolchain]\nchannel = \"1.97\"\n"); + tmp +} + +#[test] +fn missing_binary_installer_bootstraps_at_a_pin_without_tokens() { + assert!(tools_available(), "real Just and PowerShell are required for installer contracts"); + let tmp = installer_fixture(); + let paths = std::env::split_paths(&std::env::var_os("PATH").unwrap()).collect::>(); + let just_name = if cfg!(windows) { "just.exe" } else { "just" }; + let just_path = paths.iter().map(|path| path.join(just_name)).find(|path| path.is_file()).unwrap(); + let template = just_command(tmp.path(), &["_install-tool", "cargo-fixture", "1.2.3", "binstall"], &[]); + let mut command = Command::new(just_path); + command.args(template.get_args()).current_dir(tmp.path()); + for (key, value) in template.get_envs() { + if let Some(value) = value { + command.env(key, value); + } else { + command.env_remove(key); + } + } + let mut isolated_paths = vec![tmp.path().join("fake-bin")]; + isolated_paths.extend(paths.into_iter().filter(|path| { + !["cargo-binstall", "cargo-binstall.exe", "cargo-binstall.ps1", "cargo-binstall.cmd"] + .iter() + .any(|name| path.join(name).exists()) + })); + let log = tmp.path().join("credentials.log"); + let output = command + .env("PATH", std::env::join_paths(isolated_paths).unwrap()) + .env("FAKE_INSTALL_CREDENTIALS_LOG", &log) + .env("GITHUB_TOKEN", "fixture-only") + .env("GH_TOKEN", "fixture-only") + .env("CARGO_REGISTRIES_FIXTURE_TOKEN", "fixture-only") + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let calls = fs::read_to_string(log).unwrap(); + let bootstrap = calls.find("install --locked cargo-binstall --version =1.21.0|").unwrap(); + let download = calls + .find("binstall --no-confirm --locked --disable-strategies compile --no-discover-github-token") + .unwrap(); + assert!(bootstrap < download); + assert!(calls.lines().all(|line| line.ends_with('|')), "{calls}"); +} + +#[test] +fn github_just_bootstrap_is_pinned_source_only_and_anonymous() { + assert!(tools_available(), "real Just and PowerShell are required for installer contracts"); + let body = SETUP + .split_once(" - name: Install just\n") + .unwrap() + .1 + .split_once(" run: |\n") + .unwrap() + .1 + .split("\n #") + .next() + .unwrap(); + let mut recipe = String::from("[script(\"pwsh\", \"-NoProfile\")]\nbootstrap:\n"); + for line in body.lines() { + if let Some(code) = line.strip_prefix(" ") { + writeln!(recipe, " {code}").unwrap(); + } + } + for (installed, exit, success, should_install) in [ + ("1.46.0", "23", true, false), + ("1.45.0", "0", true, true), + ("1.45.0", "23", false, true), + ] { + let tmp = fixture(&[("bootstrap.just", &recipe)], &[]); + let log = tmp.path().join("credentials.log"); + write( + &tmp.path().join("fake-bin/just.ps1"), + r#" +$version = if (Test-Path "$env:FAKE_INSTALL_CREDENTIALS_LOG") { '1.46.0' } else { $env:FAKE_JUST_VERSION } +Write-Output "just $version" +"#, + ); + let output = run_just( + tmp.path(), + &["bootstrap"], + &[ + ("FAKE_INSTALL_CREDENTIALS_LOG", log.as_os_str()), + ("FAKE_JUST_VERSION", OsStr::new(installed)), + ("FAKE_INSTALL_EXIT", OsStr::new(exit)), + ("GITHUB_TOKEN", OsStr::new("fixture-only")), + ("GH_TOKEN", OsStr::new("fixture-only")), + ("CARGO_REGISTRIES_FIXTURE_TOKEN", OsStr::new("fixture-only")), + ], + ); + assert_eq!(output.status.success(), success, "{}", String::from_utf8_lossy(&output.stderr)); + assert_eq!(log.exists(), should_install); + if should_install { + assert_eq!(fs::read_to_string(log).unwrap().trim(), "install --locked --version =1.46.0 just|"); + } + } +} + #[test] fn public_api_checks_fail_when_metadata_discovery_fails() { if !tools_available() { diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index db2a3abec..58438b5d6 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -7349,9 +7349,10 @@ anvil-toolchain-stable-install: (_anvil-resolve-stable "install") # or no-op if it is already installed at or above that version. The # `installer` parameter selects between: # - "install" (cargo install --locked, pure-source). Default. -# - "binstall" (cargo binstall --no-confirm --locked, with a controlled -# cargo install fallback for tools that declare source -# prerequisites). Bootstraps cargo-binstall itself if not on PATH. +# - "binstall" (anonymous binary download, with a checked source fallback). +# - "ci" (source install, except tools with native source prerequisites, +# which try anonymous binaries first). Avoids release discovery for +# ordinary Rust tools even when prebuilt binaries exist. # `source_prereq`, when set, runs immediately before a source installation, # including a binstall fallback. [script("pwsh", "-NoProfile")] @@ -7362,11 +7363,23 @@ _install-tool-core name version installer source_prereq="": $installer = '{{installer}}' $sourcePrereq = '{{source_prereq}}' - if ($installer -ne 'install' -and $installer -ne 'binstall') { - Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install' or 'binstall')" + if ($installer -notin @('install', 'binstall', 'ci')) { + Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install', 'binstall', or 'ci')" exit 2 } + # This recipe runs in its own process: API checks in the parent retain + # their credentials, but installers and third-party build scripts do not. + foreach ($key in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN')) { + Remove-Item "Env:$key" -ErrorAction SilentlyContinue + } + foreach ($entry in @(Get-ChildItem Env: | Where-Object Name -Match '^CARGO_REGISTRIES_.*_TOKEN$')) { + Remove-Item "Env:$($entry.Name)" + } + if ($installer -eq 'ci') { + $installer = if ($sourcePrereq) { 'binstall' } else { 'install' } + } + # Already at or above the pin: skip. We don't downgrade tools the # user upgraded for their own reasons; the validate side uses # `installed >= pin`, so newer is fine. The early-exit is also what @@ -7375,17 +7388,22 @@ _install-tool-core name version installer source_prereq="": # cached binaries and fail with "binary already exists in destination". $installed = $null $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' - foreach ($line in (& cargo {{_anvil_stable_toolchain_args}} install --list 2>$null)) { + $listOutput = @(& cargo {{_anvil_stable_toolchain_args}} install --list) + if ($LASTEXITCODE -ne 0) { + throw "_install-tool-core: cargo install --list failed for '$name'" + } + foreach ($line in $listOutput) { if ($line -match $pattern) { $installed = $Matches[1]; break } } if ($installed) { - try { - if (([version]$installed) -ge ([version]$version)) { - Write-Host "$name >= $version (already satisfied; installed=$installed)" - exit 0 - } - } catch { - # Fall through to reinstall when versions don't parse as [version]. + $installedVersion = $null + $pinVersion = $null + if ([version]::TryParse($installed, [ref]$installedVersion) -and + [version]::TryParse($version, [ref]$pinVersion) -and + $installedVersion -ge $pinVersion -and + (Get-Command $name -ErrorAction SilentlyContinue)) { + Write-Host "$name >= $version (already satisfied; installed=$installed)" + exit 0 } } @@ -7393,20 +7411,17 @@ _install-tool-core name version installer source_prereq="": if ($installer -eq 'binstall') { if (-not (Get-Command cargo-binstall -ErrorAction SilentlyContinue)) { Write-Host ' Bootstrapping cargo-binstall' - & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall + & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall --version '=1.21.0' if ($LASTEXITCODE -ne 0) { Write-Error 'cargo-binstall bootstrap failed' exit $LASTEXITCODE } } - # Keep source builds behind Anvil's prerequisite check instead of - # allowing binstall to compile before that check can run. Tools with - # no source prerequisite may still use binstall's compile strategy. - $binstallArgs = @('binstall', '--no-confirm', '--locked') - if ($sourcePrereq) { - $binstallArgs += @('--disable-strategies', 'compile') - } + # No credential discovery from gh/git configuration, and no implicit + # compilation before the source-prerequisite check below. + $binstallArgs = @('binstall', '--no-confirm', '--locked', '--disable-strategies', 'compile', '--no-discover-github-token') $binstallArgs += @($name, '--version', "=$version") + if ($installed) { $binstallArgs += '--force' } & cargo {{_anvil_stable_toolchain_args}} @binstallArgs if ($LASTEXITCODE -eq 0) { exit 0 } Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow @@ -7415,7 +7430,10 @@ _install-tool-core name version installer source_prereq="": & "{{just_executable()}}" $sourcePrereq if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } - & cargo {{_anvil_stable_toolchain_args}} install --locked $name --version "=$version" + $installArgs = @('install', '--locked', $name, '--version', "=$version") + # Repair stale install metadata as well as old versions. + if ($installed) { $installArgs += '--force' } + & cargo {{_anvil_stable_toolchain_args}} @installArgs if ($LASTEXITCODE -ne 0) { Write-Error "$name install FAILED" exit $LASTEXITCODE diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index 41071d4bc..d8ab45424 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -262,17 +262,12 @@ description: | runs: using: composite steps: - # anvil-setup with group=none bootstraps the rust toolchain + - # just + binstall + cache, but skips the full catalog install. - # We follow it with just the cargo-delta install (the only tool - # this composite needs). This keeps the impact stage lean -- it's + # Install only cargo-delta inside setup, after restore and before save. + # This keeps the impact stage lean -- it's # the critical-path gating dep for every PR-tier group job. - uses: ./.github/actions/anvil-setup with: - group: none - - name: Install cargo-delta - shell: bash - run: just anvil-tool-cargo-delta-install binstall + group: impact - name: Compute impact shell: bash # Run the shared anvil-impact recipe -- the same impact building block @@ -547,8 +542,8 @@ inputs: `just anvil-setup` -- use for local "give me everything" flows. - "none": skip tool installation entirely; just restore the - Cargo cache and bootstrap just + binstall. Used by - anvil-impact, which installs only cargo-delta afterwards. + Cargo cache and bootstrap just. + - "impact": install only cargo-delta, before saving the cache. - a name containing only lowercase letters, digits, and hyphens: install only that group's prerequisites via `just anvil--setup`. @@ -622,9 +617,9 @@ runs: # one key. The prefix shares a completed cache across sibling jobs. # There is deliberately no fallback across toolchain/catalog changes: # those inputs are the cache-pruning boundary. - key: anvil-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}-${{ github.job }} + key: anvil-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just', 'justfiles/anvil/tools.just', '.github/actions/anvil-setup/action.yml') }}-${{ github.job }} restore-keys: | - anvil-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}- + anvil-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just', 'justfiles/anvil/tools.just', '.github/actions/anvil-setup/action.yml') }}- # `.crates.toml` and `.crates2.json` track which cargo-installed # tools and versions live in ~/.cargo/bin/. Without them in the # cache, `cargo install --list` and (downstream) the @@ -640,13 +635,15 @@ runs: ~/.cargo/.crates.toml ~/.cargo/.crates2.json - # cargo-binstall keeps the cold bootstrap path fast. - - name: Install cargo-binstall - uses: cargo-bins/cargo-binstall@v1.21.0 # pinned by tag: this release is an immutable release (GitHub locks the tag to a commit) - - name: Install just shell: pwsh -NoProfile -Command ". '{0}'" run: | + foreach ($key in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN')) { + Remove-Item "Env:$key" -ErrorAction SilentlyContinue + } + foreach ($entry in @(Get-ChildItem Env: | Where-Object Name -Match '^CARGO_REGISTRIES_.*_TOKEN$')) { + Remove-Item "Env:$($entry.Name)" + } $minimum = [version]'1.46.0' $installed = if (Get-Command just -ErrorAction SilentlyContinue) { [version]((& just --version) -replace '^just\s+', '') @@ -655,11 +652,8 @@ runs: } if ($null -eq $installed -or $installed -lt $minimum) { Write-Host "Installing just >= $minimum (installed: $(if ($installed) { $installed } else { 'not found' }))" - & cargo binstall --no-confirm --locked --version ">=$minimum" just - if ($LASTEXITCODE -ne 0) { - & cargo install --locked --version ">=$minimum" just - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } + & cargo install --locked --version "=$minimum" just + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } $installed = [version]((& just --version) -replace '^just\s+', '') if ($installed -lt $minimum) { @@ -680,17 +674,12 @@ runs: env: ANVIL_GROUP: ${{ inputs.group }} # When `group` is empty (the default), installs the full catalog - # via `just anvil-setup binstall`. When `group` is "none", - # skips tool installation entirely (used by anvil-impact, which - # only needs cargo-delta and installs it itself afterwards). When + # via `just anvil-setup ci`. When `group` is "none", + # skips tool installation entirely. When it is "impact", installs + # cargo-delta here so the cache save includes it. When # `group` is anything else, installs only what that group needs - # via `just anvil--setup binstall`. - # - # binstall path downloads prebuilt tool binaries from each tool's - # GitHub Releases when available (~1 min cold, vs ~30 min for - # source builds). cargo-binstall has unresolved compliance issues - # for ADO pipelines, so the ADO backend uses the default `install` - # path; GH uses `binstall`. + # via `just anvil--setup ci`. Ordinary Rust tools compile + # without release discovery; native tools prefer anonymous binaries. run: | if [[ -n "$ANVIL_GROUP" && "$ANVIL_GROUP" != "none" && ! "$ANVIL_GROUP" =~ ^[a-z0-9-]+$ ]]; then echo "::error::Invalid Anvil group; expected lowercase letters, digits, and hyphens." @@ -700,27 +689,16 @@ runs: case "$ANVIL_GROUP" in none) echo "anvil-setup: group=none, skipping tool install" ;; - "") just anvil-setup binstall ;; - *) just "anvil-$ANVIL_GROUP-setup" binstall ;; + impact) just anvil-tool-cargo-delta-install ci ;; + "") just anvil-setup ci ;; + *) just "anvil-$ANVIL_GROUP-setup" ci ;; esac - # Save the cache as the LAST step of setup, regardless of whether - # any earlier install step partially failed. `actions/cache` - # used to support this via `save-always: true`, but that knob is - # deprecated as of 2025 with the explicit message "does not work - # as intended" — failing runs simply don't save. The supported - # replacement is to call `actions/cache/save` directly as its - # own step with `if: always()`. - # - # Without this, a single catalog issue that fails the install - # step locks the cache empty forever (chicken-and-egg: failed - # run -> no save -> next run cold-starts -> still fails -> still - # no save). Tool binaries installed by anvil-tools-install are - # immutable once on disk, so partial state is strictly better - # than nothing — and subsequent runs accumulate into the cache - # until the catalog is complete. + # Preserve GitHub's ref-scoped cache trust boundary: PR writes are confined + # to their merge ref, never the default branch. Save only complete setup; + # failures must not seal an incomplete immutable key. - name: Save cargo cache - if: always() && steps.cargo-cache.outputs.cache-hit != 'true' + if: success() && steps.cargo-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.cargo-cache.outputs.cache-primary-key }} @@ -7491,9 +7469,10 @@ anvil-toolchain-stable-install: (_anvil-resolve-stable "install") # or no-op if it is already installed at or above that version. The # `installer` parameter selects between: # - "install" (cargo install --locked, pure-source). Default. -# - "binstall" (cargo binstall --no-confirm --locked, with a controlled -# cargo install fallback for tools that declare source -# prerequisites). Bootstraps cargo-binstall itself if not on PATH. +# - "binstall" (anonymous binary download, with a checked source fallback). +# - "ci" (source install, except tools with native source prerequisites, +# which try anonymous binaries first). Avoids release discovery for +# ordinary Rust tools even when prebuilt binaries exist. # `source_prereq`, when set, runs immediately before a source installation, # including a binstall fallback. [script("pwsh", "-NoProfile")] @@ -7504,11 +7483,23 @@ _install-tool-core name version installer source_prereq="": $installer = '{{installer}}' $sourcePrereq = '{{source_prereq}}' - if ($installer -ne 'install' -and $installer -ne 'binstall') { - Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install' or 'binstall')" + if ($installer -notin @('install', 'binstall', 'ci')) { + Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install', 'binstall', or 'ci')" exit 2 } + # This recipe runs in its own process: API checks in the parent retain + # their credentials, but installers and third-party build scripts do not. + foreach ($key in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN')) { + Remove-Item "Env:$key" -ErrorAction SilentlyContinue + } + foreach ($entry in @(Get-ChildItem Env: | Where-Object Name -Match '^CARGO_REGISTRIES_.*_TOKEN$')) { + Remove-Item "Env:$($entry.Name)" + } + if ($installer -eq 'ci') { + $installer = if ($sourcePrereq) { 'binstall' } else { 'install' } + } + # Already at or above the pin: skip. We don't downgrade tools the # user upgraded for their own reasons; the validate side uses # `installed >= pin`, so newer is fine. The early-exit is also what @@ -7517,17 +7508,22 @@ _install-tool-core name version installer source_prereq="": # cached binaries and fail with "binary already exists in destination". $installed = $null $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' - foreach ($line in (& cargo {{_anvil_stable_toolchain_args}} install --list 2>$null)) { + $listOutput = @(& cargo {{_anvil_stable_toolchain_args}} install --list) + if ($LASTEXITCODE -ne 0) { + throw "_install-tool-core: cargo install --list failed for '$name'" + } + foreach ($line in $listOutput) { if ($line -match $pattern) { $installed = $Matches[1]; break } } if ($installed) { - try { - if (([version]$installed) -ge ([version]$version)) { - Write-Host "$name >= $version (already satisfied; installed=$installed)" - exit 0 - } - } catch { - # Fall through to reinstall when versions don't parse as [version]. + $installedVersion = $null + $pinVersion = $null + if ([version]::TryParse($installed, [ref]$installedVersion) -and + [version]::TryParse($version, [ref]$pinVersion) -and + $installedVersion -ge $pinVersion -and + (Get-Command $name -ErrorAction SilentlyContinue)) { + Write-Host "$name >= $version (already satisfied; installed=$installed)" + exit 0 } } @@ -7535,20 +7531,17 @@ _install-tool-core name version installer source_prereq="": if ($installer -eq 'binstall') { if (-not (Get-Command cargo-binstall -ErrorAction SilentlyContinue)) { Write-Host ' Bootstrapping cargo-binstall' - & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall + & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall --version '=1.21.0' if ($LASTEXITCODE -ne 0) { Write-Error 'cargo-binstall bootstrap failed' exit $LASTEXITCODE } } - # Keep source builds behind Anvil's prerequisite check instead of - # allowing binstall to compile before that check can run. Tools with - # no source prerequisite may still use binstall's compile strategy. - $binstallArgs = @('binstall', '--no-confirm', '--locked') - if ($sourcePrereq) { - $binstallArgs += @('--disable-strategies', 'compile') - } + # No credential discovery from gh/git configuration, and no implicit + # compilation before the source-prerequisite check below. + $binstallArgs = @('binstall', '--no-confirm', '--locked', '--disable-strategies', 'compile', '--no-discover-github-token') $binstallArgs += @($name, '--version', "=$version") + if ($installed) { $binstallArgs += '--force' } & cargo {{_anvil_stable_toolchain_args}} @binstallArgs if ($LASTEXITCODE -eq 0) { exit 0 } Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow @@ -7557,7 +7550,10 @@ _install-tool-core name version installer source_prereq="": & "{{just_executable()}}" $sourcePrereq if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } - & cargo {{_anvil_stable_toolchain_args}} install --locked $name --version "=$version" + $installArgs = @('install', '--locked', $name, '--version', "=$version") + # Repair stale install metadata as well as old versions. + if ($installed) { $installArgs += '--force' } + & cargo {{_anvil_stable_toolchain_args}} @installArgs if ($LASTEXITCODE -ne 0) { Write-Error "$name install FAILED" exit $LASTEXITCODE diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index e19faf5b9..561c3dd5e 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -6175,9 +6175,10 @@ anvil-toolchain-stable-install: (_anvil-resolve-stable "install") # or no-op if it is already installed at or above that version. The # `installer` parameter selects between: # - "install" (cargo install --locked, pure-source). Default. -# - "binstall" (cargo binstall --no-confirm --locked, with a controlled -# cargo install fallback for tools that declare source -# prerequisites). Bootstraps cargo-binstall itself if not on PATH. +# - "binstall" (anonymous binary download, with a checked source fallback). +# - "ci" (source install, except tools with native source prerequisites, +# which try anonymous binaries first). Avoids release discovery for +# ordinary Rust tools even when prebuilt binaries exist. # `source_prereq`, when set, runs immediately before a source installation, # including a binstall fallback. [script("pwsh", "-NoProfile")] @@ -6188,11 +6189,23 @@ _install-tool-core name version installer source_prereq="": $installer = '{{installer}}' $sourcePrereq = '{{source_prereq}}' - if ($installer -ne 'install' -and $installer -ne 'binstall') { - Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install' or 'binstall')" + if ($installer -notin @('install', 'binstall', 'ci')) { + Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install', 'binstall', or 'ci')" exit 2 } + # This recipe runs in its own process: API checks in the parent retain + # their credentials, but installers and third-party build scripts do not. + foreach ($key in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN')) { + Remove-Item "Env:$key" -ErrorAction SilentlyContinue + } + foreach ($entry in @(Get-ChildItem Env: | Where-Object Name -Match '^CARGO_REGISTRIES_.*_TOKEN$')) { + Remove-Item "Env:$($entry.Name)" + } + if ($installer -eq 'ci') { + $installer = if ($sourcePrereq) { 'binstall' } else { 'install' } + } + # Already at or above the pin: skip. We don't downgrade tools the # user upgraded for their own reasons; the validate side uses # `installed >= pin`, so newer is fine. The early-exit is also what @@ -6201,17 +6214,22 @@ _install-tool-core name version installer source_prereq="": # cached binaries and fail with "binary already exists in destination". $installed = $null $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' - foreach ($line in (& cargo {{_anvil_stable_toolchain_args}} install --list 2>$null)) { + $listOutput = @(& cargo {{_anvil_stable_toolchain_args}} install --list) + if ($LASTEXITCODE -ne 0) { + throw "_install-tool-core: cargo install --list failed for '$name'" + } + foreach ($line in $listOutput) { if ($line -match $pattern) { $installed = $Matches[1]; break } } if ($installed) { - try { - if (([version]$installed) -ge ([version]$version)) { - Write-Host "$name >= $version (already satisfied; installed=$installed)" - exit 0 - } - } catch { - # Fall through to reinstall when versions don't parse as [version]. + $installedVersion = $null + $pinVersion = $null + if ([version]::TryParse($installed, [ref]$installedVersion) -and + [version]::TryParse($version, [ref]$pinVersion) -and + $installedVersion -ge $pinVersion -and + (Get-Command $name -ErrorAction SilentlyContinue)) { + Write-Host "$name >= $version (already satisfied; installed=$installed)" + exit 0 } } @@ -6219,20 +6237,17 @@ _install-tool-core name version installer source_prereq="": if ($installer -eq 'binstall') { if (-not (Get-Command cargo-binstall -ErrorAction SilentlyContinue)) { Write-Host ' Bootstrapping cargo-binstall' - & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall + & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall --version '=1.21.0' if ($LASTEXITCODE -ne 0) { Write-Error 'cargo-binstall bootstrap failed' exit $LASTEXITCODE } } - # Keep source builds behind Anvil's prerequisite check instead of - # allowing binstall to compile before that check can run. Tools with - # no source prerequisite may still use binstall's compile strategy. - $binstallArgs = @('binstall', '--no-confirm', '--locked') - if ($sourcePrereq) { - $binstallArgs += @('--disable-strategies', 'compile') - } + # No credential discovery from gh/git configuration, and no implicit + # compilation before the source-prerequisite check below. + $binstallArgs = @('binstall', '--no-confirm', '--locked', '--disable-strategies', 'compile', '--no-discover-github-token') $binstallArgs += @($name, '--version', "=$version") + if ($installed) { $binstallArgs += '--force' } & cargo {{_anvil_stable_toolchain_args}} @binstallArgs if ($LASTEXITCODE -eq 0) { exit 0 } Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow @@ -6241,7 +6256,10 @@ _install-tool-core name version installer source_prereq="": & "{{just_executable()}}" $sourcePrereq if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } - & cargo {{_anvil_stable_toolchain_args}} install --locked $name --version "=$version" + $installArgs = @('install', '--locked', $name, '--version', "=$version") + # Repair stale install metadata as well as old versions. + if ($installed) { $installArgs += '--force' } + & cargo {{_anvil_stable_toolchain_args}} @installArgs if ($LASTEXITCODE -ne 0) { Write-Error "$name install FAILED" exit $LASTEXITCODE diff --git a/justfiles/anvil/tools.just b/justfiles/anvil/tools.just index ec0008055..85b441024 100644 --- a/justfiles/anvil/tools.just +++ b/justfiles/anvil/tools.just @@ -411,9 +411,10 @@ anvil-toolchain-stable-install: (_anvil-resolve-stable "install") # or no-op if it is already installed at or above that version. The # `installer` parameter selects between: # - "install" (cargo install --locked, pure-source). Default. -# - "binstall" (cargo binstall --no-confirm --locked, with a controlled -# cargo install fallback for tools that declare source -# prerequisites). Bootstraps cargo-binstall itself if not on PATH. +# - "binstall" (anonymous binary download, with a checked source fallback). +# - "ci" (source install, except tools with native source prerequisites, +# which try anonymous binaries first). Avoids release discovery for +# ordinary Rust tools even when prebuilt binaries exist. # `source_prereq`, when set, runs immediately before a source installation, # including a binstall fallback. [script("pwsh", "-NoProfile")] @@ -424,11 +425,23 @@ _install-tool-core name version installer source_prereq="": $installer = '{{installer}}' $sourcePrereq = '{{source_prereq}}' - if ($installer -ne 'install' -and $installer -ne 'binstall') { - Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install' or 'binstall')" + if ($installer -notin @('install', 'binstall', 'ci')) { + Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install', 'binstall', or 'ci')" exit 2 } + # This recipe runs in its own process: API checks in the parent retain + # their credentials, but installers and third-party build scripts do not. + foreach ($key in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN')) { + Remove-Item "Env:$key" -ErrorAction SilentlyContinue + } + foreach ($entry in @(Get-ChildItem Env: | Where-Object Name -Match '^CARGO_REGISTRIES_.*_TOKEN$')) { + Remove-Item "Env:$($entry.Name)" + } + if ($installer -eq 'ci') { + $installer = if ($sourcePrereq) { 'binstall' } else { 'install' } + } + # Already at or above the pin: skip. We don't downgrade tools the # user upgraded for their own reasons; the validate side uses # `installed >= pin`, so newer is fine. The early-exit is also what @@ -437,17 +450,22 @@ _install-tool-core name version installer source_prereq="": # cached binaries and fail with "binary already exists in destination". $installed = $null $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' - foreach ($line in (& cargo {{_anvil_stable_toolchain_args}} install --list 2>$null)) { + $listOutput = @(& cargo {{_anvil_stable_toolchain_args}} install --list) + if ($LASTEXITCODE -ne 0) { + throw "_install-tool-core: cargo install --list failed for '$name'" + } + foreach ($line in $listOutput) { if ($line -match $pattern) { $installed = $Matches[1]; break } } if ($installed) { - try { - if (([version]$installed) -ge ([version]$version)) { - Write-Host "$name >= $version (already satisfied; installed=$installed)" - exit 0 - } - } catch { - # Fall through to reinstall when versions don't parse as [version]. + $installedVersion = $null + $pinVersion = $null + if ([version]::TryParse($installed, [ref]$installedVersion) -and + [version]::TryParse($version, [ref]$pinVersion) -and + $installedVersion -ge $pinVersion -and + (Get-Command $name -ErrorAction SilentlyContinue)) { + Write-Host "$name >= $version (already satisfied; installed=$installed)" + exit 0 } } @@ -455,20 +473,17 @@ _install-tool-core name version installer source_prereq="": if ($installer -eq 'binstall') { if (-not (Get-Command cargo-binstall -ErrorAction SilentlyContinue)) { Write-Host ' Bootstrapping cargo-binstall' - & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall + & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall --version '=1.21.0' if ($LASTEXITCODE -ne 0) { Write-Error 'cargo-binstall bootstrap failed' exit $LASTEXITCODE } } - # Keep source builds behind Anvil's prerequisite check instead of - # allowing binstall to compile before that check can run. Tools with - # no source prerequisite may still use binstall's compile strategy. - $binstallArgs = @('binstall', '--no-confirm', '--locked') - if ($sourcePrereq) { - $binstallArgs += @('--disable-strategies', 'compile') - } + # No credential discovery from gh/git configuration, and no implicit + # compilation before the source-prerequisite check below. + $binstallArgs = @('binstall', '--no-confirm', '--locked', '--disable-strategies', 'compile', '--no-discover-github-token') $binstallArgs += @($name, '--version', "=$version") + if ($installed) { $binstallArgs += '--force' } & cargo {{_anvil_stable_toolchain_args}} @binstallArgs if ($LASTEXITCODE -eq 0) { exit 0 } Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow @@ -477,7 +492,10 @@ _install-tool-core name version installer source_prereq="": & "{{just_executable()}}" $sourcePrereq if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } - & cargo {{_anvil_stable_toolchain_args}} install --locked $name --version "=$version" + $installArgs = @('install', '--locked', $name, '--version', "=$version") + # Repair stale install metadata as well as old versions. + if ($installed) { $installArgs += '--force' } + & cargo {{_anvil_stable_toolchain_args}} @installArgs if ($LASTEXITCODE -ne 0) { Write-Error "$name install FAILED" exit $LASTEXITCODE From 480afedc68a231990423d37f0178f1586a9bd6f2 Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:14:24 +0100 Subject: [PATCH 2/3] fix(anvil): narrow installation changes to impact caching and discovery Remove broad credential cleanup, source-first mode, cache invalidation and save-policy changes, and stale-binary repair. Keep the targeted cargo-delta source install inside impact setup, disable implicit binstall credential discovery, and use pinned binary bootstrap downloads. Make installer fixtures independent of host cargo-binstall availability and retain focused cold, warm, binary-hit and source-fallback coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .anvil.lock | 6 +- .github/actions/anvil-setup/action.yml | 69 ++-- crates/cargo-anvil/docs/design/README.md | 6 +- crates/cargo-anvil/docs/design/github.md | 68 +--- crates/cargo-anvil/docs/design/local.md | 43 +-- .../cargo-anvil/src/anvil/artifacts/github.rs | 107 ++---- .../src/anvil/artifacts/justfile.rs | 8 +- .../templates/github/setup-action.yml | 69 ++-- .../templates/justfiles/anvil/tools.just | 63 ++-- crates/cargo-anvil/tests/recipe_contracts.rs | 350 +++--------------- .../snapshots/snapshots__ado_backend.snap | 63 ++-- .../snapshots/snapshots__github_backend.snap | 132 +++---- .../snapshots/snapshots__local_only.snap | 63 ++-- justfiles/anvil/tools.just | 63 ++-- 14 files changed, 389 insertions(+), 721 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index 2b8cc5d27..156937fd6 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,7 +1,7 @@ version = 1 tool = "anvil" tool_version = "0.9.0" -catalog_checksum = "sha256:055a098e00f5a16a10818774fbb1cdc704ae6f93f2accb174cd82fb6e40c9513" +catalog_checksum = "sha256:96c5667686180202232be81717f977dbcd8c77805f21bbb022876c73e907944f" [[file]] path = ".anvil/container/Dockerfile.dockerignore" @@ -21,7 +21,7 @@ checksum = "sha256:d564a0ce424cda58c8f5b2476cf0c8b36e759afbdf8227608bd1f3fc74913 [[file]] path = ".github/actions/anvil-setup/action.yml" -checksum = "sha256:e29ccdb511ac5232ca12e33d26f11c4c985a3d666fdcce96b25353f61cd29f51" +checksum = "sha256:26ab9d71d809c0fb80e01975de9ba4d7e00c8d289063578c190b7a411c3f7027" [[file]] path = ".github/actions/anvil-setup/just-problem-matcher.json" @@ -245,7 +245,7 @@ checksum = "sha256:00453a12cbb34811ee6a2c083dade5f6198575e3b0610f49e4743366326cd [[file]] path = "justfiles/anvil/tools.just" -checksum = "sha256:16f45dfd4549728d445235f28daa8db3dae0a3033c617bc9e7b6e56c39344a4a" +checksum = "sha256:2f2f45903254f40a6510ae8163003c495e71f54e705808b7a6ac9111f82564b1" [[file]] path = "justfiles/anvil/versions.just" diff --git a/.github/actions/anvil-setup/action.yml b/.github/actions/anvil-setup/action.yml index d10a379ee..fc926b567 100644 --- a/.github/actions/anvil-setup/action.yml +++ b/.github/actions/anvil-setup/action.yml @@ -14,8 +14,8 @@ inputs: `just anvil-setup` -- use for local "give me everything" flows. - "none": skip tool installation entirely; just restore the - Cargo cache and bootstrap just. - - "impact": install only cargo-delta, before saving the cache. + Cargo cache and bootstrap just + binstall. + - "impact": install only cargo-delta from source, before saving the cache. - a name containing only lowercase letters, digits, and hyphens: install only that group's prerequisites via `just anvil--setup`. @@ -89,9 +89,9 @@ runs: # one key. The prefix shares a completed cache across sibling jobs. # There is deliberately no fallback across toolchain/catalog changes: # those inputs are the cache-pruning boundary. - key: anvil-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just', 'justfiles/anvil/tools.just', '.github/actions/anvil-setup/action.yml') }}-${{ github.job }} + key: anvil-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}-${{ github.job }} restore-keys: | - anvil-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just', 'justfiles/anvil/tools.just', '.github/actions/anvil-setup/action.yml') }}- + anvil-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}- # `.crates.toml` and `.crates2.json` track which cargo-installed # tools and versions live in ~/.cargo/bin/. Without them in the # cache, `cargo install --list` and (downstream) the @@ -107,15 +107,15 @@ runs: ~/.cargo/.crates.toml ~/.cargo/.crates2.json + # cargo-binstall keeps the cold bootstrap path fast. + - name: Install cargo-binstall + uses: cargo-bins/cargo-binstall@v1.21.0 # pinned by tag: this release is an immutable release (GitHub locks the tag to a commit) + with: + version: "1.21.0" + - name: Install just shell: pwsh -NoProfile -Command ". '{0}'" run: | - foreach ($key in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN')) { - Remove-Item "Env:$key" -ErrorAction SilentlyContinue - } - foreach ($entry in @(Get-ChildItem Env: | Where-Object Name -Match '^CARGO_REGISTRIES_.*_TOKEN$')) { - Remove-Item "Env:$($entry.Name)" - } $minimum = [version]'1.46.0' $installed = if (Get-Command just -ErrorAction SilentlyContinue) { [version]((& just --version) -replace '^just\s+', '') @@ -124,8 +124,11 @@ runs: } if ($null -eq $installed -or $installed -lt $minimum) { Write-Host "Installing just >= $minimum (installed: $(if ($installed) { $installed } else { 'not found' }))" - & cargo install --locked --version "=$minimum" just - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & cargo binstall --no-confirm --locked --no-discover-github-token --version "=$minimum" just + if ($LASTEXITCODE -ne 0) { + & cargo install --locked --version "=$minimum" just + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } } $installed = [version]((& just --version) -replace '^just\s+', '') if ($installed -lt $minimum) { @@ -146,12 +149,18 @@ runs: env: ANVIL_GROUP: ${{ inputs.group }} # When `group` is empty (the default), installs the full catalog - # via `just anvil-setup ci`. When `group` is "none", - # skips tool installation entirely. When it is "impact", installs - # cargo-delta here so the cache save includes it. When + # via `just anvil-setup binstall`. When `group` is "none", + # skips tool installation entirely. The "impact" group source-installs + # cargo-delta before the cache save, avoiding release discovery on + # the impact stage's critical path. When # `group` is anything else, installs only what that group needs - # via `just anvil--setup ci`. Ordinary Rust tools compile - # without release discovery; native tools prefer anonymous binaries. + # via `just anvil--setup binstall`. + # + # binstall path downloads prebuilt tool binaries from each tool's + # GitHub Releases when available (~1 min cold, vs ~30 min for + # source builds). cargo-binstall has unresolved compliance issues + # for ADO pipelines, so the ADO backend uses the default `install` + # path; GH uses `binstall`. run: | if [[ -n "$ANVIL_GROUP" && "$ANVIL_GROUP" != "none" && ! "$ANVIL_GROUP" =~ ^[a-z0-9-]+$ ]]; then echo "::error::Invalid Anvil group; expected lowercase letters, digits, and hyphens." @@ -161,16 +170,28 @@ runs: case "$ANVIL_GROUP" in none) echo "anvil-setup: group=none, skipping tool install" ;; - impact) just anvil-tool-cargo-delta-install ci ;; - "") just anvil-setup ci ;; - *) just "anvil-$ANVIL_GROUP-setup" ci ;; + impact) just anvil-tool-cargo-delta-install install ;; + "") just anvil-setup binstall ;; + *) just "anvil-$ANVIL_GROUP-setup" binstall ;; esac - # Preserve GitHub's ref-scoped cache trust boundary: PR writes are confined - # to their merge ref, never the default branch. Save only complete setup; - # failures must not seal an incomplete immutable key. + # Save the cache as the LAST step of setup, regardless of whether + # any earlier install step partially failed. `actions/cache` + # used to support this via `save-always: true`, but that knob is + # deprecated as of 2025 with the explicit message "does not work + # as intended" — failing runs simply don't save. The supported + # replacement is to call `actions/cache/save` directly as its + # own step with `if: always()`. + # + # Without this, a single catalog issue that fails the install + # step locks the cache empty forever (chicken-and-egg: failed + # run -> no save -> next run cold-starts -> still fails -> still + # no save). Tool binaries installed by anvil-tools-install are + # immutable once on disk, so partial state is strictly better + # than nothing — and subsequent runs accumulate into the cache + # until the catalog is complete. - name: Save cargo cache - if: success() && steps.cargo-cache.outputs.cache-hit != 'true' + if: always() && steps.cargo-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.cargo-cache.outputs.cache-primary-key }} diff --git a/crates/cargo-anvil/docs/design/README.md b/crates/cargo-anvil/docs/design/README.md index 1428a5536..4140f15ff 100644 --- a/crates/cargo-anvil/docs/design/README.md +++ b/crates/cargo-anvil/docs/design/README.md @@ -388,10 +388,8 @@ outstanding owned-file proposals and managed-region refusals. - Generated GH composite actions and ADO step templates do nothing privileged on their own; they just invoke `just` recipes. The user's workflow / pipeline file controls permissions and secrets. -- All cargo-tool installs use `--locked`. GitHub setup uses source-first installation; - native tools can use anonymous binary downloads with a checked source fallback. - Installers remove inherited GitHub and Cargo registry token environment variables. - API checks retain their separate token scope; this is not a build-script sandbox. +- All cargo-tool installs done by the setup building blocks use `--locked`. No + `cargo-binstall`. - The tool never sources or executes content from any user-edited file at runtime; everything executable in the repo is plain `just` recipes the user can read. - Recommended user-workflow shape: `permissions: contents: read` on PR workflows; grant diff --git a/crates/cargo-anvil/docs/design/github.md b/crates/cargo-anvil/docs/design/github.md index afbeae953..f540e0a63 100644 --- a/crates/cargo-anvil/docs/design/github.md +++ b/crates/cargo-anvil/docs/design/github.md @@ -772,17 +772,19 @@ result, and log link without check-suite attribution. ### `anvil-setup` `anvil-setup` is a composite action that restores Cargo home, bootstraps -Just, then invokes the requested catalog setup recipe, whose +cargo-binstall and Just, then invokes the requested catalog setup recipe, whose prerequisites provision the selected compiler and tools. Its `group` input controls which recipes run: -- empty (default): runs `just anvil-setup ci` -- the full catalog. Use +- empty (default): runs `just anvil-setup binstall` -- the full catalog. Use for local "give me everything" flows. -- `none`: restores the cache and bootstraps Just without installing catalog tools. -- `impact`: source-installs only cargo-delta, inside setup before the cache save. - Neither `none` nor `impact` needs cargo-binstall. +- `none`: skips the group/full tool fan-out. +- `impact`: runs `just anvil-tool-cargo-delta-install install` before saving the + existing Cargo cache. A cold miss builds the exact catalog pin with `--locked`; + a warm hit reuses the installed tool. This avoids release discovery for delta + in the impact stage, not because delta binaries are unavailable. - any other value (e.g. `pr-fast`, `scheduled-advisories`): runs - `just anvil--setup ci` -- only the tools, components, and + `just anvil--setup binstall` -- only the tools, components, and toolchains that group actually needs. Ordinary group names contain only lowercase letters, digits, and hyphens. `anvil-run-group` passes its group input here, so a `pr-fast` matrix leg never installs cargo-mutants. @@ -799,20 +801,12 @@ The action expects the rustup proxies on `PATH` and installs a missing selected toolchain (see §7). `anvil-impact` is described in §6 below. -Setup does not forward `github.token` to installers. Just installs from source at -exactly 1.46.0 on a cold runner. Catalog tools use exact-pin, locked source installs, -except cargo-spellcheck, whose native prerequisites justify trying anonymous binaries -first. A binary miss runs the existing libclang prerequisite check before compilation; -missing prerequisites remain a visible failure with platform-specific install hints. -Setup does not eagerly install system packages. -The installer source-builds pinned cargo-binstall 1.21.0 only if a binary path -needs it and it is absent. Warm tool hits do not bootstrap or invoke cargo-binstall. - -Install recipes strip inherited GitHub and Cargo registry token environment variables. -Binary installation disables credential discovery from GitHub CLI and Git configuration. -The separate run-group step still supplies `GITHUB_TOKEN` for API checks. Permissions -remain caller-owned. This is reduced installer exposure, not a token-free workflow or -a build-script sandbox. +Setup does not forward `github.token` to installers. Binstall calls disable token +discovery from GitHub CLI and Git configuration. Explicit caller credentials are +not removed. The later run-group step retains its existing API token scope. +Bootstrap downloads pin cargo-binstall to 1.21.0 and Just to 1.46.0; newer installed +Just versions remain accepted. Other groups retain binary-first installation and +the existing prerequisite-checked source fallback for spellcheck. Its optional `free-disk-space` input defaults to `false`. When enabled on a GitHub-hosted runner, it removes pre-installed toolchains that anvil's Rust checks do @@ -830,7 +824,7 @@ Other groups retain the action's disabled default. [local.md §4](./local.md#4-impact-scoping-via-the-anvil-impact-recipe)). It: 1. `./.github/actions/anvil-setup` with `group: impact` restores Cargo home, - bootstraps Just, and runs `just anvil-tool-cargo-delta-install ci` before saving. + bootstraps tools, and source-installs cargo-delta before the cache save. **This is the only job that runs cargo-delta to compute the impact set.** (Group setup jobs also install cargo-delta as a prerequisite, but in `consume` mode they never run it -- they read the downloaded impact cache.) @@ -906,7 +900,7 @@ toolchain file or replays options from a file suppressed by `RUSTUP_TOOLCHAIN`. Because file selection remains native, rustup applies its normal lookup from each Cargo or Rust command's working directory. -The setup action restores Cargo home, bootstraps Just, and +The setup action restores Cargo home, bootstraps cargo-binstall and Just, and then invokes the selected catalog setup recipe. Setup ensures the selected compiler is available before stable Cargo or Rust runs. GH-hosted runners provide the rustup proxy used to install a missing public MSRV or process a @@ -939,10 +933,7 @@ compiler. The `anvil-setup` composite action computes a cache key from runner OS and architecture plus hashes of `.cargo/config.toml`, either supported repository -toolchain file, `versions.just`, `tools.just`, and the setup action itself, followed -by the workflow job ID. The `anvil-v3` namespace separates source-first installations -from previous generations. Installer policy, bootstrap versions, catalog versions, -and source-install options (including any feature arguments) participate in the hash. +toolchain file, and `versions.just`, followed by the workflow job ID. Toolchain-file or catalog changes deliberately start a fresh cache generation to bound registry growth; there is no restore fallback across those boundaries. Routine `Cargo.toml`, `Cargo.lock`, and compiler-version changes do not @@ -950,24 +941,6 @@ invalidate standalone cached tools. Job discrimination prevents concurrent jobs from racing to save one key, while the fingerprint prefix shares prior installs across jobs within the same cache generation. -This is an installed-binary cache, not an exact compiler-artifact cache. An unchanged -toolchain channel resolving to a new patch release does not invalidate compatible -binaries. Environment-only compiler, target, or build-flag overrides do not enter the -key; runners that require distinct binaries must declare those inputs in the hashed -configuration, or customize the cache key. Default-feature source installs do not -share compilation artifacts between tools. - -Cache restore precedes Just and cargo-delta installation. Impact's delta installation -now precedes save, so a successful cache producer can retain that binary too. -Only successful setup saves executable caches. GitHub's existing cache ref scope -remains the trust boundary: PR caches belong to their merge ref, and cannot overwrite -or populate default-branch caches. PR reruns can reuse their own caches; sibling PRs -can restore default-branch caches, not each other's merge-ref caches. No -`pull_request_target` execution or permission increase is introduced. Failed setup -cannot seal a partial immutable key. New generations compile ordinary tools on -cold misses. Full-cache hits -query install metadata and executable presence without reinstalling tools. - The cache covers: - The `cargo install`-ed tools installed by the catalog setup recipes (`~/.cargo/bin/` @@ -975,9 +948,6 @@ The cache covers: registry (`~/.cargo/registry/`). The key includes `${{ github.job }}`, so a `pr-test` cache hit doesn't have to wait on a `pr-fast` cache miss. -Only registry archives/indexes, executables, and install ledgers are cached. Cargo -credential files, Git credential files, and GitHub CLI configuration are excluded. - The `target/` build directory is deliberately **not** cached. A per-job, per-OS, per-arch `target/` is large, and the many multi-GB entries would evict the high-value tool caches under the Actions 10 GB per-repo cache limit (LRU) — so caching it is a net loss here, @@ -1029,8 +999,8 @@ than inconsistent. Actions whose publisher has enabled GitHub [immutable releases][immutable] are pinned by tag. In the generated workflows that is, at the time of writing, -`codecov/codecov-action@v7.0.0` and `marocchino/sticky-pull-request-comment@v3.0.5`; -a repository's own hand-maintained workflows apply +`codecov/codecov-action@v7.0.0`, `marocchino/sticky-pull-request-comment@v3.0.5` and +`cargo-bins/cargo-binstall@v1.21.0`; a repository's own hand-maintained workflows apply the same rule to the actions they use, so the list a reader sees there may be longer. An immutable release locks its Git tag to one commit: the tag cannot be moved, and cannot be deleted while the release exists. The tag name cannot be reused even after diff --git a/crates/cargo-anvil/docs/design/local.md b/crates/cargo-anvil/docs/design/local.md index 109a48fca..916780002 100644 --- a/crates/cargo-anvil/docs/design/local.md +++ b/crates/cargo-anvil/docs/design/local.md @@ -229,10 +229,9 @@ catalog edit (changing a variable in `versions.just`), not an upstream-release-t surprise. `cargo-binstall` and `just` are bootstrap utilities rather than catalog checks. -GitHub setup source-installs Just 1.46.0 when the installed version is too old or -absent. It accepts newer installed versions. The binary-installer bootstrap -source-installs exactly 1.21.0 when a binary path needs it and it is absent; -it accepts an existing binary installer. +GitHub setup pins their downloads to cargo-binstall 1.21.0 and Just 1.46.0, while +accepting newer installed Just versions. The local cargo-binstall source bootstrap +continues to install the latest compatible release when the binary is absent. ### 3.2 Detecting installed versions @@ -286,7 +285,7 @@ Mirror `*-validate-prereqs` recipes exist at every composition layer (`anvil--validate-prereqs`), so it's possible to verify a group's prerequisites without installing them. -The atomic installs are idempotent (early-skip on installed >= pin with an executable on PATH), so calling +The atomic installs are fully idempotent (early-skip on installed >= pin), so calling any composition layer on every cloud-workflow run is cheap on a cache hit. There is intentionally no separate "install-missing" variant: every install recipe IS the install-missing recipe. @@ -298,29 +297,21 @@ The `installer` argument: Slow on a cold runner (~30 min for the full catalog) because every tool re-compiles common deps (`clap`, `syn`, `quote`, ...) from scratch independently. - `binstall` -- `cargo binstall --no-confirm --locked --version '='`. - Anvil disables credential discovery and compilation in cargo-binstall. Any + This selects an ordered strategy, not a binary-only backend. Anvil first asks + cargo-binstall to install the exact pin. Tools without a source prerequisite retain + cargo-binstall's compile strategy. For tools that declare a source prerequisite, + Anvil disables that compile strategy so compilation cannot bypass the check. Any nonzero binstall result then falls back to Anvil's exact-pin `cargo install`; the declared prerequisite, when present, runs immediately before that fallback. - Successful downloads avoid source compilation. -- `ci` -- source-first installation for ordinary Rust tools, including cargo-delta. - Only tools with a native source prerequisite (currently cargo-spellcheck) try - anonymous binary installation first. This avoids release discovery for ordinary - tools without forcing native dependency installation on binary cache hits. - -All three modes remove GitHub and Cargo registry token environment variables in -the installer process, before probing or installing. Binary installation also -uses `--no-discover-github-token` to prevent reading GitHub CLI or Git credentials. -Parent processes retain their environment for later API checks. Private authenticated -installation is intentionally not supported through these helpers. This reduces -ambient credential exposure; it does not sandbox build scripts or remove credentials -from files on the runner. - -An install metadata query failure stops setup. Missing executable files, old -versions, and unparseable versions trigger installation rather than a false cache hit. -When a ledger entry exists, either installer uses `--force` to repair stale metadata. - -The GitHub composite setup action calls `just anvil--setup ci` -(or just `anvil-setup ci` when no group is scoped). The ADO setup step + A successful binary path cuts the cold-runner install phase from ~30 min to ~1 min. + `cargo-binstall` itself needs to be on PATH; the GH setup composite arranges this. + +Binstall calls use `--no-discover-github-token` to avoid reading credentials from +GitHub CLI or Git configuration. Explicit token environment variables and Cargo +registry credentials are unchanged. This is not isolation from caller credentials. + +The GitHub composite setup action calls `just anvil--setup binstall` +(or just `anvil-setup binstall` when no group is scoped). The ADO setup step template uses the default `install` backend because cargo-binstall has unresolved compliance issues for internal ADO pipelines (the binary registry it pulls from isn't on the standard allow-list), so the slower pure-cargo path is the diff --git a/crates/cargo-anvil/src/anvil/artifacts/github.rs b/crates/cargo-anvil/src/anvil/artifacts/github.rs index 56fe8f42b..45c62324b 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/github.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/github.rs @@ -186,7 +186,7 @@ mod tests { #[test] fn setup_action_takes_group_input_and_dispatches() { assert!(SETUP_ACTION.contains("group:")); - assert!(SETUP_ACTION.contains("just anvil-setup ci")); + assert!(SETUP_ACTION.contains("just anvil-setup binstall")); assert!(!SETUP_ACTION.contains("_anvil-resolve-stable")); assert!(!SETUP_ACTION.contains("just anvil-toolchain-stable-install")); assert!(!SETUP_ACTION.contains("rustc-version")); @@ -208,81 +208,13 @@ mod tests { assert!(SETUP_ACTION.contains("$minimum = [version]'1.46.0'")); assert!(SETUP_ACTION.contains("cargo-anvil requires just >= $minimum")); assert!(SETUP_ACTION.contains("ANVIL_GROUP: ${{ inputs.group }}")); - assert!(SETUP_ACTION.contains("just \"anvil-$ANVIL_GROUP-setup\" ci")); + assert!(SETUP_ACTION.contains("just \"anvil-$ANVIL_GROUP-setup\" binstall")); assert!(SETUP_ACTION.contains(r"^[a-z0-9-]+$")); assert!(SETUP_ACTION.contains("::error::Invalid Anvil group;")); assert!(!SETUP_ACTION.contains("::error::Invalid Anvil group '$ANVIL_GROUP'")); assert!(SETUP_ACTION.contains("none)")); } - #[test] - fn setup_keeps_installation_anonymous_and_caches_impact_tools() { - assert!(!SETUP_ACTION.contains("${{ github.token }}")); - assert!(!IMPACT_ACTION.contains("${{ github.token }}")); - let bootstrap = SETUP_ACTION - .split_once(" - name: Install just\n") - .unwrap() - .1 - .split("\n - name:") - .next() - .unwrap(); - assert!(!bootstrap.contains("cargo binstall")); - assert!(bootstrap.contains("cargo install --locked --version \"=$minimum\" just")); - let restore = SETUP_ACTION.find(" - name: Restore cargo cache").unwrap(); - let install = SETUP_ACTION.find("impact) just anvil-tool-cargo-delta-install ci").unwrap(); - let save = SETUP_ACTION.find(" - name: Save cargo cache").unwrap(); - assert!(restore < install && install < save); - assert!(!IMPACT_ACTION.contains("run: just anvil-tool-cargo-delta-install")); - let cache_save = &SETUP_ACTION[save..]; - assert!(cache_save.contains("if: success() && steps.cargo-cache.outputs.cache-hit != 'true'")); - assert!(!SETUP_ACTION.contains("uses: cargo-bins/cargo-binstall")); - assert!(!PR_ROOT_WORKFLOW.contains("pull_request_target")); - assert_eq!(SETUP_ACTION.matches("uses: actions/cache/restore@").count(), 1); - assert_eq!(SETUP_ACTION.matches("uses: actions/cache/save@").count(), 1); - let cache_restore = &SETUP_ACTION[restore..SETUP_ACTION.find(" - name: Install just").unwrap()]; - let primary_key = cache_restore.lines().find(|line| line.trim_start().starts_with("key:")).unwrap(); - let restore_key = cache_restore - .lines() - .find(|line| line.trim_start().starts_with("anvil-v3-")) - .unwrap(); - for key in [ - "runner.os", - "runner.arch", - "'.cargo/config.toml'", - "'rust-toolchain'", - "'rust-toolchain.toml'", - "'justfiles/anvil/versions.just'", - "'justfiles/anvil/tools.just'", - "'.github/actions/anvil-setup/action.yml'", - ] { - assert!( - primary_key.contains(key) && restore_key.contains(key), - "both cache keys must include {key}" - ); - } - for cache_step in [cache_restore, cache_save] { - let paths = cache_step - .split_once(" path: |\n") - .unwrap() - .1 - .lines() - .take_while(|line| line.starts_with(" ")) - .map(str::trim) - .collect::>(); - assert_eq!( - paths, - [ - "~/.cargo/registry/cache/", - "~/.cargo/registry/index/", - "~/.cargo/bin/", - "~/.cargo/.crates.toml", - "~/.cargo/.crates2.json" - ], - "cache only archives/indexes, executables and install ledgers; never credentials or target" - ); - } - } - #[test] fn aggregate_gate_depends_on_every_validation_job() { let jobs = PR_IMPL_WORKFLOW.split_once("\njobs:\n").expect("PR workflow defines jobs").1; @@ -322,6 +254,31 @@ mod tests { assert!(!SETUP_ACTION.contains("apt-get install -y libclang-dev")); } + #[test] + fn setup_avoids_implicit_installer_credentials_and_pins_bootstrap_downloads() { + for action in [SETUP_ACTION, IMPACT_ACTION] { + assert!(!action.contains("${{ github.token }}")); + assert!(!action.contains("Remove-Item Env:")); + } + let bootstrap = SETUP_ACTION + .split_once(" - name: Install just\n") + .unwrap() + .1 + .split("\n - name:") + .next() + .unwrap(); + assert!(bootstrap.contains("cargo binstall --no-confirm --locked --no-discover-github-token --version \"=$minimum\" just")); + assert!(bootstrap.contains("cargo install --locked --version \"=$minimum\" just")); + let binstall = SETUP_ACTION + .split_once(" - name: Install cargo-binstall\n") + .unwrap() + .1 + .split("\n - name:") + .next() + .unwrap(); + assert!(binstall.contains("version: \"1.21.0\"")); + } + #[test] fn run_group_action_captures_and_reports_results() { assert!(RUN_GROUP_ACTION.contains("uses: ./.github/actions/anvil-setup")); @@ -484,14 +441,18 @@ export -f just } #[test] - fn impact_action_installs_delta_inside_setup_and_runs_the_shared_recipe() { - // The impact action reuses anvil-setup (group=impact) for cargo-delta + fn impact_action_installs_delta_before_saving_and_runs_the_shared_recipe() { + // The impact action reuses anvil-setup (group=impact) for the cargo-delta // install, then runs the same `just anvil-impact` recipe adopters run // locally and uploads the whole cache as a per-OS artifact. The include // lists reach group jobs through that downloaded cache, never job // outputs, so CI and local execution stay identical by construction. assert!(IMPACT_ACTION.contains("group: impact")); - assert!(SETUP_ACTION.contains("just anvil-tool-cargo-delta-install ci")); + assert!(!IMPACT_ACTION.contains("run: just anvil-tool-cargo-delta-install")); + let restore = SETUP_ACTION.find(" - name: Restore cargo cache").unwrap(); + let install = SETUP_ACTION.find("impact) just anvil-tool-cargo-delta-install install").unwrap(); + let save = SETUP_ACTION.find(" - name: Save cargo cache").unwrap(); + assert!(restore < install && install < save); assert!(IMPACT_ACTION.contains("run: just anvil-impact")); assert!(IMPACT_ACTION.contains("uses: actions/upload-artifact")); assert!(IMPACT_ACTION.contains("name: anvil-impact-${{ runner.os }}")); diff --git a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs index 9a1270c42..dc4777691 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs @@ -379,9 +379,11 @@ mod tests { #[test] fn spellcheck_checks_source_prerequisites_before_source_builds() { assert!( - TOOLS_JUST.contains( - "$binstallArgs = @('binstall', '--no-confirm', '--locked', '--disable-strategies', 'compile', '--no-discover-github-token')" - ), + TOOLS_JUST.contains("if ($sourcePrereq)"), + "binstall compile strategy must only be disabled for tools with source prerequisites" + ); + assert!( + TOOLS_JUST.contains("$binstallArgs += @('--disable-strategies', 'compile')"), "binstall must not compile before Anvil checks source prerequisites" ); assert!( diff --git a/crates/cargo-anvil/templates/github/setup-action.yml b/crates/cargo-anvil/templates/github/setup-action.yml index d10a379ee..fc926b567 100644 --- a/crates/cargo-anvil/templates/github/setup-action.yml +++ b/crates/cargo-anvil/templates/github/setup-action.yml @@ -14,8 +14,8 @@ inputs: `just anvil-setup` -- use for local "give me everything" flows. - "none": skip tool installation entirely; just restore the - Cargo cache and bootstrap just. - - "impact": install only cargo-delta, before saving the cache. + Cargo cache and bootstrap just + binstall. + - "impact": install only cargo-delta from source, before saving the cache. - a name containing only lowercase letters, digits, and hyphens: install only that group's prerequisites via `just anvil--setup`. @@ -89,9 +89,9 @@ runs: # one key. The prefix shares a completed cache across sibling jobs. # There is deliberately no fallback across toolchain/catalog changes: # those inputs are the cache-pruning boundary. - key: anvil-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just', 'justfiles/anvil/tools.just', '.github/actions/anvil-setup/action.yml') }}-${{ github.job }} + key: anvil-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}-${{ github.job }} restore-keys: | - anvil-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just', 'justfiles/anvil/tools.just', '.github/actions/anvil-setup/action.yml') }}- + anvil-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}- # `.crates.toml` and `.crates2.json` track which cargo-installed # tools and versions live in ~/.cargo/bin/. Without them in the # cache, `cargo install --list` and (downstream) the @@ -107,15 +107,15 @@ runs: ~/.cargo/.crates.toml ~/.cargo/.crates2.json + # cargo-binstall keeps the cold bootstrap path fast. + - name: Install cargo-binstall + uses: cargo-bins/cargo-binstall@v1.21.0 # pinned by tag: this release is an immutable release (GitHub locks the tag to a commit) + with: + version: "1.21.0" + - name: Install just shell: pwsh -NoProfile -Command ". '{0}'" run: | - foreach ($key in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN')) { - Remove-Item "Env:$key" -ErrorAction SilentlyContinue - } - foreach ($entry in @(Get-ChildItem Env: | Where-Object Name -Match '^CARGO_REGISTRIES_.*_TOKEN$')) { - Remove-Item "Env:$($entry.Name)" - } $minimum = [version]'1.46.0' $installed = if (Get-Command just -ErrorAction SilentlyContinue) { [version]((& just --version) -replace '^just\s+', '') @@ -124,8 +124,11 @@ runs: } if ($null -eq $installed -or $installed -lt $minimum) { Write-Host "Installing just >= $minimum (installed: $(if ($installed) { $installed } else { 'not found' }))" - & cargo install --locked --version "=$minimum" just - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & cargo binstall --no-confirm --locked --no-discover-github-token --version "=$minimum" just + if ($LASTEXITCODE -ne 0) { + & cargo install --locked --version "=$minimum" just + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } } $installed = [version]((& just --version) -replace '^just\s+', '') if ($installed -lt $minimum) { @@ -146,12 +149,18 @@ runs: env: ANVIL_GROUP: ${{ inputs.group }} # When `group` is empty (the default), installs the full catalog - # via `just anvil-setup ci`. When `group` is "none", - # skips tool installation entirely. When it is "impact", installs - # cargo-delta here so the cache save includes it. When + # via `just anvil-setup binstall`. When `group` is "none", + # skips tool installation entirely. The "impact" group source-installs + # cargo-delta before the cache save, avoiding release discovery on + # the impact stage's critical path. When # `group` is anything else, installs only what that group needs - # via `just anvil--setup ci`. Ordinary Rust tools compile - # without release discovery; native tools prefer anonymous binaries. + # via `just anvil--setup binstall`. + # + # binstall path downloads prebuilt tool binaries from each tool's + # GitHub Releases when available (~1 min cold, vs ~30 min for + # source builds). cargo-binstall has unresolved compliance issues + # for ADO pipelines, so the ADO backend uses the default `install` + # path; GH uses `binstall`. run: | if [[ -n "$ANVIL_GROUP" && "$ANVIL_GROUP" != "none" && ! "$ANVIL_GROUP" =~ ^[a-z0-9-]+$ ]]; then echo "::error::Invalid Anvil group; expected lowercase letters, digits, and hyphens." @@ -161,16 +170,28 @@ runs: case "$ANVIL_GROUP" in none) echo "anvil-setup: group=none, skipping tool install" ;; - impact) just anvil-tool-cargo-delta-install ci ;; - "") just anvil-setup ci ;; - *) just "anvil-$ANVIL_GROUP-setup" ci ;; + impact) just anvil-tool-cargo-delta-install install ;; + "") just anvil-setup binstall ;; + *) just "anvil-$ANVIL_GROUP-setup" binstall ;; esac - # Preserve GitHub's ref-scoped cache trust boundary: PR writes are confined - # to their merge ref, never the default branch. Save only complete setup; - # failures must not seal an incomplete immutable key. + # Save the cache as the LAST step of setup, regardless of whether + # any earlier install step partially failed. `actions/cache` + # used to support this via `save-always: true`, but that knob is + # deprecated as of 2025 with the explicit message "does not work + # as intended" — failing runs simply don't save. The supported + # replacement is to call `actions/cache/save` directly as its + # own step with `if: always()`. + # + # Without this, a single catalog issue that fails the install + # step locks the cache empty forever (chicken-and-egg: failed + # run -> no save -> next run cold-starts -> still fails -> still + # no save). Tool binaries installed by anvil-tools-install are + # immutable once on disk, so partial state is strictly better + # than nothing — and subsequent runs accumulate into the cache + # until the catalog is complete. - name: Save cargo cache - if: success() && steps.cargo-cache.outputs.cache-hit != 'true' + if: always() && steps.cargo-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.cargo-cache.outputs.cache-primary-key }} diff --git a/crates/cargo-anvil/templates/justfiles/anvil/tools.just b/crates/cargo-anvil/templates/justfiles/anvil/tools.just index 85b441024..4d37d0615 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/tools.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/tools.just @@ -411,10 +411,9 @@ anvil-toolchain-stable-install: (_anvil-resolve-stable "install") # or no-op if it is already installed at or above that version. The # `installer` parameter selects between: # - "install" (cargo install --locked, pure-source). Default. -# - "binstall" (anonymous binary download, with a checked source fallback). -# - "ci" (source install, except tools with native source prerequisites, -# which try anonymous binaries first). Avoids release discovery for -# ordinary Rust tools even when prebuilt binaries exist. +# - "binstall" (cargo binstall --no-confirm --locked, with a controlled +# cargo install fallback for tools that declare source +# prerequisites). Bootstraps cargo-binstall itself if not on PATH. # `source_prereq`, when set, runs immediately before a source installation, # including a binstall fallback. [script("pwsh", "-NoProfile")] @@ -425,23 +424,11 @@ _install-tool-core name version installer source_prereq="": $installer = '{{installer}}' $sourcePrereq = '{{source_prereq}}' - if ($installer -notin @('install', 'binstall', 'ci')) { - Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install', 'binstall', or 'ci')" + if ($installer -ne 'install' -and $installer -ne 'binstall') { + Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install' or 'binstall')" exit 2 } - # This recipe runs in its own process: API checks in the parent retain - # their credentials, but installers and third-party build scripts do not. - foreach ($key in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN')) { - Remove-Item "Env:$key" -ErrorAction SilentlyContinue - } - foreach ($entry in @(Get-ChildItem Env: | Where-Object Name -Match '^CARGO_REGISTRIES_.*_TOKEN$')) { - Remove-Item "Env:$($entry.Name)" - } - if ($installer -eq 'ci') { - $installer = if ($sourcePrereq) { 'binstall' } else { 'install' } - } - # Already at or above the pin: skip. We don't downgrade tools the # user upgraded for their own reasons; the validate side uses # `installed >= pin`, so newer is fine. The early-exit is also what @@ -450,22 +437,17 @@ _install-tool-core name version installer source_prereq="": # cached binaries and fail with "binary already exists in destination". $installed = $null $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' - $listOutput = @(& cargo {{_anvil_stable_toolchain_args}} install --list) - if ($LASTEXITCODE -ne 0) { - throw "_install-tool-core: cargo install --list failed for '$name'" - } - foreach ($line in $listOutput) { + foreach ($line in (& cargo {{_anvil_stable_toolchain_args}} install --list 2>$null)) { if ($line -match $pattern) { $installed = $Matches[1]; break } } if ($installed) { - $installedVersion = $null - $pinVersion = $null - if ([version]::TryParse($installed, [ref]$installedVersion) -and - [version]::TryParse($version, [ref]$pinVersion) -and - $installedVersion -ge $pinVersion -and - (Get-Command $name -ErrorAction SilentlyContinue)) { - Write-Host "$name >= $version (already satisfied; installed=$installed)" - exit 0 + try { + if (([version]$installed) -ge ([version]$version)) { + Write-Host "$name >= $version (already satisfied; installed=$installed)" + exit 0 + } + } catch { + # Fall through to reinstall when versions don't parse as [version]. } } @@ -473,17 +455,21 @@ _install-tool-core name version installer source_prereq="": if ($installer -eq 'binstall') { if (-not (Get-Command cargo-binstall -ErrorAction SilentlyContinue)) { Write-Host ' Bootstrapping cargo-binstall' - & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall --version '=1.21.0' + & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall if ($LASTEXITCODE -ne 0) { Write-Error 'cargo-binstall bootstrap failed' exit $LASTEXITCODE } } - # No credential discovery from gh/git configuration, and no implicit - # compilation before the source-prerequisite check below. - $binstallArgs = @('binstall', '--no-confirm', '--locked', '--disable-strategies', 'compile', '--no-discover-github-token') + # Keep source builds behind Anvil's prerequisite check instead of + # allowing binstall to compile before that check can run. Tools with + # no source prerequisite may still use binstall's compile strategy. + $binstallArgs = @('binstall', '--no-confirm', '--locked') + if ($sourcePrereq) { + $binstallArgs += @('--disable-strategies', 'compile') + } + $binstallArgs += '--no-discover-github-token' $binstallArgs += @($name, '--version', "=$version") - if ($installed) { $binstallArgs += '--force' } & cargo {{_anvil_stable_toolchain_args}} @binstallArgs if ($LASTEXITCODE -eq 0) { exit 0 } Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow @@ -492,10 +478,7 @@ _install-tool-core name version installer source_prereq="": & "{{just_executable()}}" $sourcePrereq if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } - $installArgs = @('install', '--locked', $name, '--version', "=$version") - # Repair stale install metadata as well as old versions. - if ($installed) { $installArgs += '--force' } - & cargo {{_anvil_stable_toolchain_args}} @installArgs + & cargo {{_anvil_stable_toolchain_args}} install --locked $name --version "=$version" if ($LASTEXITCODE -ne 0) { Write-Error "$name install FAILED" exit $LASTEXITCODE diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index 92809827c..d6dfbd0e0 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -37,7 +37,6 @@ const README: &str = include_str!("../templates/justfiles/anvil/checks/readme-ch const SEMVER: &str = include_str!("../templates/justfiles/anvil/checks/semver-check.just"); const EXTERNAL_TYPES: &str = include_str!("../templates/justfiles/anvil/checks/external-types.just"); const TOOLS: &str = include_str!("../templates/justfiles/anvil/tools.just"); -const SETUP: &str = include_str!("../templates/github/setup-action.yml"); const APRZ: &str = include_str!("../templates/justfiles/anvil/checks/aprz.just"); const MUTANTS_DIFF: &str = include_str!("../templates/justfiles/anvil/checks/mutants-diff.just"); const VERSIONS: &str = include_str!("../templates/justfiles/anvil/versions.just"); @@ -119,13 +118,6 @@ if ($env:FAKE_CARGO_TOOLCHAIN_LOG) { if ($env:FAKE_CARGO_AUTO_INSTALL_LOG) { Add-Content -LiteralPath $env:FAKE_CARGO_AUTO_INSTALL_LOG -Value $env:RUSTUP_AUTO_INSTALL } -if ($env:FAKE_INSTALL_CREDENTIALS_LOG) { - $present = @(Get-ChildItem Env: | Where-Object { - $_.Name -in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN') -or - $_.Name -match '^CARGO_REGISTRIES_.*_TOKEN$' - } | ForEach-Object Name) - Add-Content -LiteralPath $env:FAKE_INSTALL_CREDENTIALS_LOG -Value "$joined|$($present -join ',')" -} if ($args -contains 'each') { exit [int]$env:FAKE_EACH_EXIT } @@ -402,6 +394,9 @@ _anvil-impact-include tier: let bin = tmp.path().join("fake-bin"); fs::create_dir_all(&bin).unwrap(); write(&bin.join("cargo.ps1"), FAKE_CARGO_PS1); + // Installer fixtures must not depend on cargo-binstall being installed on + // the host. Fake Cargo owns the download/fallback outcomes. + write(&bin.join("cargo-binstall.ps1"), "exit 0\n"); write(&bin.join("git.ps1"), "exit 0\n"); write( &bin.join("rustc.ps1"), @@ -501,23 +496,10 @@ fn just_command(root: &Path, arguments: &[&str], environment: &[(&str, &OsStr)]) command.env_remove("GITHUB_ACTIONS"); command.env_remove("TF_BUILD"); for key in std::env::vars_os().map(|(key, _)| key) { - let name = key.to_string_lossy(); - if name.starts_with("ANVIL_INCLUDE_") || name.starts_with("FAKE_") { + if key.to_string_lossy().starts_with("ANVIL_INCLUDE_") { command.env_remove(key); } } - command.env("FAKE_WORKSPACE_ROOT", root); - for key in [ - "RUSTUP_TOOLCHAIN", - "ANVIL_MSRV_TOOLCHAIN", - "GITHUB_TOKEN", - "GH_TOKEN", - "GITHUB_ENTERPRISE_TOKEN", - "GH_ENTERPRISE_TOKEN", - "CARGO_REGISTRY_TOKEN", - ] { - command.env_remove(key); - } for &(key, value) in environment { command.env(key, value); } @@ -1759,290 +1741,76 @@ source-prereq: .find(|line| line.contains("binstall --no-confirm --locked")) .expect("ordinary tool must attempt binstall"); assert!( - ordinary_binstall.contains("--disable-strategies compile --no-discover-github-token"), - "all binary installs use anonymous discovery and controlled source fallback" + !ordinary_binstall.contains("--disable-strategies compile"), + "tools without source prerequisites retain binstall's compile strategy" + ); + assert!( + ordinary_binstall.contains("--no-discover-github-token"), + "binary installation must not discover credentials from gh/git configuration" ); } #[test] -fn install_tool_ci_cold_warm_and_stale_cache_paths() { - assert!(tools_available(), "real Just and PowerShell are required for installer contracts"); - for (installed, binary, should_install) in [ - ("", false, true), - ("1.2.3", true, false), - ("1.3.0", true, false), - ("1.2.2", true, true), - ("1.2.3", false, true), - ("unparseable", true, true), - ] { - let tmp = installer_fixture(); - if binary { - write(&tmp.path().join("fake-bin/cargo-fixture.ps1"), "exit 0\n"); - } - let log = tmp.path().join("cargo.log"); - let ledger = format!("cargo-fixture v{installed}:\n cargo-fixture"); - let output = run_just( - tmp.path(), - &["_install-tool", "cargo-fixture", "1.2.3", "ci"], - &[ - ("FAKE_CARGO_LOG", log.as_os_str()), - ("FAKE_INSTALL_LIST_OUTPUT", OsStr::new(&ledger)), - ], - ); - assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); - let calls = fs::read_to_string(log).unwrap(); - assert!(!calls.contains("binstall"), "ordinary CI tools must not perform release discovery"); - assert_eq!(calls.contains("install --locked cargo-fixture --version =1.2.3"), should_install); - if should_install && !installed.is_empty() { - assert!(calls.contains("--force"), "stale metadata must not suppress repair"); - } - if !should_install { - assert_eq!(calls.lines().count(), 1, "a warm hit must only query install metadata"); - } - } -} - -#[test] -fn native_tool_cache_hit_skips_binstall_and_missing_binary_forces_repair() { - assert!(tools_available(), "real Just and PowerShell are required for installer contracts"); - for binary in [true, false] { - let tmp = installer_fixture(); - if binary { - write(&tmp.path().join("fake-bin/cargo-fixture.ps1"), "exit 0\n"); - } - let log = tmp.path().join("cargo.log"); - let output = run_just( - tmp.path(), - &["_install-tool", "cargo-fixture", "1.2.3", "ci", "must-not-run"], - &[ - ("FAKE_CARGO_LOG", log.as_os_str()), - ("FAKE_INSTALL_LIST_OUTPUT", OsStr::new("cargo-fixture v1.2.3:\n cargo-fixture")), - ], - ); - assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); - let calls = fs::read_to_string(log).unwrap(); - if binary { - assert_eq!( - calls.trim(), - "install --list", - "native cache hit must avoid installers and prerequisites" - ); - } else { - assert!( - calls.contains("cargo-fixture --version =1.2.3 --force"), - "binstall must not trust stale metadata: {calls}" - ); - } - assert!(!calls.contains("install --locked cargo-fixture")); - } -} - -#[test] -fn installers_drop_inherited_tokens_without_changing_the_parent() { - assert!(tools_available(), "real Just and PowerShell are required for installer contracts"); - for installer in ["install", "binstall", "ci"] { - let tmp = installer_fixture(); - let log = tmp.path().join("credentials.log"); - let justfile_path = tmp.path().join("Justfile"); - let mut justfile = fs::read_to_string(&justfile_path).unwrap(); - justfile.push_str( - r#" -[script("pwsh", "-NoProfile")] -parent installer: - & "{{just_executable()}}" _install-tool cargo-fixture 1.2.3 '{{installer}}' - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - if ($env:GITHUB_TOKEN -ne 'fixture-only' -or $env:GH_TOKEN -ne 'fixture-only') { exit 91 } -"#, - ); - write(&justfile_path, &justfile); - let output = run_just( - tmp.path(), - &["parent", installer], - &[ - ("FAKE_INSTALL_CREDENTIALS_LOG", log.as_os_str()), - ("FAKE_BINSTALL_EXIT", OsStr::new("7")), - ("GITHUB_TOKEN", OsStr::new("fixture-only")), - ("GH_TOKEN", OsStr::new("fixture-only")), - ("GITHUB_ENTERPRISE_TOKEN", OsStr::new("fixture-only")), - ("GH_ENTERPRISE_TOKEN", OsStr::new("fixture-only")), - ("CARGO_REGISTRY_TOKEN", OsStr::new("fixture-only")), - ("CARGO_REGISTRIES_FIXTURE_TOKEN", OsStr::new("fixture-only")), - ], - ); - assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); - let calls = fs::read_to_string(log).unwrap(); - assert!(calls.contains("install --locked cargo-fixture --version =1.2.3|")); - assert!( - calls.lines().all(|line| line.ends_with('|')), - "install subprocesses received tokens: {calls}" - ); - if installer == "binstall" { - assert!(calls.contains("--no-discover-github-token")); - } - } -} - -#[test] -fn ci_native_binary_hit_skips_source_prerequisites_but_miss_checks_them() { - assert!(tools_available(), "real Just and PowerShell are required for installer contracts"); - for (binary_exit, prerequisite_exit, source_exit, success, source_runs) in [ - ("0", "9", "23", true, false), - ("7", "0", "0", true, true), - ("7", "9", "0", false, false), - ("7", "0", "23", false, true), - ] { - let tmp = installer_fixture(); - let justfile_path = tmp.path().join("Justfile"); - let mut justfile = fs::read_to_string(&justfile_path).unwrap(); - justfile.push_str( - r#" -[script("pwsh", "-NoProfile")] -source-prereq: - if ($env:GITHUB_TOKEN -or $env:GH_TOKEN) { exit 92 } - Add-Content -LiteralPath $env:FAKE_CARGO_LOG -Value 'source-prereq' - exit [int]$env:FAKE_PREREQ_EXIT -"#, - ); - write(&justfile_path, &justfile); - let log = tmp.path().join("cargo.log"); - let output = run_just( - tmp.path(), - &["_install-tool", "cargo-fixture", "1.2.3", "ci", "source-prereq"], - &[ - ("FAKE_CARGO_LOG", log.as_os_str()), - ("FAKE_BINSTALL_EXIT", OsStr::new(binary_exit)), - ("FAKE_PREREQ_EXIT", OsStr::new(prerequisite_exit)), - ("FAKE_INSTALL_EXIT", OsStr::new(source_exit)), - ("GITHUB_TOKEN", OsStr::new("fixture-only")), - ("GH_TOKEN", OsStr::new("fixture-only")), - ], - ); - assert_eq!(output.status.success(), success, "{}", String::from_utf8_lossy(&output.stderr)); - let calls = fs::read_to_string(log).unwrap(); - assert!(calls.contains("binstall --no-confirm --locked --disable-strategies compile --no-discover-github-token")); - assert_eq!(calls.contains("source-prereq"), binary_exit != "0"); - assert_eq!(calls.contains("install --locked cargo-fixture --version =1.2.3"), source_runs); - if source_runs { - assert!(calls.find("source-prereq").unwrap() < calls.find("install --locked cargo-fixture").unwrap()); - } - } -} - -#[test] -fn installer_metadata_failure_does_not_start_a_build() { +fn binary_hit_does_not_require_source_prerequisites_or_a_host_installer() { assert!(tools_available(), "real Just and PowerShell are required for installer contracts"); - let tmp = installer_fixture(); + let tmp = fixture(&[("versions.just", VERSIONS), ("tools.just", TOOLS)], &[]); let log = tmp.path().join("cargo.log"); let output = run_just( tmp.path(), - &["_install-tool", "cargo-fixture", "1.2.3", "ci"], - &[("FAKE_CARGO_LOG", log.as_os_str()), ("FAKE_INSTALL_LIST_EXIT", OsStr::new("23"))], - ); - assert_failed(&output, "installer metadata failure"); - assert!(String::from_utf8_lossy(&output.stderr).contains("cargo install --list failed")); - assert_eq!(fs::read_to_string(log).unwrap().trim(), "install --list"); -} - -fn installer_fixture() -> TempDir { - let tmp = fixture( - &[("versions.just", VERSIONS), ("tools.just", TOOLS)], - &["anvil-toolchain-stable-install"], + &["_install-tool-core", "cargo-spellcheck", "0.15.7", "binstall", "must-not-run"], + &[ + ("FAKE_CARGO_LOG", log.as_os_str()), + ("FAKE_INSTALL_LIST_OUTPUT", OsStr::new("")), + ("FAKE_BINSTALL_EXIT", OsStr::new("0")), + ("FAKE_INSTALL_EXIT", OsStr::new("23")), + ], ); - write(&tmp.path().join("rust-toolchain.toml"), "[toolchain]\nchannel = \"1.97\"\n"); - tmp -} - -#[test] -fn missing_binary_installer_bootstraps_at_a_pin_without_tokens() { - assert!(tools_available(), "real Just and PowerShell are required for installer contracts"); - let tmp = installer_fixture(); - let paths = std::env::split_paths(&std::env::var_os("PATH").unwrap()).collect::>(); - let just_name = if cfg!(windows) { "just.exe" } else { "just" }; - let just_path = paths.iter().map(|path| path.join(just_name)).find(|path| path.is_file()).unwrap(); - let template = just_command(tmp.path(), &["_install-tool", "cargo-fixture", "1.2.3", "binstall"], &[]); - let mut command = Command::new(just_path); - command.args(template.get_args()).current_dir(tmp.path()); - for (key, value) in template.get_envs() { - if let Some(value) = value { - command.env(key, value); - } else { - command.env_remove(key); - } - } - let mut isolated_paths = vec![tmp.path().join("fake-bin")]; - isolated_paths.extend(paths.into_iter().filter(|path| { - !["cargo-binstall", "cargo-binstall.exe", "cargo-binstall.ps1", "cargo-binstall.cmd"] - .iter() - .any(|name| path.join(name).exists()) - })); - let log = tmp.path().join("credentials.log"); - let output = command - .env("PATH", std::env::join_paths(isolated_paths).unwrap()) - .env("FAKE_INSTALL_CREDENTIALS_LOG", &log) - .env("GITHUB_TOKEN", "fixture-only") - .env("GH_TOKEN", "fixture-only") - .env("CARGO_REGISTRIES_FIXTURE_TOKEN", "fixture-only") - .output() - .unwrap(); assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); let calls = fs::read_to_string(log).unwrap(); - let bootstrap = calls.find("install --locked cargo-binstall --version =1.21.0|").unwrap(); - let download = calls - .find("binstall --no-confirm --locked --disable-strategies compile --no-discover-github-token") - .unwrap(); - assert!(bootstrap < download); - assert!(calls.lines().all(|line| line.ends_with('|')), "{calls}"); + assert!(calls.contains("binstall --no-confirm --locked --disable-strategies compile --no-discover-github-token")); + assert!(!calls.contains("install --locked")); } #[test] -fn github_just_bootstrap_is_pinned_source_only_and_anonymous() { +fn pinned_installer_choices_reuse_warm_cargo_metadata() { assert!(tools_available(), "real Just and PowerShell are required for installer contracts"); - let body = SETUP - .split_once(" - name: Install just\n") - .unwrap() - .1 - .split_once(" run: |\n") - .unwrap() - .1 - .split("\n #") - .next() - .unwrap(); - let mut recipe = String::from("[script(\"pwsh\", \"-NoProfile\")]\nbootstrap:\n"); - for line in body.lines() { - if let Some(code) = line.strip_prefix(" ") { - writeln!(recipe, " {code}").unwrap(); - } - } - for (installed, exit, success, should_install) in [ - ("1.46.0", "23", true, false), - ("1.45.0", "0", true, true), - ("1.45.0", "23", false, true), - ] { - let tmp = fixture(&[("bootstrap.just", &recipe)], &[]); - let log = tmp.path().join("credentials.log"); - write( - &tmp.path().join("fake-bin/just.ps1"), - r#" -$version = if (Test-Path "$env:FAKE_INSTALL_CREDENTIALS_LOG") { '1.46.0' } else { $env:FAKE_JUST_VERSION } -Write-Output "just $version" -"#, - ); - let output = run_just( - tmp.path(), - &["bootstrap"], - &[ - ("FAKE_INSTALL_CREDENTIALS_LOG", log.as_os_str()), - ("FAKE_JUST_VERSION", OsStr::new(installed)), - ("FAKE_INSTALL_EXIT", OsStr::new(exit)), - ("GITHUB_TOKEN", OsStr::new("fixture-only")), - ("GH_TOKEN", OsStr::new("fixture-only")), - ("CARGO_REGISTRIES_FIXTURE_TOKEN", OsStr::new("fixture-only")), - ], - ); - assert_eq!(output.status.success(), success, "{}", String::from_utf8_lossy(&output.stderr)); - assert_eq!(log.exists(), should_install); - if should_install { - assert_eq!(fs::read_to_string(log).unwrap().trim(), "install --locked --version =1.46.0 just|"); + for installer in ["install", "binstall"] { + for installed in ["", "1.2.3", "1.3.0", "1.2.2"] { + let tmp = fixture( + &[("versions.just", VERSIONS), ("tools.just", TOOLS)], + &["anvil-toolchain-stable-install"], + ); + write(&tmp.path().join("rust-toolchain.toml"), "[toolchain]\nchannel = \"1.97\"\n"); + let log = tmp.path().join("cargo.log"); + let ledger = if installed.is_empty() { + String::new() + } else { + format!("cargo-fixture v{installed}:") + }; + let output = run_just( + tmp.path(), + &["_install-tool", "cargo-fixture", "1.2.3", installer], + &[ + ("FAKE_CARGO_LOG", log.as_os_str()), + ("FAKE_INSTALL_LIST_OUTPUT", OsStr::new(&ledger)), + ("FAKE_INSTALL_LIST_EXIT", OsStr::new("0")), + ("FAKE_BINSTALL_EXIT", OsStr::new("0")), + ("FAKE_INSTALL_EXIT", OsStr::new("0")), + ("RUSTUP_TOOLCHAIN", OsStr::new("")), + ("ANVIL_MSRV_TOOLCHAIN", OsStr::new("")), + ], + ); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let calls = fs::read_to_string(log).unwrap(); + if installed == "1.2.3" || installed == "1.3.0" { + assert_eq!(calls.trim(), "install --list", "warm metadata must avoid either installer"); + } else if installer == "install" { + assert!(calls.contains("install --locked cargo-fixture --version =1.2.3")); + assert!(!calls.contains("binstall")); + } else { + assert!(calls.contains("binstall --no-confirm --locked --no-discover-github-token cargo-fixture --version =1.2.3")); + assert!(!calls.contains("install --locked cargo-fixture")); + } } } } diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index 58438b5d6..fee2e67f7 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -7349,10 +7349,9 @@ anvil-toolchain-stable-install: (_anvil-resolve-stable "install") # or no-op if it is already installed at or above that version. The # `installer` parameter selects between: # - "install" (cargo install --locked, pure-source). Default. -# - "binstall" (anonymous binary download, with a checked source fallback). -# - "ci" (source install, except tools with native source prerequisites, -# which try anonymous binaries first). Avoids release discovery for -# ordinary Rust tools even when prebuilt binaries exist. +# - "binstall" (cargo binstall --no-confirm --locked, with a controlled +# cargo install fallback for tools that declare source +# prerequisites). Bootstraps cargo-binstall itself if not on PATH. # `source_prereq`, when set, runs immediately before a source installation, # including a binstall fallback. [script("pwsh", "-NoProfile")] @@ -7363,23 +7362,11 @@ _install-tool-core name version installer source_prereq="": $installer = '{{installer}}' $sourcePrereq = '{{source_prereq}}' - if ($installer -notin @('install', 'binstall', 'ci')) { - Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install', 'binstall', or 'ci')" + if ($installer -ne 'install' -and $installer -ne 'binstall') { + Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install' or 'binstall')" exit 2 } - # This recipe runs in its own process: API checks in the parent retain - # their credentials, but installers and third-party build scripts do not. - foreach ($key in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN')) { - Remove-Item "Env:$key" -ErrorAction SilentlyContinue - } - foreach ($entry in @(Get-ChildItem Env: | Where-Object Name -Match '^CARGO_REGISTRIES_.*_TOKEN$')) { - Remove-Item "Env:$($entry.Name)" - } - if ($installer -eq 'ci') { - $installer = if ($sourcePrereq) { 'binstall' } else { 'install' } - } - # Already at or above the pin: skip. We don't downgrade tools the # user upgraded for their own reasons; the validate side uses # `installed >= pin`, so newer is fine. The early-exit is also what @@ -7388,22 +7375,17 @@ _install-tool-core name version installer source_prereq="": # cached binaries and fail with "binary already exists in destination". $installed = $null $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' - $listOutput = @(& cargo {{_anvil_stable_toolchain_args}} install --list) - if ($LASTEXITCODE -ne 0) { - throw "_install-tool-core: cargo install --list failed for '$name'" - } - foreach ($line in $listOutput) { + foreach ($line in (& cargo {{_anvil_stable_toolchain_args}} install --list 2>$null)) { if ($line -match $pattern) { $installed = $Matches[1]; break } } if ($installed) { - $installedVersion = $null - $pinVersion = $null - if ([version]::TryParse($installed, [ref]$installedVersion) -and - [version]::TryParse($version, [ref]$pinVersion) -and - $installedVersion -ge $pinVersion -and - (Get-Command $name -ErrorAction SilentlyContinue)) { - Write-Host "$name >= $version (already satisfied; installed=$installed)" - exit 0 + try { + if (([version]$installed) -ge ([version]$version)) { + Write-Host "$name >= $version (already satisfied; installed=$installed)" + exit 0 + } + } catch { + # Fall through to reinstall when versions don't parse as [version]. } } @@ -7411,17 +7393,21 @@ _install-tool-core name version installer source_prereq="": if ($installer -eq 'binstall') { if (-not (Get-Command cargo-binstall -ErrorAction SilentlyContinue)) { Write-Host ' Bootstrapping cargo-binstall' - & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall --version '=1.21.0' + & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall if ($LASTEXITCODE -ne 0) { Write-Error 'cargo-binstall bootstrap failed' exit $LASTEXITCODE } } - # No credential discovery from gh/git configuration, and no implicit - # compilation before the source-prerequisite check below. - $binstallArgs = @('binstall', '--no-confirm', '--locked', '--disable-strategies', 'compile', '--no-discover-github-token') + # Keep source builds behind Anvil's prerequisite check instead of + # allowing binstall to compile before that check can run. Tools with + # no source prerequisite may still use binstall's compile strategy. + $binstallArgs = @('binstall', '--no-confirm', '--locked') + if ($sourcePrereq) { + $binstallArgs += @('--disable-strategies', 'compile') + } + $binstallArgs += '--no-discover-github-token' $binstallArgs += @($name, '--version', "=$version") - if ($installed) { $binstallArgs += '--force' } & cargo {{_anvil_stable_toolchain_args}} @binstallArgs if ($LASTEXITCODE -eq 0) { exit 0 } Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow @@ -7430,10 +7416,7 @@ _install-tool-core name version installer source_prereq="": & "{{just_executable()}}" $sourcePrereq if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } - $installArgs = @('install', '--locked', $name, '--version', "=$version") - # Repair stale install metadata as well as old versions. - if ($installed) { $installArgs += '--force' } - & cargo {{_anvil_stable_toolchain_args}} @installArgs + & cargo {{_anvil_stable_toolchain_args}} install --locked $name --version "=$version" if ($LASTEXITCODE -ne 0) { Write-Error "$name install FAILED" exit $LASTEXITCODE diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index d8ab45424..a9f8eb1bc 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -542,8 +542,8 @@ inputs: `just anvil-setup` -- use for local "give me everything" flows. - "none": skip tool installation entirely; just restore the - Cargo cache and bootstrap just. - - "impact": install only cargo-delta, before saving the cache. + Cargo cache and bootstrap just + binstall. + - "impact": install only cargo-delta from source, before saving the cache. - a name containing only lowercase letters, digits, and hyphens: install only that group's prerequisites via `just anvil--setup`. @@ -617,9 +617,9 @@ runs: # one key. The prefix shares a completed cache across sibling jobs. # There is deliberately no fallback across toolchain/catalog changes: # those inputs are the cache-pruning boundary. - key: anvil-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just', 'justfiles/anvil/tools.just', '.github/actions/anvil-setup/action.yml') }}-${{ github.job }} + key: anvil-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}-${{ github.job }} restore-keys: | - anvil-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just', 'justfiles/anvil/tools.just', '.github/actions/anvil-setup/action.yml') }}- + anvil-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.cargo/config.toml', 'rust-toolchain', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}- # `.crates.toml` and `.crates2.json` track which cargo-installed # tools and versions live in ~/.cargo/bin/. Without them in the # cache, `cargo install --list` and (downstream) the @@ -635,15 +635,15 @@ runs: ~/.cargo/.crates.toml ~/.cargo/.crates2.json + # cargo-binstall keeps the cold bootstrap path fast. + - name: Install cargo-binstall + uses: cargo-bins/cargo-binstall@v1.21.0 # pinned by tag: this release is an immutable release (GitHub locks the tag to a commit) + with: + version: "1.21.0" + - name: Install just shell: pwsh -NoProfile -Command ". '{0}'" run: | - foreach ($key in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN')) { - Remove-Item "Env:$key" -ErrorAction SilentlyContinue - } - foreach ($entry in @(Get-ChildItem Env: | Where-Object Name -Match '^CARGO_REGISTRIES_.*_TOKEN$')) { - Remove-Item "Env:$($entry.Name)" - } $minimum = [version]'1.46.0' $installed = if (Get-Command just -ErrorAction SilentlyContinue) { [version]((& just --version) -replace '^just\s+', '') @@ -652,8 +652,11 @@ runs: } if ($null -eq $installed -or $installed -lt $minimum) { Write-Host "Installing just >= $minimum (installed: $(if ($installed) { $installed } else { 'not found' }))" - & cargo install --locked --version "=$minimum" just - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & cargo binstall --no-confirm --locked --no-discover-github-token --version "=$minimum" just + if ($LASTEXITCODE -ne 0) { + & cargo install --locked --version "=$minimum" just + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } } $installed = [version]((& just --version) -replace '^just\s+', '') if ($installed -lt $minimum) { @@ -674,12 +677,18 @@ runs: env: ANVIL_GROUP: ${{ inputs.group }} # When `group` is empty (the default), installs the full catalog - # via `just anvil-setup ci`. When `group` is "none", - # skips tool installation entirely. When it is "impact", installs - # cargo-delta here so the cache save includes it. When + # via `just anvil-setup binstall`. When `group` is "none", + # skips tool installation entirely. The "impact" group source-installs + # cargo-delta before the cache save, avoiding release discovery on + # the impact stage's critical path. When # `group` is anything else, installs only what that group needs - # via `just anvil--setup ci`. Ordinary Rust tools compile - # without release discovery; native tools prefer anonymous binaries. + # via `just anvil--setup binstall`. + # + # binstall path downloads prebuilt tool binaries from each tool's + # GitHub Releases when available (~1 min cold, vs ~30 min for + # source builds). cargo-binstall has unresolved compliance issues + # for ADO pipelines, so the ADO backend uses the default `install` + # path; GH uses `binstall`. run: | if [[ -n "$ANVIL_GROUP" && "$ANVIL_GROUP" != "none" && ! "$ANVIL_GROUP" =~ ^[a-z0-9-]+$ ]]; then echo "::error::Invalid Anvil group; expected lowercase letters, digits, and hyphens." @@ -689,16 +698,28 @@ runs: case "$ANVIL_GROUP" in none) echo "anvil-setup: group=none, skipping tool install" ;; - impact) just anvil-tool-cargo-delta-install ci ;; - "") just anvil-setup ci ;; - *) just "anvil-$ANVIL_GROUP-setup" ci ;; + impact) just anvil-tool-cargo-delta-install install ;; + "") just anvil-setup binstall ;; + *) just "anvil-$ANVIL_GROUP-setup" binstall ;; esac - # Preserve GitHub's ref-scoped cache trust boundary: PR writes are confined - # to their merge ref, never the default branch. Save only complete setup; - # failures must not seal an incomplete immutable key. + # Save the cache as the LAST step of setup, regardless of whether + # any earlier install step partially failed. `actions/cache` + # used to support this via `save-always: true`, but that knob is + # deprecated as of 2025 with the explicit message "does not work + # as intended" — failing runs simply don't save. The supported + # replacement is to call `actions/cache/save` directly as its + # own step with `if: always()`. + # + # Without this, a single catalog issue that fails the install + # step locks the cache empty forever (chicken-and-egg: failed + # run -> no save -> next run cold-starts -> still fails -> still + # no save). Tool binaries installed by anvil-tools-install are + # immutable once on disk, so partial state is strictly better + # than nothing — and subsequent runs accumulate into the cache + # until the catalog is complete. - name: Save cargo cache - if: success() && steps.cargo-cache.outputs.cache-hit != 'true' + if: always() && steps.cargo-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.cargo-cache.outputs.cache-primary-key }} @@ -7469,10 +7490,9 @@ anvil-toolchain-stable-install: (_anvil-resolve-stable "install") # or no-op if it is already installed at or above that version. The # `installer` parameter selects between: # - "install" (cargo install --locked, pure-source). Default. -# - "binstall" (anonymous binary download, with a checked source fallback). -# - "ci" (source install, except tools with native source prerequisites, -# which try anonymous binaries first). Avoids release discovery for -# ordinary Rust tools even when prebuilt binaries exist. +# - "binstall" (cargo binstall --no-confirm --locked, with a controlled +# cargo install fallback for tools that declare source +# prerequisites). Bootstraps cargo-binstall itself if not on PATH. # `source_prereq`, when set, runs immediately before a source installation, # including a binstall fallback. [script("pwsh", "-NoProfile")] @@ -7483,23 +7503,11 @@ _install-tool-core name version installer source_prereq="": $installer = '{{installer}}' $sourcePrereq = '{{source_prereq}}' - if ($installer -notin @('install', 'binstall', 'ci')) { - Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install', 'binstall', or 'ci')" + if ($installer -ne 'install' -and $installer -ne 'binstall') { + Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install' or 'binstall')" exit 2 } - # This recipe runs in its own process: API checks in the parent retain - # their credentials, but installers and third-party build scripts do not. - foreach ($key in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN')) { - Remove-Item "Env:$key" -ErrorAction SilentlyContinue - } - foreach ($entry in @(Get-ChildItem Env: | Where-Object Name -Match '^CARGO_REGISTRIES_.*_TOKEN$')) { - Remove-Item "Env:$($entry.Name)" - } - if ($installer -eq 'ci') { - $installer = if ($sourcePrereq) { 'binstall' } else { 'install' } - } - # Already at or above the pin: skip. We don't downgrade tools the # user upgraded for their own reasons; the validate side uses # `installed >= pin`, so newer is fine. The early-exit is also what @@ -7508,22 +7516,17 @@ _install-tool-core name version installer source_prereq="": # cached binaries and fail with "binary already exists in destination". $installed = $null $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' - $listOutput = @(& cargo {{_anvil_stable_toolchain_args}} install --list) - if ($LASTEXITCODE -ne 0) { - throw "_install-tool-core: cargo install --list failed for '$name'" - } - foreach ($line in $listOutput) { + foreach ($line in (& cargo {{_anvil_stable_toolchain_args}} install --list 2>$null)) { if ($line -match $pattern) { $installed = $Matches[1]; break } } if ($installed) { - $installedVersion = $null - $pinVersion = $null - if ([version]::TryParse($installed, [ref]$installedVersion) -and - [version]::TryParse($version, [ref]$pinVersion) -and - $installedVersion -ge $pinVersion -and - (Get-Command $name -ErrorAction SilentlyContinue)) { - Write-Host "$name >= $version (already satisfied; installed=$installed)" - exit 0 + try { + if (([version]$installed) -ge ([version]$version)) { + Write-Host "$name >= $version (already satisfied; installed=$installed)" + exit 0 + } + } catch { + # Fall through to reinstall when versions don't parse as [version]. } } @@ -7531,17 +7534,21 @@ _install-tool-core name version installer source_prereq="": if ($installer -eq 'binstall') { if (-not (Get-Command cargo-binstall -ErrorAction SilentlyContinue)) { Write-Host ' Bootstrapping cargo-binstall' - & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall --version '=1.21.0' + & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall if ($LASTEXITCODE -ne 0) { Write-Error 'cargo-binstall bootstrap failed' exit $LASTEXITCODE } } - # No credential discovery from gh/git configuration, and no implicit - # compilation before the source-prerequisite check below. - $binstallArgs = @('binstall', '--no-confirm', '--locked', '--disable-strategies', 'compile', '--no-discover-github-token') + # Keep source builds behind Anvil's prerequisite check instead of + # allowing binstall to compile before that check can run. Tools with + # no source prerequisite may still use binstall's compile strategy. + $binstallArgs = @('binstall', '--no-confirm', '--locked') + if ($sourcePrereq) { + $binstallArgs += @('--disable-strategies', 'compile') + } + $binstallArgs += '--no-discover-github-token' $binstallArgs += @($name, '--version', "=$version") - if ($installed) { $binstallArgs += '--force' } & cargo {{_anvil_stable_toolchain_args}} @binstallArgs if ($LASTEXITCODE -eq 0) { exit 0 } Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow @@ -7550,10 +7557,7 @@ _install-tool-core name version installer source_prereq="": & "{{just_executable()}}" $sourcePrereq if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } - $installArgs = @('install', '--locked', $name, '--version', "=$version") - # Repair stale install metadata as well as old versions. - if ($installed) { $installArgs += '--force' } - & cargo {{_anvil_stable_toolchain_args}} @installArgs + & cargo {{_anvil_stable_toolchain_args}} install --locked $name --version "=$version" if ($LASTEXITCODE -ne 0) { Write-Error "$name install FAILED" exit $LASTEXITCODE diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index 561c3dd5e..72f35f642 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -6175,10 +6175,9 @@ anvil-toolchain-stable-install: (_anvil-resolve-stable "install") # or no-op if it is already installed at or above that version. The # `installer` parameter selects between: # - "install" (cargo install --locked, pure-source). Default. -# - "binstall" (anonymous binary download, with a checked source fallback). -# - "ci" (source install, except tools with native source prerequisites, -# which try anonymous binaries first). Avoids release discovery for -# ordinary Rust tools even when prebuilt binaries exist. +# - "binstall" (cargo binstall --no-confirm --locked, with a controlled +# cargo install fallback for tools that declare source +# prerequisites). Bootstraps cargo-binstall itself if not on PATH. # `source_prereq`, when set, runs immediately before a source installation, # including a binstall fallback. [script("pwsh", "-NoProfile")] @@ -6189,23 +6188,11 @@ _install-tool-core name version installer source_prereq="": $installer = '{{installer}}' $sourcePrereq = '{{source_prereq}}' - if ($installer -notin @('install', 'binstall', 'ci')) { - Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install', 'binstall', or 'ci')" + if ($installer -ne 'install' -and $installer -ne 'binstall') { + Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install' or 'binstall')" exit 2 } - # This recipe runs in its own process: API checks in the parent retain - # their credentials, but installers and third-party build scripts do not. - foreach ($key in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN')) { - Remove-Item "Env:$key" -ErrorAction SilentlyContinue - } - foreach ($entry in @(Get-ChildItem Env: | Where-Object Name -Match '^CARGO_REGISTRIES_.*_TOKEN$')) { - Remove-Item "Env:$($entry.Name)" - } - if ($installer -eq 'ci') { - $installer = if ($sourcePrereq) { 'binstall' } else { 'install' } - } - # Already at or above the pin: skip. We don't downgrade tools the # user upgraded for their own reasons; the validate side uses # `installed >= pin`, so newer is fine. The early-exit is also what @@ -6214,22 +6201,17 @@ _install-tool-core name version installer source_prereq="": # cached binaries and fail with "binary already exists in destination". $installed = $null $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' - $listOutput = @(& cargo {{_anvil_stable_toolchain_args}} install --list) - if ($LASTEXITCODE -ne 0) { - throw "_install-tool-core: cargo install --list failed for '$name'" - } - foreach ($line in $listOutput) { + foreach ($line in (& cargo {{_anvil_stable_toolchain_args}} install --list 2>$null)) { if ($line -match $pattern) { $installed = $Matches[1]; break } } if ($installed) { - $installedVersion = $null - $pinVersion = $null - if ([version]::TryParse($installed, [ref]$installedVersion) -and - [version]::TryParse($version, [ref]$pinVersion) -and - $installedVersion -ge $pinVersion -and - (Get-Command $name -ErrorAction SilentlyContinue)) { - Write-Host "$name >= $version (already satisfied; installed=$installed)" - exit 0 + try { + if (([version]$installed) -ge ([version]$version)) { + Write-Host "$name >= $version (already satisfied; installed=$installed)" + exit 0 + } + } catch { + # Fall through to reinstall when versions don't parse as [version]. } } @@ -6237,17 +6219,21 @@ _install-tool-core name version installer source_prereq="": if ($installer -eq 'binstall') { if (-not (Get-Command cargo-binstall -ErrorAction SilentlyContinue)) { Write-Host ' Bootstrapping cargo-binstall' - & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall --version '=1.21.0' + & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall if ($LASTEXITCODE -ne 0) { Write-Error 'cargo-binstall bootstrap failed' exit $LASTEXITCODE } } - # No credential discovery from gh/git configuration, and no implicit - # compilation before the source-prerequisite check below. - $binstallArgs = @('binstall', '--no-confirm', '--locked', '--disable-strategies', 'compile', '--no-discover-github-token') + # Keep source builds behind Anvil's prerequisite check instead of + # allowing binstall to compile before that check can run. Tools with + # no source prerequisite may still use binstall's compile strategy. + $binstallArgs = @('binstall', '--no-confirm', '--locked') + if ($sourcePrereq) { + $binstallArgs += @('--disable-strategies', 'compile') + } + $binstallArgs += '--no-discover-github-token' $binstallArgs += @($name, '--version', "=$version") - if ($installed) { $binstallArgs += '--force' } & cargo {{_anvil_stable_toolchain_args}} @binstallArgs if ($LASTEXITCODE -eq 0) { exit 0 } Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow @@ -6256,10 +6242,7 @@ _install-tool-core name version installer source_prereq="": & "{{just_executable()}}" $sourcePrereq if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } - $installArgs = @('install', '--locked', $name, '--version', "=$version") - # Repair stale install metadata as well as old versions. - if ($installed) { $installArgs += '--force' } - & cargo {{_anvil_stable_toolchain_args}} @installArgs + & cargo {{_anvil_stable_toolchain_args}} install --locked $name --version "=$version" if ($LASTEXITCODE -ne 0) { Write-Error "$name install FAILED" exit $LASTEXITCODE diff --git a/justfiles/anvil/tools.just b/justfiles/anvil/tools.just index 85b441024..4d37d0615 100644 --- a/justfiles/anvil/tools.just +++ b/justfiles/anvil/tools.just @@ -411,10 +411,9 @@ anvil-toolchain-stable-install: (_anvil-resolve-stable "install") # or no-op if it is already installed at or above that version. The # `installer` parameter selects between: # - "install" (cargo install --locked, pure-source). Default. -# - "binstall" (anonymous binary download, with a checked source fallback). -# - "ci" (source install, except tools with native source prerequisites, -# which try anonymous binaries first). Avoids release discovery for -# ordinary Rust tools even when prebuilt binaries exist. +# - "binstall" (cargo binstall --no-confirm --locked, with a controlled +# cargo install fallback for tools that declare source +# prerequisites). Bootstraps cargo-binstall itself if not on PATH. # `source_prereq`, when set, runs immediately before a source installation, # including a binstall fallback. [script("pwsh", "-NoProfile")] @@ -425,23 +424,11 @@ _install-tool-core name version installer source_prereq="": $installer = '{{installer}}' $sourcePrereq = '{{source_prereq}}' - if ($installer -notin @('install', 'binstall', 'ci')) { - Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install', 'binstall', or 'ci')" + if ($installer -ne 'install' -and $installer -ne 'binstall') { + Write-Error "_install-tool-core: unknown installer '$installer' (expected 'install' or 'binstall')" exit 2 } - # This recipe runs in its own process: API checks in the parent retain - # their credentials, but installers and third-party build scripts do not. - foreach ($key in @('GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_ENTERPRISE_TOKEN', 'CARGO_REGISTRY_TOKEN')) { - Remove-Item "Env:$key" -ErrorAction SilentlyContinue - } - foreach ($entry in @(Get-ChildItem Env: | Where-Object Name -Match '^CARGO_REGISTRIES_.*_TOKEN$')) { - Remove-Item "Env:$($entry.Name)" - } - if ($installer -eq 'ci') { - $installer = if ($sourcePrereq) { 'binstall' } else { 'install' } - } - # Already at or above the pin: skip. We don't downgrade tools the # user upgraded for their own reasons; the validate side uses # `installed >= pin`, so newer is fine. The early-exit is also what @@ -450,22 +437,17 @@ _install-tool-core name version installer source_prereq="": # cached binaries and fail with "binary already exists in destination". $installed = $null $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' - $listOutput = @(& cargo {{_anvil_stable_toolchain_args}} install --list) - if ($LASTEXITCODE -ne 0) { - throw "_install-tool-core: cargo install --list failed for '$name'" - } - foreach ($line in $listOutput) { + foreach ($line in (& cargo {{_anvil_stable_toolchain_args}} install --list 2>$null)) { if ($line -match $pattern) { $installed = $Matches[1]; break } } if ($installed) { - $installedVersion = $null - $pinVersion = $null - if ([version]::TryParse($installed, [ref]$installedVersion) -and - [version]::TryParse($version, [ref]$pinVersion) -and - $installedVersion -ge $pinVersion -and - (Get-Command $name -ErrorAction SilentlyContinue)) { - Write-Host "$name >= $version (already satisfied; installed=$installed)" - exit 0 + try { + if (([version]$installed) -ge ([version]$version)) { + Write-Host "$name >= $version (already satisfied; installed=$installed)" + exit 0 + } + } catch { + # Fall through to reinstall when versions don't parse as [version]. } } @@ -473,17 +455,21 @@ _install-tool-core name version installer source_prereq="": if ($installer -eq 'binstall') { if (-not (Get-Command cargo-binstall -ErrorAction SilentlyContinue)) { Write-Host ' Bootstrapping cargo-binstall' - & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall --version '=1.21.0' + & cargo {{_anvil_stable_toolchain_args}} install --locked cargo-binstall if ($LASTEXITCODE -ne 0) { Write-Error 'cargo-binstall bootstrap failed' exit $LASTEXITCODE } } - # No credential discovery from gh/git configuration, and no implicit - # compilation before the source-prerequisite check below. - $binstallArgs = @('binstall', '--no-confirm', '--locked', '--disable-strategies', 'compile', '--no-discover-github-token') + # Keep source builds behind Anvil's prerequisite check instead of + # allowing binstall to compile before that check can run. Tools with + # no source prerequisite may still use binstall's compile strategy. + $binstallArgs = @('binstall', '--no-confirm', '--locked') + if ($sourcePrereq) { + $binstallArgs += @('--disable-strategies', 'compile') + } + $binstallArgs += '--no-discover-github-token' $binstallArgs += @($name, '--version', "=$version") - if ($installed) { $binstallArgs += '--force' } & cargo {{_anvil_stable_toolchain_args}} @binstallArgs if ($LASTEXITCODE -eq 0) { exit 0 } Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow @@ -492,10 +478,7 @@ _install-tool-core name version installer source_prereq="": & "{{just_executable()}}" $sourcePrereq if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } - $installArgs = @('install', '--locked', $name, '--version', "=$version") - # Repair stale install metadata as well as old versions. - if ($installed) { $installArgs += '--force' } - & cargo {{_anvil_stable_toolchain_args}} @installArgs + & cargo {{_anvil_stable_toolchain_args}} install --locked $name --version "=$version" if ($LASTEXITCODE -ne 0) { Write-Error "$name install FAILED" exit $LASTEXITCODE From 312f5f21111afbb95bb10fa09cdd0e12bd73ad83 Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:28:44 +0100 Subject: [PATCH 3/3] docs(anvil): renumber the impact procedure after the setup merge The impact steps were merged from four to three when setup took over the cargo-delta install, but the upload step kept its old ordinal, leaving the list running 1, 2, 4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cargo-anvil/docs/design/github.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cargo-anvil/docs/design/github.md b/crates/cargo-anvil/docs/design/github.md index f540e0a63..ec266e60b 100644 --- a/crates/cargo-anvil/docs/design/github.md +++ b/crates/cargo-anvil/docs/design/github.md @@ -833,7 +833,7 @@ Other groups retain the action's disabled default. `cargo delta impact`, and writes the durable cache under `target/anvil/impact/`: the per-tier `include_.txt` lists (via `_anvil-impact-format`), `impact.json`, and the `snapshots/`. -4. Uploads that whole directory as the `anvil-impact-` artifact +3. Uploads that whole directory as the `anvil-impact-` artifact (`actions/upload-artifact`). ### 6.1 How the impact result propagates to the group jobs