Skip to content

feat(release): publish verified GitHub releases - #14

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

feat(release): publish verified GitHub releases#14
jmgilman merged 2 commits into
mainfrom
feat/release-cli-slice4b

Conversation

@jmgilman

Copy link
Copy Markdown
Contributor

Implements PR 7 of the release-cli program. The last actions/github-script publication programs are gone: the CLI now owns draft discovery, tag binding, upload, convergence, and the undraft, while the workflow keeps minting the App token and running the attestation.

What lands

  • internal/stage/pubgh — the ReleaseReader, AssetReplacer, Publisher, and RefResolver ports (6–9 of the closed budget of 13), the value types, four classified errors, and Publish.
  • internal/adapter/ghrel (go-github), internal/adapter/ghup (gh release upload --clobber), internal/adapter/gitx (git rev-list -n 1).
  • internal/clipublish github --dist PATH [--no-undraft] [--json].
  • .github/workflows/publish-github-release.yml — three script steps deleted, one CLI step added, plus a cheap early tag-to-commit gate. Inputs, secrets, both outputs, permissions, timeouts, and every SHA pin unchanged.

Guarantees

The tag must resolve to the workflow's commit; exactly one release may carry the tag; the release must be a draft to start; existing assets outside the expected set are refused, never deleted; uploads clobber expected names only; assets must converge to the expected count, names, states, and digests within 12 attempts a second apart (24 attempts five seconds apart for draft discovery); the undraft happens last and only when requested. The CLI never creates a release, never re-drafts one, never deletes an asset, and never mints a token — it receives one as a redacted rel.Secret.

Polling is engine-owned: the adapters take single snapshots and the engine owns both budgets through an injected sleep, so the state machine is exhaustively testable and instant.

Verification

  • mise exec -- moon run root:check green.
  • Live rehearsal against this repository, twice, using temporary draft releases that were deleted afterwards (gh release list shows only v0.1.0, no rehearsal tags remain):
    • --no-undraft found the draft, bound the tag to GITHUB_SHA, uploaded four assets, converged their digests, and left the release a draft;
    • a second run converged again; publication with the undraft made it public and returned the URL; a further rerun reported draft:false with all four assets accepted;
    • planting a stray.txt made the next run fail with release contains an unexpected asset: stray.txt, and the stray asset was still there afterwards — refuse, never delete;
    • a wrong GITHUB_SHA was refused before any mutation;
    • after the fix below, a draft-only run against the now-public release failed with release state is indeterminate: draft-only publication requested but release … is already public, and the CLI printed the operator hint on stderr.

Review fixes in this PR

  • Blocking: --no-undraft was not enforced on the already-published branch. A public release with a matching asset set returned success under a draft-only rehearsal, so the workflow would have gone green while the release was public — the exact thing the flag exists to prevent. It is now ErrIndeterminate, refused before any asset read.
  • An ambiguous Publisher.Publish failure — which may have applied draft:false — now carries ErrIndeterminate too, matching what the docs promise.
  • The CLI prints a remediation hint on stderr for indeterminate failures: inspect the release, do not rerun blindly. That also gives the sentinel a real production inspector.
  • The tag-to-commit binding now runs before actions/attest again. Folding it into the CLI moved it after the attestation, so a moved or mis-bound tag would have left a stray, unwithdrawable provenance attestation and transparency-log entry before failing.
  • Extracting the shared retry helper had changed VerifyHandoff's error strings; the original wording is restored with regression tests.
  • Corrected the documented exit code for an unresolvable RELEASE_GH_PATH/RELEASE_GIT_PATH (1 at first use, not 2), and covered ghup's inherited-GH_TOKEN drop with a test.

Recorded follow-up

Conformance considers ErrAmbiguousRelease and ErrUnexpectedAsset sentinels without production inspectors. Kept deliberately: the plan requires classified errors for these recovery categories, and they are what the operator-facing docs name.

Add the draft-release state machine with the ReleaseReader, AssetReplacer,
Publisher, and RefResolver ports, the ghrel, ghup, and gitx adapters, and
publish github. The workflow now mints the App token and hands it to the
CLI as a redacted secret; the CLI never creates a release, re-drafts one,
or deletes an asset.
Round-1 review and conformance fixes: classify an already-public release
under --no-undraft and an ambiguous undraft call as indeterminate, print
an operator remediation hint for that class, restore VerifyHandoff's
error strings after the retry extraction, gate the tag-to-commit binding
before the attestation so a bad run cannot leave a stray attestation,
correct the documented binary-path exit code, and cover the inherited
GH_TOKEN drop.
@jmgilman
jmgilman merged commit df077f9 into main Aug 19, 2026
2 checks passed
@jmgilman
jmgilman deleted the feat/release-cli-slice4b branch August 19, 2026 21:10
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