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)
- The
Dispatch package publish workflowsstep runs underset -Eeuo pipefailand routes every abort through afail()helper that emits a::error title=Release blocked for <tag>::annotation and writes the reason to the job summary, with anERRtrap as the backstop for the aborts errexit would otherwise report bare. No release path can now end in a bare exit code. - The reseal wait, the ancestry check, the generated-path allowlist and the tag placement each say which tag was affected and that nothing was published.
- A post-condition re-reads the tag and requires it to name the resealed commit before the publish workflows are dispatched.
Ordering (#1350)
The tag is now cut on a commit that is already sealed, instead of being sealed after the fact:
- Release Please cuts the JS SDK release as a draft
(
"draft": trueon the"."package inrelease-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. - The release step dispatches the reseal and waits for its generated-only descendant to land on trunk, exactly as before.
- It creates
refs/tags/js-sdk-<version>on that resealed commit —POST /git/refs, neverPATCH. Creating a tag is not something immutable releases restrict; moving one is. - 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. - 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:
- If the tag is not on the resealed commit, the release stays a draft. No public release exists, no tag is protected, and the version can simply be re-cut. Under the old ordering the same condition left a published release on a permanently unsealable tag.
- If publishing the draft were itself rejected, the tag already names the
sealed commit, so the release is completed by publishing the draft and
re-dispatching
publish-js-sdk.yml— the annotation says exactly that. - If
"draft": trueis ever removed from the config, Release Please publishes on the bump commit again and the tag is frozen there. The step detects this from the release's owndraftoutput and aborts before the ~40-minute reseal, naming the config key to restore.
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
0.1.5-beta.0and0.1.6-beta.0: deliberately skipped, not published. Both are pre-release beta versions superseded within hours by0.1.7-beta.0, which is published. Their tags carry published GitHub Releases and therefore cannot be moved onto a sealed commit, and publishing them from a recovery branch would put bytes on npm that no tag names — the exact provenance break the seal exists to prevent. The GitHub Releases remain as history; the versions are permanently absent from npm and the version line continues from0.1.7-beta.0.0.1.7-beta.0: published through the workflow's guarded branch-recovery path from the resealed commit2327a2a63, because its tag was already frozen by the time the reseal existed.
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:
- Reseal the Release Please pull request before it merges, so the merged
bump commit is itself sealed. This is the only option that removes the second
commit entirely, but it seals a branch head, and the seal is a property of
the exact tree: any trunk movement between the reseal and the merge restales
it. Release Please would then still cut and publish on that commit, so
losing the race is unrecoverable in exactly the way this ticket is about. It
also requires pushing non-Release-Please commits onto
release-please--branches--trunk, which the disposition checks reject as not being exact bot automation, and which the bot's own branch regeneration would discard. - Make the bump commit self-verifiable so no reseal is needed. This is the
same thing as the option above — the only way to make it self-verifiable is
to regenerate evidence inside it — with the same race, or else it requires
removing
package.jsonand the version-stamped fixtures from the evidence-neutral source digest, which is weakening the seal rather than reordering around it.
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.