/*
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`);
}