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>
This commit is contained in:
garrytanandharshitgavita-07 committed 2026-10-02 14:32:50 +00:00
1 parent 7fca42ad8b
commit bc8bec4df2
17 files changed
+964 -40

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bun
import { ciGateMain } from '../lib/ci-gate';
process.exit(await ciGateMain(process.argv.slice(2)));
+15 -17
View File
@@ -581,7 +581,7 @@ When the user types `/land-and-deploy`, run this skill.
Automate read-only detection and polling. First-run setup confirmation (Step 1.5)
and pre-merge approval (Step 3.5) are mandatory when applicable. Stop on missing
access, unknown target/state, failing required CI, conflicts, or failing tests.
access, unknown target/state, unapproved red, pending or missing CI, conflicts, or failing tests.
After any merge error, read server state before deciding whether to stop.
Failures, timeouts, staging choices, rollback, and optional cleanup use the explicit
decisions below; no approval overrides a blocker or authorizes a different revision.
@@ -695,16 +695,16 @@ Choice A saves the fingerprint and continues to Step 2; B/C stop.
Tell the user: "Checking CI status and merge readiness..."
```bash
gh pr checks "$PR_NUMBER" --repo "$REPO" --required --json name,state,bucket,link
~/.claude/skills/gstack/bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD" --registration-wait 60
```
Parse valid JSON using `bucket` (pass/fail/pending/skipping/cancel). Exit 8 means
pending; a nonzero exit with valid failing checks is a CI failure. Auth/network/schema
errors are **STOP**, never "no required checks". An empty successful result or the
CLI's explicit "no required checks reported" response means none are configured.
1. Required checks **FAILING/cancelled**: **STOP**, list failures to fix.
2. Required checks **PENDING**: announce the wait and proceed to Step 3.
3. All pass (or none required): report that exact result. Skip only Step 3's wait;
It gates on all checks on this head, required or not. Act on line 1,
`VERDICT <verdict> <sha>` (`<sha>` must be `PR_HEAD`), never on the exit code.
Auth/network/schema errors are ERROR, never "no required checks".
1. ERROR, or FAIL on a `required=y|?` check: **STOP**, show the output.
2. Only `required=n` failures, or NO_CHECKS ("no CI ran on `<sha>`" after a 60 s
re-poll): Step 3.5 must approve it.
3. PENDING: Step 3. PASS: skip Step 3's wait;
continue to Step 3.4, then Step 3.5 before merging.
Also check for merge conflicts:
@@ -712,23 +712,21 @@ Also check for merge conflicts:
gh pr view "$PR_NUMBER" --repo "$REPO" --json mergeable -q .mergeable
```
If `CONFLICTING`: **STOP**, resolve conflicts first. Failed/UNKNOWN readback: **STOP**,
readiness is not established. Cancelled required checks are failures, not passes.
readiness is not established.
---
## Step 3: Wait for CI (if pending)
If required checks are still pending, wait for them to complete. Use a timeout of 15 minutes:
Poll in 4-minute rounds (300 s shell timeout), 15 minutes max; record the wait:
```bash
gh pr checks "$PR_NUMBER" --repo "$REPO" --required --watch --fail-fast --interval 30
~/.claude/skills/gstack/bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD" --wait 240
```
Record the CI wait time for the deploy report.
Pass: report duration and continue to Step 3.4, then Step 3.5 before merging.
Failure: **STOP**, show failing checks. Timeout (15 minutes): **STOP**, point to
GitHub Actions. Enforce the deadline; do not leave an unbounded watch running.
Other verdicts follow Step 2. PENDING at 15 minutes: list the still-pending checks;
ask A) wait 15 more minutes, B) exclude named `required=n` checks (Step 3.5
approves each), or C) stop.
---
+15 -17
View File
@@ -50,7 +50,7 @@ When the user types `/land-and-deploy`, run this skill.
Automate read-only detection and polling. First-run setup confirmation (Step 1.5)
and pre-merge approval (Step 3.5) are mandatory when applicable. Stop on missing
access, unknown target/state, failing required CI, conflicts, or failing tests.
access, unknown target/state, unapproved red, pending or missing CI, conflicts, or failing tests.
After any merge error, read server state before deciding whether to stop.
Failures, timeouts, staging choices, rollback, and optional cleanup use the explicit
decisions below; no approval overrides a blocker or authorizes a different revision.
@@ -154,16 +154,16 @@ Choice A saves the fingerprint and continues to Step 2; B/C stop.
Tell the user: "Checking CI status and merge readiness..."
```bash
gh pr checks "$PR_NUMBER" --repo "$REPO" --required --json name,state,bucket,link
~/.claude/skills/gstack/bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD" --registration-wait 60
```
Parse valid JSON using `bucket` (pass/fail/pending/skipping/cancel). Exit 8 means
pending; a nonzero exit with valid failing checks is a CI failure. Auth/network/schema
errors are **STOP**, never "no required checks". An empty successful result or the
CLI's explicit "no required checks reported" response means none are configured.
1. Required checks **FAILING/cancelled**: **STOP**, list failures to fix.
2. Required checks **PENDING**: announce the wait and proceed to Step 3.
3. All pass (or none required): report that exact result. Skip only Step 3's wait;
It gates on all checks on this head, required or not. Act on line 1,
`VERDICT <verdict> <sha>` (`<sha>` must be `PR_HEAD`), never on the exit code.
Auth/network/schema errors are ERROR, never "no required checks".
1. ERROR, or FAIL on a `required=y|?` check: **STOP**, show the output.
2. Only `required=n` failures, or NO_CHECKS ("no CI ran on `<sha>`" after a 60 s
re-poll): Step 3.5 must approve it.
3. PENDING: Step 3. PASS: skip Step 3's wait;
continue to Step 3.4, then Step 3.5 before merging.
Also check for merge conflicts:
@@ -171,23 +171,21 @@ Also check for merge conflicts:
gh pr view "$PR_NUMBER" --repo "$REPO" --json mergeable -q .mergeable
```
If `CONFLICTING`: **STOP**, resolve conflicts first. Failed/UNKNOWN readback: **STOP**,
readiness is not established. Cancelled required checks are failures, not passes.
readiness is not established.
---
## Step 3: Wait for CI (if pending)
If required checks are still pending, wait for them to complete. Use a timeout of 15 minutes:
Poll in 4-minute rounds (300 s shell timeout), 15 minutes max; record the wait:
```bash
gh pr checks "$PR_NUMBER" --repo "$REPO" --required --watch --fail-fast --interval 30
~/.claude/skills/gstack/bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD" --wait 240
```
Record the CI wait time for the deploy report.
Pass: report duration and continue to Step 3.4, then Step 3.5 before merging.
Failure: **STOP**, show failing checks. Timeout (15 minutes): **STOP**, point to
GitHub Actions. Enforce the deadline; do not leave an unbounded watch running.
Other verdicts follow Step 2. PENDING at 15 minutes: list the still-pending checks;
ask A) wait 15 more minutes, B) exclude named `required=n` checks (Step 3.5
approves each), or C) stop.
---
@@ -93,7 +93,7 @@ Run whichever commands are relevant based on the detected platform. Build the re
║ ├─ Staging workflow: {file or "not found"} ║
║ └─ Preview deploys: {detected or "not detected"} ║
║ WHAT WILL HAPPEN ║
║ 1. Wait for required CI if pending ║
║ 1. Wait for all CI on this head (gstack-ci-gate) ║
║ 2. Readiness checks and explicit merge approval ║
║ 3. Merge PR via {merge method} ║
║ 4. {Wait for deploy workflow / Wait 60s / Skip} ║
@@ -55,7 +55,7 @@ Run whichever commands are relevant based on the detected platform. Build the re
║ ├─ Staging workflow: {file or "not found"} ║
║ └─ Preview deploys: {detected or "not detected"} ║
║ WHAT WILL HAPPEN ║
║ 1. Wait for required CI if pending ║
║ 1. Wait for all CI on this head (gstack-ci-gate) ║
║ 2. Readiness checks and explicit merge approval ║
║ 3. Merge PR via {merge method} ║
║ 4. {Wait for deploy workflow / Wait 60s / Skip} ║
+11 -2
View File
@@ -73,7 +73,14 @@ and current state. **If `state == "CLOSED"`: STOP**, the PR closed without mergi
Only START makes the first attempt. Immediately before either merge command, repeat
readback and Step 1's local HEAD/branch/cleanliness check. Retargeting or local changes
invalidate readiness; `--match-head-commit` protects head, not destination.
Each merge block re-runs the CI gate with Step 3.5's approvals (also for `--auto`,
which waits on required checks only). A gate exit means no merge ran: **STOP** with
its output. Only branch protection or a merge queue makes check-to-merge
atomic.
```bash
CI_GATE=$(~/.claude/skills/gstack/bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD" "${CI_OVERRIDE[@]}")
echo "$CI_GATE"
case "${CI_GATE%%$'\n'*}" in "VERDICT PASS $PR_HEAD"|"VERDICT NO_CHECKS $NO_CI_APPROVED_HEAD") ;; *) exit 1 ;; esac
MERGE_ATTEMPT=auto
MERGE_EXIT=0
MERGE_ERROR=$(gh pr merge "$MERGE_FLAG" --auto --delete-branch "$PR_NUMBER" --repo "$REPO" --match-head-commit "$PR_HEAD" 2>&1) || MERGE_EXIT=$?
@@ -82,9 +89,11 @@ Return to readback, even on exit 0. Only DIRECT permits **one direct fallback**:
readback has confirmed OPEN, no auto request and no queue entry, and the auto attempt
returned one of the two documented rejection classes: auto-merge disabled, or PR
already clean/unstable with nothing required pending. The latter does not mean
auto-merge is disabled. Recheck required CI as in Step 2 before the fallback; failures
or unknown check results stop, even if GitHub calls the PR mergeable.
auto-merge is disabled.
```bash
CI_GATE=$(~/.claude/skills/gstack/bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD" "${CI_OVERRIDE[@]}")
echo "$CI_GATE"
case "${CI_GATE%%$'\n'*}" in "VERDICT PASS $PR_HEAD"|"VERDICT NO_CHECKS $NO_CI_APPROVED_HEAD") ;; *) exit 1 ;; esac
MERGE_ATTEMPT=direct
MERGE_EXIT=0
MERGE_ERROR=$(gh pr merge "$MERGE_FLAG" --delete-branch "$PR_NUMBER" --repo "$REPO" --match-head-commit "$PR_HEAD" 2>&1) || MERGE_EXIT=$?
@@ -71,7 +71,14 @@ and current state. **If `state == "CLOSED"`: STOP**, the PR closed without mergi
Only START makes the first attempt. Immediately before either merge command, repeat
readback and Step 1's local HEAD/branch/cleanliness check. Retargeting or local changes
invalidate readiness; `--match-head-commit` protects head, not destination.
Each merge block re-runs the CI gate with Step 3.5's approvals (also for `--auto`,
which waits on required checks only). A gate exit means no merge ran: **STOP** with
its output. Only branch protection or a merge queue makes check-to-merge
atomic.
```bash
CI_GATE=$(~/.claude/skills/gstack/bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD" "${CI_OVERRIDE[@]}")
echo "$CI_GATE"
case "${CI_GATE%%$'\n'*}" in "VERDICT PASS $PR_HEAD"|"VERDICT NO_CHECKS $NO_CI_APPROVED_HEAD") ;; *) exit 1 ;; esac
MERGE_ATTEMPT=auto
MERGE_EXIT=0
MERGE_ERROR=$(gh pr merge "$MERGE_FLAG" --auto --delete-branch "$PR_NUMBER" --repo "$REPO" --match-head-commit "$PR_HEAD" 2>&1) || MERGE_EXIT=$?
@@ -80,9 +87,11 @@ Return to readback, even on exit 0. Only DIRECT permits **one direct fallback**:
readback has confirmed OPEN, no auto request and no queue entry, and the auto attempt
returned one of the two documented rejection classes: auto-merge disabled, or PR
already clean/unstable with nothing required pending. The latter does not mean
auto-merge is disabled. Recheck required CI as in Step 2 before the fallback; failures
or unknown check results stop, even if GitHub calls the PR mergeable.
auto-merge is disabled.
```bash
CI_GATE=$(~/.claude/skills/gstack/bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD" "${CI_OVERRIDE[@]}")
echo "$CI_GATE"
case "${CI_GATE%%$'\n'*}" in "VERDICT PASS $PR_HEAD"|"VERDICT NO_CHECKS $NO_CI_APPROVED_HEAD") ;; *) exit 1 ;; esac
MERGE_ATTEMPT=direct
MERGE_EXIT=0
MERGE_ERROR=$(gh pr merge "$MERGE_FLAG" --delete-branch "$PR_NUMBER" --repo "$REPO" --match-head-commit "$PR_HEAD" 2>&1) || MERGE_EXIT=$?
@@ -240,6 +240,7 @@ Build the full readiness report:
╠══════════════════════════════════════════════════════════╣
║ PR: #NNN — title ║
║ Branch: feature → main ║
║ CI (head <sha7>): PASS / FAIL / PENDING / NO CI RAN ║
║ REVIEWS ║
║ ├─ Eng Review: CURRENT / STALE (N commits) / — ║
║ ├─ CEO Review: CURRENT / — (optional) ║
@@ -259,6 +260,13 @@ Build the full readiness report:
╚══════════════════════════════════════════════════════════╝
```
CI row: ERROR or red/pending `required=y|?` checks are BLOCKERS. Red/pending
`required=n` checks or NO_CHECKS first need one-way question
`land-and-deploy-ci-override` / `land-and-deploy-no-ci-confirm`, naming each check
(or "no CI ran on `<sha>`"): A) exclude exactly these / accept no CI, this head → set
`CI_OVERRIDE=(--override-head "$PR_HEAD" --exclude "<name>" ...)` or
`NO_CI_APPROVED_HEAD=$PR_HEAD`, never stored or reused; B) stop (BLOCKER).
If there are BLOCKERS (including failing free tests): show the report and **STOP**
with repair instructions. Do not offer A or C with blockers.
If there are WARNINGS but no blockers: list each warning and recommend A if
@@ -238,6 +238,7 @@ Build the full readiness report:
╠══════════════════════════════════════════════════════════╣
║ PR: #NNN — title ║
║ Branch: feature → main ║
║ CI (head <sha7>): PASS / FAIL / PENDING / NO CI RAN ║
║ REVIEWS ║
║ ├─ Eng Review: CURRENT / STALE (N commits) / — ║
║ ├─ CEO Review: CURRENT / — (optional) ║
@@ -257,6 +258,13 @@ Build the full readiness report:
╚══════════════════════════════════════════════════════════╝
```
CI row: ERROR or red/pending `required=y|?` checks are BLOCKERS. Red/pending
`required=n` checks or NO_CHECKS first need one-way question
`land-and-deploy-ci-override` / `land-and-deploy-no-ci-confirm`, naming each check
(or "no CI ran on `<sha>`"): A) exclude exactly these / accept no CI, this head → set
`CI_OVERRIDE=(--override-head "$PR_HEAD" --exclude "<name>" ...)` or
`NO_CI_APPROVED_HEAD=$PR_HEAD`, never stored or reused; B) stop (BLOCKER).
If there are BLOCKERS (including failing free tests): show the report and **STOP**
with repair instructions. Do not offer A or C with blockers.
If there are WARNINGS but no blockers: list each warning and recommend A if
+295
View File
@@ -0,0 +1,295 @@
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];
}
+16
View File
@@ -467,6 +467,22 @@ export const QUESTIONS = {
signal_key: 'decision-autonomy',
description: "Canary detected regressions — roll back the deploy?",
},
'land-and-deploy-ci-override': {
id: 'land-and-deploy-ci-override',
skill: 'land-and-deploy',
category: 'approval',
door_type: 'one-way',
options: ['accept', 'reject'],
description: "Merge this head while these named non-required CI checks are red or pending?",
},
'land-and-deploy-no-ci-confirm': {
id: 'land-and-deploy-no-ci-confirm',
skill: 'land-and-deploy',
category: 'approval',
door_type: 'one-way',
options: ['accept', 'reject'],
description: "No CI ran on this head — merge it anyway?",
},
// -----------------------------------------------------------------------
// /cso — security audit
+14
View File
@@ -0,0 +1,14 @@
{
"recorded": "gh 2.101.0, garrytan/gstack#3010, 2026-10-02",
"note": "fork PR whose workflows never ran: both queries report no checks",
"required": {
"status": 1,
"stdout": "",
"stderr": "no checks reported on the 'fix/windows-autoplan' branch\n"
},
"all": {
"status": 1,
"stdout": "",
"stderr": "no checks reported on the 'fix/windows-autoplan' branch\n"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"recorded": "gh 2.101.0, garrytan/gstack#2956, 2026-10-02",
"note": "required free-tests passes; two non-required checks fail",
"required": {
"status": 0,
"stdout": "[{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107698187589\",\"name\":\"free-tests\",\"state\":\"SUCCESS\"}]\n",
"stderr": ""
},
"all": {
"status": 0,
"stdout": "[{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018432870/job/107705433100\",\"name\":\"slices-comment\",\"state\":\"SUCCESS\"},{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018432870/job/107705239677\",\"name\":\"slices-report\",\"state\":\"FAILURE\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107698187589\",\"name\":\"free-tests\",\"state\":\"SUCCESS\"},{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018432870/job/107697209743\",\"name\":\"eval-slices (5)\",\"state\":\"FAILURE\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018432870/job/107697209626\",\"name\":\"eval-slices (1)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018432870/job/107697209549\",\"name\":\"eval-slices (3)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018432870/job/107697209578\",\"name\":\"eval-slices (2)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018432870/job/107697209506\",\"name\":\"eval-slices (6)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018432870/job/107697209479\",\"name\":\"eval-slices (4)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696973299\",\"name\":\"free-suite (11)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696973102\",\"name\":\"free-suite (1)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696973000\",\"name\":\"free-suite (19)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696973034\",\"name\":\"free-suite (7)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696972897\",\"name\":\"free-suite (5)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696972820\",\"name\":\"free-suite (2)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696973141\",\"name\":\"free-suite (15)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696972954\",\"name\":\"free-suite (18)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696972870\",\"name\":\"free-suite (16)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696972851\",\"name\":\"free-suite (3)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696972840\",\"name\":\"free-suite (6)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696972871\",\"name\":\"free-suite (9)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696973072\",\"name\":\"free-suite (12)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696973017\",\"name\":\"free-suite (8)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696972927\",\"name\":\"free-suite (14)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696972873\",\"name\":\"free-suite (10)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696972963\",\"name\":\"free-suite (20)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018432870/job/107696854897\",\"name\":\"build-image\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696972780\",\"name\":\"free-suite (4)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696972843\",\"name\":\"free-suite (13)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018432959/job/107696852747\",\"name\":\"actionlint\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36018433408/job/107696972884\",\"name\"Line truncated
"stderr": ""
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"recorded": "gh 2.101.0, garrytan/gstack#2988, 2026-10-02",
"note": "required free-tests passes; all checks pass or skip",
"required": {
"status": 0,
"stdout": "[{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633770/job/110583563885\",\"name\":\"free-tests\",\"state\":\"SUCCESS\"}]\n",
"stderr": ""
},
"all": {
"status": 0,
"stdout": "[{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633770/job/110583563885\",\"name\":\"free-tests\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633770/job/110582247757\",\"name\":\"free-suite (21)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633770/job/110582154255\",\"name\":\"cso-windows-launcher\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633059/job/110582346257\",\"name\":\"validate-native (cso-runtime-inputs-2026-09-10, rails-ruby-3.4.4, rails-ruby-3.4.4-arm64, rails, ...\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633059/job/110582345999\",\"name\":\"validate-native (cso-runtime-inputs-2026-09-10, node-24.4.0, node-24.4.0-arm64, node, linux/arm64...\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633770/job/110582247797\",\"name\":\"free-suite (20)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633770/job/110582247546\",\"name\":\"free-suite (5)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633059/job/110582346175\",\"name\":\"validate-native (cso-runtime-inputs-2026-09-10, postgresql-17.2, postgresql-17.2-arm64, postgresq...\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633059/job/110582346096\",\"name\":\"validate-native (cso-runtime-inputs-2026-09-10, bun-1.3.10, bun-1.3.10-arm64, bun, linux/arm64, a...\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633770/job/110582247541\",\"name\":\"free-suite (11)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633770/job/110582247886\",\"name\":\"free-suite (8)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633059/job/110582346456\",\"name\":\"validate-native (cso-runtime-inputs-2026-09-10, python-3.13.4-uv-0.8.0, python-3.13.4-uv-0.8.0-ar...\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633770/job/110582247616\",\"name\":\"free-suite (7)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633059/job/110582346128\",\"name\":\"validate-native (cso-runtime-inputs-2026-09-10, postgresql-17.2, postgresql-17.2-amd64, postgresq...\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633770/job/110582247642\",\"name\":\"free-suite (19)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633770/job/110582247676\",\"name\":\"free-suite (6)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633059/job/110582346229\",\"name\":\"validate-native (cso-runtime-inputs-2026-09-10, rails-ruby-3.4.4, rails-ruby-3.4.4-amd64, rails, ...\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633770/job/110582247385\",\"name\":\"free-suite (1)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633770/job/110582247456\",\"name\":\"free-suite (12)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633059/job/110582346190\",\"name\":\"validate-native (cso-runtime-inputs-2026-09-10, python-3.13.4-uv-0.8.0, python-3.13.4-uv-0.8.0-am...\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633770/job/110582247400\",\"name\":\"free-suite (14)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633059/job/110582346195\",\"name\":\"validate-native (cso-runtime-inputs-2026-09-10, bun-1.3.10, bun-1.3.10-amd64, bun, linux/amd64, a...\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633770/job/110582247704\",\"name\":\"free-suite (17)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633059/job/110582346149\",\"name\":\"validate-native (cso-runtime-inputs-2026-09-10, node-24.4.0, node-24.4.0-amd64, node, linux/amd64...\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36925633770/job/110582247429\",\"name\":\"free-suite (3)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstaLine truncated
"stderr": ""
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"recorded": "gh 2.101.0, garrytan/gstack#3005, 2026-10-02",
"note": "required free-tests fails",
"required": {
"status": 0,
"stdout": "[{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109942442346\",\"name\":\"free-tests\",\"state\":\"FAILURE\"}]\n",
"stderr": ""
},
"all": {
"status": 0,
"stdout": "[{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109942442346\",\"name\":\"free-tests\",\"state\":\"FAILURE\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769622\",\"name\":\"free-suite (21)\",\"state\":\"SUCCESS\"},{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769405\",\"name\":\"free-suite (1)\",\"state\":\"FAILURE\"},{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769674\",\"name\":\"free-suite (13)\",\"state\":\"FAILURE\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769647\",\"name\":\"free-suite (20)\",\"state\":\"SUCCESS\"},{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769835\",\"name\":\"free-suite (3)\",\"state\":\"FAILURE\"},{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769784\",\"name\":\"free-suite (18)\",\"state\":\"FAILURE\"},{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769605\",\"name\":\"free-suite (7)\",\"state\":\"FAILURE\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769847\",\"name\":\"free-suite (4)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769775\",\"name\":\"free-suite (10)\",\"state\":\"SUCCESS\"},{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769659\",\"name\":\"free-suite (15)\",\"state\":\"FAILURE\"},{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769687\",\"name\":\"free-suite (11)\",\"state\":\"FAILURE\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769467\",\"name\":\"free-suite (2)\",\"state\":\"SUCCESS\"},{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769597\",\"name\":\"free-suite (14)\",\"state\":\"FAILURE\"},{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769569\",\"name\":\"free-suite (9)\",\"state\":\"FAILURE\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769539\",\"name\":\"free-suite (16)\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769507\",\"name\":\"free-suite (8)\",\"state\":\"SUCCESS\"},{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769577\",\"name\":\"free-suite (12)\",\"state\":\"FAILURE\"},{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769390\",\"name\":\"free-suite (17)\",\"state\":\"FAILURE\"},{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769541\",\"name\":\"free-suite (5)\",\"state\":\"FAILURE\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769237\",\"name\":\"free-suite (6)\",\"state\":\"SUCCESS\"},{\"bucket\":\"fail\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940769331\",\"name\":\"free-suite (19)\",\"state\":\"FAILURE\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940659703\",\"name\":\"cso-macos-launcher\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112290/job/109940656471\",\"name\":\"Sync PR title to VERSION\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940659738\",\"name\":\"cso-docker-integration\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940659524\",\"name\":\"cso-windows-launcher\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940659062\",\"name\":\"typecheck\",\"state\":\"SUCCESS\"},{\"bucket\":\"pass\",\"link\":\"https://github.com/garrytan/gstack/actions/runs/36731112282/job/109940659494\",\"name\":\"free-plan\",\"state\":\"SUCCESS\"}]\n",
"stderr": ""
}
}
+306
View File
@@ -0,0 +1,306 @@
/**
* lib/ci-gate.ts — the /land-and-deploy merge gate (#2995).
*
* Fixtures under test/fixtures/ci-gate/ are real `gh pr checks --json` output
* (gh 2.101.0 against garrytan/gstack PRs); the remaining cases are synthetic
* responses in the same shape. gh exits 0 with --json even when checks are red
* or pending, so the verdict must come from `bucket`, never the exit status.
*/
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { ciGateMain, evaluateChecks, parseCiGateArgs, runCiGate, type CiGateOptions, type GhResult, type GhRunner } from '../lib/ci-gate';
const HEAD = 'a'.repeat(40);
const OTHER = 'b'.repeat(40);
const fixture = (name: string): { required: GhResult; all: GhResult } =>
JSON.parse(readFileSync(join(import.meta.dir, 'fixtures', 'ci-gate', `${name}.json`), 'utf8'));
const ok = (rows: unknown): GhResult => ({ status: 0, stdout: JSON.stringify(rows), stderr: '' });
const err = (stderr: string, status = 1): GhResult => ({ status, stdout: '', stderr: `${stderr}\n` });
const row = (name: string, bucket?: string) => ({ name, state: 'X', link: `https://ci.example/${encodeURIComponent(name)}`, ...(bucket === undefined ? {} : { bucket }) });
const NONE = err("no checks reported on the 'feature' branch");
const NONE_REQUIRED = err("no required checks reported on the 'feature' branch");
interface Script { heads?: string[]; required?: GhResult[]; all?: GhResult[]; api?: GhResult }
function fakeGh(script: Script): GhRunner & { calls: string[][] } {
const queues = { head: script.heads ?? [HEAD], required: script.required ?? [], all: script.all ?? [] };
const next = <T>(queue: T[]): T => (queue.length > 1 ? queue.shift()! : queue[0]);
const calls: string[][] = [];
const gh = ((args: string[]) => {
calls.push(args);
if (args[0] === 'api') return script.api ?? err('gh: Not Found (HTTP 404)');
if (args[1] === 'view') return { status: 0, stdout: `${next(queues.head)}\n`, stderr: '' };
if (args[1] === 'merge') throw new Error('the gate must never merge');
return next(args.includes('--required') ? queues.required : queues.all);
}) as GhRunner & { calls: string[][] };
gh.calls = calls;
return gh;
}
const options = (extra: Partial<CiGateOptions> = {}): CiGateOptions => ({
repo: 'owner/project', pr: '42', expectHead: HEAD, excludes: [], waitSeconds: 0, intervalSeconds: 30, registrationWaitSeconds: 0, ...extra,
});
const noSleep = async () => {};
const gate = (script: Script, extra: Partial<CiGateOptions> = {}) => runCiGate(options(extra), fakeGh(script), noSleep);
const checkLines = (lines: string[]) => lines.filter(line => line.startsWith('CHECK\t'));
describe('verdicts on recorded gh output', () => {
test('protected repo, required and all checks green or skipped → PASS', async () => {
const { required, all } = fixture('protected-pass');
expect(JSON.parse(all.stdout).some((c: { bucket: string }) => c.bucket === 'skipping')).toBe(true);
const result = await gate({ required: [required], all: [all] });
expect(result.verdict).toBe('PASS');
expect(checkLines(result.lines)).toEqual([]);
});
test('required check red → FAIL naming it as required', async () => {
const { required, all } = fixture('protected-required-red');
const result = await gate({ required: [required], all: [all] });
expect(result.verdict).toBe('FAIL');
expect(checkLines(result.lines)).toContainEqual(expect.stringMatching(/^CHECK\tfree-tests\tfail\trequired=y\thttps:/));
expect(checkLines(result.lines).filter(line => line.includes('required=n')).length).toBeGreaterThan(0);
});
test('required green but non-required red → FAIL (strict all-checks default)', async () => {
const { required, all } = fixture('protected-nonrequired-red');
expect(required.status).toBe(0);
const result = await gate({ required: [required], all: [all] });
expect(result.verdict).toBe('FAIL');
expect(checkLines(result.lines).map(line => line.split('\t').slice(1, 4))).toEqual([
['eval-slices (5)', 'fail', 'required=n'],
['slices-report', 'fail', 'required=n'],
]);
});
test('no CI on the head at all → NO_CHECKS with SHA and workflow presence', async () => {
const { required, all } = fixture('no-checks');
const result = await gate({ required: [required], all: [all], api: ok(2) });
expect(result.verdict).toBe('NO_CHECKS');
expect(result.sha).toBe(HEAD);
expect(result.lines).toContain('NOTE .github/workflows exists at this head');
expect(result.lines.some(line => line.includes(`no CI ran on ${HEAD}`))).toBe(true);
const absent = await gate({ required: [required], all: [all] });
expect(absent.lines).toContain('NOTE .github/workflows does not exist at this head');
});
});
describe('verdicts on synthetic gh output', () => {
test('unprotected repo: none required, all green → PASS; one red → FAIL (#2995)', async () => {
expect((await gate({ required: [NONE_REQUIRED], all: [ok([row('build', 'pass')])] })).verdict).toBe('PASS');
const red = await gate({ required: [NONE_REQUIRED], all: [ok([row('build', 'pass'), row('test', 'fail')])] });
expect(red.verdict).toBe('FAIL');
expect(red.lines).toContain('NOTE no required checks configured; gating on all checks');
});
test('unprotected repo with a pending check → PENDING, never a pass', async () => {
const result = await gate({ required: [NONE_REQUIRED], all: [ok([row('build', 'pass'), row('test', 'pending')])] });
expect(result.verdict).toBe('PENDING');
expect(checkLines(result.lines)).toEqual([`CHECK\ttest\tpending\trequired=n\thttps://ci.example/test`]);
});
test('403 protection metadata → gate on all checks with required=?', async () => {
const forbidden = err('GraphQL: Resource not accessible by integration (HTTP 403)');
const result = await gate({ required: [forbidden], all: [ok([row('build', 'pass'), row('lint', 'fail')])] });
expect(result.verdict).toBe('FAIL');
expect(checkLines(result.lines)).toEqual([`CHECK\tlint\tfail\trequired=?\thttps://ci.example/lint`]);
expect(result.lines.some(line => line.startsWith('NOTE required-check metadata unavailable') && line.includes('HTTP 403'))).toBe(true);
expect((await gate({ required: [forbidden], all: [ok([row('build', 'pass')])] })).verdict).toBe('PASS');
});
test('pass buckets are a whitelist; cancel and unknown buckets fail', async () => {
for (const bucket of ['pass', 'skipping', 'neutral']) {
expect((await gate({ required: [NONE_REQUIRED], all: [ok([row('x', bucket)])] })).verdict).toBe('PASS');
}
for (const bucket of ['cancel', 'fail', 'stale', 'action_required', '']) {
expect((await gate({ required: [NONE_REQUIRED], all: [ok([row('x', bucket)])] })).verdict).toBe('FAIL');
}
});
test('fail outranks pending', async () => {
expect((await gate({ required: [NONE_REQUIRED], all: [ok([row('a', 'pending'), row('b', 'cancel')])] })).verdict).toBe('FAIL');
});
test('missing bucket (old gh), malformed JSON, auth failure and unknown errors → ERROR', async () => {
const cases: GhResult[] = [
ok([{ name: 'build', state: 'SUCCESS', link: '' }]),
{ status: 0, stdout: 'not json', stderr: '' },
ok({ name: 'build', bucket: 'pass' }),
ok([{ bucket: 'pass' }]),
err('HTTP 401: Bad credentials (https://api.github.com/graphql)'),
err('unknown flag: --json'),
err('no commit found on the pull request'),
{ status: null, stdout: '', stderr: '', error: 'gh is not installed or not on PATH' },
];
for (const all of cases) {
const result = await gate({ required: [NONE_REQUIRED], all: [all] });
expect(result.verdict).toBe('ERROR');
expect(result.lines.some(line => line.startsWith('CAUSE '))).toBe(true);
expect(result.lines.some(line => line.includes('gh auth status') && line.includes('2.50.0'))).toBe(true);
}
const auth = await gate({ required: [NONE_REQUIRED], all: [err('HTTP 401: Bad credentials')] });
expect(auth.lines).toContain('STDERR HTTP 401: Bad credentials');
});
test('a nonzero exit that still carries valid JSON is judged by its buckets', async () => {
const legacy = { status: 8, stdout: JSON.stringify([row('build', 'pending')]), stderr: '' };
expect((await gate({ required: [NONE_REQUIRED], all: [legacy] })).verdict).toBe('PENDING');
});
test('worst observation wins when the two reads disagree', () => {
const result = evaluateChecks(ok([row('build', 'pending')]), ok([row('build', 'pass')]), []);
expect(result.verdict).toBe('PENDING');
expect(result.verdict !== 'ERROR' && result.lines[0]).toBe('CHECK\tbuild\tpending\trequired=y\thttps://ci.example/build');
});
test('checks that register between the two reads have unknown required status', () => {
const result = evaluateChecks(NONE, ok([row('build', 'fail')]), ['build']);
expect(result.verdict).toBe('FAIL');
expect(result.verdict !== 'ERROR' && result.lines[0]).toContain('required=?');
});
});
describe('head binding', () => {
test('a head other than --expect-head is ERROR before any check is read', async () => {
const gh = fakeGh({ heads: [OTHER], required: [NONE_REQUIRED], all: [ok([row('b', 'pass')])] });
const result = await runCiGate(options(), gh, noSleep);
expect(result.verdict).toBe('ERROR');
expect(result.sha).toBe(HEAD);
expect(result.lines).toContain(`HEAD ${OTHER}`);
expect(gh.calls.some(args => args[1] === 'checks')).toBe(false);
});
test('a head that moves while checks are read is ERROR even when checks pass', async () => {
const result = await gate({ heads: [HEAD, OTHER], required: [NONE_REQUIRED], all: [ok([row('b', 'pass')])] });
expect(result.verdict).toBe('ERROR');
expect(result.lines).toContain('CAUSE the PR head moved while checks were being read');
});
test('checks are read with and without --required, for the pinned PR and repo', async () => {
const gh = fakeGh({ required: [NONE_REQUIRED], all: [ok([row('b', 'pass')])] });
await runCiGate(options(), gh, noSleep);
expect(gh.calls).toEqual([
['pr', 'view', '42', '--repo', 'owner/project', '--json', 'headRefOid', '--jq', '.headRefOid'],
['pr', 'checks', '42', '--repo', 'owner/project', '--required', '--json', 'name,state,bucket,link'],
['pr', 'checks', '42', '--repo', 'owner/project', '--json', 'name,state,bucket,link'],
['pr', 'view', '42', '--repo', 'owner/project', '--json', 'headRefOid', '--jq', '.headRefOid'],
]);
});
});
describe('per-head, per-check overrides', () => {
const nonRequiredRed = { required: [ok([row('build', 'pass')])], all: [ok([row('build', 'pass'), row('lint', 'fail'), row('e2e', 'pending')])] };
test('excluding every named non-required failure for this head → PASS, still listed', async () => {
const result = await gate(nonRequiredRed, { excludes: ['lint', 'e2e'], overrideHead: HEAD });
expect(result.verdict).toBe('PASS');
expect(checkLines(result.lines).every(line => line.endsWith('\texcluded'))).toBe(true);
expect((await gate(nonRequiredRed, { excludes: ['lint'], overrideHead: HEAD })).verdict).toBe('PENDING');
});
test('overrides approved for another head, or without a head, are ERROR', async () => {
for (const overrideHead of [OTHER, undefined]) {
const result = await gate(nonRequiredRed, { excludes: ['lint', 'e2e'], overrideHead });
expect(result.verdict).toBe('ERROR');
expect(result.lines[0]).toBe('CAUSE check overrides were not approved for this head');
}
});
test('required and unknown-required checks can never be excluded', async () => {
const requiredRed = await gate({ required: [ok([row('build', 'fail')])], all: [ok([row('build', 'fail')])] }, { excludes: ['build'], overrideHead: HEAD });
expect(requiredRed.verdict).toBe('FAIL');
expect(checkLines(requiredRed.lines)[0]).toEndWith('\toverride-refused');
const unknown = await gate({ required: [err('HTTP 403')], all: [ok([row('lint', 'fail')])] }, { excludes: ['lint'], overrideHead: HEAD });
expect(unknown.verdict).toBe('FAIL');
});
test('an override naming no existing check is reported, not silently applied', async () => {
const result = await gate(nonRequiredRed, { excludes: ['lint', 'e2e', 'ghost'], overrideHead: HEAD });
expect(result.lines).toContain('NOTE override for unknown check "ghost" ignored');
});
});
describe('polling', () => {
const clock = () => {
let t = 0;
return { now: () => t, sleep: async (ms: number) => { t += ms; } };
};
test('delayed registration: zero checks re-polled until checks appear', async () => {
const c = clock();
const gh = fakeGh({ required: [NONE, NONE, NONE_REQUIRED], all: [NONE, NONE, ok([row('build', 'pending')])] });
const result = await runCiGate(options({ registrationWaitSeconds: 60 }), gh, c.sleep, c.now);
expect(result.verdict).toBe('PENDING');
expect(c.now()).toBe(20_000);
});
test('registration wait is bounded at 60s, then NO_CHECKS', async () => {
const c = clock();
const result = await runCiGate(options({ registrationWaitSeconds: 60 }), fakeGh({ required: [NONE], all: [NONE] }), c.sleep, c.now);
expect(result.verdict).toBe('NO_CHECKS');
expect(c.now()).toBe(60_000);
});
test('--wait polls PENDING until a final verdict', async () => {
const c = clock();
const all = [ok([row('t', 'pending')]), ok([row('t', 'pending')]), ok([row('t', 'pass')])];
const result = await runCiGate(options({ waitSeconds: 240 }), fakeGh({ required: [NONE_REQUIRED], all }), c.sleep, c.now);
expect(result.verdict).toBe('PASS');
expect(c.now()).toBe(60_000);
});
test('--wait timeout reports PENDING and names the still-pending checks', async () => {
const c = clock();
const all = [ok([row('build', 'pass'), row('deploy-preview', 'pending')])];
const result = await runCiGate(options({ waitSeconds: 240 }), fakeGh({ required: [NONE_REQUIRED], all }), c.sleep, c.now);
expect(result.verdict).toBe('PENDING');
expect(checkLines(result.lines)).toEqual(['CHECK\tdeploy-preview\tpending\trequired=n\thttps://ci.example/deploy-preview']);
expect(result.lines.some(line => line.startsWith('NOTE still pending after waiting 240s'))).toBe(true);
});
test('a failure during the wait stops immediately (fail fast)', async () => {
const c = clock();
const all = [ok([row('t', 'pending')]), ok([row('t', 'fail'), row('u', 'pending')])];
const result = await runCiGate(options({ waitSeconds: 240 }), fakeGh({ required: [NONE_REQUIRED], all }), c.sleep, c.now);
expect(result.verdict).toBe('FAIL');
expect(c.now()).toBe(30_000);
});
});
describe('CLI contract', () => {
const run = async (argv: string[], gh: GhRunner = fakeGh({ required: [NONE_REQUIRED], all: [ok([row('b', 'pass')])] })) => {
let out = '';
const code = await ciGateMain(argv, gh, text => { out += text; });
return { code, out };
};
const base = ['--repo', 'owner/project', '--pr', '42', '--expect-head', HEAD, '--registration-wait', '0'];
test('VERDICT is the first line and exit codes are 0/1/2/3/4', async () => {
const cases: Array<[Script, string, number]> = [
[{ required: [NONE_REQUIRED], all: [ok([row('b', 'pass')])] }, 'PASS', 0],
[{ required: [NONE_REQUIRED], all: [ok([row('b', 'fail')])] }, 'FAIL', 1],
[{ required: [NONE_REQUIRED], all: [ok([row('b', 'pending')])] }, 'PENDING', 2],
[{ required: [NONE], all: [NONE] }, 'NO_CHECKS', 3],
[{ required: [NONE_REQUIRED], all: [err('HTTP 502')] }, 'ERROR', 4],
];
for (const [script, verdict, code] of cases) {
const result = await run(base, fakeGh(script));
expect(result.out.split('\n')[0]).toBe(`VERDICT ${verdict} ${HEAD}`);
expect(result.code).toBe(code);
}
});
test('--help documents the verdicts and exit codes', async () => {
const result = await run(['--help']);
expect(result.code).toBe(0);
expect(result.out).toContain('Exit codes: 0 PASS, 1 FAIL, 2 PENDING, 3 NO_CHECKS, 4 ERROR.');
expect(result.out).toContain('--expect-head');
});
test('bad arguments are ERROR, never a pass', async () => {
for (const argv of [[], ['--repo', 'x', '--pr', '1', '--expect-head', HEAD], [...base, '--interval', '0'], [...base, '--bogus'], [...base.slice(0, 4), '--expect-head', 'abc']]) {
const result = await run(argv);
expect(result.code).toBe(4);
expect(result.out).toStartWith('VERDICT ERROR ');
}
expect(parseCiGateArgs([...base, '--exclude', 'a b', '--override-head', HEAD])).toMatchObject({ excludes: ['a b'], overrideHead: HEAD });
});
});
+217
View File
@@ -0,0 +1,217 @@
/**
* /land-and-deploy merge gate journeys (#2995): v1.90.2.0 gated only on
* `gh pr checks --required`, so repos without required checks merged over red
* or pending CI. These tests run the template's own merge blocks with the real
* bin/gstack-ci-gate against a fake `gh` that records every `gh pr merge`.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { getQuestion } from '../scripts/question-registry';
const ROOT = join(import.meta.dir, '..');
const GATE_BIN = join(ROOT, 'bin', 'gstack-ci-gate');
const source = (name: string) => readFileSync(join(ROOT, 'land-and-deploy', name), 'utf8');
const HEAD = 'a'.repeat(40);
const OTHER = 'b'.repeat(40);
const FAKE_GH = `#!/usr/bin/env bash
D="$FAKE_GH_DIR"
if [ "$1" = api ]; then kind=api
elif [ "$1 $2" = "pr merge" ]; then printf '%s\\n' "$*" >> "$D/merges.log"; exit 0
elif [ "$1 $2" = "pr view" ]; then kind=head
elif [ "$1 $2" = "pr checks" ]; then case " $* " in *" --required "*) kind=required ;; *) kind=all ;; esac
else echo "fake gh: unexpected $*" >&2; exit 97
fi
n=$(( $(cat "$D/count.$kind" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$D/count.$kind"
f="$D/$kind.$n"; [ -e "$f.code" ] || f="$D/$kind"
[ -e "$f.out" ] && cat "$f.out"
[ -e "$f.err" ] && cat "$f.err" >&2
exit "$(cat "$f.code" 2>/dev/null || echo 1)"
`;
type Reply = { out?: string; err?: string; code?: number };
const checks = (...rows: Array<[string, string]>): Reply => ({ out: JSON.stringify(rows.map(([name, bucket]) => ({ name, bucket, state: 'X', link: `https://ci.example/${name}` }))), code: 0 });
const NONE: Reply = { err: "no checks reported on the 'feature' branch", code: 1 };
const NONE_REQUIRED: Reply = { err: "no required checks reported on the 'feature' branch", code: 1 };
const head = (sha: string): Reply => ({ out: `${sha}\n`, code: 0 });
/** The two fenced blocks in Step 4 that run `gh pr merge`, keyed by mode. */
function mergeBlocks(): Record<'auto' | 'direct', string> {
const blocks = [...source('sections/merge-and-deploy.md.tmpl').matchAll(/```bash\n([\s\S]*?)\n```/g)]
.map(match => match[1])
.filter(block => /\bgh pr merge /.test(block));
expect(blocks).toHaveLength(2);
const auto = blocks.find(block => block.includes('--auto'));
const direct = blocks.find(block => !block.includes('--auto'));
expect(auto && direct).toBeTruthy();
return { auto: auto!, direct: direct! };
}
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'ci-gate-journey-'));
mkdirSync(join(dir, 'bin'));
writeFileSync(join(dir, 'bin', 'gh'), FAKE_GH);
chmodSync(join(dir, 'bin', 'gh'), 0o755);
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
interface Journey {
heads?: Reply[]; required: Reply | Reply[]; all: Reply | Reply[]; api?: Reply;
mode: 'auto' | 'direct'; noCiApprovedHead?: string; override?: string;
}
function runJourney(j: Journey) {
const replies: Record<string, Reply | Reply[] | undefined> = { head: j.heads ?? [head(HEAD)], required: j.required, all: j.all, api: j.api ?? { err: 'HTTP 404', code: 1 } };
for (const [kind, reply] of Object.entries(replies)) {
const list = Array.isArray(reply) ? reply : [reply!];
list.forEach((r, i) => {
const stem = join(dir, i === list.length - 1 ? kind : `${kind}.${i + 1}`);
writeFileSync(`${stem}.out`, r.out ?? '');
writeFileSync(`${stem}.err`, r.err ?? '');
writeFileSync(`${stem}.code`, String(r.code ?? 0));
});
}
const block = mergeBlocks()[j.mode].replaceAll('~/.claude/skills/gstack/bin/gstack-ci-gate', GATE_BIN);
const script = `${j.override ? `CI_OVERRIDE=(${j.override})\n` : ''}${block}`;
const result = spawnSync('bash', ['-c', script], {
encoding: 'utf8', timeout: 30_000,
env: {
PATH: `${join(dir, 'bin')}:${process.env.PATH}`, HOME: dir, FAKE_GH_DIR: dir,
REPO: 'owner/project', PR_NUMBER: '42', PR_HEAD: HEAD, MERGE_FLAG: '--squash',
NO_CI_APPROVED_HEAD: j.noCiApprovedHead ?? '',
},
});
const log = join(dir, 'merges.log');
const merges = existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [];
for (const name of readdirSync(dir).filter(name => name.startsWith('count.'))) rmSync(join(dir, name));
rmSync(log, { force: true });
return { ...result, merges };
}
describe('merge blocks never merge without a PASS for the pinned head', () => {
const blocked: Array<[string, Omit<Journey, 'mode'>]> = [
['pending check, no required checks configured', { required: NONE_REQUIRED, all: checks(['build', 'pass'], ['test', 'pending']) }],
['failing check, no required checks configured', { required: NONE_REQUIRED, all: checks(['build', 'fail']) }],
['failing required check', { required: checks(['ci', 'fail']), all: checks(['ci', 'fail']) }],
['cancelled check', { required: NONE_REQUIRED, all: checks(['build', 'cancel']) }],
['unknown bucket', { required: NONE_REQUIRED, all: checks(['build', 'stale']) }],
['no checks and no explicit no-CI approval', { required: NONE, all: NONE }],
['no-CI approval recorded for another head', { required: NONE, all: NONE, noCiApprovedHead: OTHER }],
['malformed checks JSON', { required: NONE_REQUIRED, all: { out: '{"oops"', code: 0 } }],
['check without a bucket field (old gh)', { required: NONE_REQUIRED, all: { out: '[{"name":"build","state":"SUCCESS"}]', code: 0 } }],
['gh auth failure', { required: { err: 'HTTP 401: Bad credentials', code: 1 }, all: { err: 'HTTP 401: Bad credentials', code: 1 } }],
['checks registered after the no-CI approval', { required: NONE_REQUIRED, all: checks(['build', 'pending']), noCiApprovedHead: HEAD }],
['head moved mid-gate', { heads: [head(HEAD), head(OTHER)], required: NONE_REQUIRED, all: checks(['build', 'pass']) }],
['head already moved', { heads: [head(OTHER)], required: NONE_REQUIRED, all: checks(['build', 'pass']) }],
['required green, non-required pending', { required: checks(['ci', 'pass']), all: checks(['ci', 'pass'], ['preview', 'pending']) }],
['required green, non-required red', { required: checks(['ci', 'pass']), all: checks(['ci', 'pass'], ['lint', 'fail']) }],
['override approved for another head', { required: checks(['ci', 'pass']), all: checks(['ci', 'pass'], ['lint', 'fail']), override: `--override-head ${OTHER} --exclude lint` }],
['override naming a required check', { required: checks(['ci', 'fail']), all: checks(['ci', 'fail']), override: `--override-head ${HEAD} --exclude ci` }],
];
for (const mode of ['auto', 'direct'] as const) {
for (const [name, journey] of blocked) {
test(`${mode}: ${name} → zero merges`, () => {
const result = runJourney({ ...journey, mode });
expect(result.merges).toEqual([]);
expect(result.status).not.toBe(0);
expect(result.stdout).toMatch(/^VERDICT (FAIL|PENDING|NO_CHECKS|ERROR) /);
});
}
}
});
describe('positive controls: the same blocks do merge when the gate passes', () => {
for (const mode of ['auto', 'direct'] as const) {
test(`${mode}: all green → exactly one merge pinned to the head`, () => {
const result = runJourney({ mode, required: checks(['ci', 'pass']), all: checks(['ci', 'pass'], ['docs', 'skipping']) });
expect(result.status).toBe(0);
expect(result.merges).toHaveLength(1);
expect(result.merges[0]).toContain(`--match-head-commit ${HEAD}`);
expect(result.merges[0].includes('--auto')).toBe(mode === 'auto');
});
test(`${mode}: explicit per-head override of named non-required checks → one merge`, () => {
const result = runJourney({
mode, required: checks(['ci', 'pass']), all: checks(['ci', 'pass'], ['lint check', 'fail']),
override: `--override-head "$PR_HEAD" --exclude "lint check"`,
});
expect(result.stdout).toContain('\texcluded');
expect(result.merges).toHaveLength(1);
});
test(`${mode}: no CI on the head, explicitly confirmed for this head → one merge`, () => {
const result = runJourney({ mode, required: NONE, all: NONE, noCiApprovedHead: HEAD });
expect(result.stdout).toStartWith(`VERDICT NO_CHECKS ${HEAD}`);
expect(result.merges).toHaveLength(1);
});
}
});
describe('template wiring', () => {
const main = source('SKILL.md.tmpl');
const step = (title: string, next: string) => main.slice(main.indexOf(title), main.indexOf(next));
test('Step 2 and the Step 3 CI wait call the helper, not a --required-only query', () => {
const step2 = step('## Step 2: Pre-merge checks', '## Step 3: Wait for CI');
const step3 = step('## Step 3: Wait for CI', '## Step 3.4');
expect(step2).toContain('bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD"');
expect(step3).toMatch(/bin\/gstack-ci-gate [^\n]*--expect-head "\$PR_HEAD" --wait \d+/);
expect(`${step2}${step3}`).not.toMatch(/gh pr checks[^\n]*--required/);
expect(step3).not.toContain('--watch');
expect(step3).toContain('still-pending checks');
expect(step2).toContain('never on the exit code');
});
test('both merge commands, including the direct fallback, run only after the gate', () => {
for (const block of Object.values(mergeBlocks())) {
const gate = block.indexOf('bin/gstack-ci-gate');
expect(gate).toBeGreaterThanOrEqual(0);
expect(gate).toBeLessThan(block.indexOf('gh pr merge'));
expect(block).toContain('"VERDICT PASS $PR_HEAD"|"VERDICT NO_CHECKS $NO_CI_APPROVED_HEAD") ;;');
expect(block).toContain('*) exit 1 ;;');
}
expect(source('sections/merge-and-deploy.md.tmpl')).toMatch(/Only\s+branch\s+protection\s+or\s+a\s+merge\s+queue\s+makes\s+check-to-merge\s+atomic/);
});
test('readiness report carries a CI row and one-way override questions', () => {
const readiness = source('sections/readiness-gate.md.tmpl');
expect(readiness).toContain('CI (head <sha7>)');
expect(readiness).toContain('land-and-deploy-ci-override');
expect(readiness).toContain('land-and-deploy-no-ci-confirm');
expect(readiness).toContain('never stored or reused');
});
test('generated skill files carry the helper calls', () => {
expect(source('SKILL.md')).toContain('bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD"');
expect(source('sections/merge-and-deploy.md').match(/bin\/gstack-ci-gate /g)).toHaveLength(2);
expect(source('sections/readiness-gate.md')).toContain('CI (head <sha7>)');
});
});
describe('CI override questions are one-way doors', () => {
const ids = ['land-and-deploy-ci-override', 'land-and-deploy-no-ci-confirm'];
const PREF = join(ROOT, 'bin', 'gstack-question-preference');
const pref = (...args: string[]) => spawnSync(PREF, args, { encoding: 'utf8', cwd: ROOT, timeout: 30_000, env: { ...process.env, GSTACK_HOME: dir } });
test('registered as one-way', () => {
for (const id of ids) expect(getQuestion(id)).toMatchObject({ skill: 'land-and-deploy', door_type: 'one-way' });
});
test('a never-ask preference cannot be written or auto-decide the merge over red', () => {
for (const id of ids) {
const write = pref('--write', JSON.stringify({ question_id: id, preference: 'never-ask', source: 'plan-tune' }));
expect(write.status).toBe(1);
expect(write.stderr).toContain('(door_type: one-way)');
pref('--read');
const project = readdirSync(join(dir, 'projects'))[0];
const file = join(dir, 'projects', project, 'question-preferences.json');
writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file, 'utf8')), [id]: 'never-ask' }));
const check = pref('--check', id);
expect(check.stdout).toContain('ASK_NORMALLY');
expect(check.stdout).not.toContain('AUTO_DECIDE');
}
});
});