diff --git a/package.json b/package.json index 5086c29..caf51b7 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,8 @@ "check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check", "generate:tech-log-contract": "node scripts/generate-tech-log-contract.ts", "check:tech-log-contract": "node scripts/generate-tech-log-contract.ts --check", + "generate:dev-release-manifest": "node scripts/check-dev-release-manifest.ts --write", + "check:dev-release-manifest": "node scripts/check-dev-release-manifest.ts", "generate:supply-chain": "node scripts/generate-supply-chain.ts", "verify:local-evidence": "node scripts/verify-release-candidate.ts && node scripts/verify-release.ts && node scripts/verify-supply-chain-artifacts.ts && node scripts/verify-archived-local-evidence.ts && node scripts/verify-release-candidate.ts", "verify:promotion": "node scripts/verify-exact-promotion-bundle.ts", diff --git a/public/release-manifest.json b/public/release-manifest.json index dc1933e..e10a2e4 100644 --- a/public/release-manifest.json +++ b/public/release-manifest.json @@ -38,7 +38,15 @@ }, "contractSet": { "setAlgorithm": "CA_CONTRACT_SET_V1", - "setDigest": "sha256:ad6aab71fea6a9ff87cbd170b984b339965afc90d85bb57f87801c9e0c020da2", - "packages": [] + "setDigest": "sha256:e0da77655f51592ece583826d5fc6b092f57dd2bf63307e45e7e77283e6bf437", + "packages": [ + { + "packageId": "@tech-log/studio-contract", + "version": "2.0.0", + "digest": "sha256:99f54f56ea0c582eafdbdf9be5653e3384bef0a1b08bff67f3147ee0292019ea", + "runtimeProtocolVersion": 1, + "sourceRevision": "ce2e748" + } + ] } } diff --git a/scripts/check-dev-release-manifest.ts b/scripts/check-dev-release-manifest.ts new file mode 100644 index 0000000..93d4650 --- /dev/null +++ b/scripts/check-dev-release-manifest.ts @@ -0,0 +1,42 @@ +/** + * Gate: the hand-maintained dev fixture `public/release-manifest.json` must + * declare the same `contractSet` the build compiles. + * + * `corepack pnpm dev` serves that file verbatim, and `verifyContractSet` runs + * unconditionally at boot, so a stale fixture is a hard boot failure of + * `pnpm dev` in the default `MOCK` mode — not an `HTTP`-mode caveat. Nothing + * else in the gate set reads `public/*.json`, which is how a completely broken + * `pnpm dev` shipped with every static check green. + * + * `--write` refreshes the block instead of failing; that is what + * `corepack pnpm generate:tech-log-contract` calls. + */ +import process from "node:process"; + +import { + DEV_RELEASE_MANIFEST_PATH, + checkDevReleaseManifestContractSet, + refreshDevReleaseManifestContractSet, +} from "./lib/dev-release-manifest.ts"; + +const write = process.argv.includes("--write"); + +if (write) { + const { changed, contractSet } = await refreshDevReleaseManifestContractSet(); + process.stdout.write( + `${changed ? "Updated" : "Already in sync"}: ${DEV_RELEASE_MANIFEST_PATH} contractSet ` + + `(${contractSet.packages.length} package(s), ${contractSet.setDigest})\n`, + ); +} else { + const failures = await checkDevReleaseManifestContractSet(); + if (failures.length > 0) { + process.stderr.write( + `dev release manifest drift:\n- ${failures.join("\n- ")}\n` + + `Run: corepack pnpm generate:dev-release-manifest\n`, + ); + process.exit(1); + } + process.stdout.write( + `${DEV_RELEASE_MANIFEST_PATH} contractSet matches the compiled contract set.\n`, + ); +} diff --git a/scripts/generate-tech-log-contract.ts b/scripts/generate-tech-log-contract.ts index e67ff29..b75d2d4 100644 --- a/scripts/generate-tech-log-contract.ts +++ b/scripts/generate-tech-log-contract.ts @@ -121,3 +121,21 @@ writeFileSync(SOURCE_RECORD, `${JSON.stringify(record, null, 2)}\n`); console.log( `Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`, ); + +// 재생성은 매번 package digest를 바꾼다. `pnpm dev`가 그대로 서빙하는 +// `public/release-manifest.json`은 build가 컴파일한 contract set을 그대로 +// 선언해야 하고(§5.5, `verifyContractSet`는 MOCK 모드에서도 무조건 돈다), +// 그러지 않으면 dev 부팅이 CONTRACT_SET_PACKAGE_MISSING으로 닫힌다. +// 방금 쓴 canonical-source.json을 읽어야 하므로 정적 import가 아닌 동적 +// import로 불러온다. +const { refreshDevReleaseManifestContractSet, DEV_RELEASE_MANIFEST_PATH } = + await import("./lib/dev-release-manifest.ts"); +const refreshed = await refreshDevReleaseManifestContractSet(); +console.log( + `${refreshed.changed ? "Updated" : "Already in sync"}: ${DEV_RELEASE_MANIFEST_PATH} contractSet ` + + `(${refreshed.contractSet.packages.length} package(s), ${refreshed.contractSet.setDigest})`, +); + +// contract를 다시 만들지 않아도 구성된 set은 바뀔 수 있다 +// (`installed-contract-contributions.ts`에 기여가 추가/제거되는 경우). +// 그 경로는 여기서 못 잡으므로 `check:dev-release-manifest` 게이트가 잡는다. diff --git a/scripts/lib/dev-release-manifest.ts b/scripts/lib/dev-release-manifest.ts new file mode 100644 index 0000000..518e834 --- /dev/null +++ b/scripts/lib/dev-release-manifest.ts @@ -0,0 +1,135 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { buildContractSet } from "../generate-contract-set.ts"; +import { contractSetSchema, type ContractSet } from "../../src/contracts/contract-set.ts"; + +/** + * `corepack pnpm dev` never runs the build pipeline: plain `vite` serves + * `public/release-manifest.json` verbatim. That fixture therefore has to carry + * the same `contractSet` block `scripts/generate-build-manifest.ts` writes into + * `dist/release-manifest.json`, because `verifyContractSet` runs unconditionally + * at boot — in the default `MOCK` mode as much as in `HTTP` mode. When the two + * disagree the app renders the fail-closed boot screen instead of the shell. + * + * Both halves of the answer live here so they cannot disagree: the generation + * step rewrites the block, and the CI gate compares it. + */ + +export const DEV_RELEASE_MANIFEST_PATH = "public/release-manifest.json"; + +export type DevReleaseManifestContractSet = ContractSet; + +function resolveManifestPath(root: string): string { + return path.join(root, DEV_RELEASE_MANIFEST_PATH); +} + +/** + * The contract set the build would compile, derived from + * `EXPECTED_CONTRACT_SET_PACKAGES` exactly as `generate-build-manifest.ts` + * derives it. + */ +export function expectedDevReleaseManifestContractSet(): DevReleaseManifestContractSet { + return contractSetSchema.parse( + JSON.parse(JSON.stringify(buildContractSet())), + ); +} + +/** + * Compares one manifest `contractSet` block against the compiled expectation. + * Returns one human-readable line per disagreement; an empty array means the + * fixture would boot. + */ +export function compareDevReleaseManifestContractSet( + actual: unknown, + expected: DevReleaseManifestContractSet, +): string[] { + const parsed = contractSetSchema.safeParse(actual); + if (!parsed.success) { + return [ + `${DEV_RELEASE_MANIFEST_PATH} contractSet is missing or not a valid contract set: ${parsed.error.issues + .map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`) + .join("; ")}`, + ]; + } + const manifest = parsed.data; + const failures: string[] = []; + if (manifest.setAlgorithm !== expected.setAlgorithm) { + failures.push( + `setAlgorithm drift: manifest ${manifest.setAlgorithm}, build ${expected.setAlgorithm}`, + ); + } + if (manifest.setDigest !== expected.setDigest) { + failures.push( + `setDigest drift: manifest ${manifest.setDigest}, build ${expected.setDigest}`, + ); + } + const manifestById = new Map( + manifest.packages.map((entry) => [entry.packageId, entry] as const), + ); + const expectedById = new Map( + expected.packages.map((entry) => [entry.packageId, entry] as const), + ); + for (const [packageId, entry] of expectedById) { + const declared = manifestById.get(packageId); + if (!declared) { + failures.push( + `package missing from the manifest (boot fails with CONTRACT_SET_PACKAGE_MISSING): ${packageId}@${entry.version}`, + ); + continue; + } + if (JSON.stringify(declared) !== JSON.stringify(entry)) { + failures.push( + `package drift for ${packageId}: manifest ${JSON.stringify(declared)}, build ${JSON.stringify(entry)}`, + ); + } + } + for (const packageId of manifestById.keys()) { + if (!expectedById.has(packageId)) { + failures.push( + `package declared by the manifest but not compiled into the build (boot fails with CONTRACT_SET_PACKAGE_UNEXPECTED): ${packageId}`, + ); + } + } + return failures; +} + +export async function readDevReleaseManifest( + root: string = process.cwd(), +): Promise> { + const raw = await readFile(resolveManifestPath(root), "utf8"); + const document: unknown = JSON.parse(raw); + if (!document || typeof document !== "object" || Array.isArray(document)) { + throw new TypeError(`${DEV_RELEASE_MANIFEST_PATH} must be a JSON object`); + } + return document as Record; +} + +export async function checkDevReleaseManifestContractSet( + root: string = process.cwd(), +): Promise { + const document = await readDevReleaseManifest(root); + return compareDevReleaseManifestContractSet( + document["contractSet"], + expectedDevReleaseManifestContractSet(), + ); +} + +/** + * Rewrites only the `contractSet` block. Every other field of the hand + * maintained fixture is preserved byte for byte by round-tripping the same + * two-space JSON encoding the file already uses. + */ +export async function refreshDevReleaseManifestContractSet( + root: string = process.cwd(), +): Promise> { + const target = resolveManifestPath(root); + const document = await readDevReleaseManifest(root); + const contractSet = expectedDevReleaseManifestContractSet(); + const previous = JSON.stringify(document["contractSet"]); + document["contractSet"] = contractSet; + const next = `${JSON.stringify(document, null, 2)}\n`; + const changed = previous !== JSON.stringify(contractSet); + if (changed) await writeFile(target, next, "utf8"); + return Object.freeze({ changed, contractSet }); +}