Skip to content

ci(release): decompose registry publishing into independently dispatchable per-registry workflows - #230

Open
Mikola Lysenko (mikolalysenko) wants to merge 3 commits into
mainfrom
ci/decomposed-release-publish
Open

ci(release): decompose registry publishing into independently dispatchable per-registry workflows#230
Mikola Lysenko (mikolalysenko) wants to merge 3 commits into
mainfrom
ci/decomposed-release-publish

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

What

The crates.io, npm, PyPI, and RubyGems legs of release.yml move 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 via scripts/dispatch-publish.sh and 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 against SHA256SUMS (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:

Registry Publisher filename matched against Publishers per package
crates.io top-level (workflow_ref) up to 5
npm top-level (workflow_ref) exactly 1
PyPI job-defining file (job_workflow_ref) today; flip to top-level planned (warehouse #20083) multiple
RubyGems job-defining file (job_workflow_ref) multiple

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, so workflow_ref and job_workflow_ref both name the leg's file and one publisher registration per package covers everything on every registry — immune to the PyPI flip. workflow_dispatch events are a documented exception to GITHUB_TOKEN event suppression, so no PAT is needed.

Hardening (from the adversarial review)

  • Each publish workflow serializes same-version runs via a concurrency group, so a duplicate dispatch (watcher timeout → re-run) waits and then no-ops on the idempotency probes instead of racing them; dispatch-publish.sh diagnoses a displaced-duplicate cancellation distinctly.
  • The correlation poll tolerates transient gh run list failures and binds on headBranch == v<version> in addition to the distinct-id in the run name.
  • The release run refuses to dispatch an unpinned npm/PyPI publish if the SHA256SUMS digest came out empty (fail closed).
  • timeout-minutes: 45 on 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:

  • crates.io / PyPI / RubyGems: add publish-{cargo,pypi,rubygems}.yml publishers alongside the existing release.yml ones (all three allow multiples); delete the release.yml ones only after every pre-split run (v4.0.0 re-runs on run 32441686301) has fully landed.
  • npm: single publisher per package → atomic cutover to publish-npm.yml once 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:write fan-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 standalone workflow_dispatch workflow. A release is still one dispatch: after tag + GitHub release, release.yml fans out via scripts/dispatch-publish.sh and 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_dispatch is the GITHUB_TOKEN event-suppression exception).

Retry without rebuild: each leg checks out v<version>; npm/PyPI pull binaries from the GitHub release, verify SHA256SUMS, 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.

…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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6e8d7c6. Configure here.

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