ci(release): decompose registry publishing into independently dispatchable per-registry workflows - #230
Open
Mikola Lysenko (mikolalysenko) wants to merge 3 commits into
Open
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


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.