fix(ci): tag the version bump, survive push races, release on dispatch - #327
Conversation
PR SummaryHigh Risk Overview The old is_release and changes jobs are merged into preflight, which also builds a Slack release itinerary from changeset status via build_release_itinerary.cjs. The release job commits version bumps with git add -A before changeset publish so tags point at the bumped commit; push runs only when tags exist at HEAD and uses merge on non-fast-forward instead of rebasing. The post-publish lockfile update step is removed. release now depends on all upstream jobs so docker or template failures block publish; job conditions use !cancelled() instead of always(). Slack start and success notifications include the itinerary; report-failure includes preflight in needs. Charts release output uses GITHUB_OUTPUT instead of deprecated set-output. Reviewed by Cursor Bugbot for commit 6be6880. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a5545535ff
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| - main | ||
| # A release that publishes nothing leaves the changesets intact, so it can be | ||
| # re-dispatched by hand without another push to main. | ||
| workflow_dispatch: {} |
There was a problem hiding this comment.
Restrict manual releases to main
Adding workflow_dispatch without a ref guard means this release workflow can be manually run from any branch selected in the UI/CLI; GitHub's manual-run docs describe both the branch dropdown and gh workflow run --ref. If a maintainer dispatches this on a feature branch that has changesets, the release job will still publish packages/tags and git push the version bump back to that branch instead of main, so the manual path should validate github.ref == 'refs/heads/main' before publishing.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a554553. Configure here.
…workflow Port the release-workflow fixes from e2b-dev/E2B (#1463, #1469, #1484, #1589, #1619). Every release tag here points at the commit *before* its own version bump (`@e2b/code-interpreter@2.7.0` contains `"version": "2.6.1"`), because `changesets/action` tags whatever HEAD it publishes from and the commit came afterwards. `changeset version` leaves `pnpm-lock.yaml` untouched in this repo — no workspace package depends on a sibling — so the release commit is already complete before anything is uploaded, and it can simply move ahead of the publish. That also drops the `Update lock file` step. The push is now gated on `git tag --points-at HEAD` rather than on whether publish succeeded: the tags are what has to end up reachable, and `changeset publish` pushes them before propagating a partial failure. A non-fast-forward is reconciled with a merge, not a rebase, which would strand those tags — run 30018789653 died on exactly that race with the versions already published. `git add -A` replaces `commit -am`, which cannot stage the CHANGELOG.md files `changeset version` writes fresh. Also: Slack start/success notifications with a release itinerary, `is_release` + `changes` collapsed into one `preflight` job, `always()` -> `!cancelled()` so the workflow can be cancelled, pnpm from `packageManager` and Node from `.tool-versions` instead of drifting pins, and `::set-output` replaced with `$GITHUB_OUTPUT`. Two latent bugs fixed on the way: `build-template` and `release` read `needs.changes.outputs.*` without depending on `changes`, and `release`'s `!contains(needs.*.result, 'failure')` could not see a template or docker failure, since a failed upstream makes the test jobs *skip* and skipped is not failure — so a broken template build could publish. All upstream jobs are now listed in `needs`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a554553 to
b88d58a
Compare
|
|
||
| - name: Build release itinerary | ||
| id: itinerary | ||
| if: steps.version.outputs.release == 'true' | ||
| run: | | ||
| pnpm changeset status --output=.cs-status.json | ||
| ITINERARY=$(node -e ' | ||
| const data = require("./.cs-status.json"); | ||
| const labels = { | ||
| "@e2b/code-interpreter": "JS SDK (@e2b/code-interpreter)", | ||
| "@e2b/code-interpreter-python": "Python SDK (e2b-code-interpreter)", | ||
| "@e2b/data-extractor": "Charts (e2b-charts)", | ||
| "@e2b/code-interpreter-template": "Sandbox template (code-interpreter)", | ||
| }; | ||
| const order = Object.keys(labels); | ||
| const byName = Object.fromEntries(data.releases.map(r => [r.name, r])); | ||
| const lines = order.filter(n => byName[n]).map(n => `• ${labels[n]} v${byName[n].newVersion}`); | ||
| process.stdout.write(lines.join("\n") || "• No packages were published"); | ||
| ') | ||
| rm -f .cs-status.json | ||
| { |
There was a problem hiding this comment.
🟡 The 'Build release itinerary' step embeds a ~12-line multi-line node -e script (label map, ordering, formatting) directly in release.yml, rather than following the repo's own convention of putting release logic in standalone files under .github/scripts/ (alongside is_release.sh and is_release_for_package.sh). Consider extracting it to something like .github/scripts/build_release_itinerary.cjs so it can be linted/unit-tested and any syntax error surfaces before an actual release run.
Extended reasoning...
The Build release itinerary step (release.yml lines ~92-116) builds the Slack release itinerary with an inline node -e block: it reads .cs-status.json (written by pnpm changeset status --output=...), maps package names to human-readable labels, orders them, formats each as a bullet line, and falls back to a "No packages were published" message. This is meaningfully more logic than the other steps in this job — none of which embed multi-line JS — and it is only exercised on an actual release run against real changeset data.
The repo already has an established pattern for this kind of thing: .github/scripts/is_release.sh and .github/scripts/is_release_for_package.sh are standalone, executable scripts invoked from adjacent steps in the very same job. Those scripts can be shellchecked and run locally against fixture data; the inline node -e block cannot be linted, cannot be unit-tested, and any typo or logic bug (e.g. a broken template-string interpolation, a bad property access on data.releases) will only be discovered when a real release runs — at which point the failure blocks the Slack notification step (and, depending on ordering, could fail the whole preflight job) rather than being caught in CI or code review.
Concretely, imagine a future edit adds a fifth package to the labels map but a stray comma or bracket is introduced. YAML itself would still parse fine — the script is just a string value to the run: block — so no workflow-level validation catches it. The mistake would only surface the next time a release actually runs, when node -e throws a SyntaxError and the itinerary step fails, which is the worst possible moment to discover it (mid-release, without a fast local repro path).
Extracting the transform to .github/scripts/build_release_itinerary.cjs (reading .cs-status.json from cwd or an argv path, same as the shell scripts take package names as argv) would let it be run and tested locally with mock changeset-status JSON, matches the existing convention in this same job, and does not change behavior — the workflow step would just become node .github/scripts/build_release_itinerary.cjs.
This is a maintainability nit, not a correctness bug — the current inline script is not wrong, just harder to test and lint than the surrounding scripts in the same job. It sits on a non-critical path (a Slack notification), so it should never block merging this PR.
| push: | ||
| branches: | ||
| - main | ||
| # A release that publishes nothing leaves the changesets intact, so it can be | ||
| # re-dispatched by hand without another push to main. | ||
| workflow_dispatch: {} | ||
|
|
||
| concurrency: Release-${{ github.ref }}-foxtrot | ||
|
|
There was a problem hiding this comment.
🟡 The new workflow_dispatch: {} trigger has no branch guard, so it can be manually run on any branch (not just main) — unlike the push trigger, which is scoped to branches: [main]. is_release.sh only checks for the presence of unmerged .changeset/*.md files, not the ref, so dispatching on a feature branch that still has an open changeset (the normal state of an open PR) will let release commit a version bump and genuinely publish to npm/PyPI/tags from unreviewed code. Consider adding if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) to the preflight job (or the whole workflow) to restrict dispatch to main, matching the PR description's stated "recovery, not swapped in" intent.
Extended reasoning...
What the bug is: workflow_dispatch: {} is added at .github/workflows/release.yml:9 with no if condition or ref restriction. Compare this to the existing push trigger just above it, which is explicitly scoped with branches: [main]. GitHub Actions runs a manually-dispatched workflow using the code and ref selected in the dispatch UI/API — any branch, not just main. Nothing in this workflow constrains that choice.
The code path that triggers it: preflight's "Check if new version" step calls .github/scripts/is_release.sh, which (confirmed by reading the script) does nothing more than require('@changesets/read')(cwd).then(r => !!r.length) — it reports release=true purely because .changeset/*.md files exist in the checked-out tree. It never compares the current ref/SHA to main. An open feature/PR branch that adds a changeset (the completely normal state of any PR following this repo's changesets workflow) will therefore also report release=true when dispatched. From there, release only checks needs.preflight.outputs.release == 'true' plus !cancelled()/no-failure — there's no additional ref check anywhere downstream, and secrets (PYPI_TOKEN, the VERSION_BUMPER app token, id-token: write for npm OIDC) are all available on workflow_dispatch runs the same as on push.
Why existing code doesn't prevent it: the whole gating mechanism (preflight → release) was designed around "does the checked-out ref have a pending changeset," not "is this ref main." That assumption was safe when the only trigger was push: branches: [main], but adding an unguarded workflow_dispatch breaks the implicit invariant without anything else changing to compensate.
Concrete walk-through:
- A contributor opens a feature branch
feat/xand adds.changeset/silly-cats-jump.md(standard practice for any user-facing change in a changesets repo). - Before merging, someone with write access to the repo (a maintainer doing a routine re-run, or simply exploring the Actions tab) opens the Release workflow's "Run workflow" dropdown and selects
feat/xinstead ofmain. preflightchecks outfeat/x, finds the changeset file, and setsrelease=true.releaserunspnpm run version(bumping versions per the changeset), commits, andchangesets/actionpublishes to npm/PyPI and pushes tags — all fromfeat/x, which never went through review or a merge tomain.- The result: unreviewed code is now live on public package registries, and
main's branch protection was never consulted, contradicting the PR description's own statement that dispatch is "for recovery, not swapped in" (i.e., meant to be main-only).
Fix: gate the dispatch path to main, e.g. add if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) to the preflight job (and thus transitively everything gated on it), or apply the same check to every job/release directly.
Severity reasoning: all three verifiers independently confirmed the mechanism is real and newly introduced by this PR (the workflow previously only triggered on push-to-main). However, exploiting or misfiring it requires an actor who already has write/workflow-run access to deliberately (or accidentally) pick a non-default branch in the dispatch UI — it does not fire automatically on a normal PR merge, and two of the three verifiers explicitly recommended nit/hardening rather than blocking. I'm filing this as a nit: it's a real supply-chain hardening gap worth a one-line fix, but not something that breaks on the happy path or blocks this PR.
…y script Addresses the bot review on #327. `workflow_dispatch` offers every branch in the picker, so a feature branch carrying changesets could publish real packages and push the version bump to that branch; preflight now fails fast unless the run is on main. `changesets/action` runs the publish script with `ignoreReturnCode: true` and pushes each tag it can parse out of the output before it fails the step, so a partial publish does get its tags to origin — but only the ones that appear as `New tag:` lines. `git push --tags` after the branch lands covers anything else sitting at the release commit; the checkout is shallow and fetches no tags, so the only local tags are this run's, and re-pushing the rest is a no-op. The itinerary transform moves to `.github/scripts/`, next to `is_release.sh`, where it can be run against fixture JSON instead of only during a release; a package missing from the label map now shows up under its workspace name rather than vanishing. The step is `continue-on-error` and the messages fall back to a placeholder — it feeds a Slack notification and should never be what blocks a release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Went through the bot review. Three findings addressed in 6dc0e1f, one declined. Manual dispatch could publish from any branch (Codex) — correct and worth fixing. "Push path drops release tags" (Bugbot) — the stated mechanism is wrong: Inline
Verified the new push path against a throwaway shallow clone with a racing commit and two tags — one pre-pushed, one not: both end up on origin and reachable from |
Merging a changeset to main no longer publishes on its own; changesets accumulate until someone dispatches the workflow. Nothing else depended on the push trigger — no workflow here uses `workflow_run`, and every other workflow fires on `pull_request` — so this is only a change to who decides when a release happens. The ref check added for the dispatch path now guards the only path there is, which E2B's own release workflow does not do: with production releases dispatchable from any branch in the picker, a feature branch carrying changesets would publish for real and push the version bump to itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`report-failure` did not list `preflight`, so whether a broken preflight — a failed install, an erroring `is_release.sh`, a dispatch off `main` — pings the release channel depended on `failure()` looking past this job's direct dependencies. Listing it removes the doubt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`changesets/action` runs the publish script with `ignoreReturnCode: true` and pushes every tag it parses out of the output before failing the step, including the ones for private packages: in e2b-dev/E2B the private `@e2b/python-sdk` tags all reach origin with no such step. So there is nothing left at the release commit for `git push --tags` to catch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… releases Adopts the refinements from e2b-dev/code-interpreter#327, which ported the same E2B fixes to the sibling repo. Releases are now cut by dispatching the workflow rather than by merging to main, matching E2B and code-interpreter. Changesets accumulate until someone dispatches, and since a release that publishes nothing leaves them intact, a re-dispatch is also the recovery path. `workflow_dispatch` offers every branch in the picker, so preflight refuses to run off main before anything is checked out -- a feature branch carrying changesets would otherwise publish real packages and push the bump to that branch. This also settles the open question from the previous commits: gating publish on the SDK tests no longer risks a flaky test blocking an automatic release, because releases are no longer automatic. The itinerary moves out of an inline `node -e` heredoc into .github/scripts/build_release_itinerary.cjs. Besides being readable, it fixes a silent omission: the inline version iterated a hardcoded package order, so a package added later would have been left out of the Slack message while still being published. The script sorts known packages first and falls back to the workspace name for anything else. Verified: an unlisted `@e2b/desktop-cli` now appears rather than vanishing. The step is `continue-on-error` and the messages fall back to '• (itinerary unavailable)'. It only feeds Slack, so a hiccup in `changeset status` must not be the thing that fails preflight and blocks the release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main moved under this branch: #324 pinned every action to a commit SHA, #323 upgraded to pnpm 10, and both added entries to `.tool-versions`. Conflicts resolved by taking main's toolchain conventions and re-applying this branch's changes on top of them: - `.tool-versions` is main's verbatim. This branch had added `nodejs 24`; main instead keeps `node 20` as the build/test baseline and has the release job override it with Node 24 for npm 11, which is the same outcome with the reason written down. The preflight job now reads `TOOL_VERSION_NODE`. - pnpm comes from `TOOL_VERSION_PNPM` in every job, as on main, rather than from `packageManager` via an unpinned `pnpm/action-setup@v4`. Main documents the sync requirement in `.tool-versions`, which closes the drift this branch was working around. - Every action keeps main's pinned SHA, including in the jobs this branch restructured. Nothing here introduces a new action. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three fixes found while porting this workflow to `e2b-dev/code-interpreter` ([#327](e2b-dev/code-interpreter#327)). **`release.yml` can be dispatched from any branch.** It is dispatch-only and `workflow_dispatch` offers every branch in the picker, so a feature branch carrying changesets would publish real packages to npm and PyPI and push the version bump to itself. `preflight` now fails fast unless the run is on `main`; candidates cut from a branch already go through `release-candidate.yml`. **The itinerary step can block a release.** It only feeds the Slack messages, but a `changeset status` hiccup — or a typo in a future edit to that inline `node -e` block, which no YAML validation catches — fails `preflight` and stops the release. It is now `continue-on-error` with a placeholder fallback in both messages, and the transform moved to `.github/scripts/build_release_itinerary.cjs` next to `is_release.sh`, where it can be run against fixture JSON. A package missing from the label map now shows under its workspace name instead of being dropped by `order.filter`, so a fourth publishable package would not silently vanish from the notification. **`report-failure` did not list `preflight`**, so whether a broken preflight pings `#monitoring-releases` rested on `failure()` looking past the job's direct dependencies — not documented either way, so the job now depends on it explicitly. Verified: the extracted script reproduces the current output exactly for `e2b` / `@e2b/python-sdk` / `@e2b/cli`, in the same order, and handles the empty and unlabeled-package cases; workflow validated against the Actions schema; the script matches the repo's prettier config. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#246) * fix(ci): commit the version bump before publishing so release tags point at it Ports the release workflow fixes from e2b-dev/E2B (#1463, #1469, #1484, $ git show '@e2b/desktop@2.3.1:packages/js-sdk/package.json' | grep version "version": "2.3.0", # <- tagged 2.3.1 `changeset publish` tags whatever HEAD it publishes from, so committing the bump afterwards left every tag on the commit preceding its own version bump. Anything that builds from a git tag rather than a registry got the previous release. The commit now happens first. That is safe here without E2B's `workspace:^` prerequisite: no workspace package depends on another by registry range (examples/ is outside pnpm-workspace.yaml), so `changeset version` never touches pnpm-lock.yaml -- true of every past release commit. The release commit is complete before anything uploads, which also deletes the `Update lock file` step that forced the commit to come last. A comment records the invariant so a future cross-package dep cannot silently reintroduce the cycle. The push is now gated on `git tag --points-at HEAD` rather than on publish success: the tags are what has to end up reachable, and changesets/action pushes them before propagating a non-zero exit. A non-fast-forward is reconciled with a merge, not a rebase, which would strand the tags off the branch. A publish that uploads nothing leaves the branch untouched with the changesets intact for a clean re-run. `git add -A` replaces `commit -am`, which cannot stage new files. `changeset version` writes each CHANGELOG.md fresh, so no release commit has ever contained one -- `git ls-files | grep -i changelog` is empty. In release.yml, `is_release` and `changes` are merged into one preflight job: `changes` did a second full checkout and pnpm install to compute js-sdk/python-sdk outputs that nothing consumed. Those flags now gate the SDK test workflows, which run before publish. Slack gains start and success notifications with a release itinerary; success is gated on `needs.publish.result` rather than `success()`, which a skipped test job would suppress. report-failure also needs preflight, so a release that dies before deciding what to ship still notifies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ci): don't let an empty changeset start a no-op release Addresses three review findings on the previous commit. 1. An empty changeset (`changeset add --empty`) marks a change as needing no release, but `is_release.sh` counted it, so it started a release that published nothing. Verified against @changesets/read: [{"releases":[],"summary":"","id":"zz-empty-probe"}] -> release=true The version step's only effect is deleting the marker, and that commit is deliberately not pushed when no tags were created, so the marker survived on the branch and re-triggered a no-op release -- Slack notifications included -- on every later push. The old workflow committed unconditionally after publishing, so this was a regression introduced with the tag fix. `is_release.sh` now requires a changeset that actually releases a package; the marker is consumed by the next real release. 2. The tag gate exited 0 when publish reported success but tagged nothing. A release only starts when a changeset releases something, so `changeset version` bumped a package, so that state means those versions are already on the registry -- the tail of an earlier release whose branch push failed. Re-running cannot fix it: nothing is left to publish, nothing will ever be tagged, and exiting 0 reported success on every later push while main kept the old versions. It now fails with an error naming the manual recovery. 3. The push step's merge fallback needs a merge base, which a depth-1 clone does not have once the branch has moved by more than the graft point, so the checkout now uses fetch-depth: 0. Not changed: pinning `pnpm/action-setup` to a SHA. Every action in this repo floats on a tag, including `actions/checkout`, `actions/create-github-app-token` (which mints the push token) and `changesets/action` (which runs the publish). Pinning the one action this PR happened to bump would not reduce the exposure it describes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ci): scope the release commit to the paths versioning touches `git add -A` staged the whole tree, so any non-ignored untracked file written by an earlier step in the job -- a dependency lifecycle script during `pnpm install`, a build -- would be committed and pushed to main with release credentials. Scoped to `.changeset` and `packages`, which is everything `changeset version` and postVersion write, and still uses `-A` so new CHANGELOG.md files are staged. Directories rather than explicit file lists, so a package added later stays covered. pnpm-lock.yaml is now deliberately outside that scope. It is not supposed to move at release time -- that is what makes the release commit complete before anything is published -- so a change to it means the tree being tagged is missing part of the release. Guarded with an error rather than silently committing an incomplete release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ci): release on dispatch, extract the itinerary, stop it blocking releases Adopts the refinements from e2b-dev/code-interpreter#327, which ported the same E2B fixes to the sibling repo. Releases are now cut by dispatching the workflow rather than by merging to main, matching E2B and code-interpreter. Changesets accumulate until someone dispatches, and since a release that publishes nothing leaves them intact, a re-dispatch is also the recovery path. `workflow_dispatch` offers every branch in the picker, so preflight refuses to run off main before anything is checked out -- a feature branch carrying changesets would otherwise publish real packages and push the bump to that branch. This also settles the open question from the previous commits: gating publish on the SDK tests no longer risks a flaky test blocking an automatic release, because releases are no longer automatic. The itinerary moves out of an inline `node -e` heredoc into .github/scripts/build_release_itinerary.cjs. Besides being readable, it fixes a silent omission: the inline version iterated a hardcoded package order, so a package added later would have been left out of the Slack message while still being published. The script sorts known packages first and falls back to the workspace name for anything else. Verified: an unlisted `@e2b/desktop-cli` now appears rather than vanishing. The step is `continue-on-error` and the messages fall back to '• (itinerary unavailable)'. It only feeds Slack, so a hiccup in `changeset status` must not be the thing that fails preflight and blocks the release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

Ports the release-workflow fixes from
e2b-dev/E2B(#1463, #1469, #1484, #1589, #1619), which its own PR flagged as applying here too, and switches this repo to E2B's process: releases are cut by dispatching the workflow, not by merging tomain.Every release tag in this repo points at the commit before its own version bump (
@e2b/code-interpreter@2.7.0contains"version": "2.6.1") becausechangesets/actiontags whatever HEAD it publishes from, so the version commit now happens before the publish — safe here becausechangeset versionleavespnpm-lock.yamluntouched (no workspace package depends on a sibling), which also deletes theUpdate lock filestep; the push that follows is gated ongit tag --points-at HEADand reconciles a non-fast-forward with a merge rather than a rebase, the exact race that killed run 30018789653 with the versions already published, andgit add -Areplacescommit -am, which cannot stage theCHANGELOG.mdfileschangeset versionwrites fresh.Also included: Slack start/success notifications with a release itinerary (built by
.github/scripts/build_release_itinerary.cjs, non-blocking),is_release+changescollapsed into a singlepreflightjob that refuses to run offmain,always()→!cancelled()so the workflow can actually be cancelled, pnpm taken frompackageManagerand Node from.tool-versionsinstead of drifting pins, and::set-outputreplaced with$GITHUB_OUTPUT. Two latent bugs are fixed on the way:build-templateandreleasereadneeds.changes.outputs.*without depending onchanges, andrelease's!contains(needs.*.result, 'failure')could not see a template or docker failure — a failed upstream makes the test jobs skip, and skipped is not failure — so a broken template build could publish; every upstream job is now listed inneeds.Verified by rehearsing the git logic against a throwaway shallow clone and bare origin with a commit pushed mid-release: the tags land on the bump (
@e2b/code-interpreter@2.7.1→"version": "2.7.1"), stay reachable frommainthrough the merge fallback, and a publish that uploads nothing leaves origin untouched with the changesets intact. Left alone deliberately:release_candidates.yml(a different, label-driven shape) and the test gating, which still runs JS/Python tests only when the template changes.🤖 Generated with Claude Code