feat(cli): package assemblies into OCI images on --release

Summary

bench build --release now produces the deployable artifact rather than just an optimized binary: it cross-compiles the assembly for Linux and packages it into an OCI image. Plain bench build is unchanged — native, binary-only, and needing no container tool.

Dockerfile.assembly             scaffolded, checked in; the recipe for every assembly
target/<profile>/<assembly>/
└── assembly                    the binary, and the whole build context

The context is one file. The generated cargo workspace lives outside target/, at .bench/workspace/<assembly>/, so the gigabytes cargo leaves behind cannot be uploaded to the builder by accident — a property of the layout rather than of a .dockerignore that could fail open. The Dockerfile sits outside the context and reaches the builder through --file.

bench drives a container builder the same way it drives go, cargo and copier rather than assembling layers itself: docker buildx build, plain docker build when the buildx plugin is absent, else podman build — probed once before any compiling.

Full rationale in docs/design/image-packaging.md.

Image reference

<default_registry>/<assembly>:<tag>, one sub-repository per assembly, --tag overriding the tag component only. An unset default_registry is not an error — nothing sets it today and --release must work straight after bench init — so it falls back to <bench>/<assembly>:<tag> with a warning.

Cross-compilation

--platform defaults to linux/<host-arch>. Go needs only GOOS/GOARCH/CGO_ENABLED=0, which also makes the binary static.

Rust needs a linker cargo does not ship, so every --release build goes through cargo zigbuild, including a Linux host building for its own architecture which could have used plain cargo build --target. That keeps the glibc floor at zig's 2.28 no matter who builds — cargo would link against the host's libc, and a newer build host can produce an image needing symbols the base lacks, which fails at container start rather than at build time. It also means one prerequisite story instead of a policy keyed on host OS and architecture.

mise install provides all three prerequisites: zig and cargo-zigbuild are pinned in .mise.toml, and the Linux target triples are declared in the same canonical rust entry and rendered into rust-toolchain.toml. The build image deliberately omits the targets — no CI job cross-compiles. Outside this repository the preflight names the manual fix, checking all three before compiling so a missing tool costs a second rather than a full build.

Also in this MR

  • bench scaffold passes labbench=true on the repo phase, which is what selects Dockerfile.assembly and keeps the generated cmd/<assembly>/ tree out of git.
  • bench build runs go mod tidy once per invocation, after the last assembly: the generated entrypoint is the only code that imports the chassis, so the module can only be tidied once it exists, and running it last means a tidy failure cannot fail a build.

How to test

mise install                    # zig + cargo-zigbuild + the Linux rust targets

./tests/demo-go.sh --release    # needs docker or podman
./tests/demo-rust.sh --release

Each scaffolds a bench, builds an assembly image, inspects it, runs it as a container and curls the mounted routes, then repeats the build for the other architecture. Without --release both stop at a native debug binary and need neither a container builder nor zig.

Verification

Gates green: cargo build, clippy -D warnings, 175 tests, cargo fmt, shellcheck, shfmt, editorconfig-check, toolchain:check, mise run test:e2e. docs/reference/proto.md regenerated; the diff is comment-only.

Both demos pass end to end on macOS/arm64 against the real UBI base:

Go Rust
cross-compiled Linux ELF ✔️ ✔️ (cargo zigbuild)
image inspected (arch, ExposedPorts=8080/tcp, user=1001) ✔️ ✔️
container run, both routes curled ✔️ ✔️
foreign arch built without qemu/binfmt ✔️ ✔️

Checked separately, because the demos would not have caught any of it:

  • a manual --build-arg PORT=9090 yields ExposedPorts=map[9090/tcp:{}], confirming EXPOSE ${PORT} expands (the demos use 8080, the ARG default).
  • the Rust binary's highest glibc requirement is GLIBC_2.28, below the base's 2.39.
  • two Go assemblies in one bench produce a single go mod tidy run, after both builds.
  • the missing-zig and missing-cargo-zigbuild preflights fire before compiling, and CARGO_ZIGBUILD_ZIG_PATH suppresses the former as documented.

Not covered here

  • A Linux host building a foreign architecture, end to end. Unit tests cover the policy; the linker behaviour was confirmed in an aarch64 Linux container (cargo build --target x86_64-unknown-linux-gnu fails at link), but a full run needs a Linux box with a container daemon.
  • EXPOSE ${PORT} under podman/buildah — confirmed under docker's builder only; podman is not installed here.
  • CI does not exercise the scaffold path. test:cli runs tests/test.sh, but its scaffold block is behind command -v copier and copier is not in the build image, so the Dockerfile.assembly guard runs only where copier is installed. Tracked in #49 (closed), along with running the demos in CI at all.

Reviewer checklist

  • Reviewed the changes
  • Change matches the described intent and stays in scope.
  • Code owners for the touched paths have approved (see CODEOWNERS).
  • CI is green.

Part of #2 (closed) (DM-2e, Track C)

Follow-ups opened from this review: #47 (closed) (anchor labkit in go.mod), #48 (how an existing bench picks up template changes), #49 (closed) (run the end-to-end scripts in CI).

Edited by Lucas Charles

Merge request reports

Loading
Loading