/*
Shared helpers for the three-phase build pipeline (build.prepare → astro build → build.post) used by mission.build, mission.validate, and release.prepare. Centralizes pipeline execution and build-input-hash computation to eliminate duplication.
- Does not define pipeline composition or command registration — that lives in site-kernel-checks pipelines.
- Does not run astro build directly — callers own the execSync call between build.prepare and build.post.
- ADR-0008: extracted runPipelinePhase and computeBuildInputHash from mission-materialization-commands.ts and release-commands.ts to eliminate 5x duplicated pipeline execution pattern and 2x duplicated build-input-hash computation.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
*/
import path from "node:path";
import type {
DiscoveredSiteWorkspace,
KernelPipelineReport,
} from "@warpgogol/werkstatt-engine/kernel";
import { executeKernelPipeline } from "@warpgogol/werkstatt-engine/kernel";
import { fingerprintTree } from "@warpgogol/werkstatt-engine/fingerprint/semantic";
import { byteHash } from "@warpgogol/werkstatt-engine/fingerprint";
import { atomicWriteFile } from "../werkstatt/atomic.ts";
import { resolveCurrentEcosystem, resolvePlatformSemanticHash } from "./bundle-io.ts";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
/**
* Runs a kernel pipeline phase and throws a descriptive error if it fails.
* Centralizes the executeKernelPipeline → unwrap → ok-check → format-failings pattern
* that is used by mission.build, mission.validate, and release.prepare.
* When `siteWorkspace` is provided, bypasses site discovery (needed for closed-mission
* workpieces where registry.currentMission is null).
*/
export async function runPipelinePhase(
workspaceRoot: string,
pipelineName: string,
siteName: string,
siteWorkspace?: DiscoveredSiteWorkspace,
): Promise {
const result = await executeKernelPipeline({
workspaceRoot,
pipelineName,
siteName,
outputFormat: "pretty",
siteWorkspace,
});
const report: KernelPipelineReport = Array.isArray(result) ? result[0] : result;
if (!report.ok) {
const failed = report.steps
.filter((s) => !s.ok)
.map((s) => `${s.commandName} (exit ${s.exitCode})`);
throw new Error(`${pipelineName} failed: ${failed.join(", ")}`);
}
return report;
}
export interface BuildInputHashResult {
buildInputHash: string;
workpieceTreeHash: string;
platformVersion: string;
platformSemanticHash: string;
}
/**
* Computes the build input hash for a workpiece directory.
* Centralizes the content-tree-hash + platform-version + platform-semantic-hash
* computation used by mission.build (to write build-input-hash.json) and
* release.prepare (to decide whether to reuse a distribution).
* Returns intermediate values so release.prepare can populate the release manifest.
*/
export async function computeBuildInputHash(
workspaceRoot: string,
workpieceDir: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const { version: platformVersion } = await resolveCurrentEcosystem(workspaceRoot);
const platformSemanticHash = await resolvePlatformSemanticHash(workspaceRoot);
const contentDir = path.join(workpieceDir, "src", "content");
let workpieceTreeHash = "sha256:absent";
if (await io.exists(contentDir)) {
const contentResult = await fingerprintTree(contentDir, { mode: "semantic" });
workpieceTreeHash = contentResult.value;
}
const buildInputHash = byteHash(
`${workpieceTreeHash}|${platformVersion}|${platformSemanticHash}`,
);
return { buildInputHash, workpieceTreeHash, platformVersion, platformSemanticHash };
}
export interface PreliminaryBuildIdentityFields {
releaseId: string;
systemId: string;
missionId: string;
semver: string;
}
/**
* Writes a preliminary build-identity.json to workpiece/public/.well-known/ before
* astro build so the open-source-registry-section.astro component can read deployment
* metadata (releaseId, buildTimestamp, commitSha) at build time. Without this file,
* the component falls back to placeholder values ("—") which text.normalize.apply
* later converts to "-" in the prerendered HTML.
*
* Returns the file path for later cleanup via cleanupPreliminaryBuildIdentity.
*/
export async function writePreliminaryBuildIdentity(
workpieceDir: string,
fields: PreliminaryBuildIdentityFields,
logger: { info: (msg: string) => void },
io: WorkspaceIO = createDefaultIO().io,
): Promise {
let commitSha = "0000000";
try {
const revResult = await 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 {
// Workpiece may not be a git repo — use placeholder
}
const publicWellKnownDir = path.join(workpieceDir, "public", ".well-known");
await io.mkdir(publicWellKnownDir);
const preliminary = {
releaseId: fields.releaseId,
systemId: fields.systemId,
missionId: fields.missionId,
semver: fields.semver,
distTreeHash: "",
behaviorSnapshotHash: "",
siteContentHash: "",
platformVersion: "",
platformSemanticHash: "",
commitSha,
buildTimestamp: new Date().toISOString(),
targetPlatform: "cloudflare-workers",
};
const filePath = path.join(publicWellKnownDir, "build-identity.json");
await atomicWriteFile(filePath, JSON.stringify(preliminary, null, 2) + "\n");
logger.info(` Wrote preliminary build-identity.json to workpiece/public/.well-known/`);
return filePath;
}
/**
* Removes the preliminary build-identity.json from workpiece/public/.well-known/
* after build.post completes. The copy in dist/client/.well-known/ is left intact —
* release.prepare overwrites it with the final build-identity.json.
*/
export async function cleanupPreliminaryBuildIdentity(
filePath: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
await io.rm(filePath, { recursive: true });
}