ci(release): decompose registry publishing into independently dispatchable per-registry workflows - #230
Conversation
…stry workflows
The crates.io, npm, PyPI, and RubyGems legs of release.yml move into their
own workflows (publish-{cargo,npm,pypi,rubygems}.yml). release.yml
dispatches each at the release tag via scripts/dispatch-publish.sh and
watches the run to completion, so one dispatch still releases everything
and the release job graph reflects each registry's outcome — but any single
registry can now be retried standalone (Actions → Run workflow with the
release version) without rebuilding: legs check out the v<version> tag and
source binaries from the GitHub release's assets, verified against
SHA256SUMS.
Dispatch (not reusable workflows) because registry trusted publishers match
a workflow filename but disagree on which claim: npm/crates.io match the
top-level workflow_ref, PyPI/RubyGems match job_workflow_ref (PyPI plans to
flip, warehouse#20083), and npm allows exactly one publisher per package.
Always-top-level dispatch makes both claims name the leg's own file, so one
registration per package covers every path on every registry.
workflow_dispatch events are exempt from GITHUB_TOKEN event suppression, so
no PAT is needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings (multi-agent, adversarially verified) and their fixes: - dispatch-publish.sh poll loop died under set -e on a transient gh run list failure, stranding an already-dispatched leg -> tolerate errors inside the retry loop (|| true) and require headBranch == the dispatched tag so a same-title run at another ref can never be picked up. - No concurrency group meant the documented retry path (re-run a failed watcher while its leg still runs) could race two same-version publishes past the check-then-act probes and hard-fail the loser -> every publish workflow now serializes same-version runs via concurrency groups. - npm/PyPI inputs moved from immutable same-run artifacts to mutable release assets, with SHA256SUMS itself an asset of the same release -> the release run now passes the sums file's sha256 (sums-digest input) so fan-out publishes are pinned to exactly what that run built; manual retries stay integrity-only. - Stale comments/docs: tag-job comment still described the monolith, release.yml header wrongly claimed RubyGems consumes release binaries, version-bump.yml cited the deleted one-workflow topology, releasing.md pointed npm approval at the wrong run's summary, environment comments missed the reviewer-approval/watcher-timeout interaction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Correct the concurrency-group story everywhere it is stated: GitHub keeps at most ONE waiting run per group, so a further same-version dispatch displaces (cancels) the waiting duplicate; dispatch-publish.sh now diagnoses a cancelled conclusion distinctly so a displaced-duplicate watcher failure is legible. - Fail closed on an empty SHA256SUMS digest: the release run refuses to dispatch an unpinned npm/PyPI publish instead of silently downgrading the asset check to integrity-only. - timeout-minutes: 45 on every publish leg so a wedged run cannot hold the per-version concurrency group for the 6h default; watchers raised 30->45 to match. - Environment NOTEs: a re-dispatched duplicate needs its own reviewer approval; a pending approval does not consume the leg's timeout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Bugbot Autofix is ON. A cloud agent has been kicked off to fix the reported issue.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 6e8d7c6. Configure here.
| else | ||
| echo "::error::${WORKFLOW} run for v${VERSION} failed (conclusion: ${CONCLUSION:-unknown}): ${RUN_URL}" | ||
| fi | ||
| exit 1 |
There was a problem hiding this comment.
Watcher ignores successful run conclusion
Low Severity
When gh run watch returns non-zero, the script always exits 1 even after gh run view reports conclusion of success. A transient watch/API failure after the publish leg already succeeded makes the release fan-out job fail and can trigger an unnecessary re-dispatch.
Reviewed by Cursor Bugbot for commit 6e8d7c6. Configure here.
|
CI note: the |


What
The crates.io, npm, PyPI, and RubyGems legs of
release.ymlmove into their own workflows —publish-{cargo,npm,pypi,rubygems}.yml. A release is still one dispatch:release.yml(version gate → 14-target build → tag → GitHub release) then dispatches each leg at the release tag viascripts/dispatch-publish.shand watches the dispatched run to completion, so the release run's job graph still reflects every registry's real outcome and "Re-run failed jobs" re-dispatches exactly the failed legs.After a mid-release failure, any single registry can now also be retried standalone — Actions → Publish crates.io / npm / PyPI / RubyGems → Run workflow with the release version — without rebuilding anything: each leg checks out the
v<version>tag, and npm/PyPI take the prebuilt binaries from the GitHub release's assets verified againstSHA256SUMS(release-run dispatches additionally pin the sums file by digest; manual retries are integrity-only). This is exactly the v4.0.0 pain point: fixing a registry-side trusted publisher and re-publishing one ecosystem no longer touches the other three.Why dispatch, not reusable workflows (
uses:)Verified against the registries' docs and source (warehouse, rubygems.org, crates.io) on 2026-08-21:
workflow_ref)workflow_ref)job_workflow_ref) today; flip to top-level planned (warehouse #20083)job_workflow_ref)npm matches the top-level filename and allows one publisher per package, so a leg that is sometimes
uses:-called (top-level =release.yml) and sometimes dispatched (top-level = its own file) can never be authorized for both paths. Dispatching makes every leg always the top-level workflow of its own run, soworkflow_refandjob_workflow_refboth name the leg's file and one publisher registration per package covers everything on every registry — immune to the PyPI flip.workflow_dispatchevents are a documented exception toGITHUB_TOKENevent suppression, so no PAT is needed.Hardening (from the adversarial review)
concurrencygroup, so a duplicate dispatch (watcher timeout → re-run) waits and then no-ops on the idempotency probes instead of racing them;dispatch-publish.shdiagnoses a displaced-duplicate cancellation distinctly.gh run listfailures and binds onheadBranch == v<version>in addition to the distinct-id in the run name.SHA256SUMSdigest came out empty (fail closed).timeout-minutes: 45on every leg bounds how long a wedged run can hold its concurrency group; watchers match.One-time ops after merge (before the next release)
Re-register trusted publishers against the new filenames — table in
docs/releasing.md § One-time registry setup:publish-{cargo,pypi,rubygems}.ymlpublishers alongside the existingrelease.ymlones (all three allow multiples); delete therelease.ymlones only after every pre-split run (v4.0.0 re-runs on run 32441686301) has fully landed.publish-npm.ymlonce no pre-split npm job may need re-running (approving the already-staged v4.0.0 versions needs no OIDC; only re-staging does).Review: ULTRACODE multi-agent pass (6 review dimensions → 17 raw findings → 2-vote adversarial verification → 13 confirmed, all fixed) plus a 3-lens verification pass over the fixes (4 residuals, all fixed). Registry claim semantics verified against pypi/warehouse, rubygems.org, and rust-lang/crates.io source plus official docs.
🤖 Generated with Claude Code
Note
High Risk
Rewires the entire multi-registry release path and OIDC trusted-publisher identity (workflow filenames,
actions:writefan-out, npm/PyPI asset provenance). A misconfigured publisher cutover or dispatch correlation bug can block or mis-publish a release.Overview
Release publishing is no longer inlined in
release.yml. crates.io, npm, PyPI, and RubyGems each get a standaloneworkflow_dispatchworkflow. A release is still one dispatch: after tag + GitHub release,release.ymlfans out viascripts/dispatch-publish.shand watches each run so the job graph still shows real registry outcomes.Why dispatch, not
uses:: trusted publishers key off workflow filename, and npm allows only one publisher per package. Dispatching makes every leg always the top-level workflow of its own file, covering both fan-out and manual retries without a PAT (workflow_dispatchis the GITHUB_TOKEN event-suppression exception).Retry without rebuild: each leg checks out
v<version>; npm/PyPI pull binaries from the GitHub release, verifySHA256SUMS, and (on the fan-out path) pin that sums file by digest. Same-version runs serialize; already-published/staged versions are skipped. Ops after merge: re-register OIDC publishers against the new filenames (npm cutover is atomic).Reviewed by Cursor Bugbot for commit 6e8d7c6. Configure here.