mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* 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>