Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
when given no decisions to combine.
- The CUJ gate `WARN`s a touched journey whose tests didn't run this change (`NOT_TESTED`)
instead of reporting it "healthy" against a DEGRADED/BROKEN baseline.
- **The GitHub Action now fails _closed_.** A crashed/absent `warden report aggregate` step, or an
aggregate report with a missing/unrecognized gate decision, previously defaulted the merge check
to `PASS` — a broken gate could post a green check and unblock a merge. Both now resolve to
`BLOCK`; only an explicit, recognized `PASS`/`WARN`/`BLOCK` is trusted.

## [0.4.0] — 2026-07-10 · "Dogfood"

Expand Down
23 changes: 12 additions & 11 deletions packages/github-action/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36522,18 +36522,17 @@ function parseGithubOutput(stdout) {
return out;
}
function normalizeGate(raw, fallbackReason) {
if (raw && typeof raw === "object") {
const g = raw;
const decision2 = String(g.decision ?? "PASS").toUpperCase();
return {
decision: decision2 === "BLOCK" || decision2 === "WARN" ? decision2 : "PASS",
reason: typeof g.reason === "string" ? g.reason : ""
};
const isObject = Boolean(raw) && typeof raw === "object";
const g = isObject ? raw : void 0;
const rawDecision = g ? g.decision : raw;
const reason = g ? typeof g.reason === "string" ? g.reason : "" : typeof fallbackReason === "string" ? fallbackReason : "";
const decision = String(rawDecision ?? "").toUpperCase();
if (decision === "PASS" || decision === "WARN" || decision === "BLOCK") {
return { decision, reason };
}
const decision = String(raw ?? "PASS").toUpperCase();
return {
decision: decision === "BLOCK" || decision === "WARN" ? decision : "PASS",
reason: typeof fallbackReason === "string" ? fallbackReason : ""
decision: "BLOCK",
reason: reason || "unrecognized or missing gate decision \u2014 failing closed"
};
}
function parseAggregateReport(stdout) {
Expand Down Expand Up @@ -36730,7 +36729,9 @@ async function run(deps = {}) {
report = await aggregate(exec2, { reportsDir, prNumber: pr.number, ...execOpts });
} catch (err) {
core.warning(`Warden: aggregate failed: ${errMsg2(err)}`);
report = { gate: { decision: "PASS", reason: "No aggregated results available." } };
report = {
gate: { decision: "BLOCK", reason: `aggregate failed \u2014 gate not evaluated: ${errMsg2(err)}` }
};
}
const gate = report.gate.decision;
const reportPath = report.reportPath ?? path6.join(reportsDir, "warden-ctrf.json");
Expand Down
17 changes: 17 additions & 0 deletions packages/github-action/src/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,21 @@ describe('parseAggregateReport', () => {
it('throws a WardenError when there is no JSON', () => {
expect(() => parseAggregateReport('no json here')).toThrow(WardenError);
});

it('fails closed (BLOCK) on an unrecognized gate decision', () => {
const report = parseAggregateReport(JSON.stringify({ gate: { decision: 'WEIRD' } }));
expect(report.gate.decision).toBe('BLOCK');
});

it('fails closed (BLOCK) when the gate decision is missing', () => {
const report = parseAggregateReport(JSON.stringify({ gate: { reason: 'x' } }));
expect(report.gate.decision).toBe('BLOCK');
});

it('still honors an explicit PASS decision', () => {
const report = parseAggregateReport(
JSON.stringify({ gate: { decision: 'PASS', reason: 'ok' } }),
);
expect(report.gate).toEqual({ decision: 'PASS', reason: 'ok' });
});
});
30 changes: 20 additions & 10 deletions packages/github-action/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,29 @@ export interface AggregateReport {
markdown?: string;
}

/**
* Only an explicit, recognized PASS/WARN/BLOCK is trusted. A missing or unrecognized decision
* fails **closed** to BLOCK — a gate report we can't read must never read as a green merge signal.
*/
function normalizeGate(raw: unknown, fallbackReason: unknown): AggregateReport['gate'] {
if (raw && typeof raw === 'object') {
const g = raw as { decision?: unknown; reason?: unknown };
const decision = String(g.decision ?? 'PASS').toUpperCase();
return {
decision: (decision === 'BLOCK' || decision === 'WARN' ? decision : 'PASS') as GateVerdict,
reason: typeof g.reason === 'string' ? g.reason : '',
};
const isObject = Boolean(raw) && typeof raw === 'object';
const g = isObject ? (raw as { decision?: unknown; reason?: unknown }) : undefined;
const rawDecision = g ? g.decision : raw;
const reason = g
? typeof g.reason === 'string'
? g.reason
: ''
: typeof fallbackReason === 'string'
? fallbackReason
: '';

const decision = String(rawDecision ?? '').toUpperCase();
if (decision === 'PASS' || decision === 'WARN' || decision === 'BLOCK') {
return { decision: decision as GateVerdict, reason };
}
const decision = String(raw ?? 'PASS').toUpperCase();
return {
decision: (decision === 'BLOCK' || decision === 'WARN' ? decision : 'PASS') as GateVerdict,
reason: typeof fallbackReason === 'string' ? fallbackReason : '',
decision: 'BLOCK',
reason: reason || 'unrecognized or missing gate decision — failing closed',
};
}

Expand Down
28 changes: 28 additions & 0 deletions packages/github-action/src/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,34 @@ describe('run', () => {
expect(runCall).toBeDefined();
});

it('fails closed (BLOCK) when the aggregate step crashes', async () => {
const exec: ExecFn = (_command, args) => {
if (args.includes('analyze')) {
return Promise.resolve({
stdout: 'test_tags=@x\nrisk_score=1\nrun_full_suite=false\n',
stderr: '',
});
}
if (args.includes('aggregate')) {
return Promise.reject(new Error('aggregate boom'));
}
return Promise.resolve({ stdout: '{}', stderr: '' });
};

await run({
core,
octokit: octo.octokit,
exec,
env: { GITHUB_REPOSITORY: 'acme/shop' },
eventPath: '/event.json',
fs: fakeFs(PR_EVENT),
});

// A gate that could not be evaluated must never read as green.
expect(core.outputs.gate).toBe('BLOCK');
expect(core.failed.length).toBeGreaterThan(0);
});

it('skips the AI agent when risk is below the threshold and passes the gate', async () => {
const passReport = {
gate: { decision: 'PASS', reason: 'All exit criteria met' },
Expand Down
6 changes: 5 additions & 1 deletion packages/github-action/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,12 @@ export async function run(deps: ActionDeps = {}): Promise<RunResult> {
try {
report = await aggregate(exec, { reportsDir, prNumber: pr.number, ...execOpts });
} catch (err) {
// Fail closed: a gate that could not be evaluated must not post a green check that unblocks
// the merge. Surface the crash as a BLOCK rather than defaulting to PASS.
core.warning(`Warden: aggregate failed: ${errMsg(err)}`);
report = { gate: { decision: 'PASS', reason: 'No aggregated results available.' } };
report = {
gate: { decision: 'BLOCK', reason: `aggregate failed — gate not evaluated: ${errMsg(err)}` },
};
}
const gate = report.gate.decision;
const reportPath = report.reportPath ?? path.join(reportsDir, 'warden-ctrf.json');
Expand Down