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 contextThe 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 scaffoldpasseslabbench=trueon the repo phase, which is what selectsDockerfile.assemblyand keeps the generatedcmd/<assembly>/tree out of git.bench buildrunsgo mod tidyonce 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 --releaseEach 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=9090yieldsExposedPorts=map[9090/tcp:{}], confirmingEXPOSE ${PORT}expands (the demos use 8080, theARGdefault). - 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 tidyrun, after both builds. - the missing-
zigand missing-cargo-zigbuildpreflights fire before compiling, andCARGO_ZIGBUILD_ZIG_PATHsuppresses 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-gnufails 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:clirunstests/test.sh, but its scaffold block is behindcommand -v copierand copier is not in the build image, so theDockerfile.assemblyguard 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.
Related issue
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).