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>
187 lines
9.3 KiB
TypeScript
187 lines
9.3 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
const withdrawWorkflow = new URL("../../../.github/workflows/release-withdraw.yml", import.meta.url);
|
|
const cutWorkflow = new URL("../../../.github/workflows/cut-release.yml", import.meta.url);
|
|
const prereleaseWorkflow = new URL("../../../.github/workflows/release-prerelease.yml", import.meta.url);
|
|
|
|
function section(content: string, start: string, end: string): string {
|
|
const startIndex = content.indexOf(start);
|
|
expect(startIndex, `missing section start: ${start}`).toBeGreaterThanOrEqual(0);
|
|
const endIndex = content.indexOf(end, startIndex + start.length);
|
|
expect(endIndex, `missing section end: ${end}`).toBeGreaterThan(startIndex);
|
|
return content.slice(startIndex, endIndex);
|
|
}
|
|
|
|
describe("release withdraw workflow", () => {
|
|
it("[P0] refuses a version that already shipped stable", async () => {
|
|
const withdraw = await readFile(withdrawWorkflow, "utf8");
|
|
const guard = section(withdraw, " guard:", " withdraw:");
|
|
|
|
// The whole premise is "cut but never shipped". Withdrawing a shipped
|
|
// release would delete the branch of something users are running, and the
|
|
// operator would think they had retracted it. That case is
|
|
// release-rollback's, and the error has to say so.
|
|
expect(guard).toContain("stable/latest/metadata.json");
|
|
expect(guard).toContain("stable/versions/$VERSION/metadata.json");
|
|
expect(guard).toContain("use release-rollback to retract a shipped release");
|
|
expect(guard).not.toContain("secrets.");
|
|
});
|
|
|
|
it("[P0] refuses to delete a branch carrying non-bot commits", async () => {
|
|
const withdraw = await readFile(withdrawWorkflow, "utf8");
|
|
const step = section(
|
|
withdraw,
|
|
" - name: Assert the branch holds nothing but cut commits",
|
|
" - name: Assert nobody is still working against the branch",
|
|
);
|
|
|
|
// A fix backported here and nowhere else dies with the branch. Only the
|
|
// two bots cut-release commits as may have written a branch-only commit.
|
|
expect(step).toContain("--not origin/main");
|
|
expect(step).toContain("open-design-bot|open-design-release-bot\\[bot\\]");
|
|
expect(step).toContain("would destroy them");
|
|
// The SHA must be captured before anything destructive so the branch is
|
|
// recoverable.
|
|
expect(step).toContain('echo "sha=$sha" >> "$GITHUB_OUTPUT"');
|
|
});
|
|
|
|
it("[P0] refuses while PRs still target the branch or carry its label", async () => {
|
|
const withdraw = await readFile(withdrawWorkflow, "utf8");
|
|
const step = section(
|
|
withdraw,
|
|
" - name: Assert nobody is still working against the branch",
|
|
" - name: Resolve the prerelease pointer",
|
|
);
|
|
|
|
expect(step).toContain("--base \"$BRANCH\" --state open");
|
|
expect(step).toContain("--label \"$LABEL\" --state open");
|
|
expect(step).toContain("exit 1");
|
|
});
|
|
|
|
it("[P0] will not move the prerelease pointer blind", async () => {
|
|
const withdraw = await readFile(withdrawWorkflow, "utf8");
|
|
const step = section(
|
|
withdraw,
|
|
" - name: Resolve the prerelease pointer",
|
|
" - name: Setup pnpm",
|
|
);
|
|
|
|
// Detected from the live pointer, never taken on trust from the input: the
|
|
// rollback only happens when latest actually belongs to the withdrawn
|
|
// version, and then a target is mandatory rather than silently skipped.
|
|
expect(step).toContain("prerelease/latest/metadata.json");
|
|
expect(step).toContain('"$VERSION"-prerelease.*) belongs=true');
|
|
expect(step).toContain("but prerelease_to_version is empty");
|
|
expect(withdraw).toContain("RELEASE_ROLLBACK_FROM_VERSION: ${{ steps.pre.outputs.from }}");
|
|
});
|
|
|
|
it("[P0] undoes the cut in an order that cannot strand a label", async () => {
|
|
const withdraw = await readFile(withdrawWorkflow, "utf8");
|
|
|
|
const pointer = withdraw.indexOf("- name: Repoint prerelease latest");
|
|
const label = withdraw.indexOf("- name: Delete the backport label");
|
|
const branch = withdraw.indexOf("- name: Delete the release branch");
|
|
expect(pointer).toBeGreaterThanOrEqual(0);
|
|
// A label outliving its branch invites a PR whose backport then targets a
|
|
// branch that is gone, so the label goes first; the branch is last because
|
|
// it is the hardest step to undo.
|
|
expect(pointer).toBeLessThan(label);
|
|
expect(label).toBeLessThan(branch);
|
|
});
|
|
|
|
it("[P0] plans before it destroys", async () => {
|
|
const withdraw = await readFile(withdrawWorkflow, "utf8");
|
|
const inputs = section(withdraw, " dry_run:", " notify_feishu:");
|
|
|
|
// Unlike release-rollback, a withdrawal is never the urgent case, so the
|
|
// safe mode is the default one.
|
|
expect(inputs).toContain("default: true");
|
|
for (const step of ["Delete the backport label", "Delete the release branch"]) {
|
|
const body = section(withdraw, ` - name: ${step}`, " env:");
|
|
expect(body, `${step} must be gated on dry_run`).toContain("!inputs.dry_run");
|
|
}
|
|
});
|
|
|
|
it("[P0] makes the dry run validate the pointer target instead of skipping it", async () => {
|
|
const withdraw = await readFile(withdrawWorkflow, "utf8");
|
|
|
|
// A dry run that skipped the planner validated nothing about
|
|
// `prerelease_to_version` beyond its syntax: an absent, half-published or
|
|
// feed-less target passed the plan and failed for the first time during the
|
|
// real run — after the label and the branch were already gone. The planner
|
|
// is the validation, so it runs either way and `RELEASE_DRY_RUN` decides
|
|
// only whether it writes.
|
|
for (const step of ["Setup pnpm", "Install dependencies", "Repoint prerelease latest"]) {
|
|
const body = section(withdraw, ` - name: ${step}`, "\n\n");
|
|
expect(body, `${step} must not be skipped on a dry run`).not.toContain("!inputs.dry_run");
|
|
expect(body, `${step} runs only when the pointer needs moving`).toContain(
|
|
"steps.pre.outputs.rollback == 'true'",
|
|
);
|
|
}
|
|
const planner = section(withdraw, " - name: Repoint prerelease latest", " run:");
|
|
expect(planner).toContain("RELEASE_DRY_RUN: ${{ inputs.dry_run }}");
|
|
});
|
|
|
|
it("[P0] queues against the only other writer of prerelease/latest", async () => {
|
|
const [withdraw, prerelease] = await Promise.all([
|
|
readFile(withdrawWorkflow, "utf8"),
|
|
readFile(prereleaseWorkflow, "utf8"),
|
|
]);
|
|
|
|
// A private concurrency group serialises this workflow against nothing.
|
|
// release-prerelease is the only other writer of `prerelease/latest`, and a
|
|
// publish that moved the pointer after `Resolve the prerelease pointer` read
|
|
// it would interleave its multi-object writes with the rollback's, leaving
|
|
// `latest` mixing two candidates. If the publisher's group is ever renamed,
|
|
// this pairing has to be rechecked rather than silently broken.
|
|
expect(prerelease).toContain("group: open-design-release-prerelease");
|
|
expect(withdraw).toContain("group: open-design-release-prerelease");
|
|
expect(withdraw).toContain("cancel-in-progress: false");
|
|
expect(withdraw).not.toContain("group: open-design-release-withdraw");
|
|
|
|
// The lock has to be held for the whole run, not per job. A trailing
|
|
// `notify-release-feishu` publish — the cut push and the preview-manifest
|
|
// push both start one, so this is the ordinary timeline rather than a manual
|
|
// re-run — then cannot enter the publisher until `release/vX.Y.Z` is gone,
|
|
// and its checkout of a deleted ref fails closed instead of republishing the
|
|
// withdrawn commit. A job-level group would release the lock before the
|
|
// delete and lose that.
|
|
expect(withdraw.match(/^concurrency:$/gm)).toHaveLength(1);
|
|
expect(withdraw).not.toMatch(/^ {4}concurrency:/m);
|
|
});
|
|
|
|
it("[P0] refuses to delete a branch whose tip moved after it was judged", async () => {
|
|
const withdraw = await readFile(withdrawWorkflow, "utf8");
|
|
const step = section(withdraw, " - name: Delete the release branch", " - name: Notify Feishu");
|
|
|
|
// Every finding that made the branch safe to delete was established against
|
|
// the captured SHA, and a human then read a dry run in between. A tip that
|
|
// moved means those findings describe a branch that no longer exists — and
|
|
// the likeliest new commit is the backport somebody needed. GitHub's ref
|
|
// delete has no expected-SHA precondition, so the compare is explicit and
|
|
// has to come before the delete.
|
|
const compare = step.indexOf("git/ref/heads/$BRANCH");
|
|
const remove = step.indexOf("-X DELETE");
|
|
expect(compare).toBeGreaterThanOrEqual(0);
|
|
expect(compare).toBeLessThan(remove);
|
|
expect(step).toContain('if [ "$tip" != "$SHA" ]; then');
|
|
expect(step).toContain("exit 1");
|
|
});
|
|
|
|
it("[P1] reverses exactly what cut-release created", async () => {
|
|
const [withdraw, cut] = await Promise.all([readFile(withdrawWorkflow, "utf8"), readFile(cutWorkflow, "utf8")]);
|
|
|
|
// Same label string, same App credentials — the branch delete needs the
|
|
// bypass actor on the release/v* deletion rule, which is the App that cut
|
|
// the branch in the first place.
|
|
expect(cut).toContain('gh label create "backport release/v$VERSION"');
|
|
expect(withdraw).toContain("LABEL: backport release/v${{ inputs.version }}");
|
|
for (const secret of ["RELEASE_BOT_APP_ID", "RELEASE_BOT_PRIVATE_KEY"]) {
|
|
expect(cut).toContain(`secrets.${secret}`);
|
|
expect(withdraw).toContain(`secrets.${secret}`);
|
|
}
|
|
});
|
|
});
|