From b31e53d49b9e46cf1639fe2458e4ad5292c61d54 Mon Sep 17 00:00:00 2001 From: zodyp Date: Sat, 19 Sep 2026 13:43:43 -0300 Subject: [PATCH] feat(release): one command to lock a released branch, and prove it locked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lock-released-branch.yml` cannot run: it needs a `BRANCH_LOCK_TOKEN` secret, `GITHUB_TOKEN` cannot be granted the `Administration` scope, and only a PAT or fine-grained token can hold it. The secret does not exist, so the workflow has failed on every release and no release branch has ever been locked automatically — v3.8.54 shipped with its branch still writable and was locked by hand afterwards. Creating that token is the repository owner's action, so this is the fallback, and it is a script rather than a line in a runbook for one reason: the failure Hard Rule #18 exists to prevent was a SILENT one. In the v3.8.3 incident six commits landed on an already-shipped version because nobody checked the lock had applied. A raw `gh api` PUT that half-works looks exactly like one that worked. So the script always re-reads the protection from GitHub afterwards and exits non-zero unless `lock_branch` and `enforce_admins` both come back true. A green exit means the branch is genuinely read-only — not that a request was accepted. npm run release:lock -- 3.8.54 # lock and verify npm run release:lock -- 3.8.54 --check # verify only npm run release:lock -- 3.8.54 --unlock # reopen It accepts `3.8.54`, `v3.8.54` or `release/v3.8.54`, refuses anything that is not a version rather than guessing at a branch name, and percent-encodes the slash — an unencoded one makes GitHub read the branch as a nested path. The tests assert the behaviour that matters: that a branch GitHub reports as unlocked is never reported as locked, and that the read-back actually queries the protection endpoint instead of echoing what was sent. Verification (isolated DATA_DIR/HOME/USERPROFILE/APPDATA): tests/unit/release-lock-branch-script.test.ts 7 pass / 0 fail eslint --max-warnings=0 exit 0 prettier clean Co-Authored-By: Claude Opus 5 --- package.json | 1 + scripts/release/lock-released-branch.mjs | 124 ++++++++++++++++++ tests/unit/release-lock-branch-script.test.ts | 71 ++++++++++ 3 files changed, 196 insertions(+) create mode 100644 scripts/release/lock-released-branch.mjs create mode 100644 tests/unit/release-lock-branch-script.test.ts diff --git a/package.json b/package.json index f567c25be28..849386cfcc4 100644 --- a/package.json +++ b/package.json @@ -286,6 +286,7 @@ "postbuild": "node scripts/build/colocate-standalone.mjs", "release:contributors": "node scripts/release/gen-contributors.mjs", "release:uncovered": "node scripts/release/list-uncovered-commits.mjs", + "release:lock": "node scripts/release/lock-released-branch.mjs", "test:coverage:runner": "node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", "test:unit:serial": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/unit/serial/**/*.test.ts\"", "alibaba:sync-allowlist": "node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs" diff --git a/scripts/release/lock-released-branch.mjs b/scripts/release/lock-released-branch.mjs new file mode 100644 index 00000000000..3f5a660307f --- /dev/null +++ b/scripts/release/lock-released-branch.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * Lock a released branch, by hand, with confirmation. + * + * `lock-released-branch.yml` is supposed to do this automatically when a Release is + * published, but it needs a `BRANCH_LOCK_TOKEN` secret: `GITHUB_TOKEN` cannot be granted + * the `Administration` scope, and only a PAT or fine-grained token can hold it. Until + * that secret exists the workflow fails on every release and NO release branch is locked + * — v3.8.54 shipped with its branch still writable. + * + * So this is the fallback, and it exists as a script rather than a line in a runbook for + * one reason: the failure that Hard Rule #18 was written for was a SILENT one. In the + * v3.8.3 incident six commits landed on an already-shipped version because nobody checked + * that the lock had actually applied. A raw `gh api` call that half-works looks exactly + * like one that worked. + * + * This script therefore always re-reads the protection afterwards and exits non-zero if + * the lock is not confirmed. A green exit here means the branch is genuinely read-only. + * + * Usage: + * node scripts/release/lock-released-branch.mjs 3.8.54 + * node scripts/release/lock-released-branch.mjs v3.8.54 --repo owner/name + * node scripts/release/lock-released-branch.mjs 3.8.54 --check # verify only + * node scripts/release/lock-released-branch.mjs 3.8.54 --unlock # reopen the branch + * + * Requires the `gh` CLI, authenticated as someone with admin on the repository. + */ +import { execFileSync } from "node:child_process"; + +const DEFAULT_REPO = "LMPrado-DZ23/OmniRoute"; + +/** The protection payload, identical to the one lock-released-branch.yml applies. */ +export const PROTECTION = { + required_status_checks: null, + enforce_admins: true, + required_pull_request_reviews: null, + restrictions: null, + lock_branch: true, + allow_force_pushes: false, + allow_deletions: false, +}; + +/** `3.8.54`, `v3.8.54` and `release/v3.8.54` all mean the same branch. */ +export function branchForVersion(version) { + const trimmed = String(version ?? "").trim(); + if (!trimmed) throw new Error("no version given"); + const bare = trimmed.replace(/^release\//, "").replace(/^v/, ""); + if (!/^\d+\.\d+\.\d+([.-][A-Za-z0-9.-]+)?$/.test(bare)) { + throw new Error(`not a version: ${trimmed}`); + } + return `release/v${bare}`; +} + +/** GitHub wants the slash in a branch name percent-encoded inside the path. */ +export function protectionPath(repo, branch) { + return `repos/${repo}/branches/${encodeURIComponent(branch)}/protection`; +} + +function gh(args, { input } = {}) { + return execFileSync("gh", args, { + encoding: "utf-8", + input, + stdio: ["pipe", "pipe", "pipe"], + }); +} + +/** True only when GitHub itself reports the branch as locked. */ +export function readLockState(repo, branch, run = gh) { + const out = run([ + "api", + protectionPath(repo, branch), + "--jq", + "{lock:.lock_branch.enabled, admins:.enforce_admins.enabled}", + ]); + return JSON.parse(out); +} + +function main(argv) { + const args = argv.slice(2); + const version = args.find((a) => !a.startsWith("--")); + const repoFlag = args.indexOf("--repo"); + const repo = repoFlag >= 0 ? args[repoFlag + 1] : DEFAULT_REPO; + const checkOnly = args.includes("--check"); + const unlock = args.includes("--unlock"); + + if (!version) { + console.error( + "usage: lock-released-branch.mjs [--repo owner/name] [--check|--unlock]" + ); + process.exit(2); + } + + const branch = branchForVersion(version); + const path = protectionPath(repo, branch); + + if (unlock) { + gh(["api", "-X", "DELETE", path]); + console.log(`🔓 ${branch} reopened. Re-lock it before the next release.`); + return; + } + + if (!checkOnly) { + console.log(`Locking ${branch} in ${repo}…`); + gh(["api", "-X", "PUT", path, "--input", "-"], { input: JSON.stringify(PROTECTION) }); + } + + // Never trust the PUT. The whole point of this script is the read-back. + const state = readLockState(repo, branch); + if (state.lock !== true || state.admins !== true) { + console.error( + `::error::${branch} is NOT locked (lock_branch=${state.lock}, enforce_admins=${state.admins}). ` + + `Do not consider the release closed until this reports true/true.` + ); + process.exit(1); + } + console.log(`✅ ${branch} is locked and enforced for admins.`); +} + +if ( + import.meta.url === `file://${process.argv[1]}` || + process.argv[1]?.endsWith("lock-released-branch.mjs") +) { + main(process.argv); +} diff --git a/tests/unit/release-lock-branch-script.test.ts b/tests/unit/release-lock-branch-script.test.ts new file mode 100644 index 00000000000..7728e9dbfea --- /dev/null +++ b/tests/unit/release-lock-branch-script.test.ts @@ -0,0 +1,71 @@ +/** + * Guards the fallback that stands in for `lock-released-branch.yml` while the + * `BRANCH_LOCK_TOKEN` secret does not exist. + * + * The bug this script exists to prevent is a SILENT one: in the v3.8.3 incident six + * commits landed on an already-shipped version because the lock had not applied and + * nobody checked. So the behaviour worth testing is not "does it send a PUT" — it is + * "does it refuse to report success when GitHub says the branch is not locked". + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + branchForVersion, + protectionPath, + readLockState, + PROTECTION, +} from "../../scripts/release/lock-released-branch.mjs"; + +test("a version is accepted however the operator happens to write it", () => { + for (const input of ["3.8.54", "v3.8.54", "release/v3.8.54", " v3.8.54 "]) { + assert.equal(branchForVersion(input), "release/v3.8.54", `for ${JSON.stringify(input)}`); + } +}); + +test("anything that is not a version is refused instead of guessed at", () => { + for (const bad of ["", " ", "latest", "next", "3.8", "main", "release/main", "v3.8.x"]) { + assert.throws(() => branchForVersion(bad), `expected ${JSON.stringify(bad)} to be refused`); + } +}); + +test("the branch slash is encoded, or GitHub reads it as a nested path", () => { + assert.equal( + protectionPath("owner/name", "release/v3.8.54"), + "repos/owner/name/branches/release%2Fv3.8.54/protection" + ); +}); + +test("the payload locks the branch and binds admins to it", () => { + assert.equal(PROTECTION.lock_branch, true); + assert.equal(PROTECTION.enforce_admins, true); + assert.equal(PROTECTION.allow_force_pushes, false); + assert.equal(PROTECTION.allow_deletions, false); +}); + +test("a locked branch is reported as locked", () => { + const state = readLockState("owner/name", "release/v3.8.54", () => + JSON.stringify({ lock: true, admins: true }) + ); + assert.deepEqual(state, { lock: true, admins: true }); +}); + +test("a branch GitHub says is unlocked is never reported as locked", () => { + const state = readLockState("owner/name", "release/v3.8.54", () => + JSON.stringify({ lock: false, admins: true }) + ); + assert.notEqual(state.lock, true, "an unlocked branch must not read back as locked"); +}); + +test("the read-back asks GitHub, it does not echo what was sent", () => { + const calls: string[][] = []; + readLockState("owner/name", "release/v3.8.54", (args: string[]) => { + calls.push(args); + return JSON.stringify({ lock: true, admins: true }); + }); + assert.equal(calls.length, 1, "expected exactly one API read"); + assert.ok(calls[0].includes("api"), "expected a gh api call"); + assert.ok( + calls[0].some((a) => a.includes("release%2Fv3.8.54/protection")), + "expected the read to target the branch's protection endpoint" + ); +});