Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
124 changes: 124 additions & 0 deletions scripts/release/lock-released-branch.mjs
Original file line number Diff line number Diff line change
@@ -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 <version> [--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);
}
71 changes: 71 additions & 0 deletions tests/unit/release-lock-branch-script.test.ts
Original file line number Diff line number Diff line change
@@ -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"
);
});
Loading