/* RFC-0865: Shared helper for certification-gated deployment authorization. Loads gate decisions, calls authorizeDeployment(), and records deployment operation state. Do not implement deployment adapter logic — that lives in adapter.ts and cloudflare-workers.ts. Do not implement certification evaluation — that lives in aggregation.ts. 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. RFC-1126: step 4 — artifact-hash provenance resolveArtifactHash returns { hash, source } (flag | artifact.tar.gz | release.yaml); all 4 leitstand call sites log provenance via context.logger. Tests updated for the new contract + new release.yaml provenance case. RFC-1126: step 7 — AC evidence alignment resolveArtifactHash returns path alongside { hash, source } and all 4 leitstand log lines report the resolved file path (AC-4). New AC-bound tests: config-regenerate-drift-guard.test.ts (AC-1 warn + AC-6 strict no-write), runtime/tests/workpiece-env.test.ts (AC-2 precedence). RFC probe paths updated to actual test locations. RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared RFC-1297: added gateForChannel — the gate a channel deploy/rollback is recorded under. RFC-0865, RFC-0866, RFC-0926, RFC-1097 */ import path from "node:path"; import { writeFileIfChanged } from "@warpgogol/werkstatt-engine/kernel"; import { cacheCloneCommit } from "../mission/mission-git-commit.ts"; import { gitExec } from "../werkstatt/git-exec.ts"; import type { Sha256Digest } from "@warpgogol/werkstatt-shared/fingerprint"; import { isSha256Digest, byteHashFile } from "@warpgogol/werkstatt-shared/fingerprint"; import type { GateDecisionV1, MainVerificationDecisionV1, } from "../certification/contracts/decisions.ts"; import { gateDecisionV1Schema, mainVerificationDecisionV1Schema, } from "../certification/contracts/decisions.ts"; import { authorizeDeployment, verifyMainPromotion, evaluateRollback, buildDeploymentEffectRecord, type CertificationGate, type DeploymentAuthorizationInputV1, type DeploymentAuthorizationOutcomeV1, type MainVerificationOutcomeV1, type RollbackEvaluationInputV1, type RollbackEvaluationOutcomeV1, type DeploymentEffectRecordV1, } from "../certification/deployment/authority.ts"; import type { DeploymentOperationState } from "../certification/state-machine.ts"; import { createR2StorageAdapter, verifyStoredObject, type R2StorageConfig, } from "../certification/storage/index.ts"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; export function flagSite(input: { flags: Record }): string | undefined { const v = input.flags["site"]; if (typeof v === "string") return v; const sys = input.flags["system"]; if (typeof sys === "string") return sys; return undefined; } /** RFC-1297: the gate a channel deploy/rollback is recorded under. */ export function gateForChannel(channel: "dev" | "alt" | "main"): CertificationGate { return channel === "dev" ? "dev-deploy" : channel === "alt" ? "propagate-alt" : "promote-main"; } export interface GateDecisionInput { gateDecisionPath: string; artifactHash: Sha256Digest; candidateId: string; gate: CertificationGate; durableSyncVerified: boolean; artifactReadinessVerified: boolean; forceRequested: boolean; skipRequested: boolean; waiverRequested: boolean; graceRequested: boolean; } export interface AuthorizeResult { ok: true; outcome: DeploymentAuthorizationOutcomeV1 & { ok: true }; gateDecision: GateDecisionV1; } export interface AuthorizeFailure { ok: false; outcome: DeploymentAuthorizationOutcomeV1 & { ok: false }; gateDecision: GateDecisionV1; } export type AuthorizeOutcome = AuthorizeResult | AuthorizeFailure; function formatGateDecisionMissingHint(filePath: string): string { try { const basename = path.basename(filePath, ".json"); const gateMatch = basename.match(/-(dev|alt|main)$/); const gate = gateMatch?.[1]; if (!gate) return ""; const releaseId = basename.slice(0, -(gate.length + 1)); const gateDecisionsDir = path.dirname(filePath); const cacheCloneDir = path.dirname(gateDecisionsDir); const systemId = path.basename(cacheCloneDir); return ` — run: pnpm exec werkstatt run leitstand.certify --site ${systemId} --release ${releaseId} --gate ${gate}`; } catch { return ""; } } export async function loadGateDecision( filePath: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { if (!(await io.exists(filePath))) { const hint = formatGateDecisionMissingHint(filePath); throw new Error(`[deploy] gate decision file not found: ${filePath}${hint}`); } const content = await io.readFile(filePath); const parsed = JSON.parse(content); const result = gateDecisionV1Schema.safeParse(parsed); if (!result.success) { throw new Error( `[deploy] invalid gate decision: ${result.error.issues.map((i) => i.message).join("; ")}`, ); } return result.data; } export async function loadMainVerificationDecision( filePath: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { if (!(await io.exists(filePath))) { const dir = path.dirname(filePath); let available: string[] = []; try { available = (await io.readdir(dir)) .map((e) => e.name) .filter((f) => f.endsWith("-main-verification.json")) .sort(); } catch { /* dir may not exist */ } const availableHint = available.length > 0 ? ` Available main-verification files: ${available.join(", ")}.` : ""; throw new Error( `[deploy] main verification decision file not found at ${filePath}.` + availableHint + ` Run 'leitstand.certify --site --release --gate main' first — it generates ` + `the main-verification file. The expected path is gate-decisions/-main-verification.json.`, ); } const content = await io.readFile(filePath); const parsed = JSON.parse(content); const result = mainVerificationDecisionV1Schema.safeParse(parsed); if (!result.success) { const dir = path.dirname(filePath); const releasePrefix = path.basename(filePath).replace(/-main-verification\.json$/, ""); let available: string[] = []; try { available = (await io.readdir(dir)) .map((e) => e.name) .filter((f) => f.startsWith(releasePrefix)) .sort(); } catch { /* ignore */ } throw new Error( `[deploy] invalid main verification decision: ${result.error.issues.map((i) => i.message).join("; ")}. ` + `Expected schema 'werkstatt/main-verification-decision@1'. ` + `Use the file from gate-decisions/${releasePrefix}-main-verification.json (generated by leitstand.certify --gate main), ` + `not the gate-decision file.${available.length > 0 ? ` Available files: ${available.join(", ")}` : ""}`, ); } return result.data; } /** RFC-1126: where the artifact hash was resolved from — reported for provenance. */ export type ArtifactHashSource = "flag" | "artifact.tar.gz" | "release.yaml"; export interface ResolvedArtifactHash { hash: Sha256Digest; source: ArtifactHashSource; /** Absolute path of the file the hash was resolved from; undefined for --artifact-hash. */ path?: string; } export async function resolveArtifactHash( artifactHashFlag: string | undefined, releaseDir: string | undefined, io: WorkspaceIO = createDefaultIO().io, ): Promise { if (artifactHashFlag && isSha256Digest(artifactHashFlag)) { return { hash: artifactHashFlag, source: "flag" }; } if (releaseDir && (await io.exists(releaseDir))) { const artifactPath = path.join(releaseDir, "artifact.tar.gz"); if (await io.exists(artifactPath)) { return { hash: await byteHashFile(artifactPath), source: "artifact.tar.gz", path: artifactPath, }; } const releaseYamlPath = path.join(releaseDir, "release.yaml"); if (await io.exists(releaseYamlPath)) { try { const content = await io.readFile(releaseYamlPath); const match = content.match(/^distTreeHash:\s*(sha256:[a-f0-9]{64})\s*$/m); if (match && isSha256Digest(match[1])) { return { hash: match[1], source: "release.yaml", path: releaseYamlPath }; } } catch { // release.yaml unreadable — fall through to error } } } throw new Error( "[deploy] artifact hash not provided and no artifact.tar.gz or release.yaml with distTreeHash found in release directory — use --artifact-hash or --release-dir", ); } export async function authorizeAndDeploy(input: GateDecisionInput): Promise { const gateDecision = await loadGateDecision(input.gateDecisionPath); const authInput: DeploymentAuthorizationInputV1 = { candidateId: input.candidateId, gate: input.gate, gateDecision, durableSyncVerified: input.durableSyncVerified, artifactReadinessVerified: input.artifactReadinessVerified, artifactHash: input.artifactHash, forceRequested: input.forceRequested, skipRequested: input.skipRequested, waiverRequested: input.waiverRequested, graceRequested: input.graceRequested, }; const outcome = authorizeDeployment(authInput); if (!outcome.ok) { return { ok: false, outcome: outcome as DeploymentAuthorizationOutcomeV1 & { ok: false }, gateDecision, }; } return { ok: true, outcome: outcome as DeploymentAuthorizationOutcomeV1 & { ok: true }, gateDecision, }; } export async function verifyDurableSync( artifactHash: Sha256Digest, r2Config: R2StorageConfig, ): Promise { const adapter = createR2StorageAdapter(r2Config); const result = await verifyStoredObject(adapter, artifactHash); return result.ok && result.verified; } export async function authorizeMainPromotion( gateDecisionPath: string, mainVerificationPath: string, artifactHash: Sha256Digest, candidateId: string, durableSyncVerified: boolean, forceRequested: boolean, skipRequested: boolean, waiverRequested: boolean, graceRequested: boolean, ): Promise<{ authorization: AuthorizeOutcome; mainVerification: MainVerificationOutcomeV1; }> { const gateDecision = await loadGateDecision(gateDecisionPath); const mainVerificationDecision = await loadMainVerificationDecision(mainVerificationPath); const mainVerification = verifyMainPromotion({ candidateId, mainVerificationDecision, durableSyncVerified, artifactHash, priorOperationRef: null, }); if (!mainVerification.ok) { return { authorization: { ok: false, outcome: { ok: false, ruleId: mainVerification.ruleId, message: mainVerification.message, gate: "promote-main", }, gateDecision, }, mainVerification, }; } const authInput: DeploymentAuthorizationInputV1 = { candidateId, gate: "promote-main", gateDecision, durableSyncVerified, artifactReadinessVerified: true, artifactHash, forceRequested, skipRequested, waiverRequested, graceRequested, }; const outcome = authorizeDeployment(authInput); if (!outcome.ok) { return { authorization: { ok: false, outcome: outcome as DeploymentAuthorizationOutcomeV1 & { ok: false }, gateDecision, }, mainVerification, }; } return { authorization: { ok: true, outcome: outcome as DeploymentAuthorizationOutcomeV1 & { ok: true }, gateDecision, }, mainVerification, }; } export async function evaluateRollbackRequest( input: RollbackEvaluationInputV1, ): Promise { return evaluateRollback(input); } export function buildEffectRecord( operationId: string, candidateId: string, gate: CertificationGate, channel: "dev" | "alt" | "main", artifactHash: Sha256Digest, decisionId: string, durableSyncVerified: boolean, mainVerificationDecisionId: string | null, state: DeploymentOperationState, timestamp: string, workerVersionId?: string, releaseId?: string, ): DeploymentEffectRecordV1 { return buildDeploymentEffectRecord( operationId, candidateId, gate, channel, artifactHash, decisionId, durableSyncVerified, mainVerificationDecisionId, state, timestamp, workerVersionId, releaseId, ); } export async function writeDeploymentEffectRecord( cacheCloneDir: string, record: DeploymentEffectRecordV1, deploymentUrl?: string, workerVersionId?: string, releaseId?: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { const dir = path.join(cacheCloneDir, "deployment-operations"); await io.mkdir(dir); const filePath = path.join(dir, `${record.operationId}.json`); let payload: Record = { ...record }; if (deploymentUrl) payload = { ...payload, deploymentUrl }; if (workerVersionId) payload = { ...payload, workerVersionId }; if (releaseId) payload = { ...payload, releaseId }; await writeFileIfChanged(filePath, JSON.stringify(payload, null, 2) + "\n"); const relPath = path.join("deployment-operations", `${record.operationId}.json`); try { await gitExec(cacheCloneDir, `add ${relPath}`); await cacheCloneCommit( cacheCloneDir, `deployment-operations: ${record.operationId} (${record.state})`, ); const branch = (await gitExec(cacheCloneDir, "rev-parse --abbrev-ref HEAD")).trim(); await gitExec(cacheCloneDir, `push origin ${branch}`); } catch { // Best-effort commit+push — file is written, sync will propagate later } return filePath; } export function makeR2ConfigFromEnv( env: Record, ): R2StorageConfig | null { const accountId = env.R2_ACCOUNT_ID; const accessKeyId = env.R2_ACCESS_KEY_ID; const secretAccessKey = env.R2_SECRET_ACCESS_KEY; const bucketName = env.R2_BUCKET_NAME; const apiToken = env.R2_API_TOKEN ?? env.CLOUDFLARE_API_TOKEN; if (!accountId || !accessKeyId || !secretAccessKey || !bucketName) { return null; } return { accountId, accessKeyId, secretAccessKey, bucketName, apiToken }; } export function resolveGateDecisionPath( cacheCloneDir: string, releaseId: string, gate: "dev" | "alt" | "main", override?: string, ): string { if (override) return override; return path.join(cacheCloneDir, "gate-decisions", `${releaseId}-${gate}.json`); }