Skip to content

feat(release): auto-publish the prod release once all targets land#1119

Merged
Astro-Han merged 9 commits into
devfrom
claude/release-auto-publish
Jun 3, 2026
Merged

feat(release): auto-publish the prod release once all targets land#1119
Astro-Han merged 9 commits into
devfrom
claude/release-auto-publish

Conversation

@Astro-Han
Copy link
Copy Markdown
Owner

@Astro-Han Astro-Han commented Jun 3, 2026

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.
  • Single-source guard — per-target provenance markers, a content anchor, and a seal/re-read around the publish PATCH, so a release assembled from more than one commit is never published.
  • scripts/verify-release.ts — an allowDraft seam plus provenance/parseUpdaterShaByUrl helpers.
  • scripts/finalize-latest-yml.ts — refuses to overwrite the updater metadata of an already-published release.
  • .github/workflows/build.yml — the tail publish step and actions: write on build-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 on release: 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_dispatch runs (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

Pending

Review Focus

  1. The single-source guard in 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):
    • per-target marker pawwork-<os>-<arch>-<version>.commit holding {commit, sha512[]} (distinct cell per target → no claim race);
    • content anchor (every marker's recorded installer hash must still be in the current latest*.yml; empty record fails closed);
    • seal → settle → re-read → refuse-if-any-asset-URL-moved before the publish PATCH (the last write).
  2. finalize-latest-yml.ts published-release guard (gh release view --jq .isDraft before the gh release upload --clobber loop).
  3. GITHUB_TOKEN permissionsactions: write for the mirror workflow_dispatch; contents: write for marker upload/delete and the release PATCH.
  4. Draft-release API correctness — drafts found via the list endpoint (they 404 on GET /releases/tags/{tag}), asset bytes via the asset API URL with Accept: application/octet-stream, marker upload via upload_url.

Risk Notes

  • Permissions: build-electron gains actions: write (to gh workflow run the mirror) and keeps contents: write (release PATCH + marker upload/delete). The opencode build-workflow contract test was updated to assert this.
  • Deletion behavior: provenance markers are delete-then-recreate on overwrite; the publish PATCH flips draft→false and pins target_commitish to the build commit.
  • Platform / packaging / updater: touches the macOS (arm64 + x64) and Windows (x64) release/updater flow. Installer assets themselves are protected from a later same-version build by electron-builder (releaseType defaults to draft, so it skips an already-published release); only latest*.yml needed the finalize guard.
  • Residual race: GitHub offers no cross-asset compare-and-swap, so a mixed-source publish is reachable only by two same-version builds from different commits dispatched concurrently and landing a write in the single HTTP round-trip between a check and its write — not the normal one-dispatch pipeline (each version is built once, from one commit). Documented in the script header. Eliminating it entirely would require the commit in asset filenames (breaks the updater, mirror, and site links) or one orchestrated workflow.
  • dev/beta are unaffected: gated to channel == 'prod'; dev has no publish config, beta lives in a separate repo.
  • No visible UI or copy changed (the UI checklist item below is intentionally left unticked for that reason).

How To Verify

desktop-electron full suite (bun test):                464 pass, 0 fail
  incl. publish-when-complete.test.ts (decision policy: commit-mismatch,
        completeness, content-anchor drift, empty-marker fail-closed),
        verify-release.test.ts (allowDraft seam, parseUpdaterShaByUrl),
        release-metadata-contract.test.ts (finalizer published-release guard),
        release-workflow-contract.test.ts
opencode workflow contract tests (bun test test/github):  46 pass, 0 fail
  incl. build-workflow.test.ts (build-electron permissions = actions:write + contents:write)
bun run typecheck:                                      clean
bun run typecheck:release:                              clean
actionlint .github/workflows/build.yml:                 no new findings (only
  pre-existing SC2086/SC2046 on unrelated Apple-key/notarize steps)

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

How to use this checklist:

  • Tick a box by replacing [ ] with [x]. Do not edit, add, or remove items.
  • The bot-applied label items can only be honestly ticked AFTER the PR is opened and the labeler / priority-triage bots have run — return to the PR description and tick them then.
  • Most items are required. The few that are conditional are explicitly marked (conditional); for those, leave unticked if they truly do not apply and explain why in Risk Notes. All other items must be ticked before requesting human review.
  • Type label — this PR carries exactly one of 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.
  • Routing labels — this PR carries at least one of 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.
  • Priority label — this PR carries exactly one of P0, P1, P2, P3. The priority-triage bot suggests one on PR open. Confirm or override, then tick this.
  • Human Review Status above is set to Pending, Approved by @<reviewer>, or Not required: <reason> (default is Pending; "not required" is restricted to bot-authored low-risk PRs).
  • I linked the related issue, or stated in Summary why there is no issue.
  • I described the review focus and any meaningful risks.
  • I replaced the example block in How To Verify with the real verification steps and the key result for each.
  • I did not introduce unrelated refactors, dependencies, generated files, or file changes beyond the stated scope.
  • (conditional) I manually checked visible UI or copy changes when needed, with screenshots or recordings. Leave unticked only if no visible UI or copy changed.
  • (conditional) I considered macOS and Windows impact for platform, packaging, updater, signing, paths, shell, or permissions changes. Leave unticked only if no platform/packaging surface was touched.
  • (conditional) I called out docs, release notes, dependencies, permissions, credentials, deletion behavior, generated content, or local file changes when relevant. Leave unticked only if none of those surfaces was touched.
  • I reviewed the final diff for unrelated changes and suspicious dependency changes.
  • I am targeting dev, and my PR title and commit messages use Conventional Commits in English.

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.
@Astro-Han Astro-Han added enhancement New feature or request ci Continuous integration / GitHub Actions P2 Medium priority platform Electron shell, OS integration, packaging, updater, signing, paths, and permissions labels Jun 3, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 3, 2026

Warning

Review limit reached

@Astro-Han, we couldn't start this review because you've reached your PR review rate limit.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99b13e9c-85b7-4faf-9d9b-02892a99021d

📥 Commits

Reviewing files that changed from the base of the PR and between 802bcb8 and 65a6b53.

📒 Files selected for processing (8)
  • .github/workflows/build.yml
  • packages/desktop-electron/scripts/finalize-latest-yml.ts
  • packages/desktop-electron/scripts/publish-when-complete.test.ts
  • packages/desktop-electron/scripts/publish-when-complete.ts
  • packages/desktop-electron/scripts/release-metadata-contract.test.ts
  • packages/desktop-electron/scripts/verify-release.test.ts
  • packages/desktop-electron/scripts/verify-release.ts
  • packages/opencode/test/github/build-workflow.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/release-auto-publish

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested priority: P2 (includes non-doc, non-test paths outside the low-risk bucket).

P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/desktop-electron/scripts/publish-when-complete.ts Outdated
Astro-Han added 8 commits June 3, 2026 16:20
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.
@github-actions github-actions Bot added the harness Model harness, prompts, tool descriptions, and session mechanics label Jun 3, 2026
@Astro-Han Astro-Han merged commit 8f4fe91 into dev Jun 3, 2026
35 checks passed
@Astro-Han Astro-Han deleted the claude/release-auto-publish branch June 3, 2026 11:20
Astro-Han added a commit that referenced this pull request Jun 3, 2026
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).
Astro-Han added a commit that referenced this pull request Jun 3, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci Continuous integration / GitHub Actions enhancement New feature or request harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority platform Electron shell, OS integration, packaging, updater, signing, paths, and permissions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant