/* Maintains packages/os/site-kernel-handoff/src/release/release-commands.ts as an authored site-kernel-handoff authored module so agents can evolve it without rediscovering local boundaries. Do not introduce app-specific runtime composition or deployment behavior into this reusable package source file. REVIEW-CODE-2026-10-01-01: bootSmokeAppliesToAdapter resolves the adapter and reads bootSmokeCapable() — new static adapters opt out in their own file; unknown names keep boot-smoke (strict default). RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt RFC-1249: restore diagnostic detail in failResult violations (no placeholder args) RFC-1290: rendered-text corpus + release text diff RFC-1296 ADR-0008, RFC-0357, RFC-0381, RFC-0480, RFC-0520, RFC-0522, RFC-0585, RFC-0596, RFC-0608, RFC-0655, RFC-0656, RFC-0845, RFC-0931, RFC-0948, RFC-1097, RFC-1149, warpgogol-m000196 HARDCODED_VALUES_AUDIT (RFC-0980): - semver default "0.1.0" (a) safe default — release version fallback when --semver flag is not provided - 999999 max sequence number (a) safe default — guard against release ID sequence exhaustion - Release state "prepared" (a) safe default — release state enum value - Verdict values "pass" | "fail" | "skipped" (a) safe default — verdict enum values - bootSmokeVerdict "pass" | "fail" (a) safe default — boot-smoke verdict enum values - Mission state "closed" check (a) safe default — mission state enum value - "releases" directory name (a) safe default — conventional releases directory - "evidence" directory name (a) safe default — conventional evidence directory - "validation-report.json" filename (a) safe default — conventional validation report filename - "src/entitlements.generated.yaml" path (a) safe default — generated entitlements file path - "dist/client" directory name (a) safe default — Astro output directory - "dist/server" directory name (a) safe default — Astro Cloudflare adapter output directory - "wrangler.json" / "wrangler.jsonc" filenames (a) safe default — Cloudflare wrangler config filenames - "build-identity.json" filename (a) safe default — build identity artifact filename - ".well-known" directory name (a) safe default — conventional well-known directory - "boot-smoke.json" filename (a) safe default — boot-smoke evidence filename - Fallback languages ["de", "uk"] (via detectBootSmokeLanguages) (b) workshop-specific — ensures boot-smoke runs representative requests when dist/client/ structure is unexpected */ // CI GUARD: boot-smoke tests run in .github/workflows/ci.yml // Any change to Miniflare options, language detection, or module enumeration // MUST be accompanied by a test update. See ADR-0067 for testing strategy. import path from "node:path"; import type { DiscoveredSiteWorkspace, KernelCommandInput, KernelCommandResult, KernelRuntimeContext, } from "@warpgogol/werkstatt-engine/kernel"; import { fingerprintTree } from "@warpgogol/werkstatt-engine/fingerprint/semantic"; import { readMissionManifest, writeMissionManifest, resolveMissionDir, } from "../mission/mission-io.ts"; import { acquireLock, releaseLock, generateOperationId } from "../werkstatt/index.ts"; import { atomicWriteFile, atomicMoveDir, resolveStagingDir } from "../werkstatt/atomic.ts"; import { appendAndCommitBordbuch } from "../bordbuch/bordbuch-commit-helper.ts"; import { readSystemConfigSmart, readSystemState, writeSystemState, } from "../sternsystem/registry-io.ts"; import { runPipelinePhase, computeBuildInputHash } from "../handoff/build-pipeline-helpers.ts"; import { evaluateCSurfaceGate } from "./c-surface-guard.ts"; import { checkBreaksCDeclaration } from "./breaks-c-helper.ts"; import { copyReleaseEvidence } from "./release-evidence.ts"; import { runBehaviorSnapshotCapture, runBehaviorSnapshotDiff, } from "../behavior-snapshot/behavior-snapshot-commands.ts"; import { storeArtifactCore } from "../artifact-store/artifact-store-commands.ts"; import { executeKernelCommand } from "@warpgogol/werkstatt-engine/kernel"; import { signBuildIdentity, writeReleasePublicKey, signLatestBuildArtifacts, } from "../integrity/signing.ts"; import { loadPrivateKey } from "@warpgogol/werkstatt-engine/signing"; import { filterEnv, resolveAdapter } from "../leitstand/adapters/index.ts"; import { runIntegrityBuildRecord } from "../integrity/integrity-commands.ts"; import { writeContentWarnings } from "./content-warnings.ts"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { releaseManifestSchema, legacyReleaseStateSchema, type ReleaseManifest, type LegacyReleaseDiagnostic, } from "@warpgogol/werkstatt-shared/ontology/operations"; import type { ReleaseArtifactRef } from "@warpgogol/werkstatt-shared/ontology/operations"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; import { failResult } from "@warpgogol/werkstatt-shared/checks/result-helpers"; function flagString(input: KernelCommandInput, key: string): string | undefined { const v = input.flags[key]; return typeof v === "string" ? v : undefined; } function deriveReleaseId(systemId: string, existing: string[]): string { let max = 0; for (const id of existing) { const match = id.match(/-r(\d{6})$/); if (match) { const num = parseInt(match[1], 10); if (num > max) max = num; } } const next = max + 1; if (next > 999999) { throw new Error( `[release.prepare] sequence number exhausted for system '${systemId}' (max: 999999)`, ); } return `${systemId}-r${String(next).padStart(6, "0")}`; } // Boot-smoke only applies to worker-runtime deployments — static and // no-runtime adapters emit no worker bundle, so a generated workpiece // wrangler.jsonc there is vestigial and there is nothing to boot in miniflare. // The decision goes through the adapter's bootSmokeCapable() capability so new // static adapters opt out in their own file instead of extending a deny-list // here. Unknown adapter names keep boot-smoke enabled (strict default). export function bootSmokeAppliesToAdapter(adapter: string | undefined | null): boolean { try { return resolveAdapter(adapter ?? undefined).bootSmokeCapable(); } catch { return true; } } async function listReleaseIds( workspaceRoot: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const releasesDir = path.join(workspaceRoot, "releases"); if (!(await io.exists(releasesDir))) return []; const entries = await io.readdir(releasesDir); return entries.filter((e) => e.isDirectory && !e.name.includes(".staging-")).map((e) => e.name); } async function readEntitlementFeatures( dir: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { try { const filePath = path.join(dir, "src", "entitlements.generated.yaml"); if (!(await io.exists(filePath))) return null; const raw = await io.readFile(filePath); const parsed = parseYaml(raw) as { features?: unknown }; return Array.isArray(parsed.features) ? parsed.features.map(String) : null; } catch { return null; } } async function logEntitlementDiff( logger: { info: (msg: string) => void; warn: (msg: string) => void }, workpieceDir: string, releasesBase: string, systemReleases: string[], currentReleaseId: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { try { const currentFeatures = await readEntitlementFeatures(workpieceDir); if (!currentFeatures) { logger.warn(` [entitlements] No entitlements.generated.yaml found in workpiece`); return; } const prevReleases = systemReleases .filter((id) => id !== currentReleaseId) .sort() .reverse(); const prevReleaseId = prevReleases[0]; if (!prevReleaseId) { logger.info( ` [entitlements] ${currentFeatures.length} feature(s): ${currentFeatures.join(", ")}`, ); return; } const prevReleaseDir = path.join(releasesBase, prevReleaseId); let prevFeatures: string[] | null = null; // Try dist/client first (build output), then release root const distClientDir = path.join(prevReleaseDir, "dist", "client"); if (await io.exists(distClientDir)) { prevFeatures = await readEntitlementFeatures(distClientDir); } if (!prevFeatures) { prevFeatures = await readEntitlementFeatures(prevReleaseDir); } if (!prevFeatures) { logger.info( ` [entitlements] ${currentFeatures.length} feature(s): ${currentFeatures.join(", ")} (no previous release entitlements found for diff)`, ); return; } const added = currentFeatures.filter((f) => !prevFeatures!.includes(f)); const removed = prevFeatures.filter((f) => !currentFeatures.includes(f)); if (added.length === 0 && removed.length === 0) { logger.info( ` [entitlements] No changes vs ${prevReleaseId} (${currentFeatures.length} feature(s))`, ); } else { const parts: string[] = []; if (added.length > 0) parts.push(`+${added.join(",+")}`); if (removed.length > 0) parts.push(`-${removed.join(",-")}`); logger.info(` [entitlements] Diff vs ${prevReleaseId}: ${parts.join(" ")}`); } } catch (err) { logger.warn( ` [entitlements] Diff failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`, ); } } export class LegacyReleaseError extends Error { readonly releaseId: string; readonly legacyState: string; constructor(releaseId: string, legacyState: string) { super( `[release] release '${releaseId}' uses legacy state '${legacyState}' which is no longer valid per RFC-0851`, ); this.releaseId = releaseId; this.legacyState = legacyState; } } export async function readReleaseManifest( workspaceRoot: string, releaseId: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const manifestPath = path.join(workspaceRoot, "releases", releaseId, "release.yaml"); if (!(await io.exists(manifestPath))) { throw new Error(`[release] release '${releaseId}' not found`); } const content = await io.readFile(manifestPath); const parsed = parseYaml(content); const result = releaseManifestSchema.safeParse(parsed); if (result.success) { return result.data; } const stateValue = typeof parsed === "object" && parsed !== null && "state" in parsed ? String((parsed as Record).state) : ""; const legacyStateResult = legacyReleaseStateSchema.safeParse(stateValue); if (legacyStateResult.success) { throw new LegacyReleaseError(releaseId, legacyStateResult.data); } throw new Error( `[release] release '${releaseId}' has invalid manifest: ${result.error.issues.map((i) => i.path.join(".") + ": " + i.message).join("; ")}`, ); } export async function writeReleaseYaml( workspaceRoot: string, releaseId: string, manifest: ReleaseManifest, ): Promise { const releaseDir = path.join(workspaceRoot, "releases", releaseId); const yaml = stringifyYaml(manifest, { sortMapEntries: false }); await atomicWriteFile(path.join(releaseDir, "release.yaml"), yaml); } // §6.1: release.prepare export interface ReleasePrepareData { releaseId: string; systemId: string; missionId: string; semver: string; state: "prepared"; snapshotDiffVerdict: "pass" | "fail"; cSurfaceVerdict: "pass" | "fail" | "skipped"; behaviorSnapshotHash: string; distTreeHash: string; siteContentHash: string; readableSnapshotHash: string; buildReused: boolean; bootSmokeVerdict: "pass" | "fail"; } export async function runReleasePrepare( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const missionId = flagString(input, "mission"); const semver = flagString(input, "semver") ?? "0.1.0"; if (!missionId) throw new Error("[release.prepare] --mission is required"); const manifest = await readMissionManifest(workspaceRoot, missionId); if (manifest.state !== "closed") { throw new Error( `[release.prepare] mission '${missionId}' is not closed (state: ${manifest.state}). Run \`mission.close --mission ${missionId}\` first.`, ); } // RFC-0480: require successful reconcile before preparing a release if (!manifest.reconciledAt) { throw new Error( `[release.prepare] mission '${missionId}' has not been reconciled — run mission.reconcile before preparing a release`, ); } // Check validation has passed const missionDir = await resolveMissionDir(workspaceRoot, missionId, context.io); const validationPath = path.join(missionDir, "evidence", "validation-report.json"); if (!(await context.io.exists(validationPath))) { throw new Error(`[release.prepare] mission '${missionId}' has not passed validation`); } const operationId = generateOperationId(); const systemId = manifest.systemId; await acquireLock(workspaceRoot, `system:${systemId}`, operationId, "release.prepare", "agent"); try { const existingReleases = await listReleaseIds(workspaceRoot); const systemReleases = existingReleases.filter((id) => id.startsWith(`${systemId}-r`)); const releaseId = deriveReleaseId(systemId, systemReleases); await acquireLock( workspaceRoot, `release:${releaseId}`, operationId, "release.prepare", "agent", ); try { const releasesBase = path.join(workspaceRoot, "releases"); await context.io.mkdir(releasesBase); const stagingDir = resolveStagingDir( workspaceRoot, path.join(releasesBase, releaseId), operationId, ); if (await context.io.exists(stagingDir)) { await context.io.rm(stagingDir, { recursive: true }); } await context.io.mkdir(stagingDir); // Copy validation and materialization reports const evidenceDir = path.join(missionDir, "evidence"); if (await context.io.exists(evidenceDir)) { await context.io.mkdir(path.join(stagingDir)); for (const report of ["validation-report.json", "materialization-report.json"]) { const src = path.join(evidenceDir, report); if (await context.io.exists(src)) { await context.io.copyFile(src, path.join(stagingDir, report)); } } } // RFC-1296: content warning budget — warnings per content-contract rule // plus the delta against the previous release. Increases require an // operator's --warnings-ack in the editorial sign-off (EDIT-05). await writeContentWarnings(workspaceRoot, stagingDir, systemId, releaseId, context.io); // RFC-0585: Run production build (or reuse distribution), capture snapshots, compute hashes const workpieceDir = path.join(missionDir, "workpiece"); const distributionDir = path.join(missionDir, "distribution", "dist"); const distDest = path.join(stagingDir, "dist"); let buildReused = false; // RFC-0634: commitSha is now captured from workpiece git HEAD (see below, before build) // RFC-0585: Compute build input hash for reuse decision const { buildInputHash, workpieceTreeHash, platformVersion, platformSemanticHash } = await computeBuildInputHash(workspaceRoot, workpieceDir); const distributionMetaPath = path.join(missionDir, "distribution", "build-input-hash.json"); const canReuseDistribution = (await context.io.exists(distributionDir)) && (await context.io.exists(distributionMetaPath)) && (await (async (io: WorkspaceIO = createDefaultIO().io) => { try { const meta = JSON.parse(await io.readFile(distributionMetaPath)); return meta.buildInputHash === buildInputHash; } catch { return false; } })()); // RFC-0634: Capture commitSha from workpiece git HEAD (not monorepo HEAD) let commitSha = "unknown"; try { const revResult = await context.io.exec("sh", ["-c", "git rev-parse HEAD"], { cwd: workpieceDir, }); if (revResult.exitCode !== 0) { throw new Error(`git rev-parse exited ${revResult.exitCode}`); } commitSha = revResult.stdout.trim(); } catch { logger.warn("[release.prepare] could not read workpiece HEAD sha"); } // RFC-0634: Write preliminary build-identity.json to workpiece/public/.well-known/ before build const publicWellKnownDir = path.join(workpieceDir, "public", ".well-known"); await context.io.mkdir(publicWellKnownDir); const preliminaryBuildIdentity = { releaseId, systemId, missionId, semver, distTreeHash: "", behaviorSnapshotHash: "", siteContentHash: workpieceTreeHash, platformVersion, platformSemanticHash, commitSha: commitSha === "unknown" ? "0000000" : commitSha, buildTimestamp: new Date().toISOString(), targetPlatform: "cloudflare-workers", }; await atomicWriteFile( path.join(publicWellKnownDir, "build-identity.json"), JSON.stringify(preliminaryBuildIdentity, null, 2) + "\n", ); logger.info(` Wrote preliminary build-identity.json to workpiece/public/.well-known/`); if (canReuseDistribution) { await context.io.mkdir(distDest); await copyDir(distributionDir, distDest); buildReused = true; logger.info(` Reused distribution from mission.build (build input hash matched)`); } else if (await context.io.exists(workpieceDir)) { // RFC-0356: run build.prepare → astro build → build.post unconditionally. // All three phases must succeed — without build.post the distribution is // unsigned (text.normalize.apply, passport.emit, etc. never run). // // Construct a synthetic DiscoveredSiteWorkspace pointing to the workpiece so // the pipeline executor can load the app runtime without relying on // registry.currentMission (which is null for closed missions). const workpieceSite: DiscoveredSiteWorkspace = { name: systemId, directory: workpieceDir, toolsDirectory: path.join(workpieceDir, "tools"), configPath: path.join(workpieceDir, "tools", "kernel.config.ts"), }; // RFC-0845: Playwright Chromium pre-flight check — fail fast before expensive // build.prepare → astro build → build.post cycle. // Skipped on distribution-reuse path (canReuseDistribution branch above). let preflightFailed = false; try { const preflightResult = (await executeKernelCommand({ workspaceRoot, commandName: "playwright.preflight.check", outputFormat: "pretty", })) as { exitCode?: number; summary?: string }; if ((preflightResult.exitCode ?? 0) !== 0) { const msg = preflightResult.summary ?? "Playwright Chromium is not installed"; logger.info(` [preflight] ${msg}`); preflightFailed = true; } else { logger.info(` Playwright Chromium: pre-flight check passed`); } } catch (err) { // Non-fatal: if the check itself throws unexpectedly, log and continue logger.warn( ` Playwright Chromium pre-flight check error (non-fatal): ${err instanceof Error ? err.message : String(err)}`, ); } if (preflightFailed) { throw new Error( `[release.prepare] pre-flight FAILED: Playwright Chromium is not installed. Run: pnpm exec playwright install chromium, then re-run: pnpm exec werkstatt run release.prepare --mission ${missionId}`, ); } logger.info(` Running build.prepare pipeline for ${systemId}…`); try { await runPipelinePhase(workspaceRoot, "build.prepare", systemId, workpieceSite); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error(`[release.prepare] build.prepare failed: ${msg.slice(0, 200)}`); } logger.info(` Running astro build in ${workpieceDir}…`); try { const buildResult = await context.io.exec("sh", ["-c", "pnpm exec astro build"], { cwd: workpieceDir, timeoutMs: 300_000, }); if (buildResult.exitCode !== 0) { throw new Error( `astro build exited ${buildResult.exitCode}: ${buildResult.stderr.slice(-400)}`, ); } } catch (err) { const buildError = err instanceof Error ? err.message : String(err); throw new Error(`[release.prepare] astro build failed: ${buildError.slice(0, 200)}`); } logger.info(` Running build.post pipeline for ${systemId}…`); try { await runPipelinePhase(workspaceRoot, "build.post", systemId, workpieceSite); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error(`[release.prepare] build.post failed: ${msg.slice(0, 200)}`); } const workpieceDist = path.join(workpieceDir, "dist"); if (await context.io.exists(workpieceDist)) { await context.io.mkdir(distDest); await copyDir(workpieceDist, distDest); } else { throw new Error(`[release.prepare] astro build produced no dist/ directory`); } } else { throw new Error( `[release.prepare] no workpiece or distribution found for mission '${missionId}'`, ); } // RFC-0634: Remove preliminary build-identity.json from dist/client/.well-known/ before hashing const distClientBuildIdentityPath = path.join( distDest, "client", ".well-known", "build-identity.json", ); if (await context.io.exists(distClientBuildIdentityPath)) { await context.io.rm(distClientBuildIdentityPath, { recursive: true }); logger.info(` Removed preliminary build-identity.json from dist/ before hashing`); } // RFC-0634: Clean up preliminary from workpiece/public/.well-known/ await context.io.rm(path.join(publicWellKnownDir, "build-identity.json"), { recursive: true, }); // warpgogol-m000196: write the fresh dist back to the mission // distribution — the reuse decision above reads distribution/dist + // build-input-hash.json, and a stale copy (built by an earlier ship // attempt against older platform code) both forces a pointless rebuild // next time and misleads post-ship verification that reads mission // distribution/ instead of the release artifact. Keep distribution/ == // the dist this release actually packaged. Hash recomputed post-build // (the build mutates generated workpiece artifacts), matching // persistDistribution's semantics. if (!buildReused) { const missionDistributionDir = path.join(missionDir, "distribution", "dist"); if (await context.io.exists(missionDistributionDir)) { await context.io.rm(missionDistributionDir, { recursive: true }); } await context.io.mkdir(path.dirname(missionDistributionDir)); await copyDir(distDest, missionDistributionDir); const { buildInputHash: postBuildHash } = await computeBuildInputHash( workspaceRoot, workpieceDir, ); await atomicWriteFile( distributionMetaPath, JSON.stringify( { buildInputHash: postBuildHash, computedAt: new Date().toISOString() }, null, 2, ) + "\n", ); logger.info( ` Refreshed mission distribution/ from the fresh build (build-input hash updated)`, ); } // RFC-0961: Boot-smoke — boot built worker in miniflare and execute representative requests const { runBootSmoke, planBootSmokeRequests, detectBootSmokeLanguages } = await import("./boot-smoke.ts"); // Prefer the Astro-generated wrangler.json in dist/server/ which has // correct paths relative to the dist directory. Fall back to workpiece // wrangler.jsonc if the Astro output doesn't exist. const astroWranglerPath = path.join(distDest, "server", "wrangler.json"); const wranglerConfigPath = (await context.io.exists(astroWranglerPath)) ? astroWranglerPath : path.join(workpieceDir, "wrangler.jsonc"); let bootSmokeVerdict: "pass" | "fail" = "pass"; const deploymentAdapter = (await readSystemConfigSmart(workspaceRoot, systemId, context.io)) .deployment?.adapter; if (!bootSmokeAppliesToAdapter(deploymentAdapter)) { logger.info( ` boot-smoke skipped — deployment adapter '${deploymentAdapter}' produces no worker entry`, ); } else if (await context.io.exists(wranglerConfigPath)) { // When using the Astro-generated wrangler.json, the base directory is // dist/server/ (entry.mjs and assets.directory are relative to it). const bootSmokeDistDir = wranglerConfigPath === astroWranglerPath ? path.join(distDest, "server") : distDest; // Derive languages from actual dist/client subdirectories (RFC-0978) const bootSmokeLanguages = await detectBootSmokeLanguages(distDest, context.io); const bootSmokeRequests = await planBootSmokeRequests({ distDir: distDest, languages: bootSmokeLanguages, io: context.io, }); logger.info(` Running boot-smoke (${bootSmokeRequests.length} representative requests)…`); const bootSmokeResult = await runBootSmoke({ distDir: bootSmokeDistDir, wranglerConfigPath, requests: bootSmokeRequests, io: context.io, }); const bootSmokeFailed = !bootSmokeResult.booted || bootSmokeResult.requests.some((r) => !r.ok) || bootSmokeResult.egressViolations.length > 0 || bootSmokeResult.missingBindings.length > 0; bootSmokeVerdict = bootSmokeFailed ? "fail" : "pass"; if (bootSmokeFailed) { const parts: string[] = []; if (!bootSmokeResult.booted) parts.push(`boot failed: ${bootSmokeResult.bootError ?? "unknown"}`); const failedReqs = bootSmokeResult.requests.filter((r) => !r.ok); if (failedReqs.length > 0) { for (const r of failedReqs) { logger.error( ` boot-smoke request failed: ${r.path} — status=${r.status ?? "null"}, error=${r.error ?? "none"}`, ); } parts.push(`${failedReqs.length}/${bootSmokeResult.requests.length} requests failed`); } if (bootSmokeResult.egressViolations.length > 0) parts.push(`${bootSmokeResult.egressViolations.length} egress violations`); if (bootSmokeResult.missingBindings.length > 0) parts.push(`missing bindings: ${bootSmokeResult.missingBindings.join(", ")}`); throw new Error(`[release.prepare] boot-smoke failed — ${parts.join(", ")}`); } // Persist boot-smoke.json as release evidence await atomicWriteFile( path.join(stagingDir, "boot-smoke.json"), JSON.stringify(bootSmokeResult, null, 2) + "\n", ); logger.success( ` boot-smoke passed (${bootSmokeResult.requests.length} requests, egress clean)`, ); } else { logger.warn(` wrangler.jsonc not found — skipping boot-smoke (no worker to verify)`); } // RFC-1290: carry the workpiece's .werkstatt/release-evidence/*.json into // the release and verify the text corpus hash against the staged dist — // stale evidence is false evidence (DNA-106). Then record the // release-to-release rendered-text diff via the site command by name. await copyReleaseEvidence(workpieceDir, stagingDir, context.io); try { const textDiffResult = (await executeKernelCommand({ workspaceRoot, commandName: "text.corpus.diff", siteName: systemId, argv: ["--to", "working", "--out", stagingDir], outputFormat: "json", siteExplicit: true, })) as { exitCode?: number; summary?: string }; if ((textDiffResult.exitCode ?? 1) !== 0) { throw new Error( `[release.prepare] text.corpus.diff failed — ${textDiffResult.summary ?? "no summary"}`, ); } logger.info(` text-diff recorded: ${textDiffResult.summary ?? ""}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); // Non-site profiles carry no text.corpus.diff — absence is not failure. if (message.includes("is not registered") || message.includes("No target site")) { logger.info(` text.corpus.diff not registered — skipping text-diff`); } else { throw err; } } const now = new Date().toISOString(); // RFC-0585: Capture behavior snapshots and run diff // Astro Cloudflare adapter outputs HTML to dist/client/ — scan that for routes const clientDistDir = (await context.io.exists(path.join(distDest, "client"))) ? path.join(distDest, "client") : distDest; const readableSnapshotPath = path.join(stagingDir, "readable-snapshot.json"); const productionSnapshotPath = path.join(stagingDir, "behavior-snapshot.json"); const snapshotDiffPath = path.join(stagingDir, "snapshot-diff.json"); const readableResult = await runBehaviorSnapshotCapture( { flags: { dist: clientDistDir, system: systemId, "build-kind": "readable", release: releaseId, }, argv: [], }, context, ); if (!readableResult.data) throw new Error("[release.prepare] readable snapshot capture returned no data"); await context.io.writeFile( readableSnapshotPath, JSON.stringify(readableResult.data.wrapper, null, 2) + "\n", ); const productionResult = await runBehaviorSnapshotCapture( { flags: { dist: clientDistDir, system: systemId, "build-kind": "production", release: releaseId, }, argv: [], }, context, ); if (!productionResult.data) throw new Error("[release.prepare] production snapshot capture returned no data"); await context.io.writeFile( productionSnapshotPath, JSON.stringify(productionResult.data.wrapper, null, 2) + "\n", ); const diffResult = await runBehaviorSnapshotDiff( { flags: { baseline: readableSnapshotPath, candidate: productionSnapshotPath }, argv: [], }, context, ); if (!diffResult.data) throw new Error("[release.prepare] behavior snapshot diff returned no data"); await context.io.writeFile(snapshotDiffPath, JSON.stringify(diffResult.data, null, 2) + "\n"); const snapshotDiffVerdict = diffResult.data.verdict as "pass" | "fail"; if (snapshotDiffVerdict === "fail") { throw new Error( `[release.prepare] behavior snapshot diff failed — ${diffResult.data.differences.length} structural differences`, ); } // RFC-0656: Compute deterministic distTreeHash via stable mode (normalizes PDFs, source maps, JSON timestamps) const distTreeResult = await fingerprintTree(distDest, { mode: "stable", root: distDest }); const distTreeHash = distTreeResult.value; const siteContentHash = workpieceTreeHash; const behaviorSnapshotHash = productionResult.data.behaviorSnapshotHash; const readableSnapshotHash = readableResult.data.behaviorSnapshotHash; // RFC-0608: Write build-identity.json into dist/client/.well-known/ const wellKnownDir = path.join(distDest, "client", ".well-known"); await context.io.mkdir(wellKnownDir); const buildIdentity = { releaseId, systemId, missionId, semver, distTreeHash, behaviorSnapshotHash, siteContentHash, platformVersion, platformSemanticHash, commitSha: commitSha === "unknown" ? "0000000" : commitSha, buildTimestamp: now, targetPlatform: "cloudflare-workers", }; await atomicWriteFile( path.join(wellKnownDir, "build-identity.json"), JSON.stringify(buildIdentity, null, 2) + "\n", ); // Write release manifest (RFC-0851: strict artifact-only schema) const releaseManifest: ReleaseManifest = { schemaVersion: "1.0.0", releaseId, systemId, missionId, semver, platformVersion, createdAt: now, readyAt: null, state: "prepared", commitSha: commitSha === "unknown" ? "0000000" : commitSha, platformSemanticHash, siteContentHash, distTreeHash, distArtifactHash: null, artifact: null, behaviorSnapshotHash, readableSnapshotHash, qualityReportHash: null, snapshotDiffVerdict, migratorVerdict: "pass", versionCompareVerdict: "in-sync", bootSmokeVerdict, }; // Write release.yaml directly into stagingDir — atomicMoveDir will carry it // to the final location, avoiding a redundant re-write after the move. const manifestYaml = stringifyYaml(releaseManifest, { sortMapEntries: false }); await atomicWriteFile(path.join(stagingDir, "release.yaml"), manifestYaml); // RFC-0948: Entitlement diff — log added/removed features vs previous release await logEntitlementDiff(logger, workpieceDir, releasesBase, systemReleases, releaseId); // RFC-0761: Copy .env from workpiece to release directory const srcEnv = path.join(workpieceDir, ".env"); const destEnv = path.join(stagingDir, ".env"); if (await context.io.exists(srcEnv)) { await context.io.copyFile(srcEnv, destEnv); logger.info(` Copied .env to release`); } else { logger.warn( ` .env not found in workpiece — propagate/promote will use process.env fallback`, ); } // Atomic rename const finalDir = path.join(releasesBase, releaseId); if (await context.io.exists(finalDir)) { await context.io.rm(finalDir, { recursive: true }); } await atomicMoveDir(stagingDir, finalDir); // RFC-0522: write releaseId to mission manifest for mission-to-release association const missionManifest = await readMissionManifest(workspaceRoot, missionId); missionManifest.releaseId = releaseId; await writeMissionManifest(workspaceRoot, missionManifest); // RFC-0655: sync close-report.json releaseId after writing mission.yaml const closeReportPath = path.join(missionDir, "evidence", "close-report.json"); if (await context.io.exists(closeReportPath)) { try { const closeReportRaw = await context.io.readFile(closeReportPath); const closeReport = JSON.parse(closeReportRaw) as Record; closeReport.releaseId = releaseId; await atomicWriteFile(closeReportPath, JSON.stringify(closeReport, null, 2) + "\n"); logger.info(` Synced close-report.json releaseId to ${releaseId}`); } catch (err) { logger.warn( `[release.prepare] failed to sync close-report.json: ${err instanceof Error ? err.message : String(err)}`, ); } } else { logger.warn( `[release.prepare] close-report.json not found for mission '${missionId}' — skipping releaseId sync (mission may have been closed before RFC-0477)`, ); } // RFC-0520: C-surface regression check delegated to evaluateCSurfaceGate let cSurfaceVerdict: "pass" | "fail" | "skipped" = "skipped"; try { const { runSurfaceContractValidate } = await import("../handoff/surface-contract.ts"); const surfaceResult = await runSurfaceContractValidate( { flags: { app: systemId }, argv: [] }, context, ); const mission = await readMissionManifest(workspaceRoot, missionId); const rfcId = mission.rfcId ?? null; let breaksC = false; if (rfcId) { breaksC = await checkBreaksCDeclaration(workspaceRoot, rfcId); } const guardResult = evaluateCSurfaceGate({ systemId, missionId, workspaceRoot, surfaceValidateResult: { exitCode: surfaceResult.exitCode ?? 1, summary: surfaceResult.summary, }, rfcId, breaksC, }); cSurfaceVerdict = guardResult.verdict === "fail" ? "fail" : "pass"; if (guardResult.verdict === "fail") { throw new Error(guardResult.violations[0]!.message); } if (guardResult.verdict === "pass" && breaksC) { logger.info(` ${guardResult.summary}`); } } catch (err) { if (err instanceof Error && err.message.includes("C-surface regression")) { throw err; } cSurfaceVerdict = "skipped"; } logger.success( `[release.prepare] ${releaseId} prepared (snapshot diff: ${snapshotDiffVerdict}, C-surface: ${cSurfaceVerdict})`, ); return { data: { releaseId, systemId, missionId, semver, state: "prepared", snapshotDiffVerdict, cSurfaceVerdict, behaviorSnapshotHash, distTreeHash, siteContentHash, readableSnapshotHash, buildReused, bootSmokeVerdict, }, exitCode: 0, summary: `[release.prepare] ${releaseId} prepared (snapshot diff: ${snapshotDiffVerdict}, C-surface: ${cSurfaceVerdict}, boot-smoke: ${bootSmokeVerdict})`, nextSteps: [ { action: `Mark release as ready: pnpm exec werkstatt run release.ready --release ${releaseId}`, kind: "required", }, ], }; } finally { await releaseLock(workspaceRoot, `release:${releaseId}`); } } finally { await releaseLock(workspaceRoot, `system:${systemId}`); } } async function copyDir( src: string, dest: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { if (!(await io.exists(src))) return; await io.mkdir(dest); const entries = await io.readdir(src); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory) { await copyDir(srcPath, destPath); } else { await io.copyFile(srcPath, destPath); } } } // §6.2: release.ready (renamed from release.publish by RFC-0724) export interface ReleaseReadyData { releaseId: string; systemId: string; state: "ready"; readyAt: string; artifactUri: string | null; distArtifactHash: string | null; distVerified: boolean; } export async function runReleaseReady( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const releaseId = flagString(input, "release"); if (!releaseId) throw new Error("[release.ready] --release is required"); const releaseDir = path.join(workspaceRoot, "releases", releaseId); if (!(await context.io.exists(releaseDir))) { throw new Error(`[release.ready] release '${releaseId}' not found`); } const manifest = await readReleaseManifest(workspaceRoot, releaseId); if (manifest.state !== "prepared") { throw new Error( `[release.ready] release '${releaseId}' is not prepared (state: ${manifest.state})`, ); } // RFC-0585: Check distTreeHash is not pending const distTreeHash = manifest.distTreeHash; if (!distTreeHash || distTreeHash === "sha256:pending") { throw new Error( `[release.ready] distTreeHash is pending or missing — run release.prepare to compute a real hash before marking ready`, ); } // RFC-0585: Check dist directory exists const distDir = path.join(releaseDir, "dist"); if (!(await context.io.exists(distDir))) { throw new Error( `[release.ready] release '${releaseId}' has no dist/ directory — run release.prepare to build and stage the distribution`, ); } // Check snapshot diff verdict if (manifest.snapshotDiffVerdict === "fail") { throw new Error(`[release.ready] snapshot diff verdict is fail — cannot mark ready`); } // Check discipline gates if (manifest.migratorVerdict === "fail") { throw new Error(`[release.ready] migrator.registry.validate failed — cannot mark ready`); } if (manifest.versionCompareVerdict === "refuse-downgrade") { throw new Error( `[release.ready] version-compare verdict is refuse-downgrade — cannot mark ready`, ); } const systemId = manifest.systemId; const operationId = generateOperationId(); await acquireLock(workspaceRoot, `system:${systemId}`, operationId, "release.ready", "agent"); await acquireLock(workspaceRoot, `release:${releaseId}`, operationId, "release.ready", "agent"); try { const now = new Date().toISOString(); // RFC-0596: Store artifact BEFORE state transition (eliminates partial failure) // release.ready already holds release:${releaseId} and system:${systemId} locks, // so we call the lock-free core directly — not runArtifactStorePut which would deadlock. // distDir was already declared and validated above (RFC-0585 check). const artifactResult = await storeArtifactCore( workspaceRoot, releaseId, distDir, systemId, context.io, ); // Update manifest with artifact reference and state transition in a single write const artifactRef: ReleaseArtifactRef = { uri: artifactResult.uri, provider: "local", distArtifactHash: artifactResult.distArtifactHash, distTreeHash: artifactResult.distTreeHash, siteContentHash: artifactResult.siteContentHash, byteSize: artifactResult.byteSize, fileCount: artifactResult.fileCount, }; manifest.artifact = artifactRef; manifest.distArtifactHash = artifactResult.distArtifactHash; manifest.state = "ready"; manifest.readyAt = now; await writeReleaseYaml(workspaceRoot, releaseId, manifest); // Append Bordbuch entry (RFC-0750: commit atomically) await appendAndCommitBordbuch( workspaceRoot, systemId, "release-ready", `Release ${releaseId} marked ready`, "agent", { writerRole: "release", metadata: { releaseId, semver: manifest.semver }, }, `Bordbuch: release-ready ${releaseId}`, ); // Update state const state = await readSystemState(workspaceRoot, systemId); state.lastRelease = releaseId; await writeSystemState(workspaceRoot, systemId, state); logger.success(`[release.ready] ${releaseId} marked ready`); return { data: { releaseId, systemId, state: "ready", readyAt: now, artifactUri: manifest.artifact?.uri ?? null, distArtifactHash: manifest.distArtifactHash ?? null, distVerified: true, }, exitCode: 0, summary: `[release.ready] ${releaseId} marked ready`, nextSteps: [ { action: `Deploy to dev: pnpm exec werkstatt run leitstand.dev-deploy --release ${releaseId}`, kind: "required", }, ], }; } finally { await releaseLock(workspaceRoot, `release:${releaseId}`); await releaseLock(workspaceRoot, `system:${systemId}`); } } // §6.3: release.validate export interface ReleaseValidateData { releaseId: string; manifestFound: boolean; state: string; snapshotDiffVerdict: string; artifactPresent: boolean; } export async function runReleaseValidate( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const releaseId = flagString(input, "release"); if (!releaseId) throw new Error("[release.validate] --release is required"); const releaseDir = path.join(workspaceRoot, "releases", releaseId); const manifestFound = await context.io.exists(releaseDir); if (!manifestFound) { logger.error(`[release.validate] release '${releaseId}' not found`); return { ...failResult("release.commands", [`release '${releaseId}' not found`]), data: { releaseId, manifestFound, state: "absent", snapshotDiffVerdict: "fail", artifactPresent: false, }, }; } const manifest = await readReleaseManifest(workspaceRoot, releaseId); const distDir = path.join(releaseDir, "dist"); const releaseState = manifest.state; let artifactPresent: boolean; if (releaseState === "ready") { artifactPresent = manifest.artifact !== null; if (!artifactPresent) { logger.warn( `[release.validate] ready release '${releaseId}' has no artifact — run release.ready to store it`, ); } } else { artifactPresent = (await context.io.exists(distDir)) || manifest.artifact !== null; } logger.success(`[release.validate] ${releaseId} valid (state: ${releaseState})`); return { data: { releaseId, manifestFound, state: manifest.state, snapshotDiffVerdict: manifest.snapshotDiffVerdict, artifactPresent, }, exitCode: 0, summary: `[release.validate] ${releaseId} valid (state: ${manifest.state})`, nextSteps: manifest.state === "prepared" ? [ { action: `Mark release as ready: pnpm exec werkstatt run release.ready --release ${releaseId}`, kind: "optional", }, ] : manifest.state === "ready" ? [ { action: `Deploy to dev: pnpm exec werkstatt run leitstand.dev-deploy --release ${releaseId}`, kind: "optional", }, ] : undefined, }; } // §6.4: release.list export interface ReleaseListData { releases: Array<{ releaseId: string; systemId: string; state: string }>; count: number; legacyInvalid: LegacyReleaseDiagnostic[]; } export async function runReleaseList( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const systemFilter = flagString(input, "site"); const releasesDir = path.join(workspaceRoot, "releases"); if (!(await context.io.exists(releasesDir))) { return { data: { releases: [], count: 0, legacyInvalid: [] }, exitCode: 0, summary: `[release.list] 0 releases`, }; } const entries = await context.io.readdir(releasesDir); const releases: Array<{ releaseId: string; systemId: string; state: string }> = []; const legacyInvalid: LegacyReleaseDiagnostic[] = []; for (const entry of entries) { if (!entry.isDirectory || entry.name.includes(".staging-")) continue; const releaseId = entry.name; try { const manifest = await readReleaseManifest(workspaceRoot, releaseId); const systemId = manifest.systemId; if (systemFilter && systemId !== systemFilter) continue; releases.push({ releaseId, systemId, state: manifest.state }); } catch (err) { if (err instanceof LegacyReleaseError) { legacyInvalid.push({ schema: "werkstatt/legacy-release-diagnostic@1", releaseId: err.releaseId, legacyState: err.legacyState as LegacyReleaseDiagnostic["legacyState"], ruleId: "CERT-LEGACY-STATE-01", message: `Release '${err.releaseId}' uses legacy state '${err.legacyState}' which is no longer valid per RFC-0851.`, fixHint: "This release directory is preserved but cannot be used. CERT-010 will handle audited cleanup.", }); } } } logger.info(` Found ${releases.length} valid releases, ${legacyInvalid.length} legacy invalid`); return { data: { releases, count: releases.length, legacyInvalid }, exitCode: 0, summary: `[release.list] ${releases.length} releases (${legacyInvalid.length} legacy invalid)`, }; } // §6.8: release.state.validate (RFC-0655) export interface ReleaseStateCheck { rule: string; status: "pass" | "warn" | "fail"; message: string; } export interface ReleaseStateValidateData { missionId: string | null; releaseId: string | null; systemId: string | null; releaseState: "prepared" | "ready" | "missing"; checks: ReleaseStateCheck[]; summary: string; } export async function runReleaseStateValidate( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const missionIdFlag = flagString(input, "mission"); const releaseIdFlag = flagString(input, "release"); const systemFlag = flagString(input, "site"); if (!missionIdFlag && !releaseIdFlag && !systemFlag) { throw new Error( "[release.state.validate] at least one of --mission, --release, or --site is required", ); } // Resolve the list of (missionId, releaseId, systemId) tuples to validate const targets: Array<{ missionId: string | null; releaseId: string | null; systemId: string | null; }> = []; if (missionIdFlag) { const manifest = await readMissionManifest(workspaceRoot, missionIdFlag); const rid = (manifest.releaseId as string | null) ?? null; const sid = (manifest.systemId as string | null) ?? null; targets.push({ missionId: missionIdFlag, releaseId: rid, systemId: sid }); } else if (releaseIdFlag) { const manifest = await readReleaseManifest(workspaceRoot, releaseIdFlag); const sid = manifest.systemId; const mid = manifest.missionId; targets.push({ missionId: mid, releaseId: releaseIdFlag, systemId: sid }); } else if (systemFlag) { // Validate all releases for the system const releaseIds = await listReleaseIds(workspaceRoot); for (const rid of releaseIds) { try { const manifest = await readReleaseManifest(workspaceRoot, rid); const sid = manifest.systemId; if (sid !== systemFlag) continue; const mid = manifest.missionId; targets.push({ missionId: mid, releaseId: rid, systemId: sid }); } catch { // skip unreadable } } } const allChecks: ReleaseStateCheck[] = []; let hasError = false; for (const target of targets) { const checks = await validateReleaseState(workspaceRoot, target, logger); allChecks.push(...checks); if (checks.some((c) => c.status === "fail")) { hasError = true; } } // Determine overall release state from the first target (for single-target invocations) let overallReleaseState: ReleaseStateValidateData["releaseState"] = "missing"; if (targets.length > 0 && targets[0]!.releaseId) { try { const manifest = await readReleaseManifest(workspaceRoot, targets[0]!.releaseId); overallReleaseState = manifest.state; } catch { overallReleaseState = "missing"; } } const firstTarget = targets[0]; const summary = hasError ? `[release.state.validate] ${allChecks.filter((c) => c.status === "fail").length} error${allChecks.filter((c) => c.status === "fail").length === 1 ? "" : "s"} found` : `[release.state.validate] all checks passed${allChecks.some((c) => c.status === "warn") ? ` (${allChecks.filter((c) => c.status === "warn").length} warning${allChecks.filter((c) => c.status === "warn").length === 1 ? "" : "s"})` : ""}`; if (hasError) { logger.warn(`[release.state.validate] ${summary}`); } else { logger.info(`[release.state.validate] ${summary}`); } return { data: { missionId: firstTarget?.missionId ?? null, releaseId: firstTarget?.releaseId ?? null, systemId: firstTarget?.systemId ?? null, releaseState: overallReleaseState, checks: allChecks, summary, }, summary, nextSteps: hasError ? [ { action: `Fix the failing checks above, then re-run: pnpm exec werkstatt run release.state.validate --release ${firstTarget?.releaseId ?? ""}`, kind: "required", }, ] : undefined, }; } async function validateReleaseState( workspaceRoot: string, target: { missionId: string | null; releaseId: string | null; systemId: string | null }, _logger: { warn: (msg: string) => void; info: (msg: string) => void }, io: WorkspaceIO = createDefaultIO().io, ): Promise { const checks: ReleaseStateCheck[] = []; const { missionId, releaseId, systemId } = target; // Read mission.yaml if missionId is available let missionReleaseId: string | null = null; if (missionId) { try { const manifest = await readMissionManifest(workspaceRoot, missionId); missionReleaseId = (manifest.releaseId as string | null) ?? null; } catch { // mission.yaml not found — skip mission-based checks } } // Check 1: mission-yaml-release-id-exists if (missionReleaseId) { const releaseDir = path.join(workspaceRoot, "releases", missionReleaseId); const releaseYaml = path.join(releaseDir, "release.yaml"); if (!(await io.exists(releaseDir)) || !(await io.exists(releaseYaml))) { checks.push({ rule: "mission-yaml-release-id-exists", status: "fail", message: `mission.yaml references releaseId '${missionReleaseId}' but release directory or release.yaml does not exist`, }); } else { checks.push({ rule: "mission-yaml-release-id-exists", status: "pass", message: `release directory for '${missionReleaseId}' exists`, }); } } else if (missionId) { checks.push({ rule: "mission-yaml-release-id-exists", status: "pass", message: `mission '${missionId}' has no releaseId in mission.yaml — nothing to validate`, }); } // Check 2: close-report-release-id-consistent if (missionId) { const closeReportPath = path.join( await resolveMissionDir(workspaceRoot, missionId, io), "evidence", "close-report.json", ); if (!(await io.exists(closeReportPath))) { checks.push({ rule: "close-report-release-id-consistent", status: "warn", message: `close-report.json not found for mission '${missionId}' (mission may have been closed before RFC-0477) — skipping check`, }); } else { try { const raw = await io.readFile(closeReportPath); const report = JSON.parse(raw) as Record; const closeReportReleaseId = (report.releaseId as string | null) ?? null; if (missionReleaseId && closeReportReleaseId !== missionReleaseId) { checks.push({ rule: "close-report-release-id-consistent", status: "fail", message: `close-report.json releaseId '${closeReportReleaseId}' does not match mission.yaml releaseId '${missionReleaseId}'`, }); } else { checks.push({ rule: "close-report-release-id-consistent", status: "pass", message: `close-report.json releaseId is consistent with mission.yaml`, }); } } catch (err) { checks.push({ rule: "close-report-release-id-consistent", status: "warn", message: `failed to parse close-report.json: ${err instanceof Error ? err.message : String(err)}`, }); } } } // Check 3: release-state-progressed const effectiveReleaseId = releaseId ?? missionReleaseId; if (effectiveReleaseId) { try { const manifest = await readReleaseManifest(workspaceRoot, effectiveReleaseId); const state = manifest.state; if (state === "prepared") { checks.push({ rule: "release-state-progressed", status: "warn", message: `release '${effectiveReleaseId}' is in 'prepared' state (orphaned — not yet ready)`, }); } else { checks.push({ rule: "release-state-progressed", status: "pass", message: `release '${effectiveReleaseId}' state is '${state}'`, }); } // Check 5: state-last-release-consistent if (state === "ready" && systemId) { try { const systemState = await readSystemState(workspaceRoot, systemId); const lastRelease = systemState.lastRelease as string | null | undefined; if (!lastRelease) { checks.push({ rule: "state-last-release-consistent", status: "warn", message: `release '${effectiveReleaseId}' is ready but system-state.yaml has no lastRelease for system '${systemId}'`, }); } else if (lastRelease !== effectiveReleaseId) { checks.push({ rule: "state-last-release-consistent", status: "warn", message: `system-state.yaml lastRelease '${lastRelease}' does not match ready release '${effectiveReleaseId}'`, }); } else { checks.push({ rule: "state-last-release-consistent", status: "pass", message: `system-state.yaml lastRelease is consistent`, }); } } catch { checks.push({ rule: "state-last-release-consistent", status: "warn", message: `failed to read system-state.yaml for system '${systemId}'`, }); } } } catch { checks.push({ rule: "release-state-progressed", status: "fail", message: `release '${effectiveReleaseId}' not found or unreadable`, }); } } // Check 4: bordbuch-release-id-consistent if (systemId && missionId) { try { const { readBordbuch } = await import("../bordbuch/bordbuch-io.ts"); const entries = await readBordbuch(workspaceRoot, systemId); const missionCloseEntries = entries.filter( (e) => e.kind === "mission-close" && e.missionId === missionId, ); if (missionCloseEntries.length === 0) { checks.push({ rule: "bordbuch-release-id-consistent", status: "warn", message: `no mission-close bordbuch entry found for mission '${missionId}'`, }); } else { const latestClose = missionCloseEntries[missionCloseEntries.length - 1]!; const bordbuchReleaseId = latestClose.releaseId; // The bordbuch releaseId reflects the state at close time. // If release.prepare wrote a new releaseId to mission.yaml after close, // the bordbuch correctly reflects the close-time state — not a mismatch. // We only flag a mismatch if the bordbuch has a releaseId that differs // from what mission.yaml had at close time (which we can't know post-prepare). // So we check: if bordbuch has a non-null releaseId, it should match // mission.yaml's current releaseId (since release.prepare doesn't change // bordbuch). If bordbuch has null and mission.yaml has non-null, that's // expected when release.prepare ran after close. if (bordbuchReleaseId && missionReleaseId && bordbuchReleaseId !== missionReleaseId) { checks.push({ rule: "bordbuch-release-id-consistent", status: "fail", message: `bordbuch mission-close releaseId '${bordbuchReleaseId}' does not match mission.yaml releaseId '${missionReleaseId}'`, }); } else { checks.push({ rule: "bordbuch-release-id-consistent", status: "pass", message: bordbuchReleaseId ? `bordbuch releaseId '${bordbuchReleaseId}' is consistent` : `bordbuch releaseId is null (mission closed before release.prepare — expected)`, }); } } } catch { checks.push({ rule: "bordbuch-release-id-consistent", status: "warn", message: `failed to read bordbuch for system '${systemId}'`, }); } } return checks; } // §6.9: dist.determinism.validate (RFC-0656) export interface NonDeterministicFile { path: string; normalizer: string; reason: string; } export interface DistDeterminismValidateData { distPath: string; stableHash: string; byteHash: string; hashesMatch: boolean; nonDeterministicFiles: NonDeterministicFile[]; totalFiles: number; } export async function runDistDeterminismValidate( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot } = context; const releaseId = flagString(input, "release"); const missionId = flagString(input, "mission"); if (!releaseId && !missionId) { throw new Error("[dist.determinism.validate] either --release or --mission is required"); } if (releaseId && missionId) { throw new Error("[dist.determinism.validate] --release and --mission are mutually exclusive"); } let distPath: string; if (releaseId) { distPath = path.join(workspaceRoot, "releases", releaseId, "dist"); } else { const missionDir = await resolveMissionDir(workspaceRoot, missionId!, context.io); const workpieceDist = path.join(missionDir, "workpiece", "dist"); const distributionDist = path.join(missionDir, "distribution", "dist"); if (await context.io.exists(workpieceDist)) { distPath = workpieceDist; } else if (await context.io.exists(distributionDist)) { distPath = distributionDist; } else { return { ...failResult("release.commands", [ `[dist.determinism.validate] no dist directory found for mission '${missionId}'`, ]), data: { distPath: "", stableHash: "", byteHash: "", hashesMatch: false, nonDeterministicFiles: [], totalFiles: 0, }, }; } } if (!(await context.io.exists(distPath))) { return { ...failResult("release.commands", [ `[dist.determinism.validate] dist directory does not exist: ${distPath}`, ]), data: { distPath, stableHash: "", byteHash: "", hashesMatch: false, nonDeterministicFiles: [], totalFiles: 0, }, }; } const stableResult = await fingerprintTree(distPath, { mode: "stable", root: distPath }); const byteResult = await fingerprintTree(distPath, { mode: "byte" }); const nonDeterministicFiles: NonDeterministicFile[] = []; const stableByPath = new Map(); for (const f of stableResult.files) { stableByPath.set(f.path, f); } for (const byteFile of byteResult.files) { const stableFile = stableByPath.get(byteFile.path); if (stableFile && stableFile.hash !== byteFile.hash) { nonDeterministicFiles.push({ path: byteFile.path, normalizer: stableFile.normalizer, reason: `${stableFile.normalizer} normalization changed hash`, }); } } const hashesMatch = nonDeterministicFiles.length === 0; return { data: { distPath, stableHash: stableResult.value, byteHash: byteResult.value, hashesMatch, nonDeterministicFiles, totalFiles: stableResult.files.length, }, exitCode: hashesMatch ? undefined : 1, summary: hashesMatch ? `[dist.determinism.validate] all ${stableResult.files.length} files are deterministic` : `[dist.determinism.validate] ${nonDeterministicFiles.length}/${stableResult.files.length} files are non-deterministic`, nextSteps: hashesMatch ? undefined : [ { action: `Review the ${nonDeterministicFiles.length} non-deterministic files listed above and add normalizers or exclude them from the build, then re-run: pnpm exec werkstatt run dist.determinism.validate --release ${releaseId ?? ``}`, kind: "required", }, ], }; } /* --- RFC-0931: release.sign --- */ export interface ReleaseSignData { command: "release.sign"; releaseId: string; buildIdentitySigned: boolean; signedManifestProduced: boolean; signatureHex: string; publicKeyHex: string; publicKeyUrl: string | null; reusedExistingSignature: boolean; } export async function runReleaseSign( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot } = context; const releaseId = flagString(input, "release"); if (!releaseId) throw new Error("[release.sign] --release is required"); const releaseDir = path.join(workspaceRoot, "releases", releaseId); if (!(await context.io.exists(releaseDir))) { throw new Error(`[release.sign] release directory not found: ${releaseDir}`); } const distDir = path.join(releaseDir, "dist"); const buildIdentityPath = path.join(distDir, "client", ".well-known", "build-identity.json"); if (!(await context.io.exists(buildIdentityPath))) { throw new Error( `[release.sign] build-identity.json not found at ${buildIdentityPath}. Run release.prepare first.`, ); } const env = { ...filterEnv(process.env) }; const privateKeyEnv = env["SIGNING_PRIVATE_KEY"]; const privateKeyPath = env["SIGNING_PRIVATE_KEY_PATH"]; const publicKeyUrl = env["PUBLIC_KEY_URL"]; if (!privateKeyEnv && !privateKeyPath) { throw new Error( "[release.sign] No signing key configured. Set SIGNING_PRIVATE_KEY or SIGNING_PRIVATE_KEY_PATH env var.", ); } let privateKeyBytes: Uint8Array; let privateKeyPem: string; try { if (privateKeyEnv) { privateKeyPem = privateKeyEnv; privateKeyBytes = await loadPrivateKey({ pem: privateKeyEnv }); } else { privateKeyPem = await context.io.readFile(privateKeyPath!); privateKeyBytes = await loadPrivateKey({ filePath: privateKeyPath!, encoding: "pem", }); } } catch (err) { throw new Error( `[release.sign] Failed to load private key: ${err instanceof Error ? err.message : String(err)}`, ); } try { await runIntegrityBuildRecord( { flags: {}, args: ["dist"] } as unknown as KernelCommandInput, { ...context, dryRun: false, site: undefined, workspaceRoot: releaseDir, } as KernelRuntimeContext, ); } catch { // Non-fatal: integrity.build-record may fail if dist is already recorded } const signResult = await signBuildIdentity({ buildIdentityPath, privateKeyBytes, publicKeyUrl, }); let signedManifestProduced = false; try { await signLatestBuildArtifacts({ cwd: releaseDir, privateKeyPem, publicKeyUrl, }); signedManifestProduced = true; } catch { // Non-fatal: signed-manifest.json may fail if .integrity/ artifacts are incomplete } await writeReleasePublicKey({ distDir, publicKeyHex: signResult.publicKeyHex, }); const data: ReleaseSignData = { command: "release.sign", releaseId, buildIdentitySigned: true, signedManifestProduced, signatureHex: signResult.signatureHex, publicKeyHex: signResult.publicKeyHex, publicKeyUrl: signResult.publicKeyUrl, reusedExistingSignature: signResult.reusedExistingSignature, }; return { data, exitCode: 0, summary: `[release.sign] ${releaseId}: build-identity.json signed${signResult.reusedExistingSignature ? " (reused existing signature)" : ""}, release-pubkey.json written${signedManifestProduced ? ", signed-manifest.json produced" : ""}`, }; }