diff --git a/.anvil.lock b/.anvil.lock
index 879264a7..44369516 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:1e071bf7b0c197d567567501fda541c074e267ff51c62c5b3233f64bb00c695c"
[[file]]
path = ".anvil/container/Dockerfile.dockerignore"
-checksum = "sha256:9c7906c20415ca3b832afb075c21e79f191ef14ee2fb4b6a5a95394e8b2153c1"
+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:3363992c6c006c649eae3732b2a7b04c2a4925eff369179b128d43380e2800d4"
+checksum = "sha256:fd9b76f6fffb8d33ee6f6f4039eae08838f2027fd80c342412db7c49154710e6"
[[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: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:3788845ea3d4c483917954ddf9bccf918bf69ab7c64bbf559baf59d5781c444f"
+checksum = "sha256:46d1c7acf41ce4a50444be9db1b1510fcf704127877d18bfbcc5b79f99c6e737"
[[region]]
host = ".anvil/container/Dockerfile"
diff --git a/.anvil/container/Dockerfile b/.anvil/container/Dockerfile
index bcdd9698..78f79b34 100644
--- a/.anvil/container/Dockerfile
+++ b/.anvil/container/Dockerfile
@@ -78,6 +78,12 @@ 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 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.
#
@@ -94,11 +100,24 @@ 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 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 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.
+#
+# It is deleted once the setup has read it, so it does not survive into the
+# finished image. It is the one thing the context carries that the tag does not
+# hash -- the declared MSRV is hashed instead, so an unrelated dependency edit
+# computes the same tag -- and a manifest left in place would make that tag name
+# two different filesystems.
WORKDIR /opt/anvil
-COPY justfiles ./justfiles
-COPY rust-toolchain.toml ./
+COPY . ./
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/.anvil/container/Dockerfile.dockerignore b/.anvil/container/Dockerfile.dockerignore
index 577907f0..c35584ec 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 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
@@ -25,6 +27,15 @@
# 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 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.
+#
+# 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/*
@@ -35,3 +46,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..094b72b8 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,8 +167,9 @@ 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
-generated `justfiles/anvil/` tree. The container directory is walked rather
+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.
The recipe tree is included in
@@ -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: 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.
@@ -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]: 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/docs/design/containers.md b/crates/cargo-anvil/docs/design/containers.md
index b4753c5b..b1705a27 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
@@ -133,7 +134,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` | `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
@@ -185,12 +186,33 @@ 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).
-`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
-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.
+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`. 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 it names are not admitted: they are a checkout, and the image is not one. The one path that
+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 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.
+
+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
@@ -202,8 +224,9 @@ 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 |
`.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
@@ -212,6 +235,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
@@ -225,6 +253,13 @@ 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. 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. 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.
@@ -395,7 +430,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 |
| --- | --- | --- |
@@ -611,9 +647,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:
@@ -625,8 +662,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/docs/implementation.md b/crates/cargo-anvil/docs/implementation.md
index 1515934f..029e07bd 100644
--- a/crates/cargo-anvil/docs/implementation.md
+++ b/crates/cargo-anvil/docs/implementation.md
@@ -76,6 +76,12 @@ 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 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
leaves depend on `anvil-toolchain-stable-install`; group and tier fan-out lets
diff --git a/crates/cargo-anvil/src/anvil/artifacts/container.rs b/crates/cargo-anvil/src/anvil/artifacts/container.rs
index 8e01f276..bd4534dd 100644
--- a/crates/cargo-anvil/src/anvil/artifacts/container.rs
+++ b/crates/cargo-anvil/src/anvil/artifacts/container.rs
@@ -346,8 +346,11 @@ 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"));
+ 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 +425,9 @@ mod tests {
#[test]
fn build_context_admits_only_what_the_image_copies() {
assert!(DOCKERIGNORE.contains("!justfiles"));
+ assert!(DOCKERIGNORE.contains("!Cargo.toml"));
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..f834df0d 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,8 +168,9 @@
//! ### 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
-//! generated `justfiles/anvil/` tree. The container directory is walked rather
+//! 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.
//! The recipe tree is included in
@@ -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: 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 577907f0..c35584ec 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 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
@@ -25,6 +27,15 @@
# 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 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.
+#
+# 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/*
@@ -35,3 +46,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 f98cc0ca..08f913c2 100644
--- a/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region
+++ b/crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region
@@ -1,3 +1,9 @@
+# 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 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.
#
@@ -14,11 +20,24 @@
# 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 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 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.
+#
+# It is deleted once the setup has read it, so it does not survive into the
+# finished image. It is the one thing the context carries that the tag does not
+# hash -- the declared MSRV is hashed instead, so an unrelated dependency edit
+# computes the same tag -- and a manifest left in place would make that tag name
+# two different filesystems.
WORKDIR /opt/anvil
-COPY justfiles ./justfiles
-COPY rust-toolchain.toml ./
+COPY . ./
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/templates/justfiles/anvil/container.just b/crates/cargo-anvil/templates/justfiles/anvil/container.just
index 6e3a91d8..92c48037 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, and 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.
+# 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
@@ -166,22 +167,41 @@ anvil-container-tag:
$dockerfile = "$dockerfile".Trim()
$hookRel = '.anvil/container/hooks.ps1'
- # 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")
+ # 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()
+
+ # 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")
+
+ # 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 }
+ )
+ $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
@@ -342,7 +362,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
@@ -398,6 +418,13 @@ anvil-container-tag:
# would be a MethodNotFound at tag time, before anything useful happened.
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
+ # 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)
+ [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)) {
diff --git a/crates/cargo-anvil/templates/justfiles/anvil/tools.just b/crates/cargo-anvil/templates/justfiles/anvil/tools.just
index 8e942948..0ac99f6d 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'"
@@ -340,6 +341,19 @@ _anvil-resolve-stable action="install":
exit 0
}
+ # 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.
+ #
+ # 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 }
+ 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..312b584b 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()));
@@ -405,6 +422,117 @@ fn msrv_test_propagates_nested_just_failures() {
);
}
+// 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() {
+ return;
+ }
+ let tmp = fixture(&[("versions.just", VERSIONS), ("tools.just", TOOLS)], &[]);
+
+ let output = run_just(tmp.path(), &["_anvil-resolve-stable", "root-msrv"], &[]);
+
+ 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");
+}
+
+#[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 container_build_carries_the_manifest_that_declares_the_msrv() {
+ assert!(
+ CONTAINER_DOCKERIGNORE.contains("!Cargo.toml"),
+ "the build context must admit the root manifest, or the setup cannot resolve the MSRV"
+ );
+ assert!(
+ CONTAINER_SETUP_REGION.contains("COPY . ./"),
+ "the setup region must copy the scoped context 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"),
+ "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.
+ assert!(
+ CONTAINER.contains("'msrv ' + $msrvBytes.Length"),
+ "the image tag must hash the declared MSRV value, because the image installs that toolchain"
+ );
+ assert!(
+ !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"
+ );
+ assert!(
+ !CONTAINER_SETUP_REGION.contains("ANVIL_ROOT_MSRV"),
+ "the setup region must not reintroduce the build argument"
+ );
+}
+
+/// 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!(
+ !CONTAINER_SETUP_REGION.contains("COPY rust-toolchain"),
+ "naming the toolchain file makes the image unbuildable in exactly the repositories that \
+ have nothing to pin"
+ );
+ assert!(
+ CONTAINER_DOCKERIGNORE.contains("!rust-toolchain.toml") && CONTAINER_DOCKERIGNORE.contains("!rust-toolchain\n"),
+ "the context must admit a root toolchain file in either spelling"
+ );
+ 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"
+ );
+}
+
#[test]
fn impact_format_resolves_directory_aliases_and_fails_hard() {
if !tools_available() {
@@ -1614,6 +1742,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 +1798,100 @@ 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 digest
+/// 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() {
+ 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 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() {
+ 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");
+}
+
/// 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 +1912,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 +1997,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 c6ba50a5..134333f6 100644
--- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap
+++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap
@@ -80,6 +80,12 @@ 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 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.
#
@@ -96,11 +102,24 @@ 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 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 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.
+#
+# It is deleted once the setup has read it, so it does not survive into the
+# finished image. It is the one thing the context carries that the tag does not
+# hash -- the declared MSRV is hashed instead, so an unrelated dependency edit
+# computes the same tag -- and a manifest left in place would make that tag name
+# two different filesystems.
WORKDIR /opt/anvil
-COPY justfiles ./justfiles
-COPY rust-toolchain.toml ./
+COPY . ./
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" \
@@ -126,9 +145,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 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
@@ -144,6 +165,15 @@ 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 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.
+#
+# 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/*
@@ -154,6 +184,8 @@ justfiles/anvil/**/*.anvil-proposed
!.anvil/container
.anvil/container/**/*.anvil-proposed
!rust-toolchain.toml
+!rust-toolchain
+!Cargo.toml
=== .delta.toml ===
# >>> anvil-managed: anvil-delta
@@ -3641,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, and 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.
+# 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
@@ -3664,22 +3697,41 @@ anvil-container-tag:
$dockerfile = "$dockerfile".Trim()
$hookRel = '.anvil/container/hooks.ps1'
- # 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")
+ # 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()
+
+ # 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")
+
+ # 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 }
+ )
+ $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
@@ -3840,7 +3892,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
@@ -3896,6 +3948,13 @@ anvil-container-tag:
# would be a MethodNotFound at tag time, before anything useful happened.
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
+ # 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)
+ [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)) {
@@ -6238,7 +6297,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'"
@@ -6393,6 +6453,19 @@ _anvil-resolve-stable action="install":
exit 0
}
+ # 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.
+ #
+ # 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 }
+ 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 ade9fb80..01079bdf 100644
--- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap
+++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap
@@ -80,6 +80,12 @@ 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 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.
#
@@ -96,11 +102,24 @@ 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 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 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.
+#
+# It is deleted once the setup has read it, so it does not survive into the
+# finished image. It is the one thing the context carries that the tag does not
+# hash -- the declared MSRV is hashed instead, so an unrelated dependency edit
+# computes the same tag -- and a manifest left in place would make that tag name
+# two different filesystems.
WORKDIR /opt/anvil
-COPY justfiles ./justfiles
-COPY rust-toolchain.toml ./
+COPY . ./
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" \
@@ -126,9 +145,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 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
@@ -144,6 +165,15 @@ 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 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.
+#
+# 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/*
@@ -154,6 +184,8 @@ justfiles/anvil/**/*.anvil-proposed
!.anvil/container
.anvil/container/**/*.anvil-proposed
!rust-toolchain.toml
+!rust-toolchain
+!Cargo.toml
=== .delta.toml ===
# >>> anvil-managed: anvil-delta
@@ -3778,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, and 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.
+# 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
@@ -3801,22 +3834,41 @@ anvil-container-tag:
$dockerfile = "$dockerfile".Trim()
$hookRel = '.anvil/container/hooks.ps1'
- # 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")
+ # 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()
+
+ # 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")
+
+ # 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 }
+ )
+ $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
@@ -3977,7 +4029,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
@@ -4033,6 +4085,13 @@ anvil-container-tag:
# would be a MethodNotFound at tag time, before anything useful happened.
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
+ # 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)
+ [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)) {
@@ -6375,7 +6434,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'"
@@ -6530,6 +6590,19 @@ _anvil-resolve-stable action="install":
exit 0
}
+ # 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.
+ #
+ # 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 }
+ 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 df2d8cc6..4d40a183 100644
--- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap
+++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap
@@ -80,6 +80,12 @@ 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 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.
#
@@ -96,11 +102,24 @@ 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 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 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.
+#
+# It is deleted once the setup has read it, so it does not survive into the
+# finished image. It is the one thing the context carries that the tag does not
+# hash -- the declared MSRV is hashed instead, so an unrelated dependency edit
+# computes the same tag -- and a manifest left in place would make that tag name
+# two different filesystems.
WORKDIR /opt/anvil
-COPY justfiles ./justfiles
-COPY rust-toolchain.toml ./
+COPY . ./
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" \
@@ -126,9 +145,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 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
@@ -144,6 +165,15 @@ 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 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.
+#
+# 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/*
@@ -154,6 +184,8 @@ justfiles/anvil/**/*.anvil-proposed
!.anvil/container
.anvil/container/**/*.anvil-proposed
!rust-toolchain.toml
+!rust-toolchain
+!Cargo.toml
=== .delta.toml ===
# >>> anvil-managed: anvil-delta
@@ -2511,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, and 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.
+# 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
@@ -2534,22 +2567,41 @@ anvil-container-tag:
$dockerfile = "$dockerfile".Trim()
$hookRel = '.anvil/container/hooks.ps1'
- # 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")
+ # 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()
+
+ # 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")
+
+ # 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 }
+ )
+ $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
@@ -2710,7 +2762,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
@@ -2766,6 +2818,13 @@ anvil-container-tag:
# would be a MethodNotFound at tag time, before anything useful happened.
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
+ # 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)
+ [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)) {
@@ -5108,7 +5167,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'"
@@ -5263,6 +5323,19 @@ _anvil-resolve-stable action="install":
exit 0
}
+ # 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.
+ #
+ # 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 }
+ 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..92c48037 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, and 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.
+# 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
@@ -166,22 +167,41 @@ anvil-container-tag:
$dockerfile = "$dockerfile".Trim()
$hookRel = '.anvil/container/hooks.ps1'
- # 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")
+ # 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()
+
+ # 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")
+
+ # 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 }
+ )
+ $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
@@ -342,7 +362,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
@@ -398,6 +418,13 @@ anvil-container-tag:
# would be a MethodNotFound at tag time, before anything useful happened.
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
+ # 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)
+ [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)) {
diff --git a/justfiles/anvil/tools.just b/justfiles/anvil/tools.just
index 8e942948..0ac99f6d 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'"
@@ -340,6 +341,19 @@ _anvil-resolve-stable action="install":
exit 0
}
+ # 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.
+ #
+ # 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 }
+ exit 0
+ }
+
if ($action -in @('msrv', 'install-msrv')) {
$msrvSelection = Get-MsrvSelection
if ($null -eq $msrvSelection) {