import fs from "fs"; import path from "path"; import YAML from "yaml"; import { WorkflowState } from "./state"; import type { ParameterSchema } from "./releaseParameters"; export interface WorkflowManifest { id: string; description?: string; steps: WorkflowStep[]; } export interface WorkflowStep { id: string; kind: string; [key: string]: unknown; } const SUPPORTED_STEP_KINDS = new Set([ "deploy", "call", "upgrade", "view", "check", "custom", ]); export interface TaskParameters { run?: { targets?: string[]; }; params?: Record>; } export interface WorkflowTaskMetadata { deploymentSignerAddress?: string; releaseTargetNetworks?: string[]; [key: string]: unknown; } export type ExistingDeploymentPolicy = | { mode: "reuse" } | { mode: "redeploy"; salt: string; expectedAddress: string; reason: string; }; export type ExistingDeploymentPolicies = Record; export interface ResolveContext { targetId: string; target: Record; params: Record; state: WorkflowState; } export const readYamlFile = (filePath: string): T => { return YAML.parse(fs.readFileSync(filePath, "utf8")) as T; }; export const validateWorkflowStepIds = (steps: WorkflowStep[]): void => { const seen = new Set(); for (const [index, step] of steps.entries()) { const stepId = typeof step.id === "string" ? step.id.trim() : ""; if (!stepId) { throw new Error(`Workflow step at index ${index} requires id`); } if (step.id !== stepId) { throw new Error(`Workflow step id "${step.id}" must not contain surrounding whitespace`); } if (seen.has(stepId)) { throw new Error(`Duplicate workflow step id "${stepId}"`); } seen.add(stepId); } }; export const validateDeploySaveAs = (steps: WorkflowStep[]): void => { const seen = new Set(); for (const step of steps) { if (step.kind !== "deploy") continue; const saveAs = typeof step.saveAs === "string" ? step.saveAs.trim() : ""; if (!saveAs) { throw new Error(`Deploy step ${step.id} requires saveAs`); } if (saveAs.includes(":")) { throw new Error(`Deploy step ${step.id} saveAs "${saveAs}" must not contain ":"`); } if (seen.has(saveAs)) { throw new Error(`Duplicate deploy saveAs "${saveAs}"`); } seen.add(saveAs); } }; export const validateWorkflowStepKinds = (steps: WorkflowStep[]): void => { for (const step of steps) { if (!SUPPORTED_STEP_KINDS.has(step.kind)) { throw new Error(`Unsupported workflow step kind "${step.kind}" in step ${step.id}`); } } }; const asObject = (value: unknown): Record => { if (!value || typeof value !== "object" || Array.isArray(value)) { return {}; } return value as Record; }; const assertPolicyFields = ( instanceId: string, policy: Record, allowedFields: string[], ): void => { const allowed = new Set(allowedFields); const unsupported = Object.keys(policy).filter((field) => !allowed.has(field)); if (unsupported.length > 0) { throw new Error( "existingDeploymentPolicies." + instanceId + " has unsupported field(s): " + unsupported.join(", "), ); } }; export const validateExistingDeploymentPolicies = ( value: unknown, ): ExistingDeploymentPolicies => { if (value === undefined) return {}; if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("existingDeploymentPolicies must be an object keyed by deploy saveAs"); } const policies: ExistingDeploymentPolicies = {}; for (const [instanceId, policyValue] of Object.entries(value)) { if (!instanceId.trim() || instanceId !== instanceId.trim() || instanceId.includes(":")) { throw new Error("existingDeploymentPolicies key must be a deploy saveAs without colons"); } const policy = asObject(policyValue); if (policy.mode === "reuse") { assertPolicyFields(instanceId, policy, ["mode"]); policies[instanceId] = { mode: "reuse" }; continue; } if (policy.mode !== "redeploy") { throw new Error( "existingDeploymentPolicies." + instanceId + " mode must be reuse or redeploy", ); } assertPolicyFields(instanceId, policy, ["mode", "salt", "expectedAddress", "reason"]); if (typeof policy.salt !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(policy.salt)) { throw new Error( "existingDeploymentPolicies." + instanceId + " redeploy salt must be bytes32", ); } if ( typeof policy.expectedAddress !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(policy.expectedAddress) ) { throw new Error( "existingDeploymentPolicies." + instanceId + " redeploy expectedAddress must be an EVM address", ); } if (typeof policy.reason !== "string" || !policy.reason.trim()) { throw new Error( "existingDeploymentPolicies." + instanceId + " redeploy reason must be non-empty", ); } policies[instanceId] = { mode: "redeploy", salt: policy.salt, expectedAddress: policy.expectedAddress, reason: policy.reason.trim(), }; } return policies; }; export const assertExistingDeploymentPoliciesMatchTarget = ( policies: ExistingDeploymentPolicies, targetId: string, existingInstanceIds: string[], ): void => { const existing = new Set(existingInstanceIds); for (const instanceId of Object.keys(policies)) { if (!existing.has(instanceId)) { throw new Error( `Unknown deployment policy instance for target ${targetId}: ${instanceId}`, ); } } }; export const requireExistingDeploymentPolicy = ( policies: ExistingDeploymentPolicies, instanceId: string, ): ExistingDeploymentPolicy => { const policy = policies[instanceId]; if (!policy) { throw new Error( "Existing deployment " + instanceId + " requires an explicit reuse or redeploy policy", ); } return policy; }; export const resolveExistingDeploymentPolicyAction = ( policy: ExistingDeploymentPolicy, context: { instanceId: string; existingAddress: string; currentSalt: string; upgradeable: boolean; }, ): ExistingDeploymentPolicy => { if (policy.mode === "reuse") return policy; if (context.upgradeable) { throw new Error( "cannot redeploy UUPS proxy " + context.instanceId + "; use an upgrade step", ); } if (policy.salt.toLowerCase() === context.currentSalt.toLowerCase()) { throw new Error("redeploy " + context.instanceId + " requires a new CREATE2 salt"); } if (policy.expectedAddress.toLowerCase() === context.existingAddress.toLowerCase()) { throw new Error( "redeploy " + context.instanceId + " expectedAddress must differ from the existing address", ); } return policy; }; const requireStringField = ( source: Record, key: string, message: string, ): string => { const value = source[key]; if (typeof value !== "string" || !value.trim()) { throw new Error(message); } return value.trim(); }; export const validateDeployCreate2Strategy = (steps: WorkflowStep[]): void => { for (const step of steps) { if (step.kind !== "deploy") continue; if (step.strategy !== "create2") { throw new Error(`Deploy step ${step.id} requires strategy: create2`); } const create2 = asObject(step.create2); requireStringField(create2, "salt", `Deploy step ${step.id} requires create2.salt`); if ("implSalt" in create2) { throw new Error( `Deploy step ${step.id} does not support create2.implSalt; UUPS placeholder CREATE2 options are managed by the workflow runtime`, ); } if (step.proxy !== undefined) { if (step.upgradeableStrategy !== "uups-placeholder-proxy") { throw new Error(`Deploy step ${step.id} requires upgradeableStrategy: uups-placeholder-proxy`); } if (String(step.contract) !== "contracts/workflow/UUPSPlaceholder.sol:UUPSPlaceholder") { throw new Error( `Deploy step ${step.id} must create UUPS proxies with contracts/workflow/UUPSPlaceholder.sol:UUPSPlaceholder`, ); } if (!asObject(step.firstUpgrade).contract) { throw new Error(`Deploy step ${step.id} requires firstUpgrade.contract`); } } } }; export const loadManifest = (taskDir: string): WorkflowManifest => { const manifest = readYamlFile(path.join(taskDir, "manifest.yaml")); if (!manifest.steps || !Array.isArray(manifest.steps)) { throw new Error("manifest.yaml must define a steps array"); } validateWorkflowStepIds(manifest.steps); validateWorkflowStepKinds(manifest.steps); validateDeploySaveAs(manifest.steps); validateDeployCreate2Strategy(manifest.steps); return manifest; }; export const loadParameters = (taskDir: string): TaskParameters => { return readYamlFile(path.join(taskDir, "parameters.yaml")); }; const isNonZeroEvmAddress = (value: string): boolean => { return /^0x[0-9a-fA-F]{40}$/.test(value) && value.toLowerCase() !== "0x0000000000000000000000000000000000000000"; }; const SAFE_RELEASE_TARGET_NETWORK = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; const UNSAFE_RELEASE_TARGET_NETWORKS = new Set(["__proto__", "prototype", "constructor"]); const validateReleaseTargetNetworks = (value: unknown): string[] | undefined => { if (value === undefined) return undefined; if (!Array.isArray(value)) { throw new Error("task.yaml releaseTargetNetworks must be an array of safe network ids"); } const networks = value.map((network, index) => { if ( typeof network !== "string" || !network || network !== network.trim() || !SAFE_RELEASE_TARGET_NETWORK.test(network) || UNSAFE_RELEASE_TARGET_NETWORKS.has(network) ) { throw new Error( `task.yaml releaseTargetNetworks[${index}] must be a safe non-empty network id`, ); } return network; }); if (new Set(networks).size !== networks.length) { throw new Error("task.yaml releaseTargetNetworks must not contain duplicates"); } return networks; }; export const loadTaskMetadata = (taskDir: string): WorkflowTaskMetadata => { const metadata = readYamlFile(path.join(taskDir, "task.yaml")); const signer = metadata.deploymentSignerAddress; if (signer !== undefined && (typeof signer !== "string" || !isNonZeroEvmAddress(signer.trim()))) { throw new Error( "task.yaml deploymentSignerAddress must be a valid non-zero EVM address", ); } const releaseTargetNetworks = validateReleaseTargetNetworks(metadata.releaseTargetNetworks); return { ...metadata, ...(signer === undefined ? {} : { deploymentSignerAddress: signer.trim() }), ...(releaseTargetNetworks === undefined ? {} : { releaseTargetNetworks }), }; }; export const loadParameterSchema = (taskDir: string): ParameterSchema => { const filePath = path.join(taskDir, "parameters.schema.json"); if (!fs.existsSync(filePath)) { throw new Error("Missing parameters.schema.json for release execution"); } let schema: unknown; try { schema = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch (error) { throw new Error( "parameters.schema.json must contain valid JSON: " + (error instanceof Error ? error.message : String(error)), ); } if (!schema || typeof schema !== "object" || Array.isArray(schema)) { throw new Error("parameters.schema.json must be an object"); } return schema as ParameterSchema; }; export const targetParams = ( parameters: TaskParameters, targetId: string, ): Record => { const allParams = parameters.params || {}; const globalParams = allParams.global || {}; const networkParams = allParams[targetId]; if (!networkParams) { throw new Error(`Missing parameters for target "${targetId}"`); } return { ...networkParams, global: globalParams, }; }; const readPath = (source: Record, pathExpression: string): unknown => { const parts = pathExpression.split("."); let current: unknown = source; for (const part of parts) { if (typeof current !== "object" || current === null || !(part in current)) { throw new Error(`Reference not found: ${pathExpression}`); } current = (current as Record)[part]; } return current; }; export const resolveValue = (value: unknown, context: ResolveContext): unknown => { if (typeof value === "string") { if (value.startsWith("$param.")) { return readPath(context.params, value.slice("$param.".length)); } if (value.startsWith("$target.")) { return readPath(context.target, value.slice("$target.".length)); } return value; } if (Array.isArray(value)) { return value.map((item) => resolveValue(item, context)); } if (typeof value === "object" && value !== null) { const objectValue = value as Record; if (typeof objectValue.$ref === "string") { const ref = objectValue.$ref; if (ref.startsWith("state.")) { return context.state.get(ref.slice("state.".length)); } if (ref.startsWith("target.")) { return readPath(context.target, ref.slice("target.".length)); } if (ref.startsWith("param.")) { return readPath(context.params, ref.slice("param.".length)); } throw new Error(`Unsupported reference: ${ref}`); } return Object.fromEntries( Object.entries(objectValue).map(([key, nestedValue]) => [ key, resolveValue(nestedValue, context), ]), ); } return value; };