diff --git a/package.json b/package.json index 7b29895e6..b19705561 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "prepare": "lefthook install", "release": "pnpm build && changeset publish", "test": "turbo test", + "test:release-gates": "node --experimental-strip-types scripts/verification-command.mts --id release-gate-tests", "test:coverage:core": "pnpm --filter @croco/problems-core --filter @croco/telemetry-api --filter @croco/tenant-core --filter @croco/dataloader-core --filter @croco/ratelimit-core --filter @croco/llm-core --filter @croco/storage-core --filter @croco/architecture-policy build && node --experimental-strip-types scripts/verification-command.mts --id core-coverage", "test:coverage:core:warning": "node --experimental-strip-types scripts/verification-command.mts --id core-coverage-warning", "typecheck": "turbo typecheck", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86b753a7c..6ce46d87a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,7 @@ overrides: '@hono/node-server': 1.19.10 '@opentelemetry/auto-instrumentations-node': 0.78.0 '@opentelemetry/exporter-prometheus': 0.220.0 + '@opentelemetry/propagator-jaeger': 2.9.0 '@opentelemetry/sdk-node': 0.220.0 '@grpc/grpc-js': 1.14.4 '@isaacs/brace-expansion': 5.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ec9cf49b2..bace96ec3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,7 @@ overrides: "@hono/node-server": 1.19.10 "@opentelemetry/auto-instrumentations-node": 0.78.0 "@opentelemetry/exporter-prometheus": 0.220.0 + "@opentelemetry/propagator-jaeger": 2.9.0 "@opentelemetry/sdk-node": 0.220.0 "@grpc/grpc-js": 1.14.4 "@isaacs/brace-expansion": 5.0.1 @@ -62,3 +63,7 @@ allowBuilds: protobufjs: true sharp: false workerd: true + +auditConfig: + ignoreGhsas: + - GHSA-f88m-g3jw-g9cj diff --git a/scripts/release-gate-maintenance.mts b/scripts/release-gate-maintenance.mts new file mode 100644 index 000000000..a59f97190 --- /dev/null +++ b/scripts/release-gate-maintenance.mts @@ -0,0 +1,160 @@ +export const RELEASE_GATE_TEST_PATHS = [ + "scripts/tests/alpha-release-smoke.spec.ts", + "scripts/tests/api-docs-trigger-check.spec.ts", + "scripts/tests/architecture-policy-check.spec.ts", + "scripts/tests/bench-threshold-check.spec.ts", + "scripts/tests/benchmark-workflow.spec.ts", + "scripts/tests/changeset-required-check.spec.ts", + "scripts/tests/ci-executable-policy.spec.ts", + "scripts/tests/ci-workflow.spec.ts", + "scripts/tests/core-coverage-warning-check.spec.ts", + "scripts/tests/create-croco-app-generated-smoke.spec.ts", + "scripts/tests/dependency-audit-policy.spec.ts", + "scripts/tests/doc-examples-check.spec.ts", + "scripts/tests/first-success-verify.spec.ts", + "scripts/tests/generated-secret-placeholder-policy.spec.ts", + "scripts/tests/normalize-packages.spec.ts", + "scripts/tests/package-bin-smoke.spec.ts", + "scripts/tests/package-docs-check.spec.ts", + "scripts/tests/package-entrypoint-smoke.spec.ts", + "scripts/tests/package-manifest-contracts.spec.ts", + "scripts/tests/package-quality-report.spec.ts", + "scripts/tests/problem-registry.spec.ts", + "scripts/tests/production-ready-check.spec.ts", + "scripts/tests/provenance-config-check.spec.ts", + "scripts/tests/provider-certification-check.spec.ts", + "scripts/tests/public-api-surface.spec.ts", + "scripts/tests/release-docs-check.spec.ts", + "scripts/tests/release-metadata-check.spec.ts", + "scripts/tests/release-spine-evidence.spec.ts", + "scripts/tests/release-workflow.spec.ts", + "scripts/tests/security-allowlist-metadata-check.spec.ts", + "scripts/tests/spine-promotion-check.spec.ts", + "scripts/tests/static-misuse-check.spec.ts", + "scripts/tests/strict-contract-typecheck.spec.ts", + "scripts/tests/tracked-file-mutation-guard.spec.ts", + "scripts/tests/verification-change-classifier.spec.ts", + "scripts/tests/verification-command.spec.ts", + "scripts/tests/verification-manifest.spec.ts", + "scripts/tests/verification-policy.spec.ts", + "scripts/tests/verify-circular-allowlist.spec.ts", +] as const; + +export const RELEASE_GATE_ENTRYPOINT_PATHS = [ + "scripts/alpha-release-smoke.mts", + "scripts/api-docs-trigger-check.mts", + "scripts/architecture-policy-check.mts", + "scripts/bench-threshold-check.mts", + "scripts/changeset-required-check.mts", + "scripts/ci-executable-policy.mts", + "scripts/core-coverage-warning-check.mts", + "scripts/create-croco-app-generated-smoke.mts", + "scripts/dependency-audit-policy.mts", + "scripts/doc-examples-check.mts", + "scripts/first-success-verify.mts", + "scripts/generated-secret-placeholder-policy.mts", + "scripts/normalize-packages.mjs", + "scripts/package-bin-smoke.mts", + "scripts/package-docs-check.mts", + "scripts/package-entrypoint-smoke.mts", + "scripts/package-quality-report.mts", + "scripts/problem-registry.mts", + "scripts/production-ready-check.mts", + "scripts/provenance-config-check.mts", + "scripts/provider-certification-check.mts", + "scripts/public-api-surface.mts", + "scripts/quick-start-lambda-smoke.mts", + "scripts/release-docs-check.mts", + "scripts/release-metadata-check.mts", + "scripts/security-allowlist-metadata-check.mts", + "scripts/spine-promotion-check.mts", + "scripts/static-misuse-check.mts", + "scripts/strict-contract-typecheck.mts", + "scripts/tracked-file-mutation-guard.mts", + "scripts/verification-policy.mts", + "scripts/verify-circular-allowlist.mts", +] as const; + +export const RELEASE_GATE_SUPPORT_PATHS = [ + "scripts/certification-policy.mts", + "scripts/create-croco-app-generated-smoke-journey-report.mts", + "scripts/create-croco-app-generated-smoke-matrix.mts", + "scripts/create-croco-app-generated-smoke-report.mts", + "scripts/create-croco-app-generated-smoke-support.mts", + "scripts/first-success-generated-contract.mts", + "scripts/internal-croco-compatibility-policy.mjs", + "scripts/package-manifest-contracts.mjs", + "scripts/peer-dependency-range-policy.mjs", + "scripts/release-gate-maintenance.mts", + "scripts/release-spine-evidence.mts", + "scripts/verification-change-classifier.mts", + "scripts/verification-command.mts", + "scripts/verification-dispatcher.mts", + "scripts/verification-manifest.mts", + "scripts/verification-problem.mts", + "scripts/workflow-verification-contract.mts", +] as const; + +export const RELEASE_GATE_IMPLEMENTATION_PATHS = [ + ...RELEASE_GATE_ENTRYPOINT_PATHS, + ...RELEASE_GATE_SUPPORT_PATHS, +] as const; + +export const RELEASE_GATE_WORKFLOW_PATHS = [ + ".github/workflows/benchmark.yml", + ".github/workflows/ci.yml", + ".github/workflows/release.yml", +] as const; + +export const RELEASE_GATE_POLICY_INPUT_PATHS = [ + ".changeset/config.json", + ".github/renovate.json", + ".gitignore", + ".gitleaks.toml", + ".gitleaksignore", + ".madge-circular-allowlist.txt", + ".npmrc", + ".nvmrc", + ".oxfmtignore", + ".oxfmtrc.json", + ".oxlintrc.json", + ".prettierignore", + "RELEASING.md", + "benchmarks/baseline.json", + "benchmarks/thresholds.json", + "ci-reports/bundle-size/baseline.json", + "ci-reports/coverage/core-baseline.txt", + "croco.arch.json", + "docs/doc-examples-baseline.json", + "docs/package-catalog.json", + "docs/package-docs-baseline.json", + "docs/problem-code-registry.json", + "docs/release/croco-1.0-spine.md", + "docs/release/silent-success-audit.json", + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "public-api-surface.snapshot.json", + "scripts/internal-peer-dependency-range-exceptions.json", + "scripts/security-allowlist-metadata.json", + "scripts/static-misuse-empty-catch-allowlist.json", + "scripts/static-misuse-raw-error-allowlist.json", + "tsconfig.json", + "tsconfig/contract-strict.baseline.json", + "turbo.json", + "vitest.config.bench.ts", + "vitest.config.ts", +] as const; + +export const RELEASE_GATE_MAINTENANCE_PATHS = [ + ...RELEASE_GATE_WORKFLOW_PATHS, + ...RELEASE_GATE_POLICY_INPUT_PATHS, + ...RELEASE_GATE_IMPLEMENTATION_PATHS, + ...RELEASE_GATE_TEST_PATHS, +] as const; + +const RELEASE_GATE_MAINTENANCE_PATH_SET = new Set(RELEASE_GATE_MAINTENANCE_PATHS); + +export function isReleaseGateMaintenancePath(path: string): boolean { + return RELEASE_GATE_MAINTENANCE_PATH_SET.has(path); +} diff --git a/scripts/security-allowlist-metadata.json b/scripts/security-allowlist-metadata.json index 9ea841b9d..f3fa081c1 100644 --- a/scripts/security-allowlist-metadata.json +++ b/scripts/security-allowlist-metadata.json @@ -1,7 +1,14 @@ { "schemaVersion": 1, "audit": { - "ignoreGhsas": [] + "ignoreGhsas": [ + { + "id": "GHSA-f88m-g3jw-g9cj", + "owner": "kang-heewon", + "reason": "Next.js 15 and 16 constrain their optional sharp dependency to the vulnerable 0.34 line; retain the generated-app compatibility path while upstream adopts sharp 0.35.", + "reviewBy": "2026-08-05" + } + ] }, "secretScan": { "gitleaks": { diff --git a/scripts/tests/ci-workflow.spec.ts b/scripts/tests/ci-workflow.spec.ts index 785073230..92db4ef04 100644 --- a/scripts/tests/ci-workflow.spec.ts +++ b/scripts/tests/ci-workflow.spec.ts @@ -79,6 +79,7 @@ describe("CI verification profile contract", () => { 'if [ "${{ github.event_name }}" = "pull_request" ]; then\n args+=(--allow-pending-release-metadata --base "${{ steps.verification.outputs.base }}" --head HEAD)', ); expect(WORKFLOW).toContain('--base "${{ steps.verification.outputs.base }}" --head HEAD'); + expect(WORKFLOW).not.toContain("test:release-gates"); }); it("keeps advisory scans and ecosystem smoke outside blocking profiles", () => { diff --git a/scripts/tests/first-success-verify.spec.ts b/scripts/tests/first-success-verify.spec.ts index 88e6f94be..029d7c2b2 100644 --- a/scripts/tests/first-success-verify.spec.ts +++ b/scripts/tests/first-success-verify.spec.ts @@ -3,6 +3,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + GENERATED_NODE_ENGINE_RANGE, + GENERATED_NODE_VERSION, +} from "../../packages/create-croco-app/src/node-runtime.js"; import { validateGeneratedSaasDocsContract } from "../first-success-generated-contract.mts"; const scriptPath = resolve(__dirname, "../first-success-verify.mts"); @@ -83,7 +87,7 @@ describe("first-success-verify.mts", () => { const result = runScript(root); - expect(result.status).toBe(0); + expect(result.status, result.stderr || result.stdout).toBe(0); expect(result.stdout).toContain("first-success contract verification PASSED"); }); @@ -95,7 +99,7 @@ describe("first-success-verify.mts", () => { const result = runScript(root); - expect(result.status).toBe(0); + expect(result.status, result.stderr || result.stdout).toBe(0); }); it("rejects a quick-start smoke dispatcher with the wrong command ID", () => { @@ -179,7 +183,7 @@ describe("first-success-verify.mts", () => { const result = runScript(root); - expect(result.status).toBe(0); + expect(result.status, result.stderr || result.stdout).toBe(0); expect(result.stdout).toContain("first-success contract verification PASSED"); }); @@ -485,6 +489,8 @@ function createFixture(options: FixtureOptions = {}): string { "pnpm install", "pnpm dev", "pnpm quick-start-lambda:smoke", + `Node.js ${GENERATED_NODE_ENGINE_RANGE}`, + `nvm install ${GENERATED_NODE_VERSION}`, "x-api-key: test-key", "401", "api_user_create", diff --git a/scripts/tests/package-entrypoint-smoke.spec.ts b/scripts/tests/package-entrypoint-smoke.spec.ts index d439d4658..eb830abd8 100644 --- a/scripts/tests/package-entrypoint-smoke.spec.ts +++ b/scripts/tests/package-entrypoint-smoke.spec.ts @@ -257,7 +257,7 @@ describe("package-entrypoint-smoke.mts", () => { expect(result.stdout).toContain( "esm decorator metadata and implicit DI ok @croco/metering-core", ); - }); + }, 30_000); it("fails when the packed auth service loses concrete constructor metadata", () => { const root = createTempRoot(); @@ -293,7 +293,7 @@ describe("package-entrypoint-smoke.mts", () => { expect(`${result.stdout}\n${result.stderr}`).toContain( "Container.get(MeterRegistry) expected default cacheTtlMs=60000", ); - }); + }, 30_000); it("matches packed tarballs by manifest name when package names share a prefix", () => { const root = createTempRoot(); diff --git a/scripts/tests/release-workflow.spec.ts b/scripts/tests/release-workflow.spec.ts index f7a67e3c9..08e6d7ac1 100644 --- a/scripts/tests/release-workflow.spec.ts +++ b/scripts/tests/release-workflow.spec.ts @@ -18,6 +18,7 @@ describe("Release verification profile contract", () => { ); expect(workflow).toContain('pnpm verify:publish -- "${args[@]}"'); expect(workflow.match(/verify:publish/g)).toHaveLength(1); + expect(workflow).not.toContain("test:release-gates"); expect(workflow).not.toContain("--allow-pending-release-metadata"); }); diff --git a/scripts/tests/verification-change-classifier.spec.ts b/scripts/tests/verification-change-classifier.spec.ts index 3f4603b2c..9fcbdc71a 100644 --- a/scripts/tests/verification-change-classifier.spec.ts +++ b/scripts/tests/verification-change-classifier.spec.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import { classifyVerificationChanges } from "../verification-change-classifier.mts"; -import { verificationImplementationPaths } from "../verification-manifest.mts"; +import { + createVerificationManifest, + verificationImplementationPaths, +} from "../verification-manifest.mts"; +import { RELEASE_GATE_MAINTENANCE_PATHS } from "../release-gate-maintenance.mts"; import { formatVerificationProblem, VerificationProblem } from "../verification-problem.mts"; describe("verification change classifier", () => { @@ -17,11 +21,11 @@ describe("verification change classifier", () => { [[".github/workflows/ci.yml"], "publish"], [[".changeset/new.md"], "repo"], [["packages/retry-core/package.json"], "publish"], - [["turbo.json"], "spine"], - [["vitest.config.ts"], "spine"], - [["tsconfig.json"], "spine"], - [[".nvmrc"], "spine"], - [[".gitignore"], "spine"], + [["turbo.json"], "publish"], + [["vitest.config.ts"], "publish"], + [["tsconfig.json"], "publish"], + [[".nvmrc"], "publish"], + [[".gitignore"], "publish"], ] as const)("routes pull request files %j to %s", (files, profile) => { expect(classifyVerificationChanges("pull_request", files, "ci")).toMatchObject({ profile, @@ -81,6 +85,25 @@ describe("verification change classifier", () => { }, ); + it.each(RELEASE_GATE_MAINTENANCE_PATHS)( + "routes authoritative release-gate maintenance %s through publish verification", + (path) => { + for (const event of ["pull_request", "push"] as const) { + const classification = classifyVerificationChanges(event, [path]); + expect(classification).toMatchObject({ + profile: "publish", + shouldRunVerification: true, + }); + const releaseGate = createVerificationManifest("publish", { + base: "origin/trunk", + changedFiles: [path], + head: "HEAD", + }).find(({ id }) => id === "release-gate-tests"); + expect(releaseGate?.applicable).toBe(true); + } + }, + ); + it.each([ "scripts/verification-manifest.mts", "scripts/verification-change-classifier.mts", diff --git a/scripts/tests/verification-manifest.spec.ts b/scripts/tests/verification-manifest.spec.ts index af30e7587..0df9f6171 100644 --- a/scripts/tests/verification-manifest.spec.ts +++ b/scripts/tests/verification-manifest.spec.ts @@ -1,13 +1,58 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { basename, dirname, extname, relative, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { assertVerificationManifest, createVerificationManifest, + verificationImplementationPaths, } from "../verification-manifest.mts"; +import { + RELEASE_GATE_ENTRYPOINT_PATHS, + RELEASE_GATE_IMPLEMENTATION_PATHS, + RELEASE_GATE_MAINTENANCE_PATHS, + RELEASE_GATE_POLICY_INPUT_PATHS, + RELEASE_GATE_SUPPORT_PATHS, + RELEASE_GATE_TEST_PATHS, + RELEASE_GATE_WORKFLOW_PATHS, +} from "../release-gate-maintenance.mts"; import type { EvidenceCommand } from "../release-spine-evidence.mts"; +const ROOT_DIR = resolve(__dirname, "../.."); +const SCRIPT_EXTENSIONS = [".mts", ".ts", ".mjs", ".js"] as const; + +function releaseGateImportSpecifiers(source: string): readonly string[] { + return [...source.matchAll(/(?:\bfrom\s+|\bimport\s*(?:\(\s*)?)(["'])(\.{1,2}\/[^"']+)\1/g)] + .map((match) => match[2]) + .filter((specifier): specifier is string => specifier !== undefined); +} + +function discoverReleaseGateScriptPaths(roots: readonly string[]): readonly string[] { + const discovered = new Set(); + const pending = [...roots]; + + for (const path of pending) { + if (discovered.has(path)) continue; + discovered.add(path); + + const source = readFileSync(resolve(ROOT_DIR, path), "utf8"); + for (const specifier of releaseGateImportSpecifiers(source)) { + const unresolved = resolve(ROOT_DIR, dirname(path), specifier); + const candidates = extname(unresolved) + ? [unresolved] + : SCRIPT_EXTENSIONS.map((extension) => `${unresolved}${extension}`); + const resolved = candidates.find((candidate) => existsSync(candidate)); + if (!resolved) continue; + const repositoryPath = relative(ROOT_DIR, resolved).replaceAll("\\", "/"); + if (repositoryPath.startsWith("scripts/") && !discovered.has(repositoryPath)) { + pending.push(repositoryPath); + } + } + } + + return [...discovered].sort(); +} + const repoIds = [ "verification-policy", "verification-contract-tests", @@ -52,6 +97,16 @@ const spineOnlyIds = [ ]; describe("verification manifest", () => { + it("discovers static, dynamic, and side-effect relative imports", () => { + expect( + releaseGateImportSpecifiers(` + import value from "./static.mts"; + import("./dynamic.mts"); + import "./side-effect.mts"; + `), + ).toEqual(["./static.mts", "./dynamic.mts", "./side-effect.mts"]); + }); + it("composes exact ordered repo, spine, and publish profiles", () => { expect(createVerificationManifest("repo").map(({ id }) => id)).toEqual(repoIds); expect(createVerificationManifest("spine").map(({ id }) => id)).toEqual([ @@ -61,6 +116,7 @@ describe("verification manifest", () => { expect(createVerificationManifest("publish").map(({ id }) => id)).toEqual([ ...repoIds, ...spineOnlyIds, + "release-gate-tests", "release-metadata", "spine-bundle-size", "dependency-audit-policy", @@ -72,6 +128,103 @@ describe("verification manifest", () => { ).toEqual(["node", "--experimental-strip-types", "scripts/package-quality-report.mts"]); }); + it("runs one authoritative release-gate suite without duplicating contract tests", () => { + const manual = createVerificationManifest("publish"); + expect(manual.find(({ id }) => id === "release-gate-tests")?.applicable).toBe(true); + expect(manual.find(({ id }) => id === "verification-contract-tests")?.applicable).toBe(false); + + const maintenance = createVerificationManifest("publish", { + base: "origin/trunk", + changedFiles: ["scripts/production-ready-check.mts"], + head: "HEAD", + }); + expect(maintenance.find(({ id }) => id === "release-gate-tests")?.applicable).toBe(true); + expect(maintenance.find(({ id }) => id === "verification-contract-tests")?.applicable).toBe( + false, + ); + + const packageCandidate = createVerificationManifest("publish", { + base: "origin/trunk", + changedFiles: ["packages/retry-core/package.json"], + head: "HEAD", + }); + expect(packageCandidate.find(({ id }) => id === "release-gate-tests")?.applicable).toBe(false); + expect( + packageCandidate.find(({ id }) => id === "verification-contract-tests")?.applicable, + ).toBe(true); + + expect( + createVerificationManifest("repo").find(({ id }) => id === "verification-contract-tests") + ?.applicable, + ).toBe(true); + expect( + createVerificationManifest("spine").find(({ id }) => id === "verification-contract-tests") + ?.applicable, + ).toBe(true); + }); + + it("keeps the release-gate inventory complete, sorted, and executable from one root alias", () => { + const command = createVerificationManifest("publish").find( + ({ id }) => id === "release-gate-tests", + ); + const packageJson = JSON.parse( + readFileSync(resolve(__dirname, "../../package.json"), "utf8"), + ) as { scripts?: Record }; + + expect(RELEASE_GATE_TEST_PATHS).toHaveLength(39); + expect(RELEASE_GATE_TEST_PATHS).toEqual([...RELEASE_GATE_TEST_PATHS].sort()); + expect(RELEASE_GATE_ENTRYPOINT_PATHS).toEqual([...RELEASE_GATE_ENTRYPOINT_PATHS].sort()); + expect(RELEASE_GATE_SUPPORT_PATHS).toEqual([...RELEASE_GATE_SUPPORT_PATHS].sort()); + expect(RELEASE_GATE_POLICY_INPUT_PATHS).toEqual([...RELEASE_GATE_POLICY_INPUT_PATHS].sort()); + expect(RELEASE_GATE_WORKFLOW_PATHS).toEqual([...RELEASE_GATE_WORKFLOW_PATHS].sort()); + expect(RELEASE_GATE_ENTRYPOINT_PATHS).toEqual(verificationImplementationPaths()); + expect(new Set(RELEASE_GATE_MAINTENANCE_PATHS).size).toBe( + RELEASE_GATE_MAINTENANCE_PATHS.length, + ); + expect(RELEASE_GATE_MAINTENANCE_PATHS).toContain("scripts/release-gate-maintenance.mts"); + expect(command?.command).toEqual([ + "pnpm", + "exec", + "vitest", + "run", + "--no-file-parallelism", + ...RELEASE_GATE_TEST_PATHS, + "--config", + "vitest.config.ts", + ]); + expect(packageJson.scripts?.["test:release-gates"]).toBe( + "node --experimental-strip-types scripts/verification-command.mts --id release-gate-tests", + ); + for (const path of RELEASE_GATE_MAINTENANCE_PATHS) { + expect(existsSync(resolve(ROOT_DIR, path)), path).toBe(true); + } + for (const workflowPath of RELEASE_GATE_WORKFLOW_PATHS) { + const workflowContractPath = `scripts/tests/${basename(workflowPath, ".yml")}-workflow.spec.ts`; + expect(RELEASE_GATE_TEST_PATHS, workflowContractPath).toContain(workflowContractPath); + } + + const discoveredScriptPaths = discoverReleaseGateScriptPaths([ + ...RELEASE_GATE_ENTRYPOINT_PATHS, + ...RELEASE_GATE_TEST_PATHS, + "scripts/release-gate-maintenance.mts", + ]); + expect(RELEASE_GATE_MAINTENANCE_PATHS).toEqual(expect.arrayContaining(discoveredScriptPaths)); + expect(RELEASE_GATE_IMPLEMENTATION_PATHS).toEqual( + expect.arrayContaining(discoveredScriptPaths.filter((path) => !path.includes("/tests/"))), + ); + + const matchingImplementationSpecs = discoveredScriptPaths + .filter((path) => /^scripts\/[^/]+\.(?:mts|mjs|ts)$/.test(path)) + .map( + (implementationPath) => + `scripts/tests/${basename(implementationPath).replace(/\.(?:mts|mjs|ts)$/, ".spec.ts")}`, + ) + .filter((testPath) => existsSync(testPath)); + expect(RELEASE_GATE_TEST_PATHS).toEqual( + expect.arrayContaining([...new Set(matchingImplementationSpecs)]), + ); + }); + it("runs publish package gates only for their relevant changed inputs", () => { const maintenance = createVerificationManifest("publish", { base: "origin/trunk", @@ -85,6 +238,20 @@ describe("verification manifest", () => { expect(maintenance.find(({ id }) => id === "release-metadata")?.applicable).toBe(false); expect(maintenance.find(({ id }) => id === "spine-bundle-size")?.applicable).toBe(false); + for (const path of [ + ".changeset/config.json", + "ci-reports/bundle-size/baseline.json", + "docs/package-catalog.json", + ]) { + const directInput = createVerificationManifest("publish", { + base: "origin/trunk", + changedFiles: [path], + head: "HEAD", + }); + expect(directInput.find(({ id }) => id === "release-gate-tests")?.applicable).toBe(true); + expect(directInput.find(({ id }) => id === "spine-bundle-size")?.applicable).toBe(true); + } + const verifierChanges = createVerificationManifest("publish", { base: "origin/trunk", changedFiles: ["scripts/release-metadata-check.mts", "scripts/package-quality-report.mts"], diff --git a/scripts/verification-change-classifier.mts b/scripts/verification-change-classifier.mts index 212f5b7dd..4ef7084ab 100644 --- a/scripts/verification-change-classifier.mts +++ b/scripts/verification-change-classifier.mts @@ -6,6 +6,7 @@ import { argv } from "node:process"; import { pathToFileURL } from "node:url"; import { verificationImplementationPaths } from "./verification-manifest.mts"; +import { isReleaseGateMaintenancePath } from "./release-gate-maintenance.mts"; import { formatVerificationProblem, VerificationProblem } from "./verification-problem.mts"; import type { VerificationProfile } from "./verification-manifest.mts"; @@ -26,6 +27,7 @@ const PROFILE_STRENGTH: Record = { repo: 1, spine: const MANIFEST_IMPLEMENTATION_PATHS = new Set(verificationImplementationPaths()); function classifyPath(path: string): PathKind { + if (isReleaseGateMaintenancePath(path)) return "publish"; if (path === ".changeset/README.md") return "repo"; if (path === ".changeset/config.json") return "publish"; if (/^\.changeset\/(pre\.json|[^/]+\.md)$/.test(path)) return "changeset"; diff --git a/scripts/verification-manifest.mts b/scripts/verification-manifest.mts index aa11b3dc5..9efe4660e 100644 --- a/scripts/verification-manifest.mts +++ b/scripts/verification-manifest.mts @@ -1,4 +1,8 @@ import type { EvidenceCommand } from "./release-spine-evidence.mts"; +import { + isReleaseGateMaintenancePath, + RELEASE_GATE_TEST_PATHS, +} from "./release-gate-maintenance.mts"; import { VerificationProblem } from "./verification-problem.mts"; export type VerificationProfile = "repo" | "spine" | "publish"; @@ -33,7 +37,10 @@ function isApplicableToChangedFiles( return context.changedFiles.some(predicate); } -const repoOnly = (context: VerificationContext): readonly EvidenceCommand[] => [ +const repoOnly = ( + context: VerificationContext, + suppressVerificationContractTests = false, +): readonly EvidenceCommand[] => [ { id: "verification-policy", label: "Read-only verification policy", @@ -62,6 +69,7 @@ const repoOnly = (context: VerificationContext): readonly EvidenceCommand[] => [ "scripts/tests/verification-policy.spec.ts", ], timeoutMs: minutes(10), + applicable: !suppressVerificationContractTests, }, { id: "changeset-required", @@ -535,6 +543,23 @@ const SPINE_ONLY: readonly EvidenceCommand[] = [ ]; const publishOnly = (context: VerificationContext): readonly EvidenceCommand[] => [ + { + id: "release-gate-tests", + label: "Release-gate maintenance tests", + category: "quality", + command: [ + "pnpm", + "exec", + "vitest", + "run", + "--no-file-parallelism", + ...RELEASE_GATE_TEST_PATHS, + "--config", + "vitest.config.ts", + ], + timeoutMs: minutes(30), + applicable: isApplicableToChangedFiles(context, isReleaseGateMaintenancePath), + }, { id: "release-metadata", label: "Release metadata", @@ -562,6 +587,9 @@ const publishOnly = (context: VerificationContext): readonly EvidenceCommand[] = context, (path) => path === "scripts/package-quality-report.mts" || + path === ".changeset/config.json" || + path === "ci-reports/bundle-size/baseline.json" || + path === "docs/package-catalog.json" || /^(?:pnpm-lock\.yaml|pnpm-workspace\.yaml|turbo\.json|tsconfig(?:\.[^/]+)?\.json)$/.test( path, ) || @@ -673,7 +701,8 @@ export function createVerificationManifest( profile: VerificationProfile, context: VerificationContext = {}, ): readonly EvidenceCommand[] { - const repo = repoOnly(context); + const releaseGateMaintenance = isApplicableToChangedFiles(context, isReleaseGateMaintenancePath); + const repo = repoOnly(context, profile === "publish" && releaseGateMaintenance); const commands = profile === "repo" ? repo