Skip to content

feat(plugin-oci): OCI/container image integration (build, push, load, pull) - #159

Open
raphaelvigee wants to merge 34 commits into
masterfrom
worktree-bridge-cse_01Eu3bwzWeKmzmR38LBBjtJk
Open

feat(plugin-oci): OCI/container image integration (build, push, load, pull)#159
raphaelvigee wants to merge 34 commits into
masterfrom
worktree-bridge-cse_01Eu3bwzWeKmzmR38LBBjtJk

Conversation

@raphaelvigee

@raphaelvigee raphaelvigee commented Jul 22, 2026

Copy link
Copy Markdown
Member

What

A full OCI/container image integration for heph, as the plugin-oci crate. Four drivers, selected in a BUILD file via driver = "...".

It ships the way the go and gha plugins do — not compiled into the CLI, but as a per-os/arch cdylib published with a manifest a workspace opts into:

plugins:
  - url: https://…/heph-oci-plugin.json

oci_image — build (cacheable)

Builds a container image archive from a Dockerfile + build context via host docker buildx. Outputs the archive (<name>.tar) and a digest group.

target(
    name = "img", driver = "oci_image",
    context = {"": [":dockerfile"], "bin": ["//cmd/server:bin"]},
    dockerfile = "Dockerfile",
    build_args = {"VERSION": "1.2"},
    stage = "runtime",                    # multi-stage: buildx --target
    platforms = ["linux/amd64", "linux/arm64"],
    builder = "multi",                    # which buildx builder to build on
    bases = {"base": [":alpine"]},        # FROM base, from an oci_pull layout
    secrets = ["id=token,env=TOKEN"], ssh = ["default"],
    format = "oci",                       # "oci" (default) | "docker"
    cache_from = ["type=registry,ref=reg/img:cache"],
    cache_to = ["type=inline"],
)

The build context is the sandbox workspace root, so a context dep from any package is reachable and COPY paths are workspace-relative. Each context group is also exported as a SRC_<GROUP> build arg, so a Dockerfile need not hardcode the layout:

ARG SRC_BIN
COPY ${SRC_BIN} /usr/bin/server

oci_pull — base-image fetch (cacheable)

The image-world http_fetch: pulls a registry image into a cacheable archive so a shared base is pulled once. Warns on unpinned tags (pin @sha256: for reproducibility).

target(name = "alpine", driver = "oci_pull",
       ref = "docker.io/library/alpine@sha256:...",
       layout = True,          # OCI layout dir — the form `bases` consumes
       all_platforms = True)   # keep the whole index, for a multi-arch build

oci_push — push to registry (action, uncached)

skopeo copy --multi-arch all <transport>:<tar> docker://<ref> — daemonless, keeps a manifest list intact, skips blobs the registry already has. tool = "docker" does load + tag + push + rmi instead, for a docker-format archive.

target(name = "push", driver = "oci_push", image = ":img", ref = "reg.io/me/app:1.2")

oci_load — load into local daemon (action, uncached)

docker load -i for a docker archive; skopeo copy oci-archive:<tar> docker-daemon:<tag> for an OCI archive. A daemon tag holds one image, so a multi-arch archive is narrowed by platform (default linux/<host arch>).

target(name = "load", driver = "oci_load", image = ":img", tag = "app:dev")

Multi-stage and multi-arch

Multi-stage is stage → buildx --target. Multi-arch has one working route, and every step of it is exercised end to end against a real registry:

oci_pull(layout = True, all_platforms = True)   [skopeo]
  → oci_image(platforms = […], bases = {…})     [buildx, container builder, format = "oci"]
    → oci_push                                  [skopeo, --multi-arch all]
    → oci_load(platform = "linux/amd64")        [skopeo — a daemon tag holds one image]

A docker-CLI-only route (format = "docker", no skopeo anywhere) is fully supported but single-arch end to end; platforms with more than one entry and format = "docker" is rejected at parse.

Caching (the efficiency goal)

Layered, in order of impact:

  1. heph input-hash cache (primary win)oci_image inputs are the context files, Dockerfile, build args, stage, platforms, the resolved builder platform, base contexts, secrets and ssh specs. Unchanged inputs → cache hit → no rebuild; the archive is served from the local or remote cache. Image timestamp nondeterminism doesn't defeat this: heph keys on inputs and serves the identical cached archive to consumers.
  2. BuildKit layer cachecache_from / cache_to wire --cache-from/--cache-to (registry/inline). Deliberately excluded from the input hash (a build optimization, not part of the image's identity — changing them never busts the heph cache).
  3. oci_pull base cache — base images cached (local + remote), pulled once.

What is in the key, and why

The remote cache key carries no OS or arch segment, so an unpinned platform is a wrong-artifact bug, not a miss:

  • platforms empty → the builder is asked for its default platform at parse time (docker buildx inspect --bootstrap, memoized per builder) and the answer is hashed. Without it an arm64 laptop and an amd64 runner compute the same key for different images.
  • builder is hashed — which builder runs decides the platforms, the BuildKit version and the layer cache. That is why BUILDX_BUILDER is stripped from the child environment: an ambient builder would change the image behind the key's back. What is hashed is the name, the same gap the docker version itself has.
  • oci_pull always resolves to a concrete os/arch (or all), never "whatever the host is".

Known, deliberate exemptions are documented in the module header: unpinned FROM base images, whatever RUN fetches, secret/ssh values, the host docker/skopeo version, and .dockerignore.

Design

  • oci_image / oci_pull are cacheable artifact drivers; oci_push / oci_load are CacheConfig::off() actions (external side effects, run every time).
  • Push/load consume only the archive output group ("") of their image target — an explicit group selector is rejected rather than honoured.
  • Every subprocess goes through hproc::proc_exec: cleared environment with an explicit passthrough allowlist, explicit cwd, output streamed to the user as it arrives, and the child SIGKILLed on cancellation (a detached buildx would otherwise keep writing into a sandbox the cleaner is deleting, and park runtime shutdown).
  • Builder is host docker buildx / skopeo (host capability, like http_fetch's network). A hermetic toolchain can replace it later without changing targets.
  • Modeled on the plugin-http ManagedDriver template; sandbox/staging/output-collection via ManagedDriverBridge.

Tests

86 unit tests (plugin-oci) — argv assembly, parse → def, hash semantics, and run() against fake binaries. Notably: cache refs do not affect the input hash while build args / secrets / ssh / builder / platform do; the child environment really is cleared.

5 engine e2e against a fake docker (crates/e2e/tests/oci.rs) — outputs are real artifacts a downstream target consumes, an unchanged context is a cache hit, a cross-package dep reaches the build as SRC_*.

10 e2e against the real docker and skopeo (crates/e2e/tests/oci_docker.rs) — every assertion is something only a real builder can answer: the archive is the format the driver claims, the digest is a real sha256, COPY ${SRC_BIN} resolves, stage narrows the build to one stage's DAG (proven by a sibling stage that cannot build), a multi-platform build indexes both architectures, builder works, and a full multi-arch push → pull → FROM base round trip against a throwaway registry:2. They skip rather than fail where the host cannot run them (macOS CI has no daemon); skopeo is on the dev shell, which is how CI gets it.

Three bugs the real-docker suite caught that the fake could not:

  • bases never worked. A dep's unpack list names files, not the directories holding them, so the build context resolved to …/base.oci/oci-layout — a file. Every FROM <base> build failed.
  • On a stock Docker Engine, no oci_image target can build at all. The plain docker driver has no file exporters, so --output type=oci,dest=… and type=docker,dest=… both fail regardless of format. BuildKit names the exporter but not the remedy; the driver now supplies it (create a docker-container builder and name it with builder =, or enable the containerd image store). Not checked at parse: buildx inspect cannot see the image store, so only the build itself avoids false negatives.
  • oci_load failed outright on macOS for a multi-arch archive — skopeo matched the host's darwin/arm64 against a manifest list that has no such instance.

Toolchain requirements (runtime)

Host docker buildx (build) and skopeo (push/pull, and OCI load). The buildx builder must be able to write an image archive to a file: a docker-container builder, or a daemon with the containerd image store. Multi-platform builds need the same. A hermetic toolchain is a possible follow-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo

@raphaelvigee raphaelvigee changed the title feat(plugin-oci): add oci_image driver to build container image archives feat(plugin-oci): OCI/container image integration (build, push, load, pull) Jul 22, 2026
@raphaelvigee
raphaelvigee force-pushed the worktree-bridge-cse_01Eu3bwzWeKmzmR38LBBjtJk branch 9 times, most recently from ae98f7f to 3812062 Compare August 3, 2026 13:34
@raphaelvigee
raphaelvigee force-pushed the worktree-bridge-cse_01Eu3bwzWeKmzmR38LBBjtJk branch 2 times, most recently from 262c35a to f22fdfa Compare August 4, 2026 22:28
raphaelvigee and others added 18 commits August 5, 2026 10:34
Introduces the `plugin-oci` crate and its first driver, `oci_image`, which
builds a container image archive (OCI or docker format) from a Dockerfile +
build context and exposes it as a cacheable target output, plus a `digest`
output group for cheap downstream consumption.

The builder is host `docker buildx` (a host capability, like http_fetch's
network). Caching is layered: heph's input-hash cache is the primary win — an
unchanged context is a cache hit and the image is not rebuilt; `cache_from` /
`cache_to` wire BuildKit's registry/inline layer cache when a build does run,
and are deliberately excluded from the input hash since they are build
optimizations, not part of the image's identity.

First milestone of an OCI integration; `oci_push` / `oci_load` action drivers
and `oci_pull` base-image caching follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two non-cached action drivers that ship an oci_image archive off the build:

- oci_push: uploads the image to a registry with `skopeo copy`
  (`<transport>:<tar>` -> `docker://<ref>`), which reads both OCI and docker
  archives daemonlessly and skips blobs the registry already has.
- oci_load: loads the image into the local docker daemon — `docker load -i`
  for a docker-format archive, `skopeo copy oci-archive:<tar>
  docker-daemon:<tag>` for an OCI archive (which needs an explicit tag).

Both consume only the image archive output group ("") of their `image` target
via the `|`-group selector, never the digest group. Both are actions with
external side effects, so they set CacheConfig::off() and run every time.

Shared helpers land in pluginoci/mod.rs: run_cmd / run_cmd_cancellable (spawn
off-runtime, race cancellation) and dep_single_file (read a dep input's .list
to get the one materialized archive path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
oci_pull is the image-world analogue of http_fetch: it pulls an image from a
registry into a cacheable archive output (no target inputs — bytes come from
the network), so a base image shared across many oci_image builds is pulled
once and served from the local/remote cache thereafter.

Uses `skopeo copy docker://<ref> <transport>:<tar>`. Like an http_fetch
without sha256, pulling a mutable tag (no @digest) warns: heph keys the cache
on the ref string, so a moved tag would serve the stale archive — pin by
@sha256:digest for a reproducible pull.

The BuildKit layer-cache wiring (cache_from / cache_to) shipped with oci_image
in the first commit; this completes the caching story: input-hash cache
(whole image) -> registry/inline layer cache -> oci_pull base cache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rounds out oci_image build inputs:

- secrets: raw `--secret` specs (id=...,src=... / id=...,env=...), consumed via
  `RUN --mount=type=secret`. Hashed.
- ssh: raw `--ssh` specs (default / id=...,src=...), consumed via
  `RUN --mount=type=ssh`. Hashed.

Multi-arch was already wired via the `platforms` field (`--platform` CSV); the
doc now notes it needs a container-driver buildx builder, since the default
daemon builder is single-platform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a `tool` knob (skopeo | docker) to oci_push / oci_pull / oci_load so a
docker-format image can move without skopeo installed. It defaults per format —
docker-format archives use the `docker` CLI, oci-format archives use skopeo —
so skopeo is only required for OCI archives.

- oci_push tool=docker: `docker load -i` + `docker tag <loaded> <ref>` +
  `docker push <ref>` (parses the loaded ref from `docker load` stdout).
- oci_pull tool=docker: `docker pull <ref>` + `docker save <ref> -o <tar>`.
- oci_load tool=docker: `docker load -i <tar>` (already the docker-format path,
  now selectable); skopeo path handles either archive via `<transport>:` and
  needs a tag.

`tool="docker"` with an `oci` archive is rejected at parse (the docker CLI is
docker-format only), and a skopeo load still requires `tag`. The shared run_cmd
helpers now return captured stdout so the docker push path can read the loaded
image ref.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`tst` links a test binary per workspace crate; with the nix store plus the
heavy dependency graph this intermittently exhausts the ~14 GB ubuntu-latest
root disk during linking ("No space left on device"), failing Test linux/amd64
while every other job (Builds, Lint, darwin Test) passes. Drop preinstalled
toolchains the job never uses (android SDK, dotnet, ghc, CodeQL) to reclaim
~15 GB before the build. Linux-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…exec

Review of the four OCI drivers turned up three ways a build could be silently
wrong and one way it could hang. Fixed together because they share the same
root: the drivers assembled commands but never owned what those commands
actually read or produced.

Cache-key soundness:

- The built platform is now in the key. `platforms = []` builds for the
  builder's default platform, which differs per host, while `OciImageDef::hash`
  saw only the empty vec. With the remote cache on by default, an arm64 laptop
  and an amd64 runner computed one key for two different images and traded
  them. `parse` now probes `docker buildx inspect --bootstrap` (memoized per
  process) and hashes the answer. Same defect in `oci_pull`, where both skopeo
  and docker resolve a manifest list against the host: `platform` is now an
  explicit, hashed `os/arch`, defaulting to Linux on the host arch.
- `secrets` / `ssh` are sorted before hashing. Reordering them produces the
  same image and must not force a rebuild. `platforms` stays order-sensitive —
  it orders the manifest list — and now says so at the hash site.

Correctness:

- The build context is the sandbox workspace root, not the package dir. Deps
  materialize at their workspace-relative paths, so a `context` dep from
  another package landed outside the context that was passed to buildx: the
  Dockerfile could not see it, while it still moved the cache key. `COPY` paths
  are now workspace-relative.
- `skopeo copy` passes `--multi-arch all`. The default (`system`) pushed one
  architecture out of a multi-platform archive on Linux, exit 0, and failed
  outright on macOS where no instance matches `darwin`.
- `oci_load` applies an explicit `tag` on the docker path. `docker load` has no
  `--tag`, so the attribute was accepted, hashed, and dropped, leaving a
  dangling `<none>:<none>` image.
- `oci_push` drops the temporary local tag after pushing, so it no longer does
  `oci_load`'s job as a side effect.

Subprocess handling — replaces the hand-rolled `std::process::Command` +
`spawn_blocking` with `hproc::proc_exec`, the primitive the rest of the tree
uses:

- Cancellation kills the child. Ctrl-C previously left `docker buildx` running,
  streaming into a sandbox the cleaner was deleting, and parked runtime
  shutdown until it finished. Cancellation now also returns `CancelledError`,
  so the engine records an abort rather than a failure.
- The environment is cleared and explicitly repopulated. `BUILDX_BUILDER`,
  `DOCKER_DEFAULT_PLATFORM` and `SOURCE_DATE_EPOCH` all change the image and
  none are in the key.
- The working directory is explicit.
- Output streams to the user as it arrives instead of being buffered whole,
  discarded on success, and pasted entire into the error on failure. Errors
  quote a bounded tail.

New surface:

- `bases` maps a name to an `oci_pull(layout = True)` target and wires it to
  `--build-context <name>=oci-layout://…`, so a Dockerfile can `FROM <name>` a
  base heph produced and hashed. Until now nothing could consume an `oci_pull`
  output, so its stated purpose was unreachable.
- Each `context` group is exported as a `SRC_<GROUP>` build arg holding
  context-relative paths, matching exec's `SRC_*` convention. The group names
  were previously inert.
- `oci_pull(layout = True)` writes an OCI layout directory, which is what
  `oci-layout://` reads.

Renames and validation (pre-release, no users yet): `target` -> `stage`, since
`target()` already means something else in a BUILD file; per-target output
names (`<name>.tar` / `<name>.digest`) so two image targets in one package do
not declare the same path; reject an absolute `dockerfile`, a `src=` secret or
ssh source, `=` in a build-arg key, `insecure` with the docker tool, multi-
platform with `format = "docker"`, an `out` with a directory component, and an
explicit output group on a push/load `image` ref.

Tests: `run()` had no coverage on any driver and the injection hooks meant to
provide it were private and asserted only that a setter set a field. The
constructors are now public, a shared fake-binary harness drives `run()` end to
end, and the four tautological tests are gone. 36 -> 71 tests, covering the
digest round-trip, stderr on failure, missing metadata, missing binary,
cancellation killing the child, env clearing, the docker push sequence, and the
layout transport.

Docs: the module header listed what feeds the hash in a way that read as
exhaustive. It now names what does not — `FROM` base images, `RUN` network
fetches, secret values, host tool versions, `.dockerignore` — since an
undocumented exclusion is a trap even when the exclusion is right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
The unit tests drive `run()` directly; these go through the real engine, so
they cover what a driver does not own on its own:

- the archive and `digest` groups are collected as real artifacts a downstream
  bash target can depend on and read;
- an unchanged context is a cache hit and does not shell out to the builder a
  second time — the driver's whole efficiency claim, and only the builder can
  prove it;
- a `context` dep from another package is actually inside the build context and
  reachable through its `SRC_*` build arg (the bug fixed in the previous
  commit: it used to be hashed but invisible to the build);
- a failing build fails the target with the builder's own message, and never
  produces an artifact.

No daemon required: the driver is registered pointed at a shell script that
records its argv and writes what buildx would have written. The fake's stand-in
"archive" is a listing of the build context, so a test can assert what the
build could actually see rather than only what the command line said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
Multi-stage already worked (`stage` -> buildx `--target`); this covers the
multi-arch half of the family, which had two gaps.

`oci_pull` could only ever select one instance out of a manifest list, so it
could not produce a base image for a multi-platform build: the layout handed to
buildx via `bases` has no manifest for the architectures it was not pulled for,
and the build fails on whichever one that is. Add `all_platforms = True`
(skopeo `--multi-arch all`), which keeps the index intact. It is rejected with
`tool = "docker"` (`docker save` writes one image) and alongside an explicit
`platform` (a contradiction), and it is part of the cache key -- a full index
and a single instance are different bytes under the same ref.

`oci_load` left the instance choice to skopeo's own default, which matches the
host's GOOS/GOARCH. For a multi-arch archive that is a `darwin` no Linux
manifest list contains, so the load failed outright on macOS. It now pins
`platform` (default linux/<host arch>) onto the copy, hashed, and rejects it for
`docker load`, which has no instance selection.

Also: the multi-platform build-failure hint now names the `bases` that must be
pulled with `all_platforms`, and `default_platform`/`split_platform` move to the
shared module.

Tests: `--multi-arch all` drops the overrides; all_platforms hashes apart from a
single instance and is rejected for docker/with platform; load pins the
instance, defaults to linux, hashes per platform, rejects docker; e2e proves a
multi-stage multi-arch build reaches the builder with both `--target` and the
full `--platform` list, and that two stages are two cache entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
Rebase onto master, where `proc_exec` replaced the two per-stream
`ChunkReader`s with one `OutputReader` carrying `(StreamId, Vec<u8>)` (which is
what removes head-of-line blocking between the streams on macOS), and made
`wait_or_cancel` crate-private behind `Handle::spawn_wait` so the wait cannot
share a task with the reader.

`run_tool` now tees the single merged reader, routing by `StreamId`, and waits
through `spawn_wait(ctoken.clone_arc())`.

Also silence the newly-enforced `panic_in_result_fn` in the oci e2e test, the
same way every other test file in the workspace does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
The fake-binary suite proves the driver builds the argv it means to; it cannot
prove BuildKit accepts that argv, that the archive is the format the driver
claims, or that a `COPY` written against heph's context layout resolves. This
adds a suite that runs the real thing.

It found a real bug: `bases` never worked. A dep's unpack list names files, not
the directories holding them, so `resolve_named_contexts` found no directory
entry and fell back to the first staged path — handing buildx
`oci-layout://…/base.oci/oci-layout`, a file. Every `FROM <base>` build failed.
The layout root is now recovered from its `oci-layout` marker, and a base that
is not a layout at all says so and names the fix.

Coverage, all skipping rather than failing when the host cannot run it:

- oci_image: real OCI archive + a real sha256 digest; `format = "docker"`
  produces a `manifest.json` archive (modern BuildKit ships an `index.json` in
  it too, so the docker manifest is the only discriminator); a cross-package dep
  reached through `SRC_<GROUP>`; `stage` narrowing the build to one stage's DAG,
  proven by a sibling stage that cannot build; a multi-platform build whose
  manifest list carries both architectures.
- skopeo: an `oci` archive loaded into the daemon; and a full multi-arch
  round-trip against a throwaway `registry:2` — push with the index intact, pull
  it back as an all-platforms layout, then build `FROM base` for both platforms
  off that layout. That is every multi-arch claim the drivers make, end to end.

skopeo joins the dev shell (`devenv.nix`), which is how CI gets it.

Probes are bounded: `docker info` walks every configured builder, so one stale
context hangs it forever, and skopeo's `docker-daemon:` transport goes to
`$DOCKER_HOST` / `/var/run/docker.sock` rather than the docker context — on a
host running another runtime that socket is dead and the copy never returns. A
deadline turns both into skips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
`oci_image` gains `builder`, wired to `docker buildx --builder`.

The gap it closes: which builder runs decides the platforms available, the
BuildKit version, and the layer cache behind the image — and until now that was
pure ambient host state. `BUILDX_BUILDER` is stripped from the child environment
precisely because an ambient builder would change the image behind the cache
key's back, but with nothing to replace it a multi-platform target was stuck
with whatever builder the host's `docker buildx use` had selected. On a default
Docker install that is the daemon driver, which cannot emit a manifest list at
all, so `platforms = [...]` simply failed with no in-BUILD-file remedy.

`builder` is a hashed input, which is what makes it legitimate where the
environment variable was not. What is hashed is the name: two machines whose
`multi` builders differ still agree on the key, the same way they already do for
the `docker` version itself. Naming the builder narrows that gap rather than
closing it, and it is now stated in the file where the reader can see it.

The default-platform probe follows the selection — asking the default builder
would put another builder's platform in this target's key — and is memoized per
builder rather than once per process, since bootstrapping a container builder is
seconds and a workspace has many targets.

The multi-platform failure hint now tells the user how to fix it: create a
`docker-container` builder and name it, or, if one is already named, check that
it is the driver it claims to be.

Def format version 2 -> 3.

Tests: `--builder` in the argv; the hash differs per builder; the probe names
the selected builder; and an e2e that creates a real `docker-container` builder,
points a target at it, and checks both architectures came out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
CI on stock Docker Engine failed every `oci_image` build with

    ERROR: failed to build: OCI exporter is not supported for the docker driver.
    ERROR: failed to build: Docker exporter is not supported for the docker driver.

The plain `docker` driver — what a stock daemon selects by default — has no file
exporters at all. It can load or push into the daemon and nothing else, so
`--output type=oci,dest=…` and `type=docker,dest=…` both fail, and with them
every `oci_image` target regardless of `format`. It needs a `docker-container`
builder or the daemon's containerd image store. Local development missed this
because OrbStack's builder has the exporters.

BuildKit names the exporter but not the remedy, so the driver adds it: create a
container builder and select it with `builder = "…"`, or turn on the containerd
image store. When `builder` is already set, the message says to check that one
instead. BuildKit's own text stays in the chain.

Not diagnosed at parse time: `buildx inspect` reports the driver but not the
image store, so a containerd-backed daemon would be rejected wrongly. The check
that cannot produce a false negative is the build itself.

The e2e suite now does what a user on such a host has to do — every
archive-producing test gets a `docker-container` builder when the default one
cannot export, and uses the default otherwise. That also retires the
"multi-platform capable" gate: those tests now run everywhere instead of
skipping on exactly the hosts they were written for. Builder names are unique
per instance, not per process: tests run in parallel and each removes its
builder on drop, so a shared name tore the builder out from under a running
build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
The `oci_*` drivers were compiled into the CLI; the go and gha plugins are not —
they ship as per-os/arch cdylibs published as release assets with a
`heph-<name>-plugin.json` manifest a workspace selects with

    plugins:
      - url: https://…/heph-oci-plugin.json

oci now follows that pattern. New `crates/plugin-oci-cdylib` exports the stable
ABI trio (`create` / `set_log_sink` / `set_supervisor`) and hands back the four
drivers as a driver-only bundle — no provider, no hooks: images are declared
through whatever provider the workspace already uses.

The supervisor entry matters more here than elsewhere. This cdylib links its own
`proc`, whose tracker the host's startup `init` never reached, so without it
every `docker buildx` and `skopeo` child goes unregistered with the sidecar. A
detached buildx is exactly the child that must not outlive the run: it keeps
writing into a sandbox the cleaner is deleting.

The CLI no longer depends on `plugin-oci` at all — the bootstrap registrations
and the `heph::pluginoci` re-export are gone, so the binary does not carry code
it never registers. The e2e suites depend on the crate directly, the way
plugingo-e2e does for the go plugin.

CI builds the cdylib in the same cargo invocation as the other two (shared dep
graph, overlapping LTO tails), runs macos-portable over it, uploads it per
os/arch, and generates `heph-oci-plugin.json` alongside the go and gha manifests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
CI linux/amd64 failed in `build_args_affect_hash`:

    docker buildx inspect: spawn `…/docker`: Text file busy (os error 26)

The fake-binary writers used `fs::write` + `set_permissions`, which is exactly
the sequence `hcore::fsutil::write_executable` exists to replace. `File` sets
`O_CLOEXEC`, but that only closes the descriptor at `execve`, so a sibling test
forking between our create and our exec leaves its child holding an inherited
writable fd for the whole fork→execve window — and the exec of that file fails
with ETXTBSY. Tests in one binary run in parallel, so the racing sibling is
another oci test spawning its own fake.

`write_executable` drains those descriptors (flock on the writable fd, close,
reopen read-only and acquire shared) before returning. Linux-only symptom, which
is why the arm64 and darwin legs stayed green and local runs never showed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
`heph run //…:docker_image` aborted the whole process:

    panicked at tokio/src/process/unix/mod.rs: there is no reactor running,
    must be called from the context of a Tokio 1.x runtime
    …
    thread caused non-unwinding panic. aborting.

A loaded cdylib's statically-linked tokio is a separate instance from the
host's, and the future the plugin returns is polled by a *host* worker thread,
whose tokio thread-locals belong to the host's copy — the plugin's copy sees no
runtime there at all. Any reactor touch panics, and a panic across the ABI seam
is a non-unwinding abort, not an error.

`run` has hopped onto `cdylib_runtime()` for exactly this reason since it was
written. `parse` did not, because until now no plugin shelled out from it:
`oci_image` asks buildx for its default platform at parse time, since that
platform is part of the cache key. `apply_transitive` gets the same treatment —
it is the same shape one call later.

Cost is a task hop per parse. Parse already crosses the ABI seam with a
protobuf-encoded TargetSpec in and a TargetDef out, so a spawn plus a oneshot is
noise against what it already pays.

Covered by a bin_e2e smoke test, which is the only place this is observable: it
needs a real dlopen'd cdylib in the real binary, and every in-process test runs
on the host's runtime where the reactor is right there. Verified by reverting
this fix — the test reproduces the abort — and restoring it.

The oci cdylib joins the staged dist (devenv `e2e`) so that suite can load it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
`dockerfile` was a path only, which made a generated Dockerfile awkward in the
one way that matters: the user had to list the producing target in `context`
*and* separately spell the workspace-relative path its output lands at. Two
statements of the same fact, and the second one is a guess about staging that
the driver could change under them.

It now also takes an address — `:gen` or `//base:Dockerfile`, the same two
prefixes `context` / `bases` / `image` already accept, neither of which any
sensible path starts with. That target becomes a hashed, runtime dep of its
own, and `run` reads the file from where the dep actually staged it:

    oci_image(name = "img", dockerfile = ":gen", context = [":srcs"])

The address itself is not hashed — the dep's own content hash is what the key
follows, so renaming the target that produces identical bytes does not split
the cache entry.

Paths keep working unchanged, including the absolute-path rejection, whose
message now names the address form as the other way out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
raphaelvigee and others added 10 commits August 5, 2026 10:34
One INFO line per built image, on a run that may build dozens, saying only what
the target already exposes: the digest is the `digest` output group, and the
addr is what the progress display is already showing. Nothing is lost and the
default-verbosity output gets quieter.

The action drivers keep theirs — `oci_push` / `oci_load` / `oci_pull` mutate a
registry or the daemon, run rarely, and leave no output group behind to inspect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
`docker buildx build` can run for minutes with nothing to show for it. Its
output went to the `stdout`/`stderr` sinks on the run request and nowhere else,
and outside an interactive run those are `None` — so the whole progress log,
including the one BuildKit prints when a `COPY` cannot resolve, was discarded.

The tool's output now also goes to `<sandbox>/log.txt`, which is the file the
engine collects as the target's output artifact and renders a tail of in the
failure box — the same place `pluginexec` puts a `bash` target's output, so an
`oci_image` target now behaves like every other target rather than being
silently mute. Both streams land there in arrival order, appended rather than
truncated so a multi-command run (`oci_push` does load, tag, push, rmi) keeps
one log.

Second half, in the SDK: a cdylib driver had no way to stream at all. The run
stream's `stdout_chunk` / `stderr_chunk` frames were reserved but never sent —
`run_once` handed the driver `stdout: None, stderr: None` — and the host's
`drain_run` discarded them in turn. The guest now writes them as frames on the
same channel that carries the terminal result (so the log arrives before the
result that ends the stream), and the host relays them to the target's own
stdio as they arrive. Backpressure is real: the sinks reserve channel capacity
rather than dropping, so a host that stops reading slows the child.

Verified against the real binary loading the real cdylib: a failing build now
prints BuildKit's whole progress log, ending at the `COPY` that could not
resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
Master's `pluginbuildfile::Provider::new` now takes a `tokio::runtime::Handle`
alongside the root; the oci suites still called the one-argument form. Same
`init.runtime.clone()` every other fixture in the crate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
Every assertion that the build context is the sandbox workspace root was made
on an OCI-format build: the argv test, the `run` test, and both cross-package
`SRC_*` tests all use the default format. The docker-format tests only checked
the exporter (`type=docker`) and the archive shape (`manifest.json`).

`format` selects the exporter and nothing else — `context_dir` is
`sandbox_ws_dir` unconditionally, and it also anchors the Dockerfile join and
the `SRC_*` relativization — so the two cannot diverge today. Nothing pinned
that, though, and it is the kind of shared branch someone splits later.

The docker-format argv test now asserts the context arg, and the real-docker
one `COPY`s a cross-package dep through `SRC_BIN`: a package-rooted context
puts that path outside the context and BuildKit fails the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
…rate

Every CI job died in seconds:

    error: cannot update the lock file … because --locked was passed

`gen/proto/Cargo.toml` is generated and untracked. Mine predated master's
dependency sweep and still pinned `pbjson = "0.7"`, so when the merge left the
lock inconsistent and cargo repaired it here, it resolved against that stale
crate: pbjson came back down to 0.7 and dragged `base64 0.21` with it —
re-adding the duplicate stack #339 had just removed. CI regenerates `gen/`
itself, gets pbjson 0.9, and the committed lock then matches nothing.

Regenerated the proto crate, reset the lock to master's, and let cargo add only
what this branch actually introduces. The delta is now exactly the two new
workspace members and nothing else.

Verified the way CI does: `cargo check --locked --workspace --all-targets`
inside the dev shell, plus the test suites and lint under `--locked`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
Master changed `#[derive(Spec)]`'s generated `from` to take
`&HashMap<String, Value>`; the four `oci_*` parsers still handed it an owned
clone. Passing the borrow drops the clone with it, which is what the other
plugins already do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
…ime probe

`oci_image` with no explicit `platforms` builds whatever buildx defaults to, and
nothing else in the key varies by platform — the remote key is
`package/name/hashin`, no arch segment — so that default has to reach the key or
two machines file different image bytes under one entry. It did, via a
`docker buildx inspect --bootstrap` in `parse`, memoized per process.

Paying for that in `parse` was wrong three ways. It ran on fully-cached runs,
where nothing rebuilds and `--bootstrap` can still start a container. It made
`heph inspect def` shell out. And a driver that shells out from `parse` is what
aborted the process once the plugin became a cdylib, because `parse` is polled
on a host worker with no reactor for the plugin's tokio.

The answer is a target: `//@heph/oci:platform`, declared by a small provider this
plugin now carries, running the probe and writing the platform it reports. An
image without explicit `platforms` depends on it, so the platform enters the key
as an ordinary input hash. It shows up in `heph inspect deps`, the engine
single-flights it across every image target, and its subprocess gets the same
cancellation and logging every other target gets. `parse` is pure again.

It is cached **locally but never remotely**. Local caching is the point — an
uncached dep must execute every invocation to produce the hashout its consumer
needs, so only a cached one makes a warm run free. Remote caching would be the
original bug wearing a hat: the platform is host state, and publishing it would
let one machine's answer serve another's.

Invalidation: the env vars selecting daemon and builder are hashed into the def,
and a named `builder` rides the target's address, so both re-probe. What stays
sticky is `docker buildx use <other>`, which moves docker's own state with no env
change — a stale default, not a wrong artifact, since the recorded platform is
passed explicitly as `--platform` so the image and its key agree. Documented on
the attribute; `platforms` opts out entirely.

Gone with it: `resolve_builder_platform`, the per-process memo, the
`builder_platform` def field and its hash entry.

Tests: the dep is declared (and named per builder) when `platforms` is empty and
absent when it is not; the probe def is local-cached and not remote-cached; the
selecting env and the builder name key apart; and an engine e2e that the probe
runs once across two runs — the property the whole design exists for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
A dep with no `unpack_root` annotation materializes at the sandbox workspace
dir, and that dir is exactly what `run` hands buildx as the build context. Two
deps had no business being there.

The builder-platform probe: `@heph/oci/platform.txt` sat in the context, so a
`COPY . /app` shipped heph's plumbing inside the user's image. Worse than the
stray file, silently: the probe dep exists only when `platforms` is unset, so
adding `platforms = [...]` changed the built image's contents with no error and
nothing to point at.

`bases`: an OCI layout is a directory of layer blobs, hundreds of MB for a real
base. It sat in the context too, so buildx transferred the whole base to the
builder as context bytes *on top of* reading it through `--build-context`, every
build — and a wildcard `COPY` pulled the base image into the image built from it.

Both now land in `<sandbox>/exec_oci_<origin>`. `context` groups are untouched:
being in the context is their entire purpose. Neither consumer notices —
`dep_single_file` and `resolve_named_contexts` read absolute paths out of the
dep's list file.

Tests: the cross-package e2e asserts the context contains the dep and *not* the
probe file — verified it fails without the annotation, reporting
`./@heph/oci/platform.txt` in the listing — and a parse test pins the rule
itself: annotated exactly when the input is not a `context` dep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
`--output type=…,dest=` pointed straight at the declared output path, which
lives in the workspace dir, which is what buildx is handed as the build context.
So the image archive was created and grown *inside* the context that produces
it: a wildcard `COPY` pulled the in-progress image into the image, and BuildKit
walked a tree the build was still writing to.

The build now writes to the sandbox dir and the archive is renamed into its
declared path once the digest has been read — same filesystem, so a rename, not
a copy of the image. This is what the metadata file has always done, twenty
lines up, and for the same reason.

Chosen over staging a generated `.dockerignore`, which would have masked the
problem rather than removed it: the file would still be created inside the
context mid-build, it collides with a `.dockerignore` a `context` dep may
legitimately produce (and `!` negations make merging unsafe in both directions),
and BuildKit's two candidate locations — `<context>/.dockerignore` and
`<dockerfile>.dockerignore` — make a silently-ineffective ignore file easy to
produce. An ignore file is the right tool for letting users exclude their *own*
context files, which is a different feature.

Test: the cross-package e2e now asserts no `.tar` in the context listing —
verified it fails without this, reporting `./app/img.tar`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
@raphaelvigee
raphaelvigee force-pushed the worktree-bridge-cse_01Eu3bwzWeKmzmR38LBBjtJk branch from 3768c4b to 30721f9 Compare August 5, 2026 08:37
raphaelvigee and others added 5 commits August 5, 2026 18:19
`linux/x86_64` and `linux/amd64` are the same machine; `linux/arm64/v8` and
`linux/arm64` are the same CPU. Every spelling was carried verbatim into
`--platform` and into the cache key, so one image had as many cache entries as
the user had ways of spelling its platform — a silent miss, not an error.

Normalized once at parse, before argv and before the hash, in `oci_image`'s
`platforms` and in `oci_pull`/`oci_load`'s `platform`. A deliberate subset of
containerd's rules: the aliases people actually type, plus dropping arm64's
redundant default variant. An unknown but well-formed platform passes through
lowercased rather than being rejected — BuildKit accepts platforms heph has
never heard of, and guessing wrong would be worse than carrying the user's word.

This also has to land before deps can stage per platform: the platform string
becomes a directory name that must byte-match what BuildKit expands
`TARGETPLATFORM` to, and `linux/x86_64` never would.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
A multi-platform image whose binary differs per architecture had no way to say
so, and no workaround. Both go variants of one binary produce the same
workspace-relative path — `target_bin.rs` derives the filename from the import
path, not the variant — so listing both in `context` is an `output collision`,
and BuildKit's own `COPY bin-${TARGETARCH}` idiom needs distinct names heph
cannot give them. The only escape was a per-platform `sh` target that renames
the binary: two extra targets and a full copy, per image.

`context_by_platform` takes `{platform: {group: [addr]}}` — `context`'s inner
shape, keyed by platform, so the outer keys are the strings in `platforms` three
lines above and coverage is eyeballable. Each platform's deps unpack under their
own root (which is what stops the collision) and are linked into the build
context at `.heph/ctx/<platform>/…`. Links, not copies: the bytes are already
staged. The Dockerfile selects with BuildKit's own variable, and never learns
heph's layout:

    ARG TARGETPLATFORM
    ARG CTX_BY_PLATFORM
    ARG SRC_BIN
    COPY ${CTX_BY_PLATFORM}/${TARGETPLATFORM}/${SRC_BIN} /usr/bin/server

`SRC_<GROUP>` is relative to the platform prefix, so a group must produce the
same paths on every platform — one target built two ways, the usual case — and
disagreement is an error rather than a silently platform-dependent build arg.

Every mismatch is a hard parse error naming the fix: a platform in the map that
is not in `platforms`, a platform in `platforms` with no entry (which would
otherwise fail one leg of the fan-out deep in a BuildKit log), no explicit
`platforms` at all (the probe answers at run time, so heph cannot say which key
to write), and a group name shared with `context` (two writers of one `SRC_` arg).
Silently dropping a platform's deps is the one outcome that must never ship — it
removes a binary from an image with no signal.

The platform rides in the origin id, so a `HEPH_DEBUG_HASH` trace names *the
arm64 binary* rather than "a context dep". The `(platform, group)` key set is
hashed — sorted, since unlike `platforms` these carry no manifest ordering — so
adding or removing a platform busts the key even when every dep is unchanged.

Def format version 3 -> 4, covering this and the platform normalization.

`crates/plugin` and `crates/core` gain the nested `{string: {string: [string]}}`
spec type this needed; strict, with no bare-string shorthand, because the outer
key carries meaning that cannot be defaulted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
…kopeo

`oci_push` and `oci_pull` shelled out to skopeo, which is standard on Linux CI
and absent from a stock Mac — the reason the default `format = "oci"` was
awkward to recommend. They now speak the OCI distribution protocol in-process
via `oci-client` (maintained by the ORAS project).

Two new modules carry it. `archive.rs` reads and writes OCI layouts, as a tar or
a directory, because `oci-client` has no concept of an archive — it speaks to
registries and nothing else. `registry.rs` is the transport: blobs the registry
already has are skipped via `blob_exists`, and a multi-platform layout pushes
every instance plus the manifest list that ties them together.

Credentials are the part skopeo did invisibly. `oci-client`'s `RegistryAuth` is
only Anonymous/Basic/Bearer, so `docker_credential` resolves
`~/.docker/config.json`, podman's `auth.json`, and the `docker-credential-*`
helpers named by `credsStore`/`credHelpers` — without it ECR/GCR/ACR would stop
working. An unconfigured registry falls back to anonymous rather than failing,
since a public pull needs no credentials.

BUILD-file surface, both breaking and both deliberate:

- `tool` is gone from both drivers. There is one implementation now, and it
  handles what the two tools handled between them.
- `format` is gone too, which is the larger cut: `oci-client` speaks OCI
  manifests, so push/pull no longer handle docker-format archives at all.
  `oci_image` keeps `format` (buildx still emits both shapes), so the drivers
  now differ on this — deliberately. For a docker-format round trip, use
  `oci_load` and the docker CLI directly.

Both fail loudly: the `Spec` derive rejects unknown keys, so an old BUILD file
gets a parse error, never a silent reinterpretation.

Def versions: push 1 -> 2, pull 3 -> 4, since `tool` and `format` were in the
key. `oci_push` is uncached so its bump costs nothing, but **`oci_pull`'s
invalidates every cached pull artifact, local and remote** — every base image
refetches from its registry on first use after this lands, and the shared remote
cache carries a dead generation under the old key space. Correct direction (a
full miss, never a wrong hit), but it is a real fleet-wide cost.

Archives are written reproducibly — fixed mtime, blobs in sorted order — because
they are cached artifacts and a wall-clock timestamp would make one image hash
two ways.

The argv tests for both drivers go with the argv. Two of them had started
reaching docker.io the moment the fake-binary indirection disappeared, and one
in `pull.rs` was passing vacuously: it asserted a "docker tool + oci format"
error contained "oci", which it still did — via the words "parse oci_pull
config" wrapping an "unknown entries: tool" failure. The real coverage for this
path is the local-registry round trip in the docker e2e.

`oci_load` still uses skopeo; it converts next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
Three things that only made sense together, because they are the last of
skopeo coming out.

`oci_load` no longer shells out at all. It converts the layout to the
hybrid shape a daemon accepts (`manifest.json` alongside the OCI blobs,
layers left gzipped — the daemon inflates them) and POSTs it to
`/images/load` through bollard, then tags the result. Two things skopeo
was quietly doing had to be written out: resolving the active docker
*context* (`currentContext` → `contexts/meta/<sha256>/meta.json`) rather
than assuming `/var/run/docker.sock`, which is why the load hung for the
full 120s on a colima/orbstack host; and reading the loaded reference out
of the daemon's own narration, because a containerd-backed daemon ignores
`RepoTags` in the archive.

`oci_pull` takes `platforms = [...]` or `all_platforms = True`. It used to
resolve one instance against the *client's* default platform, which on an
arm64 mac matches nothing in a `linux/*` index. Asking for a platform the
registry does not publish now fails naming what it does publish, rather
than yielding a layout that breaks later inside someone else's build.

A layout written to disk carries `org.opencontainers.image.ref.name` on
its index — skopeo's `oci:<dir>:latest` supplied that tag, and without it
buildx cannot resolve `oci-layout://` for a `FROM` on a pulled base.

skopeo is gone from devenv, from the docs, and from the e2e gates. The
whole real-docker suite (10 tests) passes without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
The Dockerfile rule was called `oci_image`, but every attribute it has
names a docker concept — `dockerfile`, `context`, `build_args`, `stage`,
`builder`, `secrets`, `ssh`, `cache_from`, `cache_to` — and it depends on
a probe target whose only job is to ask buildx a question. It is a
`docker_build`, and calling it `oci_image` claimed the standards name for
the one driver that cannot uphold the standard's properties.

The name matters more than usual here because a daemonless, layer-based
image rule is coming, and that rule is the one users and agents should
reach for by default: no daemon, no QEMU for cross-arch, and a cache key
that actually covers what the build reads. It should be `oci_image`, and
that name has to be free.

None of this is released — `crates/plugin-oci` is not on master — so the
rename costs nothing today and would be permanent the day #159 merges.

Also splits `pluginoci/mod.rs`: the Dockerfile driver moves to its own
`docker_build.rs`, and `mod.rs` keeps only what more than one driver
needs (the archive-format enum, platform-string handling, the dep-list
helpers, the test fakes). `mod.rs` was 3353 lines hosting one driver plus
everything shared; adding a second image driver to it was not an option.

Drive-bys, both stale rather than new:
- the cdylib docs still described `skopeo` as a host capability, three
  commits after it stopped being one, and still said "four drivers".
- a test's doc comment had been orphaned onto the following test, so
  `run_names_the_fix_when_the_builder_has_no_file_exporter` carried a
  first line about `containerimage.digest`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
`context`, `context_by_platform` and `bases` map a *name* to a target, and
only the names reached the cache key: `DockerBuildDef` had no `context`
field at all, `bases` hashed `Vec<String>` of group names, and
`platform_context_keys` hashed sorted `(platform, group)` pairs.

Nothing else carried the mapping. `hashin` (engine/meta.rs:54-88) folds a
*sorted, unlabeled multiset* of dep hashouts — no origin_id, no address —
and `inputs_result_meta` folds every output group a dep has regardless of
the `|group` selector on the ref. So all of these were one cache entry
for two different images, with no error and nothing to point at:

    context = {"a": [":x"], "b": [":y"]}   vs the swap
      -> SRC_A and SRC_B name each other's files
    bases   = {"base": ":alpine", "tools": ":ubuntu"}   vs the swap
      -> `FROM base` resolves to the other image
    context_by_platform amd64/arm64 with their targets swapped
      -> the amd64 leg stages the arm64 binary
    context = {"a": [":bin|release"]}   vs [":bin|debug"]
      -> the selector reaches no hash at all

The def now carries the normalized `TargetAddr` rendering (which includes
`|output`) alongside each name, ordered by name and, within a group, in
declaration order. Normalized rather than as-written so `:x` and `//app:x`
in package `app` stay one key rather than trading a wrong-artifact bug for
a spurious-miss one.

`dockerfile = ":target"` still omits its address, and that stays correct:
it is a single-occupant role, so changing which target fills it changes
that dep's hashout and therefore `hashin`. An N-occupant named mapping has
no such property — which is the generalization that produced this bug.

DOCKER_BUILD_FORMAT_VERSION 4 -> 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQZxsTqanJRWLWdPSchBo
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.

1 participant