feat(release): auto-publish the prod release once all targets land#1119
Conversation
Today the GitHub release stays a draft after the build: nothing flips it to published, so the R2 mirror (which triggers on release:published) never fires and an operator has to publish by hand. Automate that final step. - publish-when-complete.ts: at the tail of every prod finalize/full build, reuse verifyReleasePayload (new allowDraft option) to confirm the draft is complete, then `gh release edit --draft=false` pinned to the build commit and dispatch mirror-release-to-r2.yml. Incomplete drafts are a no-op, so the last target to finish is the one that publishes. - verify-release.ts: add allowDraft to verifyReleasePayload; default false keeps the mirror's verifier failing on drafts as before. - build.yml: run the script after "Finalize updater metadata" (gated to channel==prod) and grant actions: write so it can dispatch the mirror. A GITHUB_TOKEN publish does not fire release:published, so the mirror is dispatched explicitly rather than via the webhook. Cannot be end-to-end tested without a real release; covered by unit tests for the pure decision policy and the allowDraft branch.
|
Warning Review limit reached
More reviews will be available in 1 minute and 4 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (8)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces a new script publish-when-complete.ts (along with unit tests) to automatically publish a draft release once all build targets and updater metadata are uploaded, and then trigger an R2 mirror. It also updates verify-release.ts to support an allowDraft option to suppress draft validation errors during completeness checks. A critical issue was identified in fetchTagSha where fetching the tag ref returns the tag object SHA instead of the commit SHA for annotated tags, which would cause the commit verification check to fail. A suggestion was provided to resolve this by querying the /commits/{ref} endpoint instead.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Address Codex review of the auto-publish step: - Provenance ledger: use the draft's target_commitish as a single-source record. electron-builder creates a fresh draft pointing at the branch and never rewrites an existing draft (verified in electron-publish 26.8.1), so the first target pins it to its build commit and any later target built from a different commit fails closed instead of publishing a release whose mac and win installers came from different commits. Replaces the git-tag-ref check, which only existed after publish. - Poll on "wait": re-read the release a few times before no-oping, so the last target to finish absorbs GitHub's read-after-write lag on the assets it just uploaded rather than leaving the release stuck as a draft.
…race Follow-up to the auto-publish review: - BUILD_SHA now resolves to inputs.source_sha only on finalize (which checks out source_sha) and github.sha otherwise (full/submit build the checked-out commit). This stops a full run from feeding the provenance ledger a source_sha that disagrees with what it actually built. - Document that the target_commitish ledger closes the realistic sequential case but not a same-instant concurrent claim by two differently-sourced targets (mac-finalize vs win-full); the loser fails loudly, and fully closing it would require per-asset commit provenance (out of scope).
The target_commitish ledger only mitigated mixed-source publishing: two targets built from different commits that finished within the same window could still race (last claim writer wins) because a single mutable field was the ledger. Replace it with one provenance marker asset per target (pawwork-<os>-<arch>-<version>.commit, holding the build commit). The publisher publishes only when every expected marker is present AND they all agree. Distinct cells per target means no claim race: a divergent target leaves a disagreeing marker, so no run ever sees "all agree" and every run fails closed. Markers are not in releaseAssetNames, so the R2 mirror skips them and the verifier ignores them as extra assets. Also publish via the release id (PATCH) instead of `gh release edit <tag>`, which is draft-safe regardless of gh's by-tag draft resolution, and pass RELEASE_OS/RELEASE_ARCH so each target can write its own marker.
- putProvenanceMarker now re-deletes and retries the create on a transient 422 already_exists (delete-not-yet-visible or a concurrent same-target run), so a clash can no longer leave a complete release stuck as a draft. - Document the irreducible residual: GitHub offers no cross-asset compare-and-swap, so a rerun-from-a-different-commit clobbering assets in the sub-second window between another target reading "all agree" and its publish PATCH could still slip a mixed-source release through. The realistic cases are fully closed; eliminating this one needs commit-in-asset-names (breaking) or dropping multi-dispatch.
…lish
The per-target commit markers close the concurrent-claim race but not a
clobber that lands after a marker is written: a target rebuilt from another
commit reuses the same version, so its marker's commit field can still read as
agreeing while the installer it points at has changed underneath.
Two layers harden the residual window, both fail-closed:
- Content anchor. Each marker now records {commit, sha512[]} — the build commit
AND the content hash of the updater asset it produced. Before publishing,
every recorded sha512 must still be present in the current latest*.yml; a
rebuild from another commit yields a different hash, so a stale marker no
longer matches and the publish is refused. An empty hash list (a metadata
read miss at marker time) is tolerated so a transient hiccup cannot deadlock
the release into a permanent draft.
- Seal + re-read. Right before the publish PATCH (the only draft->published
write), snapshot the tracked asset URLs, settle, re-read, and refuse if any
URL moved — electron-builder's overwrite DELETEs then re-creates an asset, so
a clobber always changes the URL. The PATCH is the last write.
Residual is now CI-unreachable: a mixed-source publish would require an asset
clobbered from a different commit within the single HTTP round-trip between the
final re-read and the PATCH. Eliminating even that needs the commit in the
asset filenames (breaks the updater/mirror/site links) or one orchestrated
workflow.
…er hole
Two race fixes surfaced by review of the auto-publish guard:
- Empty-hash provenance markers defeated the content anchor. A target that
could not read its own installer hash wrote {sha512: []}, and the drift check
passed it by vacuous truth, so a stale/foreign installer could ride along.
Now the writer retries reading its hash and fails rather than vouch for
nothing, and the decision policy treats an empty record as a content-anchor
failure -- fail closed either way.
- finalize-latest-yml could clobber an already-published release. Its
`gh release upload --clobber` resolves by tag regardless of draft state, so a
later same-version build from a different commit could rewrite the published
latest*.yml to hashes that no longer match the (electron-builder leaves these
untouched) published installers, breaking auto-update. It now refuses to write
to a non-draft release; the normal flow only ever finalizes a draft, so this
fires solely on a same-version re-dispatch.
Also: when another job wins the publish during our seal window, still dispatch
the mirror (its GITHUB_TOKEN publish fires no webhook and its own dispatch may
have failed); and log a clear warning when the poll window is exhausted still
incomplete, noting a re-dispatch recovers a stuck draft.
…ease guard finalize-latest-yml now calls `gh release view --jq .isDraft` before uploading, but the contract test's fake gh only handled `release download`; every other invocation fell through printing nothing, so the guard read an empty isDraft, treated the release as published, and aborted — failing all finalize cases. Make the fake answer `release view` with a configurable draft flag (default true, the normal flow) and add a case asserting the finalizer refuses to upload when the release is already published. No production change.
… dispatch The build workflow contract test pinned build-electron to actions:read, but the tail publish step needs actions:write to dispatch the R2 mirror via workflow_dispatch (a GITHUB_TOKEN publish fires no release:published webhook). Update the assertion to match the permission the workflow now declares.
A Codex audit of the checklist against the merged #1119 pipeline found the earlier "manual steps are a fallback" framing was a foot-gun: the manual gh release edit --draft=false bypasses every guard the auto-publisher adds (completeness, single-source markers, metadata hash anchor, seal/re-read), so following it when auto-publish has failed-closed force-publishes exactly the bad state the guard caught. - Step 4: a prod release publishes itself; describe how to read the auto-publish outcome (published / wait / fail) and what to do for each. Demote manual publish to a clearly-marked last resort that pins target_commitish and is never used for a partial draft or to mark a non-prod build latest. - Step 5: correct the R2 trigger — the normal path is the auto-publisher's explicit workflow_dispatch (a GITHUB_TOKEN publish emits no release:published); the webhook only fires on a manual personal-credential publish. - Step 3: note all three final targets must come from one build commit (mac finalize uses source_sha, win full uses dev HEAD) or auto-publish fails closed; fix the stale source_workflow_ref example (now a workflow-snapshot tag).
…ow (#1137) Align the release checklist with the PR #1119 auto-publish flow (verified by a two-round Codex audit of the doc against build.yml + the release scripts). - Step 4: a prod release publishes itself; document reading the auto-publish outcome (published / wait / fail). Manual `gh release edit` is a last resort that pins `target_commitish` and is never used for a partial draft or to mark a non-prod build latest — it bypasses the #1119 guards. - Step 5: the normal R2 trigger is the auto-publisher's explicit workflow_dispatch (a GITHUB_TOKEN publish emits no release:published); the webhook fires only on a manual personal-credential publish. - Step 3: all three final targets must come from one build commit (mac finalize uses source_sha, win full uses dev HEAD) or auto-publish fails closed; fix the stale source_workflow_ref example. Docs only.
Summary
Auto-publish the prod GitHub release once every target's installers and updater metadata have landed in the draft, then dispatch the R2 mirror — removing the manual "publish + pin tag + dispatch mirror" step at the end of every release.
scripts/publish-when-complete.ts(new) — runs at the tail of each prod finalize/full target; the last target to complete flips the draft to published and dispatches the mirror.scripts/verify-release.ts— anallowDraftseam plus provenance/parseUpdaterShaByUrlhelpers.scripts/finalize-latest-yml.ts— refuses to overwrite the updater metadata of an already-published release..github/workflows/build.yml— the tail publish step andactions: writeonbuild-electron.No related issue; this is a self-contained release-pipeline automation.
Why
The pipeline leaves a draft GitHub release after every build and nothing flips it to published, so
mirror-release-to-r2.yml(which triggers onrelease: published) never fires, and an operator has to manually publish the draft, pin the tag to the build commit, and dispatch the mirror.The hard part is that one version's assets are assembled across multiple, sometimes concurrent
workflow_dispatchruns (mac arm64 + x64 finalize, win full), each with its own source commit, and installers carry only the version (not the commit) in their names. So a naive "publish when the draft looks complete" could publish a release whose mac and win halves came from different commits. The guard below is designed to fail closed against that.Related Issue
None — self-contained automation of an existing manual release step. Rationale stated in Summary.
Human Review Status
PendingReview Focus
publish-when-complete.ts— whether a mixed-source publish is still reachable by the normal one-dispatch CI pipeline (vs. only by two concurrent same-version dispatches from different commits racing a single HTTP round-trip):pawwork-<os>-<arch>-<version>.commitholding{commit, sha512[]}(distinct cell per target → no claim race);latest*.yml; empty record fails closed);finalize-latest-yml.tspublished-release guard (gh release view --jq .isDraftbefore thegh release upload --clobberloop).GITHUB_TOKENpermissions —actions: writefor the mirrorworkflow_dispatch;contents: writefor marker upload/delete and the release PATCH.GET /releases/tags/{tag}), asset bytes via the asset API URL withAccept: application/octet-stream, marker upload viaupload_url.Risk Notes
build-electrongainsactions: write(togh workflow runthe mirror) and keepscontents: write(release PATCH + marker upload/delete). The opencode build-workflow contract test was updated to assert this.draft→falseand pinstarget_commitishto the build commit.releaseTypedefaults todraft, so it skips an already-published release); onlylatest*.ymlneeded the finalize guard.channel == 'prod'; dev has no publish config, beta lives in a separate repo.How To Verify
End-to-end cannot be exercised without a real prod release; the script is written fail-closed (incomplete → no-op draft, ambiguous/mixed → fail), so the worst case is "stays a draft, publish manually", never "publishes something broken".
Screenshots or Recordings
N/A — no visible UI or copy changes.
Checklist
bug,enhancement,task,documentation. Type labels are author-added; the labeler bot does NOT assign them. Add the label in the GitHub UI, then tick this.app,ui,platform,harness,ci. The labeler bot assigns these on PR open based on changed paths. Confirm the bot's choice (or override if wrong), then tick this.P0,P1,P2,P3. The priority-triage bot suggests one on PR open. Confirm or override, then tick this.Pending,Approved by @<reviewer>, orNot required: <reason>(default isPending; "not required" is restricted to bot-authored low-risk PRs).dev, and my PR title and commit messages use Conventional Commits in English.