Files
gstack/bin/gstack-release-gate
T
Garry Tanandcapy-ai[bot] cff1de2039 v1.95.0.0 feat(release-gate): gate main every 8 hours and feed the stable release (inert until adopted) (#3917)
* feat(release-gate): gate main every 8 hours and feed the stable release (inert until adopted)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(release-gate): exit codes match the bin

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(release-gate): blobless checkout and free-only workflow classification

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (finish an open gate before a new head; neutral check for an unfinished gate)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes, pass 2 (the workflow reads main head itself; an open gate on stable or older than three cadences)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: Step 11 review fixes (no paid dispatch without the record on origin; held or failed leaves the gate job green)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(release-gate): re-pin the key to 1.95.0.0 above main; move assertInsideRoot to lib/path-containment.ts to keep ship-policy.ts under 800 lines

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(release-gate): type the narrow fixture rows (test typecheck ratchet)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(release-gate): charge the trial reservation for a null or missing bisect prefix cost so the cap holds across rounds

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(capy-install): wrapper 4 follows the update channel (Step 14.5 audit)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(pregate-selection): mark the fixture wrapper bump as a deliberate old literal

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(pregate-selection): put the literal marker on its own comment line

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(release-gate-workflow): run policy steps with Git Bash argument conversion off, as on the Linux runners (windows-scoped-shard)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: release gate evidence and hold in releases.md, release_gate row in the policy key table (Step 14.5 audit)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(ship-policy): defer the release_gate key-table row to the adoption PR (named documentation risk, GSTA-258)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-04 01:22:38 +00:00

63 lines
4.1 KiB
TypeScript
Executable File

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-release-gate — the full paid gate on main every cadence_hours, only
* when main moved since the last gate (docs/ship-policy.md#release-gate;
* logic: lib/release-gate.ts). The gate never moves the stable branch:
* scripts/release-cut.ts is its only writer and holds every cut while the
* newest gate is red.
*
* gstack-release-gate run [--dry-run] [--sha <main sha>] [--ignore-cadence]
* gstack-release-gate status
* gstack-release-gate bisect [--sha <red sha>] [--spend-cap <usd>] [--dry-run]
*
* Every subcommand takes --repo <owner/name> (default $GITHUB_REPOSITORY, else
* gh repo view), --policy <file> (a whole ship policy, validated; default the
* reviewed .gstack/ship-policy.json on origin/main), --remote <name> (origin),
* --summary <file> (appends each line as a bullet) and --outputs <file>
* (appends action=<action> and sha=<sha> for the workflow). run and bisect
* take --poll-seconds (60) and --budget-minutes (330): past the budget nothing
* waits, and the record stays resumable by the next run or bisect.
* Exit 0: inert, skipped, dry run, gated green, or status with no hold;
* 1: red (held), incomplete or could not finish; 2: usage.
*/
import { randomBytes } from 'node:crypto';
import * as fs from 'node:fs';
import { bisectGate, gateStatus, loadReleaseGate, runGate, type GateEnv, type GateOut } from '../lib/release-gate';
import { runGh } from '../scripts/recover-receipts';
import { gitIn } from '../scripts/train-gate';
const USAGE = 'usage: gstack-release-gate run [--dry-run] [--sha <sha>] [--ignore-cadence] | status | bisect [--sha <sha>] [--spend-cap <usd>] [--dry-run]\n'
+ ' common: [--repo <owner/name>] [--policy <file>] [--remote <name>] [--summary <file>] [--outputs <file>] [--poll-seconds <n>] [--budget-minutes <n>]';
const [command, ...rest] = process.argv.slice(2);
const flag = (name: string) => { const i = rest.indexOf(`--${name}`); return i >= 0 ? rest[i + 1] : undefined; };
const has = (name: string) => rest.includes(`--${name}`);
const usage = (why?: string): never => { console.error(why ? `${why}\n${USAGE}` : USAGE); process.exit(2); };
if (command === '--help' || command === '-h') { console.log(USAGE); process.exit(0); }
if (!['run', 'status', 'bisect'].includes(command ?? '')) usage();
const sha = flag('sha');
if (sha !== undefined && !/^[0-9a-f]{40}$/.test(sha)) usage('--sha needs a full 40-hex commit');
const spendCap = flag('spend-cap') === undefined ? undefined : Number(flag('spend-cap'));
if (spendCap !== undefined && !(spendCap >= 0)) usage('--spend-cap needs dollars');
const repoRoot = process.cwd();
const repo = flag('repo') ?? process.env.GITHUB_REPOSITORY ?? runGh(['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], 60_000).stdout.trim();
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) usage(`cannot name the repository (${JSON.stringify(repo)}); pass --repo <owner/name>`);
const loaded = loadReleaseGate(repoRoot, { policyFile: flag('policy') });
const env: GateEnv = {
repo, repoRoot, remote: flag('remote') ?? 'origin', gh: runGh, git: gitIn(repoRoot), now: Date.now, sleep: ms => Bun.sleepSync(ms),
random: () => randomBytes(3).toString('hex'), pollMs: Number(flag('poll-seconds') ?? 60) * 1000,
deadline: Date.now() + Number(flag('budget-minutes') ?? 330) * 60_000,
};
let out: GateOut;
if ('error' in loaded) out = { action: 'failed', sha: null, lines: [`RELEASE: policy refused: ${loaded.error}`] };
else if (command === 'run') out = runGate(env, { ...loaded, sha, dryRun: has('dry-run'), ignoreCadence: has('ignore-cadence') });
else if (command === 'bisect') out = bisectGate(env, { ...loaded, sha, spendCap, dryRun: has('dry-run') });
else out = gateStatus(env, loaded);
const summary = flag('summary');
for (const line of out.lines) {
console.log(line);
if (summary) fs.appendFileSync(summary, `- ${line}\n`);
}
const outputs = flag('outputs');
if (outputs) fs.appendFileSync(outputs, `action=${out.action}\nsha=${out.sha ?? ''}\ncheck=${'gate' in loaded && loaded.gate ? loaded.gate.check_name : ''}\n`);
process.exit(out.action === 'held' || out.action === 'failed' ? 1 : 0);