fix: harden CI evidence and removal contracts

This commit is contained in:
DongHyeonka
2026-08-02 14:48:04 +09:00
parent 1bb2cc4a20
commit f49d147b01
20 changed files with 1175 additions and 767 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"providerAdapter": ".gitea/workflows/quality-gates.yml",
"commands": [],
"artifactSchemas": [{ "id": "text", "kind": "text", "maxBytes": 1024 }],
"artifacts": [{ "id": "log", "path": "artifacts/gate.txt", "schemaId": "text" }],
"artifacts": [{ "id": "log", "path": "artifacts/gate.txt", "schemaId": "text", "production": "runner-generated" }],
"gates": [
{ "id": "FE-GATE-001", "name": "one", "commandIds": ["command"], "logArtifactId": "log", "evidenceArtifactIds": ["log"], "retentionClassId": "merge" },
{ "id": "FE-GATE-001", "name": "duplicate", "commandIds": ["command"], "logArtifactId": "log", "evidenceArtifactIds": ["log"], "retentionClassId": "merge" }
+1 -1
View File
@@ -3,7 +3,7 @@
"providerAdapter": ".gitea/workflows/quality-gates.yml",
"commands": [],
"artifactSchemas": [],
"artifacts": [{ "id": "log", "path": "artifacts/gate.txt", "schemaId": "missing" }],
"artifacts": [{ "id": "log", "path": "artifacts/gate.txt", "schemaId": "missing", "production": "runner-generated" }],
"gates": [],
"stages": [],
"jobs": [],
@@ -66,7 +66,7 @@ jobs:
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Install Playwright browsers
if: \${{ matrix.browser }}
run: corepack pnpm exec playwright install --with-deps chromium firefox webkit
@@ -106,7 +106,7 @@ jobs:
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Install Playwright browsers
if: \${{ matrix.browser }}
run: corepack pnpm exec playwright install --with-deps chromium firefox webkit
@@ -139,7 +139,7 @@ jobs:
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Build candidate once and verify local evidence
run: corepack pnpm ci:gate -- FE-GATE-015
- name: Archive and validate the exact candidate file set
@@ -195,7 +195,7 @@ jobs:
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Download release candidate
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
with:
@@ -237,7 +237,7 @@ jobs:
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Download release candidate
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
with:
@@ -281,7 +281,7 @@ jobs:
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Download release candidate
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7
with:
@@ -339,7 +339,7 @@ jobs:
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Run blocking gate
run: corepack pnpm ci:gate -- \${{ matrix.gate }}
- name: Upload production gate evidence
@@ -369,7 +369,7 @@ jobs:
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Run blocking gate
run: corepack pnpm ci:gate -- FE-GATE-018
- name: Upload field gate evidence
@@ -395,7 +395,7 @@ jobs:
- name: Frozen install
run: |
corepack enable
corepack pnpm install --frozen-lockfile
corepack pnpm install --frozen-lockfile --ignore-scripts
- name: Run documentation gate
run: corepack pnpm ci:gate -- FE-GATE-017
- name: Upload documentation gate evidence
+6 -1
View File
@@ -55,7 +55,12 @@ async function writeArtifact(root: string, relative: string, value: string | Buf
}
function artifact(pathname: string, schemaId: string): CiGateArtifact {
return { id: `artifact-${schemaId}`, path: pathname, schemaId };
return {
id: `artifact-${schemaId}`,
path: pathname,
schemaId,
production: "source-controlled",
};
}
describe("CI artifact validator", () => {
+118 -1
View File
@@ -123,6 +123,7 @@ describe("CI gate contract", () => {
["duplicate stage id", (value: Record<string, any>) => value.stages.push({ ...value.stages[0] }), /duplicate stage id/i],
["duplicate job id", (value: Record<string, any>) => value.jobs.push({ ...value.jobs[0] }), /duplicate job id/i],
["duplicate artifact path", (value: Record<string, any>) => value.artifacts.push({ ...value.artifacts[0], id: "duplicate-path" }), /duplicate artifact path/i],
["missing artifact production classification", (value: Record<string, any>) => delete value.artifacts[0].production, /production/i],
["unknown command reference", (value: Record<string, any>) => value.gates[0].commandIds.push("missing-command"), /unknown command missing-command/i],
["unknown artifact reference", (value: Record<string, any>) => (value.gates[0].logArtifactId = "missing-artifact"), /unknown artifact missing-artifact/i],
["unknown schema reference", (value: Record<string, any>) => (value.artifacts[0].schemaId = "missing-schema"), /unknown artifact schema missing-schema/i],
@@ -177,6 +178,27 @@ describe("CI gate contract", () => {
await expect(loadCiGateContract(root)).rejects.toThrow(/missing package scripts/i);
});
it("rejects swapped canonical gate command ownership", async () => {
const root = await mkdtemp(path.join(tmpdir(), "ci-contract-gate-shape-"));
temporaryRoots.push(root);
await mkdir(path.join(root, "config/ci"), { recursive: true });
const contract = JSON.parse(
JSON.stringify(await loadCiGateContract(process.cwd())),
) as Record<string, any>;
const security = contract.gates.find((gate: Record<string, any>) => gate.id === "FE-GATE-013");
const documentation = contract.gates.find((gate: Record<string, any>) => gate.id === "FE-GATE-017");
[security.commandIds, documentation.commandIds] = [
documentation.commandIds,
security.commandIds,
];
await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`);
await writeFile(path.join(root, "package.json"), await readFile("package.json"));
await expect(loadCiGateContract(root)).rejects.toThrow(
/canonical gate semantic shape|lacks a bound producer command/i,
);
});
it.each(["check:artifact-schemas", "check:ci-workflow"])(
"rejects a missing nested check:ci dependency: %s",
async (removedScript) => {
@@ -191,11 +213,29 @@ describe("CI gate contract", () => {
await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`);
await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`);
await expect(loadCiGateContract(root)).rejects.toThrow(
/missing package scripts|package script graph invalid/i,
/missing package scripts|package script graph invalid|canonical check:ci dependency/i,
);
},
);
it.each(["check:artifact-schemas", "check:ci-workflow"])(
"rejects a no-op nested check:ci dependency: %s",
async (bypassedScript) => {
const root = await mkdtemp(path.join(tmpdir(), "ci-contract-script-meaning-"));
temporaryRoots.push(root);
await mkdir(path.join(root, "config/ci"), { recursive: true });
const contract = await loadCiGateContract(process.cwd());
const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as {
scripts: Record<string, string>;
};
packageDocument.scripts[bypassedScript] = "true";
await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`);
await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`);
await expect(loadCiGateContract(root)).rejects.toThrow(/canonical check:ci dependency/i);
},
);
it.each([
["true bypass", "true"],
["direct self recursion", "corepack pnpm check:ci"],
@@ -292,6 +332,79 @@ describe("CI gate contract", () => {
expect(log.byteLength).toBeLessThanOrEqual(8_192);
expect(log.toString("utf8")).toMatch(/aggregate output|INFRASTRUCTURE_FAILURE/i);
}, 20_000);
it("rejects stale command-generated evidence from a successful no-op producer", async () => {
const root = await mkdtemp(path.join(tmpdir(), "ci-gate-stale-evidence-"));
temporaryRoots.push(root);
await mkdir(path.join(root, "config/ci"), { recursive: true });
await mkdir(path.join(root, "artifacts/tests"), { recursive: true });
const contract = JSON.parse(
JSON.stringify(await loadCiGateContract(process.cwd())),
) as Record<string, any>;
const command = contract.commands.find(
(entry: Record<string, any>) => entry.id === "test-runtime-schema",
);
command.script = "test:stale-evidence-noop";
const evidence = contract.artifacts.find(
(entry: Record<string, any>) => entry.path === "artifacts/tests/runtime-schema.xml",
);
const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as {
scripts: Record<string, string>;
};
packageDocument.scripts[command.script] = "true";
await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`);
await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`);
await writeFile(
path.join(root, evidence.path),
'<testsuite name="stale" tests="0" failures="0"/>\n',
);
const result = spawnSync(
process.execPath,
[path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-004"],
{ cwd: root, encoding: "utf8", env: { ...process.env, CI: "false" } },
);
expect(result.status).toBe(1);
expect(
await readFile(path.join(root, "artifacts/quality/gates/FE-GATE-004.txt"), "utf8"),
).toMatch(/not freshly produced/i);
});
it("accepts a fresh deterministic rewrite with identical evidence bytes", async () => {
const root = await mkdtemp(path.join(tmpdir(), "ci-gate-identical-rewrite-"));
temporaryRoots.push(root);
await mkdir(path.join(root, "config/ci"), { recursive: true });
await mkdir(path.join(root, "artifacts/tests"), { recursive: true });
const contract = JSON.parse(
JSON.stringify(await loadCiGateContract(process.cwd())),
) as Record<string, any>;
const command = contract.commands.find(
(entry: Record<string, any>) => entry.id === "test-runtime-schema",
);
command.script = "test:identical-evidence-rewrite";
const evidence = contract.artifacts.find(
(entry: Record<string, any>) => entry.path === "artifacts/tests/runtime-schema.xml",
);
const evidenceBytes = '<testsuite name="deterministic" tests="0" failures="0"/>\n';
const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as {
scripts: Record<string, string>;
};
packageDocument.scripts[command.script] =
`node -e 'require("node:fs").writeFileSync("${evidence.path}", Buffer.from("${Buffer.from(evidenceBytes).toString("base64")}", "base64"))'`;
await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`);
await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`);
await writeFile(path.join(root, evidence.path), evidenceBytes);
const result = spawnSync(
process.execPath,
[path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-004"],
{ cwd: root, encoding: "utf8", env: { ...process.env, CI: "false" } },
);
expect(result.status).toBe(0);
expect(result.stdout).toMatch(/FE-GATE-004 runtime-schema: PASS/);
});
});
describe("CI workflow generation", () => {
@@ -311,6 +424,10 @@ describe("CI workflow generation", () => {
);
expect(first).not.toContain("process_dist_sha256");
expect(first).toContain("persist-credentials: false");
expect(
first.match(/corepack pnpm install --frozen-lockfile --ignore-scripts/gu),
).toHaveLength(9);
expect(first).not.toMatch(/corepack pnpm install --frozen-lockfile$/mu);
expect(first).toContain("verify-ci-candidate-archive.ts --archive");
expect(first).toContain("--extract-to");
expect(first).not.toMatch(/\btar\s+[^\n]*--extract/u);
+54
View File
@@ -0,0 +1,54 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, it } from "vitest";
import { loadCiGateContract } from "../../scripts/contracts/ci-gates.ts";
import {
pruneRemovalFixtureCiContract,
} from "../../scripts/lib/removal-fixture.ts";
const temporaryRoots: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
it("prunes removed command and evidence references from a reduced CI contract", async () => {
const root = await mkdtemp(path.join(tmpdir(), "removal-fixture-contract-"));
temporaryRoots.push(root);
await mkdir(path.join(root, "config/ci"), { recursive: true });
await writeFile(path.join(root, "config/ci/gates.json"), await readFile("config/ci/gates.json"));
await writeFile(path.join(root, "package.json"), await readFile("package.json"));
await pruneRemovalFixtureCiContract({
root,
removedScripts: new Set(["test:reference-feature"]),
removedEvidencePathFragments: ["reference-feature.xml"],
});
const contract = await loadCiGateContract(root, { mode: "removal-fixture" });
expect(contract.commands.some(({ script }) => script === "test:reference-feature")).toBe(false);
expect(contract.artifacts.some(({ path }) => path.includes("reference-feature.xml"))).toBe(false);
expect(contract.gates.some(({ commandIds }) => commandIds.includes("test-reference-feature"))).toBe(false);
expect(contract.gates.some(({ evidenceArtifactIds }) =>
evidenceArtifactIds.includes("artifact-artifacts-tests-reference-feature-xml")
)).toBe(false);
});
it("rejects pruning that leaves a reduced gate without commands", async () => {
const root = await mkdtemp(path.join(tmpdir(), "removal-fixture-empty-gate-"));
temporaryRoots.push(root);
await mkdir(path.join(root, "config/ci"), { recursive: true });
await writeFile(path.join(root, "config/ci/gates.json"), await readFile("config/ci/gates.json"));
await writeFile(path.join(root, "package.json"), await readFile("package.json"));
await expect(pruneRemovalFixtureCiContract({
root,
removedScripts: new Set(["test:runtime-schema"]),
removedEvidencePathFragments: ["runtime-schema.xml"],
})).rejects.toThrow(/commandIds|too small|at least 1/i);
});
+5 -1
View File
@@ -704,7 +704,11 @@ describe("supply-chain policy", () => {
});
it("wires the exact security fixture checker as a passing CI gate", async () => {
const contract = await loadCiGateContract(process.cwd());
const contract = await loadCiGateContract(process.cwd(), {
mode: process.env.CI_CONTRACT_MODE === "removal-fixture"
? "removal-fixture"
: "canonical",
});
const index = indexCiGateContract(contract);
const securityGate = index.gates.get("FE-GATE-013");
expect(securityGate).toBeDefined();