/*
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" : ""}`,
};
}