Skip to content

feat(image): build OCI layouts from staged binaries - #15

Merged
jmgilman merged 2 commits into
mainfrom
feat/release-cli-image-build
Aug 20, 2026
Merged

feat(image): build OCI layouts from staged binaries#15
jmgilman merged 2 commits into
mainfrom
feat/release-cli-image-build

Conversation

@jmgilman

Copy link
Copy Markdown
Contributor

PR 8 of the eleven-PR release-cli program (.journal/002/PLAN.md). Replaces inventory steps OB-08 through OB-17 of go-oci-build.yml with one tested Go command and fires the plan's deferred persisted-projection trigger.

Scope

  • stage --profile go now writes dist/oci-build-inputs.json (release.dev/oci-build-inputs/v1): profile plus, per Linux platform, the binary name, the artifact-root-relative path, and the canonical SHA-256 digest. goprof.SelectBinaries gained the shared-binary-name check the YAML used to perform, and the staging engine now hashes both canonical binaries. The stage --json envelope is unchanged.
  • New release-cli image build stages both binaries into a scratch workspace (single streaming hash-and-copy pass, mode 0755), verifies each digest against the projection, verifies each staged file is a static 64-bit little-endian ET_EXEC for its architecture with debug/elf, writes vars.json and canonical-binaries.sha256, copies both configs, builds signed APK repositories, requires exactly one nonempty .apk and a nonempty APKINDEX.tar.gz per architecture, copies the APK public key, composes the locked two-platform layout, and requires the lockfile, layout files, and both SBOMs to be nonempty.
  • Ports 11 and 12 of the closed 13-port budget: image.APKBuilder -> internal/adapter/melange, image.Composer -> internal/adapter/apko. Both are thin exec adapters with argv and working directories byte-identical to the YAML they replace, mockery-generated mocks, and bounded stderr tails. Port 13 (actenv) stays unbuilt: the workflow still reads the --json envelope with jq.
  • Workflows: go-pre-publish.yml uploads the projection alongside artifacts.json; go-oci-build.yml replaces three shell steps with one image build --json step (id: build) that resolves the pinned Melange and apko binaries with mise which and derives the build date from the tagged commit. No workflow input, output, permission, concurrency setting, timeout, or action pin changed.
  • The old verifier stays as an independent oracle. The run: script of Verify authoritative OCI image is byte-identical to main (verified by parsing both YAML documents); only its env: now reads steps.build.outputs.*. PR 9 replaces it.

Verification

  • mise exec -- moon run root:check: format, lint, build, test, protocol stamp, and mock freshness all pass.
  • Real end-to-end rehearsal on a laptop with the pinned Melange 0.59.1, apko 1.2.37, and Docker: two real static Go Linux binaries -> stage projection -> image build -> genuine two-platform layout. The unchanged YAML verifier, run inside ubuntu:24.04 with GNU tar 1.35, accepted the output and emitted an image-digest equal to an independent sha256sum of layout/index.json. Layer inspection: mode 0755, uid/gid 0/0, executable bytes identical to both canonical inputs. Melange and apko argv were compared against the removed YAML line by line.
  • Fail-closed exercises: tampered canonical byte -> digest mismatch, exit 1, zero packages written; a dynamically linked binary -> ELF type ET_DYN, want ET_EXEC; amd64 bytes served as arm64 -> ELF machine EM_X86_64, want EM_AARCH64; populated work or output root -> refused; a second JSON value in the projection -> refused; --work nested in --output -> exit 2 before any directory is created; missing or non-RFC-3339 --build-date, missing GITHUB_REF_NAME, missing GITHUB_SHA -> exit 2, one envelope, no directories created; success without --json -> zero stdout bytes.
  • Review: two rounds. Round 1 found four issues, all fixed, including a real fail-open where a --work nested under --output published the ephemeral APK private signing key inside the uploaded artifact. Round 2 re-verified every fix by execution and caught 15 of 15 mutations. Conformance audited all 24 AGENTS.md rules: 22 PASS, and the two FAIL verdicts (a false size-limit contract on the projection decoder, and three documented-versus-actual ordering claims) were fixed rather than documented away.
  • Caller-ceiling audit found a pre-existing latent break: examples/go-release's release-assets job granted less than go-pre-publish.yml declares, which fails a consumer run at startup with no API-visible diagnostic. Fixed here, along with the caller skeleton and permission tables in docs/reference/github-release-contract.md and the builder grant sentence in docs/reference/oci-image-contract.md. All four caller jobs now match their callees exactly in this repo, the example, and the documented skeleton.
  • Also fixed a load-sensitive flake shared by the cosign, ghup, and gitx adapter cancellation tests: fixture startup and the post-cancel bound now use separate budgets. No assertion weakened.

Notes

  • The engine takes *os.Root for the work and output roots; the plan forbids a filesystem port, and os.OpenRoot stays at the CLI composition edge.
  • The bounded stderr-tail exec helper is duplicated in both new adapters because the plan explicitly forbids a shared execx package.
  • Follow-up, unchanged from prior slices: mockery's testify template emits no Godoc for generated expecter types.

Replace OB-08 through OB-17 of go-oci-build.yml with `release-cli image
build`, and have `stage --profile go` persist the artifact-local
oci-build-inputs projection its first downstream reader now consumes.

The old YAML verifier (OB-18 to OB-21) stays as an independent migration
oracle: its script is byte-identical to main, only its step-output
references moved. PR 9 replaces it.

Ports 11 and 12 of the closed budget arrive as thin melange and apko exec
adapters with byte-identical argv. Verified against the real pinned tools
plus Docker: the unchanged verifier accepts the CLI's two-platform layout.
Every exec-adapter test wrote its POSIX shell fake into its own t.TempDir()
and immediately exec'd it. On Linux a parallel sibling's fork/exec inherits
the still-open write descriptor, so the exec fails with ETXTBSY ("text file
busy"). That is the real cause of the cancellation-test flake seen on macOS
under load and of the PR CI failure, reproduced with `go test ./...` in a
linux/amd64 golang:1.26 container.

TestMain now writes each package's fake exactly once before any test can
exec it. Per-test t.TempDir() still holds argv records, start markers,
stderr fixtures, and working directories.

gochecknoglobals joins the existing _test.go exclusion list because the
shared fixture path must be package scoped; the rule still covers
production code.
@jmgilman
jmgilman merged commit e235a28 into main Aug 20, 2026
2 checks passed
@jmgilman
jmgilman deleted the feat/release-cli-image-build branch August 20, 2026 00:49
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