Skip to content

feat(oci): prepare digest publication - #11

Merged
jmgilman merged 2 commits into
mainfrom
feat/release-cli-slice3b
Aug 19, 2026
Merged

feat(oci): prepare digest publication#11
jmgilman merged 2 commits into
mainfrom
feat/release-cli-slice3b

Conversation

@jmgilman

Copy link
Copy Markdown
Contributor

Implements PR 4 of the release-cli program: digest-addressed OCI publication, recursive Cosign signing, the versioned OCIPrepareResult, and publish oci prepare [--dry-run]. Builds on PR 3 (plan tags).

No workflow change. The actions/github-script steps in publish-oci-image.yml (OP-13/OP-14/OP-15) keep publishing, signing, attesting, and tagging until the finalize slice lands, so invariant 14 (trust metadata strictly before public tags) is never temporarily weakened.

What lands

  • internal/stage/pubociDescriptor, DigestRef/Image.Pin, the ContentPusher and Signer ports (3 and 5 of the closed budget of 13), ReadLayout over fs.FS with exact index.json byte retention and deduplicated blob ordering, the release.dev/oci-prepare/v1 result, and Prepare.
  • internal/adapter/regPushBlob, PushManifest, Verify on the existing oras-go v2 client. Content is streamed; layers are never buffered.
  • internal/adapter/cosign — exec adapter running exactly cosign sign --yes --recursive <image>@<digest>.
  • internal/clipublish oci prepare --layout PATH [--image] [--version] --digest D [--dry-run] [--plain-http] [--json], plus RegistryConfig and the main.go wiring.
  • Reference docs for the command, its result schema, and the tag-policy interaction.

Order of operations

Read and validate the layout → require the computed index digest to equal --digest → collect fresh registry state and plan tags, so an immutable-tag conflict refuses before any write → push every unique blob, each platform manifest, then the index, by digest → verify the index and every platform manifest resolve → sign recursively. --dry-run stops after planning, writes nothing, and marks the result "authoritative": false. The command never creates or moves a tag.

Verification

  • mise exec -- moon run root:check green: format, lint, build, test, protocol stamp, mock freshness.
  • End-to-end smoke against a local go-containerregistry registry with a two-platform layout fixture (7 blobs, one layer shared by both platforms) and a recording cosign stub: dry run left the catalog empty and invoked no signer; the authoritative run pushed content, left tags: null, made the index resolve by digest, and invoked exactly sign --yes --recursive …@sha256:4ef3cfc6…; a rerun converged; a wrong --digest failed before any write; a planted conflicting 1.4.0 tag refused with immutable tag conflict before any push or signature.
  • Safety fences exercised on the built binary: --plain-http against ghcr.io exits 2; RELEASE_PLAIN_HTTP no longer exists; RELEASE_DRY_RUN=yes exits 2 instead of publishing; RELEASE_DRY_RUN=true yields authoritative:false.

Defects the smoke and review caught, fixed here

  • Double close. oras hands the content reader to net/http, which always closes a request body, so a caller-owned *os.File was closed twice. The adapter now shields the reader and TestPushBlobLeavesReaderOpen pins the ownership rule. Found by the live smoke, not by the mock tests.
  • 409 treated as success. A refused layer upload could yield a signed, authoritative:true result for an image with a missing layer, because verification only resolves manifests. HTTP 409 is now an error.
  • Silent unsafe default. RELEASE_DRY_RUN=yes parsed as false and performed a real publication plus signature. Unparsable booleans are now exit 2.
  • Unfenced transport downgrade. --plain-http was environment-activatable and host-agnostic while carrying the registry token. It is now flag-only and refused for non-loopback hosts.
  • No retry on transient pushes. A streamed body has no GetBody, so oras-go's retry transport could not replay it. The engine now retries ErrRetryable pushes and verifications four times (1s/2s/4s) with a reopened stream and an injected sleep.
  • Also: nil-content guard, cmd.WaitDelay so a cancelled cosign cannot hang the CLI, a required platform on every index descriptor (no more "/" attestation subjects), and tests for the 4 MiB JSON bound, symlink escape, and pushed media types.

Deliberate deviations from the plan's indicative signatures

The plan's Repository is the already-shipped Image; Verify(ctx, ref DigestRef) drops the redundant expected argument because a digest-pinned reference carries it; the result exposes an ordered observed[] projection because rel.ChannelState is a struct-keyed map and cannot be JSON; attestation subjects carry platform and digest only, leaving SBOM paths to the workflow.

Add the OCI layout reader, the prepare engine with the ContentPusher and
Signer ports, the oras push half of the reg adapter, the cosign exec
adapter, and publish oci prepare with --dry-run. No workflow change: the
github-script publisher stays authoritative until finalize lands.
Round-1 review and conformance fixes: reject a 409 blob push instead of
treating it as success, guard nil content, fail on an unparsable
RELEASE_DRY_RUN rather than publishing, fence --plain-http to loopback
and drop its environment variable, retry transient pushes with a
reopened stream, bound cosign's wait after cancellation, require a
platform on every index descriptor, and cover the JSON size bound and
symlink escapes.
@jmgilman
jmgilman merged commit 257ac5f into main Aug 19, 2026
2 checks passed
@jmgilman
jmgilman deleted the feat/release-cli-slice3b branch August 19, 2026 16:52
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