From f4561333aa9da90e24d1300ac564b977ac4670a5 Mon Sep 17 00:00:00 2001 From: Martin Havelka Date: Thu, 3 Sep 2026 13:26:43 +0200 Subject: [PATCH 1/7] fix(cargo-anvil): supply the declared MSRV to the container image build `anvil-msrv-test-setup` resolves the MSRV by scanning the root `Cargo.toml`, and `just anvil-setup` reaches it through the PR tier. The container image is built with no repository source in its context by design, so that scan threw and the install layer failed. No image was produced, which left every `anvil-container` recipe unusable after a cold build. The MSRV is the one version anvil installs that is declared in the source rather than pinned in `versions.just`, and the image already carries the repository's other toolchain declaration, `rust-toolchain.toml`. Close that asymmetry by making the MSRV an image input: `anvil-container-tag` resolves the declared value through a new `_anvil-resolve-stable root-msrv` action and hashes it, the build passes it as `ARG ANVIL_ROOT_MSRV`, and the resolver reads it only when no root manifest is present, so it can never shadow a real declaration. The value is carried rather than the manifest holding it. Copying `Cargo.toml` into the context would rename the image on every dependency edit, obliging a publisher to rebuild and republish for changes that cannot alter a byte the image contains, while `rust-version` moves perhaps once. A repository that declares no MSRV sends `none`, which is an answer; an unset variable is not, so a build that drops the argument stops rather than producing an image silently missing a toolchain it claims to install. Validated with a cold image build on Docker 29.7.1: the build completes and `rustup toolchain list` in the resulting image reports the declared MSRV alongside the pinned stable and nightlies. Contract tests cover manifest precedence over the override, the `none` declaration, the refusal when nothing is declared, and that the tag follows the MSRV while ignoring unrelated manifest edits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80f12d44-5a83-4650-9806-d3e247175858 --- .anvil.lock | 8 +- .anvil/container/Dockerfile | 12 + crates/cargo-anvil/docs/design/containers.md | 24 +- crates/cargo-anvil/docs/implementation.md | 11 + .../anvil/container/Dockerfile.setup.region | 12 + .../templates/justfiles/anvil/container.just | 38 +++- .../templates/justfiles/anvil/tools.just | 64 +++++- crates/cargo-anvil/tests/recipe_contracts.rs | 210 ++++++++++++++++++ .../snapshots/snapshots__ado_backend.snap | 114 +++++++++- .../snapshots/snapshots__github_backend.snap | 114 +++++++++- .../snapshots/snapshots__local_only.snap | 114 +++++++++- justfiles/anvil/container.just | 38 +++- justfiles/anvil/tools.just | 64 +++++- 13 files changed, 788 insertions(+), 35 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index 79fc1184..4d489ddc 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,7 +1,7 @@ version = 1 tool = "anvil" tool_version = "0.6.0" -catalog_checksum = "sha256:b4f90b5873db3987b212783d2e0ed9cc6642e00446875e8b57749d5d67bbb506" +catalog_checksum = "sha256:faba6d3dd32ed4e949ccd2729132e8e761e748a9dfccd0c266f18938cc9501e7" [[file]] path = ".anvil/container/Dockerfile.dockerignore" @@ -169,7 +169,7 @@ checksum = "sha256:6efd7378a2cd0f5d86519bd32fd86f2055a60191187dd77a8842b374b8eb7 [[file]] path = "justfiles/anvil/container.just" -checksum = "sha256:3363992c6c006c649eae3732b2a7b04c2a4925eff369179b128d43380e2800d4" +checksum = "sha256:3bc750b3aff761a5f1966000d4431301f00c967085c4bee6ff49345a8d34c732" [[file]] path = "justfiles/anvil/groups/pr-fast.just" @@ -229,7 +229,7 @@ checksum = "sha256:00453a12cbb34811ee6a2c083dade5f6198575e3b0610f49e4743366326cd [[file]] path = "justfiles/anvil/tools.just" -checksum = "sha256:48b887481a7eb8dc7615367347c93b8a02dd7baafb0ef80ece883f03600285dd" +checksum = "sha256:0a8f8c08daafb60f09d12a6f1859a5130017ec81fc059b07f0117eeb203879ac" [[file]] path = "justfiles/anvil/versions.just" @@ -253,7 +253,7 @@ checksum = "sha256:7b409a9b560c214e10b50f74330fb6f8c0c12c3d83494e0dcf016f2411b50 [[region]] host = ".anvil/container/Dockerfile" id = "anvil-container-setup" -checksum = "sha256:3788845ea3d4c483917954ddf9bccf918bf69ab7c64bbf559baf59d5781c444f" +checksum = "sha256:52211b5e09389699fa4aa3f9d6b0d3274588ba027af1222205c842e3f7c1299d" [[region]] host = ".anvil/container/Dockerfile" diff --git a/.anvil/container/Dockerfile b/.anvil/container/Dockerfile index bcdd9698..9cd9a41d 100644 --- a/.anvil/container/Dockerfile +++ b/.anvil/container/Dockerfile @@ -94,7 +94,19 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. +# `ANVIL_ROOT_MSRV` carries the one version this image cannot read for itself. +# Every other pin travels in `versions.just`, but the MSRV is declared in the +# repository's root `Cargo.toml`, and no source is in this context by design. +# Docker puts a declared `ARG` in the environment of the `RUN` below, which is +# where the MSRV resolver looks when it finds no manifest. It is an `ARG` rather +# than an `ENV` so it does not persist into the finished image: at run time the +# checkout is present and answers for itself. +# +# The build driver always passes it, sending `none` for a repository that +# declares no MSRV. Left unset -- a hand-rolled `docker build`, say -- the +# resolver stops and names the variable rather than installing nothing. WORKDIR /opt/anvil +ARG ANVIL_ROOT_MSRV COPY justfiles ./justfiles COPY rust-toolchain.toml ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ diff --git a/crates/cargo-anvil/docs/design/containers.md b/crates/cargo-anvil/docs/design/containers.md index b4753c5b..ff20c66f 100644 --- a/crates/cargo-anvil/docs/design/containers.md +++ b/crates/cargo-anvil/docs/design/containers.md @@ -133,7 +133,7 @@ never read, rewritten or reordered. | `anvil-container-base-image` | `ARG BASE_IMAGE`, pinned to a digest. | A second `ARG BASE_IMAGE=…` to build on a different base. | | `anvil-container-base` | `FROM`, the version pins for `pwsh`, `just`, `rustup` and `cargo-binstall`, and the `ENV` block. | Anything the first network access needs: a root CA, `http_proxy`, an internal package mirror. | | `anvil-container-tools` | System packages and those four tools. | Libraries a catalog tool needs to compile, for tools `binstall` has no prebuilt binary for. | -| `anvil-container-setup` | `COPY` of the recipe tree, then `just anvil-setup`. | Anything the repository's own checks need at run time. | +| `anvil-container-setup` | `ARG ANVIL_ROOT_MSRV`, `COPY` of the recipe tree, then `just anvil-setup`. | Anything the repository's own checks need at run time. | | `anvil-container-entry` | `ANVIL_IN_CONTAINER`, `WORKDIR`, `CMD`. | — | Each gap sits at the only point in the build where its kind of addition works: a certificate has to land before the @@ -185,6 +185,16 @@ The image installs its tools by running `just anvil-setup`, the same recipe the generated pins. There is no second tool list to keep synchronized, and consequently a tool-pin change renames the image (§4.1). +One version that recipe needs is not in the pins. `anvil-msrv-test-setup` installs the toolchain named by the +repository's declared MSRV, which lives in the root `Cargo.toml` — repository source, which this context deliberately +does not carry. The build therefore resolves the value on the host and passes it as `ARG ANVIL_ROOT_MSRV`, which +Docker places in the environment of the setup `RUN`; the MSRV resolver reads it there whenever it finds no root +manifest. A repository that declares no MSRV sends `none`, which is an answer. An unset variable is not: the resolver +stops and names it, so a build that loses the argument cannot quietly produce an image missing a toolchain it claims +to install. It is an `ARG` rather than an `ENV` so it does not survive into the finished image — at run time the +checkout is mounted and answers for itself, and the variable is consulted only when there is no manifest, so a stale +value can never shadow a real declaration. + `Dockerfile.dockerignore` scopes the build context to `justfiles/anvil/`, `.anvil/container/` and `rust-toolchain.toml`, denying everything else. The recipe tree is copied whole because `just` has to parse it to run `anvil-setup`, and it is hashed whole (§4). `.anvil/container/` is admitted so a gap can `COPY` a file placed beside @@ -204,6 +214,7 @@ define the image. The name derives from the repository directory (§5.1). | every file under `.anvil/container/` | always | | `rust-toolchain.toml` | always | | every file under `justfiles/anvil/` | always | +| the declared root MSRV | always | `.anvil/container/` is hashed by walking it, not as a fixed list of three known files. The Dockerfile is composed, so a repository can `COPY` something from one of its gaps — a root CA, an install script, a patch — and a downstream @@ -225,6 +236,17 @@ into the build. The cost is that editing any recipe renames the image and the next run rebuilds it. That is the correct trade: a tag that can name contents the image does not have makes every guarantee below meaningless. +The declared root MSRV is the one input that is not a file. The image installs that toolchain, so raising it changes +what the image contains and must rename it — but the value is declared in the repository's root `Cargo.toml`, which +is not in the build context (§3) and cannot be, because the image is built without repository source. The build +resolves the value on the host, passes it as `ARG ANVIL_ROOT_MSRV`, and hashes it here. + +The value is hashed rather than the manifest that carries it, and that is a correctness choice rather than a +convenience. `Cargo.toml` is the busiest file in a workspace — every dependency edit touches it — while `rust-version` +moves perhaps once in a repository's life. Admitting the file to the context and the digest would rename the image, and +oblige a publisher to rebuild and republish it, for a long stream of changes that cannot alter a single byte the image +contains. A tag must change when the contents change and not otherwise; hashing the value is what makes that true. + The hook file's **content** is an input, since it determines what the build installs. Its **output** is deliberately excluded: a credential must never influence a tag. diff --git a/crates/cargo-anvil/docs/implementation.md b/crates/cargo-anvil/docs/implementation.md index 1515934f..7a1b14a1 100644 --- a/crates/cargo-anvil/docs/implementation.md +++ b/crates/cargo-anvil/docs/implementation.md @@ -76,6 +76,17 @@ cannot auto-install a compiler during validation. Installation uses the same anchored toolchain-list match and emits a dedicated rustup bootstrap diagnostic when the executable is absent. +`tools.just` additionally accepts the declared root MSRV out of band, through +`ANVIL_ROOT_MSRV`, and exposes it to callers as the `root-msrv` action. This +exists for the container image, which is built without repository source and so +has no root manifest to scan; the variable is read only when the manifest is +absent, so it can never shadow a real declaration. `versions.just` does not +mirror it, and the divergence is deliberate rather than drift: its selector +returns before the manifest scan whenever a root toolchain file is present, and +the image requires one, so the branch is unreachable there. The scanners +themselves — accepted syntax and `workspace.package`-before-`package` +precedence — stay identical and remain bound by the rule above. + Setup dependencies, rather than the cloud templates, route provisioning. Cargo-tool installers, default-component installers, and stable-only setup leaves depend on `anvil-toolchain-stable-install`; group and tier fan-out lets diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region b/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region index f98cc0ca..f6742787 100644 --- a/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region @@ -14,7 +14,19 @@ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. +# `ANVIL_ROOT_MSRV` carries the one version this image cannot read for itself. +# Every other pin travels in `versions.just`, but the MSRV is declared in the +# repository's root `Cargo.toml`, and no source is in this context by design. +# Docker puts a declared `ARG` in the environment of the `RUN` below, which is +# where the MSRV resolver looks when it finds no manifest. It is an `ARG` rather +# than an `ENV` so it does not persist into the finished image: at run time the +# checkout is present and answers for itself. +# +# The build driver always passes it, sending `none` for a repository that +# declares no MSRV. Left unset -- a hand-rolled `docker build`, say -- the +# resolver stops and names the variable rather than installing nothing. WORKDIR /opt/anvil +ARG ANVIL_ROOT_MSRV COPY justfiles ./justfiles COPY rust-toolchain.toml ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ diff --git a/crates/cargo-anvil/templates/justfiles/anvil/container.just b/crates/cargo-anvil/templates/justfiles/anvil/container.just index 6e3a91d8..ae933335 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/container.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/container.just @@ -143,11 +143,11 @@ _anvil-container-dockerfile: # Print the exec image reference for the current inputs, without building it. # # The tag is a SHA-256 over the image's declared inputs: the Dockerfile and its -# ignore file, the pinned toolchain, the optional hook, and the whole generated +# ignore file, the pinned toolchain, the optional hook, the whole generated # recipe tree -- because the image installs its tools by running # `just anvil-setup`, whose dependency chain reaches the tier, group, check and -# tool recipes alike. Editing any of them can change what the image contains, so -# any of them can rename it. +# tool recipes alike -- and the declared root MSRV. Editing any of them can +# change what the image contains, so any of them can rename it. # # This is the only recipe that computes the reference; everything else asks it. # It is public because a publisher needs the tag before there is an image to @@ -166,6 +166,21 @@ anvil-container-tag: $dockerfile = "$dockerfile".Trim() $hookRel = '.anvil/container/hooks.ps1' + # The declared root MSRV is an input the build context cannot carry. The + # image installs that toolchain, so a bump changes what the image contains + # and has to rename it -- exactly what every other input here is hashed for. + # + # The value, not the manifest it comes from. `Cargo.toml` would be the + # obvious file to admit and hash, but it is the busiest file in a workspace: + # dependency edits touch it constantly while `rust-version` moves perhaps + # once in a repository's life. Hashing the file would rename the image, and + # oblige a publisher to rebuild and republish it, for a stream of changes + # that cannot alter a single byte the image contains. Hashing the value + # renames it exactly when the contents differ. + $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $rootMsrv = "$rootMsrv".Trim() + # Both named rather than left to the walk, so that either one going missing # is a hard failure at the check below. The walk cannot notice an absent # file, and the ignore file's absence is the worse of the two: the build @@ -398,6 +413,15 @@ anvil-container-tag: # would be a MethodNotFound at tag time, before anything useful happened. $sha = [System.Security.Cryptography.SHA256]::Create() try { + # Framed with its own record tag, ahead of the files and in a fixed + # position, so it cannot be confused with a file named `msrv` and the + # order of the stream stays a property of the code rather than of the + # filesystem. Length-prefixed like every other record, for the same + # reason: the stream has to be self-delimiting. + $msrvBytes = [System.Text.Encoding]::UTF8.GetBytes($rootMsrv) + $msrvHeader = [System.Text.Encoding]::UTF8.GetBytes('msrv ' + $msrvBytes.Length + ' ') + [void]$sha.TransformBlock($msrvHeader, 0, $msrvHeader.Length, $null, 0) + [void]$sha.TransformBlock($msrvBytes, 0, $msrvBytes.Length, $null, 0) foreach ($rel in $ordered) { $path = Join-Path $repoRoot $rel if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { @@ -637,6 +661,14 @@ _anvil-container-image: # It also keeps the identity scheme honest: without this, two hosts of # different architecture compute the same tag for different images. $buildCmd = @('build', '--platform', 'linux/amd64', '--file', "$engineRoot/$dockerfile", '--tag', $image) + # The one input the context cannot carry, and the same value the tag was + # computed from -- resolved here rather than passed down so the two + # cannot drift apart silently. `none` is sent verbatim for a repository + # that declares no MSRV: the resolver in the image treats it as an + # answer, and stops if it arrives with none at all. + $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $buildCmd += @('--build-arg', "ANVIL_ROOT_MSRV=$("$rootMsrv".Trim())") # BuildKit reads `.dockerignore` on its own; buildah reads # only a context-root ignore file and needs to be pointed at ours. Named # rather than probed, because the flag is rejected outright by the engine diff --git a/crates/cargo-anvil/templates/justfiles/anvil/tools.just b/crates/cargo-anvil/templates/justfiles/anvil/tools.just index 8e942948..32ecd984 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/tools.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/tools.just @@ -185,7 +185,8 @@ _anvil-resolve-stable action="install": 'install', 'validate-workspace-msrv', 'msrv', - 'install-msrv' + 'install-msrv', + 'root-msrv' ) if ($action -notin $validActions) { Write-Error "_anvil-resolve-stable: unknown action '$action'" @@ -201,10 +202,34 @@ _anvil-resolve-stable action="install": ) } - function Get-RootMsrv([switch] $AllowMissing) { + # The declared root MSRV, or $null when the repository declares none. + # + # Every other version this tree installs is pinned in versions.just, which + # travels with the recipes. The MSRV is the one toolchain declaration that + # lives in the source instead, and the container image is built with no + # source in its context on purpose -- the checkout arrives at run time, + # under a different root. So when the manifest is absent the declaration is + # taken from ANVIL_ROOT_MSRV, which the image build passes in. + # + # `none` is a declaration, not an absence: it says the repository has no + # MSRV. An unset variable with no manifest stays a hard error, so a build + # that fails to pass the value cannot quietly produce an image with no MSRV + # toolchain and leave every later run to discover that for itself. + # + # The variable is only consulted when there is no manifest, so it cannot + # shadow one. Inside the image the checkout is the working directory and + # answers for itself; nothing stale can win over a real declaration. + function Get-RootMsrvDeclaration { $manifestPath = Join-Path $repoRoot 'Cargo.toml' if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { - throw "anvil: Cargo.toml not found at repository root '$repoRoot'" + if ([string]::IsNullOrWhiteSpace($env:ANVIL_ROOT_MSRV)) { + throw "anvil: Cargo.toml not found at repository root '$repoRoot' and ANVIL_ROOT_MSRV is unset; a tree without repository source must be told the declared MSRV, or 'none' if the repository declares no MSRV" + } + $declared = $env:ANVIL_ROOT_MSRV.Trim() + if ($declared -eq 'none') { + return $null + } + return $declared } # This bootstrap scan must choose Cargo before Cargo is available to @@ -230,6 +255,14 @@ _anvil-resolve-stable action="install": if ($values.ContainsKey('package')) { return $values['package'] } + return $null + } + + function Get-RootMsrv([switch] $AllowMissing) { + $declared = Get-RootMsrvDeclaration + if (-not [string]::IsNullOrWhiteSpace($declared)) { + return $declared + } if ($AllowMissing) { return $null @@ -255,6 +288,15 @@ _anvil-resolve-stable action="install": function Assert-WorkspaceMsrvCompatibility { $manifestPath = Join-Path $repoRoot 'Cargo.toml' + # Named here rather than left to cargo. This check reads every member's + # resolved rust-version, so it needs the manifest itself, not just the + # root MSRV that ANVIL_ROOT_MSRV can stand in for. Without this, a tree + # carrying the variable but no source would reach `cargo metadata` and + # fail with a path error that says nothing about why the source is + # absent. + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw "anvil: Cargo.toml not found at repository root '$repoRoot'; workspace MSRV validation reads every member manifest and cannot run without repository source" + } $rootMsrv = Get-RootMsrv if (-not (Test-ToolchainInstalled $rootMsrv)) { throw "anvil: root MSRV toolchain '$rootMsrv' is not installed; run 'just anvil-toolchain-stable-install' before validating prerequisites" @@ -340,6 +382,22 @@ _anvil-resolve-stable action="install": exit 0 } + # The declared root MSRV, spoken as the image build's vocabulary rather than + # as presence and absence: a version, or `none`. Total on purpose, because + # the caller feeds it into the container image tag, where an empty answer + # and an unasked question must not hash alike. + # + # Deliberately the *declared* value, not `Get-MsrvSelection`'s. The mapping + # through ANVIL_MSRV_TOOLCHAIN names a toolchain provisioned on the host for + # an environment that replaces public rustup; the image has public rustup + # and installs the declared version itself, so baking a host mapping into it + # would name a toolchain that does not exist there. + if ($action -eq 'root-msrv') { + $declared = Get-RootMsrv -AllowMissing + if ([string]::IsNullOrWhiteSpace($declared)) { 'none' } else { $declared } + exit 0 + } + if ($action -in @('msrv', 'install-msrv')) { $msrvSelection = Get-MsrvSelection if ($null -eq $msrvSelection) { diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index 7b1f8220..dbdc600b 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -32,6 +32,8 @@ const MUTANTS_DIFF: &str = include_str!("../templates/justfiles/anvil/checks/mut const VERSIONS: &str = include_str!("../templates/justfiles/anvil/versions.just"); const REGENERATE_WORKFLOW: &str = include_str!("../../../.github/workflows/regenerate-check.yml"); const CONTAINER: &str = include_str!("../templates/justfiles/anvil/container.just"); +const CONTAINER_SETUP_REGION: &str = include_str!("../templates/anvil/container/Dockerfile.setup.region"); +const CONTAINER_DOCKERIGNORE: &str = include_str!("../templates/anvil/container/Dockerfile.dockerignore"); // Any nonzero value works; naming it prevents tests from implying an external exit-code contract. const ARBITRARY_FAILURE_EXIT: &str = "23"; @@ -250,6 +252,21 @@ fn fixture(imports: &[(&str, &str)], dependency_recipes: &[&str]) -> TempDir { tmp } +/// `anvil-container-tag` resolves the declared root MSRV through +/// `_anvil-resolve-stable`, which the container fixtures do not import. Stubbing +/// it keeps them focused on the digest, and lets a case vary the value the tag +/// frames without standing up a manifest. +fn stub_msrv_resolver(root: &Path) { + let justfile_path = root.join("Justfile"); + let mut justfile = fs::read_to_string(&justfile_path).unwrap(); + justfile.push_str( + "\n[script(\"pwsh\", \"-NoProfile\")]\n\ + _anvil-resolve-stable action:\n\ + \x20 if ($env:FAKE_ROOT_MSRV) { Write-Output $env:FAKE_ROOT_MSRV } else { Write-Output 'none' }\n", + ); + write(&justfile_path, &justfile); +} + fn path_with_fake_bin(root: &Path) -> OsString { let mut paths = vec![root.join("fake-bin")]; paths.extend(std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())); @@ -274,6 +291,10 @@ fn run_just(root: &Path, arguments: &[&str], environment: &[(&str, &OsStr)]) -> // silently short-circuit the recipe before it did anything. A test that // cares about either value passes it explicitly below. command.env_remove("ANVIL_IMPACT"); + // Same hazard, one resolver down: this names the MSRV for a tree with no + // root manifest, and a fixture that inherited a developer's exported value + // would resolve differently from a clean checkout. + command.env_remove("ANVIL_ROOT_MSRV"); for key in std::env::vars_os().map(|(key, _)| key) { if key.to_string_lossy().starts_with("ANVIL_INCLUDE_") { command.env_remove(key); @@ -405,6 +426,147 @@ fn msrv_test_propagates_nested_just_failures() { ); } +// The container image is built with no repository source in its context, so the +// MSRV -- the one version anvil installs that is declared in `Cargo.toml` +// rather than pinned in `versions.just` -- has to reach the build another way. +// These cover the contract that carries it: the manifest always wins where +// there is one, the environment answers only where there is none, and a tree +// that is told nothing refuses rather than silently installing no toolchain. +#[test] +fn root_msrv_prefers_the_manifest_over_the_container_override() { + if !tools_available() { + return; + } + let tmp = fixture(&[("versions.just", VERSIONS), ("tools.just", TOOLS)], &[]); + + let output = run_just( + tmp.path(), + &["_anvil-resolve-stable", "root-msrv"], + &[("ANVIL_ROOT_MSRV", OsStr::new("1.60.0"))], + ); + + assert!( + output.status.success(), + "root-msrv should resolve from the manifest:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "1.97", + "a real declaration must never be shadowed by the value the image build passes in" + ); +} + +#[test] +fn root_msrv_reports_none_when_the_repository_declares_no_msrv() { + if !tools_available() { + return; + } + let tmp = fixture(&[("versions.just", VERSIONS), ("tools.just", TOOLS)], &[]); + write( + &tmp.path().join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n", + ); + + let output = run_just(tmp.path(), &["_anvil-resolve-stable", "root-msrv"], &[]); + + assert!( + output.status.success(), + "root-msrv must answer for a repository with no MSRV:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "none", + "the answer must be total, because the caller hashes it into the container image tag" + ); +} + +#[test] +fn sourceless_tree_resolves_the_msrv_the_image_build_passes_in() { + if !tools_available() { + return; + } + let tmp = fixture(&[("versions.just", VERSIONS), ("tools.just", TOOLS)], &[]); + fs::remove_file(tmp.path().join("Cargo.toml")).unwrap(); + + let declared = run_just( + tmp.path(), + &["_anvil-resolve-stable", "msrv"], + &[("ANVIL_ROOT_MSRV", OsStr::new("1.93.1"))], + ); + assert!( + declared.status.success(), + "a tree told its MSRV must resolve it:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&declared.stdout), + String::from_utf8_lossy(&declared.stderr) + ); + assert_eq!(String::from_utf8_lossy(&declared.stdout).trim(), "1.93.1"); + + // `none` is an answer, so the install is a clean no-op rather than a + // failure: the repository being built genuinely has no MSRV to install. + let none = run_just( + tmp.path(), + &["_anvil-resolve-stable", "install-msrv"], + &[("ANVIL_ROOT_MSRV", OsStr::new("none"))], + ); + assert!( + none.status.success(), + "'none' must be accepted as a declaration:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&none.stdout), + String::from_utf8_lossy(&none.stderr) + ); +} + +#[test] +fn sourceless_tree_told_nothing_refuses_instead_of_installing_no_toolchain() { + if !tools_available() { + return; + } + let tmp = fixture(&[("versions.just", VERSIONS), ("tools.just", TOOLS)], &[]); + fs::remove_file(tmp.path().join("Cargo.toml")).unwrap(); + + let output = run_just(tmp.path(), &["_anvil-resolve-stable", "install-msrv"], &[]); + + assert_failed(&output, "MSRV install in a tree with neither a manifest nor a declared MSRV"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("ANVIL_ROOT_MSRV"), + "the refusal must name the variable that would have answered, or a build that drops \ + the argument produces an image silently missing the MSRV toolchain:\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn container_build_carries_the_msrv_as_a_value_rather_than_the_manifest() { + assert!( + CONTAINER_SETUP_REGION.contains("ARG ANVIL_ROOT_MSRV"), + "the setup region must declare the build argument the MSRV resolver reads" + ); + assert!( + !CONTAINER_SETUP_REGION.contains("ENV ANVIL_ROOT_MSRV"), + "it must not persist into the finished image, where the mounted checkout answers instead" + ); + assert!( + CONTAINER.contains("'--build-arg', \"ANVIL_ROOT_MSRV=$(\"$rootMsrv\".Trim())\""), + "the build driver must pass the resolved MSRV to the engine" + ); + assert!( + CONTAINER.contains("'msrv ' + $msrvBytes.Length"), + "the image tag must hash the MSRV, because the image installs that toolchain" + ); + // The manifest is the obvious thing to copy and hash, and it is the wrong + // one: every dependency edit touches it while `rust-version` moves perhaps + // once, so admitting it would rebuild and rename the image for a long + // stream of changes that cannot alter a byte the image contains. + assert!( + !CONTAINER_DOCKERIGNORE.contains("!Cargo.toml"), + "the build context must not admit the root manifest" + ); +} + #[test] fn impact_format_resolves_directory_aliases_and_fails_hard() { if !tools_available() { @@ -1614,6 +1776,7 @@ fn the_image_tag_follows_the_executable_bit() { write(&root.join(".anvil/container/Dockerfile"), "FROM scratch\n"); write(&root.join(".anvil/container/Dockerfile.dockerignore"), "*\n!justfiles\n"); write(&root.join("justfiles/anvil/setup.sh"), "echo hello\n"); + stub_msrv_resolver(root); write( &root.join("fake-bin/git.ps1"), "if ($args -contains 'ls-files' -and $env:FAKE_UNTRACKED -ne '1') {\n \ @@ -1669,6 +1832,51 @@ fn the_image_tag_follows_the_executable_bit() { assert_eq!(plain, tag("0"), "the tag must depend on the inputs alone"); } +/// The image installs the toolchain named by the repository's declared MSRV, so +/// raising it changes what the image contains and must rename it. The value +/// reaches the digest out of band, because the manifest declaring it is not in +/// the build context and deliberately stays out: it is the busiest file in a +/// workspace, and hashing it would rename the image for a long stream of +/// dependency edits that cannot alter a byte the image contains. +#[test] +fn the_image_tag_follows_the_declared_msrv() { + if !tools_available() { + return; + } + let tmp = fixture(&[("container.just", CONTAINER)], &[]); + let root = tmp.path(); + write(&root.join("rust-toolchain.toml"), "[toolchain]\nchannel = \"stable\"\n"); + write(&root.join(".anvil/container/Dockerfile"), "FROM scratch\n"); + write(&root.join(".anvil/container/Dockerfile.dockerignore"), "*\n!justfiles\n"); + write(&root.join("justfiles/anvil/mod.just"), "# recipes\n"); + stub_msrv_resolver(root); + write(&root.join("fake-bin/git.ps1"), "exit 0\n"); + + let tag = |msrv: &str| { + let output = run_just(root, &["anvil-container-tag"], &[("FAKE_ROOT_MSRV", OsStr::new(msrv))]); + assert!( + output.status.success(), + "computing the tag failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_owned() + }; + + let declared = tag("1.93.1"); + assert_ne!( + declared, + tag("1.94.0"), + "an MSRV bump installs a different toolchain, so it must rename the image" + ); + assert_ne!( + declared, + tag("none"), + "a repository that declares no MSRV gets an image with no MSRV toolchain in it" + ); + assert_eq!(declared, tag("1.93.1"), "the tag must depend on the inputs alone"); +} + /// The tag is computed from the index while the build copies the working tree, /// so the two have to agree about the executable bit. Where they do not, the /// reference names an image the build does not produce, and the run stops @@ -1689,6 +1897,7 @@ fn a_working_tree_mode_the_tag_did_not_frame_stops_the_run() { write(&root.join(".anvil/container/Dockerfile"), "FROM scratch\n"); write(&root.join(".anvil/container/Dockerfile.dockerignore"), "*\n!justfiles\n"); write(&root.join("justfiles/anvil/setup.sh"), "echo hello\n"); + stub_msrv_resolver(root); // The digest frames this path from `ls-files --stage`, which reports // 100644 in every case below -- including the intent-to-add ones, where // the raw index mode is zero but the placeholder is a real mode. @@ -1773,6 +1982,7 @@ fn a_link_among_the_image_inputs_is_refused() { ] { let tmp = fixture(&[("container.just", CONTAINER)], &[]); let root = tmp.path(); + stub_msrv_resolver(root); write(&root.join("elsewhere/target.just"), "# shared\n"); write(&root.join("elsewhere/Dockerfile"), "FROM scratch\n"); // Everything the tag needs, except whatever this case replaces with a diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index bbcffad6..7be430f4 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -96,7 +96,19 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. +# `ANVIL_ROOT_MSRV` carries the one version this image cannot read for itself. +# Every other pin travels in `versions.just`, but the MSRV is declared in the +# repository's root `Cargo.toml`, and no source is in this context by design. +# Docker puts a declared `ARG` in the environment of the `RUN` below, which is +# where the MSRV resolver looks when it finds no manifest. It is an `ARG` rather +# than an `ENV` so it does not persist into the finished image: at run time the +# checkout is present and answers for itself. +# +# The build driver always passes it, sending `none` for a repository that +# declares no MSRV. Left unset -- a hand-rolled `docker build`, say -- the +# resolver stops and names the variable rather than installing nothing. WORKDIR /opt/anvil +ARG ANVIL_ROOT_MSRV COPY justfiles ./justfiles COPY rust-toolchain.toml ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ @@ -3636,11 +3648,11 @@ _anvil-container-dockerfile: # Print the exec image reference for the current inputs, without building it. # # The tag is a SHA-256 over the image's declared inputs: the Dockerfile and its -# ignore file, the pinned toolchain, the optional hook, and the whole generated +# ignore file, the pinned toolchain, the optional hook, the whole generated # recipe tree -- because the image installs its tools by running # `just anvil-setup`, whose dependency chain reaches the tier, group, check and -# tool recipes alike. Editing any of them can change what the image contains, so -# any of them can rename it. +# tool recipes alike -- and the declared root MSRV. Editing any of them can +# change what the image contains, so any of them can rename it. # # This is the only recipe that computes the reference; everything else asks it. # It is public because a publisher needs the tag before there is an image to @@ -3659,6 +3671,21 @@ anvil-container-tag: $dockerfile = "$dockerfile".Trim() $hookRel = '.anvil/container/hooks.ps1' + # The declared root MSRV is an input the build context cannot carry. The + # image installs that toolchain, so a bump changes what the image contains + # and has to rename it -- exactly what every other input here is hashed for. + # + # The value, not the manifest it comes from. `Cargo.toml` would be the + # obvious file to admit and hash, but it is the busiest file in a workspace: + # dependency edits touch it constantly while `rust-version` moves perhaps + # once in a repository's life. Hashing the file would rename the image, and + # oblige a publisher to rebuild and republish it, for a stream of changes + # that cannot alter a single byte the image contains. Hashing the value + # renames it exactly when the contents differ. + $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $rootMsrv = "$rootMsrv".Trim() + # Both named rather than left to the walk, so that either one going missing # is a hard failure at the check below. The walk cannot notice an absent # file, and the ignore file's absence is the worse of the two: the build @@ -3891,6 +3918,15 @@ anvil-container-tag: # would be a MethodNotFound at tag time, before anything useful happened. $sha = [System.Security.Cryptography.SHA256]::Create() try { + # Framed with its own record tag, ahead of the files and in a fixed + # position, so it cannot be confused with a file named `msrv` and the + # order of the stream stays a property of the code rather than of the + # filesystem. Length-prefixed like every other record, for the same + # reason: the stream has to be self-delimiting. + $msrvBytes = [System.Text.Encoding]::UTF8.GetBytes($rootMsrv) + $msrvHeader = [System.Text.Encoding]::UTF8.GetBytes('msrv ' + $msrvBytes.Length + ' ') + [void]$sha.TransformBlock($msrvHeader, 0, $msrvHeader.Length, $null, 0) + [void]$sha.TransformBlock($msrvBytes, 0, $msrvBytes.Length, $null, 0) foreach ($rel in $ordered) { $path = Join-Path $repoRoot $rel if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { @@ -4130,6 +4166,14 @@ _anvil-container-image: # It also keeps the identity scheme honest: without this, two hosts of # different architecture compute the same tag for different images. $buildCmd = @('build', '--platform', 'linux/amd64', '--file', "$engineRoot/$dockerfile", '--tag', $image) + # The one input the context cannot carry, and the same value the tag was + # computed from -- resolved here rather than passed down so the two + # cannot drift apart silently. `none` is sent verbatim for a repository + # that declares no MSRV: the resolver in the image treats it as an + # answer, and stops if it arrives with none at all. + $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $buildCmd += @('--build-arg', "ANVIL_ROOT_MSRV=$("$rootMsrv".Trim())") # BuildKit reads `.dockerignore` on its own; buildah reads # only a context-root ignore file and needs to be pointed at ours. Named # rather than probed, because the flag is rejected outright by the engine @@ -6233,7 +6277,8 @@ _anvil-resolve-stable action="install": 'install', 'validate-workspace-msrv', 'msrv', - 'install-msrv' + 'install-msrv', + 'root-msrv' ) if ($action -notin $validActions) { Write-Error "_anvil-resolve-stable: unknown action '$action'" @@ -6249,10 +6294,34 @@ _anvil-resolve-stable action="install": ) } - function Get-RootMsrv([switch] $AllowMissing) { + # The declared root MSRV, or $null when the repository declares none. + # + # Every other version this tree installs is pinned in versions.just, which + # travels with the recipes. The MSRV is the one toolchain declaration that + # lives in the source instead, and the container image is built with no + # source in its context on purpose -- the checkout arrives at run time, + # under a different root. So when the manifest is absent the declaration is + # taken from ANVIL_ROOT_MSRV, which the image build passes in. + # + # `none` is a declaration, not an absence: it says the repository has no + # MSRV. An unset variable with no manifest stays a hard error, so a build + # that fails to pass the value cannot quietly produce an image with no MSRV + # toolchain and leave every later run to discover that for itself. + # + # The variable is only consulted when there is no manifest, so it cannot + # shadow one. Inside the image the checkout is the working directory and + # answers for itself; nothing stale can win over a real declaration. + function Get-RootMsrvDeclaration { $manifestPath = Join-Path $repoRoot 'Cargo.toml' if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { - throw "anvil: Cargo.toml not found at repository root '$repoRoot'" + if ([string]::IsNullOrWhiteSpace($env:ANVIL_ROOT_MSRV)) { + throw "anvil: Cargo.toml not found at repository root '$repoRoot' and ANVIL_ROOT_MSRV is unset; a tree without repository source must be told the declared MSRV, or 'none' if the repository declares no MSRV" + } + $declared = $env:ANVIL_ROOT_MSRV.Trim() + if ($declared -eq 'none') { + return $null + } + return $declared } # This bootstrap scan must choose Cargo before Cargo is available to @@ -6278,6 +6347,14 @@ _anvil-resolve-stable action="install": if ($values.ContainsKey('package')) { return $values['package'] } + return $null + } + + function Get-RootMsrv([switch] $AllowMissing) { + $declared = Get-RootMsrvDeclaration + if (-not [string]::IsNullOrWhiteSpace($declared)) { + return $declared + } if ($AllowMissing) { return $null @@ -6303,6 +6380,15 @@ _anvil-resolve-stable action="install": function Assert-WorkspaceMsrvCompatibility { $manifestPath = Join-Path $repoRoot 'Cargo.toml' + # Named here rather than left to cargo. This check reads every member's + # resolved rust-version, so it needs the manifest itself, not just the + # root MSRV that ANVIL_ROOT_MSRV can stand in for. Without this, a tree + # carrying the variable but no source would reach `cargo metadata` and + # fail with a path error that says nothing about why the source is + # absent. + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw "anvil: Cargo.toml not found at repository root '$repoRoot'; workspace MSRV validation reads every member manifest and cannot run without repository source" + } $rootMsrv = Get-RootMsrv if (-not (Test-ToolchainInstalled $rootMsrv)) { throw "anvil: root MSRV toolchain '$rootMsrv' is not installed; run 'just anvil-toolchain-stable-install' before validating prerequisites" @@ -6388,6 +6474,22 @@ _anvil-resolve-stable action="install": exit 0 } + # The declared root MSRV, spoken as the image build's vocabulary rather than + # as presence and absence: a version, or `none`. Total on purpose, because + # the caller feeds it into the container image tag, where an empty answer + # and an unasked question must not hash alike. + # + # Deliberately the *declared* value, not `Get-MsrvSelection`'s. The mapping + # through ANVIL_MSRV_TOOLCHAIN names a toolchain provisioned on the host for + # an environment that replaces public rustup; the image has public rustup + # and installs the declared version itself, so baking a host mapping into it + # would name a toolchain that does not exist there. + if ($action -eq 'root-msrv') { + $declared = Get-RootMsrv -AllowMissing + if ([string]::IsNullOrWhiteSpace($declared)) { 'none' } else { $declared } + exit 0 + } + if ($action -in @('msrv', 'install-msrv')) { $msrvSelection = Get-MsrvSelection if ($null -eq $msrvSelection) { diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index b9a3237a..78548c41 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -96,7 +96,19 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. +# `ANVIL_ROOT_MSRV` carries the one version this image cannot read for itself. +# Every other pin travels in `versions.just`, but the MSRV is declared in the +# repository's root `Cargo.toml`, and no source is in this context by design. +# Docker puts a declared `ARG` in the environment of the `RUN` below, which is +# where the MSRV resolver looks when it finds no manifest. It is an `ARG` rather +# than an `ENV` so it does not persist into the finished image: at run time the +# checkout is present and answers for itself. +# +# The build driver always passes it, sending `none` for a repository that +# declares no MSRV. Left unset -- a hand-rolled `docker build`, say -- the +# resolver stops and names the variable rather than installing nothing. WORKDIR /opt/anvil +ARG ANVIL_ROOT_MSRV COPY justfiles ./justfiles COPY rust-toolchain.toml ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ @@ -3622,11 +3634,11 @@ _anvil-container-dockerfile: # Print the exec image reference for the current inputs, without building it. # # The tag is a SHA-256 over the image's declared inputs: the Dockerfile and its -# ignore file, the pinned toolchain, the optional hook, and the whole generated +# ignore file, the pinned toolchain, the optional hook, the whole generated # recipe tree -- because the image installs its tools by running # `just anvil-setup`, whose dependency chain reaches the tier, group, check and -# tool recipes alike. Editing any of them can change what the image contains, so -# any of them can rename it. +# tool recipes alike -- and the declared root MSRV. Editing any of them can +# change what the image contains, so any of them can rename it. # # This is the only recipe that computes the reference; everything else asks it. # It is public because a publisher needs the tag before there is an image to @@ -3645,6 +3657,21 @@ anvil-container-tag: $dockerfile = "$dockerfile".Trim() $hookRel = '.anvil/container/hooks.ps1' + # The declared root MSRV is an input the build context cannot carry. The + # image installs that toolchain, so a bump changes what the image contains + # and has to rename it -- exactly what every other input here is hashed for. + # + # The value, not the manifest it comes from. `Cargo.toml` would be the + # obvious file to admit and hash, but it is the busiest file in a workspace: + # dependency edits touch it constantly while `rust-version` moves perhaps + # once in a repository's life. Hashing the file would rename the image, and + # oblige a publisher to rebuild and republish it, for a stream of changes + # that cannot alter a single byte the image contains. Hashing the value + # renames it exactly when the contents differ. + $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $rootMsrv = "$rootMsrv".Trim() + # Both named rather than left to the walk, so that either one going missing # is a hard failure at the check below. The walk cannot notice an absent # file, and the ignore file's absence is the worse of the two: the build @@ -3877,6 +3904,15 @@ anvil-container-tag: # would be a MethodNotFound at tag time, before anything useful happened. $sha = [System.Security.Cryptography.SHA256]::Create() try { + # Framed with its own record tag, ahead of the files and in a fixed + # position, so it cannot be confused with a file named `msrv` and the + # order of the stream stays a property of the code rather than of the + # filesystem. Length-prefixed like every other record, for the same + # reason: the stream has to be self-delimiting. + $msrvBytes = [System.Text.Encoding]::UTF8.GetBytes($rootMsrv) + $msrvHeader = [System.Text.Encoding]::UTF8.GetBytes('msrv ' + $msrvBytes.Length + ' ') + [void]$sha.TransformBlock($msrvHeader, 0, $msrvHeader.Length, $null, 0) + [void]$sha.TransformBlock($msrvBytes, 0, $msrvBytes.Length, $null, 0) foreach ($rel in $ordered) { $path = Join-Path $repoRoot $rel if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { @@ -4116,6 +4152,14 @@ _anvil-container-image: # It also keeps the identity scheme honest: without this, two hosts of # different architecture compute the same tag for different images. $buildCmd = @('build', '--platform', 'linux/amd64', '--file', "$engineRoot/$dockerfile", '--tag', $image) + # The one input the context cannot carry, and the same value the tag was + # computed from -- resolved here rather than passed down so the two + # cannot drift apart silently. `none` is sent verbatim for a repository + # that declares no MSRV: the resolver in the image treats it as an + # answer, and stops if it arrives with none at all. + $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $buildCmd += @('--build-arg', "ANVIL_ROOT_MSRV=$("$rootMsrv".Trim())") # BuildKit reads `.dockerignore` on its own; buildah reads # only a context-root ignore file and needs to be pointed at ours. Named # rather than probed, because the flag is rejected outright by the engine @@ -6219,7 +6263,8 @@ _anvil-resolve-stable action="install": 'install', 'validate-workspace-msrv', 'msrv', - 'install-msrv' + 'install-msrv', + 'root-msrv' ) if ($action -notin $validActions) { Write-Error "_anvil-resolve-stable: unknown action '$action'" @@ -6235,10 +6280,34 @@ _anvil-resolve-stable action="install": ) } - function Get-RootMsrv([switch] $AllowMissing) { + # The declared root MSRV, or $null when the repository declares none. + # + # Every other version this tree installs is pinned in versions.just, which + # travels with the recipes. The MSRV is the one toolchain declaration that + # lives in the source instead, and the container image is built with no + # source in its context on purpose -- the checkout arrives at run time, + # under a different root. So when the manifest is absent the declaration is + # taken from ANVIL_ROOT_MSRV, which the image build passes in. + # + # `none` is a declaration, not an absence: it says the repository has no + # MSRV. An unset variable with no manifest stays a hard error, so a build + # that fails to pass the value cannot quietly produce an image with no MSRV + # toolchain and leave every later run to discover that for itself. + # + # The variable is only consulted when there is no manifest, so it cannot + # shadow one. Inside the image the checkout is the working directory and + # answers for itself; nothing stale can win over a real declaration. + function Get-RootMsrvDeclaration { $manifestPath = Join-Path $repoRoot 'Cargo.toml' if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { - throw "anvil: Cargo.toml not found at repository root '$repoRoot'" + if ([string]::IsNullOrWhiteSpace($env:ANVIL_ROOT_MSRV)) { + throw "anvil: Cargo.toml not found at repository root '$repoRoot' and ANVIL_ROOT_MSRV is unset; a tree without repository source must be told the declared MSRV, or 'none' if the repository declares no MSRV" + } + $declared = $env:ANVIL_ROOT_MSRV.Trim() + if ($declared -eq 'none') { + return $null + } + return $declared } # This bootstrap scan must choose Cargo before Cargo is available to @@ -6264,6 +6333,14 @@ _anvil-resolve-stable action="install": if ($values.ContainsKey('package')) { return $values['package'] } + return $null + } + + function Get-RootMsrv([switch] $AllowMissing) { + $declared = Get-RootMsrvDeclaration + if (-not [string]::IsNullOrWhiteSpace($declared)) { + return $declared + } if ($AllowMissing) { return $null @@ -6289,6 +6366,15 @@ _anvil-resolve-stable action="install": function Assert-WorkspaceMsrvCompatibility { $manifestPath = Join-Path $repoRoot 'Cargo.toml' + # Named here rather than left to cargo. This check reads every member's + # resolved rust-version, so it needs the manifest itself, not just the + # root MSRV that ANVIL_ROOT_MSRV can stand in for. Without this, a tree + # carrying the variable but no source would reach `cargo metadata` and + # fail with a path error that says nothing about why the source is + # absent. + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw "anvil: Cargo.toml not found at repository root '$repoRoot'; workspace MSRV validation reads every member manifest and cannot run without repository source" + } $rootMsrv = Get-RootMsrv if (-not (Test-ToolchainInstalled $rootMsrv)) { throw "anvil: root MSRV toolchain '$rootMsrv' is not installed; run 'just anvil-toolchain-stable-install' before validating prerequisites" @@ -6374,6 +6460,22 @@ _anvil-resolve-stable action="install": exit 0 } + # The declared root MSRV, spoken as the image build's vocabulary rather than + # as presence and absence: a version, or `none`. Total on purpose, because + # the caller feeds it into the container image tag, where an empty answer + # and an unasked question must not hash alike. + # + # Deliberately the *declared* value, not `Get-MsrvSelection`'s. The mapping + # through ANVIL_MSRV_TOOLCHAIN names a toolchain provisioned on the host for + # an environment that replaces public rustup; the image has public rustup + # and installs the declared version itself, so baking a host mapping into it + # would name a toolchain that does not exist there. + if ($action -eq 'root-msrv') { + $declared = Get-RootMsrv -AllowMissing + if ([string]::IsNullOrWhiteSpace($declared)) { 'none' } else { $declared } + exit 0 + } + if ($action -in @('msrv', 'install-msrv')) { $msrvSelection = Get-MsrvSelection if ($null -eq $msrvSelection) { diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index 9855809a..82b3b192 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -96,7 +96,19 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. +# `ANVIL_ROOT_MSRV` carries the one version this image cannot read for itself. +# Every other pin travels in `versions.just`, but the MSRV is declared in the +# repository's root `Cargo.toml`, and no source is in this context by design. +# Docker puts a declared `ARG` in the environment of the `RUN` below, which is +# where the MSRV resolver looks when it finds no manifest. It is an `ARG` rather +# than an `ENV` so it does not persist into the finished image: at run time the +# checkout is present and answers for itself. +# +# The build driver always passes it, sending `none` for a repository that +# declares no MSRV. Left unset -- a hand-rolled `docker build`, say -- the +# resolver stops and names the variable rather than installing nothing. WORKDIR /opt/anvil +ARG ANVIL_ROOT_MSRV COPY justfiles ./justfiles COPY rust-toolchain.toml ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ @@ -2506,11 +2518,11 @@ _anvil-container-dockerfile: # Print the exec image reference for the current inputs, without building it. # # The tag is a SHA-256 over the image's declared inputs: the Dockerfile and its -# ignore file, the pinned toolchain, the optional hook, and the whole generated +# ignore file, the pinned toolchain, the optional hook, the whole generated # recipe tree -- because the image installs its tools by running # `just anvil-setup`, whose dependency chain reaches the tier, group, check and -# tool recipes alike. Editing any of them can change what the image contains, so -# any of them can rename it. +# tool recipes alike -- and the declared root MSRV. Editing any of them can +# change what the image contains, so any of them can rename it. # # This is the only recipe that computes the reference; everything else asks it. # It is public because a publisher needs the tag before there is an image to @@ -2529,6 +2541,21 @@ anvil-container-tag: $dockerfile = "$dockerfile".Trim() $hookRel = '.anvil/container/hooks.ps1' + # The declared root MSRV is an input the build context cannot carry. The + # image installs that toolchain, so a bump changes what the image contains + # and has to rename it -- exactly what every other input here is hashed for. + # + # The value, not the manifest it comes from. `Cargo.toml` would be the + # obvious file to admit and hash, but it is the busiest file in a workspace: + # dependency edits touch it constantly while `rust-version` moves perhaps + # once in a repository's life. Hashing the file would rename the image, and + # oblige a publisher to rebuild and republish it, for a stream of changes + # that cannot alter a single byte the image contains. Hashing the value + # renames it exactly when the contents differ. + $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $rootMsrv = "$rootMsrv".Trim() + # Both named rather than left to the walk, so that either one going missing # is a hard failure at the check below. The walk cannot notice an absent # file, and the ignore file's absence is the worse of the two: the build @@ -2761,6 +2788,15 @@ anvil-container-tag: # would be a MethodNotFound at tag time, before anything useful happened. $sha = [System.Security.Cryptography.SHA256]::Create() try { + # Framed with its own record tag, ahead of the files and in a fixed + # position, so it cannot be confused with a file named `msrv` and the + # order of the stream stays a property of the code rather than of the + # filesystem. Length-prefixed like every other record, for the same + # reason: the stream has to be self-delimiting. + $msrvBytes = [System.Text.Encoding]::UTF8.GetBytes($rootMsrv) + $msrvHeader = [System.Text.Encoding]::UTF8.GetBytes('msrv ' + $msrvBytes.Length + ' ') + [void]$sha.TransformBlock($msrvHeader, 0, $msrvHeader.Length, $null, 0) + [void]$sha.TransformBlock($msrvBytes, 0, $msrvBytes.Length, $null, 0) foreach ($rel in $ordered) { $path = Join-Path $repoRoot $rel if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { @@ -3000,6 +3036,14 @@ _anvil-container-image: # It also keeps the identity scheme honest: without this, two hosts of # different architecture compute the same tag for different images. $buildCmd = @('build', '--platform', 'linux/amd64', '--file', "$engineRoot/$dockerfile", '--tag', $image) + # The one input the context cannot carry, and the same value the tag was + # computed from -- resolved here rather than passed down so the two + # cannot drift apart silently. `none` is sent verbatim for a repository + # that declares no MSRV: the resolver in the image treats it as an + # answer, and stops if it arrives with none at all. + $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $buildCmd += @('--build-arg', "ANVIL_ROOT_MSRV=$("$rootMsrv".Trim())") # BuildKit reads `.dockerignore` on its own; buildah reads # only a context-root ignore file and needs to be pointed at ours. Named # rather than probed, because the flag is rejected outright by the engine @@ -5103,7 +5147,8 @@ _anvil-resolve-stable action="install": 'install', 'validate-workspace-msrv', 'msrv', - 'install-msrv' + 'install-msrv', + 'root-msrv' ) if ($action -notin $validActions) { Write-Error "_anvil-resolve-stable: unknown action '$action'" @@ -5119,10 +5164,34 @@ _anvil-resolve-stable action="install": ) } - function Get-RootMsrv([switch] $AllowMissing) { + # The declared root MSRV, or $null when the repository declares none. + # + # Every other version this tree installs is pinned in versions.just, which + # travels with the recipes. The MSRV is the one toolchain declaration that + # lives in the source instead, and the container image is built with no + # source in its context on purpose -- the checkout arrives at run time, + # under a different root. So when the manifest is absent the declaration is + # taken from ANVIL_ROOT_MSRV, which the image build passes in. + # + # `none` is a declaration, not an absence: it says the repository has no + # MSRV. An unset variable with no manifest stays a hard error, so a build + # that fails to pass the value cannot quietly produce an image with no MSRV + # toolchain and leave every later run to discover that for itself. + # + # The variable is only consulted when there is no manifest, so it cannot + # shadow one. Inside the image the checkout is the working directory and + # answers for itself; nothing stale can win over a real declaration. + function Get-RootMsrvDeclaration { $manifestPath = Join-Path $repoRoot 'Cargo.toml' if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { - throw "anvil: Cargo.toml not found at repository root '$repoRoot'" + if ([string]::IsNullOrWhiteSpace($env:ANVIL_ROOT_MSRV)) { + throw "anvil: Cargo.toml not found at repository root '$repoRoot' and ANVIL_ROOT_MSRV is unset; a tree without repository source must be told the declared MSRV, or 'none' if the repository declares no MSRV" + } + $declared = $env:ANVIL_ROOT_MSRV.Trim() + if ($declared -eq 'none') { + return $null + } + return $declared } # This bootstrap scan must choose Cargo before Cargo is available to @@ -5148,6 +5217,14 @@ _anvil-resolve-stable action="install": if ($values.ContainsKey('package')) { return $values['package'] } + return $null + } + + function Get-RootMsrv([switch] $AllowMissing) { + $declared = Get-RootMsrvDeclaration + if (-not [string]::IsNullOrWhiteSpace($declared)) { + return $declared + } if ($AllowMissing) { return $null @@ -5173,6 +5250,15 @@ _anvil-resolve-stable action="install": function Assert-WorkspaceMsrvCompatibility { $manifestPath = Join-Path $repoRoot 'Cargo.toml' + # Named here rather than left to cargo. This check reads every member's + # resolved rust-version, so it needs the manifest itself, not just the + # root MSRV that ANVIL_ROOT_MSRV can stand in for. Without this, a tree + # carrying the variable but no source would reach `cargo metadata` and + # fail with a path error that says nothing about why the source is + # absent. + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw "anvil: Cargo.toml not found at repository root '$repoRoot'; workspace MSRV validation reads every member manifest and cannot run without repository source" + } $rootMsrv = Get-RootMsrv if (-not (Test-ToolchainInstalled $rootMsrv)) { throw "anvil: root MSRV toolchain '$rootMsrv' is not installed; run 'just anvil-toolchain-stable-install' before validating prerequisites" @@ -5258,6 +5344,22 @@ _anvil-resolve-stable action="install": exit 0 } + # The declared root MSRV, spoken as the image build's vocabulary rather than + # as presence and absence: a version, or `none`. Total on purpose, because + # the caller feeds it into the container image tag, where an empty answer + # and an unasked question must not hash alike. + # + # Deliberately the *declared* value, not `Get-MsrvSelection`'s. The mapping + # through ANVIL_MSRV_TOOLCHAIN names a toolchain provisioned on the host for + # an environment that replaces public rustup; the image has public rustup + # and installs the declared version itself, so baking a host mapping into it + # would name a toolchain that does not exist there. + if ($action -eq 'root-msrv') { + $declared = Get-RootMsrv -AllowMissing + if ([string]::IsNullOrWhiteSpace($declared)) { 'none' } else { $declared } + exit 0 + } + if ($action -in @('msrv', 'install-msrv')) { $msrvSelection = Get-MsrvSelection if ($null -eq $msrvSelection) { diff --git a/justfiles/anvil/container.just b/justfiles/anvil/container.just index 6e3a91d8..ae933335 100644 --- a/justfiles/anvil/container.just +++ b/justfiles/anvil/container.just @@ -143,11 +143,11 @@ _anvil-container-dockerfile: # Print the exec image reference for the current inputs, without building it. # # The tag is a SHA-256 over the image's declared inputs: the Dockerfile and its -# ignore file, the pinned toolchain, the optional hook, and the whole generated +# ignore file, the pinned toolchain, the optional hook, the whole generated # recipe tree -- because the image installs its tools by running # `just anvil-setup`, whose dependency chain reaches the tier, group, check and -# tool recipes alike. Editing any of them can change what the image contains, so -# any of them can rename it. +# tool recipes alike -- and the declared root MSRV. Editing any of them can +# change what the image contains, so any of them can rename it. # # This is the only recipe that computes the reference; everything else asks it. # It is public because a publisher needs the tag before there is an image to @@ -166,6 +166,21 @@ anvil-container-tag: $dockerfile = "$dockerfile".Trim() $hookRel = '.anvil/container/hooks.ps1' + # The declared root MSRV is an input the build context cannot carry. The + # image installs that toolchain, so a bump changes what the image contains + # and has to rename it -- exactly what every other input here is hashed for. + # + # The value, not the manifest it comes from. `Cargo.toml` would be the + # obvious file to admit and hash, but it is the busiest file in a workspace: + # dependency edits touch it constantly while `rust-version` moves perhaps + # once in a repository's life. Hashing the file would rename the image, and + # oblige a publisher to rebuild and republish it, for a stream of changes + # that cannot alter a single byte the image contains. Hashing the value + # renames it exactly when the contents differ. + $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $rootMsrv = "$rootMsrv".Trim() + # Both named rather than left to the walk, so that either one going missing # is a hard failure at the check below. The walk cannot notice an absent # file, and the ignore file's absence is the worse of the two: the build @@ -398,6 +413,15 @@ anvil-container-tag: # would be a MethodNotFound at tag time, before anything useful happened. $sha = [System.Security.Cryptography.SHA256]::Create() try { + # Framed with its own record tag, ahead of the files and in a fixed + # position, so it cannot be confused with a file named `msrv` and the + # order of the stream stays a property of the code rather than of the + # filesystem. Length-prefixed like every other record, for the same + # reason: the stream has to be self-delimiting. + $msrvBytes = [System.Text.Encoding]::UTF8.GetBytes($rootMsrv) + $msrvHeader = [System.Text.Encoding]::UTF8.GetBytes('msrv ' + $msrvBytes.Length + ' ') + [void]$sha.TransformBlock($msrvHeader, 0, $msrvHeader.Length, $null, 0) + [void]$sha.TransformBlock($msrvBytes, 0, $msrvBytes.Length, $null, 0) foreach ($rel in $ordered) { $path = Join-Path $repoRoot $rel if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { @@ -637,6 +661,14 @@ _anvil-container-image: # It also keeps the identity scheme honest: without this, two hosts of # different architecture compute the same tag for different images. $buildCmd = @('build', '--platform', 'linux/amd64', '--file', "$engineRoot/$dockerfile", '--tag', $image) + # The one input the context cannot carry, and the same value the tag was + # computed from -- resolved here rather than passed down so the two + # cannot drift apart silently. `none` is sent verbatim for a repository + # that declares no MSRV: the resolver in the image treats it as an + # answer, and stops if it arrives with none at all. + $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $buildCmd += @('--build-arg', "ANVIL_ROOT_MSRV=$("$rootMsrv".Trim())") # BuildKit reads `.dockerignore` on its own; buildah reads # only a context-root ignore file and needs to be pointed at ours. Named # rather than probed, because the flag is rejected outright by the engine diff --git a/justfiles/anvil/tools.just b/justfiles/anvil/tools.just index 8e942948..32ecd984 100644 --- a/justfiles/anvil/tools.just +++ b/justfiles/anvil/tools.just @@ -185,7 +185,8 @@ _anvil-resolve-stable action="install": 'install', 'validate-workspace-msrv', 'msrv', - 'install-msrv' + 'install-msrv', + 'root-msrv' ) if ($action -notin $validActions) { Write-Error "_anvil-resolve-stable: unknown action '$action'" @@ -201,10 +202,34 @@ _anvil-resolve-stable action="install": ) } - function Get-RootMsrv([switch] $AllowMissing) { + # The declared root MSRV, or $null when the repository declares none. + # + # Every other version this tree installs is pinned in versions.just, which + # travels with the recipes. The MSRV is the one toolchain declaration that + # lives in the source instead, and the container image is built with no + # source in its context on purpose -- the checkout arrives at run time, + # under a different root. So when the manifest is absent the declaration is + # taken from ANVIL_ROOT_MSRV, which the image build passes in. + # + # `none` is a declaration, not an absence: it says the repository has no + # MSRV. An unset variable with no manifest stays a hard error, so a build + # that fails to pass the value cannot quietly produce an image with no MSRV + # toolchain and leave every later run to discover that for itself. + # + # The variable is only consulted when there is no manifest, so it cannot + # shadow one. Inside the image the checkout is the working directory and + # answers for itself; nothing stale can win over a real declaration. + function Get-RootMsrvDeclaration { $manifestPath = Join-Path $repoRoot 'Cargo.toml' if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { - throw "anvil: Cargo.toml not found at repository root '$repoRoot'" + if ([string]::IsNullOrWhiteSpace($env:ANVIL_ROOT_MSRV)) { + throw "anvil: Cargo.toml not found at repository root '$repoRoot' and ANVIL_ROOT_MSRV is unset; a tree without repository source must be told the declared MSRV, or 'none' if the repository declares no MSRV" + } + $declared = $env:ANVIL_ROOT_MSRV.Trim() + if ($declared -eq 'none') { + return $null + } + return $declared } # This bootstrap scan must choose Cargo before Cargo is available to @@ -230,6 +255,14 @@ _anvil-resolve-stable action="install": if ($values.ContainsKey('package')) { return $values['package'] } + return $null + } + + function Get-RootMsrv([switch] $AllowMissing) { + $declared = Get-RootMsrvDeclaration + if (-not [string]::IsNullOrWhiteSpace($declared)) { + return $declared + } if ($AllowMissing) { return $null @@ -255,6 +288,15 @@ _anvil-resolve-stable action="install": function Assert-WorkspaceMsrvCompatibility { $manifestPath = Join-Path $repoRoot 'Cargo.toml' + # Named here rather than left to cargo. This check reads every member's + # resolved rust-version, so it needs the manifest itself, not just the + # root MSRV that ANVIL_ROOT_MSRV can stand in for. Without this, a tree + # carrying the variable but no source would reach `cargo metadata` and + # fail with a path error that says nothing about why the source is + # absent. + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw "anvil: Cargo.toml not found at repository root '$repoRoot'; workspace MSRV validation reads every member manifest and cannot run without repository source" + } $rootMsrv = Get-RootMsrv if (-not (Test-ToolchainInstalled $rootMsrv)) { throw "anvil: root MSRV toolchain '$rootMsrv' is not installed; run 'just anvil-toolchain-stable-install' before validating prerequisites" @@ -340,6 +382,22 @@ _anvil-resolve-stable action="install": exit 0 } + # The declared root MSRV, spoken as the image build's vocabulary rather than + # as presence and absence: a version, or `none`. Total on purpose, because + # the caller feeds it into the container image tag, where an empty answer + # and an unasked question must not hash alike. + # + # Deliberately the *declared* value, not `Get-MsrvSelection`'s. The mapping + # through ANVIL_MSRV_TOOLCHAIN names a toolchain provisioned on the host for + # an environment that replaces public rustup; the image has public rustup + # and installs the declared version itself, so baking a host mapping into it + # would name a toolchain that does not exist there. + if ($action -eq 'root-msrv') { + $declared = Get-RootMsrv -AllowMissing + if ([string]::IsNullOrWhiteSpace($declared)) { 'none' } else { $declared } + exit 0 + } + if ($action -in @('msrv', 'install-msrv')) { $msrvSelection = Get-MsrvSelection if ($null -eq $msrvSelection) { From 85c84ff9207eadb23c93eb3c4ca623fba0f14427 Mon Sep 17 00:00:00 2001 From: Martin Havelka Date: Fri, 4 Sep 2026 11:34:10 +0200 Subject: [PATCH 2/7] refactor(cargo-anvil): let the image read the MSRV instead of being told it The setup region installs the toolchain named by the repository's declared MSRV, which lives in the root `Cargo.toml` rather than in `versions.just`. That value reached the build as `ARG ANVIL_ROOT_MSRV`, resolved on the host and passed as a build argument, with the resolver reading the variable whenever it found no manifest. That channel is fragile in a way the composed Dockerfile makes likely. The setup region is a documented replacement point, and the `ARG` sat inside it, so a repository substituting its own region kept receiving the build argument while silently losing the declaration that receives it. The resulting failure is `failed to install MSRV toolchain '1.92'`, which sends the reader hunting a toolchain that is not the problem. Admit the root manifest to the build context and copy it to `/opt/anvil` instead. That directory is already the root the recipes resolve against: it is `justfile_directory()`, and it holds `justfiles/` and the toolchain pin. Copying the manifest alongside them completes it for the one question the setup asks, and the resolver reads it there exactly as it does on a developer's machine. `tools.just` therefore returns to what it was, minus the new `root-msrv` action the tag still needs. The variable, its `none` sentinel, its error message and the `--build-arg` all go away, and with them a general resolver's knowledge of containers. Dropping the `COPY` now fails with `Cargo.toml not found at repository root`, which names the missing thing. The workspace members the manifest lists stay out of the context: they are a checkout, and the image is not one. The one path that would need them, workspace MSRV validation, returns early whenever a root toolchain file selects the compiler, which this image requires and copies. The tag continues to hash the resolved value rather than the file. The manifest is the busiest file in a workspace while `rust-version` moves perhaps once in a repository's life, so hashing it would rename the image, and oblige a publisher to rebuild and republish, for a stream of edits that cannot alter a byte the image contains. Contracts follow the mechanism: the context admits the manifest, the setup region copies it, and no build argument survives anywhere. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2963084a-ef08-4cf7-adc6-e78e61556a84 --- .anvil.lock | 10 +- .anvil/container/Dockerfile | 27 ++-- .anvil/container/Dockerfile.dockerignore | 7 + crates/cargo-anvil/docs/design/containers.md | 42 +++--- crates/cargo-anvil/docs/implementation.md | 17 +-- .../anvil/container/Dockerfile.dockerignore | 7 + .../anvil/container/Dockerfile.setup.region | 27 ++-- .../templates/justfiles/anvil/container.just | 28 ++-- .../templates/justfiles/anvil/tools.just | 51 +------ crates/cargo-anvil/tests/recipe_contracts.rs | 126 +++++------------- .../snapshots/snapshots__ado_backend.snap | 115 ++++++---------- .../snapshots/snapshots__github_backend.snap | 113 ++++++---------- .../snapshots/snapshots__local_only.snap | 113 ++++++---------- justfiles/anvil/container.just | 28 ++-- justfiles/anvil/tools.just | 51 +------ 15 files changed, 260 insertions(+), 502 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index 879264a7..2d564bd6 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,11 +1,11 @@ version = 1 tool = "anvil" tool_version = "0.7.0" -catalog_checksum = "sha256:3415a24c3e1b7216ecfd37ecfda02b4e6039851b70f07e45a676597254874191" +catalog_checksum = "sha256:6ea919ef87bdaf061ef838700e8c834858d9cc24bd7a6eb60c88d21c58844ef0" [[file]] path = ".anvil/container/Dockerfile.dockerignore" -checksum = "sha256:9c7906c20415ca3b832afb075c21e79f191ef14ee2fb4b6a5a95394e8b2153c1" +checksum = "sha256:b966b2c2f977583512702197f754c915919a8a5a6b9a739c9ef161bb9f985fc7" [[file]] path = ".github/actions/anvil-impact/action.yml" @@ -173,7 +173,7 @@ checksum = "sha256:6efd7378a2cd0f5d86519bd32fd86f2055a60191187dd77a8842b374b8eb7 [[file]] path = "justfiles/anvil/container.just" -checksum = "sha256:3363992c6c006c649eae3732b2a7b04c2a4925eff369179b128d43380e2800d4" +checksum = "sha256:585387ffd34db593998ddf96ffc4110ae72c5e6e656dde3b9c32855715bc776b" [[file]] path = "justfiles/anvil/groups/pr-fast.just" @@ -233,7 +233,7 @@ checksum = "sha256:00453a12cbb34811ee6a2c083dade5f6198575e3b0610f49e4743366326cd [[file]] path = "justfiles/anvil/tools.just" -checksum = "sha256:48b887481a7eb8dc7615367347c93b8a02dd7baafb0ef80ece883f03600285dd" +checksum = "sha256:d5f3d9887791deffea9472a46141010e98efcc41deb709bc6da3e1b72fdeefde" [[file]] path = "justfiles/anvil/versions.just" @@ -257,7 +257,7 @@ checksum = "sha256:7b409a9b560c214e10b50f74330fb6f8c0c12c3d83494e0dcf016f2411b50 [[region]] host = ".anvil/container/Dockerfile" id = "anvil-container-setup" -checksum = "sha256:3788845ea3d4c483917954ddf9bccf918bf69ab7c64bbf559baf59d5781c444f" +checksum = "sha256:0a4b9120cccb9e2f520b64bd1fac0cef8092e79024188d317f27f985a021aefc" [[region]] host = ".anvil/container/Dockerfile" diff --git a/.anvil/container/Dockerfile b/.anvil/container/Dockerfile index 9cd9a41d..825a7478 100644 --- a/.anvil/container/Dockerfile +++ b/.anvil/container/Dockerfile @@ -94,21 +94,26 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. -# `ANVIL_ROOT_MSRV` carries the one version this image cannot read for itself. -# Every other pin travels in `versions.just`, but the MSRV is declared in the -# repository's root `Cargo.toml`, and no source is in this context by design. -# Docker puts a declared `ARG` in the environment of the `RUN` below, which is -# where the MSRV resolver looks when it finds no manifest. It is an `ARG` rather -# than an `ENV` so it does not persist into the finished image: at run time the -# checkout is present and answers for itself. +# The root manifest is copied because the MSRV is declared there. Every other +# pin travels in `versions.just`, which is part of the recipe tree; the MSRV is +# the one toolchain declaration that lives in the repository's source instead, +# and the resolver reads it from the repository root. # -# The build driver always passes it, sending `none` for a repository that -# declares no MSRV. Left unset -- a hand-rolled `docker build`, say -- the -# resolver stops and names the variable rather than installing nothing. +# `/opt/anvil` is already that root as far as the recipes are concerned: it is +# `justfile_directory()`, and it holds `justfiles/` and the toolchain pin. +# Copying the manifest alongside them completes it for the one question the +# setup asks. The workspace members are not here and are not needed: the +# toolchain file above selects the compiler, which is the branch that skips +# workspace MSRV validation. +# +# It is copied after `justfiles`, so an MSRV edit invalidates no layer the +# recipes alone would not. The image tag hashes the declared value rather than +# this file, so the constant churn of dependency edits does not rename an image +# whose contents they cannot change. WORKDIR /opt/anvil -ARG ANVIL_ROOT_MSRV COPY justfiles ./justfiles COPY rust-toolchain.toml ./ +COPY Cargo.toml ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ && just anvil-setup binstall \ && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ diff --git a/.anvil/container/Dockerfile.dockerignore b/.anvil/container/Dockerfile.dockerignore index 577907f0..c0b712b4 100644 --- a/.anvil/container/Dockerfile.dockerignore +++ b/.anvil/container/Dockerfile.dockerignore @@ -25,6 +25,12 @@ # the image tag digests, so what the context admits and what the tag covers stay # the same set -- including the `.anvil-proposed` siblings both exclude, which # are anvil's review artifacts rather than build inputs. +# +# The root `Cargo.toml` is admitted for one value: the declared MSRV, which the +# setup installs a toolchain for. It is the only build input that lives in the +# repository's source rather than in the recipe tree. The workspace members it +# names are deliberately not admitted, so the context stays a recipe tree plus +# two declarations rather than a checkout. * !justfiles justfiles/* @@ -35,3 +41,4 @@ justfiles/anvil/**/*.anvil-proposed !.anvil/container .anvil/container/**/*.anvil-proposed !rust-toolchain.toml +!Cargo.toml diff --git a/crates/cargo-anvil/docs/design/containers.md b/crates/cargo-anvil/docs/design/containers.md index ff20c66f..abdea43d 100644 --- a/crates/cargo-anvil/docs/design/containers.md +++ b/crates/cargo-anvil/docs/design/containers.md @@ -133,7 +133,7 @@ never read, rewritten or reordered. | `anvil-container-base-image` | `ARG BASE_IMAGE`, pinned to a digest. | A second `ARG BASE_IMAGE=…` to build on a different base. | | `anvil-container-base` | `FROM`, the version pins for `pwsh`, `just`, `rustup` and `cargo-binstall`, and the `ENV` block. | Anything the first network access needs: a root CA, `http_proxy`, an internal package mirror. | | `anvil-container-tools` | System packages and those four tools. | Libraries a catalog tool needs to compile, for tools `binstall` has no prebuilt binary for. | -| `anvil-container-setup` | `ARG ANVIL_ROOT_MSRV`, `COPY` of the recipe tree, then `just anvil-setup`. | Anything the repository's own checks need at run time. | +| `anvil-container-setup` | `COPY` of the recipe tree and the root manifest, then `just anvil-setup`. | Anything the repository's own checks need at run time. | | `anvil-container-entry` | `ANVIL_IN_CONTAINER`, `WORKDIR`, `CMD`. | — | Each gap sits at the only point in the build where its kind of addition works: a certificate has to land before the @@ -186,17 +186,18 @@ generated pins. There is no second tool list to keep synchronized, and consequen image (§4.1). One version that recipe needs is not in the pins. `anvil-msrv-test-setup` installs the toolchain named by the -repository's declared MSRV, which lives in the root `Cargo.toml` — repository source, which this context deliberately -does not carry. The build therefore resolves the value on the host and passes it as `ARG ANVIL_ROOT_MSRV`, which -Docker places in the environment of the setup `RUN`; the MSRV resolver reads it there whenever it finds no root -manifest. A repository that declares no MSRV sends `none`, which is an answer. An unset variable is not: the resolver -stops and names it, so a build that loses the argument cannot quietly produce an image missing a toolchain it claims -to install. It is an `ARG` rather than an `ENV` so it does not survive into the finished image — at run time the -checkout is mounted and answers for itself, and the variable is consulted only when there is no manifest, so a stale -value can never shadow a real declaration. - -`Dockerfile.dockerignore` scopes the build context to `justfiles/anvil/`, `.anvil/container/` and -`rust-toolchain.toml`, denying everything else. The recipe tree is copied whole because `just` has to parse it to run +repository's declared MSRV, which lives in the root `Cargo.toml` rather than in `versions.just`. That manifest is +therefore admitted to the build context and copied to `/opt/anvil`, which is already the root the recipes resolve +against: it holds `justfiles/` and `rust-toolchain.toml`, and `justfile_directory()` names it. The MSRV resolver then +reads the manifest there exactly as it does on a developer's machine, with no container-specific path in it. + +The workspace members that manifest names are deliberately not admitted. They are a checkout, and the image is not +one; the context stays a recipe tree plus two declarations. The one code path that would need them, workspace MSRV +validation, reads every member's resolved `rust-version` — and it is unreachable here, because it returns early +whenever a root toolchain file selects the compiler, which this image requires and copies. + +`Dockerfile.dockerignore` scopes the build context to `justfiles/anvil/`, `.anvil/container/`, `rust-toolchain.toml` +and the root `Cargo.toml`, denying everything else. The recipe tree is copied whole because `just` has to parse it to run `anvil-setup`, and it is hashed whole (§4). `.anvil/container/` is admitted so a gap can `COPY` a file placed beside the Dockerfile; anvil's own `.anvil-proposed` review artifacts are excluded from both the context and the digest. BuildKit reads `.dockerignore` in preference to a root `.dockerignore`, so the repository neither needs to @@ -237,15 +238,14 @@ The cost is that editing any recipe renames the image and the next run rebuilds that can name contents the image does not have makes every guarantee below meaningless. The declared root MSRV is the one input that is not a file. The image installs that toolchain, so raising it changes -what the image contains and must rename it — but the value is declared in the repository's root `Cargo.toml`, which -is not in the build context (§3) and cannot be, because the image is built without repository source. The build -resolves the value on the host, passes it as `ARG ANVIL_ROOT_MSRV`, and hashes it here. - -The value is hashed rather than the manifest that carries it, and that is a correctness choice rather than a -convenience. `Cargo.toml` is the busiest file in a workspace — every dependency edit touches it — while `rust-version` -moves perhaps once in a repository's life. Admitting the file to the context and the digest would rename the image, and -oblige a publisher to rebuild and republish it, for a long stream of changes that cannot alter a single byte the image -contains. A tag must change when the contents change and not otherwise; hashing the value is what makes that true. +what the image contains and must rename it. The value is declared in the root `Cargo.toml`, which the context does +admit (§3), and the digest takes the resolved value rather than the file. + +That is a correctness choice rather than a convenience. `Cargo.toml` is the busiest file in a workspace — every +dependency edit touches it — while `rust-version` moves perhaps once in a repository's life. Hashing the file would +rename the image, and oblige a publisher to rebuild and republish it, for a long stream of changes that cannot alter a +single byte the image contains. A tag must change when the contents change and not otherwise; hashing the value is +what makes that true. The hook file's **content** is an input, since it determines what the build installs. Its **output** is deliberately excluded: a credential must never influence a tag. diff --git a/crates/cargo-anvil/docs/implementation.md b/crates/cargo-anvil/docs/implementation.md index 7a1b14a1..6e886d1f 100644 --- a/crates/cargo-anvil/docs/implementation.md +++ b/crates/cargo-anvil/docs/implementation.md @@ -76,16 +76,13 @@ cannot auto-install a compiler during validation. Installation uses the same anchored toolchain-list match and emits a dedicated rustup bootstrap diagnostic when the executable is absent. -`tools.just` additionally accepts the declared root MSRV out of band, through -`ANVIL_ROOT_MSRV`, and exposes it to callers as the `root-msrv` action. This -exists for the container image, which is built without repository source and so -has no root manifest to scan; the variable is read only when the manifest is -absent, so it can never shadow a real declaration. `versions.just` does not -mirror it, and the divergence is deliberate rather than drift: its selector -returns before the manifest scan whenever a root toolchain file is present, and -the image requires one, so the branch is unreachable there. The scanners -themselves — accepted syntax and `workspace.package`-before-`package` -precedence — stay identical and remain bound by the rule above. +`tools.just` additionally exposes the declared root MSRV to callers as the +`root-msrv` action, answering with the version or `none`. It exists for the +container image tag, which hashes that value rather than the manifest carrying +it, and being total matters there: an empty answer and an unasked question must +not hash alike. The action reads the manifest through the same scanner as every +other path, so accepted syntax and `workspace.package`-before-`package` +precedence stay bound by the rule above. Setup dependencies, rather than the cloud templates, route provisioning. Cargo-tool installers, default-component installers, and stable-only setup diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore b/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore index 577907f0..c0b712b4 100644 --- a/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore @@ -25,6 +25,12 @@ # the image tag digests, so what the context admits and what the tag covers stay # the same set -- including the `.anvil-proposed` siblings both exclude, which # are anvil's review artifacts rather than build inputs. +# +# The root `Cargo.toml` is admitted for one value: the declared MSRV, which the +# setup installs a toolchain for. It is the only build input that lives in the +# repository's source rather than in the recipe tree. The workspace members it +# names are deliberately not admitted, so the context stays a recipe tree plus +# two declarations rather than a checkout. * !justfiles justfiles/* @@ -35,3 +41,4 @@ justfiles/anvil/**/*.anvil-proposed !.anvil/container .anvil/container/**/*.anvil-proposed !rust-toolchain.toml +!Cargo.toml diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region b/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region index f6742787..728c511d 100644 --- a/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region @@ -14,21 +14,26 @@ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. -# `ANVIL_ROOT_MSRV` carries the one version this image cannot read for itself. -# Every other pin travels in `versions.just`, but the MSRV is declared in the -# repository's root `Cargo.toml`, and no source is in this context by design. -# Docker puts a declared `ARG` in the environment of the `RUN` below, which is -# where the MSRV resolver looks when it finds no manifest. It is an `ARG` rather -# than an `ENV` so it does not persist into the finished image: at run time the -# checkout is present and answers for itself. +# The root manifest is copied because the MSRV is declared there. Every other +# pin travels in `versions.just`, which is part of the recipe tree; the MSRV is +# the one toolchain declaration that lives in the repository's source instead, +# and the resolver reads it from the repository root. # -# The build driver always passes it, sending `none` for a repository that -# declares no MSRV. Left unset -- a hand-rolled `docker build`, say -- the -# resolver stops and names the variable rather than installing nothing. +# `/opt/anvil` is already that root as far as the recipes are concerned: it is +# `justfile_directory()`, and it holds `justfiles/` and the toolchain pin. +# Copying the manifest alongside them completes it for the one question the +# setup asks. The workspace members are not here and are not needed: the +# toolchain file above selects the compiler, which is the branch that skips +# workspace MSRV validation. +# +# It is copied after `justfiles`, so an MSRV edit invalidates no layer the +# recipes alone would not. The image tag hashes the declared value rather than +# this file, so the constant churn of dependency edits does not rename an image +# whose contents they cannot change. WORKDIR /opt/anvil -ARG ANVIL_ROOT_MSRV COPY justfiles ./justfiles COPY rust-toolchain.toml ./ +COPY Cargo.toml ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ && just anvil-setup binstall \ && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ diff --git a/crates/cargo-anvil/templates/justfiles/anvil/container.just b/crates/cargo-anvil/templates/justfiles/anvil/container.just index ae933335..6b1fc98d 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/container.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/container.just @@ -166,17 +166,17 @@ anvil-container-tag: $dockerfile = "$dockerfile".Trim() $hookRel = '.anvil/container/hooks.ps1' - # The declared root MSRV is an input the build context cannot carry. The - # image installs that toolchain, so a bump changes what the image contains - # and has to rename it -- exactly what every other input here is hashed for. + # The declared root MSRV is hashed because the image installs that + # toolchain: a bump changes what the image contains and has to rename it, + # exactly what every other input here is hashed for. # - # The value, not the manifest it comes from. `Cargo.toml` would be the - # obvious file to admit and hash, but it is the busiest file in a workspace: - # dependency edits touch it constantly while `rust-version` moves perhaps - # once in a repository's life. Hashing the file would rename the image, and - # oblige a publisher to rebuild and republish it, for a stream of changes - # that cannot alter a single byte the image contains. Hashing the value - # renames it exactly when the contents differ. + # The value, not the manifest it comes from. The manifest is in the build + # context, but it is the busiest file in a workspace: dependency edits touch + # it constantly while `rust-version` moves perhaps once in a repository's + # life. Hashing the file would rename the image, and oblige a publisher to + # rebuild and republish it, for a stream of changes that cannot alter a + # single byte the image contains. Hashing the value renames it exactly when + # the contents differ. $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $rootMsrv = "$rootMsrv".Trim() @@ -661,14 +661,6 @@ _anvil-container-image: # It also keeps the identity scheme honest: without this, two hosts of # different architecture compute the same tag for different images. $buildCmd = @('build', '--platform', 'linux/amd64', '--file', "$engineRoot/$dockerfile", '--tag', $image) - # The one input the context cannot carry, and the same value the tag was - # computed from -- resolved here rather than passed down so the two - # cannot drift apart silently. `none` is sent verbatim for a repository - # that declares no MSRV: the resolver in the image treats it as an - # answer, and stops if it arrives with none at all. - $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $buildCmd += @('--build-arg', "ANVIL_ROOT_MSRV=$("$rootMsrv".Trim())") # BuildKit reads `.dockerignore` on its own; buildah reads # only a context-root ignore file and needs to be pointed at ours. Named # rather than probed, because the flag is rejected outright by the engine diff --git a/crates/cargo-anvil/templates/justfiles/anvil/tools.just b/crates/cargo-anvil/templates/justfiles/anvil/tools.just index 32ecd984..a3ca2c3a 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/tools.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/tools.just @@ -202,34 +202,10 @@ _anvil-resolve-stable action="install": ) } - # The declared root MSRV, or $null when the repository declares none. - # - # Every other version this tree installs is pinned in versions.just, which - # travels with the recipes. The MSRV is the one toolchain declaration that - # lives in the source instead, and the container image is built with no - # source in its context on purpose -- the checkout arrives at run time, - # under a different root. So when the manifest is absent the declaration is - # taken from ANVIL_ROOT_MSRV, which the image build passes in. - # - # `none` is a declaration, not an absence: it says the repository has no - # MSRV. An unset variable with no manifest stays a hard error, so a build - # that fails to pass the value cannot quietly produce an image with no MSRV - # toolchain and leave every later run to discover that for itself. - # - # The variable is only consulted when there is no manifest, so it cannot - # shadow one. Inside the image the checkout is the working directory and - # answers for itself; nothing stale can win over a real declaration. - function Get-RootMsrvDeclaration { + function Get-RootMsrv([switch] $AllowMissing) { $manifestPath = Join-Path $repoRoot 'Cargo.toml' if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { - if ([string]::IsNullOrWhiteSpace($env:ANVIL_ROOT_MSRV)) { - throw "anvil: Cargo.toml not found at repository root '$repoRoot' and ANVIL_ROOT_MSRV is unset; a tree without repository source must be told the declared MSRV, or 'none' if the repository declares no MSRV" - } - $declared = $env:ANVIL_ROOT_MSRV.Trim() - if ($declared -eq 'none') { - return $null - } - return $declared + throw "anvil: Cargo.toml not found at repository root '$repoRoot'" } # This bootstrap scan must choose Cargo before Cargo is available to @@ -255,14 +231,6 @@ _anvil-resolve-stable action="install": if ($values.ContainsKey('package')) { return $values['package'] } - return $null - } - - function Get-RootMsrv([switch] $AllowMissing) { - $declared = Get-RootMsrvDeclaration - if (-not [string]::IsNullOrWhiteSpace($declared)) { - return $declared - } if ($AllowMissing) { return $null @@ -288,15 +256,6 @@ _anvil-resolve-stable action="install": function Assert-WorkspaceMsrvCompatibility { $manifestPath = Join-Path $repoRoot 'Cargo.toml' - # Named here rather than left to cargo. This check reads every member's - # resolved rust-version, so it needs the manifest itself, not just the - # root MSRV that ANVIL_ROOT_MSRV can stand in for. Without this, a tree - # carrying the variable but no source would reach `cargo metadata` and - # fail with a path error that says nothing about why the source is - # absent. - if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { - throw "anvil: Cargo.toml not found at repository root '$repoRoot'; workspace MSRV validation reads every member manifest and cannot run without repository source" - } $rootMsrv = Get-RootMsrv if (-not (Test-ToolchainInstalled $rootMsrv)) { throw "anvil: root MSRV toolchain '$rootMsrv' is not installed; run 'just anvil-toolchain-stable-install' before validating prerequisites" @@ -389,9 +348,9 @@ _anvil-resolve-stable action="install": # # Deliberately the *declared* value, not `Get-MsrvSelection`'s. The mapping # through ANVIL_MSRV_TOOLCHAIN names a toolchain provisioned on the host for - # an environment that replaces public rustup; the image has public rustup - # and installs the declared version itself, so baking a host mapping into it - # would name a toolchain that does not exist there. + # an environment that replaces public rustup; the image installs the + # declared version itself, so baking a host mapping into its tag would name + # a toolchain that need not exist there. if ($action -eq 'root-msrv') { $declared = Get-RootMsrv -AllowMissing if ([string]::IsNullOrWhiteSpace($declared)) { 'none' } else { $declared } diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index dbdc600b..51dec8c9 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -426,24 +426,21 @@ fn msrv_test_propagates_nested_just_failures() { ); } -// The container image is built with no repository source in its context, so the -// MSRV -- the one version anvil installs that is declared in `Cargo.toml` -// rather than pinned in `versions.just` -- has to reach the build another way. -// These cover the contract that carries it: the manifest always wins where -// there is one, the environment answers only where there is none, and a tree -// that is told nothing refuses rather than silently installing no toolchain. +// The container image is built without a checkout, but the MSRV -- the one +// version anvil installs that is declared in `Cargo.toml` rather than pinned in +// `versions.just` -- has to reach the build. The root manifest is admitted into +// the build context for exactly that, so the resolver reads it there the same +// way it does anywhere else. These cover the two ends: the resolver answers +// totally enough to be hashed, and the context and the setup region carry the +// manifest that lets it answer at all. #[test] -fn root_msrv_prefers_the_manifest_over_the_container_override() { +fn root_msrv_reports_the_declared_version() { if !tools_available() { return; } let tmp = fixture(&[("versions.just", VERSIONS), ("tools.just", TOOLS)], &[]); - let output = run_just( - tmp.path(), - &["_anvil-resolve-stable", "root-msrv"], - &[("ANVIL_ROOT_MSRV", OsStr::new("1.60.0"))], - ); + let output = run_just(tmp.path(), &["_anvil-resolve-stable", "root-msrv"], &[]); assert!( output.status.success(), @@ -451,11 +448,7 @@ fn root_msrv_prefers_the_manifest_over_the_container_override() { String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - assert_eq!( - String::from_utf8_lossy(&output.stdout).trim(), - "1.97", - "a real declaration must never be shadowed by the value the image build passes in" - ); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "1.97"); } #[test] @@ -485,85 +478,38 @@ fn root_msrv_reports_none_when_the_repository_declares_no_msrv() { } #[test] -fn sourceless_tree_resolves_the_msrv_the_image_build_passes_in() { - if !tools_available() { - return; - } - let tmp = fixture(&[("versions.just", VERSIONS), ("tools.just", TOOLS)], &[]); - fs::remove_file(tmp.path().join("Cargo.toml")).unwrap(); - - let declared = run_just( - tmp.path(), - &["_anvil-resolve-stable", "msrv"], - &[("ANVIL_ROOT_MSRV", OsStr::new("1.93.1"))], - ); - assert!( - declared.status.success(), - "a tree told its MSRV must resolve it:\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&declared.stdout), - String::from_utf8_lossy(&declared.stderr) - ); - assert_eq!(String::from_utf8_lossy(&declared.stdout).trim(), "1.93.1"); - - // `none` is an answer, so the install is a clean no-op rather than a - // failure: the repository being built genuinely has no MSRV to install. - let none = run_just( - tmp.path(), - &["_anvil-resolve-stable", "install-msrv"], - &[("ANVIL_ROOT_MSRV", OsStr::new("none"))], - ); - assert!( - none.status.success(), - "'none' must be accepted as a declaration:\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&none.stdout), - String::from_utf8_lossy(&none.stderr) - ); -} - -#[test] -fn sourceless_tree_told_nothing_refuses_instead_of_installing_no_toolchain() { - if !tools_available() { - return; - } - let tmp = fixture(&[("versions.just", VERSIONS), ("tools.just", TOOLS)], &[]); - fs::remove_file(tmp.path().join("Cargo.toml")).unwrap(); - - let output = run_just(tmp.path(), &["_anvil-resolve-stable", "install-msrv"], &[]); - - assert_failed(&output, "MSRV install in a tree with neither a manifest nor a declared MSRV"); +fn container_build_carries_the_manifest_that_declares_the_msrv() { assert!( - String::from_utf8_lossy(&output.stderr).contains("ANVIL_ROOT_MSRV"), - "the refusal must name the variable that would have answered, or a build that drops \ - the argument produces an image silently missing the MSRV toolchain:\nstderr:\n{}", - String::from_utf8_lossy(&output.stderr) + CONTAINER_DOCKERIGNORE.contains("!Cargo.toml"), + "the build context must admit the root manifest, or the setup cannot resolve the MSRV" ); -} - -#[test] -fn container_build_carries_the_msrv_as_a_value_rather_than_the_manifest() { assert!( - CONTAINER_SETUP_REGION.contains("ARG ANVIL_ROOT_MSRV"), - "the setup region must declare the build argument the MSRV resolver reads" + CONTAINER_SETUP_REGION.contains("COPY Cargo.toml ./"), + "the setup region must copy the manifest to the root the recipes resolve against" ); + // The members it names are a checkout, and the image is not one. Admitting + // them would make the context a source tree and rebuild the image on every + // commit. assert!( - !CONTAINER_SETUP_REGION.contains("ENV ANVIL_ROOT_MSRV"), - "it must not persist into the finished image, where the mounted checkout answers instead" + !CONTAINER_DOCKERIGNORE.contains("!sources") && !CONTAINER_DOCKERIGNORE.contains("!crates"), + "the context must stay a recipe tree plus declarations, not a checkout" ); + // The manifest is in the context but must not be in the identity: every + // dependency edit touches it while `rust-version` moves perhaps once, so + // hashing the file would rename the image for a long stream of changes that + // cannot alter a byte it contains. assert!( - CONTAINER.contains("'--build-arg', \"ANVIL_ROOT_MSRV=$(\"$rootMsrv\".Trim())\""), - "the build driver must pass the resolved MSRV to the engine" + CONTAINER.contains("'msrv ' + $msrvBytes.Length"), + "the image tag must hash the declared MSRV value, because the image installs that toolchain" ); assert!( - CONTAINER.contains("'msrv ' + $msrvBytes.Length"), - "the image tag must hash the MSRV, because the image installs that toolchain" + !CONTAINER.contains("ANVIL_ROOT_MSRV"), + "the value travels in the context as a file, not as a build argument a replaced setup \ + region can silently drop" ); - // The manifest is the obvious thing to copy and hash, and it is the wrong - // one: every dependency edit touches it while `rust-version` moves perhaps - // once, so admitting it would rebuild and rename the image for a long - // stream of changes that cannot alter a byte the image contains. assert!( - !CONTAINER_DOCKERIGNORE.contains("!Cargo.toml"), - "the build context must not admit the root manifest" + !CONTAINER_SETUP_REGION.contains("ANVIL_ROOT_MSRV"), + "the setup region must not reintroduce the build argument" ); } @@ -1833,11 +1779,11 @@ fn the_image_tag_follows_the_executable_bit() { } /// The image installs the toolchain named by the repository's declared MSRV, so -/// raising it changes what the image contains and must rename it. The value -/// reaches the digest out of band, because the manifest declaring it is not in -/// the build context and deliberately stays out: it is the busiest file in a -/// workspace, and hashing it would rename the image for a long stream of -/// dependency edits that cannot alter a byte the image contains. +/// raising it changes what the image contains and must rename it. The digest +/// takes the value rather than the manifest that declares it: the manifest is in +/// the build context, but it is the busiest file in a workspace, and hashing it +/// would rename the image for a long stream of dependency edits that cannot +/// alter a byte the image contains. #[test] fn the_image_tag_follows_the_declared_msrv() { 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 14daf252..bc9054cf 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -96,21 +96,26 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. -# `ANVIL_ROOT_MSRV` carries the one version this image cannot read for itself. -# Every other pin travels in `versions.just`, but the MSRV is declared in the -# repository's root `Cargo.toml`, and no source is in this context by design. -# Docker puts a declared `ARG` in the environment of the `RUN` below, which is -# where the MSRV resolver looks when it finds no manifest. It is an `ARG` rather -# than an `ENV` so it does not persist into the finished image: at run time the -# checkout is present and answers for itself. -# -# The build driver always passes it, sending `none` for a repository that -# declares no MSRV. Left unset -- a hand-rolled `docker build`, say -- the -# resolver stops and names the variable rather than installing nothing. +# The root manifest is copied because the MSRV is declared there. Every other +# pin travels in `versions.just`, which is part of the recipe tree; the MSRV is +# the one toolchain declaration that lives in the repository's source instead, +# and the resolver reads it from the repository root. +# +# `/opt/anvil` is already that root as far as the recipes are concerned: it is +# `justfile_directory()`, and it holds `justfiles/` and the toolchain pin. +# Copying the manifest alongside them completes it for the one question the +# setup asks. The workspace members are not here and are not needed: the +# toolchain file above selects the compiler, which is the branch that skips +# workspace MSRV validation. +# +# It is copied after `justfiles`, so an MSRV edit invalidates no layer the +# recipes alone would not. The image tag hashes the declared value rather than +# this file, so the constant churn of dependency edits does not rename an image +# whose contents they cannot change. WORKDIR /opt/anvil -ARG ANVIL_ROOT_MSRV COPY justfiles ./justfiles COPY rust-toolchain.toml ./ +COPY Cargo.toml ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ && just anvil-setup binstall \ && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ @@ -156,6 +161,12 @@ CMD ["bash"] # the image tag digests, so what the context admits and what the tag covers stay # the same set -- including the `.anvil-proposed` siblings both exclude, which # are anvil's review artifacts rather than build inputs. +# +# The root `Cargo.toml` is admitted for one value: the declared MSRV, which the +# setup installs a toolchain for. It is the only build input that lives in the +# repository's source rather than in the recipe tree. The workspace members it +# names are deliberately not admitted, so the context stays a recipe tree plus +# two declarations rather than a checkout. * !justfiles justfiles/* @@ -166,6 +177,7 @@ justfiles/anvil/**/*.anvil-proposed !.anvil/container .anvil/container/**/*.anvil-proposed !rust-toolchain.toml +!Cargo.toml === .delta.toml === # >>> anvil-managed: anvil-delta @@ -3676,17 +3688,17 @@ anvil-container-tag: $dockerfile = "$dockerfile".Trim() $hookRel = '.anvil/container/hooks.ps1' - # The declared root MSRV is an input the build context cannot carry. The - # image installs that toolchain, so a bump changes what the image contains - # and has to rename it -- exactly what every other input here is hashed for. + # The declared root MSRV is hashed because the image installs that + # toolchain: a bump changes what the image contains and has to rename it, + # exactly what every other input here is hashed for. # - # The value, not the manifest it comes from. `Cargo.toml` would be the - # obvious file to admit and hash, but it is the busiest file in a workspace: - # dependency edits touch it constantly while `rust-version` moves perhaps - # once in a repository's life. Hashing the file would rename the image, and - # oblige a publisher to rebuild and republish it, for a stream of changes - # that cannot alter a single byte the image contains. Hashing the value - # renames it exactly when the contents differ. + # The value, not the manifest it comes from. The manifest is in the build + # context, but it is the busiest file in a workspace: dependency edits touch + # it constantly while `rust-version` moves perhaps once in a repository's + # life. Hashing the file would rename the image, and oblige a publisher to + # rebuild and republish it, for a stream of changes that cannot alter a + # single byte the image contains. Hashing the value renames it exactly when + # the contents differ. $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $rootMsrv = "$rootMsrv".Trim() @@ -4171,14 +4183,6 @@ _anvil-container-image: # It also keeps the identity scheme honest: without this, two hosts of # different architecture compute the same tag for different images. $buildCmd = @('build', '--platform', 'linux/amd64', '--file', "$engineRoot/$dockerfile", '--tag', $image) - # The one input the context cannot carry, and the same value the tag was - # computed from -- resolved here rather than passed down so the two - # cannot drift apart silently. `none` is sent verbatim for a repository - # that declares no MSRV: the resolver in the image treats it as an - # answer, and stops if it arrives with none at all. - $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $buildCmd += @('--build-arg', "ANVIL_ROOT_MSRV=$("$rootMsrv".Trim())") # BuildKit reads `.dockerignore` on its own; buildah reads # only a context-root ignore file and needs to be pointed at ours. Named # rather than probed, because the flag is rejected outright by the engine @@ -6299,34 +6303,10 @@ _anvil-resolve-stable action="install": ) } - # The declared root MSRV, or $null when the repository declares none. - # - # Every other version this tree installs is pinned in versions.just, which - # travels with the recipes. The MSRV is the one toolchain declaration that - # lives in the source instead, and the container image is built with no - # source in its context on purpose -- the checkout arrives at run time, - # under a different root. So when the manifest is absent the declaration is - # taken from ANVIL_ROOT_MSRV, which the image build passes in. - # - # `none` is a declaration, not an absence: it says the repository has no - # MSRV. An unset variable with no manifest stays a hard error, so a build - # that fails to pass the value cannot quietly produce an image with no MSRV - # toolchain and leave every later run to discover that for itself. - # - # The variable is only consulted when there is no manifest, so it cannot - # shadow one. Inside the image the checkout is the working directory and - # answers for itself; nothing stale can win over a real declaration. - function Get-RootMsrvDeclaration { + function Get-RootMsrv([switch] $AllowMissing) { $manifestPath = Join-Path $repoRoot 'Cargo.toml' if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { - if ([string]::IsNullOrWhiteSpace($env:ANVIL_ROOT_MSRV)) { - throw "anvil: Cargo.toml not found at repository root '$repoRoot' and ANVIL_ROOT_MSRV is unset; a tree without repository source must be told the declared MSRV, or 'none' if the repository declares no MSRV" - } - $declared = $env:ANVIL_ROOT_MSRV.Trim() - if ($declared -eq 'none') { - return $null - } - return $declared + throw "anvil: Cargo.toml not found at repository root '$repoRoot'" } # This bootstrap scan must choose Cargo before Cargo is available to @@ -6352,14 +6332,6 @@ _anvil-resolve-stable action="install": if ($values.ContainsKey('package')) { return $values['package'] } - return $null - } - - function Get-RootMsrv([switch] $AllowMissing) { - $declared = Get-RootMsrvDeclaration - if (-not [string]::IsNullOrWhiteSpace($declared)) { - return $declared - } if ($AllowMissing) { return $null @@ -6385,15 +6357,6 @@ _anvil-resolve-stable action="install": function Assert-WorkspaceMsrvCompatibility { $manifestPath = Join-Path $repoRoot 'Cargo.toml' - # Named here rather than left to cargo. This check reads every member's - # resolved rust-version, so it needs the manifest itself, not just the - # root MSRV that ANVIL_ROOT_MSRV can stand in for. Without this, a tree - # carrying the variable but no source would reach `cargo metadata` and - # fail with a path error that says nothing about why the source is - # absent. - if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { - throw "anvil: Cargo.toml not found at repository root '$repoRoot'; workspace MSRV validation reads every member manifest and cannot run without repository source" - } $rootMsrv = Get-RootMsrv if (-not (Test-ToolchainInstalled $rootMsrv)) { throw "anvil: root MSRV toolchain '$rootMsrv' is not installed; run 'just anvil-toolchain-stable-install' before validating prerequisites" @@ -6486,9 +6449,9 @@ _anvil-resolve-stable action="install": # # Deliberately the *declared* value, not `Get-MsrvSelection`'s. The mapping # through ANVIL_MSRV_TOOLCHAIN names a toolchain provisioned on the host for - # an environment that replaces public rustup; the image has public rustup - # and installs the declared version itself, so baking a host mapping into it - # would name a toolchain that does not exist there. + # an environment that replaces public rustup; the image installs the + # declared version itself, so baking a host mapping into its tag would name + # a toolchain that need not exist there. if ($action -eq 'root-msrv') { $declared = Get-RootMsrv -AllowMissing if ([string]::IsNullOrWhiteSpace($declared)) { 'none' } else { $declared } diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index c704a396..db9444b7 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -96,21 +96,26 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. -# `ANVIL_ROOT_MSRV` carries the one version this image cannot read for itself. -# Every other pin travels in `versions.just`, but the MSRV is declared in the -# repository's root `Cargo.toml`, and no source is in this context by design. -# Docker puts a declared `ARG` in the environment of the `RUN` below, which is -# where the MSRV resolver looks when it finds no manifest. It is an `ARG` rather -# than an `ENV` so it does not persist into the finished image: at run time the -# checkout is present and answers for itself. +# The root manifest is copied because the MSRV is declared there. Every other +# pin travels in `versions.just`, which is part of the recipe tree; the MSRV is +# the one toolchain declaration that lives in the repository's source instead, +# and the resolver reads it from the repository root. # -# The build driver always passes it, sending `none` for a repository that -# declares no MSRV. Left unset -- a hand-rolled `docker build`, say -- the -# resolver stops and names the variable rather than installing nothing. +# `/opt/anvil` is already that root as far as the recipes are concerned: it is +# `justfile_directory()`, and it holds `justfiles/` and the toolchain pin. +# Copying the manifest alongside them completes it for the one question the +# setup asks. The workspace members are not here and are not needed: the +# toolchain file above selects the compiler, which is the branch that skips +# workspace MSRV validation. +# +# It is copied after `justfiles`, so an MSRV edit invalidates no layer the +# recipes alone would not. The image tag hashes the declared value rather than +# this file, so the constant churn of dependency edits does not rename an image +# whose contents they cannot change. WORKDIR /opt/anvil -ARG ANVIL_ROOT_MSRV COPY justfiles ./justfiles COPY rust-toolchain.toml ./ +COPY Cargo.toml ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ && just anvil-setup binstall \ && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ @@ -156,6 +161,12 @@ CMD ["bash"] # the image tag digests, so what the context admits and what the tag covers stay # the same set -- including the `.anvil-proposed` siblings both exclude, which # are anvil's review artifacts rather than build inputs. +# +# The root `Cargo.toml` is admitted for one value: the declared MSRV, which the +# setup installs a toolchain for. It is the only build input that lives in the +# repository's source rather than in the recipe tree. The workspace members it +# names are deliberately not admitted, so the context stays a recipe tree plus +# two declarations rather than a checkout. * !justfiles justfiles/* @@ -166,6 +177,7 @@ justfiles/anvil/**/*.anvil-proposed !.anvil/container .anvil/container/**/*.anvil-proposed !rust-toolchain.toml +!Cargo.toml === .delta.toml === # >>> anvil-managed: anvil-delta @@ -3813,17 +3825,17 @@ anvil-container-tag: $dockerfile = "$dockerfile".Trim() $hookRel = '.anvil/container/hooks.ps1' - # The declared root MSRV is an input the build context cannot carry. The - # image installs that toolchain, so a bump changes what the image contains - # and has to rename it -- exactly what every other input here is hashed for. + # The declared root MSRV is hashed because the image installs that + # toolchain: a bump changes what the image contains and has to rename it, + # exactly what every other input here is hashed for. # - # The value, not the manifest it comes from. `Cargo.toml` would be the - # obvious file to admit and hash, but it is the busiest file in a workspace: - # dependency edits touch it constantly while `rust-version` moves perhaps - # once in a repository's life. Hashing the file would rename the image, and - # oblige a publisher to rebuild and republish it, for a stream of changes - # that cannot alter a single byte the image contains. Hashing the value - # renames it exactly when the contents differ. + # The value, not the manifest it comes from. The manifest is in the build + # context, but it is the busiest file in a workspace: dependency edits touch + # it constantly while `rust-version` moves perhaps once in a repository's + # life. Hashing the file would rename the image, and oblige a publisher to + # rebuild and republish it, for a stream of changes that cannot alter a + # single byte the image contains. Hashing the value renames it exactly when + # the contents differ. $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $rootMsrv = "$rootMsrv".Trim() @@ -4308,14 +4320,6 @@ _anvil-container-image: # It also keeps the identity scheme honest: without this, two hosts of # different architecture compute the same tag for different images. $buildCmd = @('build', '--platform', 'linux/amd64', '--file', "$engineRoot/$dockerfile", '--tag', $image) - # The one input the context cannot carry, and the same value the tag was - # computed from -- resolved here rather than passed down so the two - # cannot drift apart silently. `none` is sent verbatim for a repository - # that declares no MSRV: the resolver in the image treats it as an - # answer, and stops if it arrives with none at all. - $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $buildCmd += @('--build-arg', "ANVIL_ROOT_MSRV=$("$rootMsrv".Trim())") # BuildKit reads `.dockerignore` on its own; buildah reads # only a context-root ignore file and needs to be pointed at ours. Named # rather than probed, because the flag is rejected outright by the engine @@ -6436,34 +6440,10 @@ _anvil-resolve-stable action="install": ) } - # The declared root MSRV, or $null when the repository declares none. - # - # Every other version this tree installs is pinned in versions.just, which - # travels with the recipes. The MSRV is the one toolchain declaration that - # lives in the source instead, and the container image is built with no - # source in its context on purpose -- the checkout arrives at run time, - # under a different root. So when the manifest is absent the declaration is - # taken from ANVIL_ROOT_MSRV, which the image build passes in. - # - # `none` is a declaration, not an absence: it says the repository has no - # MSRV. An unset variable with no manifest stays a hard error, so a build - # that fails to pass the value cannot quietly produce an image with no MSRV - # toolchain and leave every later run to discover that for itself. - # - # The variable is only consulted when there is no manifest, so it cannot - # shadow one. Inside the image the checkout is the working directory and - # answers for itself; nothing stale can win over a real declaration. - function Get-RootMsrvDeclaration { + function Get-RootMsrv([switch] $AllowMissing) { $manifestPath = Join-Path $repoRoot 'Cargo.toml' if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { - if ([string]::IsNullOrWhiteSpace($env:ANVIL_ROOT_MSRV)) { - throw "anvil: Cargo.toml not found at repository root '$repoRoot' and ANVIL_ROOT_MSRV is unset; a tree without repository source must be told the declared MSRV, or 'none' if the repository declares no MSRV" - } - $declared = $env:ANVIL_ROOT_MSRV.Trim() - if ($declared -eq 'none') { - return $null - } - return $declared + throw "anvil: Cargo.toml not found at repository root '$repoRoot'" } # This bootstrap scan must choose Cargo before Cargo is available to @@ -6489,14 +6469,6 @@ _anvil-resolve-stable action="install": if ($values.ContainsKey('package')) { return $values['package'] } - return $null - } - - function Get-RootMsrv([switch] $AllowMissing) { - $declared = Get-RootMsrvDeclaration - if (-not [string]::IsNullOrWhiteSpace($declared)) { - return $declared - } if ($AllowMissing) { return $null @@ -6522,15 +6494,6 @@ _anvil-resolve-stable action="install": function Assert-WorkspaceMsrvCompatibility { $manifestPath = Join-Path $repoRoot 'Cargo.toml' - # Named here rather than left to cargo. This check reads every member's - # resolved rust-version, so it needs the manifest itself, not just the - # root MSRV that ANVIL_ROOT_MSRV can stand in for. Without this, a tree - # carrying the variable but no source would reach `cargo metadata` and - # fail with a path error that says nothing about why the source is - # absent. - if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { - throw "anvil: Cargo.toml not found at repository root '$repoRoot'; workspace MSRV validation reads every member manifest and cannot run without repository source" - } $rootMsrv = Get-RootMsrv if (-not (Test-ToolchainInstalled $rootMsrv)) { throw "anvil: root MSRV toolchain '$rootMsrv' is not installed; run 'just anvil-toolchain-stable-install' before validating prerequisites" @@ -6623,9 +6586,9 @@ _anvil-resolve-stable action="install": # # Deliberately the *declared* value, not `Get-MsrvSelection`'s. The mapping # through ANVIL_MSRV_TOOLCHAIN names a toolchain provisioned on the host for - # an environment that replaces public rustup; the image has public rustup - # and installs the declared version itself, so baking a host mapping into it - # would name a toolchain that does not exist there. + # an environment that replaces public rustup; the image installs the + # declared version itself, so baking a host mapping into its tag would name + # a toolchain that need not exist there. if ($action -eq 'root-msrv') { $declared = Get-RootMsrv -AllowMissing if ([string]::IsNullOrWhiteSpace($declared)) { 'none' } else { $declared } diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index 439a99ce..05c1ebaf 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -96,21 +96,26 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. -# `ANVIL_ROOT_MSRV` carries the one version this image cannot read for itself. -# Every other pin travels in `versions.just`, but the MSRV is declared in the -# repository's root `Cargo.toml`, and no source is in this context by design. -# Docker puts a declared `ARG` in the environment of the `RUN` below, which is -# where the MSRV resolver looks when it finds no manifest. It is an `ARG` rather -# than an `ENV` so it does not persist into the finished image: at run time the -# checkout is present and answers for itself. +# The root manifest is copied because the MSRV is declared there. Every other +# pin travels in `versions.just`, which is part of the recipe tree; the MSRV is +# the one toolchain declaration that lives in the repository's source instead, +# and the resolver reads it from the repository root. # -# The build driver always passes it, sending `none` for a repository that -# declares no MSRV. Left unset -- a hand-rolled `docker build`, say -- the -# resolver stops and names the variable rather than installing nothing. +# `/opt/anvil` is already that root as far as the recipes are concerned: it is +# `justfile_directory()`, and it holds `justfiles/` and the toolchain pin. +# Copying the manifest alongside them completes it for the one question the +# setup asks. The workspace members are not here and are not needed: the +# toolchain file above selects the compiler, which is the branch that skips +# workspace MSRV validation. +# +# It is copied after `justfiles`, so an MSRV edit invalidates no layer the +# recipes alone would not. The image tag hashes the declared value rather than +# this file, so the constant churn of dependency edits does not rename an image +# whose contents they cannot change. WORKDIR /opt/anvil -ARG ANVIL_ROOT_MSRV COPY justfiles ./justfiles COPY rust-toolchain.toml ./ +COPY Cargo.toml ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ && just anvil-setup binstall \ && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ @@ -156,6 +161,12 @@ CMD ["bash"] # the image tag digests, so what the context admits and what the tag covers stay # the same set -- including the `.anvil-proposed` siblings both exclude, which # are anvil's review artifacts rather than build inputs. +# +# The root `Cargo.toml` is admitted for one value: the declared MSRV, which the +# setup installs a toolchain for. It is the only build input that lives in the +# repository's source rather than in the recipe tree. The workspace members it +# names are deliberately not admitted, so the context stays a recipe tree plus +# two declarations rather than a checkout. * !justfiles justfiles/* @@ -166,6 +177,7 @@ justfiles/anvil/**/*.anvil-proposed !.anvil/container .anvil/container/**/*.anvil-proposed !rust-toolchain.toml +!Cargo.toml === .delta.toml === # >>> anvil-managed: anvil-delta @@ -2546,17 +2558,17 @@ anvil-container-tag: $dockerfile = "$dockerfile".Trim() $hookRel = '.anvil/container/hooks.ps1' - # The declared root MSRV is an input the build context cannot carry. The - # image installs that toolchain, so a bump changes what the image contains - # and has to rename it -- exactly what every other input here is hashed for. + # The declared root MSRV is hashed because the image installs that + # toolchain: a bump changes what the image contains and has to rename it, + # exactly what every other input here is hashed for. # - # The value, not the manifest it comes from. `Cargo.toml` would be the - # obvious file to admit and hash, but it is the busiest file in a workspace: - # dependency edits touch it constantly while `rust-version` moves perhaps - # once in a repository's life. Hashing the file would rename the image, and - # oblige a publisher to rebuild and republish it, for a stream of changes - # that cannot alter a single byte the image contains. Hashing the value - # renames it exactly when the contents differ. + # The value, not the manifest it comes from. The manifest is in the build + # context, but it is the busiest file in a workspace: dependency edits touch + # it constantly while `rust-version` moves perhaps once in a repository's + # life. Hashing the file would rename the image, and oblige a publisher to + # rebuild and republish it, for a stream of changes that cannot alter a + # single byte the image contains. Hashing the value renames it exactly when + # the contents differ. $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $rootMsrv = "$rootMsrv".Trim() @@ -3041,14 +3053,6 @@ _anvil-container-image: # It also keeps the identity scheme honest: without this, two hosts of # different architecture compute the same tag for different images. $buildCmd = @('build', '--platform', 'linux/amd64', '--file', "$engineRoot/$dockerfile", '--tag', $image) - # The one input the context cannot carry, and the same value the tag was - # computed from -- resolved here rather than passed down so the two - # cannot drift apart silently. `none` is sent verbatim for a repository - # that declares no MSRV: the resolver in the image treats it as an - # answer, and stops if it arrives with none at all. - $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $buildCmd += @('--build-arg', "ANVIL_ROOT_MSRV=$("$rootMsrv".Trim())") # BuildKit reads `.dockerignore` on its own; buildah reads # only a context-root ignore file and needs to be pointed at ours. Named # rather than probed, because the flag is rejected outright by the engine @@ -5169,34 +5173,10 @@ _anvil-resolve-stable action="install": ) } - # The declared root MSRV, or $null when the repository declares none. - # - # Every other version this tree installs is pinned in versions.just, which - # travels with the recipes. The MSRV is the one toolchain declaration that - # lives in the source instead, and the container image is built with no - # source in its context on purpose -- the checkout arrives at run time, - # under a different root. So when the manifest is absent the declaration is - # taken from ANVIL_ROOT_MSRV, which the image build passes in. - # - # `none` is a declaration, not an absence: it says the repository has no - # MSRV. An unset variable with no manifest stays a hard error, so a build - # that fails to pass the value cannot quietly produce an image with no MSRV - # toolchain and leave every later run to discover that for itself. - # - # The variable is only consulted when there is no manifest, so it cannot - # shadow one. Inside the image the checkout is the working directory and - # answers for itself; nothing stale can win over a real declaration. - function Get-RootMsrvDeclaration { + function Get-RootMsrv([switch] $AllowMissing) { $manifestPath = Join-Path $repoRoot 'Cargo.toml' if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { - if ([string]::IsNullOrWhiteSpace($env:ANVIL_ROOT_MSRV)) { - throw "anvil: Cargo.toml not found at repository root '$repoRoot' and ANVIL_ROOT_MSRV is unset; a tree without repository source must be told the declared MSRV, or 'none' if the repository declares no MSRV" - } - $declared = $env:ANVIL_ROOT_MSRV.Trim() - if ($declared -eq 'none') { - return $null - } - return $declared + throw "anvil: Cargo.toml not found at repository root '$repoRoot'" } # This bootstrap scan must choose Cargo before Cargo is available to @@ -5222,14 +5202,6 @@ _anvil-resolve-stable action="install": if ($values.ContainsKey('package')) { return $values['package'] } - return $null - } - - function Get-RootMsrv([switch] $AllowMissing) { - $declared = Get-RootMsrvDeclaration - if (-not [string]::IsNullOrWhiteSpace($declared)) { - return $declared - } if ($AllowMissing) { return $null @@ -5255,15 +5227,6 @@ _anvil-resolve-stable action="install": function Assert-WorkspaceMsrvCompatibility { $manifestPath = Join-Path $repoRoot 'Cargo.toml' - # Named here rather than left to cargo. This check reads every member's - # resolved rust-version, so it needs the manifest itself, not just the - # root MSRV that ANVIL_ROOT_MSRV can stand in for. Without this, a tree - # carrying the variable but no source would reach `cargo metadata` and - # fail with a path error that says nothing about why the source is - # absent. - if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { - throw "anvil: Cargo.toml not found at repository root '$repoRoot'; workspace MSRV validation reads every member manifest and cannot run without repository source" - } $rootMsrv = Get-RootMsrv if (-not (Test-ToolchainInstalled $rootMsrv)) { throw "anvil: root MSRV toolchain '$rootMsrv' is not installed; run 'just anvil-toolchain-stable-install' before validating prerequisites" @@ -5356,9 +5319,9 @@ _anvil-resolve-stable action="install": # # Deliberately the *declared* value, not `Get-MsrvSelection`'s. The mapping # through ANVIL_MSRV_TOOLCHAIN names a toolchain provisioned on the host for - # an environment that replaces public rustup; the image has public rustup - # and installs the declared version itself, so baking a host mapping into it - # would name a toolchain that does not exist there. + # an environment that replaces public rustup; the image installs the + # declared version itself, so baking a host mapping into its tag would name + # a toolchain that need not exist there. if ($action -eq 'root-msrv') { $declared = Get-RootMsrv -AllowMissing if ([string]::IsNullOrWhiteSpace($declared)) { 'none' } else { $declared } diff --git a/justfiles/anvil/container.just b/justfiles/anvil/container.just index ae933335..6b1fc98d 100644 --- a/justfiles/anvil/container.just +++ b/justfiles/anvil/container.just @@ -166,17 +166,17 @@ anvil-container-tag: $dockerfile = "$dockerfile".Trim() $hookRel = '.anvil/container/hooks.ps1' - # The declared root MSRV is an input the build context cannot carry. The - # image installs that toolchain, so a bump changes what the image contains - # and has to rename it -- exactly what every other input here is hashed for. + # The declared root MSRV is hashed because the image installs that + # toolchain: a bump changes what the image contains and has to rename it, + # exactly what every other input here is hashed for. # - # The value, not the manifest it comes from. `Cargo.toml` would be the - # obvious file to admit and hash, but it is the busiest file in a workspace: - # dependency edits touch it constantly while `rust-version` moves perhaps - # once in a repository's life. Hashing the file would rename the image, and - # oblige a publisher to rebuild and republish it, for a stream of changes - # that cannot alter a single byte the image contains. Hashing the value - # renames it exactly when the contents differ. + # The value, not the manifest it comes from. The manifest is in the build + # context, but it is the busiest file in a workspace: dependency edits touch + # it constantly while `rust-version` moves perhaps once in a repository's + # life. Hashing the file would rename the image, and oblige a publisher to + # rebuild and republish it, for a stream of changes that cannot alter a + # single byte the image contains. Hashing the value renames it exactly when + # the contents differ. $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $rootMsrv = "$rootMsrv".Trim() @@ -661,14 +661,6 @@ _anvil-container-image: # It also keeps the identity scheme honest: without this, two hosts of # different architecture compute the same tag for different images. $buildCmd = @('build', '--platform', 'linux/amd64', '--file', "$engineRoot/$dockerfile", '--tag', $image) - # The one input the context cannot carry, and the same value the tag was - # computed from -- resolved here rather than passed down so the two - # cannot drift apart silently. `none` is sent verbatim for a repository - # that declares no MSRV: the resolver in the image treats it as an - # answer, and stops if it arrives with none at all. - $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $buildCmd += @('--build-arg', "ANVIL_ROOT_MSRV=$("$rootMsrv".Trim())") # BuildKit reads `.dockerignore` on its own; buildah reads # only a context-root ignore file and needs to be pointed at ours. Named # rather than probed, because the flag is rejected outright by the engine diff --git a/justfiles/anvil/tools.just b/justfiles/anvil/tools.just index 32ecd984..a3ca2c3a 100644 --- a/justfiles/anvil/tools.just +++ b/justfiles/anvil/tools.just @@ -202,34 +202,10 @@ _anvil-resolve-stable action="install": ) } - # The declared root MSRV, or $null when the repository declares none. - # - # Every other version this tree installs is pinned in versions.just, which - # travels with the recipes. The MSRV is the one toolchain declaration that - # lives in the source instead, and the container image is built with no - # source in its context on purpose -- the checkout arrives at run time, - # under a different root. So when the manifest is absent the declaration is - # taken from ANVIL_ROOT_MSRV, which the image build passes in. - # - # `none` is a declaration, not an absence: it says the repository has no - # MSRV. An unset variable with no manifest stays a hard error, so a build - # that fails to pass the value cannot quietly produce an image with no MSRV - # toolchain and leave every later run to discover that for itself. - # - # The variable is only consulted when there is no manifest, so it cannot - # shadow one. Inside the image the checkout is the working directory and - # answers for itself; nothing stale can win over a real declaration. - function Get-RootMsrvDeclaration { + function Get-RootMsrv([switch] $AllowMissing) { $manifestPath = Join-Path $repoRoot 'Cargo.toml' if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { - if ([string]::IsNullOrWhiteSpace($env:ANVIL_ROOT_MSRV)) { - throw "anvil: Cargo.toml not found at repository root '$repoRoot' and ANVIL_ROOT_MSRV is unset; a tree without repository source must be told the declared MSRV, or 'none' if the repository declares no MSRV" - } - $declared = $env:ANVIL_ROOT_MSRV.Trim() - if ($declared -eq 'none') { - return $null - } - return $declared + throw "anvil: Cargo.toml not found at repository root '$repoRoot'" } # This bootstrap scan must choose Cargo before Cargo is available to @@ -255,14 +231,6 @@ _anvil-resolve-stable action="install": if ($values.ContainsKey('package')) { return $values['package'] } - return $null - } - - function Get-RootMsrv([switch] $AllowMissing) { - $declared = Get-RootMsrvDeclaration - if (-not [string]::IsNullOrWhiteSpace($declared)) { - return $declared - } if ($AllowMissing) { return $null @@ -288,15 +256,6 @@ _anvil-resolve-stable action="install": function Assert-WorkspaceMsrvCompatibility { $manifestPath = Join-Path $repoRoot 'Cargo.toml' - # Named here rather than left to cargo. This check reads every member's - # resolved rust-version, so it needs the manifest itself, not just the - # root MSRV that ANVIL_ROOT_MSRV can stand in for. Without this, a tree - # carrying the variable but no source would reach `cargo metadata` and - # fail with a path error that says nothing about why the source is - # absent. - if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { - throw "anvil: Cargo.toml not found at repository root '$repoRoot'; workspace MSRV validation reads every member manifest and cannot run without repository source" - } $rootMsrv = Get-RootMsrv if (-not (Test-ToolchainInstalled $rootMsrv)) { throw "anvil: root MSRV toolchain '$rootMsrv' is not installed; run 'just anvil-toolchain-stable-install' before validating prerequisites" @@ -389,9 +348,9 @@ _anvil-resolve-stable action="install": # # Deliberately the *declared* value, not `Get-MsrvSelection`'s. The mapping # through ANVIL_MSRV_TOOLCHAIN names a toolchain provisioned on the host for - # an environment that replaces public rustup; the image has public rustup - # and installs the declared version itself, so baking a host mapping into it - # would name a toolchain that does not exist there. + # an environment that replaces public rustup; the image installs the + # declared version itself, so baking a host mapping into its tag would name + # a toolchain that need not exist there. if ($action -eq 'root-msrv') { $declared = Get-RootMsrv -AllowMissing if ([string]::IsNullOrWhiteSpace($declared)) { 'none' } else { $declared } From 4d7116ffc39a4db60cd4ee9ed67d16a48255dfa2 Mon Sep 17 00:00:00 2001 From: Martin Havelka Date: Fri, 4 Sep 2026 12:14:52 +0200 Subject: [PATCH 3/7] docs(cargo-anvil): trim the MSRV commentary to what is not inferable The prose ran about four lines per line of code, well past what the change needs. Keep the reasons a reader cannot recover from the code -- why the value is hashed rather than the manifest, why the members stay out of the context, why the declared value rather than the mapped one -- and drop the restatement around them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2963084a-ef08-4cf7-adc6-e78e61556a84 --- .anvil.lock | 10 +-- .anvil/container/Dockerfile | 22 ++---- .anvil/container/Dockerfile.dockerignore | 8 +-- crates/cargo-anvil/docs/design/containers.md | 26 +++---- crates/cargo-anvil/docs/implementation.md | 12 ++-- .../anvil/container/Dockerfile.dockerignore | 8 +-- .../anvil/container/Dockerfile.setup.region | 22 ++---- .../templates/justfiles/anvil/container.just | 24 +++---- .../templates/justfiles/anvil/tools.just | 15 ++-- crates/cargo-anvil/tests/recipe_contracts.rs | 24 ++----- .../snapshots/snapshots__ado_backend.snap | 69 +++++++------------ .../snapshots/snapshots__github_backend.snap | 69 +++++++------------ .../snapshots/snapshots__local_only.snap | 69 +++++++------------ justfiles/anvil/container.just | 24 +++---- justfiles/anvil/tools.just | 15 ++-- 15 files changed, 142 insertions(+), 275 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index 2d564bd6..37224e4a 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,11 +1,11 @@ version = 1 tool = "anvil" tool_version = "0.7.0" -catalog_checksum = "sha256:6ea919ef87bdaf061ef838700e8c834858d9cc24bd7a6eb60c88d21c58844ef0" +catalog_checksum = "sha256:47d5f35a51db0310f20ad41691cb45f660a547475e93579a1bb9e894439ed3bd" [[file]] path = ".anvil/container/Dockerfile.dockerignore" -checksum = "sha256:b966b2c2f977583512702197f754c915919a8a5a6b9a739c9ef161bb9f985fc7" +checksum = "sha256:3907b9d8d15211ef5adff9ac2bcd23023797ed7b9961a59e28324bb7666ec340" [[file]] path = ".github/actions/anvil-impact/action.yml" @@ -173,7 +173,7 @@ checksum = "sha256:6efd7378a2cd0f5d86519bd32fd86f2055a60191187dd77a8842b374b8eb7 [[file]] path = "justfiles/anvil/container.just" -checksum = "sha256:585387ffd34db593998ddf96ffc4110ae72c5e6e656dde3b9c32855715bc776b" +checksum = "sha256:68a83a014c29bb32db24d8be8140a8a86b829a96f30e5ca72d9b43e06764a5b1" [[file]] path = "justfiles/anvil/groups/pr-fast.just" @@ -233,7 +233,7 @@ checksum = "sha256:00453a12cbb34811ee6a2c083dade5f6198575e3b0610f49e4743366326cd [[file]] path = "justfiles/anvil/tools.just" -checksum = "sha256:d5f3d9887791deffea9472a46141010e98efcc41deb709bc6da3e1b72fdeefde" +checksum = "sha256:a4dcb0de11f57187f88cc1c27e0263391a74b53b09ccd27428f0d07c022bd524" [[file]] path = "justfiles/anvil/versions.just" @@ -257,7 +257,7 @@ checksum = "sha256:7b409a9b560c214e10b50f74330fb6f8c0c12c3d83494e0dcf016f2411b50 [[region]] host = ".anvil/container/Dockerfile" id = "anvil-container-setup" -checksum = "sha256:0a4b9120cccb9e2f520b64bd1fac0cef8092e79024188d317f27f985a021aefc" +checksum = "sha256:710cddf5cb7a73259485c6573eaa90a180cb19f26bd59a8e1f1bcb6592d92218" [[region]] host = ".anvil/container/Dockerfile" diff --git a/.anvil/container/Dockerfile b/.anvil/container/Dockerfile index 825a7478..602f6ef0 100644 --- a/.anvil/container/Dockerfile +++ b/.anvil/container/Dockerfile @@ -94,22 +94,12 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. -# The root manifest is copied because the MSRV is declared there. Every other -# pin travels in `versions.just`, which is part of the recipe tree; the MSRV is -# the one toolchain declaration that lives in the repository's source instead, -# and the resolver reads it from the repository root. -# -# `/opt/anvil` is already that root as far as the recipes are concerned: it is -# `justfile_directory()`, and it holds `justfiles/` and the toolchain pin. -# Copying the manifest alongside them completes it for the one question the -# setup asks. The workspace members are not here and are not needed: the -# toolchain file above selects the compiler, which is the branch that skips -# workspace MSRV validation. -# -# It is copied after `justfiles`, so an MSRV edit invalidates no layer the -# recipes alone would not. The image tag hashes the declared value rather than -# this file, so the constant churn of dependency edits does not rename an image -# whose contents they cannot change. +# The root manifest is copied for the MSRV, the one version `anvil-setup` +# installs that is declared in repository source rather than pinned in +# `versions.just`. `/opt/anvil` is the root the recipes already resolve against, +# so the resolver reads it there with no container-specific path. The workspace +# members it names are not copied and are not needed: the toolchain file selects +# the compiler, which is the branch that skips workspace MSRV validation. WORKDIR /opt/anvil COPY justfiles ./justfiles COPY rust-toolchain.toml ./ diff --git a/.anvil/container/Dockerfile.dockerignore b/.anvil/container/Dockerfile.dockerignore index c0b712b4..98c979e2 100644 --- a/.anvil/container/Dockerfile.dockerignore +++ b/.anvil/container/Dockerfile.dockerignore @@ -26,11 +26,9 @@ # the same set -- including the `.anvil-proposed` siblings both exclude, which # are anvil's review artifacts rather than build inputs. # -# The root `Cargo.toml` is admitted for one value: the declared MSRV, which the -# setup installs a toolchain for. It is the only build input that lives in the -# repository's source rather than in the recipe tree. The workspace members it -# names are deliberately not admitted, so the context stays a recipe tree plus -# two declarations rather than a checkout. +# The root `Cargo.toml` is admitted for the declared MSRV, the only build input +# that lives in repository source rather than in the recipe tree. The workspace +# members it names stay out: they are a checkout, and the image is not one. * !justfiles justfiles/* diff --git a/crates/cargo-anvil/docs/design/containers.md b/crates/cargo-anvil/docs/design/containers.md index abdea43d..2a64d9f9 100644 --- a/crates/cargo-anvil/docs/design/containers.md +++ b/crates/cargo-anvil/docs/design/containers.md @@ -186,15 +186,13 @@ generated pins. There is no second tool list to keep synchronized, and consequen image (§4.1). One version that recipe needs is not in the pins. `anvil-msrv-test-setup` installs the toolchain named by the -repository's declared MSRV, which lives in the root `Cargo.toml` rather than in `versions.just`. That manifest is -therefore admitted to the build context and copied to `/opt/anvil`, which is already the root the recipes resolve -against: it holds `justfiles/` and `rust-toolchain.toml`, and `justfile_directory()` names it. The MSRV resolver then -reads the manifest there exactly as it does on a developer's machine, with no container-specific path in it. +repository's declared MSRV, which lives in the root `Cargo.toml`. That manifest is therefore admitted to the context +and copied to `/opt/anvil`, the root the recipes already resolve against, so the MSRV resolver reads it there with no +container-specific path in it. -The workspace members that manifest names are deliberately not admitted. They are a checkout, and the image is not -one; the context stays a recipe tree plus two declarations. The one code path that would need them, workspace MSRV -validation, reads every member's resolved `rust-version` — and it is unreachable here, because it returns early -whenever a root toolchain file selects the compiler, which this image requires and copies. +The workspace members it names are not admitted: they are a checkout, and the image is not one. The one path that +would need them, workspace MSRV validation, returns early whenever a root toolchain file selects the compiler, which +this image requires and copies. `Dockerfile.dockerignore` scopes the build context to `justfiles/anvil/`, `.anvil/container/`, `rust-toolchain.toml` and the root `Cargo.toml`, denying everything else. The recipe tree is copied whole because `just` has to parse it to run @@ -238,14 +236,10 @@ The cost is that editing any recipe renames the image and the next run rebuilds that can name contents the image does not have makes every guarantee below meaningless. The declared root MSRV is the one input that is not a file. The image installs that toolchain, so raising it changes -what the image contains and must rename it. The value is declared in the root `Cargo.toml`, which the context does -admit (§3), and the digest takes the resolved value rather than the file. - -That is a correctness choice rather than a convenience. `Cargo.toml` is the busiest file in a workspace — every -dependency edit touches it — while `rust-version` moves perhaps once in a repository's life. Hashing the file would -rename the image, and oblige a publisher to rebuild and republish it, for a long stream of changes that cannot alter a -single byte the image contains. A tag must change when the contents change and not otherwise; hashing the value is -what makes that true. +what the image contains and must rename it. The digest takes the resolved value rather than the manifest declaring it: +`Cargo.toml` is the busiest file in a workspace while `rust-version` moves perhaps once, so hashing the file would +rename the image, and oblige a publisher to rebuild and republish, for a stream of edits that cannot alter a byte the +image contains. The hook file's **content** is an input, since it determines what the build installs. Its **output** is deliberately excluded: a credential must never influence a tag. diff --git a/crates/cargo-anvil/docs/implementation.md b/crates/cargo-anvil/docs/implementation.md index 6e886d1f..029e07bd 100644 --- a/crates/cargo-anvil/docs/implementation.md +++ b/crates/cargo-anvil/docs/implementation.md @@ -76,13 +76,11 @@ cannot auto-install a compiler during validation. Installation uses the same anchored toolchain-list match and emits a dedicated rustup bootstrap diagnostic when the executable is absent. -`tools.just` additionally exposes the declared root MSRV to callers as the -`root-msrv` action, answering with the version or `none`. It exists for the -container image tag, which hashes that value rather than the manifest carrying -it, and being total matters there: an empty answer and an unasked question must -not hash alike. The action reads the manifest through the same scanner as every -other path, so accepted syntax and `workspace.package`-before-`package` -precedence stay bound by the rule above. +`tools.just` additionally exposes the declared root MSRV as the `root-msrv` +action, answering with the version or `none`. It exists for the container image +tag, which hashes that value, and being total matters there: an empty answer and +an unasked question must not hash alike. It reads the manifest through the same +scanner as every other path. Setup dependencies, rather than the cloud templates, route provisioning. Cargo-tool installers, default-component installers, and stable-only setup diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore b/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore index c0b712b4..98c979e2 100644 --- a/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore @@ -26,11 +26,9 @@ # the same set -- including the `.anvil-proposed` siblings both exclude, which # are anvil's review artifacts rather than build inputs. # -# The root `Cargo.toml` is admitted for one value: the declared MSRV, which the -# setup installs a toolchain for. It is the only build input that lives in the -# repository's source rather than in the recipe tree. The workspace members it -# names are deliberately not admitted, so the context stays a recipe tree plus -# two declarations rather than a checkout. +# The root `Cargo.toml` is admitted for the declared MSRV, the only build input +# that lives in repository source rather than in the recipe tree. The workspace +# members it names stay out: they are a checkout, and the image is not one. * !justfiles justfiles/* diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region b/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region index 728c511d..30b88801 100644 --- a/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region @@ -14,22 +14,12 @@ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. -# The root manifest is copied because the MSRV is declared there. Every other -# pin travels in `versions.just`, which is part of the recipe tree; the MSRV is -# the one toolchain declaration that lives in the repository's source instead, -# and the resolver reads it from the repository root. -# -# `/opt/anvil` is already that root as far as the recipes are concerned: it is -# `justfile_directory()`, and it holds `justfiles/` and the toolchain pin. -# Copying the manifest alongside them completes it for the one question the -# setup asks. The workspace members are not here and are not needed: the -# toolchain file above selects the compiler, which is the branch that skips -# workspace MSRV validation. -# -# It is copied after `justfiles`, so an MSRV edit invalidates no layer the -# recipes alone would not. The image tag hashes the declared value rather than -# this file, so the constant churn of dependency edits does not rename an image -# whose contents they cannot change. +# The root manifest is copied for the MSRV, the one version `anvil-setup` +# installs that is declared in repository source rather than pinned in +# `versions.just`. `/opt/anvil` is the root the recipes already resolve against, +# so the resolver reads it there with no container-specific path. The workspace +# members it names are not copied and are not needed: the toolchain file selects +# the compiler, which is the branch that skips workspace MSRV validation. WORKDIR /opt/anvil COPY justfiles ./justfiles COPY rust-toolchain.toml ./ diff --git a/crates/cargo-anvil/templates/justfiles/anvil/container.just b/crates/cargo-anvil/templates/justfiles/anvil/container.just index 6b1fc98d..2973625a 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/container.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/container.just @@ -166,17 +166,11 @@ anvil-container-tag: $dockerfile = "$dockerfile".Trim() $hookRel = '.anvil/container/hooks.ps1' - # The declared root MSRV is hashed because the image installs that - # toolchain: a bump changes what the image contains and has to rename it, - # exactly what every other input here is hashed for. - # - # The value, not the manifest it comes from. The manifest is in the build - # context, but it is the busiest file in a workspace: dependency edits touch - # it constantly while `rust-version` moves perhaps once in a repository's - # life. Hashing the file would rename the image, and oblige a publisher to - # rebuild and republish it, for a stream of changes that cannot alter a - # single byte the image contains. Hashing the value renames it exactly when - # the contents differ. + # Hashed because the image installs this toolchain, so a bump changes what + # the image contains. The resolved value rather than the manifest carrying + # it: dependency edits touch that file constantly while `rust-version` moves + # perhaps once, and hashing it would rename the image for a stream of + # changes that cannot alter a byte it contains. $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $rootMsrv = "$rootMsrv".Trim() @@ -413,11 +407,9 @@ anvil-container-tag: # would be a MethodNotFound at tag time, before anything useful happened. $sha = [System.Security.Cryptography.SHA256]::Create() try { - # Framed with its own record tag, ahead of the files and in a fixed - # position, so it cannot be confused with a file named `msrv` and the - # order of the stream stays a property of the code rather than of the - # filesystem. Length-prefixed like every other record, for the same - # reason: the stream has to be self-delimiting. + # Own record tag, length-prefixed and in a fixed position ahead of the + # files, so it cannot collide with a file named `msrv` and the stream + # stays self-delimiting. $msrvBytes = [System.Text.Encoding]::UTF8.GetBytes($rootMsrv) $msrvHeader = [System.Text.Encoding]::UTF8.GetBytes('msrv ' + $msrvBytes.Length + ' ') [void]$sha.TransformBlock($msrvHeader, 0, $msrvHeader.Length, $null, 0) diff --git a/crates/cargo-anvil/templates/justfiles/anvil/tools.just b/crates/cargo-anvil/templates/justfiles/anvil/tools.just index a3ca2c3a..0ac99f6d 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/tools.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/tools.just @@ -341,16 +341,13 @@ _anvil-resolve-stable action="install": exit 0 } - # The declared root MSRV, spoken as the image build's vocabulary rather than - # as presence and absence: a version, or `none`. Total on purpose, because - # the caller feeds it into the container image tag, where an empty answer - # and an unasked question must not hash alike. + # A version or `none`, never empty: the caller hashes this into the image + # tag, where an absent answer and an unasked question must not agree. # - # Deliberately the *declared* value, not `Get-MsrvSelection`'s. The mapping - # through ANVIL_MSRV_TOOLCHAIN names a toolchain provisioned on the host for - # an environment that replaces public rustup; the image installs the - # declared version itself, so baking a host mapping into its tag would name - # a toolchain that need not exist there. + # The declared value, not `Get-MsrvSelection`'s. That mapping names a + # toolchain provisioned on the host; the image installs the declared version + # itself, so hashing the mapping would name a toolchain that need not exist + # there. if ($action -eq 'root-msrv') { $declared = Get-RootMsrv -AllowMissing if ([string]::IsNullOrWhiteSpace($declared)) { 'none' } else { $declared } diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index 51dec8c9..84246c33 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -426,13 +426,9 @@ fn msrv_test_propagates_nested_just_failures() { ); } -// The container image is built without a checkout, but the MSRV -- the one -// version anvil installs that is declared in `Cargo.toml` rather than pinned in -// `versions.just` -- has to reach the build. The root manifest is admitted into -// the build context for exactly that, so the resolver reads it there the same -// way it does anywhere else. These cover the two ends: the resolver answers -// totally enough to be hashed, and the context and the setup region carry the -// manifest that lets it answer at all. +// The MSRV is the one version anvil installs that is declared in `Cargo.toml` +// rather than pinned in `versions.just`. The manifest is admitted to the build +// context so the resolver reads it there as it does anywhere else. #[test] fn root_msrv_reports_the_declared_version() { if !tools_available() { @@ -487,17 +483,13 @@ fn container_build_carries_the_manifest_that_declares_the_msrv() { CONTAINER_SETUP_REGION.contains("COPY Cargo.toml ./"), "the setup region must copy the manifest to the root the recipes resolve against" ); - // The members it names are a checkout, and the image is not one. Admitting - // them would make the context a source tree and rebuild the image on every - // commit. + // The members it names are a checkout, and the image is not one. assert!( !CONTAINER_DOCKERIGNORE.contains("!sources") && !CONTAINER_DOCKERIGNORE.contains("!crates"), "the context must stay a recipe tree plus declarations, not a checkout" ); // The manifest is in the context but must not be in the identity: every - // dependency edit touches it while `rust-version` moves perhaps once, so - // hashing the file would rename the image for a long stream of changes that - // cannot alter a byte it contains. + // dependency edit touches it while `rust-version` moves perhaps once. assert!( CONTAINER.contains("'msrv ' + $msrvBytes.Length"), "the image tag must hash the declared MSRV value, because the image installs that toolchain" @@ -1780,10 +1772,8 @@ fn the_image_tag_follows_the_executable_bit() { /// The image installs the toolchain named by the repository's declared MSRV, so /// raising it changes what the image contains and must rename it. The digest -/// takes the value rather than the manifest that declares it: the manifest is in -/// the build context, but it is the busiest file in a workspace, and hashing it -/// would rename the image for a long stream of dependency edits that cannot -/// alter a byte the image contains. +/// takes the resolved value rather than the manifest declaring it: dependency +/// edits touch that file constantly while `rust-version` moves perhaps once. #[test] fn the_image_tag_follows_the_declared_msrv() { 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 bc9054cf..d0cddc22 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -96,22 +96,12 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. -# The root manifest is copied because the MSRV is declared there. Every other -# pin travels in `versions.just`, which is part of the recipe tree; the MSRV is -# the one toolchain declaration that lives in the repository's source instead, -# and the resolver reads it from the repository root. -# -# `/opt/anvil` is already that root as far as the recipes are concerned: it is -# `justfile_directory()`, and it holds `justfiles/` and the toolchain pin. -# Copying the manifest alongside them completes it for the one question the -# setup asks. The workspace members are not here and are not needed: the -# toolchain file above selects the compiler, which is the branch that skips -# workspace MSRV validation. -# -# It is copied after `justfiles`, so an MSRV edit invalidates no layer the -# recipes alone would not. The image tag hashes the declared value rather than -# this file, so the constant churn of dependency edits does not rename an image -# whose contents they cannot change. +# The root manifest is copied for the MSRV, the one version `anvil-setup` +# installs that is declared in repository source rather than pinned in +# `versions.just`. `/opt/anvil` is the root the recipes already resolve against, +# so the resolver reads it there with no container-specific path. The workspace +# members it names are not copied and are not needed: the toolchain file selects +# the compiler, which is the branch that skips workspace MSRV validation. WORKDIR /opt/anvil COPY justfiles ./justfiles COPY rust-toolchain.toml ./ @@ -162,11 +152,9 @@ CMD ["bash"] # the same set -- including the `.anvil-proposed` siblings both exclude, which # are anvil's review artifacts rather than build inputs. # -# The root `Cargo.toml` is admitted for one value: the declared MSRV, which the -# setup installs a toolchain for. It is the only build input that lives in the -# repository's source rather than in the recipe tree. The workspace members it -# names are deliberately not admitted, so the context stays a recipe tree plus -# two declarations rather than a checkout. +# The root `Cargo.toml` is admitted for the declared MSRV, the only build input +# that lives in repository source rather than in the recipe tree. The workspace +# members it names stay out: they are a checkout, and the image is not one. * !justfiles justfiles/* @@ -3688,17 +3676,11 @@ anvil-container-tag: $dockerfile = "$dockerfile".Trim() $hookRel = '.anvil/container/hooks.ps1' - # The declared root MSRV is hashed because the image installs that - # toolchain: a bump changes what the image contains and has to rename it, - # exactly what every other input here is hashed for. - # - # The value, not the manifest it comes from. The manifest is in the build - # context, but it is the busiest file in a workspace: dependency edits touch - # it constantly while `rust-version` moves perhaps once in a repository's - # life. Hashing the file would rename the image, and oblige a publisher to - # rebuild and republish it, for a stream of changes that cannot alter a - # single byte the image contains. Hashing the value renames it exactly when - # the contents differ. + # Hashed because the image installs this toolchain, so a bump changes what + # the image contains. The resolved value rather than the manifest carrying + # it: dependency edits touch that file constantly while `rust-version` moves + # perhaps once, and hashing it would rename the image for a stream of + # changes that cannot alter a byte it contains. $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $rootMsrv = "$rootMsrv".Trim() @@ -3935,11 +3917,9 @@ anvil-container-tag: # would be a MethodNotFound at tag time, before anything useful happened. $sha = [System.Security.Cryptography.SHA256]::Create() try { - # Framed with its own record tag, ahead of the files and in a fixed - # position, so it cannot be confused with a file named `msrv` and the - # order of the stream stays a property of the code rather than of the - # filesystem. Length-prefixed like every other record, for the same - # reason: the stream has to be self-delimiting. + # Own record tag, length-prefixed and in a fixed position ahead of the + # files, so it cannot collide with a file named `msrv` and the stream + # stays self-delimiting. $msrvBytes = [System.Text.Encoding]::UTF8.GetBytes($rootMsrv) $msrvHeader = [System.Text.Encoding]::UTF8.GetBytes('msrv ' + $msrvBytes.Length + ' ') [void]$sha.TransformBlock($msrvHeader, 0, $msrvHeader.Length, $null, 0) @@ -6442,16 +6422,13 @@ _anvil-resolve-stable action="install": exit 0 } - # The declared root MSRV, spoken as the image build's vocabulary rather than - # as presence and absence: a version, or `none`. Total on purpose, because - # the caller feeds it into the container image tag, where an empty answer - # and an unasked question must not hash alike. + # A version or `none`, never empty: the caller hashes this into the image + # tag, where an absent answer and an unasked question must not agree. # - # Deliberately the *declared* value, not `Get-MsrvSelection`'s. The mapping - # through ANVIL_MSRV_TOOLCHAIN names a toolchain provisioned on the host for - # an environment that replaces public rustup; the image installs the - # declared version itself, so baking a host mapping into its tag would name - # a toolchain that need not exist there. + # The declared value, not `Get-MsrvSelection`'s. That mapping names a + # toolchain provisioned on the host; the image installs the declared version + # itself, so hashing the mapping would name a toolchain that need not exist + # there. if ($action -eq 'root-msrv') { $declared = Get-RootMsrv -AllowMissing if ([string]::IsNullOrWhiteSpace($declared)) { 'none' } else { $declared } diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index db9444b7..1855d95f 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -96,22 +96,12 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. -# The root manifest is copied because the MSRV is declared there. Every other -# pin travels in `versions.just`, which is part of the recipe tree; the MSRV is -# the one toolchain declaration that lives in the repository's source instead, -# and the resolver reads it from the repository root. -# -# `/opt/anvil` is already that root as far as the recipes are concerned: it is -# `justfile_directory()`, and it holds `justfiles/` and the toolchain pin. -# Copying the manifest alongside them completes it for the one question the -# setup asks. The workspace members are not here and are not needed: the -# toolchain file above selects the compiler, which is the branch that skips -# workspace MSRV validation. -# -# It is copied after `justfiles`, so an MSRV edit invalidates no layer the -# recipes alone would not. The image tag hashes the declared value rather than -# this file, so the constant churn of dependency edits does not rename an image -# whose contents they cannot change. +# The root manifest is copied for the MSRV, the one version `anvil-setup` +# installs that is declared in repository source rather than pinned in +# `versions.just`. `/opt/anvil` is the root the recipes already resolve against, +# so the resolver reads it there with no container-specific path. The workspace +# members it names are not copied and are not needed: the toolchain file selects +# the compiler, which is the branch that skips workspace MSRV validation. WORKDIR /opt/anvil COPY justfiles ./justfiles COPY rust-toolchain.toml ./ @@ -162,11 +152,9 @@ CMD ["bash"] # the same set -- including the `.anvil-proposed` siblings both exclude, which # are anvil's review artifacts rather than build inputs. # -# The root `Cargo.toml` is admitted for one value: the declared MSRV, which the -# setup installs a toolchain for. It is the only build input that lives in the -# repository's source rather than in the recipe tree. The workspace members it -# names are deliberately not admitted, so the context stays a recipe tree plus -# two declarations rather than a checkout. +# The root `Cargo.toml` is admitted for the declared MSRV, the only build input +# that lives in repository source rather than in the recipe tree. The workspace +# members it names stay out: they are a checkout, and the image is not one. * !justfiles justfiles/* @@ -3825,17 +3813,11 @@ anvil-container-tag: $dockerfile = "$dockerfile".Trim() $hookRel = '.anvil/container/hooks.ps1' - # The declared root MSRV is hashed because the image installs that - # toolchain: a bump changes what the image contains and has to rename it, - # exactly what every other input here is hashed for. - # - # The value, not the manifest it comes from. The manifest is in the build - # context, but it is the busiest file in a workspace: dependency edits touch - # it constantly while `rust-version` moves perhaps once in a repository's - # life. Hashing the file would rename the image, and oblige a publisher to - # rebuild and republish it, for a stream of changes that cannot alter a - # single byte the image contains. Hashing the value renames it exactly when - # the contents differ. + # Hashed because the image installs this toolchain, so a bump changes what + # the image contains. The resolved value rather than the manifest carrying + # it: dependency edits touch that file constantly while `rust-version` moves + # perhaps once, and hashing it would rename the image for a stream of + # changes that cannot alter a byte it contains. $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $rootMsrv = "$rootMsrv".Trim() @@ -4072,11 +4054,9 @@ anvil-container-tag: # would be a MethodNotFound at tag time, before anything useful happened. $sha = [System.Security.Cryptography.SHA256]::Create() try { - # Framed with its own record tag, ahead of the files and in a fixed - # position, so it cannot be confused with a file named `msrv` and the - # order of the stream stays a property of the code rather than of the - # filesystem. Length-prefixed like every other record, for the same - # reason: the stream has to be self-delimiting. + # Own record tag, length-prefixed and in a fixed position ahead of the + # files, so it cannot collide with a file named `msrv` and the stream + # stays self-delimiting. $msrvBytes = [System.Text.Encoding]::UTF8.GetBytes($rootMsrv) $msrvHeader = [System.Text.Encoding]::UTF8.GetBytes('msrv ' + $msrvBytes.Length + ' ') [void]$sha.TransformBlock($msrvHeader, 0, $msrvHeader.Length, $null, 0) @@ -6579,16 +6559,13 @@ _anvil-resolve-stable action="install": exit 0 } - # The declared root MSRV, spoken as the image build's vocabulary rather than - # as presence and absence: a version, or `none`. Total on purpose, because - # the caller feeds it into the container image tag, where an empty answer - # and an unasked question must not hash alike. + # A version or `none`, never empty: the caller hashes this into the image + # tag, where an absent answer and an unasked question must not agree. # - # Deliberately the *declared* value, not `Get-MsrvSelection`'s. The mapping - # through ANVIL_MSRV_TOOLCHAIN names a toolchain provisioned on the host for - # an environment that replaces public rustup; the image installs the - # declared version itself, so baking a host mapping into its tag would name - # a toolchain that need not exist there. + # The declared value, not `Get-MsrvSelection`'s. That mapping names a + # toolchain provisioned on the host; the image installs the declared version + # itself, so hashing the mapping would name a toolchain that need not exist + # there. if ($action -eq 'root-msrv') { $declared = Get-RootMsrv -AllowMissing if ([string]::IsNullOrWhiteSpace($declared)) { 'none' } else { $declared } diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index 05c1ebaf..ece923dd 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -96,22 +96,12 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. -# The root manifest is copied because the MSRV is declared there. Every other -# pin travels in `versions.just`, which is part of the recipe tree; the MSRV is -# the one toolchain declaration that lives in the repository's source instead, -# and the resolver reads it from the repository root. -# -# `/opt/anvil` is already that root as far as the recipes are concerned: it is -# `justfile_directory()`, and it holds `justfiles/` and the toolchain pin. -# Copying the manifest alongside them completes it for the one question the -# setup asks. The workspace members are not here and are not needed: the -# toolchain file above selects the compiler, which is the branch that skips -# workspace MSRV validation. -# -# It is copied after `justfiles`, so an MSRV edit invalidates no layer the -# recipes alone would not. The image tag hashes the declared value rather than -# this file, so the constant churn of dependency edits does not rename an image -# whose contents they cannot change. +# The root manifest is copied for the MSRV, the one version `anvil-setup` +# installs that is declared in repository source rather than pinned in +# `versions.just`. `/opt/anvil` is the root the recipes already resolve against, +# so the resolver reads it there with no container-specific path. The workspace +# members it names are not copied and are not needed: the toolchain file selects +# the compiler, which is the branch that skips workspace MSRV validation. WORKDIR /opt/anvil COPY justfiles ./justfiles COPY rust-toolchain.toml ./ @@ -162,11 +152,9 @@ CMD ["bash"] # the same set -- including the `.anvil-proposed` siblings both exclude, which # are anvil's review artifacts rather than build inputs. # -# The root `Cargo.toml` is admitted for one value: the declared MSRV, which the -# setup installs a toolchain for. It is the only build input that lives in the -# repository's source rather than in the recipe tree. The workspace members it -# names are deliberately not admitted, so the context stays a recipe tree plus -# two declarations rather than a checkout. +# The root `Cargo.toml` is admitted for the declared MSRV, the only build input +# that lives in repository source rather than in the recipe tree. The workspace +# members it names stay out: they are a checkout, and the image is not one. * !justfiles justfiles/* @@ -2558,17 +2546,11 @@ anvil-container-tag: $dockerfile = "$dockerfile".Trim() $hookRel = '.anvil/container/hooks.ps1' - # The declared root MSRV is hashed because the image installs that - # toolchain: a bump changes what the image contains and has to rename it, - # exactly what every other input here is hashed for. - # - # The value, not the manifest it comes from. The manifest is in the build - # context, but it is the busiest file in a workspace: dependency edits touch - # it constantly while `rust-version` moves perhaps once in a repository's - # life. Hashing the file would rename the image, and oblige a publisher to - # rebuild and republish it, for a stream of changes that cannot alter a - # single byte the image contains. Hashing the value renames it exactly when - # the contents differ. + # Hashed because the image installs this toolchain, so a bump changes what + # the image contains. The resolved value rather than the manifest carrying + # it: dependency edits touch that file constantly while `rust-version` moves + # perhaps once, and hashing it would rename the image for a stream of + # changes that cannot alter a byte it contains. $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $rootMsrv = "$rootMsrv".Trim() @@ -2805,11 +2787,9 @@ anvil-container-tag: # would be a MethodNotFound at tag time, before anything useful happened. $sha = [System.Security.Cryptography.SHA256]::Create() try { - # Framed with its own record tag, ahead of the files and in a fixed - # position, so it cannot be confused with a file named `msrv` and the - # order of the stream stays a property of the code rather than of the - # filesystem. Length-prefixed like every other record, for the same - # reason: the stream has to be self-delimiting. + # Own record tag, length-prefixed and in a fixed position ahead of the + # files, so it cannot collide with a file named `msrv` and the stream + # stays self-delimiting. $msrvBytes = [System.Text.Encoding]::UTF8.GetBytes($rootMsrv) $msrvHeader = [System.Text.Encoding]::UTF8.GetBytes('msrv ' + $msrvBytes.Length + ' ') [void]$sha.TransformBlock($msrvHeader, 0, $msrvHeader.Length, $null, 0) @@ -5312,16 +5292,13 @@ _anvil-resolve-stable action="install": exit 0 } - # The declared root MSRV, spoken as the image build's vocabulary rather than - # as presence and absence: a version, or `none`. Total on purpose, because - # the caller feeds it into the container image tag, where an empty answer - # and an unasked question must not hash alike. + # A version or `none`, never empty: the caller hashes this into the image + # tag, where an absent answer and an unasked question must not agree. # - # Deliberately the *declared* value, not `Get-MsrvSelection`'s. The mapping - # through ANVIL_MSRV_TOOLCHAIN names a toolchain provisioned on the host for - # an environment that replaces public rustup; the image installs the - # declared version itself, so baking a host mapping into its tag would name - # a toolchain that need not exist there. + # The declared value, not `Get-MsrvSelection`'s. That mapping names a + # toolchain provisioned on the host; the image installs the declared version + # itself, so hashing the mapping would name a toolchain that need not exist + # there. if ($action -eq 'root-msrv') { $declared = Get-RootMsrv -AllowMissing if ([string]::IsNullOrWhiteSpace($declared)) { 'none' } else { $declared } diff --git a/justfiles/anvil/container.just b/justfiles/anvil/container.just index 6b1fc98d..2973625a 100644 --- a/justfiles/anvil/container.just +++ b/justfiles/anvil/container.just @@ -166,17 +166,11 @@ anvil-container-tag: $dockerfile = "$dockerfile".Trim() $hookRel = '.anvil/container/hooks.ps1' - # The declared root MSRV is hashed because the image installs that - # toolchain: a bump changes what the image contains and has to rename it, - # exactly what every other input here is hashed for. - # - # The value, not the manifest it comes from. The manifest is in the build - # context, but it is the busiest file in a workspace: dependency edits touch - # it constantly while `rust-version` moves perhaps once in a repository's - # life. Hashing the file would rename the image, and oblige a publisher to - # rebuild and republish it, for a stream of changes that cannot alter a - # single byte the image contains. Hashing the value renames it exactly when - # the contents differ. + # Hashed because the image installs this toolchain, so a bump changes what + # the image contains. The resolved value rather than the manifest carrying + # it: dependency edits touch that file constantly while `rust-version` moves + # perhaps once, and hashing it would rename the image for a stream of + # changes that cannot alter a byte it contains. $rootMsrv = & '{{ replace(just_executable(), "'", "''") }}' _anvil-resolve-stable root-msrv if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $rootMsrv = "$rootMsrv".Trim() @@ -413,11 +407,9 @@ anvil-container-tag: # would be a MethodNotFound at tag time, before anything useful happened. $sha = [System.Security.Cryptography.SHA256]::Create() try { - # Framed with its own record tag, ahead of the files and in a fixed - # position, so it cannot be confused with a file named `msrv` and the - # order of the stream stays a property of the code rather than of the - # filesystem. Length-prefixed like every other record, for the same - # reason: the stream has to be self-delimiting. + # Own record tag, length-prefixed and in a fixed position ahead of the + # files, so it cannot collide with a file named `msrv` and the stream + # stays self-delimiting. $msrvBytes = [System.Text.Encoding]::UTF8.GetBytes($rootMsrv) $msrvHeader = [System.Text.Encoding]::UTF8.GetBytes('msrv ' + $msrvBytes.Length + ' ') [void]$sha.TransformBlock($msrvHeader, 0, $msrvHeader.Length, $null, 0) diff --git a/justfiles/anvil/tools.just b/justfiles/anvil/tools.just index a3ca2c3a..0ac99f6d 100644 --- a/justfiles/anvil/tools.just +++ b/justfiles/anvil/tools.just @@ -341,16 +341,13 @@ _anvil-resolve-stable action="install": exit 0 } - # The declared root MSRV, spoken as the image build's vocabulary rather than - # as presence and absence: a version, or `none`. Total on purpose, because - # the caller feeds it into the container image tag, where an empty answer - # and an unasked question must not hash alike. + # A version or `none`, never empty: the caller hashes this into the image + # tag, where an absent answer and an unasked question must not agree. # - # Deliberately the *declared* value, not `Get-MsrvSelection`'s. The mapping - # through ANVIL_MSRV_TOOLCHAIN names a toolchain provisioned on the host for - # an environment that replaces public rustup; the image installs the - # declared version itself, so baking a host mapping into its tag would name - # a toolchain that need not exist there. + # The declared value, not `Get-MsrvSelection`'s. That mapping names a + # toolchain provisioned on the host; the image installs the declared version + # itself, so hashing the mapping would name a toolchain that need not exist + # there. if ($action -eq 'root-msrv') { $declared = Get-RootMsrv -AllowMissing if ([string]::IsNullOrWhiteSpace($declared)) { 'none' } else { $declared } From a23b5b8078e7c408f7a4a5d1c1405da10ad9f273 Mon Sep 17 00:00:00 2001 From: Martin Havelka Date: Fri, 4 Sep 2026 12:32:03 +0200 Subject: [PATCH 4/7] test(cargo-anvil): pin the toolchain-file precondition the MSRV design rests on Workspace MSRV validation reads every member manifest, and the build context carries none. It stays out of reach only because a root toolchain file selects the compiler and makes the resolver return early -- which holds today by accident, since the unconditional COPY means a repository without one cannot build an image at all. Making that COPY conditional is a reasonable fix for those repositories, and it would silently put the branch back in reach of a partial workspace. State the dependency so that change has to confront this one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2963084a-ef08-4cf7-adc6-e78e61556a84 --- crates/cargo-anvil/docs/design/containers.md | 8 ++++++-- crates/cargo-anvil/tests/recipe_contracts.rs | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/cargo-anvil/docs/design/containers.md b/crates/cargo-anvil/docs/design/containers.md index 2a64d9f9..15a23b59 100644 --- a/crates/cargo-anvil/docs/design/containers.md +++ b/crates/cargo-anvil/docs/design/containers.md @@ -191,8 +191,12 @@ and copied to `/opt/anvil`, the root the recipes already resolve against, so the container-specific path in it. The workspace members it names are not admitted: they are a checkout, and the image is not one. The one path that -would need them, workspace MSRV validation, returns early whenever a root toolchain file selects the compiler, which -this image requires and copies. +would need them, workspace MSRV validation, returns early whenever a root toolchain file selects the compiler. + +That makes the toolchain file a precondition of this design rather than a convenience: the image requires one, copies +it, and relies on it to keep workspace validation out of reach of a context that has no members. A repository without +one cannot build the image today, because the `COPY` above is unconditional. Should that become conditional, this +design needs revisiting alongside it. `Dockerfile.dockerignore` scopes the build context to `justfiles/anvil/`, `.anvil/container/`, `rust-toolchain.toml` and the root `Cargo.toml`, denying everything else. The recipe tree is copied whole because `just` has to parse it to run diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index 84246c33..3e595cbe 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -503,6 +503,17 @@ fn container_build_carries_the_manifest_that_declares_the_msrv() { !CONTAINER_SETUP_REGION.contains("ANVIL_ROOT_MSRV"), "the setup region must not reintroduce the build argument" ); + // Load-bearing for the two assertions above rather than incidental. The + // resolver's workspace MSRV validation reads every member manifest, which + // this context does not carry -- and it is unreachable only because a root + // toolchain file selects the compiler, which makes it return early. Making + // this COPY conditional, so a repository without one can build, would put + // that branch back in reach of a partial workspace. + assert!( + CONTAINER_SETUP_REGION.contains("COPY rust-toolchain.toml ./"), + "the setup region must copy a root toolchain file: the MSRV design depends on one being \ + present to keep workspace validation out of reach of a context with no members" + ); } #[test] From e33082883461dff809b40773a68e8ace8b3b9070 Mon Sep 17 00:00:00 2001 From: Martin Havelka Date: Fri, 4 Sep 2026 13:08:40 +0200 Subject: [PATCH 5/7] fix(cargo-anvil): let a repository with no root toolchain file build the image The setup region named `rust-toolchain.toml` in a COPY and the tag recipe listed it as a required input, so a repository that pins its compiler by other means could not build an image at all: the build failed at the first COPY, and `anvil-container-tag` refused before that. `microsoft/oxidizer` is such a repository. No engine anvil supports offers a portable COPY of a path that may not exist, so the region names no input at all and copies the context whole. The ignore file already scopes that context to precisely the image's inputs, so what it admits and what the image contains become the same set. It now admits both toolchain-file spellings: naming only the TOML would leave a repository that pins with the extensionless file building an image whose compiler silently disagreed with its own checkout. The tag discovers the file rather than requiring it, in both spellings. Absence is one fewer record in the digest, so the two states cannot share a reference, and the file's mode still comes from the index. The ignore file stays a named, required input for the reason it always was. The design note this replaces claimed the toolchain file kept workspace MSRV validation out of reach of a memberless context. It does not: that validation hangs off the `-validate-prereqs` recipes, and `anvil-setup` -- the only thing the image runs -- depends on none of them. `just --dry-run anvil-setup binstall` reaches `_anvil-resolve-stable install-msrv` and nothing else, so no resolver change is needed and the doc and contract test now state the real reason. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .anvil.lock | 8 +- .anvil/container/Dockerfile | 22 +++-- .anvil/container/Dockerfile.dockerignore | 16 ++- crates/cargo-anvil/README.md | 16 +-- crates/cargo-anvil/docs/design/containers.md | 54 ++++++---- .../src/anvil/artifacts/container.rs | 17 +++- crates/cargo-anvil/src/lib.rs | 14 ++- .../anvil/container/Dockerfile.dockerignore | 16 ++- .../anvil/container/Dockerfile.setup.region | 22 +++-- .../templates/justfiles/anvil/container.just | 52 ++++++---- crates/cargo-anvil/tests/recipe_contracts.rs | 99 ++++++++++++++++--- .../snapshots/snapshots__ado_backend.snap | 90 +++++++++++------ .../snapshots/snapshots__github_backend.snap | 90 +++++++++++------ .../snapshots/snapshots__local_only.snap | 90 +++++++++++------ justfiles/anvil/container.just | 52 ++++++---- 15 files changed, 469 insertions(+), 189 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index 37224e4a..08ada7cf 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,11 +1,11 @@ version = 1 tool = "anvil" tool_version = "0.7.0" -catalog_checksum = "sha256:47d5f35a51db0310f20ad41691cb45f660a547475e93579a1bb9e894439ed3bd" +catalog_checksum = "sha256:9ce582a04c38ec6e895e8110cbec58dc683ea25f49c6a647b0a3e1707a09754e" [[file]] path = ".anvil/container/Dockerfile.dockerignore" -checksum = "sha256:3907b9d8d15211ef5adff9ac2bcd23023797ed7b9961a59e28324bb7666ec340" +checksum = "sha256:96a1e2439466ab228c7726f0882699c69af707fb9551472c82b318b1a35765b3" [[file]] path = ".github/actions/anvil-impact/action.yml" @@ -173,7 +173,7 @@ checksum = "sha256:6efd7378a2cd0f5d86519bd32fd86f2055a60191187dd77a8842b374b8eb7 [[file]] path = "justfiles/anvil/container.just" -checksum = "sha256:68a83a014c29bb32db24d8be8140a8a86b829a96f30e5ca72d9b43e06764a5b1" +checksum = "sha256:e0b5bf50232f43139016e50eb476b7a67b99a660bfa224028f78956e2b3eff28" [[file]] path = "justfiles/anvil/groups/pr-fast.just" @@ -257,7 +257,7 @@ checksum = "sha256:7b409a9b560c214e10b50f74330fb6f8c0c12c3d83494e0dcf016f2411b50 [[region]] host = ".anvil/container/Dockerfile" id = "anvil-container-setup" -checksum = "sha256:710cddf5cb7a73259485c6573eaa90a180cb19f26bd59a8e1f1bcb6592d92218" +checksum = "sha256:975ea8fe3ba2367fe133d73bdeb449a7d71e6be92e6e5539b200b6536f8a067d" [[region]] host = ".anvil/container/Dockerfile" diff --git a/.anvil/container/Dockerfile b/.anvil/container/Dockerfile index 602f6ef0..a0040bf7 100644 --- a/.anvil/container/Dockerfile +++ b/.anvil/container/Dockerfile @@ -78,6 +78,17 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # <<< anvil-managed: anvil-container-tools # >>> anvil-managed: anvil-container-setup +# The context is copied whole rather than input by input, because one of the +# inputs is optional: a repository that pins its compiler by other means owns no +# root `rust-toolchain`/`rust-toolchain.toml`, and a `COPY` of a path that may +# not exist is not portable across the engines anvil supports. Naming the file +# would make an image unbuildable in exactly the repositories that have nothing +# to pin. `Dockerfile.dockerignore` already scopes the context to precisely the +# image's inputs, so deferring to it costs no breadth and makes what the context +# admits and what the image contains the same set by construction. What rides +# along -- `.anvil/container/` -- is the committed input the gaps below `COPY` +# from, and the image never runs it. +# # The whole recipe tree is copied because `just` parses it to reach the install # recipes. # @@ -94,16 +105,15 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. -# The root manifest is copied for the MSRV, the one version `anvil-setup` +# The root manifest is admitted for the MSRV, the one version `anvil-setup` # installs that is declared in repository source rather than pinned in # `versions.just`. `/opt/anvil` is the root the recipes already resolve against, # so the resolver reads it there with no container-specific path. The workspace -# members it names are not copied and are not needed: the toolchain file selects -# the compiler, which is the branch that skips workspace MSRV validation. +# members it names are not admitted and are not needed: the one path that reads +# them is workspace MSRV validation, which hangs off the `-validate-prereqs` +# recipes, and `anvil-setup` depends on none of them. WORKDIR /opt/anvil -COPY justfiles ./justfiles -COPY rust-toolchain.toml ./ -COPY Cargo.toml ./ +COPY . ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ && just anvil-setup binstall \ && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ diff --git a/.anvil/container/Dockerfile.dockerignore b/.anvil/container/Dockerfile.dockerignore index 98c979e2..2a198f55 100644 --- a/.anvil/container/Dockerfile.dockerignore +++ b/.anvil/container/Dockerfile.dockerignore @@ -7,9 +7,11 @@ # `.dockerignore`, so this scopes the exec-image build context without the # repository having to own a root ignore file or having one silently overridden. # -# The build context is the repository root but the image only needs two things. -# Excluding everything else keeps a cold build from streaming the whole -# worktree (and every stale `target/`) to the daemon. +# The build context is the repository root but the image needs a small, named +# set out of it. Excluding everything else keeps a cold build from streaming the +# whole worktree (and every stale `target/`) to the daemon -- and, because the +# setup region copies the context whole, this file is also the definition of +# what the image contains. # # The context is narrowed to `justfiles/anvil/` rather than all of `justfiles/` # so that a cold build does not stream unrelated trees to the daemon. The @@ -29,6 +31,13 @@ # The root `Cargo.toml` is admitted for the declared MSRV, the only build input # that lives in repository source rather than in the recipe tree. The workspace # members it names stay out: they are a checkout, and the image is not one. +# +# Both toolchain-file spellings are admitted, and neither is required. A +# repository that owns one has its compiler selected from it, in the image as on +# a host; one that pins by other means builds an image whose compiler is the +# declared MSRV, which is what a host would resolve there too. Admitting only +# `rust-toolchain.toml` would give the second repository an image that silently +# disagreed with its own checkout. * !justfiles justfiles/* @@ -39,4 +48,5 @@ justfiles/anvil/**/*.anvil-proposed !.anvil/container .anvil/container/**/*.anvil-proposed !rust-toolchain.toml +!rust-toolchain !Cargo.toml diff --git a/crates/cargo-anvil/README.md b/crates/cargo-anvil/README.md index 4e9651a4..d7751aa7 100644 --- a/crates/cargo-anvil/README.md +++ b/crates/cargo-anvil/README.md @@ -157,7 +157,8 @@ pin the first image’s tools over every later one. through `wsl.exe` when it finds none on `PATH` and translates repository paths with `wslpath`. * `just` and `PowerShell` Core (`pwsh`) on the host. -* A repository-owned `rust-toolchain.toml`. +* A declared root MSRV. A repository-owned `rust-toolchain` file, in either + spelling, is honoured where it exists but is not required. Docker is supported; Podman works on a best-effort basis, with two documented gaps on Windows. The image is pinned to `linux/amd64`, so on @@ -166,7 +167,8 @@ ARM64 hosts it is emulated and is substantially slower. #### Image identity The tag *is* a SHA-256 digest over the inputs that define the image: -everything under `.anvil/container/`, `rust-toolchain.toml`, and the whole +everything under `.anvil/container/`, a root `rust-toolchain` file where the +repository owns one, and the whole generated `justfiles/anvil/` tree. The container directory is walked rather than named file by file, because the Dockerfile is composed and a repository can `COPY` a certificate or an install script it places there. @@ -257,9 +259,11 @@ those pins at the moment of the edit, which is why the gaps exist. A downstream catalog that needs a different base OS for every repository it manages replaces the base and tool regions instead, inheriting the catalog -install and the entry contract. A replacement that copies more of the tree -must replace the ignore file with it, since the build context admits only -`justfiles/anvil/`, `.anvil/container/` and `rust-toolchain.toml`. See +install and the entry contract. A replacement that needs more of the tree +must replace the ignore file with it, since that file is what scopes the +build context — and, because the setup region copies the context whole, what +the image contains: `justfiles/anvil/`, `.anvil/container/`, a root +`rust-toolchain` file and the root `Cargo.toml`. See [`artifacts::container`][__link1] and the design document for the full contract, the host setup for each engine, and the known limitations. @@ -491,7 +495,7 @@ And `docs/verification.md` for the continuous-validation strategy. This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjNhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbVqn03OrTnSYblGjeKgXeGVgb6z3iwQiK18Abc5kLxsXto9xhZIGDa2NhcmdvLWFudmlsZTAuNy4wa2NhcmdvX2Fudmls + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjNhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbmvEgSYsFVgkbCDtjnkoqqhgbgmvgMGS3gq0b1832bIf3mOhhZIGDa2NhcmdvLWFudmlsZTAuNy4wa2NhcmdvX2Fudmls [__link0]: https://crates.io/crates/cargo-delta [__link1]: https://docs.rs/cargo-anvil/0.7.0/cargo_anvil/?search=artifacts::container [__link10]: https://docs.rs/cargo-anvil/0.7.0/cargo_anvil/?search=artifacts diff --git a/crates/cargo-anvil/docs/design/containers.md b/crates/cargo-anvil/docs/design/containers.md index 15a23b59..125e59a5 100644 --- a/crates/cargo-anvil/docs/design/containers.md +++ b/crates/cargo-anvil/docs/design/containers.md @@ -53,9 +53,10 @@ Both are addressed by executing the recipe unchanged inside an image built from bodies are identical in either mode, and cloud workflows are unaffected: they run the same recipes natively on their own agents. The image is pinned to resemble that environment, not to reproduce it. -Image construction has a deliberately stricter compiler contract than native execution: the repository must own -`rust-toolchain.toml`. The build context admits that file but not `rust-toolchain`, and the build does not inherit the -host's `RUSTUP_TOOLCHAIN`; rustup therefore selects the image compiler from the repository-owned TOML file. +Image construction does not require the repository to own a `rust-toolchain.toml`. Where one exists — either +spelling — the context admits it and rustup selects the image compiler from it, exactly as it would on a host. Where a +repository pins its compiler by other means and owns neither, the image compiler is the declared root MSRV, which is +again what a host resolves there. The build does not inherit the host's `RUSTUP_TOOLCHAIN` in either case. ## 2. Command surface @@ -191,19 +192,23 @@ and copied to `/opt/anvil`, the root the recipes already resolve against, so the container-specific path in it. The workspace members it names are not admitted: they are a checkout, and the image is not one. The one path that -would need them, workspace MSRV validation, returns early whenever a root toolchain file selects the compiler. - -That makes the toolchain file a precondition of this design rather than a convenience: the image requires one, copies -it, and relies on it to keep workspace validation out of reach of a context that has no members. A repository without -one cannot build the image today, because the `COPY` above is unconditional. Should that become conditional, this -design needs revisiting alongside it. - -`Dockerfile.dockerignore` scopes the build context to `justfiles/anvil/`, `.anvil/container/`, `rust-toolchain.toml` -and the root `Cargo.toml`, denying everything else. The recipe tree is copied whole because `just` has to parse it to run -`anvil-setup`, and it is hashed whole (§4). `.anvil/container/` is admitted so a gap can `COPY` a file placed beside -the Dockerfile; anvil's own `.anvil-proposed` review artifacts are excluded from both the context and the digest. -BuildKit reads `.dockerignore` in preference to a root `.dockerignore`, so the repository neither needs to -own a root ignore file nor can have one silently override this. +would need them is workspace MSRV validation, which the image never reaches: it hangs off +`anvil-tool-rustc-validate-prereqs`, and no `-setup` recipe depends on a `-validate-prereqs` recipe. Inside a running +container that validation does execute, against `/workspace` — a real checkout, with its members. + +The setup region copies the context whole rather than naming each input, because one input is optional. A repository +that pins its compiler by other means owns no root toolchain file, and a `COPY` of a path that may not exist is not +portable across the engines anvil supports, so naming the file would leave exactly those repositories unable to build +an image at all. Deferring to the ignore file costs no breadth — it already scopes the context to precisely the image's +inputs — and makes what the context admits and what the image contains the same set by construction. `.anvil/container/` +rides along with it; that is the committed input a gap `COPY`s from, and the image never runs it. + +`Dockerfile.dockerignore` scopes the build context to `justfiles/anvil/`, `.anvil/container/`, a root toolchain file in +either spelling, and the root `Cargo.toml`, denying everything else. The recipe tree is copied whole because `just` has +to parse it to run `anvil-setup`, and it is hashed whole (§4). `.anvil/container/` is admitted so a gap can `COPY` a +file placed beside the Dockerfile; anvil's own `.anvil-proposed` review artifacts are excluded from both the context +and the digest. BuildKit reads `.dockerignore` in preference to a root `.dockerignore`, so the repository +neither needs to own a root ignore file nor can have one silently override this. ## 4. Image identity @@ -215,7 +220,7 @@ define the image. The name derives from the repository directory (§5.1). | Input | Hashed | | --- | --- | | every file under `.anvil/container/` | always | -| `rust-toolchain.toml` | always | +| `rust-toolchain.toml` or `rust-toolchain` | when the repository owns one | | every file under `justfiles/anvil/` | always | | the declared root MSRV | always | @@ -226,6 +231,11 @@ change the image under a reference that already resolves, which is the hole the Dockerfile is still a hard error, checked by name: the walk alone would let it contribute nothing and yield a confident tag for an image that cannot be built. +The root toolchain file is the one input whose absence is not an error. It is discovered rather than required, in both +spellings, and a repository that owns none simply contributes one fewer record to the digest — a state distinct from +owning one, so the two cannot share a tag. The ignore file is still required by name, for the reason above: it +contributes nothing to the digest but decides what the context, and therefore the image, contains. + The recipe tree is hashed in full. `just anvil-setup` reaches the install recipes through the tier, group and check recipes, so the routing decides *whether* a tool is installed just as surely as `tools.just` decides *how*: dropping an `anvil--setup` dependency from a group changes the installed set while `tools.just` and `versions.just` stay @@ -415,7 +425,8 @@ image, executes the requested command directly instead of launching another cont ## 6. Engines and host setup anvil installs nothing and manages no virtual machine. Beyond the engine, the host needs `just` and PowerShell Core -(`pwsh`), which every generated recipe requires, and the repository must own a `rust-toolchain.toml`. +(`pwsh`), which every generated recipe requires. The repository needs a declared root MSRV; a `rust-toolchain.toml` is +honoured where it exists but is not required (§1). | | Docker | Podman | | --- | --- | --- | @@ -631,9 +642,10 @@ Replacing a *region* rather than the whole file is what makes a downstream catal private-environment catalog rewrites the base and tool layers and nothing else. Replacing `dockerfile_setup()` reintroduces the second tool list the design exists to avoid, and is almost never right. -**A replacement must keep the ignore file in step.** A region that `COPY`s anything outside `justfiles/anvil/`, -`.anvil/container/` and `rust-toolchain.toml` must also replace `artifacts::container::dockerignore()` (§3), or the -added paths never reach the build context and the build fails on a missing file. +**A replacement must keep the ignore file in step.** The setup region `COPY`s the context whole, so the ignore file is +what decides the image's contents. A region that needs anything outside `justfiles/anvil/`, `.anvil/container/`, a root +toolchain file and the root `Cargo.toml` must also replace `artifacts::container::dockerignore()` (§3), or the added +paths never reach the build context. **Anything extra it copies is digested, provided it lives under `.anvil/container/`.** The hashed set is that whole directory (§4.1), so an installer script, a config file or a certificate placed beside the Dockerfile is an input: diff --git a/crates/cargo-anvil/src/anvil/artifacts/container.rs b/crates/cargo-anvil/src/anvil/artifacts/container.rs index 8e01f276..c7813f30 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/container.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/container.rs @@ -346,8 +346,15 @@ mod tests { // the same recipe the checks use, from the same generated pins. let composed = composed_dockerfile(); assert!(composed.contains("just anvil-setup binstall")); - assert!(composed.contains("COPY justfiles")); - assert!(composed.contains("COPY rust-toolchain.toml")); + // The context is copied whole, so the recipe tree and the declarations + // reach the image through the ignore file rather than by name. A root + // toolchain file is optional, and no engine offers a portable `COPY` of + // a path that may not exist. + assert!(composed.contains("COPY . ./")); + assert!( + !composed.contains("COPY rust-toolchain"), + "naming the toolchain file would leave a repository without one unable to build" + ); // The re-entry guard the recipe relies on to avoid nesting. assert!(composed.contains("ENV ANVIL_IN_CONTAINER=1")); } @@ -422,7 +429,13 @@ mod tests { #[test] fn build_context_admits_only_what_the_image_copies() { assert!(DOCKERIGNORE.contains("!justfiles")); + assert!(DOCKERIGNORE.contains("!Cargo.toml")); + // Both spellings, because the setup region names neither: admitting + // only the TOML would give a repository that pins with the + // extensionless file an image whose compiler silently disagreed with + // its own checkout. assert!(DOCKERIGNORE.contains("!rust-toolchain.toml")); + assert!(DOCKERIGNORE.contains("!rust-toolchain\n")); } #[test] diff --git a/crates/cargo-anvil/src/lib.rs b/crates/cargo-anvil/src/lib.rs index 1d665f5e..33b1e50d 100644 --- a/crates/cargo-anvil/src/lib.rs +++ b/crates/cargo-anvil/src/lib.rs @@ -158,7 +158,8 @@ //! through `wsl.exe` when it finds none on `PATH` and translates repository //! paths with `wslpath`. //! - `just` and `PowerShell` Core (`pwsh`) on the host. -//! - A repository-owned `rust-toolchain.toml`. +//! - A declared root MSRV. A repository-owned `rust-toolchain` file, in either +//! spelling, is honoured where it exists but is not required. //! //! Docker is supported; Podman works on a best-effort basis, with two //! documented gaps on Windows. The image is pinned to `linux/amd64`, so on @@ -167,7 +168,8 @@ //! ### Image identity //! //! The tag *is* a SHA-256 digest over the inputs that define the image: -//! everything under `.anvil/container/`, `rust-toolchain.toml`, and the whole +//! everything under `.anvil/container/`, a root `rust-toolchain` file where the +//! repository owns one, and the whole //! generated `justfiles/anvil/` tree. The container directory is walked rather //! than named file by file, because the Dockerfile is composed and a //! repository can `COPY` a certificate or an install script it places there. @@ -258,9 +260,11 @@ //! //! A downstream catalog that needs a different base OS for every repository it //! manages replaces the base and tool regions instead, inheriting the catalog -//! install and the entry contract. A replacement that copies more of the tree -//! must replace the ignore file with it, since the build context admits only -//! `justfiles/anvil/`, `.anvil/container/` and `rust-toolchain.toml`. See +//! install and the entry contract. A replacement that needs more of the tree +//! must replace the ignore file with it, since that file is what scopes the +//! build context — and, because the setup region copies the context whole, what +//! the image contains: `justfiles/anvil/`, `.anvil/container/`, a root +//! `rust-toolchain` file and the root `Cargo.toml`. See //! [`artifacts::container`] and the design document for the full contract, the //! host setup for each engine, and the known limitations. //! diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore b/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore index 98c979e2..2a198f55 100644 --- a/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore @@ -7,9 +7,11 @@ # `.dockerignore`, so this scopes the exec-image build context without the # repository having to own a root ignore file or having one silently overridden. # -# The build context is the repository root but the image only needs two things. -# Excluding everything else keeps a cold build from streaming the whole -# worktree (and every stale `target/`) to the daemon. +# The build context is the repository root but the image needs a small, named +# set out of it. Excluding everything else keeps a cold build from streaming the +# whole worktree (and every stale `target/`) to the daemon -- and, because the +# setup region copies the context whole, this file is also the definition of +# what the image contains. # # The context is narrowed to `justfiles/anvil/` rather than all of `justfiles/` # so that a cold build does not stream unrelated trees to the daemon. The @@ -29,6 +31,13 @@ # The root `Cargo.toml` is admitted for the declared MSRV, the only build input # that lives in repository source rather than in the recipe tree. The workspace # members it names stay out: they are a checkout, and the image is not one. +# +# Both toolchain-file spellings are admitted, and neither is required. A +# repository that owns one has its compiler selected from it, in the image as on +# a host; one that pins by other means builds an image whose compiler is the +# declared MSRV, which is what a host would resolve there too. Admitting only +# `rust-toolchain.toml` would give the second repository an image that silently +# disagreed with its own checkout. * !justfiles justfiles/* @@ -39,4 +48,5 @@ justfiles/anvil/**/*.anvil-proposed !.anvil/container .anvil/container/**/*.anvil-proposed !rust-toolchain.toml +!rust-toolchain !Cargo.toml diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region b/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region index 30b88801..4ee97f1a 100644 --- a/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region @@ -1,3 +1,14 @@ +# The context is copied whole rather than input by input, because one of the +# inputs is optional: a repository that pins its compiler by other means owns no +# root `rust-toolchain`/`rust-toolchain.toml`, and a `COPY` of a path that may +# not exist is not portable across the engines anvil supports. Naming the file +# would make an image unbuildable in exactly the repositories that have nothing +# to pin. `Dockerfile.dockerignore` already scopes the context to precisely the +# image's inputs, so deferring to it costs no breadth and makes what the context +# admits and what the image contains the same set by construction. What rides +# along -- `.anvil/container/` -- is the committed input the gaps below `COPY` +# from, and the image never runs it. +# # The whole recipe tree is copied because `just` parses it to reach the install # recipes. # @@ -14,16 +25,15 @@ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. -# The root manifest is copied for the MSRV, the one version `anvil-setup` +# The root manifest is admitted for the MSRV, the one version `anvil-setup` # installs that is declared in repository source rather than pinned in # `versions.just`. `/opt/anvil` is the root the recipes already resolve against, # so the resolver reads it there with no container-specific path. The workspace -# members it names are not copied and are not needed: the toolchain file selects -# the compiler, which is the branch that skips workspace MSRV validation. +# members it names are not admitted and are not needed: the one path that reads +# them is workspace MSRV validation, which hangs off the `-validate-prereqs` +# recipes, and `anvil-setup` depends on none of them. WORKDIR /opt/anvil -COPY justfiles ./justfiles -COPY rust-toolchain.toml ./ -COPY Cargo.toml ./ +COPY . ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ && just anvil-setup binstall \ && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ diff --git a/crates/cargo-anvil/templates/justfiles/anvil/container.just b/crates/cargo-anvil/templates/justfiles/anvil/container.just index 2973625a..656966b8 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/container.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/container.just @@ -143,11 +143,12 @@ _anvil-container-dockerfile: # Print the exec image reference for the current inputs, without building it. # # The tag is a SHA-256 over the image's declared inputs: the Dockerfile and its -# ignore file, the pinned toolchain, the optional hook, the whole generated -# recipe tree -- because the image installs its tools by running -# `just anvil-setup`, whose dependency chain reaches the tier, group, check and -# tool recipes alike -- and the declared root MSRV. Editing any of them can -# change what the image contains, so any of them can rename it. +# ignore file, the root toolchain file where the repository owns one, the +# optional hook, the whole generated recipe tree -- because the image installs +# its tools by running `just anvil-setup`, whose dependency chain reaches the +# tier, group, check and tool recipes alike -- and the declared root MSRV. +# Editing any of them can change what the image contains, so any of them can +# rename it. # # This is the only recipe that computes the reference; everything else asks it. # It is public because a publisher needs the tag before there is an image to @@ -175,22 +176,35 @@ anvil-container-tag: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $rootMsrv = "$rootMsrv".Trim() - # Both named rather than left to the walk, so that either one going missing - # is a hard failure at the check below. The walk cannot notice an absent - # file, and the ignore file's absence is the worse of the two: the build - # still succeeds, but BuildKit falls back to a root ignore file or none, so - # the context widens to files the digest does not hash and the tag stops - # covering what `COPY` puts in the image. - $inputs = @('rust-toolchain.toml', "$dockerfile.dockerignore") + # The ignore file is named rather than left to the walk, so that it going + # missing is a hard failure at the check below. The walk cannot notice an + # absent file, and this absence is the quiet kind: the build still succeeds, + # but BuildKit falls back to a root ignore file or none, so the context + # widens to files the digest does not hash and the tag stops covering what + # `COPY` puts in the image. + $inputs = @("$dockerfile.dockerignore") + + # A root toolchain file is optional -- a repository may pin its compiler by + # other means -- so it is discovered rather than required, and absence is + # simply one fewer record in the stream. Both spellings are checked because + # the context admits both and rustup reads both. The digest still has to + # follow it: the image installs the toolchain it selects, so an edit to it + # changes what the image contains. + $toolchainFiles = @( + @('rust-toolchain.toml', 'rust-toolchain') | + Where-Object { Test-Path -LiteralPath (Join-Path $repoRoot $_) -PathType Leaf } + ) + $inputs += $toolchainFiles + $links = @() - # The declared input and every directory on the way to a walk root are + # The declared inputs and every directory on the way to a walk root are # checked here, because a walk only ever reports descendants: a link that # *is* a root, or that is a parent of one, is traversed through and never - # appears in its own output. `COPY justfiles ./justfiles` copies from - # `justfiles`, not from `justfiles/anvil`, so a link at the parent is copied - # as a link while everything here reads through it -- the same divergence - # this guard exists to refuse, reached one level up. - foreach ($declared in @('rust-toolchain.toml', '.anvil', '.anvil/container', 'justfiles', 'justfiles/anvil')) { + # appears in its own output. `COPY . ./` copies from the context root, so a + # link at `justfiles` is copied as a link while everything here reads + # through it -- the same divergence this guard exists to refuse, reached one + # level up. + foreach ($declared in @('rust-toolchain.toml', 'rust-toolchain', '.anvil', '.anvil/container', 'justfiles', 'justfiles/anvil')) { $item = Get-Item -LiteralPath (Join-Path $repoRoot $declared) -Force -ErrorAction SilentlyContinue if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { $links += $item.FullName @@ -351,7 +365,7 @@ anvil-container-tag: # file admits, so an unstaged `chmod` on one of those would refuse a build # it cannot affect. $indexMode = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) - $tracked = @('.anvil/container', 'justfiles/anvil', 'rust-toolchain.toml') + $tracked = @('.anvil/container', 'justfiles/anvil', 'rust-toolchain.toml', 'rust-toolchain') if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Write-Error 'anvil: git is required to compute the container image tag, because the image inputs are framed with the file modes git records. Without it two hosts would compute different tags for identical content. Install git, or set ANVIL_CONTAINER_ENGINE aside and build the image where git is available.' exit 1 diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index 3e595cbe..c3262738 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -480,8 +480,8 @@ fn container_build_carries_the_manifest_that_declares_the_msrv() { "the build context must admit the root manifest, or the setup cannot resolve the MSRV" ); assert!( - CONTAINER_SETUP_REGION.contains("COPY Cargo.toml ./"), - "the setup region must copy the manifest to the root the recipes resolve against" + CONTAINER_SETUP_REGION.contains("COPY . ./"), + "the setup region must copy the scoped context to the root the recipes resolve against" ); // The members it names are a checkout, and the image is not one. assert!( @@ -503,16 +503,36 @@ fn container_build_carries_the_manifest_that_declares_the_msrv() { !CONTAINER_SETUP_REGION.contains("ANVIL_ROOT_MSRV"), "the setup region must not reintroduce the build argument" ); - // Load-bearing for the two assertions above rather than incidental. The - // resolver's workspace MSRV validation reads every member manifest, which - // this context does not carry -- and it is unreachable only because a root - // toolchain file selects the compiler, which makes it return early. Making - // this COPY conditional, so a repository without one can build, would put - // that branch back in reach of a partial workspace. +} + +/// A repository may pin its compiler by means other than a root toolchain file, +/// and such a repository must still be able to build an image. No engine anvil +/// supports offers a portable `COPY` of a path that may not exist, so the setup +/// region names no input at all and the ignore file decides what the context -- +/// and therefore the image -- contains. +#[test] +fn the_container_build_does_not_require_a_root_toolchain_file() { assert!( - CONTAINER_SETUP_REGION.contains("COPY rust-toolchain.toml ./"), - "the setup region must copy a root toolchain file: the MSRV design depends on one being \ - present to keep workspace validation out of reach of a context with no members" + !CONTAINER_SETUP_REGION.contains("COPY rust-toolchain"), + "naming the toolchain file makes the image unbuildable in exactly the repositories that \ + have nothing to pin" + ); + // Both spellings, because neither is named by a `COPY`: admitting only the + // TOML would leave a repository that pins with the extensionless file + // building an image whose compiler disagreed with its own checkout. + assert!( + CONTAINER_DOCKERIGNORE.contains("!rust-toolchain.toml") && CONTAINER_DOCKERIGNORE.contains("!rust-toolchain\n"), + "the context must admit a root toolchain file in either spelling" + ); + // The digest still has to follow the file where one exists, so it is + // discovered rather than required. + assert!( + CONTAINER.contains("$toolchainFiles"), + "the tag must hash a root toolchain file when the repository owns one" + ); + assert!( + !CONTAINER.contains("$inputs = @('rust-toolchain.toml'"), + "the tag must not fail on a repository that owns no root toolchain file" ); } @@ -1824,6 +1844,63 @@ fn the_image_tag_follows_the_declared_msrv() { assert_eq!(declared, tag("1.93.1"), "the tag must depend on the inputs alone"); } +/// A repository may pin its compiler by means other than a root toolchain file, +/// so the tag has to answer for one that owns none rather than refusing it. The +/// two states must still be distinguishable: the image selects its compiler +/// from that file where it exists and from the declared MSRV where it does not, +/// so they cannot share a reference. Both spellings count, because the context +/// admits both and rustup reads both. +#[test] +fn the_image_tag_treats_a_root_toolchain_file_as_optional() { + if !tools_available() { + return; + } + let tmp = fixture(&[("container.just", CONTAINER)], &[]); + let root = tmp.path(); + write(&root.join(".anvil/container/Dockerfile"), "FROM scratch\n"); + write(&root.join(".anvil/container/Dockerfile.dockerignore"), "*\n!justfiles\n"); + write(&root.join("justfiles/anvil/mod.just"), "# recipes\n"); + stub_msrv_resolver(root); + write(&root.join("fake-bin/git.ps1"), "exit 0\n"); + + let tag = || { + let output = run_just(root, &["anvil-container-tag"], &[]); + assert!( + output.status.success(), + "computing the tag failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_owned() + }; + + let none = tag(); + + write(&root.join("rust-toolchain.toml"), "[toolchain]\nchannel = \"1.90\"\n"); + let toml = tag(); + assert_ne!( + none, toml, + "owning a toolchain file changes the image's compiler, so it must rename it" + ); + + write(&root.join("rust-toolchain.toml"), "[toolchain]\nchannel = \"1.91\"\n"); + assert_ne!( + toml, + tag(), + "the image installs the toolchain the file selects, so an edit must rename it" + ); + + fs::remove_file(root.join("rust-toolchain.toml")).unwrap(); + write(&root.join("rust-toolchain"), "[toolchain]\nchannel = \"1.90\"\n"); + let extensionless = tag(); + assert_ne!(none, extensionless, "the extensionless spelling is an image input too"); + assert_ne!( + toml, extensionless, + "the same bytes under the other spelling are a different input, and rustup reads them in a \ + different order" + ); +} + /// The tag is computed from the index while the build copies the working tree, /// so the two have to agree about the executable bit. Where they do not, the /// reference names an image the build does not produce, and the run stops diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index d0cddc22..35754cd5 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -80,6 +80,17 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # <<< anvil-managed: anvil-container-tools # >>> anvil-managed: anvil-container-setup +# The context is copied whole rather than input by input, because one of the +# inputs is optional: a repository that pins its compiler by other means owns no +# root `rust-toolchain`/`rust-toolchain.toml`, and a `COPY` of a path that may +# not exist is not portable across the engines anvil supports. Naming the file +# would make an image unbuildable in exactly the repositories that have nothing +# to pin. `Dockerfile.dockerignore` already scopes the context to precisely the +# image's inputs, so deferring to it costs no breadth and makes what the context +# admits and what the image contains the same set by construction. What rides +# along -- `.anvil/container/` -- is the committed input the gaps below `COPY` +# from, and the image never runs it. +# # The whole recipe tree is copied because `just` parses it to reach the install # recipes. # @@ -96,16 +107,15 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. -# The root manifest is copied for the MSRV, the one version `anvil-setup` +# The root manifest is admitted for the MSRV, the one version `anvil-setup` # installs that is declared in repository source rather than pinned in # `versions.just`. `/opt/anvil` is the root the recipes already resolve against, # so the resolver reads it there with no container-specific path. The workspace -# members it names are not copied and are not needed: the toolchain file selects -# the compiler, which is the branch that skips workspace MSRV validation. +# members it names are not admitted and are not needed: the one path that reads +# them is workspace MSRV validation, which hangs off the `-validate-prereqs` +# recipes, and `anvil-setup` depends on none of them. WORKDIR /opt/anvil -COPY justfiles ./justfiles -COPY rust-toolchain.toml ./ -COPY Cargo.toml ./ +COPY . ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ && just anvil-setup binstall \ && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ @@ -133,9 +143,11 @@ CMD ["bash"] # `.dockerignore`, so this scopes the exec-image build context without the # repository having to own a root ignore file or having one silently overridden. # -# The build context is the repository root but the image only needs two things. -# Excluding everything else keeps a cold build from streaming the whole -# worktree (and every stale `target/`) to the daemon. +# The build context is the repository root but the image needs a small, named +# set out of it. Excluding everything else keeps a cold build from streaming the +# whole worktree (and every stale `target/`) to the daemon -- and, because the +# setup region copies the context whole, this file is also the definition of +# what the image contains. # # The context is narrowed to `justfiles/anvil/` rather than all of `justfiles/` # so that a cold build does not stream unrelated trees to the daemon. The @@ -155,6 +167,13 @@ CMD ["bash"] # The root `Cargo.toml` is admitted for the declared MSRV, the only build input # that lives in repository source rather than in the recipe tree. The workspace # members it names stay out: they are a checkout, and the image is not one. +# +# Both toolchain-file spellings are admitted, and neither is required. A +# repository that owns one has its compiler selected from it, in the image as on +# a host; one that pins by other means builds an image whose compiler is the +# declared MSRV, which is what a host would resolve there too. Admitting only +# `rust-toolchain.toml` would give the second repository an image that silently +# disagreed with its own checkout. * !justfiles justfiles/* @@ -165,6 +184,7 @@ justfiles/anvil/**/*.anvil-proposed !.anvil/container .anvil/container/**/*.anvil-proposed !rust-toolchain.toml +!rust-toolchain !Cargo.toml === .delta.toml === @@ -3653,11 +3673,12 @@ _anvil-container-dockerfile: # Print the exec image reference for the current inputs, without building it. # # The tag is a SHA-256 over the image's declared inputs: the Dockerfile and its -# ignore file, the pinned toolchain, the optional hook, the whole generated -# recipe tree -- because the image installs its tools by running -# `just anvil-setup`, whose dependency chain reaches the tier, group, check and -# tool recipes alike -- and the declared root MSRV. Editing any of them can -# change what the image contains, so any of them can rename it. +# ignore file, the root toolchain file where the repository owns one, the +# optional hook, the whole generated recipe tree -- because the image installs +# its tools by running `just anvil-setup`, whose dependency chain reaches the +# tier, group, check and tool recipes alike -- and the declared root MSRV. +# Editing any of them can change what the image contains, so any of them can +# rename it. # # This is the only recipe that computes the reference; everything else asks it. # It is public because a publisher needs the tag before there is an image to @@ -3685,22 +3706,35 @@ anvil-container-tag: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $rootMsrv = "$rootMsrv".Trim() - # Both named rather than left to the walk, so that either one going missing - # is a hard failure at the check below. The walk cannot notice an absent - # file, and the ignore file's absence is the worse of the two: the build - # still succeeds, but BuildKit falls back to a root ignore file or none, so - # the context widens to files the digest does not hash and the tag stops - # covering what `COPY` puts in the image. - $inputs = @('rust-toolchain.toml', "$dockerfile.dockerignore") + # The ignore file is named rather than left to the walk, so that it going + # missing is a hard failure at the check below. The walk cannot notice an + # absent file, and this absence is the quiet kind: the build still succeeds, + # but BuildKit falls back to a root ignore file or none, so the context + # widens to files the digest does not hash and the tag stops covering what + # `COPY` puts in the image. + $inputs = @("$dockerfile.dockerignore") + + # A root toolchain file is optional -- a repository may pin its compiler by + # other means -- so it is discovered rather than required, and absence is + # simply one fewer record in the stream. Both spellings are checked because + # the context admits both and rustup reads both. The digest still has to + # follow it: the image installs the toolchain it selects, so an edit to it + # changes what the image contains. + $toolchainFiles = @( + @('rust-toolchain.toml', 'rust-toolchain') | + Where-Object { Test-Path -LiteralPath (Join-Path $repoRoot $_) -PathType Leaf } + ) + $inputs += $toolchainFiles + $links = @() - # The declared input and every directory on the way to a walk root are + # The declared inputs and every directory on the way to a walk root are # checked here, because a walk only ever reports descendants: a link that # *is* a root, or that is a parent of one, is traversed through and never - # appears in its own output. `COPY justfiles ./justfiles` copies from - # `justfiles`, not from `justfiles/anvil`, so a link at the parent is copied - # as a link while everything here reads through it -- the same divergence - # this guard exists to refuse, reached one level up. - foreach ($declared in @('rust-toolchain.toml', '.anvil', '.anvil/container', 'justfiles', 'justfiles/anvil')) { + # appears in its own output. `COPY . ./` copies from the context root, so a + # link at `justfiles` is copied as a link while everything here reads + # through it -- the same divergence this guard exists to refuse, reached one + # level up. + foreach ($declared in @('rust-toolchain.toml', 'rust-toolchain', '.anvil', '.anvil/container', 'justfiles', 'justfiles/anvil')) { $item = Get-Item -LiteralPath (Join-Path $repoRoot $declared) -Force -ErrorAction SilentlyContinue if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { $links += $item.FullName @@ -3861,7 +3895,7 @@ anvil-container-tag: # file admits, so an unstaged `chmod` on one of those would refuse a build # it cannot affect. $indexMode = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) - $tracked = @('.anvil/container', 'justfiles/anvil', 'rust-toolchain.toml') + $tracked = @('.anvil/container', 'justfiles/anvil', 'rust-toolchain.toml', 'rust-toolchain') if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Write-Error 'anvil: git is required to compute the container image tag, because the image inputs are framed with the file modes git records. Without it two hosts would compute different tags for identical content. Install git, or set ANVIL_CONTAINER_ENGINE aside and build the image where git is available.' exit 1 diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index 1855d95f..6ddcf2e7 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -80,6 +80,17 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # <<< anvil-managed: anvil-container-tools # >>> anvil-managed: anvil-container-setup +# The context is copied whole rather than input by input, because one of the +# inputs is optional: a repository that pins its compiler by other means owns no +# root `rust-toolchain`/`rust-toolchain.toml`, and a `COPY` of a path that may +# not exist is not portable across the engines anvil supports. Naming the file +# would make an image unbuildable in exactly the repositories that have nothing +# to pin. `Dockerfile.dockerignore` already scopes the context to precisely the +# image's inputs, so deferring to it costs no breadth and makes what the context +# admits and what the image contains the same set by construction. What rides +# along -- `.anvil/container/` -- is the committed input the gaps below `COPY` +# from, and the image never runs it. +# # The whole recipe tree is copied because `just` parses it to reach the install # recipes. # @@ -96,16 +107,15 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. -# The root manifest is copied for the MSRV, the one version `anvil-setup` +# The root manifest is admitted for the MSRV, the one version `anvil-setup` # installs that is declared in repository source rather than pinned in # `versions.just`. `/opt/anvil` is the root the recipes already resolve against, # so the resolver reads it there with no container-specific path. The workspace -# members it names are not copied and are not needed: the toolchain file selects -# the compiler, which is the branch that skips workspace MSRV validation. +# members it names are not admitted and are not needed: the one path that reads +# them is workspace MSRV validation, which hangs off the `-validate-prereqs` +# recipes, and `anvil-setup` depends on none of them. WORKDIR /opt/anvil -COPY justfiles ./justfiles -COPY rust-toolchain.toml ./ -COPY Cargo.toml ./ +COPY . ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ && just anvil-setup binstall \ && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ @@ -133,9 +143,11 @@ CMD ["bash"] # `.dockerignore`, so this scopes the exec-image build context without the # repository having to own a root ignore file or having one silently overridden. # -# The build context is the repository root but the image only needs two things. -# Excluding everything else keeps a cold build from streaming the whole -# worktree (and every stale `target/`) to the daemon. +# The build context is the repository root but the image needs a small, named +# set out of it. Excluding everything else keeps a cold build from streaming the +# whole worktree (and every stale `target/`) to the daemon -- and, because the +# setup region copies the context whole, this file is also the definition of +# what the image contains. # # The context is narrowed to `justfiles/anvil/` rather than all of `justfiles/` # so that a cold build does not stream unrelated trees to the daemon. The @@ -155,6 +167,13 @@ CMD ["bash"] # The root `Cargo.toml` is admitted for the declared MSRV, the only build input # that lives in repository source rather than in the recipe tree. The workspace # members it names stay out: they are a checkout, and the image is not one. +# +# Both toolchain-file spellings are admitted, and neither is required. A +# repository that owns one has its compiler selected from it, in the image as on +# a host; one that pins by other means builds an image whose compiler is the +# declared MSRV, which is what a host would resolve there too. Admitting only +# `rust-toolchain.toml` would give the second repository an image that silently +# disagreed with its own checkout. * !justfiles justfiles/* @@ -165,6 +184,7 @@ justfiles/anvil/**/*.anvil-proposed !.anvil/container .anvil/container/**/*.anvil-proposed !rust-toolchain.toml +!rust-toolchain !Cargo.toml === .delta.toml === @@ -3790,11 +3810,12 @@ _anvil-container-dockerfile: # Print the exec image reference for the current inputs, without building it. # # The tag is a SHA-256 over the image's declared inputs: the Dockerfile and its -# ignore file, the pinned toolchain, the optional hook, the whole generated -# recipe tree -- because the image installs its tools by running -# `just anvil-setup`, whose dependency chain reaches the tier, group, check and -# tool recipes alike -- and the declared root MSRV. Editing any of them can -# change what the image contains, so any of them can rename it. +# ignore file, the root toolchain file where the repository owns one, the +# optional hook, the whole generated recipe tree -- because the image installs +# its tools by running `just anvil-setup`, whose dependency chain reaches the +# tier, group, check and tool recipes alike -- and the declared root MSRV. +# Editing any of them can change what the image contains, so any of them can +# rename it. # # This is the only recipe that computes the reference; everything else asks it. # It is public because a publisher needs the tag before there is an image to @@ -3822,22 +3843,35 @@ anvil-container-tag: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $rootMsrv = "$rootMsrv".Trim() - # Both named rather than left to the walk, so that either one going missing - # is a hard failure at the check below. The walk cannot notice an absent - # file, and the ignore file's absence is the worse of the two: the build - # still succeeds, but BuildKit falls back to a root ignore file or none, so - # the context widens to files the digest does not hash and the tag stops - # covering what `COPY` puts in the image. - $inputs = @('rust-toolchain.toml', "$dockerfile.dockerignore") + # The ignore file is named rather than left to the walk, so that it going + # missing is a hard failure at the check below. The walk cannot notice an + # absent file, and this absence is the quiet kind: the build still succeeds, + # but BuildKit falls back to a root ignore file or none, so the context + # widens to files the digest does not hash and the tag stops covering what + # `COPY` puts in the image. + $inputs = @("$dockerfile.dockerignore") + + # A root toolchain file is optional -- a repository may pin its compiler by + # other means -- so it is discovered rather than required, and absence is + # simply one fewer record in the stream. Both spellings are checked because + # the context admits both and rustup reads both. The digest still has to + # follow it: the image installs the toolchain it selects, so an edit to it + # changes what the image contains. + $toolchainFiles = @( + @('rust-toolchain.toml', 'rust-toolchain') | + Where-Object { Test-Path -LiteralPath (Join-Path $repoRoot $_) -PathType Leaf } + ) + $inputs += $toolchainFiles + $links = @() - # The declared input and every directory on the way to a walk root are + # The declared inputs and every directory on the way to a walk root are # checked here, because a walk only ever reports descendants: a link that # *is* a root, or that is a parent of one, is traversed through and never - # appears in its own output. `COPY justfiles ./justfiles` copies from - # `justfiles`, not from `justfiles/anvil`, so a link at the parent is copied - # as a link while everything here reads through it -- the same divergence - # this guard exists to refuse, reached one level up. - foreach ($declared in @('rust-toolchain.toml', '.anvil', '.anvil/container', 'justfiles', 'justfiles/anvil')) { + # appears in its own output. `COPY . ./` copies from the context root, so a + # link at `justfiles` is copied as a link while everything here reads + # through it -- the same divergence this guard exists to refuse, reached one + # level up. + foreach ($declared in @('rust-toolchain.toml', 'rust-toolchain', '.anvil', '.anvil/container', 'justfiles', 'justfiles/anvil')) { $item = Get-Item -LiteralPath (Join-Path $repoRoot $declared) -Force -ErrorAction SilentlyContinue if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { $links += $item.FullName @@ -3998,7 +4032,7 @@ anvil-container-tag: # file admits, so an unstaged `chmod` on one of those would refuse a build # it cannot affect. $indexMode = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) - $tracked = @('.anvil/container', 'justfiles/anvil', 'rust-toolchain.toml') + $tracked = @('.anvil/container', 'justfiles/anvil', 'rust-toolchain.toml', 'rust-toolchain') if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Write-Error 'anvil: git is required to compute the container image tag, because the image inputs are framed with the file modes git records. Without it two hosts would compute different tags for identical content. Install git, or set ANVIL_CONTAINER_ENGINE aside and build the image where git is available.' exit 1 diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index ece923dd..8551b2c4 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -80,6 +80,17 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # <<< anvil-managed: anvil-container-tools # >>> anvil-managed: anvil-container-setup +# The context is copied whole rather than input by input, because one of the +# inputs is optional: a repository that pins its compiler by other means owns no +# root `rust-toolchain`/`rust-toolchain.toml`, and a `COPY` of a path that may +# not exist is not portable across the engines anvil supports. Naming the file +# would make an image unbuildable in exactly the repositories that have nothing +# to pin. `Dockerfile.dockerignore` already scopes the context to precisely the +# image's inputs, so deferring to it costs no breadth and makes what the context +# admits and what the image contains the same set by construction. What rides +# along -- `.anvil/container/` -- is the committed input the gaps below `COPY` +# from, and the image never runs it. +# # The whole recipe tree is copied because `just` parses it to reach the install # recipes. # @@ -96,16 +107,15 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # volume over each, and an engine seeds a new volume from the image path it # covers; a path that does not exist seeds as root-owned 0755, which the # `--user` mapping cannot write, so the first cargo fetch fails with EACCES. -# The root manifest is copied for the MSRV, the one version `anvil-setup` +# The root manifest is admitted for the MSRV, the one version `anvil-setup` # installs that is declared in repository source rather than pinned in # `versions.just`. `/opt/anvil` is the root the recipes already resolve against, # so the resolver reads it there with no container-specific path. The workspace -# members it names are not copied and are not needed: the toolchain file selects -# the compiler, which is the branch that skips workspace MSRV validation. +# members it names are not admitted and are not needed: the one path that reads +# them is workspace MSRV validation, which hangs off the `-validate-prereqs` +# recipes, and `anvil-setup` depends on none of them. WORKDIR /opt/anvil -COPY justfiles ./justfiles -COPY rust-toolchain.toml ./ -COPY Cargo.toml ./ +COPY . ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ && just anvil-setup binstall \ && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ @@ -133,9 +143,11 @@ CMD ["bash"] # `.dockerignore`, so this scopes the exec-image build context without the # repository having to own a root ignore file or having one silently overridden. # -# The build context is the repository root but the image only needs two things. -# Excluding everything else keeps a cold build from streaming the whole -# worktree (and every stale `target/`) to the daemon. +# The build context is the repository root but the image needs a small, named +# set out of it. Excluding everything else keeps a cold build from streaming the +# whole worktree (and every stale `target/`) to the daemon -- and, because the +# setup region copies the context whole, this file is also the definition of +# what the image contains. # # The context is narrowed to `justfiles/anvil/` rather than all of `justfiles/` # so that a cold build does not stream unrelated trees to the daemon. The @@ -155,6 +167,13 @@ CMD ["bash"] # The root `Cargo.toml` is admitted for the declared MSRV, the only build input # that lives in repository source rather than in the recipe tree. The workspace # members it names stay out: they are a checkout, and the image is not one. +# +# Both toolchain-file spellings are admitted, and neither is required. A +# repository that owns one has its compiler selected from it, in the image as on +# a host; one that pins by other means builds an image whose compiler is the +# declared MSRV, which is what a host would resolve there too. Admitting only +# `rust-toolchain.toml` would give the second repository an image that silently +# disagreed with its own checkout. * !justfiles justfiles/* @@ -165,6 +184,7 @@ justfiles/anvil/**/*.anvil-proposed !.anvil/container .anvil/container/**/*.anvil-proposed !rust-toolchain.toml +!rust-toolchain !Cargo.toml === .delta.toml === @@ -2523,11 +2543,12 @@ _anvil-container-dockerfile: # Print the exec image reference for the current inputs, without building it. # # The tag is a SHA-256 over the image's declared inputs: the Dockerfile and its -# ignore file, the pinned toolchain, the optional hook, the whole generated -# recipe tree -- because the image installs its tools by running -# `just anvil-setup`, whose dependency chain reaches the tier, group, check and -# tool recipes alike -- and the declared root MSRV. Editing any of them can -# change what the image contains, so any of them can rename it. +# ignore file, the root toolchain file where the repository owns one, the +# optional hook, the whole generated recipe tree -- because the image installs +# its tools by running `just anvil-setup`, whose dependency chain reaches the +# tier, group, check and tool recipes alike -- and the declared root MSRV. +# Editing any of them can change what the image contains, so any of them can +# rename it. # # This is the only recipe that computes the reference; everything else asks it. # It is public because a publisher needs the tag before there is an image to @@ -2555,22 +2576,35 @@ anvil-container-tag: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $rootMsrv = "$rootMsrv".Trim() - # Both named rather than left to the walk, so that either one going missing - # is a hard failure at the check below. The walk cannot notice an absent - # file, and the ignore file's absence is the worse of the two: the build - # still succeeds, but BuildKit falls back to a root ignore file or none, so - # the context widens to files the digest does not hash and the tag stops - # covering what `COPY` puts in the image. - $inputs = @('rust-toolchain.toml', "$dockerfile.dockerignore") + # The ignore file is named rather than left to the walk, so that it going + # missing is a hard failure at the check below. The walk cannot notice an + # absent file, and this absence is the quiet kind: the build still succeeds, + # but BuildKit falls back to a root ignore file or none, so the context + # widens to files the digest does not hash and the tag stops covering what + # `COPY` puts in the image. + $inputs = @("$dockerfile.dockerignore") + + # A root toolchain file is optional -- a repository may pin its compiler by + # other means -- so it is discovered rather than required, and absence is + # simply one fewer record in the stream. Both spellings are checked because + # the context admits both and rustup reads both. The digest still has to + # follow it: the image installs the toolchain it selects, so an edit to it + # changes what the image contains. + $toolchainFiles = @( + @('rust-toolchain.toml', 'rust-toolchain') | + Where-Object { Test-Path -LiteralPath (Join-Path $repoRoot $_) -PathType Leaf } + ) + $inputs += $toolchainFiles + $links = @() - # The declared input and every directory on the way to a walk root are + # The declared inputs and every directory on the way to a walk root are # checked here, because a walk only ever reports descendants: a link that # *is* a root, or that is a parent of one, is traversed through and never - # appears in its own output. `COPY justfiles ./justfiles` copies from - # `justfiles`, not from `justfiles/anvil`, so a link at the parent is copied - # as a link while everything here reads through it -- the same divergence - # this guard exists to refuse, reached one level up. - foreach ($declared in @('rust-toolchain.toml', '.anvil', '.anvil/container', 'justfiles', 'justfiles/anvil')) { + # appears in its own output. `COPY . ./` copies from the context root, so a + # link at `justfiles` is copied as a link while everything here reads + # through it -- the same divergence this guard exists to refuse, reached one + # level up. + foreach ($declared in @('rust-toolchain.toml', 'rust-toolchain', '.anvil', '.anvil/container', 'justfiles', 'justfiles/anvil')) { $item = Get-Item -LiteralPath (Join-Path $repoRoot $declared) -Force -ErrorAction SilentlyContinue if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { $links += $item.FullName @@ -2731,7 +2765,7 @@ anvil-container-tag: # file admits, so an unstaged `chmod` on one of those would refuse a build # it cannot affect. $indexMode = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) - $tracked = @('.anvil/container', 'justfiles/anvil', 'rust-toolchain.toml') + $tracked = @('.anvil/container', 'justfiles/anvil', 'rust-toolchain.toml', 'rust-toolchain') if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Write-Error 'anvil: git is required to compute the container image tag, because the image inputs are framed with the file modes git records. Without it two hosts would compute different tags for identical content. Install git, or set ANVIL_CONTAINER_ENGINE aside and build the image where git is available.' exit 1 diff --git a/justfiles/anvil/container.just b/justfiles/anvil/container.just index 2973625a..656966b8 100644 --- a/justfiles/anvil/container.just +++ b/justfiles/anvil/container.just @@ -143,11 +143,12 @@ _anvil-container-dockerfile: # Print the exec image reference for the current inputs, without building it. # # The tag is a SHA-256 over the image's declared inputs: the Dockerfile and its -# ignore file, the pinned toolchain, the optional hook, the whole generated -# recipe tree -- because the image installs its tools by running -# `just anvil-setup`, whose dependency chain reaches the tier, group, check and -# tool recipes alike -- and the declared root MSRV. Editing any of them can -# change what the image contains, so any of them can rename it. +# ignore file, the root toolchain file where the repository owns one, the +# optional hook, the whole generated recipe tree -- because the image installs +# its tools by running `just anvil-setup`, whose dependency chain reaches the +# tier, group, check and tool recipes alike -- and the declared root MSRV. +# Editing any of them can change what the image contains, so any of them can +# rename it. # # This is the only recipe that computes the reference; everything else asks it. # It is public because a publisher needs the tag before there is an image to @@ -175,22 +176,35 @@ anvil-container-tag: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $rootMsrv = "$rootMsrv".Trim() - # Both named rather than left to the walk, so that either one going missing - # is a hard failure at the check below. The walk cannot notice an absent - # file, and the ignore file's absence is the worse of the two: the build - # still succeeds, but BuildKit falls back to a root ignore file or none, so - # the context widens to files the digest does not hash and the tag stops - # covering what `COPY` puts in the image. - $inputs = @('rust-toolchain.toml', "$dockerfile.dockerignore") + # The ignore file is named rather than left to the walk, so that it going + # missing is a hard failure at the check below. The walk cannot notice an + # absent file, and this absence is the quiet kind: the build still succeeds, + # but BuildKit falls back to a root ignore file or none, so the context + # widens to files the digest does not hash and the tag stops covering what + # `COPY` puts in the image. + $inputs = @("$dockerfile.dockerignore") + + # A root toolchain file is optional -- a repository may pin its compiler by + # other means -- so it is discovered rather than required, and absence is + # simply one fewer record in the stream. Both spellings are checked because + # the context admits both and rustup reads both. The digest still has to + # follow it: the image installs the toolchain it selects, so an edit to it + # changes what the image contains. + $toolchainFiles = @( + @('rust-toolchain.toml', 'rust-toolchain') | + Where-Object { Test-Path -LiteralPath (Join-Path $repoRoot $_) -PathType Leaf } + ) + $inputs += $toolchainFiles + $links = @() - # The declared input and every directory on the way to a walk root are + # The declared inputs and every directory on the way to a walk root are # checked here, because a walk only ever reports descendants: a link that # *is* a root, or that is a parent of one, is traversed through and never - # appears in its own output. `COPY justfiles ./justfiles` copies from - # `justfiles`, not from `justfiles/anvil`, so a link at the parent is copied - # as a link while everything here reads through it -- the same divergence - # this guard exists to refuse, reached one level up. - foreach ($declared in @('rust-toolchain.toml', '.anvil', '.anvil/container', 'justfiles', 'justfiles/anvil')) { + # appears in its own output. `COPY . ./` copies from the context root, so a + # link at `justfiles` is copied as a link while everything here reads + # through it -- the same divergence this guard exists to refuse, reached one + # level up. + foreach ($declared in @('rust-toolchain.toml', 'rust-toolchain', '.anvil', '.anvil/container', 'justfiles', 'justfiles/anvil')) { $item = Get-Item -LiteralPath (Join-Path $repoRoot $declared) -Force -ErrorAction SilentlyContinue if ($item -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { $links += $item.FullName @@ -351,7 +365,7 @@ anvil-container-tag: # file admits, so an unstaged `chmod` on one of those would refuse a build # it cannot affect. $indexMode = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) - $tracked = @('.anvil/container', 'justfiles/anvil', 'rust-toolchain.toml') + $tracked = @('.anvil/container', 'justfiles/anvil', 'rust-toolchain.toml', 'rust-toolchain') if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Write-Error 'anvil: git is required to compute the container image tag, because the image inputs are framed with the file modes git records. Without it two hosts would compute different tags for identical content. Install git, or set ANVIL_CONTAINER_ENGINE aside and build the image where git is available.' exit 1 From 4b6fef2183e9a61efeab1de55c74c76e7c0143d6 Mon Sep 17 00:00:00 2001 From: Martin Havelka Date: Fri, 4 Sep 2026 13:40:21 +0200 Subject: [PATCH 6/7] fix(cargo-anvil): delete the copied manifest once the setup has read it The tag hashes the declared MSRV rather than the manifest, so an edit that leaves rust-version alone computes the same tag. With the manifest left in place that tag named two different filesystems, and a reused or published image carried a Cargo.toml matching no checkout. Deleting it in the same RUN that reads it makes the identity exact again, and restores the design note's claim that those edits cannot alter a byte the image contains. Three statements in containers.md that still described the old three-path context are corrected alongside it, including the replacement rule a downstream catalog reads verbatim. Also drops an env_remove for ANVIL_ROOT_MSRV: nothing on this branch reads that variable, so the call could not affect what a fixture resolves and its comment described a mechanism that no longer exists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2963084a-ef08-4cf7-adc6-e78e61556a84 --- .anvil.lock | 4 ++-- .anvil/container/Dockerfile | 7 +++++++ crates/cargo-anvil/docs/design/containers.md | 17 ++++++++++++----- .../anvil/container/Dockerfile.setup.region | 7 +++++++ crates/cargo-anvil/tests/recipe_contracts.rs | 12 ++++++++---- .../tests/snapshots/snapshots__ado_backend.snap | 7 +++++++ .../snapshots/snapshots__github_backend.snap | 7 +++++++ .../tests/snapshots/snapshots__local_only.snap | 7 +++++++ 8 files changed, 57 insertions(+), 11 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index 37224e4a..dabcbc35 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,7 +1,7 @@ version = 1 tool = "anvil" tool_version = "0.7.0" -catalog_checksum = "sha256:47d5f35a51db0310f20ad41691cb45f660a547475e93579a1bb9e894439ed3bd" +catalog_checksum = "sha256:b166a2f12430af0b657400716eb68573c23cd4e9711549c99901a86bc3b897d3" [[file]] path = ".anvil/container/Dockerfile.dockerignore" @@ -257,7 +257,7 @@ checksum = "sha256:7b409a9b560c214e10b50f74330fb6f8c0c12c3d83494e0dcf016f2411b50 [[region]] host = ".anvil/container/Dockerfile" id = "anvil-container-setup" -checksum = "sha256:710cddf5cb7a73259485c6573eaa90a180cb19f26bd59a8e1f1bcb6592d92218" +checksum = "sha256:6a516c50a5a717a2b7635e043f2cc33fd872bdf5950a456718518b1af0bf75f9" [[region]] host = ".anvil/container/Dockerfile" diff --git a/.anvil/container/Dockerfile b/.anvil/container/Dockerfile index 602f6ef0..c595740c 100644 --- a/.anvil/container/Dockerfile +++ b/.anvil/container/Dockerfile @@ -100,12 +100,19 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # so the resolver reads it there with no container-specific path. The workspace # members it names are not copied and are not needed: the toolchain file selects # the compiler, which is the branch that skips workspace MSRV validation. +# +# It is deleted once the setup has read it, so it does not survive into the +# finished image. The tag hashes the declared MSRV rather than the file, so an +# unrelated dependency edit computes the same tag; a manifest left in place +# would make that tag name two different filesystems, and a reused or published +# image would carry a manifest that does not match any checkout. WORKDIR /opt/anvil COPY justfiles ./justfiles COPY rust-toolchain.toml ./ COPY Cargo.toml ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ && just anvil-setup binstall \ + && rm -f Cargo.toml \ && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ && rm -f "${CARGO_HOME}/credentials" "${CARGO_HOME}/credentials.toml" "${HOME:-/root}/.netrc" /root/.netrc \ && mkdir -p "${CARGO_HOME}/registry" "${CARGO_HOME}/git" \ diff --git a/crates/cargo-anvil/docs/design/containers.md b/crates/cargo-anvil/docs/design/containers.md index 15a23b59..24a9ab77 100644 --- a/crates/cargo-anvil/docs/design/containers.md +++ b/crates/cargo-anvil/docs/design/containers.md @@ -193,6 +193,10 @@ container-specific path in it. The workspace members it names are not admitted: they are a checkout, and the image is not one. The one path that would need them, workspace MSRV validation, returns early whenever a root toolchain file selects the compiler. +The manifest is deleted once the setup has read it, so it is in the build context but not in the finished image. That +keeps the tag honest: it hashes the declared MSRV rather than the file, so a dependency edit computes the same tag, +and nothing is left behind for that tag to misdescribe. + That makes the toolchain file a precondition of this design rather than a convenience: the image requires one, copies it, and relies on it to keep workspace validation out of reach of a context that has no members. A repository without one cannot build the image today, because the `COPY` above is unconditional. Should that become conditional, this @@ -243,7 +247,8 @@ The declared root MSRV is the one input that is not a file. The image installs t what the image contains and must rename it. The digest takes the resolved value rather than the manifest declaring it: `Cargo.toml` is the busiest file in a workspace while `rust-version` moves perhaps once, so hashing the file would rename the image, and oblige a publisher to rebuild and republish, for a stream of edits that cannot alter a byte the -image contains. +image contains. That last clause is what the setup region's `rm` buys: the manifest is read and deleted, so an edit +unrelated to `rust-version` computes the same tag for a filesystem that really is identical. The hook file's **content** is an input, since it determines what the build installs. Its **output** is deliberately excluded: a credential must never influence a tag. @@ -632,8 +637,9 @@ private-environment catalog rewrites the base and tool layers and nothing else. second tool list the design exists to avoid, and is almost never right. **A replacement must keep the ignore file in step.** A region that `COPY`s anything outside `justfiles/anvil/`, -`.anvil/container/` and `rust-toolchain.toml` must also replace `artifacts::container::dockerignore()` (§3), or the -added paths never reach the build context and the build fails on a missing file. +`.anvil/container/`, `rust-toolchain.toml` and the root `Cargo.toml` must also replace +`artifacts::container::dockerignore()` (§3), or the added paths never reach the build context and the build fails on a +missing file. **Anything extra it copies is digested, provided it lives under `.anvil/container/`.** The hashed set is that whole directory (§4.1), so an installer script, a config file or a certificate placed beside the Dockerfile is an input: @@ -645,8 +651,9 @@ manual `ANVIL_CONTAINER_NO_CACHE=1`. catalog-owned files. The reason is legibility rather than identity: the directory is the recipe tree, `just` parses every file the image copies, and a catalog that hides an installer script there makes the tool set harder to reason about than one that keeps it in `.anvil/`. Identity is safe either way, because the digest covers every file the build -context admits (§4.1), not only the recipes — a repository that adds a non-recipe file by hand still renames the tag -when it edits it. +context admits and the image keeps (§4.1), not only the recipes — a repository that adds a non-recipe file by hand +still renames the tag when it edits it. The root `Cargo.toml` is the one admitted file the digest does not cover as +bytes, and it is also the one the setup deletes once read, so it is in no image for the tag to misdescribe. A fork inherits everything else: the recipes, the identity scheme, the cache volumes, the mounts, and the re-entry guard. A different base OS with a different toolchain source is two region replacements plus one hook. diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region b/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region index 30b88801..d3b8d1af 100644 --- a/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region @@ -20,12 +20,19 @@ # so the resolver reads it there with no container-specific path. The workspace # members it names are not copied and are not needed: the toolchain file selects # the compiler, which is the branch that skips workspace MSRV validation. +# +# It is deleted once the setup has read it, so it does not survive into the +# finished image. The tag hashes the declared MSRV rather than the file, so an +# unrelated dependency edit computes the same tag; a manifest left in place +# would make that tag name two different filesystems, and a reused or published +# image would carry a manifest that does not match any checkout. WORKDIR /opt/anvil COPY justfiles ./justfiles COPY rust-toolchain.toml ./ COPY Cargo.toml ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ && just anvil-setup binstall \ + && rm -f Cargo.toml \ && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ && rm -f "${CARGO_HOME}/credentials" "${CARGO_HOME}/credentials.toml" "${HOME:-/root}/.netrc" /root/.netrc \ && mkdir -p "${CARGO_HOME}/registry" "${CARGO_HOME}/git" \ diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index 3e595cbe..40b9d52c 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -291,10 +291,6 @@ fn run_just(root: &Path, arguments: &[&str], environment: &[(&str, &OsStr)]) -> // silently short-circuit the recipe before it did anything. A test that // cares about either value passes it explicitly below. command.env_remove("ANVIL_IMPACT"); - // Same hazard, one resolver down: this names the MSRV for a tree with no - // root manifest, and a fixture that inherited a developer's exported value - // would resolve differently from a clean checkout. - command.env_remove("ANVIL_ROOT_MSRV"); for key in std::env::vars_os().map(|(key, _)| key) { if key.to_string_lossy().starts_with("ANVIL_INCLUDE_") { command.env_remove(key); @@ -483,6 +479,14 @@ fn container_build_carries_the_manifest_that_declares_the_msrv() { CONTAINER_SETUP_REGION.contains("COPY Cargo.toml ./"), "the setup region must copy the manifest to the root the recipes resolve against" ); + // The tag hashes the declared MSRV, not the file, so an unrelated dependency + // edit computes the same tag. A manifest left in the image would make that + // tag name two different filesystems. + assert!( + CONTAINER_SETUP_REGION.contains("rm -f Cargo.toml"), + "the setup region must delete the manifest once read, or the tag describes an image that \ + can differ from it" + ); // The members it names are a checkout, and the image is not one. assert!( !CONTAINER_DOCKERIGNORE.contains("!sources") && !CONTAINER_DOCKERIGNORE.contains("!crates"), diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index d0cddc22..15089b14 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -102,12 +102,19 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # so the resolver reads it there with no container-specific path. The workspace # members it names are not copied and are not needed: the toolchain file selects # the compiler, which is the branch that skips workspace MSRV validation. +# +# It is deleted once the setup has read it, so it does not survive into the +# finished image. The tag hashes the declared MSRV rather than the file, so an +# unrelated dependency edit computes the same tag; a manifest left in place +# would make that tag name two different filesystems, and a reused or published +# image would carry a manifest that does not match any checkout. WORKDIR /opt/anvil COPY justfiles ./justfiles COPY rust-toolchain.toml ./ COPY Cargo.toml ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ && just anvil-setup binstall \ + && rm -f Cargo.toml \ && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ && rm -f "${CARGO_HOME}/credentials" "${CARGO_HOME}/credentials.toml" "${HOME:-/root}/.netrc" /root/.netrc \ && mkdir -p "${CARGO_HOME}/registry" "${CARGO_HOME}/git" \ diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index 1855d95f..9efbfab1 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -102,12 +102,19 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # so the resolver reads it there with no container-specific path. The workspace # members it names are not copied and are not needed: the toolchain file selects # the compiler, which is the branch that skips workspace MSRV validation. +# +# It is deleted once the setup has read it, so it does not survive into the +# finished image. The tag hashes the declared MSRV rather than the file, so an +# unrelated dependency edit computes the same tag; a manifest left in place +# would make that tag name two different filesystems, and a reused or published +# image would carry a manifest that does not match any checkout. WORKDIR /opt/anvil COPY justfiles ./justfiles COPY rust-toolchain.toml ./ COPY Cargo.toml ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ && just anvil-setup binstall \ + && rm -f Cargo.toml \ && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ && rm -f "${CARGO_HOME}/credentials" "${CARGO_HOME}/credentials.toml" "${HOME:-/root}/.netrc" /root/.netrc \ && mkdir -p "${CARGO_HOME}/registry" "${CARGO_HOME}/git" \ diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index ece923dd..aa49c556 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -102,12 +102,19 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # so the resolver reads it there with no container-specific path. The workspace # members it names are not copied and are not needed: the toolchain file selects # the compiler, which is the branch that skips workspace MSRV validation. +# +# It is deleted once the setup has read it, so it does not survive into the +# finished image. The tag hashes the declared MSRV rather than the file, so an +# unrelated dependency edit computes the same tag; a manifest left in place +# would make that tag name two different filesystems, and a reused or published +# image would carry a manifest that does not match any checkout. WORKDIR /opt/anvil COPY justfiles ./justfiles COPY rust-toolchain.toml ./ COPY Cargo.toml ./ RUN printf "import 'justfiles/anvil/mod.just'\n" > Justfile \ && just anvil-setup binstall \ + && rm -f Cargo.toml \ && rm -rf "${CARGO_HOME}/registry/cache" "${CARGO_HOME}/registry/src" \ && rm -f "${CARGO_HOME}/credentials" "${CARGO_HOME}/credentials.toml" "${HOME:-/root}/.netrc" /root/.netrc \ && mkdir -p "${CARGO_HOME}/registry" "${CARGO_HOME}/git" \ From 97df1ca829a3602803af3b47982cd8596c5d125d Mon Sep 17 00:00:00 2001 From: Martin Havelka Date: Fri, 4 Sep 2026 14:08:31 +0200 Subject: [PATCH 7/7] docs(cargo-anvil): trim the container commentary to what is not inferable The comments added with the previous commit ran well past the change. Several restated the assertion message directly below them; others explained what the code already says. Keep the reasons a reader cannot recover: why the context is copied whole, why both toolchain-file spellings are admitted and neither required, and why the tag discovers the file rather than requiring it. Drop the rest. No behaviour changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .anvil.lock | 8 ++--- .anvil/container/Dockerfile | 11 ++---- .anvil/container/Dockerfile.dockerignore | 14 ++++---- crates/cargo-anvil/README.md | 14 ++++---- .../src/anvil/artifacts/container.rs | 8 ----- crates/cargo-anvil/src/lib.rs | 12 +++---- .../anvil/container/Dockerfile.dockerignore | 14 ++++---- .../anvil/container/Dockerfile.setup.region | 11 ++---- .../templates/justfiles/anvil/container.just | 9 ++--- crates/cargo-anvil/tests/recipe_contracts.rs | 29 +++++----------- .../snapshots/snapshots__ado_backend.snap | 34 +++++++------------ .../snapshots/snapshots__github_backend.snap | 34 +++++++------------ .../snapshots/snapshots__local_only.snap | 34 +++++++------------ justfiles/anvil/container.just | 9 ++--- 14 files changed, 85 insertions(+), 156 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index 08ada7cf..f8dd6280 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,11 +1,11 @@ version = 1 tool = "anvil" tool_version = "0.7.0" -catalog_checksum = "sha256:9ce582a04c38ec6e895e8110cbec58dc683ea25f49c6a647b0a3e1707a09754e" +catalog_checksum = "sha256:bbed0373abaf88235183c57dde4170fa16f724fd0b73b899f33f0fee08722343" [[file]] path = ".anvil/container/Dockerfile.dockerignore" -checksum = "sha256:96a1e2439466ab228c7726f0882699c69af707fb9551472c82b318b1a35765b3" +checksum = "sha256:427447e66dcebc14fe00bc4c6d8c8e8c972b16b7ba0fce1fb3a2bf39cfe4cc30" [[file]] path = ".github/actions/anvil-impact/action.yml" @@ -173,7 +173,7 @@ checksum = "sha256:6efd7378a2cd0f5d86519bd32fd86f2055a60191187dd77a8842b374b8eb7 [[file]] path = "justfiles/anvil/container.just" -checksum = "sha256:e0b5bf50232f43139016e50eb476b7a67b99a660bfa224028f78956e2b3eff28" +checksum = "sha256:fd9b76f6fffb8d33ee6f6f4039eae08838f2027fd80c342412db7c49154710e6" [[file]] path = "justfiles/anvil/groups/pr-fast.just" @@ -257,7 +257,7 @@ checksum = "sha256:7b409a9b560c214e10b50f74330fb6f8c0c12c3d83494e0dcf016f2411b50 [[region]] host = ".anvil/container/Dockerfile" id = "anvil-container-setup" -checksum = "sha256:975ea8fe3ba2367fe133d73bdeb449a7d71e6be92e6e5539b200b6536f8a067d" +checksum = "sha256:8b01f42fbbcaaf1d2166598f7a6810cfd424d1ae26a35a5b09a6220c2735728a" [[region]] host = ".anvil/container/Dockerfile" diff --git a/.anvil/container/Dockerfile b/.anvil/container/Dockerfile index a0040bf7..6da38f01 100644 --- a/.anvil/container/Dockerfile +++ b/.anvil/container/Dockerfile @@ -80,14 +80,9 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # >>> anvil-managed: anvil-container-setup # The context is copied whole rather than input by input, because one of the # inputs is optional: a repository that pins its compiler by other means owns no -# root `rust-toolchain`/`rust-toolchain.toml`, and a `COPY` of a path that may -# not exist is not portable across the engines anvil supports. Naming the file -# would make an image unbuildable in exactly the repositories that have nothing -# to pin. `Dockerfile.dockerignore` already scopes the context to precisely the -# image's inputs, so deferring to it costs no breadth and makes what the context -# admits and what the image contains the same set by construction. What rides -# along -- `.anvil/container/` -- is the committed input the gaps below `COPY` -# from, and the image never runs it. +# root toolchain file, and a `COPY` of a path that may not exist is not portable +# across the engines anvil supports. `.anvil/container/` rides along as a +# consequence; the image never reads it. # # The whole recipe tree is copied because `just` parses it to reach the install # recipes. diff --git a/.anvil/container/Dockerfile.dockerignore b/.anvil/container/Dockerfile.dockerignore index 2a198f55..c35584ec 100644 --- a/.anvil/container/Dockerfile.dockerignore +++ b/.anvil/container/Dockerfile.dockerignore @@ -10,8 +10,8 @@ # The build context is the repository root but the image needs a small, named # set out of it. Excluding everything else keeps a cold build from streaming the # whole worktree (and every stale `target/`) to the daemon -- and, because the -# setup region copies the context whole, this file is also the definition of -# what the image contains. +# setup region copies the context whole, this file decides what the image +# contains. # # The context is narrowed to `justfiles/anvil/` rather than all of `justfiles/` # so that a cold build does not stream unrelated trees to the daemon. The @@ -32,12 +32,10 @@ # that lives in repository source rather than in the recipe tree. The workspace # members it names stay out: they are a checkout, and the image is not one. # -# Both toolchain-file spellings are admitted, and neither is required. A -# repository that owns one has its compiler selected from it, in the image as on -# a host; one that pins by other means builds an image whose compiler is the -# declared MSRV, which is what a host would resolve there too. Admitting only -# `rust-toolchain.toml` would give the second repository an image that silently -# disagreed with its own checkout. +# Neither toolchain-file spelling is required, and both are admitted: a +# repository that pins by other means builds an image whose compiler is the +# declared MSRV, and admitting only the TOML would give one that pins with the +# extensionless file an image that silently disagreed with its own checkout. * !justfiles justfiles/* diff --git a/crates/cargo-anvil/README.md b/crates/cargo-anvil/README.md index d7751aa7..094b72b8 100644 --- a/crates/cargo-anvil/README.md +++ b/crates/cargo-anvil/README.md @@ -168,8 +168,8 @@ ARM64 hosts it is emulated and is substantially slower. The tag *is* a SHA-256 digest over the inputs that define the image: everything under `.anvil/container/`, a root `rust-toolchain` file where the -repository owns one, and the whole -generated `justfiles/anvil/` tree. The container directory is walked rather +repository owns one, and the whole generated `justfiles/anvil/` tree. The +container directory is walked rather than named file by file, because the Dockerfile is composed and a repository can `COPY` a certificate or an install script it places there. The recipe tree is included in @@ -260,10 +260,10 @@ those pins at the moment of the edit, which is why the gaps exist. A downstream catalog that needs a different base OS for every repository it manages replaces the base and tool regions instead, inheriting the catalog install and the entry contract. A replacement that needs more of the tree -must replace the ignore file with it, since that file is what scopes the -build context — and, because the setup region copies the context whole, what -the image contains: `justfiles/anvil/`, `.anvil/container/`, a root -`rust-toolchain` file and the root `Cargo.toml`. See +must replace the ignore file with it: the setup region copies the context +whole, so that file decides what the image contains, which today is +`justfiles/anvil/`, `.anvil/container/`, a root `rust-toolchain` file and +the root `Cargo.toml`. See [`artifacts::container`][__link1] and the design document for the full contract, the host setup for each engine, and the known limitations. @@ -495,7 +495,7 @@ And `docs/verification.md` for the continuous-validation strategy. This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjNhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbmvEgSYsFVgkbCDtjnkoqqhgbgmvgMGS3gq0b1832bIf3mOhhZIGDa2NhcmdvLWFudmlsZTAuNy4wa2NhcmdvX2Fudmls + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjNhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQb1SwbqKADGLgb2SUB5pyIW5AbZYWAp-o-cjIb3p-idO-iZi1hZIGDa2NhcmdvLWFudmlsZTAuNy4wa2NhcmdvX2Fudmls [__link0]: https://crates.io/crates/cargo-delta [__link1]: https://docs.rs/cargo-anvil/0.7.0/cargo_anvil/?search=artifacts::container [__link10]: https://docs.rs/cargo-anvil/0.7.0/cargo_anvil/?search=artifacts diff --git a/crates/cargo-anvil/src/anvil/artifacts/container.rs b/crates/cargo-anvil/src/anvil/artifacts/container.rs index c7813f30..bd4534dd 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/container.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/container.rs @@ -346,10 +346,6 @@ mod tests { // the same recipe the checks use, from the same generated pins. let composed = composed_dockerfile(); assert!(composed.contains("just anvil-setup binstall")); - // The context is copied whole, so the recipe tree and the declarations - // reach the image through the ignore file rather than by name. A root - // toolchain file is optional, and no engine offers a portable `COPY` of - // a path that may not exist. assert!(composed.contains("COPY . ./")); assert!( !composed.contains("COPY rust-toolchain"), @@ -430,10 +426,6 @@ mod tests { fn build_context_admits_only_what_the_image_copies() { assert!(DOCKERIGNORE.contains("!justfiles")); assert!(DOCKERIGNORE.contains("!Cargo.toml")); - // Both spellings, because the setup region names neither: admitting - // only the TOML would give a repository that pins with the - // extensionless file an image whose compiler silently disagreed with - // its own checkout. assert!(DOCKERIGNORE.contains("!rust-toolchain.toml")); assert!(DOCKERIGNORE.contains("!rust-toolchain\n")); } diff --git a/crates/cargo-anvil/src/lib.rs b/crates/cargo-anvil/src/lib.rs index 33b1e50d..f834df0d 100644 --- a/crates/cargo-anvil/src/lib.rs +++ b/crates/cargo-anvil/src/lib.rs @@ -169,8 +169,8 @@ //! //! The tag *is* a SHA-256 digest over the inputs that define the image: //! everything under `.anvil/container/`, a root `rust-toolchain` file where the -//! repository owns one, and the whole -//! generated `justfiles/anvil/` tree. The container directory is walked rather +//! repository owns one, and the whole generated `justfiles/anvil/` tree. The +//! container directory is walked rather //! than named file by file, because the Dockerfile is composed and a //! repository can `COPY` a certificate or an install script it places there. //! The recipe tree is included in @@ -261,10 +261,10 @@ //! A downstream catalog that needs a different base OS for every repository it //! manages replaces the base and tool regions instead, inheriting the catalog //! install and the entry contract. A replacement that needs more of the tree -//! must replace the ignore file with it, since that file is what scopes the -//! build context — and, because the setup region copies the context whole, what -//! the image contains: `justfiles/anvil/`, `.anvil/container/`, a root -//! `rust-toolchain` file and the root `Cargo.toml`. See +//! must replace the ignore file with it: the setup region copies the context +//! whole, so that file decides what the image contains, which today is +//! `justfiles/anvil/`, `.anvil/container/`, a root `rust-toolchain` file and +//! the root `Cargo.toml`. See //! [`artifacts::container`] and the design document for the full contract, the //! host setup for each engine, and the known limitations. //! diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore b/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore index 2a198f55..c35584ec 100644 --- a/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore @@ -10,8 +10,8 @@ # The build context is the repository root but the image needs a small, named # set out of it. Excluding everything else keeps a cold build from streaming the # whole worktree (and every stale `target/`) to the daemon -- and, because the -# setup region copies the context whole, this file is also the definition of -# what the image contains. +# setup region copies the context whole, this file decides what the image +# contains. # # The context is narrowed to `justfiles/anvil/` rather than all of `justfiles/` # so that a cold build does not stream unrelated trees to the daemon. The @@ -32,12 +32,10 @@ # that lives in repository source rather than in the recipe tree. The workspace # members it names stay out: they are a checkout, and the image is not one. # -# Both toolchain-file spellings are admitted, and neither is required. A -# repository that owns one has its compiler selected from it, in the image as on -# a host; one that pins by other means builds an image whose compiler is the -# declared MSRV, which is what a host would resolve there too. Admitting only -# `rust-toolchain.toml` would give the second repository an image that silently -# disagreed with its own checkout. +# Neither toolchain-file spelling is required, and both are admitted: a +# repository that pins by other means builds an image whose compiler is the +# declared MSRV, and admitting only the TOML would give one that pins with the +# extensionless file an image that silently disagreed with its own checkout. * !justfiles justfiles/* diff --git a/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region b/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region index 4ee97f1a..b1f743bc 100644 --- a/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region +++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region @@ -1,13 +1,8 @@ # The context is copied whole rather than input by input, because one of the # inputs is optional: a repository that pins its compiler by other means owns no -# root `rust-toolchain`/`rust-toolchain.toml`, and a `COPY` of a path that may -# not exist is not portable across the engines anvil supports. Naming the file -# would make an image unbuildable in exactly the repositories that have nothing -# to pin. `Dockerfile.dockerignore` already scopes the context to precisely the -# image's inputs, so deferring to it costs no breadth and makes what the context -# admits and what the image contains the same set by construction. What rides -# along -- `.anvil/container/` -- is the committed input the gaps below `COPY` -# from, and the image never runs it. +# root toolchain file, and a `COPY` of a path that may not exist is not portable +# across the engines anvil supports. `.anvil/container/` rides along as a +# consequence; the image never reads it. # # The whole recipe tree is copied because `just` parses it to reach the install # recipes. diff --git a/crates/cargo-anvil/templates/justfiles/anvil/container.just b/crates/cargo-anvil/templates/justfiles/anvil/container.just index 656966b8..92c48037 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/container.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/container.just @@ -184,12 +184,9 @@ anvil-container-tag: # `COPY` puts in the image. $inputs = @("$dockerfile.dockerignore") - # A root toolchain file is optional -- a repository may pin its compiler by - # other means -- so it is discovered rather than required, and absence is - # simply one fewer record in the stream. Both spellings are checked because - # the context admits both and rustup reads both. The digest still has to - # follow it: the image installs the toolchain it selects, so an edit to it - # changes what the image contains. + # Discovered rather than required: a repository may pin its compiler by + # other means and own neither spelling, and both are checked because the + # context admits both. $toolchainFiles = @( @('rust-toolchain.toml', 'rust-toolchain') | Where-Object { Test-Path -LiteralPath (Join-Path $repoRoot $_) -PathType Leaf } diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index c3262738..3b3b82b7 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -505,11 +505,9 @@ fn container_build_carries_the_manifest_that_declares_the_msrv() { ); } -/// A repository may pin its compiler by means other than a root toolchain file, -/// and such a repository must still be able to build an image. No engine anvil -/// supports offers a portable `COPY` of a path that may not exist, so the setup -/// region names no input at all and the ignore file decides what the context -- -/// and therefore the image -- contains. +/// No engine anvil supports offers a portable `COPY` of a path that may not +/// exist, so the setup region names no input and the ignore file decides what +/// the context, and therefore the image, contains. #[test] fn the_container_build_does_not_require_a_root_toolchain_file() { assert!( @@ -517,15 +515,10 @@ fn the_container_build_does_not_require_a_root_toolchain_file() { "naming the toolchain file makes the image unbuildable in exactly the repositories that \ have nothing to pin" ); - // Both spellings, because neither is named by a `COPY`: admitting only the - // TOML would leave a repository that pins with the extensionless file - // building an image whose compiler disagreed with its own checkout. assert!( CONTAINER_DOCKERIGNORE.contains("!rust-toolchain.toml") && CONTAINER_DOCKERIGNORE.contains("!rust-toolchain\n"), "the context must admit a root toolchain file in either spelling" ); - // The digest still has to follow the file where one exists, so it is - // discovered rather than required. assert!( CONTAINER.contains("$toolchainFiles"), "the tag must hash a root toolchain file when the repository owns one" @@ -1844,12 +1837,10 @@ fn the_image_tag_follows_the_declared_msrv() { assert_eq!(declared, tag("1.93.1"), "the tag must depend on the inputs alone"); } -/// A repository may pin its compiler by means other than a root toolchain file, -/// so the tag has to answer for one that owns none rather than refusing it. The -/// two states must still be distinguishable: the image selects its compiler -/// from that file where it exists and from the declared MSRV where it does not, -/// so they cannot share a reference. Both spellings count, because the context -/// admits both and rustup reads both. +/// The tag has to answer for a repository that owns no toolchain file rather +/// than refusing it, and the two states must not share a reference: the image +/// takes its compiler from that file where it exists and from the declared MSRV +/// where it does not. #[test] fn the_image_tag_treats_a_root_toolchain_file_as_optional() { if !tools_available() { @@ -1894,11 +1885,7 @@ fn the_image_tag_treats_a_root_toolchain_file_as_optional() { write(&root.join("rust-toolchain"), "[toolchain]\nchannel = \"1.90\"\n"); let extensionless = tag(); assert_ne!(none, extensionless, "the extensionless spelling is an image input too"); - assert_ne!( - toml, extensionless, - "the same bytes under the other spelling are a different input, and rustup reads them in a \ - different order" - ); + assert_ne!(toml, extensionless, "the same bytes under the other spelling are a different input"); } /// The tag is computed from the index while the build copies the working tree, diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index 35754cd5..951f1806 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -82,14 +82,9 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # >>> anvil-managed: anvil-container-setup # The context is copied whole rather than input by input, because one of the # inputs is optional: a repository that pins its compiler by other means owns no -# root `rust-toolchain`/`rust-toolchain.toml`, and a `COPY` of a path that may -# not exist is not portable across the engines anvil supports. Naming the file -# would make an image unbuildable in exactly the repositories that have nothing -# to pin. `Dockerfile.dockerignore` already scopes the context to precisely the -# image's inputs, so deferring to it costs no breadth and makes what the context -# admits and what the image contains the same set by construction. What rides -# along -- `.anvil/container/` -- is the committed input the gaps below `COPY` -# from, and the image never runs it. +# root toolchain file, and a `COPY` of a path that may not exist is not portable +# across the engines anvil supports. `.anvil/container/` rides along as a +# consequence; the image never reads it. # # The whole recipe tree is copied because `just` parses it to reach the install # recipes. @@ -146,8 +141,8 @@ CMD ["bash"] # The build context is the repository root but the image needs a small, named # set out of it. Excluding everything else keeps a cold build from streaming the # whole worktree (and every stale `target/`) to the daemon -- and, because the -# setup region copies the context whole, this file is also the definition of -# what the image contains. +# setup region copies the context whole, this file decides what the image +# contains. # # The context is narrowed to `justfiles/anvil/` rather than all of `justfiles/` # so that a cold build does not stream unrelated trees to the daemon. The @@ -168,12 +163,10 @@ CMD ["bash"] # that lives in repository source rather than in the recipe tree. The workspace # members it names stay out: they are a checkout, and the image is not one. # -# Both toolchain-file spellings are admitted, and neither is required. A -# repository that owns one has its compiler selected from it, in the image as on -# a host; one that pins by other means builds an image whose compiler is the -# declared MSRV, which is what a host would resolve there too. Admitting only -# `rust-toolchain.toml` would give the second repository an image that silently -# disagreed with its own checkout. +# Neither toolchain-file spelling is required, and both are admitted: a +# repository that pins by other means builds an image whose compiler is the +# declared MSRV, and admitting only the TOML would give one that pins with the +# extensionless file an image that silently disagreed with its own checkout. * !justfiles justfiles/* @@ -3714,12 +3707,9 @@ anvil-container-tag: # `COPY` puts in the image. $inputs = @("$dockerfile.dockerignore") - # A root toolchain file is optional -- a repository may pin its compiler by - # other means -- so it is discovered rather than required, and absence is - # simply one fewer record in the stream. Both spellings are checked because - # the context admits both and rustup reads both. The digest still has to - # follow it: the image installs the toolchain it selects, so an edit to it - # changes what the image contains. + # Discovered rather than required: a repository may pin its compiler by + # other means and own neither spelling, and both are checked because the + # context admits both. $toolchainFiles = @( @('rust-toolchain.toml', 'rust-toolchain') | Where-Object { Test-Path -LiteralPath (Join-Path $repoRoot $_) -PathType Leaf } diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index 6ddcf2e7..9bb7bee2 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -82,14 +82,9 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # >>> anvil-managed: anvil-container-setup # The context is copied whole rather than input by input, because one of the # inputs is optional: a repository that pins its compiler by other means owns no -# root `rust-toolchain`/`rust-toolchain.toml`, and a `COPY` of a path that may -# not exist is not portable across the engines anvil supports. Naming the file -# would make an image unbuildable in exactly the repositories that have nothing -# to pin. `Dockerfile.dockerignore` already scopes the context to precisely the -# image's inputs, so deferring to it costs no breadth and makes what the context -# admits and what the image contains the same set by construction. What rides -# along -- `.anvil/container/` -- is the committed input the gaps below `COPY` -# from, and the image never runs it. +# root toolchain file, and a `COPY` of a path that may not exist is not portable +# across the engines anvil supports. `.anvil/container/` rides along as a +# consequence; the image never reads it. # # The whole recipe tree is copied because `just` parses it to reach the install # recipes. @@ -146,8 +141,8 @@ CMD ["bash"] # The build context is the repository root but the image needs a small, named # set out of it. Excluding everything else keeps a cold build from streaming the # whole worktree (and every stale `target/`) to the daemon -- and, because the -# setup region copies the context whole, this file is also the definition of -# what the image contains. +# setup region copies the context whole, this file decides what the image +# contains. # # The context is narrowed to `justfiles/anvil/` rather than all of `justfiles/` # so that a cold build does not stream unrelated trees to the daemon. The @@ -168,12 +163,10 @@ CMD ["bash"] # that lives in repository source rather than in the recipe tree. The workspace # members it names stay out: they are a checkout, and the image is not one. # -# Both toolchain-file spellings are admitted, and neither is required. A -# repository that owns one has its compiler selected from it, in the image as on -# a host; one that pins by other means builds an image whose compiler is the -# declared MSRV, which is what a host would resolve there too. Admitting only -# `rust-toolchain.toml` would give the second repository an image that silently -# disagreed with its own checkout. +# Neither toolchain-file spelling is required, and both are admitted: a +# repository that pins by other means builds an image whose compiler is the +# declared MSRV, and admitting only the TOML would give one that pins with the +# extensionless file an image that silently disagreed with its own checkout. * !justfiles justfiles/* @@ -3851,12 +3844,9 @@ anvil-container-tag: # `COPY` puts in the image. $inputs = @("$dockerfile.dockerignore") - # A root toolchain file is optional -- a repository may pin its compiler by - # other means -- so it is discovered rather than required, and absence is - # simply one fewer record in the stream. Both spellings are checked because - # the context admits both and rustup reads both. The digest still has to - # follow it: the image installs the toolchain it selects, so an edit to it - # changes what the image contains. + # Discovered rather than required: a repository may pin its compiler by + # other means and own neither spelling, and both are checked because the + # context admits both. $toolchainFiles = @( @('rust-toolchain.toml', 'rust-toolchain') | Where-Object { Test-Path -LiteralPath (Join-Path $repoRoot $_) -PathType Leaf } diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index 8551b2c4..059dd952 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -82,14 +82,9 @@ RUN curl -fsSLo /tmp/cargo-binstall.tgz \ # >>> anvil-managed: anvil-container-setup # The context is copied whole rather than input by input, because one of the # inputs is optional: a repository that pins its compiler by other means owns no -# root `rust-toolchain`/`rust-toolchain.toml`, and a `COPY` of a path that may -# not exist is not portable across the engines anvil supports. Naming the file -# would make an image unbuildable in exactly the repositories that have nothing -# to pin. `Dockerfile.dockerignore` already scopes the context to precisely the -# image's inputs, so deferring to it costs no breadth and makes what the context -# admits and what the image contains the same set by construction. What rides -# along -- `.anvil/container/` -- is the committed input the gaps below `COPY` -# from, and the image never runs it. +# root toolchain file, and a `COPY` of a path that may not exist is not portable +# across the engines anvil supports. `.anvil/container/` rides along as a +# consequence; the image never reads it. # # The whole recipe tree is copied because `just` parses it to reach the install # recipes. @@ -146,8 +141,8 @@ CMD ["bash"] # The build context is the repository root but the image needs a small, named # set out of it. Excluding everything else keeps a cold build from streaming the # whole worktree (and every stale `target/`) to the daemon -- and, because the -# setup region copies the context whole, this file is also the definition of -# what the image contains. +# setup region copies the context whole, this file decides what the image +# contains. # # The context is narrowed to `justfiles/anvil/` rather than all of `justfiles/` # so that a cold build does not stream unrelated trees to the daemon. The @@ -168,12 +163,10 @@ CMD ["bash"] # that lives in repository source rather than in the recipe tree. The workspace # members it names stay out: they are a checkout, and the image is not one. # -# Both toolchain-file spellings are admitted, and neither is required. A -# repository that owns one has its compiler selected from it, in the image as on -# a host; one that pins by other means builds an image whose compiler is the -# declared MSRV, which is what a host would resolve there too. Admitting only -# `rust-toolchain.toml` would give the second repository an image that silently -# disagreed with its own checkout. +# Neither toolchain-file spelling is required, and both are admitted: a +# repository that pins by other means builds an image whose compiler is the +# declared MSRV, and admitting only the TOML would give one that pins with the +# extensionless file an image that silently disagreed with its own checkout. * !justfiles justfiles/* @@ -2584,12 +2577,9 @@ anvil-container-tag: # `COPY` puts in the image. $inputs = @("$dockerfile.dockerignore") - # A root toolchain file is optional -- a repository may pin its compiler by - # other means -- so it is discovered rather than required, and absence is - # simply one fewer record in the stream. Both spellings are checked because - # the context admits both and rustup reads both. The digest still has to - # follow it: the image installs the toolchain it selects, so an edit to it - # changes what the image contains. + # Discovered rather than required: a repository may pin its compiler by + # other means and own neither spelling, and both are checked because the + # context admits both. $toolchainFiles = @( @('rust-toolchain.toml', 'rust-toolchain') | Where-Object { Test-Path -LiteralPath (Join-Path $repoRoot $_) -PathType Leaf } diff --git a/justfiles/anvil/container.just b/justfiles/anvil/container.just index 656966b8..92c48037 100644 --- a/justfiles/anvil/container.just +++ b/justfiles/anvil/container.just @@ -184,12 +184,9 @@ anvil-container-tag: # `COPY` puts in the image. $inputs = @("$dockerfile.dockerignore") - # A root toolchain file is optional -- a repository may pin its compiler by - # other means -- so it is discovered rather than required, and absence is - # simply one fewer record in the stream. Both spellings are checked because - # the context admits both and rustup reads both. The digest still has to - # follow it: the image installs the toolchain it selects, so an edit to it - # changes what the image contains. + # Discovered rather than required: a repository may pin its compiler by + # other means and own neither spelling, and both are checked because the + # context admits both. $toolchainFiles = @( @('rust-toolchain.toml', 'rust-toolchain') | Where-Object { Test-Path -LiteralPath (Join-Path $repoRoot $_) -PathType Leaf }