Skip to content

fix(cargo-anvil): let a repository with no root toolchain file build the container image - #161

Draft
martinhavelka (wukchung) wants to merge 10 commits into
mainfrom
u/mhavelka/anvil-container-toolchain-optional
Draft

fix(cargo-anvil): let a repository with no root toolchain file build the container image#161
martinhavelka (wukchung) wants to merge 10 commits into
mainfrom
u/mhavelka/anvil-container-toolchain-optional

Conversation

@wukchung

@wukchung martinhavelka (wukchung) commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🤖 Authored by an AI agent. Please verify before acting.

Stacked on #155. Base is u/mhavelka/anvil-container-msrv; review the last commit only.

Problem

The container setup region named the toolchain file directly:

COPY justfiles ./justfiles
COPY rust-toolchain.toml ./
COPY Cargo.toml ./

and anvil-container-tag listed the same path among the inputs it requires. A repository that pins its compiler by other means owns no root rust-toolchain / rust-toolchain.toml, so just anvil-container failed at the tag with container image input is missing, and would have failed at the first COPY had it got that far. Nothing else about the feature was reachable. microsoft/oxidizer is exactly such a repository, which is where this was reported.

Change

The region names no input and copies the context whole:

WORKDIR /opt/anvil
COPY . ./

No engine anvil supports offers a portable COPY of a path that may not exist -- a glob that matches nothing is an error on the classic builder and on buildah, and BuildKit's opt-out is a frontend flag -- so a conditional COPY was not available. 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. .anvil/container/ rides along; it is committed source, it has to be in the context for a gap COPY to work at all, and the image never runs it.

The ignore file now admits both toolchain-file spellings. Admitting only the TOML while naming neither in a COPY would leave a repository that pins with the extensionless rust-toolchain building an image whose compiler silently disagreed with its own checkout -- a worse outcome than today's hard failure.

The tag discovers the toolchain file rather than requiring it, in both spellings. Absence is one fewer record in the length-prefixed stream, so a repository with a toolchain file and one without cannot share a tag, and either spelling is its own input. The file's git mode still comes from the index. The ignore file stays a named, required input for the reason it always was: the walk cannot notice its absence, and without it the context widens to files the digest never hashed.

The coupled constraint in the bug does not hold

The bug (and the last commit of #155) said the unconditional COPY was load-bearing: that workspace MSRV validation stays out of reach of a memberless context only because a root toolchain file makes the resolver return early, so making the COPY conditional would put cargo metadata in front of a manifest whose members are absent.

That is not what protects it. Assert-WorkspaceMsrvCompatibility is reached through anvil-tool-rustc-validate-prereqs, and no -setup recipe depends on any -validate-prereqs recipe. The image runs just anvil-setup binstall and nothing else:

just --dry-run anvil-setup binstall

reaches _anvil-resolve-stable install-msrv and no other resolver action. Inside a running container the validation does execute, but against /workspace -- a real checkout, with its members. So no resolver change is needed; tools.just is untouched. The design note and the contract test that asserted the old reason now state the real one.

Validation

cargo test -p cargo-anvil --all-features (523 passed), cargo fmt, cargo clippy -p cargo-anvil --all-targets --all-features -D warnings, cargo spellcheck via just spellcheck, just readme, and cargo anvil --dry-run reporting the in-tree state current.

New coverage: a contract test that the setup region names no toolchain file and that the context admits both spellings, and a behavioural test driving anvil-container-tag through four states -- no toolchain file, rust-toolchain.toml, an edit to it, and the extensionless spelling with identical bytes -- asserting each produces a distinct tag and none refuses. The three emitted-tree snapshots were re-recorded and reviewed; they carry only the intended changes.

Validated end to end against the repository the report came from, microsoft/oxidizer (PR 728, which adopts 0.7.0 and owns no rust-toolchain* file), on Docker 29.7.1:

  1. Reproduced on that branch as released: just anvil-container-tag fails with anvil: container image input is missing: rust-toolchain.toml, before any COPY is reached.
  2. Regenerated with this build; the emitted region is COPY . ./ and the tag now resolves.
  3. Cold image build from that context succeeds. With no toolchain file the compiler comes from the declared MSRV, as it would on a host: anvil: installing stable toolchain '1.95', and rustup toolchain list inside the image reports 1.95 (active, default) beside the two pinned nightlies. just anvil-container <command> then runs in it.
  4. Also checked in isolation: a replica of the image context -- root manifest, recipe tree, no members, no toolchain file -- resolves the MSRV, and just --dry-run anvil-setup binstall there reaches _anvil-resolve-stable install-msrv and no other resolver action, which is the claim above holding in oxidizer's own generated tree rather than only in this one.

No regression for a repository that does own one: a cold build of this repository produces 1.97 (active) from rust-toolchain.toml beside the 1.95 MSRV, matching what #155 recorded before the COPY changed.

Podman is unverified; only Docker was available here. COPY . ./ has no engine-specific semantics, which is why it was chosen over a conditional COPY, but that is an argument rather than an observation.

No version bump or CHANGELOG entry: this repo generates the CHANGELOG at release time from conventional-commit messages.

WI: https://o365exchange.visualstudio.com/O365%20Core/_workitems/edit/7840047

`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
…old 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
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
…n 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
…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>
@codecov-commenter

Codecov Comments Bot (codecov-commenter) commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.5%. Comparing base (4b6fef2) to head (279cd17).
⚠️ Report is 2 commits behind head on u/mhavelka/anvil-container-msrv.

❌ Your project status has failed because the head coverage (97.5%) is below the target coverage (100.0%). You can increase the head coverage or adjust the target coverage.

Additional details and impacted files
@@                        Coverage Diff                        @@
##           u/mhavelka/anvil-container-msrv    #161     +/-   ##
=================================================================
- Coverage                             97.5%   97.5%   -0.1%     
=================================================================
  Files                                  299     299             
  Lines                                67766   67766             
=================================================================
- Hits                                 66129   66122      -7     
- Misses                                1637    1644      +7     
Flag Coverage Δ
linux 97.5% <ø> (-0.1%) ⬇️
linux-arm 97.5% <ø> (+<0.1%) ⬆️
windows 97.7% <ø> (+<0.1%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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
…able

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>
… into u/mhavelka/anvil-container-toolchain-optional

# Conflicts:
#	.anvil.lock
#	.anvil/container/Dockerfile
#	crates/cargo-anvil/docs/design/containers.md
#	crates/cargo-anvil/templates/anvil/container/Dockerfile.setup.region
#	crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap
#	crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap
#	crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap
Base automatically changed from u/mhavelka/anvil-container-msrv to main September 4, 2026 15:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants