mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* 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>
296 lines
15 KiB
TypeScript
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];
|
|
}
|