feat(anvil): containerized execution - simplified - #83
feat(anvil): containerized execution - simplified#83martinhavelka (wukchung) wants to merge 87 commits into
Conversation
The 0.4.0 backend shipped nine generated files -- two shell drivers, two image-id helpers, an entrypoint, a README -- plus a tier-routing seam (runner.just, the anvil-runner managed region) and roughly 1200 lines of duplicated .sh/.ps1. Customizing it meant owning that surface. Containerized execution is now two artifacts and one optional hook: - .anvil/container/Dockerfile (with its build-context ignore file) defines what the image contains. It installs tools by running just anvil-setup, so there is no second tool list to keep in step. A repository edits it in place; a downstream catalog replaces it via replace_artifact. - justfiles/anvil/container.just drives the engine. The image tag *is* a SHA-256 over the Dockerfile, its ignore file, rust-toolchain.toml, the optional hook and the generated recipe tree, so presence implies freshness and there is no staleness bookkeeping. - .anvil/container/hooks.ps1, when present, supplies credentials. Anvil-PreBuild returns BuildKit secrets, Anvil-PreRun returns run-time environment; both are passed by variable name so a value never reaches a process argument or an image layer, and an empty value fails closed. There is no configuration file: whether the group is emitted is a catalog decision, and the only host-specific value -- which engine to call -- is the ANVIL_CONTAINER_ENGINE variable read at run time. There is no transparent tier routing either: just anvil-pr runs natively and the container is reached only through just anvil-container <recipe>. aprz.just drops its container-specific token plumbing; inside the image a credential now arrives as an ordinary environment variable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Docker installed inside WSL without Docker Desktop is a documented, common Windows setup -- it is the one this repository's own docs describe -- and it leaves no Windows CLI behind. Invoking the engine directly therefore made the feature unreachable on exactly the configuration we tell people to build. On Windows only, and only when the engine is absent from PATH, the recipe now routes through the default WSL distribution and translates the repository root with wslpath. Paths cross that boundary with forward slashes, since the intervening shell would otherwise eat the separators and hand wslpath a mangled path. Build secrets and forwarded run-time values are exported through WSLENV, which is where the engine reads them from when it runs there. Docker Desktop and Podman ship a Windows CLI, are found on PATH, and never take this path. scripts/test-anvil-container.ps1 is a black-box walk through the feature from a user's seat: it creates a repository, generates into it, and then only does what a developer would do. Its setup is held to that standard deliberately -- if it had to hand-write a generated file or work around a defect, that would be a bug in the product rather than something the script should absorb. 43 checks, against a real Docker daemon: the emitted artifacts, a first run that builds, a second that reuses, a toolchain bump that renames the tag and a revert that restores it, a Dockerfile edit that survives regeneration, a hook whose secret reaches the build without reaching a layer and whose value reaches a recipe at run time, an empty secret that fails closed, and a nested invocation that stays native. The design doc is rewritten against the narrowed contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…r engine The customization story was stated three times -- in containers.md, in extensibility.md 6.1, and in the crate docs that generate the README. That happened because main already carried a container subsection in extensibility.md for the old backend, and it was rewritten in place rather than reconsidered. Three copies of the same contract drift, and the one that drifts is whichever the reader happens to find. containers.md now owns it: the levers, who each one is for, and the coupling between the Dockerfile and its ignore file, including what a fork inherits rather than replaces. extensibility.md 6.1 keeps only what is genuinely a rule of the extensibility system -- justfiles/ holds .just recipes and nothing else, enforced by CatalogBuilder::build -- and explains that containerized execution is why. The customization detail is a pointer. The crate docs keep a sentence, since a README reader needs "you can change the image", not the fork API. Host setup gains a subsection per engine. Docker and podman need different things on Windows, and the old prose buried that: Docker Desktop and podman both ship a Windows CLI, while Docker-in-WSL does not and is reached through the WSL fallback. Each path is now written out, and podman's status is stated plainly -- wired up, expected to work, verified by nothing, with the BuildKit assumptions in the credential path called out as the specific risk. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
…onestly Three fixes found by CI and by running the e2e against podman. dependency_recipe_sources became dead when the old container tests were removed. CI builds with -D warnings, so dead_code is a hard error there and a silent warning locally; the function had no remaining caller and is deleted. "natively" was missing from the dictionary, which is the whole of the spell-check failure. cargo-spellcheck cannot run on this machine (it needs libclang), so the word was found by CI rather than locally. Running the e2e with -Engine podman surfaced a genuine engine defect: podman 6.0.2 on Windows cannot mount a build secret at all. It composes its own temp path from the build context after translating it into its machine's view, then joins it with a Windows separator, and fails before the build starts. A four-line Dockerfile reproduces it with no anvil involved, and `src=` fails identically to `env=`, so there is nothing to work around on our side. The secret plumbing therefore stays as it was -- by environment variable name, so the value never touches disk. The build failure path gains one hint, conditioned on "secrets were passed and the build failed" rather than on the engine, because that error names neither the secret nor the engine. The e2e now skips the three hook sections on podman-for-Windows with the reason stated, rather than reporting a known engine limitation as failure every run, and its image cleanup matches anywhere in a reference: podman reports images fully qualified (localhost/anvil-...) where docker does not, so the old prefix match left images behind and the next run saw a warm cache it expected to be cold. Validation: docker 43/43, podman 31/31 (3 hook sections skipped), cargo test -p cargo-anvil --all-features under -D warnings, --dry-run clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Codecov Report✅ All modified and coverable lines are covered by tests. ❌ Your project status has failed because the head coverage (97.7%) 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 @@
## main #83 +/- ##
======================================
Coverage 97.7% 97.7%
======================================
Files 286 286
Lines 62174 62333 +159
======================================
+ Hits 60751 60911 +160
+ Misses 1423 1422 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…e tag Section 9 of the e2e was headed "hook output does not change the tag" and did not test it. One assertion rewrote the hook to byte-identical contents, which only shows the hash is deterministic; the other changed the hook body and asserted the tag *did* move, directly contradicting the comment above it. Both were really re-testing that file content is hashed, which section 7 already covers, so the invariant the design leans on -- a credential must never influence a tag -- was unverified by a test that claimed to cover it. Proving it needs the hook file to be byte-identical while what it returns differs, so the fixture hook now reads its value from the environment and the reference is resolved twice with two different values. That matters beyond tidiness: if a minted value reached the hash, two developers holding different tokens would compute different images from identical inputs, and every token rotation would force a rebuild. The file-content half is kept as its own assertion, since both halves are load-bearing and they pull in opposite directions. Found by an independent review pass over the PR. docker 44/44. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
clippy::cloned_ref_to_slice_refs fires on \paths(&[replaced.clone()])\, and the workspace denies warnings. The clone was pointless anyway -- the slice only needs to borrow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…se review gaps An independent review pass over the PR found two functional regressions against behaviour the deleted runner.just explicitly had, plus a set of comments and assertions that claimed more than the code delivered. Every `just` value pasted into a PowerShell literal is escaped again. The removed runner.just wrapped each interpolation in replace(…, "'", "''") and the new driver had none, so a repository path containing an apostrophe broke every anvil-container recipe, and `target` let the remainder run as host PowerShell -- outside any container, and before the engine check. The image name and workdir stay unescaped because the first is regex-sanitized at definition and the second is a literal; a unit test now asserts nothing else slips through, since this convention has been lost once already. `*target` is split back into argv. Joining its parts into one argument made `anvil-container anvil-setup binstall` look for a recipe named "anvil-setup binstall", and around fifty generated recipes take a parameter -- including the one the image's own Dockerfile runs. ANVIL_CONTAINER_NO_REBUILD is checked outside the NO_CACHE guard. Nested inside it, a developer with NO_CACHE exported got a from-scratch build out of `anvil-container-status`, which is precisely what NO_REBUILD exists to prevent. The e2e's "no secret in any image layer" assertion could not fail: `history` reports the command that created each layer, not its contents, and the value is never a build argument. It now greps the image filesystem, where a written secret would actually land. Documentation corrected where it outran the code: containers.md §5 still described the src= transport that was reverted; the Dockerfile claimed the recipe refuses a floating base image, which it never checks; the COPY comment overstated what is copied; the cache-volume comment claimed collision-freedom the basename cannot provide; extensibility.md kept a conclusion whose mechanism had changed underneath it; and the crate docs omitted the WSL-only engine path this feature exists to support. Podman's second limitation -- it reads only a context-root ignore file, so the whole worktree is streamed -- is documented beside the build-secret one. docker 46/46; cargo test and clippy --all-targets --all-features clean under -D warnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
BuildKit finds `<dockerfile>.dockerignore` on its own; buildah reads only a context-root ignore file. Without the flag every podman build streamed the whole worktree -- `target/` included -- to the daemon, and a consumer repository owning a root `.dockerignore` had that one obeyed instead, which can exclude `justfiles/` and fail the build for a reason that names nothing relevant. Named rather than probed, because the engine that does not take the flag rejects it outright. This is a capability difference between the two engines, in the same category as the uid mapping already handled, not a second code path through the feature. docker 46/46, podman 31/31. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Anvil spliced `[lints] workspace = true` into every workspace member. A member that already declares its own `[lints.*]` then carries both, which cargo rejects outright -- and because the failure is in `cargo metadata`, it takes down every check in the workspace rather than only the offending crate. The ox-tools dogfood hit exactly this: one migrated crate keeps a deliberately lenient lint set, and generating made the repository unbuildable. Generalize the existing delta opt-out, which already solves the same shape of problem for `trip_wire_patterns`, into one `region_body` decision covering both regions. A crate that owns its lints now gets an empty managed region: the region stays tracked, so dropping the crate's own lints later adopts the catalog with no further gesture, and the plan explains the deferral rather than leaving it to be discovered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 72a310c4-f37b-497e-a10b-eb8d982d532d
…ve one `anvil-container-tag` prints the reference for the current inputs and exits. It is now the only place the content hash is computed -- the resolver asks it rather than repeating the computation -- because a publisher needs the tag before there is an image to inspect, and a published tag is only meaningful while it is the same reference the consumer will later look up. `Anvil-ResolveImage` is a third hook phase, offered the tag when nothing local matches and before a build starts. It returns the reference it made available rather than re-tagging to the local name: a local tag asserts "built here from these inputs", and a fetched image only claims that, since the hash is over source files and cannot be re-derived from layers. The returned reference is inspected before use, because the run is `--pull=never` and a hook that reported an image it never fetched would fail later and further from the cause. Every failure falls through to a local build -- a publisher that has not caught up must not block the change it has not caught up with. Resolution sits inside the NO_CACHE guard, since ignoring the cache has to mean the remote one too, and before the NO_REBUILD guard, since fetching is not building. ANVIL_CONTAINER_NO_RESOLVE skips it, and `anvil-container-status` sets it so a question about this machine cannot pull gigabytes to answer itself. The identity claim in the design doc is qualified accordingly: presence implies the current inputs by construction only for an image built here; for a resolved one the claim rests on the registry's tag immutability. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ae262693-67bf-46d4-a5af-df8da17c2553
clippy::needless_raw_string_hashes, denied workspace-wide. The assertion string contains no quote, so a plain raw string is enough. Mine for pushing without running clippy on that commit; the gate exists exactly for this.
…ints" This reverts commit 3c8d4d7.
…al' into u/mhavelka/anvil-container-acr # Conflicts: # .anvil.lock
The container documentation had drifted into narrating how the feature was built: it defended design choices against alternatives readers never saw, referenced a configuration file that was never shipped, and cited the end-to-end suite as evidence. Rewrite both the design doc and the crate docs around what the feature does and how to use it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ence `containers.md` becomes a reference specification: purpose, command surface, execution model, emitted artifacts, image identity, environment variables, the hook contract, host requirements, customization, and limitations. Every claim is stated against the implementation in `container.just`, `container.rs` and the default `Dockerfile`. Four inaccuracies are corrected: - Process identity. A Linux host passes `--user <uid>:<gid>`; `--userns keep-id`, which rootless podman needs for bind-mount ownership, is not passed. The previous text asserted both that a fork inherits "the uid mapping" and that no mapping is applied. - Podman is best-effort rather than fully supported, and all three known differences from Docker are listed together. - A new recipe subdirectory needs no ignore-file override: the build context re-admits `justfiles/` as a directory and the identity hashes it recursively. Only a Dockerfile that copies something else must replace the ignore file with it. - `hooks.ps1` is not emitted by default, which is what `local.md` already said. Behaviour previously undocumented is now specified: the per-invocation engine override, cache-volume sharing between checkouts of the same directory name, a missing image input as a hard error, the `ANVIL_SECRET_<id>` mount naming, `--pull=never` on run, and the ordinal-sort and LF-normalization properties that make the digest stable across platforms. The crate documentation in `lib.rs` keeps the short form and links out; `README.md` is regenerated from it. `extensibility.md` states the `justfiles/`-holds-recipes-only rule once and points at the customization section, whose anchor had gone stale. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7cdda814-67ed-42b6-9c60-01149a70f78d
…rose The `podman-docker` alias is removed as a justification for not probing between engines, in the recipe comment and in `containers.md`. The behaviour is unchanged: anvil still uses only the engine `ANVIL_CONTAINER_ENGINE` names. The remaining reasons -- presence is not reachability, and a silent choice between two engines splits the image cache -- carry the point on their own. Several passages are rephrased as reference documentation rather than as development notes. The engine-resolution rationale, the WSL path translation and the podman limitations were written as defect narratives, reproducing the diagnosis rather than stating the behaviour a reader needs. The podman build-secret error text is kept, since it is what a user matches against. Two further corrections: the artifact count is three, not two -- `container.just`, the `Dockerfile` and its ignore file, as `artifacts::container::all()` returns and §9 already said -- and `hooks.ps1` is described as one optional script that may define up to three functions, rather than as a single hook. `justfiles/anvil/container.just` and the three tree snapshots are regenerated from the template. `cargo run -p cargo-anvil -- anvil --dry-run` reports 78 items, all unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7cdda814-67ed-42b6-9c60-01149a70f78d
`containers.md` is reordered so each concept is introduced once, at the level a reader needs it, and detail follows the overview it depends on: interface -> artifacts -> identity -> runtime -> engines -> hook -> customization Two sections move as a result. The environment variables join the recipe table in "Command surface", since together they are the complete user-facing interface, and engine resolution and path translation move out of the execution model into "Engines and host setup", which previously repeated them from the other side. Duplication removed. The `just anvil-setup` install path was explained in the artifacts section and again under hashed inputs; the build-context scope was stated in artifacts and again under customization; the engine diagnostic policy appeared in both engine resolution and host requirements; "all three functions are optional" appeared twice in consecutive paragraphs; and the limitations section restated the platform pin, the toolchain requirement and the podman gap already covered above. Each now appears once, with a section reference where a reader might look for it elsewhere. The result is 10% shorter (3714 to 3338 words) while covering the same surface. `lib.rs` is corrected to match: it still described the feature as two generated artifacts, which the previous commit fixed only in the design document. `README.md` is regenerated from it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7cdda814-67ed-42b6-9c60-01149a70f78d
martinhavelka (wukchung)
left a comment
There was a problem hiding this comment.
🤖 Automated review pass over this branch: six independent models, then every finding checked against the code before it was written up. Eleven raw findings, eight survived. Both Gemini slots returned nothing, so this is a five-model pass; one slot was still running when the review was posted.
Dropped after checking:
- Spaces in a repository path breaking the WSL bind mount.
wsl.exepreserves a spaced argument; tested. image inspectthrowing under$ErrorActionPreference = 'Stop'instead of setting$LASTEXITCODE.$PSNativeCommandUseErrorActionPreferenceisFalseon pwsh 7.6.4.-split '\s+'losing argument boundaries. Real limitation, butjustflattens*targetbefore this code sees it, so the information is already gone and the comment says so.
Of the eight, the first two are worth acting on before merge; the rest are smaller. The volume finding is the one that undercuts a stated design property, and it reproduces against a real daemon.
martinhavelka (wukchung)
left a comment
There was a problem hiding this comment.
🤖 Follow-up. The sixth reviewer in the pass finished well after the others and is reported separately so the existing threads stay stable.
It independently reached the cache-volume finding and the double-quoted interpolation, which is worth knowing: those two were single-model findings in the first batch and are now two-model. It added three further items, all verified here before posting, plus one point that changes the shape of the volume finding rather than standing alone, which is a reply on that thread instead of a new one.
One thing it raised is deliberately not posted. It flagged the whitespace re-split of *target as a defect and proposed passing the values as positional arguments; just flattens a variadic parameter into a single string before the recipe body can see it, and a [script] recipe receives no argv, so the boundaries are gone upstream of this code and the comment above the split already says so. Real limitation, not a defect, and the same conclusion the earlier batch reached.
There was a problem hiding this comment.
Pull request overview
This PR rewrites cargo-anvil’s containerized execution to a much smaller, explicit surface: container runs are opt-in via just anvil-container …, the image is defined by a repository-owned .anvil/container/Dockerfile (+ Dockerfile.dockerignore), and the previous tier-routing seam (ANVIL_RUNNER, runner.just, tier-routing tests, and the large generated .anvil/container/ driver set) is removed. Documentation and templates are updated to match the new contract, including the optional PowerShell hook (hooks.ps1) for secrets/env/image resolution.
Changes:
- Remove transparent tier routing (
ANVIL_RUNNER,runner.just, routed tier recipes) so tiers always run natively and container use is explicit. - Introduce the simplified container backend:
justfiles/anvil/container.justdrives build/run and computes a content-based image tag;.anvil/container/Dockerfile*defines the image;hooks.ps1is optional. - Update docs/tests/templates to reflect the new container contract and credential handling (including APRZ token behavior).
Reviewed changes
Copilot reviewed 53 out of 54 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| justfiles/anvil/tiers.just | Removes routing via _anvil-run; tiers call their work directly. |
| justfiles/anvil/runner.just | Removes legacy tier-routing recipe file. |
| justfiles/anvil/mod.just | Drops import of runner.just. |
| justfiles/anvil/container.just | Replaces old driver invocation with new PowerShell-based engine/tag/build/run implementation. |
| justfiles/anvil/checks/aprz.just | Simplifies token acquisition to GITHUB_TOKEN or gh auth token, warns and continues unauthenticated otherwise. |
| Justfile | Removes anvil-runner managed region and ANVIL_RUNNER default. |
| crates/cargo-coverage-gate/README.md | Updates doc2readme dependency metadata blob. |
| crates/cargo-anvil/tests/tier_routing.rs | Removes tests for tier-routing seam (ANVIL_RUNNER, _anvil-run). |
| crates/cargo-anvil/tests/extensibility.rs | Updates container artifact expectations to Dockerfile/Dockerignore/hooks model and adjusts customization test. |
| crates/cargo-anvil/tests/container_upgrade.rs | Removes upgrade-path tests tied to the previous .anvil/container/ asset set relocation. |
| crates/cargo-anvil/tests/container_customization_bash.rs | Removes Bash driver contract tests for the old customization/driver system. |
| crates/cargo-anvil/templates/regions/justfile-runner.just | Removes the template for the anvil-runner region. |
| crates/cargo-anvil/templates/justfiles/anvil/tiers.just | Mirrors tier de-routing changes in templates. |
| crates/cargo-anvil/templates/justfiles/anvil/runner.just | Removes template for legacy runner.just. |
| crates/cargo-anvil/templates/justfiles/anvil/mod.just | Mirrors removal of runner.just import in templates. |
| crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just | Mirrors APRZ token logic update in templates. |
| crates/cargo-anvil/templates/container/Dockerfile.dockerignore | Adds new build-context scoping ignore file template for Dockerfile-based image. |
| crates/cargo-anvil/templates/container/Dockerfile | Adds new default execution-image Dockerfile template (installs tools via just anvil-setup binstall). |
| crates/cargo-anvil/templates/anvil/container/run-in-container.sh | Removes legacy generated Bash driver script template. |
| crates/cargo-anvil/templates/anvil/container/run-in-container.ps1 | Removes legacy generated PowerShell driver script template. |
| crates/cargo-anvil/templates/anvil/container/README.md | Removes legacy generated container backend README template. |
| crates/cargo-anvil/templates/anvil/container/image-id.sh | Removes legacy image-id helper template (Bash). |
| crates/cargo-anvil/templates/anvil/container/image-id.ps1 | Removes legacy image-id helper template (PowerShell). |
| crates/cargo-anvil/templates/anvil/container/entrypoint.sh | Removes legacy entrypoint template. |
| crates/cargo-anvil/templates/anvil/container/Containerfile.dockerignore | Removes legacy Containerfile ignore template. |
| crates/cargo-anvil/templates/anvil/container/Containerfile | Removes legacy Containerfile template. |
| crates/cargo-anvil/src/lib.rs | Updates crate docs to describe explicit container invocation, content-based image identity, and hooks. |
| crates/cargo-anvil/src/anvil/artifacts/region.rs | Removes Justfile runner region artifact emission. |
| crates/cargo-anvil/src/anvil/artifacts/mod.rs | Removes runner artifacts and adjusts owned-file marker expectations for editable Dockerfile artifacts. |
| crates/cargo-anvil/src/anvil/artifacts/justfile.rs | Removes embedded runner.just and updates template assertions for tiers/APRZ. |
| crates/cargo-anvil/README.md | Mirrors updated containerized execution documentation. |
| crates/cargo-anvil/docs/design/README.md | Updates design-doc index and repository layout notes for the new container model. |
| crates/cargo-anvil/docs/design/local.md | Updates emitted-tree documentation for .anvil/container/ and container invocation model. |
| crates/cargo-anvil/docs/design/extensibility.md | Replaces the old “optional container runner” section with placement rules aligned to new content hashing model. |
| .spelling | Adds new container-related terms and removes obsolete Containerfile. |
| .anvil/container/run-in-container.sh | Removes legacy generated Bash driver file from this repo’s generated assets. |
| .anvil/container/run-in-container.ps1 | Removes legacy generated PowerShell driver file from this repo’s generated assets. |
| .anvil/container/README.md | Removes legacy generated README from this repo’s generated assets. |
| .anvil/container/image-id.sh | Removes legacy generated image-id helper (Bash) from this repo’s generated assets. |
| .anvil/container/image-id.ps1 | Removes legacy generated image-id helper (PowerShell) from this repo’s generated assets. |
| .anvil/container/entrypoint.sh | Removes legacy generated entrypoint from this repo’s generated assets. |
| .anvil/container/Containerfile.dockerignore | Removes legacy generated ignore file from this repo’s generated assets. |
| .anvil/container/Containerfile | Removes legacy generated Containerfile from this repo’s generated assets. |
| .anvil/container/Dockerfile.dockerignore | Adds the new generated Dockerfile ignore file to this repo’s generated assets. |
| .anvil/container/Dockerfile | Adds the new generated Dockerfile to this repo’s generated assets. |
| .anvil.lock | Updates tracked artifact set/checksums to the new container artifacts and removed runner region. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The run mounted named volumes over \ and \. An engine seeds a named volume from the image only when that volume is first created, so the first image's tools were pinned over every later tag: a tool bump renamed the tag, built a new image, and still ran the old binary. Both paths are also world-writable, so the shadowed copy was mutable and outlived the tag that named it. Cache only cargo's content-addressed registry and git downloads. Engine calls in WSL used 'wsl.exe --', which hands the command line to the login shell. A repository path containing \$ was silently truncated -- wslpath still exited 0, so the guard never fired and the wrong directory was bind-mounted -- and a ';' in a recipe argument ran on the WSL host. Use --exec, which bypasses the shell. Also: derive the image name so it is always a valid reference (a checkout in 'ox-tools (copy)' produced a trailing separator that the engine rejects); report a failed volume removal instead of always exiting 0; give a mapped uid a writable HOME; compute the tag before the presence query so status cannot report a fatal input error as 'not present'; correct the ANVIL_IN_CONTAINER comment and the documented engine override, neither of which matched the code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
anvil-aprz runs in the pr-fast group and queries the GitHub advisory API, which allows 60 requests an hour unauthenticated -- fewer than a full tier needs. Inside the image it had no route to a token at all, so the flagship 'just anvil-container anvil-pr' degraded to warnings and rate limits instead of failing with instructions. Forward GITHUB_TOKEN by name when the host already has it set, so a containerized tier authenticates exactly as the same recipe does natively and CI keeps working when it runs a tier this way. It is forwarded, never minted: running 'gh auth token' in the driver would hand a broadly-scoped credential to every recipe in the container, including the ones that never see it on the host, where anvil-aprz scopes it to itself. Forwarding by name only works if the engine can see the name, so the WSLENV bridge now covers every forwarded variable rather than only the hook's -- without that, '-e NAME' reaches an engine that cannot see NAME and silently forwards nothing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The image copied all of justfiles/ but the tag hashed only justfiles/anvil/**/*.just. A file that is copied without being hashed can change what a build produces while naming a tag that already resolves -- and because presence of the tag is checked first, the existing image is reused and the change is never built. Narrow the context to justfiles/anvil/ so the two sets agree by construction. The public catalog had no reachable symptom, since the synthetic Justfile imports only the anvil tree, but a fork that replaces the Dockerfile can copy anything it likes, and the hashed set is fixed and cannot be extended. Record that as a limitation rather than leaving it to be discovered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…le's The region removal loop resolved the host's casing for its liveness and refusal checks, then read, spliced and wrote under the casing the lock recorded. `HostTextCache` is keyed by the exact string and the writes of the same pass seeded it under the resolved spelling, so after a case-only rename the removal missed the cache, re-read the pre-pass file, and wrote that text back. Removals apply after writes, so the stale text won: a host that gained a region in the same pass lost it again, and for a composed Dockerfile that is every region in the file. The read, the cache update and the write now use the resolved spelling; the manifest key stays as recorded so the retired entry is still purged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8e2a0c6a-d500-4acf-a91b-b443c029e866
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 78 out of 82 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
crates/cargo-anvil/src/plan.rs:493
- The manifest-provided
hostis used to compute a filesystem path for rewriting during region removal without validating that it is a safe repo-root-relative path. If.anvil.lockis corrupted or malicious, this could overwrite files outside the repository root.
(Target::Region { host, id }, Decision::Remove) => {
// Untouched orphan region: splice the markers + body
// out of the host file and drop the manifest entry.
// The host is resolved to its on-disk casing for the
// write, for the reason the File arm above gives; the
// manifest key stays as recorded so the entry is purged.
let spliced = item.spliced_host.as_ref().expect("region Remove must carry spliced host");
let abs = repo_root.join(resolve_existing_case_insensitive(repo_root, host));
write_file(&abs, spliced)?;
Every path in the manifest is joined to the repository root and then read, written or deleted. `Path::join` discards the base when given an absolute or drive-qualified path, and `..` climbs out of it, so a corrupted or hand-edited `.anvil.lock` could direct any of those operations outside the repository. Paths are always stored `/`-separated and repository-relative, so the check costs nothing a valid manifest can trip on. Validation goes at the load site rather than the delete sites, so it covers every path the manifest yields, including the ones used for writes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8e2a0c6a-d500-4acf-a91b-b443c029e866
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 78 out of 82 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
crates/cargo-anvil/src/region.rs:234
RegionPlacement::Atis documented as a byte offset, but this branch slicestext[..offset]/text[offset..]. If a caller passes an offset that is not on a UTF-8 char boundary, these slices will panic at runtime. Sinceupsert_region_with_placementis public, it should defensively normalize the offset to a valid char boundary before slicing.
Brings in cargo-gamma. The only conflict is `.spelling`, resolved as the union both sides intend for a dictionary. Carries one fix alongside the merge: `ensure_contained` admitted an empty path and a bare `.`, neither of which has a component to reject, so both resolved to the repository root and would send a read, write or delete at a directory. A manifest path must name at least one segment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8e2a0c6a-d500-4acf-a91b-b443c029e866
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 78 out of 82 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore:4
- This template is used for the repository-composed
Dockerfile.dockerignore. Since consumers may need to adjust the build context when they addCOPYstatements in Dockerfile gaps, the “DO NOT EDIT DIRECTLY” wording conflicts with the intended customization story. Use a weaker provenance marker instead so edits are not discouraged.
.anvil/container/Dockerfile.dockerignore:4 - The Dockerfile allow-list is intended to be repository-composable (e.g., when adding
COPYstatements in the Dockerfile gaps), but this header says “DO NOT EDIT DIRECTLY”, which contradicts the documented customization model and may discourage required edits. Consider switching to the weaker provenance marker used for co-owned files (e.g. “Managed by cargo-anvil”) and remove the hard "do not edit" wording.
# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY.
# Update the corresponding template in the cargo-anvil crate.
…the Dockerfile header region Five defects, each reachable from an ordinary setup: - A wrapped tier ran unauthenticated. `just --dry-run` reports the bodies just runs itself, not the body of a recipe one of them launches as a child process, so planning `anvil-scheduled` showed the `_anvil-unscoped` wrapper and none of the checks under it. The driver read that as "needs no token" and `anvil-aprz` then slept on the advisory API's unauthenticated rate limit, inside an image with no `gh` of its own. The driver now follows each nested target a plan names. - The boundary forwarded `ANVIL_INCLUDE_MODIFIED` / `_AFFECTED` / `_REQUIRED`, which nothing reads, and dropped `ANVIL_IMPACT`, which `anvil-impact` does. A CI group job exports `consume`; a container that did not inherit it recomputed scoping from a diff instead of trusting the artifact the group had downloaded. - `--separate-git-dir` produced a container in which git could not resolve a single ref. The mount triggered on the git directory differing from the common one, but that redirect leaves them equal, so the checkout kept a `.git` file naming a host path. The predicate is now the shape of `.git`. A git directory outside its common directory is refused with a message rather than mounted at a path that climbs out of the mount. - The recipes hard-coded `.anvil/container/Dockerfile` while the generator resolves that path against the casing already on disk. On a case-sensitive filesystem a repository carrying `dockerfile` had its regions maintained in a file the build then could not find. - The manifest's containment guard was lexical, so a path whose components are all ordinary still left the repository when one of them was a symlink pointing out of it. Since the manifest is committed, one commit could add both the link and the entry naming a path through it. Writes, deletes and proposals now resolve before acting. The Dockerfile loses its header region: it held only commentary, and a managed region that contributes no build instruction is a section that exists to hold prose. The guidance it carried is in containers.md, where it does not have to be reconciled into every adopter's file. The copyright notice moves to the scaffold, alongside the parser directive, so a fresh file still carries it. Remaining region comments state the constraint that is not visible in the instruction below them and nothing else. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8e2a0c6a-d500-4acf-a91b-b443c029e866
…he last commit dropped The appended tests left the file without a final newline, which rustfmt rejects, and the containment doc comment introduced a word the hunspell dictionary does not carry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8e2a0c6a-d500-4acf-a91b-b443c029e866
COPY carries a file's mode into the image, so a chmod +x with no content change altered what the image contained while the tag kept resolving, and the stale image was reused. The bit comes from git's index rather than the filesystem: Windows has no executable bit, so a filesystem read would make two checkouts of one commit disagree on the tag. A path git cannot account for frames as non-executable, which can cost a cache miss but never a stale reuse. Every image reference changes once. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8e2a0c6a-d500-4acf-a91b-b443c029e866
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 77 out of 81 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
crates/cargo-anvil/src/manifest.rs:91
ensure_containedclaims manifest paths are “/”-separated, but it doesn’t currently reject backslash-separated inputs. On Unix, a path containing\is treated as a literal character (not a separator), which can make the same.anvil.lockbehave differently across platforms and break case/casing resolution logic that assumes/splitting.
/// target a directory. Paths are always stored `/`-separated and
/// repository-relative, so anything else is malformed.
///
.anvil/container/Dockerfile.dockerignore:4
- This file is described in the design docs as intentionally customizable (“Managed by cargo-anvil.” rather than “DO NOT EDIT”), but the header still says “GENERATED … DO NOT EDIT DIRECTLY”, which is likely to mislead adopters who need to widen the build context (or derived catalogs that intentionally replace/adjust this file).
# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY.
# Update the corresponding template in the cargo-anvil crate.
…drop a duplicated tier comment Path::components folds a trailing '.' away, so 'a/.' satisfied the name-count check while naming a directory, against the contract the function documents. The raw final segment now decides. The full tier carried its summary line twice, once as prose and once as the doc comment just --list reads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8e2a0c6a-d500-4acf-a91b-b443c029e866
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 77 out of 81 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
crates/cargo-anvil/src/region.rs:234
RegionPlacement::Attreatsoffsetas a byte index, but the implementation slicestext[..offset]/text[offset..]without ensuringoffsetis on a UTF-8 char boundary. If a caller ever passes a non-boundary byte offset (the API docs call it a byte offset), this will panic at runtime. Normalizeoffsetto a char boundary before slicing.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 77 out of 81 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
crates/cargo-anvil/templates/anvil/container/Dockerfile.header:4
docs/design/updates.mdstates the containerDockerfileand its ignore file should use a weaker provenance marker (“Managed by cargo-anvil.”) rather than a “do not edit” header. The generated Dockerfile currently has no such marker (and the upgrade test fixture uses it), so consumers won’t get the intended guidance.
crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore:4
- This file is intended to be user-customizable (it’s explicitly called out as a “Managed by cargo-anvil” exception in
docs/design/updates.md), but its header currently says “GENERATED … DO NOT EDIT DIRECTLY”, which contradicts the documented customization path for.anvil/container/Dockerfile*and discourages the intended edits.
Windows treats the backslash as a separator and Unix as an ordinary filename character, so a path carrying one denotes different things on different machines and slips past whichever check is written in terms of the other: 'a\\' counts a component on Windows yet ends no '/' segment, and '..\\x' climbs on Windows while reading as a single filename on Unix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8e2a0c6a-d500-4acf-a91b-b443c029e866
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 77 out of 81 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
justfiles/anvil/container.just:135
anvil-container-tagassumes the build-context ignore file is named<DockerfileName>.dockerignore(derived from the resolved Dockerfile path). If a repo has a case-only renamed Dockerfile (e.g..anvil/container/dockerfile), the generated ignore file is still.anvil/container/Dockerfile.dockerignore, so tag computation/build will look for.anvil/container/dockerfile.dockerignoreand fail. Add an explicit check here so the failure is immediate and actionable (and consider aligning generation so the ignore file casing matches the Dockerfile casing).
…mod naming the wrong image, and reject a drive-qualified manifest path Resolving the Dockerfile to its on-disk casing broke the ignore file. The engine derives that name from the Dockerfile's -- BuildKit reads <dockerfile>.dockerignore and takes no flag pointing elsewhere -- while anvil maintains the artifact at a fixed canonical path, so a repository carrying 'dockerfile' would build with no ignore file at all and stream the whole worktree into the context. The same derivation missed the owned file in the text-normalized set, so CRLF and LF checkouts could disagree on the tag. The two names must agree and only one can move, so a case variant is now refused with the reason rather than accommodated. The digest reads the index mode while the build copies the working tree, so an unstaged chmod changed the image without renaming it. Framing the working tree instead would break the property the index was chosen for: Windows has no executable bit, so two checkouts of one commit would disagree and a published image would stop resolving. The disagreement is refused instead, naming the file and the recovery. Path::components reports a drive qualifier as a Prefix on Windows and as an ordinary name on Unix, so 'C:x.txt' was accepted there despite the documented contract. Matched lexically now. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8e2a0c6a-d500-4acf-a91b-b443c029e866
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 77 out of 81 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
justfiles/anvil/checks/aprz.just:30
- The
gh auth tokencall doesn’t check$LASTEXITCODE. For external commands, a non-zero exit code won’t be caught bytry/catch, so this can treat a failedghinvocation as success if it emits any output. Explicitly clear$tokwhenghexits non-zero before using it asGITHUB_TOKEN.
git diff --raw encodes an unstaged deletion as a transition to mode 000000 and an intent-to-add as one from it, so the guard added for chmod drift aborted the tag on both. Retiring a managed file and not yet staging the deletion is the ordinary way to reach that. A path missing from the build context is missing from the digest too, so only a transition between two real modes is drift. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8e2a0c6a-d500-4acf-a91b-b443c029e866
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 77 out of 81 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
justfiles/anvil/checks/aprz.just:37
- In a container run, this recipe cannot obtain a token via
gh(the image intentionally has no gh CLI), so whenGITHUB_TOKENis missing it will always fall back to unauthenticated access and can block for up to an hour (as the comment above notes). Consider failing fast whenANVIL_IN_CONTAINERis set and no token is available, with actionable instructions to set hostGITHUB_TOKENor authenticateghon the host soanvil-containercan forward it.
…igest framed The drift guard compared the two mode fields of git diff --raw, but the raw index field is not what the digest uses. An intent-to-add entry reports zero there while ls-files --stage reports a real placeholder mode, so an executable file added with git add -N was exempted although it reaches the build context with its mode, and an ordinary one would have been rejected. Only a zero working-tree mode is exempt now, because that is a deletion and the path is in neither the context nor the digest. Everything else is compared against the framed bit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8e2a0c6a-d500-4acf-a91b-b443c029e866
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 77 out of 81 changed files in this pull request and generated no new comments.
Suppressed comments (2)
.anvil/container/Dockerfile.dockerignore:4
- This file’s header says "GENERATED ... DO NOT EDIT DIRECTLY", but the design docs explicitly call out
.anvil/container/Dockerfile*as an exception that should be edited in place when customizing container builds. The current wording is misleading for repository maintainers.
# GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY.
# Update the corresponding template in the cargo-anvil crate.
crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore:4
- This header says "GENERATED ... DO NOT EDIT DIRECTLY", but the design docs explicitly call out
.anvil/container/Dockerfile*as an exception that should be editable in place (marked "Managed by cargo-anvil."). The current wording is misleading and contradicts the documented customization path.
COPY preserves a symlink as a symlink while the digest walk reads through it, so replacing a regular file with a link to identical bytes changed the image object without renaming the tag. The executable bit was the only part of the mode being framed, and the drift guard compared the same single bit, so a 100644 to 120000 transition passed both. The digest now frames the index mode itself and the guard compares against that mode, which subsumes the executable bit and covers every regular-file transition git can record. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8e2a0c6a-d500-4acf-a91b-b443c029e866
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 77 out of 81 changed files in this pull request and generated no new comments.
Suppressed comments (4)
Previously missed (3) — in code that hasn't changed since the last review.
crates/cargo-anvil/templates/anvil/container/Dockerfile.header:4
- This Dockerfile scaffold doesn’t include the "Managed by cargo-anvil." provenance marker, but
docs/design/updates.mdsays.anvil/container/Dockerfile*should intentionally use that weaker marker (because repositories are expected to edit these files in place). Keeping the template aligned avoids confusing downstream users and keeps generated output consistent with the design docs.
crates/cargo-anvil/src/lib.rs:244 - The rustdoc says anvil owns "six regions" in
.anvil/container/Dockerfile, but the generated Dockerfile/lock entries show five managed regions (base-image, base, tools, setup, entry). This mismatch makes the contract unclear for users adding content to the gaps.
//! `.anvil/container/Dockerfile` is a **user-composed file with managed
//! regions**: anvil owns six regions inside it and keeps them current, and the
//! gaps between them are the repository's. Add to the gap that matches when the
//! addition is needed -- re-declare `ARG BASE_IMAGE` to build on another base,
crates/cargo-anvil/docs/design/local.md:65
- This sentence says the container Dockerfile is composed from "four managed regions", but the current composed Dockerfile contains five anvil-managed regions (base-image, base, tools, setup, entry). The count matters because it determines how many user-editable gaps exist.
container `Dockerfile` is composed the same way, from four managed regions with
crates/cargo-anvil/templates/anvil/container/Dockerfile.dockerignore:4
- The header says "GENERATED BY cargo-anvil. DO NOT EDIT DIRECTLY", but
docs/design/updates.mdexplicitly calls out.anvil/container/Dockerfile*as an exception that should be marked "Managed by cargo-anvil." (since repositories are expected to customize the Dockerfile and ignore file). This header currently contradicts that contract.
|
Impact
Any anvil-managed repository can run its checks in a pinned Linux image with one command, on Windows or Linux, with no image to publish and nothing to configure. This replaces the 0.4.0 container backend, which shipped nine generated files into every consumer repository and could not build its own image.
For a repository that does not use containers, nothing changes: three artifacts are emitted, and no recipe behaves differently.
Architecture
Three generated artifacts, plus an optional hook.
justfiles/anvil/container.just.anvil/container/Dockerfile.anvil/container/Dockerfile.dockerignore.anvil/container/hooks.ps1The image and the checks cannot disagree about the toolset. The image installs its tools by running
just anvil-setup— the same recipe the checks use, from the same pins — so there is no second tool list to drift.The Dockerfile is composed, not owned. An owned file that invites in-place edits freezes the base digest and four tool pins the moment a repository edits it, while the tag keeps resolving — because it hashes their file. The identity scheme works perfectly and still names a stale image. Anvil owns five regions (
base-image,base,tools,setup,entry) and the four gaps between them are yours, each defined by what must already be true at that point in the build: a secondARG BASE_IMAGEbeforeFROM, a root CA before the first download, a compile dependency beforeanvil-setup, a run-time tool after it. Regions do not make anvil's content unwritable — an edit inside one is still preserved, as everywhere else in the tool — they remove the reason to edit it.Two constraints the region engine grew for this:
# syntax=docker/dockerfile:1is honoured only as line 1 and a sentinel is a comment, so it is seeded as a scaffold outside the regions; and region order is semantic, so ordering is enforced rather than assumed.A host anvil cannot compose safely is refused, not guessed at.
upsert_regionappends a missing region at end-of-file, which is right only when the file is already the composed shape — so four states are rejected with a diagnostic and the file left byte-identical: a previous render the repository has since edited, a Dockerfile anvil has never owned, one carrying some regions but not all, and one whose regions are out of order. Refusal means the lock too: the recorded checksum is the provenance the next run reclassifies from, and reverting the edit is the clean recovery, which only exists while that entry survives. Classification happens once per host from its on-disk state, so a refusal is atomic rather than landing a write first.The image tag is a digest of its inputs: everything under
.anvil/container/,rust-toolchain.toml, and every file underjustfiles/anvil/— which is exactly what the build context copies. The container directory is walked rather than named file by file, so anything a gapCOPYs is an input too. The whole recipe tree counts becausejust anvil-setupreaches the install recipes through the tier, group and check recipes, so the routing decides whether a tool is installed just as surely astools.justdecides how. A changed input names a tag that cannot exist yet, so a build follows, and an image that is present is current by construction. The cost is that editing any recipe rebuilds the image; a tag that could name contents the image does not have would make every guarantee here meaningless.The image supplies the toolchain, never the code. Recipes always execute from the mount; the copy baked into the image exists only to install tools at build time.
Design:
docs/design/containers.md.Usage
The arguments are the argv executed inside the image, so anvil recipes are reached by naming
just. The first run builds the image; later runs start immediately.just anvil-prstill runs natively — the container is opt-in per invocation, never transparent routing.Requires Docker or Podman (
ANVIL_CONTAINER_ENGINE=podman). On Windows, an engine installed only inside WSL is reached throughwsl.exeautomatically, so Docker Desktop is not required.A fork supplies an internal image and credentials by replacing the base and tool regions and adding
hooks.ps1, which may defineAnvil-BuildSecrets(build secrets),Anvil-RunEnv(run-time environment) andAnvil-ResolveImage(pull a published image). It inherits the catalog install and the entry contract, so a different base OS is two region replacements rather than a forked 112-line file. Credentials are passed by variable name in both phases, so a value never reaches a process argument or an image layer, and an empty value fails closed.Validation
cargo test -p cargo-anvil --no-fail-fast— 447 passed, 0 failedscripts/test-anvil-container.ps1 -Engine docker— 71/71 in 9m30s, black-box against a real daemon: artifacts, build, reuse, tag changes and reverts, content in a Dockerfile gap surviving regeneration, an edit inside a region being preserved, a non-justcommand running in the image, hook secrets reaching the build but not a layer, empty secrets failing closedcargo anvil --dry-run— exit 0, 87 items unchanged, so the regenerate gate stays greencargo +nightly fmt -- --check,cargo clippy --workspace --all-targets --all-features -- -D warnings,just anvil-spellcheck— cleanBreaking
ANVIL_RUNNER, theanvil-runnermanaged region,justfiles/anvil/runner.justand the 0.4.0.anvil/container/asset set are gone. A repository that adopted 0.4.0 regenerates and usesjust anvil-container just <recipe>. Nothing consumes that backend today.A repository on the previous layout, where
.anvil/container/Dockerfilewas an owned file, is re-seeded into the composed form on the next run: a file tracked as owned in the lock and carrying none of the regions is a previous render, not composition. A Dockerfile the repository wrote itself is refused and left untouched, because there is no position for the regions that would not put its content aboveFROM; the refusal names the file and the recovery.artifacts::container::dockerfile()never reachedmain— it was introduced and then replaced within this branch — so this is not a break against the released API. It matters to work already built on an earlier commit of this branch: the single constructor is nowdockerfile_base_image(),dockerfile_base(),dockerfile_tools(),dockerfile_setup()anddockerfile_entry(), and a catalog replacing the whole file moves to replacing the regions it actually cares about.Version and
CHANGELOG.mdare untouched: releasing is a separate PR viascripts/release-crate.ps1.