Files
gstack/lib/ci-gate.ts
T
f30b7b788a v1.91.13.0 fix: severe bug wave (autoplan guard, land-and-deploy CI gate, project identity, gbrain, review gates) (#3013)
* fix(land-and-deploy): never merge over red, pending or missing CI (#2995)

/land-and-deploy gated only on required checks, so a repo with none
(every GitHub Free private repo, any repo without branch protection)
skipped the CI wait and could merge over red CI. The verdict now comes
from bin/gstack-ci-gate, a tested helper bound to the head SHA: all
checks by default, cancel/unknown buckets fail, NO_CHECKS needs explicit
per-head approval, and both merge commands (including --auto) run only
after a PASS for the same SHA they pin with --match-head-commit.

Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com>

* fix(slug): give nested-group projects their own state bucket and verify checkpoint identity (#3003, #2901)

gstack-slug kept only the last two remote path segments, so
customer-a/product/repo and customer-b/product/repo shared one bucket
and /context-restore could present another project's checkpoint.

- One canonical-remote rule (bash + TS, golden vectors) used by all six
  resolvers: lowercased host, no userinfo/port/.git, Azure DevOps and
  Bitbucket Server normalized. 3+ segment remotes get
  <last-two>-<16 hex>; two-segment remotes (all of GitHub) are unchanged.
- Versioned sticky cache (v2:) keeps cache hits git-free.
- context-save stamps the credential-free remote and root;
  context-restore only treats this project's checkpoints as latest,
  reports foreign/unverified ones first, and treats a different root
  with the same remote (Conductor handoff) as information.
- gstack-slug --adopt-legacy copies only files the user confirms.
- question-preference-hook reads the same versioned cache, so never-ask
  preferences fire; unknown identity asks instead of auto-deciding.

* fix(gbrain): brain context loads, managed sync works, memory sync stops duplicating (#2883, #2985, #2922)

- brain-context-load called 'gbrain list_pages' (an MCP tool name), so
  every skill's brain context was 0 bytes. It now uses 'gbrain list'
  with gbrain's real filter flags, and prints one line when queries fail.
- The code-stage sync always passes --no-pull (managed gbrain >= 0.51
  refuses without it); a gbrain that rejects the flag fails with an
  upgrade message and is never retried without it. stderr is streamed
  with a bounded tail and quoted in failure summaries.
- /sync-gbrain gains --sources (and GSTACK_MEMORY_INGEST_SOURCES); by
  default it skips types already synced through a federated curated
  source, so curated pages stop being re-duplicated.

Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com>

* fix(codex,cso): clean Codex reviews stop failing; semgrep and trivy SARIF results import (#2769, #3011)

- Codex review gate is PASS / FAIL / UNVERIFIED. Untagged output (what
  a clean 'codex review --base' prints) is UNVERIFIED, not FAIL, and
  never a false PASS; P2/P3-only output passes.
- import-sarif resolves semgrep's undeclared %SRCROOT% and trivy's
  ROOTPATH (including git-URL targets) through explicit, captured
  mappings; declared bases must sit inside the source root or the
  run's checkout, URIs are percent-decoded, and rejected results are
  counted with rule, base and path instead of silently dropped.

* fix: version strings are not IPs, failed update checks are not 'up to date', private security reporting (#2856, #2786, #2984)

- The pre-push redact hook exempts a root VERSION file that is only a
  version string and the version token in CHANGELOG release headings;
  the engine allowlist is untouched.
- gstack-update-check caches an unreadable remote as CHECK_FAILED with
  a short TTL instead of UP_TO_DATE; /gstack-upgrade never reports
  'latest' after a failed check.
- SECURITY.md points reporters at GitHub private vulnerability
  reporting.
- pr-title-sync (pull_request_target, write token) pins actions/checkout
  to a reviewed SHA.

* fix(autoplan): publication guard accepts real Claude Code journals (#3007, #3009, #2968, #2977, #2986, #3006)

The guard required a session journal to start with a user message, but
Claude Code starts it with SessionStart hook attachments (and a forked
compacted session starts with a compact_boundary), so every phase was
denied for anyone with a SessionStart hook. Windows path spellings
(C:/, /c/, drive case) were rejected too.

- Reader accepts the captured startup, compact, clear and fork shapes
  (claude 2.1.284 fixture), computes root uniqueness over every
  candidate, and returns typed reason codes instead of one string.
- Hard denials (competing root, foreign cwd, sidechain, agent, cycle,
  malformed, identity) say what happened and how to run the reviews by
  hand; unflushed journals still retry.
- A well-formed, stable journal whose root shape is unrecognized now
  warns ('phase publication was NOT verified') and continues, without
  overriding Claude Code's own permission check; no bypass switch.
- Windows paths fold before validation, so '..' is still rejected.
- Periodic drift canary runs the reader on fresh journals from the
  pinned claude.

Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com>
Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com>
Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com>
Co-authored-by: elmesery-dev <elmesery.dev@gmail.com>

* v1.91.13.0: severe bug fix wave release notes, stale gbrain verbs in docs

* test: seed the versioned slug cache in the memory-injection deny case (#2901)

The preference hook now resolves the project bucket from gstack-slug's
v2 slug-cache entry instead of basename(cwd); this fixture still wrote
preferences under the basename bucket without the entry the skill
preamble always writes first.

* fix: full-suite follow-ups for the fix wave

- gstack-slug's --help heredoc is over 512B, so it gets the BASH_COMPAT=50
  pipe-deadlock guard like the other bins.
- Refresh the codex/factory ship goldens for the plan-gates slug change.
- The periodic lane gains the autoplan journal drift canary (107 paid
  files); raise the eval-slices cap to 25 so every slice still starts
  at once until its recorded duration lets the planner pack it.

* fix(plan-eng-review): clearer Step 0 sequencing, write routes and section outputs

The workflow judge scored plan-eng-review's instructions at
actionability 3.67 (bar 4) on text unchanged from main. Resolve the
ambiguities it named together, without changing any gate or output:
Step 0 names the Read's order and keeps one STOP; the pending-call,
read-only, forbidden-write and failed-save rules become one Write
routes block; header/label limits and grid checks are concrete;
Performance gets a checklist; each section has a stated output shape;
Lake Score is defined. Same total length (parity cap).

---------

Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com>
Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com>
Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com>
Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com>
Co-authored-by: elmesery-dev <elmesery.dev@gmail.com>
2026-10-02 11:11:00 -07:00

296 lines
15 KiB
TypeScript

import { spawnSync } from 'node:child_process';
export type CiVerdict = 'PASS' | 'FAIL' | 'PENDING' | 'NO_CHECKS' | 'ERROR';
export const CI_GATE_EXIT: Record<CiVerdict, number> = { PASS: 0, FAIL: 1, PENDING: 2, NO_CHECKS: 3, ERROR: 4 };
export const MIN_GH_VERSION = '2.50.0';
export interface GhResult { status: number | null; stdout: string; stderr: string; error?: string }
export type GhRunner = (args: string[]) => GhResult;
export interface CiGateOptions {
repo: string;
pr: string;
expectHead: string;
excludes: string[];
overrideHead?: string;
waitSeconds: number;
intervalSeconds: number;
registrationWaitSeconds: number;
}
export interface CiGateResult { verdict: CiVerdict; sha: string; lines: string[] }
type Required = 'y' | 'n' | '?';
type Klass = 'pass' | 'pending' | 'fail';
interface Row { name: string; bucket: string; link: string; required: Required }
type ChecksRead =
| { kind: 'rows'; rows: { name: string; bucket: string; link: string }[] }
| { kind: 'none' }
| { kind: 'none-required' }
| { kind: 'error'; cause: string; stderr: string };
const PASS_BUCKETS = new Set(['pass', 'skipping', 'neutral']);
const HEAD_RE = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
const RANK: Record<Klass, number> = { pass: 0, pending: 1, fail: 2 };
export const CI_GATE_USAGE = `gstack-ci-gate --repo OWNER/NAME --pr NUMBER --expect-head SHA [options]
Decides whether a pull request's CI allows a merge of exactly SHA. Reads the
PR head, gates on every check (required ones and all others), and re-reads the
head afterwards; a moved head is ERROR.
Output (stdout), always first:
VERDICT <PASS|FAIL|PENDING|NO_CHECKS|ERROR> <sha>
then one line per non-passing check:
CHECK<TAB>name<TAB>bucket<TAB>required=y|n|?<TAB>link[<TAB>excluded|override-refused]
then NOTE / HEAD / CAUSE / STDERR / FIX lines. Act on the VERDICT line, not the exit code.
Exit codes: 0 PASS, 1 FAIL, 2 PENDING, 3 NO_CHECKS, 4 ERROR.
Buckets: pass, skipping and neutral pass; pending waits; fail, cancel and any
unknown bucket fail; a missing bucket field is ERROR (needs gh >= ${MIN_GH_VERSION}).
Options:
--exclude NAME drop one named non-required check from the verdict
(repeatable; needs --override-head; required checks
and checks of unknown required status are never dropped)
--override-head SHA head the exclusions were approved for; must equal
--expect-head
--wait SECONDS keep polling while PENDING, up to SECONDS (default 0)
--interval SECONDS poll interval (default 30)
--registration-wait S when no check exists yet, re-poll up to S seconds
before reporting NO_CHECKS (default 0; use 60 right
after a push, when checks may not be registered yet)
--help show this help`;
export function bucketClass(bucket: string): Klass {
if (PASS_BUCKETS.has(bucket)) return 'pass';
return bucket === 'pending' ? 'pending' : 'fail';
}
function lastLine(text: string): string {
return text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? '';
}
export function parseChecks(result: GhResult): ChecksRead {
if (result.error) return { kind: 'error', cause: result.error, stderr: lastLine(result.stderr) };
const text = result.stdout.trim();
if (text) {
let parsed: unknown;
try { parsed = JSON.parse(text); } catch {
return { kind: 'error', cause: 'gh pr checks printed output that is not JSON', stderr: lastLine(result.stderr) };
}
if (!Array.isArray(parsed)) return { kind: 'error', cause: 'gh pr checks JSON is not a list', stderr: lastLine(result.stderr) };
const rows = [];
for (const item of parsed) {
if (!item || typeof item !== 'object' || typeof (item as { name?: unknown }).name !== 'string') {
return { kind: 'error', cause: 'gh pr checks returned a check without a name', stderr: lastLine(result.stderr) };
}
const { name, bucket, link } = item as { name: string; bucket?: unknown; link?: unknown };
if (typeof bucket !== 'string') {
return { kind: 'error', cause: `gh pr checks returned no bucket field for "${name}"`, stderr: lastLine(result.stderr) };
}
rows.push({ name, bucket, link: typeof link === 'string' ? link : '' });
}
return { kind: 'rows', rows };
}
if (result.status === 0) return { kind: 'rows', rows: [] };
if (/no required checks reported/.test(result.stderr)) return { kind: 'none-required' };
if (/no checks reported/.test(result.stderr)) return { kind: 'none' };
return { kind: 'error', cause: `gh pr checks exited ${result.status ?? 'abnormally'}`, stderr: lastLine(result.stderr) };
}
interface Evaluation { verdict: Exclude<CiVerdict, 'ERROR'>; lines: string[] }
type EvalOrError = Evaluation | { verdict: 'ERROR'; cause: string; stderr: string };
export function evaluateChecks(required: GhResult, all: GhResult, excludes: string[]): EvalOrError {
const allRead = parseChecks(all);
if (allRead.kind === 'error') return { verdict: 'ERROR', cause: allRead.cause, stderr: allRead.stderr };
if (allRead.kind === 'none-required') return { verdict: 'ERROR', cause: 'gh pr checks without --required reported no required checks', stderr: '' };
const reqRead = parseChecks(required);
const notes: string[] = [];
const requiredKnown = reqRead.kind === 'rows' || reqRead.kind === 'none-required';
if (reqRead.kind === 'error') {
notes.push(`NOTE required-check metadata unavailable (${reqRead.stderr || reqRead.cause}); gating on all checks, overrides refused`);
} else if (reqRead.kind === 'none' && allRead.kind === 'rows' && allRead.rows.length > 0) {
notes.push('NOTE checks registered while they were being read; required status unknown, overrides refused');
}
const requiredRows = reqRead.kind === 'rows' ? reqRead.rows : [];
const requiredNames = new Set(requiredRows.map(row => row.name));
if (requiredKnown && requiredNames.size === 0) notes.push('NOTE no required checks configured; gating on all checks');
const rows = new Map<string, Row>();
const observe = (obs: { name: string; bucket: string; link: string }) => {
const required: Required = !requiredKnown ? '?' : requiredNames.has(obs.name) ? 'y' : 'n';
const prior = rows.get(obs.name);
if (!prior || RANK[bucketClass(obs.bucket)] > RANK[bucketClass(prior.bucket)]) rows.set(obs.name, { ...obs, required });
};
for (const obs of allRead.kind === 'rows' ? allRead.rows : []) observe(obs);
for (const obs of requiredRows) observe(obs);
if (rows.size === 0) return { verdict: 'NO_CHECKS', lines: notes.filter(n => !n.includes('no required checks')) };
const excluded = new Set<string>();
const lines: string[] = [];
for (const name of excludes) {
const row = rows.get(name);
if (!row) notes.push(`NOTE override for unknown check "${name}" ignored`);
else if (row.required === 'n') excluded.add(name);
}
let worst: Klass = 'pass';
for (const row of [...rows.values()].sort((a, b) => a.name.localeCompare(b.name))) {
const klass = bucketClass(row.bucket);
const isExcluded = excluded.has(row.name);
if (!isExcluded && RANK[klass] > RANK[worst]) worst = klass;
if (klass === 'pass') continue;
const marker = isExcluded ? '\texcluded' : excludes.includes(row.name) ? '\toverride-refused' : '';
lines.push(`CHECK\t${row.name}\t${row.bucket}\trequired=${row.required}\t${row.link}${marker}`);
}
const verdict = worst === 'fail' ? 'FAIL' : worst === 'pending' ? 'PENDING' : 'PASS';
return { verdict, lines: [...lines, ...notes] };
}
function readHead(gh: GhRunner, opts: CiGateOptions): { head: string } | { cause: string; stderr: string } {
const result = gh(['pr', 'view', opts.pr, '--repo', opts.repo, '--json', 'headRefOid', '--jq', '.headRefOid']);
if (result.error) return { cause: result.error, stderr: lastLine(result.stderr) };
const head = result.stdout.trim();
if (result.status !== 0 || !HEAD_RE.test(head)) {
return { cause: `gh pr view could not read the PR head (exit ${result.status ?? 'abnormal'})`, stderr: lastLine(result.stderr) };
}
return { head };
}
function errorLines(cause: string, stderr: string): string[] {
return [
`CAUSE ${cause}`,
...(stderr ? [`STDERR ${stderr}`] : []),
`FIX check \`gh auth status\`; gstack-ci-gate needs gh >= ${MIN_GH_VERSION} (\`gh --version\`)`,
];
}
function evaluateOnce(gh: GhRunner, opts: CiGateOptions): CiGateResult {
const fail = (lines: string[]): CiGateResult => ({ verdict: 'ERROR', sha: opts.expectHead, lines });
const before = readHead(gh, opts);
if ('cause' in before) return fail(errorLines(before.cause, before.stderr));
if (before.head !== opts.expectHead) {
return fail([`HEAD ${before.head}`, 'CAUSE the PR head is not the approved head', 'FIX rerun /land-and-deploy from Step 1 for the new head']);
}
const checkArgs = ['pr', 'checks', opts.pr, '--repo', opts.repo];
const fields = ['--json', 'name,state,bucket,link'];
const required = gh([...checkArgs, '--required', ...fields]);
const all = gh([...checkArgs, ...fields]);
const after = readHead(gh, opts);
if ('cause' in after) return fail(errorLines(after.cause, after.stderr));
if (after.head !== before.head) {
return fail([`HEAD ${after.head}`, 'CAUSE the PR head moved while checks were being read', 'FIX rerun /land-and-deploy from Step 1 for the new head']);
}
const evaluation = evaluateChecks(required, all, opts.excludes);
if (evaluation.verdict === 'ERROR') return fail(errorLines(evaluation.cause, evaluation.stderr));
return { verdict: evaluation.verdict, sha: opts.expectHead, lines: evaluation.lines };
}
function workflowsNote(gh: GhRunner, opts: CiGateOptions): string {
const result = gh(['api', `repos/${opts.repo}/contents/.github/workflows?ref=${opts.expectHead}`, '--jq', 'length']);
if (!result.error && result.status === 0 && /^\d+$/.test(result.stdout.trim())) {
return Number(result.stdout.trim()) > 0 ? 'NOTE .github/workflows exists at this head' : 'NOTE .github/workflows is empty at this head';
}
if (/HTTP 404|Not Found/.test(result.stderr)) return 'NOTE .github/workflows does not exist at this head';
return 'NOTE could not tell whether .github/workflows exists at this head';
}
export async function runCiGate(
opts: CiGateOptions,
gh: GhRunner,
sleep: (ms: number) => Promise<void> = ms => new Promise(resolve => setTimeout(resolve, ms)),
now: () => number = Date.now,
): Promise<CiGateResult> {
if (opts.excludes.length > 0 && opts.overrideHead !== opts.expectHead) {
return {
verdict: 'ERROR', sha: opts.expectHead,
lines: ['CAUSE check overrides were not approved for this head', 'FIX approve overrides again in the readiness report for the current head'],
};
}
const started = now();
const elapsed = () => (now() - started) / 1000;
let result = evaluateOnce(gh, opts);
while (true) {
if (result.verdict === 'NO_CHECKS' && elapsed() + Math.min(opts.intervalSeconds, 10) <= opts.registrationWaitSeconds) {
await sleep(Math.min(opts.intervalSeconds, 10) * 1000);
} else if (result.verdict === 'PENDING' && elapsed() + opts.intervalSeconds <= opts.waitSeconds) {
await sleep(opts.intervalSeconds * 1000);
} else break;
result = evaluateOnce(gh, opts);
}
if (result.verdict === 'NO_CHECKS') {
result.lines.push(`NOTE no CI ran on ${opts.expectHead} (looked for ${Math.round(elapsed())}s)`, workflowsNote(gh, opts));
}
if (result.verdict === 'PENDING' && opts.waitSeconds > 0) {
result.lines.push(`NOTE still pending after waiting ${Math.round(elapsed())}s`);
}
return result;
}
export function parseCiGateArgs(argv: string[]): CiGateOptions | { help: true } | { error: string; expectHead?: string } {
const opts: CiGateOptions = {
repo: '', pr: '', expectHead: '', excludes: [], waitSeconds: 0, intervalSeconds: 30, registrationWaitSeconds: 0,
};
const seconds = (value: string | undefined, flag: string, positive = false): number | string => {
if (value === undefined || !/^\d+(?:\.\d+)?$/.test(value)) return `${flag} needs a number of seconds`;
const n = Number(value);
return positive && n <= 0 ? `${flag} must be greater than 0` : n;
};
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
const value = argv[i + 1];
if (flag === '--help' || flag === '-h') return { help: true };
const take = (): string | undefined => { i++; return value; };
switch (flag) {
case '--repo': opts.repo = take() ?? ''; break;
case '--pr': opts.pr = take() ?? ''; break;
case '--expect-head': opts.expectHead = take() ?? ''; break;
case '--override-head': opts.overrideHead = take() ?? ''; break;
case '--exclude': {
const name = take();
if (!name) return { error: '--exclude needs a check name', expectHead: opts.expectHead };
opts.excludes.push(name);
break;
}
case '--wait': case '--interval': case '--registration-wait': {
const parsed = seconds(take(), flag, flag === '--interval');
if (typeof parsed === 'string') return { error: parsed, expectHead: opts.expectHead };
if (flag === '--wait') opts.waitSeconds = parsed;
else if (flag === '--interval') opts.intervalSeconds = parsed;
else opts.registrationWaitSeconds = parsed;
break;
}
default: return { error: `unknown argument ${flag}`, expectHead: opts.expectHead };
}
}
if (!/^[^/\s]+\/[^/\s]+$/.test(opts.repo)) return { error: '--repo OWNER/NAME is required', expectHead: opts.expectHead };
if (!/^[1-9]\d*$/.test(opts.pr)) return { error: '--pr NUMBER is required', expectHead: opts.expectHead };
if (!HEAD_RE.test(opts.expectHead)) return { error: '--expect-head needs a full commit SHA', expectHead: opts.expectHead };
return opts;
}
export const spawnGh: GhRunner = args => {
const result = spawnSync('gh', args, { encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
if (result.error) {
const code = (result.error as NodeJS.ErrnoException).code;
return { status: null, stdout: '', stderr: result.stderr ?? '', error: code === 'ENOENT' ? 'gh is not installed or not on PATH' : `gh failed to run (${code ?? result.error.message})` };
}
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
};
export async function ciGateMain(argv: string[], gh: GhRunner = spawnGh, write: (text: string) => void = text => process.stdout.write(text)): Promise<number> {
const parsed = parseCiGateArgs(argv);
if ('help' in parsed) {
write(`${CI_GATE_USAGE}\n`);
return 0;
}
if ('error' in parsed) {
write(`VERDICT ERROR ${parsed.expectHead || '-'}\nCAUSE ${parsed.error}\nFIX see gstack-ci-gate --help\n`);
return CI_GATE_EXIT.ERROR;
}
const result = await runCiGate(parsed, gh);
write([`VERDICT ${result.verdict} ${result.sha}`, ...result.lines].join('\n') + '\n');
return CI_GATE_EXIT[result.verdict];
}