fix: declare the compiled contract set in the dev release manifest
`corepack pnpm dev` did not boot. Plain `vite` serves `public/release-manifest.json` verbatim, and that hand-maintained fixture still declared `"packages": []` after the first real contract contribution was registered. `verifyContractSet` runs unconditionally at boot — before any adapter is chosen, so in the default `MOCK` mode as much as in `HTTP` — saw the build had compiled `@tech-log/studio-contract` and failed closed with `CONTRACT_SET_PACKAGE_MISSING`. Production builds were never affected: `scripts/generate-build-manifest.ts` derives `dist/release-manifest.json`'s block from the same composed set. Editing the fixture by hand is not the fix — it had already gone stale twice, once when the package first appeared and once when the contract moved 2.0.0 -> 3.0.0, because every regeneration changes the package digest. So `generate:tech-log-contract` now refreshes the block itself, as its last step and through a dynamic import so it reads the canonical source it just wrote. `generate:dev-release-manifest` does the same refresh on its own. The composed set can also change without the contract being regenerated — a contribution added to or removed from `installed-contract-contributions.ts` moves it. Tying the refresh to contract generation is therefore necessary but not sufficient; the CI gate that follows is what closes that half. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d84b57bb3f
commit
b75c9d0956
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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` 게이트가 잡는다.
|
||||
|
||||
@@ -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<Record<string, unknown>> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
export async function checkDevReleaseManifestContractSet(
|
||||
root: string = process.cwd(),
|
||||
): Promise<readonly string[]> {
|
||||
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<Readonly<{ changed: boolean; contractSet: DevReleaseManifestContractSet }>> {
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user