Release tag sealing under immutable releases

Issues #1337 and #1350. Three consecutive JS SDK releases produced GitHub Releases that never published to npm, and the release log said only Process completed with exit code 1. This records what actually happened, what the pipeline now does about it, and the disposition of the two versions left stranded.

What the release tag has to satisfy

release-seal check requires a release tag to name a commit whose derived sample artifacts are sealed to that commit and stamp that release's version. Release Please's version-bump commit can never satisfy that: it edits package.json and the version-stamped fixtures listed in release-please-config.json, all inside the evidence-neutral source digest, so a pristine checkout of it fails samples:verify with a gate-receipt digest mismatch, and the sample artifacts it carries still self-report the previous version.

regenerate-derived-artifacts.yml produces a generated-only descendant that is sealed. release-please.yml waits for that reseal and puts the js-sdk-* tag on it before anything publishes. Two checks make tagging the descendant honest: the merge base of the descendant must be the release commit, and every changed path must be on the generated-output allowlist, so only regenerated bytes can differ and the tag still names exactly this release's source.

What went wrong

The three failures had different mechanisms and one shared symptom — no diagnostic naming the tag.

Release Release Please run Mechanism
js-sdk-v0.1.5-beta.0 31732034723 The awaited reseal run failed. gh run watch --exit-status aborted the step upstream of the tag move, which therefore never executed.
js-sdk-v0.1.6-beta.0 31732155436 Same as above: the reseal run failed and the step aborted before the tag move.
js-sdk-v0.1.7-beta.0 31748631384 The tag move executed and was rejected: gh: Repository rule violations found / Cannot update this protected ref. (HTTP 422).

The third is the structural one. Org-enforced immutable releases protect any tag that already carries a published release, and Release Please publishes the release on the version-bump commit. From that moment the tag cannot be moved, so the "cut the release, then seal the tag" ordering cannot complete. This is the same class of defect as the frozen rolling sample-bundles-latest release in #1325: a mutate-later pattern invalidated retroactively by immutable releases.

Also worth recording, because it wasted diagnosis time: git push --force --dry-run reported the tag move would succeed. Dry-run pushes do not evaluate ruleset or immutability rules, so a green dry run is not evidence that a push will land.

What the pipeline does now

Two changes, in the order they were made. The first was loudness, because a release step that silently does not fire is worse than one that fails. The second was the ordering itself, because loudness only converts an unrecoverable release into a blocked one.

Loudness (#1349)

Ordering (#1350)

The tag is now cut on a commit that is already sealed, instead of being sealed after the fact:

  1. Release Please cuts the JS SDK release as a draft ("draft": true on the "." package in release-please-config.json). GitHub does not create a Git tag for a draft release, and immutability does not attach until a release is published — so at this point there is no tag and nothing is protected.
  2. The release step dispatches the reseal and waits for its generated-only descendant to land on trunk, exactly as before.
  3. It creates refs/tags/js-sdk-<version> on that resealed commit — POST /git/refs, never PATCH. Creating a tag is not something immutable releases restrict; moving one is.
  4. It re-reads the tag (the #1349 post-condition), and only if the tag names the resealed commit does it publish the draft with PATCH /releases/<id> -F draft=false.
  5. It re-reads the tag again after publication. This is not a duplicate check: GitHub creates the tag itself, at the release's target_commitish — the unsealed bump commit — when a draft is published while its tag does not exist. Publication is therefore the last operation that can still misplace a release.

Nothing in the path writes to an existing ref or edits a published release, so nothing in it can be rejected by immutable releases. This is also the flow GitHub recommends for immutable releases in its own documentation — "create releases as drafts first, attach all assets, and then publish" — applied to the tag's commit rather than to release assets.

The verified GitHub behaviours this depends on were checked against a real repository before the change was written, not inferred:

Behaviour Observed
Creating a draft release for a tag that does not exist No tag ref is created (GET /git/ref/tags/<tag> → 404)
Creating the tag afterwards, on a different commit Accepted
Publishing that draft Release binds to the existing tag; target_commitish is ignored and the tag still resolves to the commit we chose
Publishing a draft whose tag does not exist GitHub creates the tag at target_commitish — the unsealed bump commit
POST /git/refs for an existing ref 422 Reference already exists, which the step treats as an idempotent resume only when the existing tag already names the resealed commit

One thing in that path is not proven: PATCH /releases/<id> -F draft=false was exercised on a repository that could not have immutable releases enabled, because the setting is exposed nowhere in the REST or GraphQL API (only the derived immutable flag on a release object), and manufacturing a permanent junk release here to test it is not reversible under immutability. Two things make that acceptable: publishing performs no ref write at all when the tag already exists, so there is nothing for ref protection to reject; and if it were rejected the state is recoverable, because the tag already names the sealed commit and the release is still a draft.

One consequence had to be handled explicitly. A draft release has no tag, and Release Please's release iterator skips a release with no tag commit, so during the window between the draft being cut and its tag being created there is no previous release for Release Please to find. The reseal's own automation merge is a trunk push inside that window, and a Release Please run there would rebuild the next release pull request from far too much history. The release-please job therefore carries a concurrency group with cancel-in-progress: false, so such a run simply queues and executes once the tag exists.

Failure modes

The two post-conditions are still able to fail, and the reordering is what makes their failures cheap:

test/scripts/release-please-tag-seal.test.mjs executes that shell against a stubbed gh and asserts the whole ordering — including that the trace of side effects is exactly reseal → create tag → publish release → publish npm — plus every abort above. It runs in SDK CI, so the guards cannot be removed without failing a check.

Disposition of the stranded versions

Residual

0.1.5-beta.0 and 0.1.6-beta.0 remain unpublishable; their tags carry published releases and are frozen. Nothing about the new ordering can repair a release that was already cut under the old one — it only guarantees that the next one is cut correctly.

The other two options considered in #1350 were rejected on merit:

The chosen ordering leaves release-please--branches--trunk untouched: the fix lives in release-please-config.json and release-please.yml on trunk, both read fresh on every run, so it survives any regeneration of the bot branch and applies to the release pull request that is already open.