Files
d9dd6fefa7 feat(release): add one-dispatch withdrawal for an unshipped cut (#8509)
* feat(release): add one-dispatch withdrawal for an unshipped cut

A release that was cut but will not ship had no undo. cut-release creates a
branch, a backport label, a Feishu card, and — through the prerelease build its
push triggers — moves prerelease/latest onto the new line. Abandoning that
version meant reversing four surfaces by hand, and the one that actually
matters is easy to forget: internal validators auto-update, so they sit on a
package nobody intends to release.

release-withdraw reverses each, in the order that leaves the least mess if a
later step fails: repoint prerelease/latest at an older published prerelease,
delete the label, delete the branch, post the matching withdrawal card. The
branch delete uses the release App token because the release/v* ruleset blocks
deletion and that App is one of its bypass actors — the same token that created
the branch.

It refuses more than it does. A version that reached stable is not an unshipped
cut, and the error names release-rollback instead. A branch carrying any commit
that is not on main and not from the release bots would lose work, so it stops.
Open PRs targeting the branch, or still carrying its label, stop it. The
prerelease pointer is read live rather than trusted from the input, and is only
moved when it actually belongs to the withdrawn version — at which point a
target becomes mandatory rather than silently skipped. dry_run defaults to true:
a withdrawal is never the urgent case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(release): make the withdrawal queue behind the pointer's other writer, and give the dry run teeth

Both findings from review.

Concurrency. `open-design-release-withdraw` was a group of one, so it serialised
this workflow against nothing, and the header comment claimed a guarantee it
never had. `release-prerelease.yml` is the only other writer of
`prerelease/latest` and holds `open-design-release-prerelease`; the withdrawal
now joins it.

The publish this races is the ordinary cut timeline, not a manual re-run: the cut
push and the preview-manifest push that follows each start
`notify-release-feishu` -> `release-prerelease`. Three failures came from the
unique group. Reading the pointer before an in-flight publish finishes resolves
`belongs=false`, so the repoint is skipped and the publish then installs the
withdrawn version after a green run. Interleaved writes mix `latest`, because
the publisher writes metadata first and feeds second while `applyLatestRollback`
writes feeds first and metadata last, and every PUT is unconditional. And a
publish landing *after* a repoint restores the abandoned pointer, because the
publisher's CAS refuses only backward moves.

The group is workflow-level and must stay that way, which is the part worth
keeping in mind: it is held through the branch delete, so a queued trailing
publish cannot enter the publisher until `release/vX.Y.Z` is gone and then fails
closed on a checkout of a deleted ref. A job-level group would release the lock
first and lose that. A P0 assertion pins the shared string against
release-prerelease — renaming the publisher's group now breaks a test rather
than the invariant — and pins the single workflow-level block.

`cut-release.yml` stays out of the group: it writes no R2 object of its own, and
what needed serialising was the publisher it triggers.

The branch also gets a compare-and-delete, re-reading the tip and refusing if it
has moved off the SHA that "nothing but cut commits" and "no open PRs" were
established against. Not a substitute for the lock, which is what fixes the
publish race — this covers the likelier window the lock does not see, a backport
landing while a human reads the dry run. GitHub's ref delete takes no
expected-SHA precondition, so the compare is explicit.

Dry run. `Setup pnpm`, `Install dependencies` and `Repoint prerelease latest`
were all gated on `!inputs.dry_run`, which removed the credentialed half of the
validation from the mode that exists to validate. `prerelease_to_version` only
had to be syntactically plausible: an absent, incompletely published or
feed-less target, or one below the prerelease launcher floor, passed the plan and
failed for the first time during the real run. It still fails before the label
and branch deletes, so nothing was destroyed by it — but the dry run is the path
this workflow tells the operator to trust, and `[P0] plans before it destroys`
required `!inputs.dry_run` on the repoint step, locking the skip in.

The three steps now run whenever the pointer needs moving and the planner takes
`RELEASE_DRY_RUN: ${{ inputs.dry_run }}`, matching how release-rollback already
wires it. "The dry run passed" now means the real run has nothing left to
discover, and the header and the step summary say so. The two deletions and the
Feishu card stay gated on the input.

Each of the five behaviours was mutation-checked: reverted at exactly one site
(three for the `if:` gate), exactly one test went red. Workflows re-linted with
the CI-pinned pair, actionlint 1.7.12 with shellcheck 0.11.0 on PATH, proven
wired by a probe workflow that reports SC2086.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 07:11:02 +00:00
..