#!/usr/bin/env node import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { ensureTag, loadRelease, publishRelease, resolveGiteaApiUrl, } from "./release.mjs"; const SUCCESS = new Set(["DEPLOYED"]); const FAILURE = new Set(["ERROR", "SHUTDOWN"]); const APP_FIELDS = [ "id", "name", "appType", "projectId", "sourceType", "buildMethod", "containerImageSource", "containerRegistryUsername", "containerRegistryPassword", "containerCommand", "containerArgs", "securityContextRunAsUser", "securityContextRunAsGroup", "securityContextFsGroup", "securityContextPrivileged", "gitUrl", "gitBranch", "gitUsername", "gitToken", "dockerfilePath", "replicas", "envVars", "memoryReservation", "memoryLimit", "cpuReservation", "cpuLimit", "webhookId", "ingressNetworkPolicy", "egressNetworkPolicy", "useNetworkPolicy", "healthChechHttpGetPath", "healthCheckHttpScheme", "healthCheckHttpHeadersJson", "healthCheckHttpPort", "healthCheckPeriodSeconds", "healthCheckTimeoutSeconds", "healthCheckFailureThreshold", "healthCheckTcpPort", ]; const COLLECTION_FIELDS = { appDomains: ["id", "hostname", "port", "useSsl", "redirectHttps"], appPorts: ["id", "port"], appNodePorts: ["id", "port", "nodePort", "protocol"], appFileMounts: ["id", "containerMountPath", "content"], appVolumes: [ "id", "containerMountPath", "size", "accessMode", "storageClassName", "shareWithOtherApps", "sharedVolumeId", ], appBasicAuths: ["id", "username", "password"], }; function requiredString(value, name) { const normalized = String(value ?? "").trim(); if (!normalized) throw new Error(`${name} is required.`); return normalized; } function pick(source, fields) { return Object.fromEntries( fields.filter((field) => source[field] !== undefined).map((field) => [field, source[field]]), ); } export function toSavePayload(app) { const payload = pick(app, APP_FIELDS); for (const [name, fields] of Object.entries(COLLECTION_FIELDS)) { payload[name] = Array.isArray(app[name]) ? app[name].map((item) => pick(item, fields)) : []; } return payload; } export function mergeEnvironment(source, overrides = {}) { const rows = []; const positions = new Map(); for (const line of String(source ?? "").split(/\r?\n/)) { const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); if (!match) { if (line.trim()) rows.push({ raw: line }); continue; } positions.set(match[1], rows.length); rows.push({ key: match[1], value: match[2] }); } for (const [key, rawValue] of Object.entries(overrides)) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`Invalid environment key ${key}.`); const row = { key, value: String(rawValue) }; const position = positions.get(key); if (position === undefined) { positions.set(key, rows.length); rows.push(row); } else { rows[position] = row; } } return rows.map((row) => row.raw ?? `${row.key}=${row.value}`).join("\n"); } async function readBody(response) { const text = await response.text(); if (!text) return null; try { return JSON.parse(text); } catch { return { detail: text }; } } export function createQuickStackClient({ baseUrl, token, fetchImpl = fetch }) { const base = requiredString(baseUrl, "QuickStack base URL").replace(/\/$/, ""); const bearer = requiredString(token, "QuickStack API token"); async function request(method, pathname, body) { const response = await fetchImpl(`${base}${pathname}`, { method, headers: { Accept: "application/json", Authorization: `Bearer ${bearer}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }), }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); const parsed = await readBody(response); if (!response.ok) { throw Object.assign( new Error(`${method} ${pathname} failed with HTTP ${response.status}: ${parsed?.detail ?? response.statusText}`), { statusCode: response.status }, ); } return parsed; } return { getApp: (id) => request("GET", `/api/v1/apps/${encodeURIComponent(id)}`), saveApp: (payload) => request("POST", "/api/v1/apps", payload), deployApp: (id) => request("POST", `/api/v1/apps/${encodeURIComponent(id)}/deploy`), getDeployment: (appId, deploymentId) => request( "GET", `/api/v1/apps/${encodeURIComponent(appId)}/deploy/${encodeURIComponent(deploymentId)}`, ), }; } async function waitForDeployment(client, appId, deploymentId, sleep) { for (let attempt = 1; attempt <= 180; attempt += 1) { let deployment; try { deployment = await client.getDeployment(appId, deploymentId); } catch (error) { if (error?.statusCode === 404) { console.log(`QuickStack deployment ${deploymentId}: NOT_VISIBLE_YET (${attempt}/180)`); await sleep(5_000); continue; } throw error; } const status = String(deployment?.status ?? "UNKNOWN").toUpperCase(); console.log(`QuickStack deployment ${deploymentId}: ${status} (${attempt}/180)`); if (SUCCESS.has(status)) return deployment; if (FAILURE.has(status)) throw new Error(`QuickStack deployment ${deploymentId} ended with ${status}.`); await sleep(5_000); } throw new Error(`QuickStack deployment ${deploymentId} timed out.`); } async function trigger(client, appId, sleep) { const started = await client.deployApp(appId); const deploymentId = requiredString(started?.deploymentId, "QuickStack deployment ID"); return { deploymentId, deployment: await waitForDeployment(client, appId, deploymentId, sleep) }; } async function triggerWithRetries({ client, appId, sleep, attempts = 1, retryDelayMs = 15_000, label = "deployment", restoreConfig, }) { let latestError; for (let attempt = 1; attempt <= attempts; attempt += 1) { try { return await trigger(client, appId, sleep); } catch (error) { latestError = error; if (attempt >= attempts) throw error; console.log( `QuickStack ${label} attempt ${attempt}/${attempts} failed; retrying in ${retryDelayMs / 1000}s: ${error instanceof Error ? error.message : error}`, ); await sleep(retryDelayMs); await restoreConfig(); } } throw latestError; } export async function verifyEndpoint(postflight, { sha, fetchImpl = fetch, sleep }) { if (!postflight) return null; const url = requiredString(postflight.url, "Postflight URL"); const attempts = Number(postflight.attempts ?? 60); const intervalMs = Number(postflight.intervalSeconds ?? 10) * 1000; const statusMax = Number(postflight.statusMax ?? 399); const expectedHeader = String(postflight.expectedHeader ?? "").trim(); const expectedValue = String(postflight.expectedValue ?? "") .replaceAll("$sha12", sha.slice(0, 12)) .replaceAll("$sha", sha); if (expectedValue && !expectedHeader) throw new Error("Postflight expectedValue requires expectedHeader."); let latest = "no response"; for (let attempt = 1; attempt <= attempts; attempt += 1) { try { const response = await fetchImpl(url, { redirect: "follow", signal: AbortSignal.timeout(20_000), }); const actual = expectedHeader ? String(response.headers.get(expectedHeader) ?? "").trim() : ""; latest = `HTTP ${response.status}${expectedHeader ? ` ${expectedHeader}=${actual || ""}` : ""}`; console.log(`Postflight ${attempt}/${attempts}: ${latest}`); if (response.status >= 200 && response.status <= statusMax && (!expectedValue || actual === expectedValue)) { return { status: response.status, headerValue: actual || null }; } } catch (error) { latest = error instanceof Error ? error.message : String(error); console.log(`Postflight ${attempt}/${attempts}: ${latest}`); } if (attempt < attempts) await sleep(intervalMs); } throw new Error(`Postflight failed for ${url} (${latest}).`); } export async function deployExactImage({ client, appId, image, registryUsername, registryToken, environment = {}, healthCheckTcpPort, postflight, sha, deploymentAttempts = 1, deploymentRetrySeconds = 15, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), fetchImpl = fetch, }) { if (!/@sha256:[0-9a-f]{64}$/.test(image)) throw new Error("Deployment image must use an exact sha256 digest."); const attempts = Number(deploymentAttempts); const retryDelaySeconds = Number(deploymentRetrySeconds); if (!Number.isInteger(attempts) || attempts < 1) throw new Error("Deployment attempts must be a positive integer."); if (!Number.isFinite(retryDelaySeconds) || retryDelaySeconds < 0) { throw new Error("Deployment retry seconds must be a non-negative number."); } const current = await client.getApp(appId); const previous = toSavePayload(current); const next = { ...previous, sourceType: "CONTAINER", containerImageSource: image, containerRegistryUsername: requiredString(registryUsername, "Registry username"), containerRegistryPassword: requiredString(registryToken, "Registry token"), envVars: mergeEnvironment(previous.envVars, environment), ...(healthCheckTcpPort === undefined ? {} : { healthCheckTcpPort: Number(healthCheckTcpPort) }), }; await client.saveApp(next); try { const result = await triggerWithRetries({ client, appId, sleep, attempts, retryDelayMs: retryDelaySeconds * 1000, label: "deployment", restoreConfig: () => client.saveApp(next), }); const persisted = await client.getApp(appId); if (persisted.sourceType !== "CONTAINER" || persisted.containerImageSource !== image) { throw new Error("QuickStack did not persist the exact image digest."); } await verifyEndpoint(postflight, { sha, fetchImpl, sleep }); return { appId, deploymentId: result.deploymentId, image, status: result.deployment.status }; } catch (error) { await client.saveApp(previous); try { const rollback = await triggerWithRetries({ client, appId, sleep, attempts, retryDelayMs: retryDelaySeconds * 1000, label: "rollback", restoreConfig: () => client.saveApp(previous), }); throw new Error( `Deployment failed and was rolled back with ${rollback.deploymentId}: ${error instanceof Error ? error.message : error}`, ); } catch (rollbackError) { if (rollbackError instanceof Error && rollbackError.message.startsWith("Deployment failed and was rolled back")) { throw rollbackError; } throw new AggregateError([error, rollbackError], `Deployment and rollback failed for ${appId}.`); } } } function run(command, args, options = {}) { const result = spawnSync(command, args, { cwd: options.cwd, env: options.env, input: options.input, encoding: "utf8", maxBuffer: 100 * 1024 * 1024, stdio: options.capture ? ["pipe", "pipe", "pipe"] : ["pipe", "inherit", "inherit"], }); if (result.error) throw result.error; if (result.status !== 0) { if (options.capture) { process.stdout.write(result.stdout ?? ""); process.stderr.write(result.stderr ?? ""); } throw new Error(`${command} ${args.join(" ")} exited with ${result.status}.`); } return `${result.stdout ?? ""}${result.stderr ?? ""}`; } function safeRelative(value, name) { const normalized = requiredString(value, name).replace(/^\.\//, ""); if (path.isAbsolute(normalized) || normalized.split(/[\\/]/).includes("..")) { throw new Error(`${name} must stay inside the repository.`); } return normalized || "."; } export function selectDeployment(config, branch) { if (config.version !== 1 || !Array.isArray(config.deployments)) { throw new Error("Deployment manifest must use version 1 and contain deployments[]."); } return config.deployments.find((entry) => entry.branch === branch) ?? null; } export function validateTarget(target) { const name = requiredString(target.name, "Target name"); const image = requiredString(target.image, `Image for ${name}`).toLowerCase(); if (!/^[a-z0-9][a-z0-9._/-]*$/.test(image)) throw new Error(`Invalid OCI image path ${image}.`); const appId = requiredString(target.appId, `QuickStack app ID for ${name}`); const deploymentAttempts = target.deploymentAttempts === undefined ? undefined : Number(target.deploymentAttempts); const deploymentRetrySeconds = target.deploymentRetrySeconds === undefined ? undefined : Number(target.deploymentRetrySeconds); if (deploymentAttempts !== undefined && (!Number.isInteger(deploymentAttempts) || deploymentAttempts < 1)) { throw new Error(`Deployment attempts for ${name} must be a positive integer.`); } if (deploymentRetrySeconds !== undefined && (!Number.isFinite(deploymentRetrySeconds) || deploymentRetrySeconds < 0)) { throw new Error(`Deployment retry seconds for ${name} must be a non-negative number.`); } return { ...target, name, image, appId, ...(deploymentAttempts === undefined ? {} : { deploymentAttempts }), ...(deploymentRetrySeconds === undefined ? {} : { deploymentRetrySeconds }), dockerfile: safeRelative(target.dockerfile ?? "Dockerfile", `Dockerfile for ${name}`), context: safeRelative(target.context ?? ".", `Build context for ${name}`), }; } export function expandTokens(value, context) { return String(value) .replaceAll("$sha12", context.sha.slice(0, 12)) .replaceAll("$sha", context.sha) .replaceAll("$version", context.version ?? ""); } function validateImagePath(value, name) { const image = requiredString(value, name).toLowerCase(); if (!/^[a-z0-9][a-z0-9._/-]*$/.test(image)) throw new Error(`Invalid OCI image path ${image}.`); return image; } export function validateArtifact(artifact) { const name = requiredString(artifact.name, "Artifact name"); const requiredFiles = (artifact.requiredFiles ?? []).map((file) => { const normalized = requiredString(file, `Required container file for ${name}`); if (!/^\/[A-Za-z0-9._/-]+$/.test(normalized) || normalized.includes("..")) { throw new Error(`Required container file for ${name} must be a safe absolute path.`); } return normalized; }); const buildArgs = Object.fromEntries( Object.entries(artifact.buildArgs ?? {}).map(([key, value]) => { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`Invalid build argument ${key}.`); return [key, String(value)]; }), ); return { ...artifact, name, image: validateImagePath(artifact.image, `Image for ${name}`), dockerfile: safeRelative(artifact.dockerfile ?? "Dockerfile", `Dockerfile for ${name}`), context: safeRelative(artifact.context ?? ".", `Build context for ${name}`), buildArgs, requiredFiles, }; } export function validateApplication(application, artifactNames) { const name = requiredString(application.name, "Application name"); const artifact = requiredString(application.artifact, `Artifact for ${name}`); if (!artifactNames.has(artifact)) throw new Error(`Application ${name} references unknown artifact ${artifact}.`); const dependsOn = (application.dependsOn ?? []).map((dependency) => requiredString(dependency, `Dependency for ${name}`)); const deploymentAttempts = application.deploymentAttempts === undefined ? undefined : Number(application.deploymentAttempts); const deploymentRetrySeconds = application.deploymentRetrySeconds === undefined ? undefined : Number(application.deploymentRetrySeconds); if (deploymentAttempts !== undefined && (!Number.isInteger(deploymentAttempts) || deploymentAttempts < 1)) { throw new Error(`Deployment attempts for ${name} must be a positive integer.`); } if (deploymentRetrySeconds !== undefined && (!Number.isFinite(deploymentRetrySeconds) || deploymentRetrySeconds < 0)) { throw new Error(`Deployment retry seconds for ${name} must be a non-negative number.`); } return { ...application, name, artifact, appId: requiredString(application.appId, `QuickStack app ID for ${name}`), dependsOn, ...(deploymentAttempts === undefined ? {} : { deploymentAttempts }), ...(deploymentRetrySeconds === undefined ? {} : { deploymentRetrySeconds }), }; } export function orderApplications(applications) { const byName = new Map(applications.map((application) => [application.name, application])); if (byName.size !== applications.length) throw new Error("Application names must be unique."); for (const application of applications) { for (const dependency of application.dependsOn) { if (!byName.has(dependency)) throw new Error(`Application ${application.name} depends on unknown application ${dependency}.`); if (dependency === application.name) throw new Error(`Application ${application.name} cannot depend on itself.`); } } const ordered = []; const complete = new Set(); while (ordered.length < applications.length) { const ready = applications.find( (application) => !complete.has(application.name) && application.dependsOn.every((dependency) => complete.has(dependency)), ); if (!ready) throw new Error("Application dependencies contain a cycle."); ordered.push(ready); complete.add(ready.name); } return ordered; } export function selectPipeline(config, branch) { if (config.version !== 2 || !Array.isArray(config.artifacts) || !Array.isArray(config.pipelines)) { throw new Error("Deployment manifest version 2 must contain artifacts[] and pipelines[]."); } return config.pipelines.find((entry) => entry.branch === branch) ?? null; } export function validateVersion2Pipeline(config, branch) { const pipeline = selectPipeline(config, branch); if (!pipeline) return null; const strategy = requiredString(pipeline.strategy, `Strategy for ${branch}`); if (!new Set(["candidate", "promote"]).has(strategy)) { throw new Error(`Unsupported strategy ${strategy} for ${branch}.`); } const artifacts = config.artifacts.map(validateArtifact); const artifactNames = new Set(artifacts.map((artifact) => artifact.name)); if (artifactNames.size !== artifacts.length) throw new Error("Artifact names must be unique."); const applications = orderApplications( (pipeline.applications ?? []).map((application) => validateApplication(application, artifactNames)), ); if (!applications.length && strategy !== "candidate") { throw new Error(`Pipeline ${pipeline.name ?? branch} has no applications.`); } if (strategy === "promote" && !pipeline.release) { throw new Error(`Promotion pipeline ${pipeline.name ?? branch} requires release configuration.`); } return { ...pipeline, strategy, artifacts, applications }; } export function resolveDeploymentBranch({ eventName, environment = process.env, eventPayload } = {}) { if (eventName !== "pull_request") { return requiredString(environment.GITHUB_REF_NAME || environment.GITEA_REF_NAME, "Git branch"); } const direct = String(environment.GITHUB_BASE_REF || environment.GITEA_BASE_REF || "").trim(); if (direct) return direct; let payload = eventPayload; if (payload === undefined) { const eventPath = String(environment.GITHUB_EVENT_PATH || environment.GITEA_EVENT_PATH || "").trim(); if (eventPath) { payload = JSON.parse(fs.readFileSync(eventPath, "utf8")); } } return requiredString( payload?.pull_request?.base?.ref || payload?.pull_request?.base?.repo?.default_branch, "Pull request base branch", ); } export function resolvePullRequestHeadBranch({ environment = process.env, eventPayload } = {}) { const direct = String(environment.GITHUB_HEAD_REF || environment.GITEA_HEAD_REF || "").trim(); if (direct) return direct; let payload = eventPayload; if (payload === undefined) { const eventPath = String(environment.GITHUB_EVENT_PATH || environment.GITEA_EVENT_PATH || "").trim(); if (eventPath) { payload = JSON.parse(fs.readFileSync(eventPath, "utf8")); } } return requiredString(payload?.pull_request?.head?.ref, "Pull request head branch"); } export function classifyVersion2Execution(pipeline, eventName) { if (eventName === "pull_request") { return pipeline.strategy === "promote" ? "validate-candidate" : "build-validation"; } return pipeline.strategy === "candidate" ? "build-deploy" : "promote"; } export function validatePromotionPullRequestSource(pipeline, headBranch) { const sourceBranch = requiredString(pipeline.source?.branch, "Promotion source branch"); const actualHeadBranch = requiredString(headBranch, "Pull request head branch"); if (actualHeadBranch !== sourceBranch) { throw new Error(`Production promotion pull requests must originate from ${sourceBranch}, not ${actualHeadBranch}.`); } return sourceBranch; } function appendSummary(text) { const summary = process.env.GITHUB_STEP_SUMMARY ?? process.env.GITEA_STEP_SUMMARY; if (summary) fs.appendFileSync(summary, `${text}\n`); } function dockerLogin(registry, dockerEnv) { run( "docker", ["login", registry, "--username", requiredString(process.env.REGISTRY_USERNAME, "Registry username"), "--password-stdin"], { env: dockerEnv, input: requiredString(process.env.REGISTRY_TOKEN, "Registry token") }, ); } function exactDigestFromPush(pushOutput, taggedImage) { const digest = [...pushOutput.matchAll(/digest:\s*(sha256:[0-9a-f]{64})/g)].at(-1)?.[1]; if (!digest) throw new Error(`Registry did not return a digest for ${taggedImage}.`); return digest; } function verifyRequiredContainerFiles(taggedImage, requiredFiles, options) { for (const filename of requiredFiles) { run("docker", ["run", "--rm", "--entrypoint", "test", taggedImage, "-s", filename], options); } } function buildArtifact({ artifact, registry, sha, workspace, dockerEnv, validationOnly }) { const taggedImage = `${registry}/${artifact.image}:sha-${sha}`; const buildArgs = [ "build", "--progress=plain", "--file", artifact.dockerfile, "--label", `org.opencontainers.image.source=${process.env.GITHUB_SERVER_URL ?? process.env.GITEA_SERVER_URL}/${process.env.GITHUB_REPOSITORY ?? process.env.GITEA_REPOSITORY}`, "--label", `org.opencontainers.image.revision=${sha}`, "--tag", taggedImage, ]; for (const [key, value] of Object.entries(artifact.buildArgs)) { buildArgs.push("--build-arg", `${key}=${expandTokens(value, { sha })}`); } buildArgs.push(artifact.context); console.log(`Building ${artifact.name} from ${artifact.dockerfile} as ${taggedImage}.`); run("docker", buildArgs, { cwd: workspace, env: dockerEnv }); verifyRequiredContainerFiles(taggedImage, artifact.requiredFiles, { cwd: workspace, env: dockerEnv }); if (validationOnly) return { artifact, taggedImage, exactImage: null, digest: null }; const pushOutput = run("docker", ["push", taggedImage], { cwd: workspace, env: dockerEnv, capture: true }); process.stdout.write(pushOutput); const digest = exactDigestFromPush(pushOutput, taggedImage); return { artifact, taggedImage, digest, exactImage: `${registry}/${artifact.image}@${digest}` }; } function pullCandidateArtifact({ artifact, registry, sourceSha, workspace, dockerEnv }) { const taggedImage = `${registry}/${artifact.image}:sha-${sourceSha}`; run("docker", ["pull", taggedImage], { cwd: workspace, env: dockerEnv }); verifyRequiredContainerFiles(taggedImage, artifact.requiredFiles, { cwd: workspace, env: dockerEnv }); const repoDigests = run( "docker", ["image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", taggedImage], { cwd: workspace, env: dockerEnv, capture: true }, ).split(/\r?\n/).map((value) => value.trim()).filter(Boolean); const prefix = `${registry}/${artifact.image}@`; const exactImage = repoDigests.find((value) => value.startsWith(prefix)); if (!exactImage || !/@sha256:[0-9a-f]{64}$/.test(exactImage)) { throw new Error(`Could not resolve an immutable candidate digest for ${taggedImage}.`); } return { artifact, taggedImage, exactImage, digest: exactImage.slice(exactImage.lastIndexOf("@") + 1) }; } function resolvePromotionSource(workspace, source = {}) { const mergeParent = Number(source.mergeParent ?? 2); if (!Number.isInteger(mergeParent) || mergeParent < 1) { throw new Error("Promotion mergeParent must be a positive integer."); } let sourceSha; try { sourceSha = run("git", ["rev-parse", `HEAD^${mergeParent}`], { cwd: workspace, capture: true }).trim(); } catch { throw new Error(`Promotion requires a merge commit with parent ${mergeParent}.`); } if (source.requireTreeMatch !== false) { const releaseTree = run("git", ["rev-parse", "HEAD^{tree}"], { cwd: workspace, capture: true }).trim(); const candidateTree = run("git", ["rev-parse", `${sourceSha}^{tree}`], { cwd: workspace, capture: true }).trim(); if (releaseTree !== candidateTree) { throw new Error("The production merge tree differs from its tested candidate parent."); } } return sourceSha; } function promoteArtifactAliases(artifacts, releaseTag, options) { for (const artifact of artifacts.values()) { const releaseImage = `${artifact.exactImage.slice(0, artifact.exactImage.lastIndexOf("@"))}:${releaseTag}`; run("docker", ["tag", artifact.taggedImage, releaseImage], options); const pushOutput = run("docker", ["push", releaseImage], { ...options, capture: true }); process.stdout.write(pushOutput); const releaseDigest = exactDigestFromPush(pushOutput, releaseImage); if (releaseDigest !== artifact.digest) { throw new Error(`Release alias ${releaseImage} does not match the verified candidate digest.`); } console.log(`Promoted ${artifact.exactImage} as ${releaseImage}.`); } } async function deployApplications({ applications, artifacts, sha, client }) { for (const application of applications) { const artifact = artifacts.get(application.artifact); const environment = Object.fromEntries( Object.entries(application.environment ?? {}).map(([key, value]) => [key, expandTokens(value, { sha })]), ); const result = await deployExactImage({ client, appId: application.appId, image: artifact.exactImage, registryUsername: process.env.REGISTRY_USERNAME, registryToken: process.env.REGISTRY_TOKEN, environment, healthCheckTcpPort: application.healthCheckTcpPort, postflight: application.postflight, deploymentAttempts: application.deploymentAttempts, deploymentRetrySeconds: application.deploymentRetrySeconds, sha, }); console.log(`Deployed ${application.name}: ${result.image}`); appendSummary(`- ${application.name}: \`${result.image}\` (${result.status})`); } } async function executeVersion2({ config, pipeline, eventName, sha, workspace }) { const commitMessage = run("git", ["log", "-1", "--pretty=%B"], { cwd: workspace, capture: true }); const validationOnly = eventName === "pull_request"; const skipMarker = pipeline.strategy === "promote" ? String(pipeline.release?.skipMarker ?? "[skip prod-release]") : "[skip quickstack-deploy]"; if (!validationOnly && eventName === "push" && commitMessage.includes(skipMarker)) { console.log(`Deployment intentionally skipped by commit marker ${skipMarker}.`); return; } const registry = requiredString(config.registry ?? "gitea.nuvisphere.de", "OCI registry").replace(/\/$/, ""); const dockerConfig = fs.mkdtempSync(path.join(os.tmpdir(), "quickstack-docker-")); const dockerEnv = { ...process.env, DOCKER_CONFIG: dockerConfig }; const execution = classifyVersion2Execution(pipeline, eventName); try { if (execution !== "build-validation") dockerLogin(registry, dockerEnv); if (execution === "build-validation" || execution === "build-deploy") { const artifacts = new Map(); for (const artifact of pipeline.artifacts) { artifacts.set(artifact.name, buildArtifact({ artifact, registry, sha, workspace, dockerEnv, validationOnly: execution === "build-validation", })); } if (execution === "build-validation") { appendSummary(`Validated ${artifacts.size} immutable OCI artifact(s) for ${pipeline.branch}.`); return; } const client = createQuickStackClient({ baseUrl: process.env.QUICKSTACK_BASE_URL, token: process.env.QUICKSTACK_API_TOKEN, }); await deployApplications({ applications: pipeline.applications, artifacts, sha, client }); return; } if (execution === "validate-candidate") { const sourceBranch = validatePromotionPullRequestSource(pipeline, resolvePullRequestHeadBranch()); const release = loadRelease(workspace, pipeline.release); const artifacts = new Map(); for (const artifact of pipeline.artifacts) { artifacts.set(artifact.name, pullCandidateArtifact({ artifact, registry, sourceSha: sha, workspace, dockerEnv, })); } console.log(`Validated ${release.tag} against ${artifacts.size} tested candidate artifact(s) from ${sourceBranch} at ${sha}; no rebuild or deployment performed.`); appendSummary(`Validated release ${release.tag} against tested candidate \`${sha}\` from \`${sourceBranch}\` without rebuilding.`); return; } const sourceSha = resolvePromotionSource(workspace, pipeline.source); const release = loadRelease(workspace, pipeline.release); const artifacts = new Map(); for (const artifact of pipeline.artifacts) { artifacts.set(artifact.name, pullCandidateArtifact({ artifact, registry, sourceSha, workspace, dockerEnv, })); } if (eventName !== "push") { console.log(`Validated ${release.tag} against candidate ${sourceSha}; manual promotion does not mutate Production.`); appendSummary(`Validated release ${release.tag} against candidate \`${sourceSha}\` without deployment.`); return; } const gitea = { apiUrl: resolveGiteaApiUrl(), repository: requiredString(process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY, "Gitea repository"), token: requiredString(process.env.GITEA_TOKEN, "Gitea token"), }; await ensureTag({ ...gitea, tag: release.tag, target: sha }); promoteArtifactAliases(artifacts, release.tag, { cwd: workspace, env: dockerEnv }); const client = createQuickStackClient({ baseUrl: process.env.QUICKSTACK_BASE_URL, token: process.env.QUICKSTACK_API_TOKEN, }); await deployApplications({ applications: pipeline.applications, artifacts, sha: sourceSha, client }); const published = await publishRelease({ ...gitea, release: release.release }); console.log(`Published Gitea release ${release.tag}: ${published?.html_url ?? "success"}`); appendSummary(`- Release: ${release.tag}`); } finally { fs.rmSync(dockerConfig, { recursive: true, force: true }); } } async function main() { const workspace = process.env.GITHUB_WORKSPACE ?? process.cwd(); const configPath = path.resolve(workspace, process.env.QUICKSTACK_DEPLOY_CONFIG ?? ".quickstack/deploy.json"); if (!fs.existsSync(configPath)) { console.log(`No ${path.relative(workspace, configPath)} manifest; scoped deployment does not apply.`); return; } const config = JSON.parse(fs.readFileSync(configPath, "utf8")); const eventName = process.env.GITHUB_EVENT_NAME ?? process.env.GITEA_EVENT_NAME ?? ""; const branch = resolveDeploymentBranch({ eventName }); if (config.version === 2) { const pipeline = validateVersion2Pipeline(config, branch); if (!pipeline) { console.log(`No deployment is declared for branch ${branch}.`); return; } const sha = requiredString(process.env.GITHUB_SHA ?? process.env.GITEA_SHA, "Git SHA").toLowerCase(); if (!/^[0-9a-f]{40,64}$/.test(sha)) throw new Error("Git SHA must be a full hexadecimal commit ID."); await executeVersion2({ config, pipeline, eventName, sha, workspace }); return; } const deployment = selectDeployment(config, branch); if (!deployment) { console.log(`No deployment is declared for branch ${branch}.`); return; } const targets = (deployment.targets ?? []).map(validateTarget); if (!targets.length) throw new Error(`Deployment ${deployment.name ?? branch} has no targets.`); const sha = requiredString(process.env.GITHUB_SHA ?? process.env.GITEA_SHA, "Git SHA").toLowerCase(); if (!/^[0-9a-f]{40,64}$/.test(sha)) throw new Error("Git SHA must be a full hexadecimal commit ID."); const commitMessage = run("git", ["log", "-1", "--pretty=%B"], { cwd: workspace, capture: true }); if (eventName === "push" && commitMessage.includes("[skip quickstack-deploy]")) { console.log("Deployment intentionally skipped by commit marker."); return; } const validationOnly = eventName === "pull_request"; const registry = requiredString(config.registry ?? "gitea.nuvisphere.de", "OCI registry").replace(/\/$/, ""); const dockerConfig = fs.mkdtempSync(path.join(os.tmpdir(), "quickstack-docker-")); const dockerEnv = { ...process.env, DOCKER_CONFIG: dockerConfig }; try { if (!validationOnly) { run( "docker", ["login", registry, "--username", requiredString(process.env.REGISTRY_USERNAME, "Registry username"), "--password-stdin"], { env: dockerEnv, input: requiredString(process.env.REGISTRY_TOKEN, "Registry token") }, ); } const client = validationOnly ? null : createQuickStackClient({ baseUrl: process.env.QUICKSTACK_BASE_URL, token: process.env.QUICKSTACK_API_TOKEN, }); for (const target of targets) { const taggedImage = `${registry}/${target.image}:sha-${sha}`; const buildArgs = [ "build", "--progress=plain", "--file", target.dockerfile, "--label", `org.opencontainers.image.source=${process.env.GITHUB_SERVER_URL ?? process.env.GITEA_SERVER_URL}/${process.env.GITHUB_REPOSITORY ?? process.env.GITEA_REPOSITORY}`, "--label", `org.opencontainers.image.revision=${sha}`, "--tag", taggedImage, ]; for (const [key, value] of Object.entries(target.buildArgs ?? {})) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`Invalid build argument ${key}.`); buildArgs.push("--build-arg", `${key}=${value}`); } buildArgs.push(target.context); console.log(`Building ${target.name} from ${target.dockerfile} as ${taggedImage}.`); run("docker", buildArgs, { cwd: workspace, env: dockerEnv }); if (validationOnly) continue; const pushOutput = run("docker", ["push", taggedImage], { cwd: workspace, env: dockerEnv, capture: true }); process.stdout.write(pushOutput); const digest = [...pushOutput.matchAll(/digest:\s*(sha256:[0-9a-f]{64})/g)].at(-1)?.[1]; if (!digest) throw new Error(`Registry did not return a digest for ${taggedImage}.`); const exactImage = `${registry}/${target.image}@${digest}`; const result = await deployExactImage({ client, appId: target.appId, image: exactImage, registryUsername: process.env.REGISTRY_USERNAME, registryToken: process.env.REGISTRY_TOKEN, environment: target.environment, healthCheckTcpPort: target.healthCheckTcpPort, postflight: target.postflight, deploymentAttempts: target.deploymentAttempts, deploymentRetrySeconds: target.deploymentRetrySeconds, sha, }); console.log(`Deployed ${target.name}: ${result.image}`); appendSummary(`- ${target.name}: \`${result.image}\` (${result.status})`); } if (validationOnly) appendSummary(`Validated ${targets.length} QuickStack OCI target(s) for ${branch}.`); } finally { fs.rmSync(dockerConfig, { recursive: true, force: true }); } } const invokedPath = process.argv[1] ? fs.realpathSync(process.argv[1]) : ""; const modulePath = fs.realpathSync(fileURLToPath(import.meta.url)); if (invokedPath === modulePath) { main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }); }