Skip to content

feat(oci): finalize trusted image tags - #12

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

feat(oci): finalize trusted image tags#12
jmgilman merged 2 commits into
mainfrom
feat/release-cli-slice3c

Conversation

@jmgilman

Copy link
Copy Markdown
Contributor

Implements PR 5 of the release-cli program and performs the two-phase cutover. The publisher workflow no longer publishes, signs, or tags by itself: release-cli publish oci prepare pushes content by digest and signs it, the three actions/attest steps run, and release-cli publish oci finalize applies tags last.

Invariant 14 — trust metadata strictly before public tags — is the reason for the split, and it is now enforced by construction: no code path in the CLI can write a tag during prepare, and finalize runs after the attestations.

What lands

  • internal/stage/puboci/finalize.go — the TagCommitter port (4 of the closed budget of 13), Finalize, release.dev/oci-finalize/v1, ErrNotAuthoritative, ErrStateDrift.
  • internal/adapter/reg/tag.goCommit: resolve image@digest once, then apply each tag strictly serially, verifying each write before the next.
  • internal/clipublish oci finalize --result -, which accepts only stdin and only the exact --json envelope emitted by publish oci prepare.
  • .github/workflows/publish-oci-image.yml — ORAS and the four actions/github-script publication blocks are gone; aqua:oras-project/oras is dropped from the toolchain. Inputs, the six outputs and their meanings, permissions, the repository-wide concurrency group, timeouts, and every SHA pin are unchanged.
  • docs/explanation/two-phase-oci-publication.md plus reference and how-to updates.

Finalize semantics

Refuse a non-authoritative result → collect fresh state → refuse drift → re-plan from what is actually there (never replay the serialized plan) → commit the ordered tags serially → independently verify through StateReader that the exact tag and every applied tag resolve to the index digest.

The drift check has one deliberate exception: a tag now sitting on the candidate digest is this publication's own partially completed work. Review found that exception was too broad, so it is now scoped to tags the prepared plan would have created. A channel the plan chose to retain (it sat on a newer release) that later moves onto our digest is drift, not convergence — otherwise a channel regression performed by someone else would be reported as a successful publication.

Verification

  • mise exec -- moon run root:check green.
  • Live GHCR rehearsal against scratch packages (since deleted), with a recording cosign stub:
    • prepare pushed content and left tags: null; finalize applied 0.0.1, 0.0, 0, latest, and every tag resolved to the index digest on real GHCR;
    • publishing 0.0.2 moved every channel; republishing the older 0.0.1 reported accepted: [0.0.1], retained: [0.0, 0, latest] and left the tag map pointing at 0.0.2 — invariants 11 and 12 proven against the real registry;
    • a fixture whose index annotation disagreed with the published version was refused with channel 0.0 points outside its minor release line, which is the planner catching a genuine inconsistency.
  • Local two-phase smoke over an in-process registry: rerun convergence (all four accepted, nothing applied), drift refusal after retagging latest to a foreign digest, authoritative:false refused, and the stdin usage errors.
  • Audited: no permissions change, no references to the deleted steps.push/steps.tags outputs anywhere in .github or examples/.

Review fixes in this PR

  • Scoped the drift convergence exception (above) — the one blocking correctness finding.
  • Bounded the finalize stdin decode; the documented 4 MiB limit was decorative because the envelope's result was buffered as json.RawMessage first.
  • Commit now counts a tag as applied when the write lands but its verification read fails, so the operator-facing count is right exactly when the outcome is ambiguous.
  • The credential-scrub step can no longer fail the job or leave the credential behind: it traps its temp file and deletes the config outright if scrubbing fails.
  • Added the tests the plan names as evidence: ambiguous write (committer succeeds, registry disagrees), verification retry and exhaustion, three uncovered drift branches, and a full preparefinalize pass over the in-memory registry.

Outstanding before the first real release

The live rehearsal covered digest publication and tagging against real GHCR, but keyless Cosign signing and the three actions/attest steps only run inside Actions. Spike B proved those against real GHCR previously; the first tag after this merge is the end-to-end proof. publish-image: false remains the rollback: it exercises prepare --dry-run, applies no tags, and keeps image-reference empty.

Add fresh-state finalize with drift refusal and serial verified tag
commits, the reg TagCommitter, publish oci finalize --result -, and the
two-phase workflow cutover: prepare, three actions/attest steps, then
finalize. Tags are now the last thing that happens in a publication.
Round-1 review and conformance fixes: accept the now-at-candidate-digest
transition only for tags the prepared plan would have created, bound the
finalize stdin decode, report an accurate applied count when a tag write
lands but its verification fails, make the credential scrub unable to
fail the job or leave credentials behind, and add the ambiguous-write,
verification-retry, drift-branch, and prepare-to-finalize registry
tests.
@jmgilman
jmgilman merged commit 3a649f0 into main Aug 19, 2026
2 checks passed
@jmgilman
jmgilman deleted the feat/release-cli-slice3c branch August 19, 2026 18:42
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