mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 00:44:07 +01:00
* 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>
58 lines
4.4 KiB
TypeScript
58 lines
4.4 KiB
TypeScript
/**
|
|
* The `release_gate` block of .gstack/ship-policy.json
|
|
* (docs/ship-policy.md#release-gate): the full paid gate on the base branch
|
|
* every few hours, run by bin/gstack-release-gate from
|
|
* .github/workflows/release-gate.yml, instead of a gate per train or
|
|
* per push. The gate never writes the stable branch: scripts/release-cut.ts
|
|
* stays its only writer, reads a green gate as "gated" and holds every cut
|
|
* while the newest gate is red. Validated here so lib/ship-policy.ts stays
|
|
* one schema file.
|
|
*/
|
|
import type { PolicyError } from './ship-policy';
|
|
|
|
export interface ReleaseGatePolicy {
|
|
/** Least hours between two gate dispatches; at least RELEASE_GATE_SCHEDULE_HOURS, the workflow's schedule. */
|
|
cadence_hours: number;
|
|
/** Workflow file dispatched for the full paid gate and the bisect; it accepts the eval-bg dispatch inputs. */
|
|
gate_workflow: string;
|
|
/** The branch the gate tests (its head at each run). */
|
|
branch: string;
|
|
/** The branch scripts/release-cut.ts moves; the gate only reads it to count pending commits. */
|
|
stable_ref: string;
|
|
/** The check run published on a gated commit, read by release-cut as gated. */
|
|
check_name: string;
|
|
/** What a red gate does. hold-release: the red record keeps release-cut from cutting until a later gate is green. */
|
|
on_red: 'hold-release';
|
|
/** Cap over one red gate's bisect, at RELEASE_TRIAL_RESERVATION_USD per case trial; 0 skips the bisect. */
|
|
bisect_spend_cap_usd: number;
|
|
}
|
|
|
|
/** The cron step of release-gate.yml's schedule (test/release-gate-workflow.test.ts pins it): no cadence gates more often. */
|
|
export const RELEASE_GATE_SCHEDULE_HOURS = 8;
|
|
const REF = /^(?!.*\.\.)(?!\/)(?!.*\/$)[A-Za-z0-9._/-]{1,100}$/;
|
|
const FIELDS: Record<keyof ReleaseGatePolicy, (v: unknown) => string | null> = {
|
|
cadence_hours: v => (Number.isInteger(v) && (v as number) >= RELEASE_GATE_SCHEDULE_HOURS && (v as number) <= 168 ? null : `must be a whole number of hours from ${RELEASE_GATE_SCHEDULE_HOURS} (the workflow's schedule) to 168`),
|
|
gate_workflow: v => (typeof v === 'string' && /^[A-Za-z0-9_.-]{1,64}\.ya?ml$/.test(v) ? null : 'must be a workflow file name such as evals.yml'),
|
|
branch: v => (typeof v === 'string' && REF.test(v) ? null : 'must be a branch name'),
|
|
stable_ref: v => (typeof v === 'string' && REF.test(v) ? null : 'must be a branch name'),
|
|
check_name: v => (typeof v === 'string' && /^[A-Za-z0-9_./-]{1,64}$/.test(v) ? null : 'must be a check-run name ([A-Za-z0-9_./-]{1,64})'),
|
|
on_red: v => (v === 'hold-release' ? null : 'must be hold-release'),
|
|
bisect_spend_cap_usd: v => (typeof v === 'number' && v >= 0 && v <= 500 ? null : 'must be dollars from 0 to 500'),
|
|
};
|
|
|
|
export const RELEASE_GATE_EXPLANATION = '{ cadence_hours, gate_workflow, branch, stable_ref, check_name, on_red, bisect_spend_cap_usd } or null: the release gate (docs/ship-policy.md#release-gate). bin/gstack-release-gate dispatches the full paid gate (gate_workflow) on the head of branch at most once per cadence_hours, only when branch moved since the last gate, records the verdict as a release note in refs/notes/gstack-trains and publishes check_name on the gated commit; a red gate bisects its red cases (bisect_spend_cap_usd, 0 skips) and, with on_red hold-release, keeps scripts/release-cut.ts from moving stable_ref until a later gate is green. It never writes stable_ref. While the block is set, the per-push main backstop stands down. null keeps the per-push backstop.';
|
|
|
|
export function releaseGateErrors(raw: unknown): PolicyError[] {
|
|
const g = raw as Record<string, unknown> | null;
|
|
if (!g || typeof g !== 'object' || Array.isArray(g)) return [{ code: 'POLICY_INVALID', path: 'release_gate', message: `must be { ${Object.keys(FIELDS).join(', ')} } or null` }];
|
|
const out: PolicyError[] = [];
|
|
for (const k of Object.keys(g)) if (!(k in FIELDS)) out.push({ code: 'POLICY_INVALID', path: `release_gate.${k}`, message: 'unknown key' });
|
|
for (const [k, check] of Object.entries(FIELDS)) {
|
|
if (!(k in g)) { out.push({ code: 'POLICY_INVALID', path: `release_gate.${k}`, message: 'missing (every release_gate field is required)' }); continue; }
|
|
const why = check(g[k]);
|
|
if (why) out.push({ code: 'POLICY_INVALID', path: `release_gate.${k}`, message: `${JSON.stringify(g[k])} ${why}` });
|
|
}
|
|
if (typeof g.branch === 'string' && g.branch === g.stable_ref) out.push({ code: 'POLICY_INVALID', path: 'release_gate.stable_ref', message: 'must differ from branch' });
|
|
return out;
|
|
}
|