fix: harden CI evidence and removal contracts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user