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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ jobs:
- name: PR policy self-test
run: npm run check:pr-policy

- name: Gate-manifest self-test
run: npm run check:gate-manifest

- name: Codebase index coverage
run: npm run docs:check-index

Expand All @@ -177,6 +180,20 @@ jobs:
- name: Brand asset check
run: npm run brand:check

# More generated-output drift detectors that were local-only in
# verify:cheap — same rationale as the design-system guards above. Without
# them a stale site map, therapy index, or design-system contract merged
# green because no workflow ran them. The gate-manifest self-test now fails
# CI if this list drifts from verify:cheap again.
- name: Site map drift
run: npm run sitemap:check

- name: Therapy data index drift
run: npm run check:therapy-data-index

- name: Design-system contract
run: npm run check:design-system-contract

# Fails if a SECURITY DEFINER public function is left executable by
# PUBLIC/anon (privilege-escalation / cross-tenant read surface).
- name: Function-grant guard
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"test:e2e:visual": "node scripts/run-playwright.mjs --config=playwright.visual.config.ts",
"test:cross-tenant:staging": "node scripts/run-tsx.mjs scripts/test-cross-tenant-staging.ts",
"verify:cheap": "node scripts/run-heavy.mjs --npm-script verify:cheap:internal",
"verify:cheap:internal": "npm run check:runtime && npm run check:github-actions && npm run check:ci-scope && npm run check:ci-triage && npm run check:pr-policy && npm run sitemap:check && npm run docs:check-index && npm run check:knip && npm run check:maintainability-budgets && npm run brand:check && npm run check:therapy-data-index && npm run check:type-scale && npm run check:icon-scale && npm run check:design-system-contract && npm run check:function-grants && npm run check:owner-scope && npm run lint && npm run typecheck && npm run test",
"verify:cheap:internal": "npm run check:runtime && npm run check:github-actions && npm run check:ci-scope && npm run check:ci-triage && npm run check:pr-policy && npm run check:gate-manifest && npm run sitemap:check && npm run docs:check-index && npm run check:knip && npm run check:maintainability-budgets && npm run brand:check && npm run check:therapy-data-index && npm run check:type-scale && npm run check:icon-scale && npm run check:design-system-contract && npm run check:function-grants && npm run check:owner-scope && npm run lint && npm run typecheck && npm run test",
"verify:pr-local": "node scripts/verify-pr-local.mjs",
"verify:ui": "npm run check:runtime && npm run test:e2e:pr",
"verify:release": "npm run check:runtime && npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e && npm run check:production-readiness && npm run governance:release && npm run eval:quality:release",
Expand All @@ -57,6 +57,7 @@
"check:maintainability-budgets": "node scripts/check-maintainability-budgets.mjs",
"check:ci-scope": "node scripts/ci-change-scope.mjs --self-test",
"check:ci-triage": "node scripts/ci-triage.mjs --self-test",
"check:gate-manifest": "node scripts/check-gate-manifest.mjs",
"check:pr-policy": "node scripts/pr-policy.mjs --self-test && node scripts/check-pr-policy-workflow.mjs",
"check:env-parity": "node scripts/check-env-parity.mjs",
"sweep:branch-ledger": "node scripts/sweep-branch-ledger.mjs",
Expand Down
94 changes: 94 additions & 0 deletions scripts/check-gate-manifest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env node
// Gate-manifest self-test (maturity L3).
//
// Invariant: every static gate in the local `verify:cheap:internal` chain must
// also run in CI. Without this, a gate added to the local chain can be silently
// missed in `.github/workflows/ci.yml`, so a regression it would catch merges
// green because no workflow runs it. This has already happened twice — the
// `static-pr` job comment records type/icon/brand being promoted after that
// exact miss, and sitemap/therapy-data-index/design-system-contract were a
// second instance. This check makes the drift a hard CI failure instead.
//
// Direction is one-way on purpose: CI may run MORE than the local chain (e.g.
// `format:check`, or heavier build/e2e gates in other jobs). It must never run
// LESS of the local static set.
import { readFileSync } from "node:fs";

const pkg = JSON.parse(readFileSync("package.json", "utf8"));
const ci = readFileSync(".github/workflows/ci.yml", "utf8");

const localChain = pkg.scripts?.["verify:cheap:internal"] ?? "";
const localGates = [...localChain.matchAll(/npm run ([\w:.-]+)/g)].map((m) => m[1]);
if (localGates.length === 0) {
console.error("gate-manifest: could not parse verify:cheap:internal from package.json.");
process.exit(1);
}

// The `npm run <script>` invoked by a real YAML `run:` step, anchored to the
// field so a comment that merely mentions `run: npm run X` cannot masquerade as
// an executed gate (which would let the drift check pass after the real step was
// deleted). Trailing `# comment` on the step line is allowed. Steps in this repo
// are single-command, so a single capture is sufficient.
const npmRunScript = (line) => line.match(/^\s*(?:-\s*)?run:\s+npm run ([\w:.-]+)\s*(?:#.*)?$/)?.[1];

// Extract the `run: npm run X` scripts inside a named top-level job (2-space key).
function jobScripts(name) {
const lines = ci.split(/\r?\n/);
const start = lines.findIndex((line) => line === ` ${name}:`);
if (start === -1) return null;
const scripts = [];
for (let i = start + 1; i < lines.length; i++) {
if (/^ \S/.test(lines[i])) break; // reached the next top-level job
const script = npmRunScript(lines[i]);
if (script) scripts.push(script);
}
return scripts;
}

const staticPr = jobScripts("static-pr");
if (!staticPr) {
console.error("gate-manifest: could not find the `static-pr` job in .github/workflows/ci.yml.");
process.exit(1);
}

// Every `npm run X` anywhere in ci.yml — used to satisfy gates that run in CI
// under a different job/name than in the local chain.
const allCiScripts = new Set(ci.split(/\r?\n/).map(npmRunScript).filter(Boolean));

// A local gate whose CI counterpart has a different script name / job.
const CI_EQUIVALENT = new Map([
// `npm run test` locally is the full vitest run; CI enforces it as coverage in
// the dedicated `coverage` job, not in static-pr.
["test", "test:coverage"],
]);

// Local gates that deliberately do NOT run in CI. Empty today; kept as the
// explicit, reviewed escape hatch so any future exemption is a conscious edit.
const LOCAL_ONLY = new Set();

const failures = [];
for (const gate of localGates) {
if (LOCAL_ONLY.has(gate)) continue;
const equivalent = CI_EQUIVALENT.get(gate);
if (equivalent) {
if (!allCiScripts.has(equivalent)) {
failures.push(`verify:cheap runs "${gate}" (CI counterpart "${equivalent}") but no CI job runs "${equivalent}".`);
}
continue;
}
if (!staticPr.includes(gate)) {
failures.push(
`verify:cheap runs "${gate}" but the static-pr CI job does not — add "- run: npm run ${gate}" to the static-pr job in .github/workflows/ci.yml, or record a mapping/exemption in scripts/check-gate-manifest.mjs.`,
);
}
}

if (failures.length > 0) {
console.error("Gate-manifest drift — a local verify:cheap gate is not enforced in CI:");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}

console.log(
`Gate-manifest OK: all ${localGates.length} verify:cheap gates are enforced in CI (static-pr + mapped jobs).`,
);