chore(release): script the 0a.0b PR re-home with a verified read-back (#7312)

The parallel-cycle model hands the frozen release/vX to the captain and cuts
release/vX+1 for everyone else. Phase 0a.0b step 3 then re-homes every open PR
onto the new cycle — today as a hand-run loop of gh pr edit --base.

Three things make that loop unreliable at exactly the moment it matters:

  1. gh pr edit --base FAILS SILENTLY (v3.8.42). It exits 0 and leaves the base
     untouched, so every edit needs a gh pr view --json baseRefName read-back.
     A human mid-release skips that.
  2. gh pr list caps at 30 results by default. A loop written without --limit
     re-homes the first 30 of 148 and reports success.
  3. Volume: the v3.8.49 freeze had 148 open PRs — roughly 450 API calls across
     edit, verify and comment.

The script does the read-back on every PR, uses --limit 300, is idempotent (a
PR already on the next base is skipped, so a resumed release re-runs safely),
refuses to start when the next branch does not exist yet, and exits non-zero
listing any PR whose retarget did not take.

It also prints the reminder that it cannot solve the other half: PRs opened
AFTER it runs. Those need the repo default_branch pointed at the live cycle —
contributors open PRs against the default branch, and while that stays on main
they never target a release branch at all (6 such PRs on 2026-07-15).

classify() is pure and unit-tested: retarget open and draft PRs on the frozen
branch; never touch main (the release PR's own lane), an older shipped release,
or a PR already re-homed.

Refs #7307
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-15 19:48:42 -03:00
committed by GitHub
parent d3f88716bb
commit d3a9ad557d
3 changed files with 215 additions and 0 deletions

View File

@@ -0,0 +1 @@
- **chore(release):** add `scripts/release/rehome-open-prs.mjs` — the Phase 0a.0b PR re-home, scripted with a read-back after every retarget. `gh pr edit --base` exits 0 without applying (v3.8.42), `gh pr list` silently caps at 30, and the v3.8.49 freeze had 148 open PRs to move — none of which a hand-run loop survives reliably.

View File

@@ -0,0 +1,158 @@
#!/usr/bin/env node
// scripts/release/rehome-open-prs.mjs
//
// Parallel-cycle PR re-home (generate-release Phase 0a.0b, step 3).
// Retargets every open PR whose base is the FROZEN release/v<CURRENT> onto the
// freshly cut release/v<NEXT>, so development keeps flowing while the captain
// owns the frozen branch. Design: _tasks/release-flow/2026-07-04_proposta-ciclo-paralelo-v2.md
//
// Usage:
// node scripts/release/rehome-open-prs.mjs <current> <next> [--dry-run]
// e.g. node scripts/release/rehome-open-prs.mjs 3.8.49 3.8.50
//
// WHY THIS EXISTS AS A SCRIPT AND NOT A `gh pr edit` LOOP IN THE SKILL:
//
// 1. `gh pr edit --base` FAILS SILENTLY (v3.8.42 lesson). It exits 0 while
// leaving the base untouched — so every edit MUST be read back with
// `gh pr view --json baseRefName`. A hand-run loop skips that under
// fatigue; this does not.
// 2. Volume. At the v3.8.49 freeze there were 148 open PRs on the release
// branch — ~450 API calls between edit, verify and comment. That is not a
// thing a human does reliably at 2am mid-release.
// 3. `gh pr list` defaults to **30 results**. A loop written without
// `--limit` silently re-homes the first 30 and reports success.
//
// Idempotent: a PR already based on release/v<next> is skipped, so a resumed
// release re-runs this safely.
//
// NOT covered here (by design): PRs opened AFTER this runs. Those are handled
// by flipping the repo's default_branch to release/v<next> at 0a.0b — see the
// skill. Contributors open PRs against the default branch; if that still points
// at `main`, they never target a release branch at all.
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const REPO = "diegosouzapw/OmniRoute";
function gh(args, { allowFail = false } = {}) {
try {
return execFileSync("gh", args, { encoding: "utf8" }).trim();
} catch (err) {
if (allowFail) return null;
throw new Error(`gh ${args.join(" ")} failed: ${err.stderr || err.message}`);
}
}
/**
* Pure: classify what should happen to a PR given its current base.
* Split out so the decision is unit-testable without touching the network.
*/
export function classify(pr, currentBase, nextBase) {
if (pr.baseRefName === nextBase) return { action: "skip", reason: "already re-homed" };
if (pr.baseRefName !== currentBase) {
return { action: "skip", reason: `base is ${pr.baseRefName}, not the frozen branch` };
}
if (pr.isDraft) return { action: "retarget", reason: "draft — retarget anyway, it still needs a home" };
return { action: "retarget", reason: "open PR on the frozen branch" };
}
function main(argv) {
const dryRun = argv.includes("--dry-run");
const [current, next] = argv.filter((a) => !a.startsWith("--"));
if (!current || !next) {
console.error("Usage: node scripts/release/rehome-open-prs.mjs <current> <next> [--dry-run]");
console.error(" e.g. node scripts/release/rehome-open-prs.mjs 3.8.49 3.8.50");
process.exit(2);
}
const currentBase = `release/v${current}`;
const nextBase = `release/v${next}`;
// The next branch MUST exist before we point anything at it, or every edit
// 422s and we have re-homed nothing while reporting progress.
const exists = gh(["api", `repos/${REPO}/branches/${nextBase}`, "--jq", ".name"], {
allowFail: true,
});
if (!exists) {
console.error(`${nextBase} does not exist on origin — cut it first (0a.0b step 1).`);
process.exit(1);
}
// --limit 300: `gh pr list` returns 30 by default. Without this the loop
// silently re-homes a third of the queue and exits 0.
const raw = gh([
"pr", "list", "--repo", REPO, "--state", "open", "--limit", "300",
"--base", currentBase, "--json", "number,title,isDraft,baseRefName",
]);
const prs = JSON.parse(raw);
console.log(`${prs.length} open PR(s) on ${currentBase}${nextBase}${dryRun ? " [DRY RUN]" : ""}\n`);
const failed = [];
let moved = 0;
let skipped = 0;
for (const pr of prs) {
const { action, reason } = classify(pr, currentBase, nextBase);
if (action === "skip") {
console.log(` · #${pr.number} skipped — ${reason}`);
skipped++;
continue;
}
if (dryRun) {
console.log(` → #${pr.number} would retarget — ${reason}`);
moved++;
continue;
}
gh(["pr", "edit", String(pr.number), "--repo", REPO, "--base", nextBase], { allowFail: true });
// The read-back is the whole point: `gh pr edit --base` exits 0 on failure.
const actual = gh(
["pr", "view", String(pr.number), "--repo", REPO, "--json", "baseRefName", "--jq", ".baseRefName"],
{ allowFail: true }
);
if (actual !== nextBase) {
console.error(` ✖ #${pr.number} STILL on ${actual ?? "?"} — retarget did not take`);
failed.push({ number: pr.number, actual });
continue;
}
gh([
"pr", "comment", String(pr.number), "--repo", REPO,
"--body",
`Re-homed to \`${nextBase}\`: v${current} entered its release freeze, so the branch now belongs ` +
`to the release captain and development continues on the next cycle. Nothing is wrong with this ` +
`PR — it just needed a live base. No action needed from you; CI will re-run against the new base.`,
], { allowFail: true });
console.log(` ✔ #${pr.number}${nextBase}`);
moved++;
}
console.log(`\n${moved} re-homed, ${skipped} skipped, ${failed.length} failed`);
if (failed.length) {
console.error(
`\n${failed.length} PR(s) did not take the retarget: ${failed.map((f) => `#${f.number}`).join(", ")}\n` +
` Re-run this script (it is idempotent) or retarget those by hand and verify with\n` +
` gh pr view <N> --json baseRefName`
);
process.exit(1);
}
if (!dryRun && moved > 0) {
console.log(
`\nReminder (0a.0b): flip the repo default_branch so PRs opened from now on are born on the\n` +
`right base — this script cannot reach PRs that do not exist yet:\n` +
` gh api -X PATCH repos/${REPO} -f default_branch="${nextBase}"`
);
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main(process.argv.slice(2));
}

View File

@@ -0,0 +1,56 @@
// Guard for scripts/release/rehome-open-prs.mjs — the parallel-cycle PR re-home
// (generate-release Phase 0a.0b step 3).
//
// The script exists because `gh pr edit --base` fails SILENTLY (v3.8.42): it exits 0
// while leaving the base untouched. The network side of that is verified by a read-back
// in the script itself; what is testable here is the decision — which PRs get retargeted
// and which are left alone. Getting that wrong in either direction is expensive:
// - retargeting a PR that was never on the frozen branch drags unrelated work into the cycle
// - skipping one strands a contributor on a branch the captain has taken over
import test from "node:test";
import assert from "node:assert/strict";
import { classify } from "../../scripts/release/rehome-open-prs.mjs";
const FROZEN = "release/v3.8.49";
const NEXT = "release/v3.8.50";
test("retargets an open PR sitting on the frozen release branch", () => {
const { action } = classify(
{ number: 1, baseRefName: FROZEN, isDraft: false },
FROZEN,
NEXT
);
assert.equal(action, "retarget");
});
test("retargets a DRAFT on the frozen branch — a draft still needs a live base", () => {
const { action } = classify({ number: 2, baseRefName: FROZEN, isDraft: true }, FROZEN, NEXT);
assert.equal(action, "retarget");
});
test("skips a PR already re-homed — the script must be idempotent for a resumed release", () => {
const { action, reason } = classify(
{ number: 3, baseRefName: NEXT, isDraft: false },
FROZEN,
NEXT
);
assert.equal(action, "skip");
assert.match(reason, /already re-homed/);
});
test("never touches a PR based on main — that is the release PR's own lane", () => {
const { action, reason } = classify({ number: 4, baseRefName: "main", isDraft: false }, FROZEN, NEXT);
assert.equal(action, "skip");
assert.match(reason, /not the frozen branch/);
});
test("never touches a PR based on an older, already-shipped release", () => {
const { action } = classify(
{ number: 5, baseRefName: "release/v3.8.47", isDraft: false },
FROZEN,
NEXT
);
assert.equal(action, "skip");
});