#!/usr/bin/env bun import { spawnSync } from "node:child_process"; import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; function option(name) { const index = process.argv.indexOf(name); if (index === -1) return null; const value = process.argv[index + 1]; if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`); return value; } function record(value, path) { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); return value; } function required(value, path) { if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${path} must be a non-empty single-line string`); return value; } function run(command, args, cwd, allowFailure = false) { const result = spawnSync(command, args, { cwd, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 }); if (result.error !== undefined) throw result.error; if (result.status !== 0 && !allowFailure) { const detail = `${result.stderr || result.stdout || "command failed"}`.trim().slice(-2000); throw new Error(`${command} ${args.join(" ")} failed (${result.status}): ${detail}`); } return result; } function safeManifestPath(value) { const path = required(value, "delivery.gitops.manifestPath"); if (path.startsWith("/") || path.split("/").includes("..")) throw new Error("delivery.gitops.manifestPath must be a safe relative path"); return path; } function safeReleaseStatePath(value) { const path = required(value, "delivery.gitops.releaseStatePath"); if (path.startsWith("/") || path.split("/").includes("..")) throw new Error("delivery.gitops.releaseStatePath must be a safe relative path"); return path; } function releaseValue(releaseDir, name) { return required(readFileSync(resolve(releaseDir, name), "utf8").trim(), `${releaseDir}/${name}`); } function removeFile(path) { try { unlinkSync(path); } catch (error) { if (error?.code !== "ENOENT") throw error; } } function readReleaseState(path, serviceRef) { let value; try { value = record(JSON.parse(readFileSync(path, "utf8")), path); } catch (error) { if (error?.code === "ENOENT") return null; throw error; } if (value.kind !== "UniDeskHostReleaseState") throw new Error(`${path}.kind must be UniDeskHostReleaseState`); if (value.serviceRef !== serviceRef) throw new Error(`${path}.serviceRef must match delivery.serviceRef`); const sourceCommit = required(value.sourceCommit, `${path}.sourceCommit`); const digest = required(value.digest, `${path}.digest`); const digestRef = required(value.digestRef, `${path}.digestRef`); if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error(`${path}.sourceCommit must be a full Git commit SHA`); if (!/^sha256:[0-9a-f]{64}$/u.test(digest)) throw new Error(`${path}.digest must be sha256:<64 hex>`); return { sourceCommit, digest, digestRef }; } function main() { const configPath = resolve(option("--config") ?? "config/unidesk-host-k8s.yaml"); const sourceRoot = resolve(option("--source-root") ?? process.cwd()); const metadataPath = resolve(required(option("--metadata"), "--metadata")); const releaseDir = resolve(required(option("--release-dir"), "--release-dir")); const sourceCommit = required(option("--source-commit"), "--source-commit"); const worktree = resolve(option("--worktree") ?? "/workspace/unidesk-host-gitops"); if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full Git commit SHA"); const config = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configPath); const delivery = record(config.delivery, "delivery"); if (delivery.enabled !== true && delivery.enabled !== false) throw new Error("delivery.enabled must be boolean"); const enabled = delivery.enabled === true; const action = releaseValue(releaseDir, "action"); const reason = releaseValue(releaseDir, "reason"); const baselineSourceCommit = readFileSync(resolve(releaseDir, "baseline-source-commit"), "utf8").trim() || null; if (!new Set(["build", "skip", "disabled"]).has(action)) throw new Error(`${releaseDir}/action must be build, skip, or disabled`); if ((enabled && action === "disabled") || (!enabled && action !== "disabled")) throw new Error("release action does not match delivery.enabled"); const serviceRef = required(delivery.serviceRef, "delivery.serviceRef"); const imageConfig = record(delivery.image, "delivery.image"); const gitops = record(delivery.gitops, "delivery.gitops"); const author = record(gitops.author, "delivery.gitops.author"); const readUrl = required(gitops.readUrl, "delivery.gitops.readUrl"); const writeUrl = required(gitops.writeUrl, "delivery.gitops.writeUrl"); const branch = required(gitops.branch, "delivery.gitops.branch"); const manifestPath = safeManifestPath(gitops.manifestPath); const releaseStatePath = safeReleaseStatePath(gitops.releaseStatePath); let digest = null; let digestRef = null; let manifest = null; let runtimeSourceCommit = null; if (action === "build") { const metadata = record(JSON.parse(readFileSync(metadataPath, "utf8")), metadataPath); digest = required(metadata["containerimage.digest"], `${metadataPath}.containerimage.digest`); if (!/^sha256:[0-9a-f]{64}$/u.test(digest)) throw new Error("containerimage.digest must be sha256:<64 hex>"); digestRef = `${required(imageConfig.repository, "delivery.image.repository")}@${digest}`; runtimeSourceCommit = sourceCommit; const render = run("bun", [ "scripts/native/deploy/render-unidesk-host-service.mjs", "--config", configPath, "--service-ref", serviceRef, "--image", digestRef, "--source-commit", sourceCommit, ], sourceRoot); manifest = render.stdout; if (!manifest.includes("kind: Deployment") || !manifest.includes("kind: Service")) throw new Error("service renderer did not emit Service and Deployment objects"); } rmSync(worktree, { recursive: true, force: true }); run("git", ["clone", "--no-checkout", readUrl, worktree], sourceRoot); const fetched = run("git", ["fetch", "origin", branch], worktree, true).status === 0; if (fetched) { run("git", ["checkout", "-B", branch, `origin/${branch}`], worktree); } else { run("git", ["checkout", "--orphan", branch], worktree); run("git", ["rm", "-rf", "."], worktree, true); } const targetPath = resolve(worktree, manifestPath); const statePath = resolve(worktree, releaseStatePath); if (!targetPath.startsWith(`${worktree}/`)) throw new Error("resolved manifest path escaped the GitOps worktree"); if (!statePath.startsWith(`${worktree}/`)) throw new Error("resolved release state path escaped the GitOps worktree"); const existingState = readReleaseState(statePath, serviceRef); if (action === "skip") { if (existingState === null) throw new Error("skip requires an existing GitOps release state"); digest = existingState.digest; digestRef = existingState.digestRef; runtimeSourceCommit = existingState.sourceCommit; } else if (action === "build" && manifest !== null) { mkdirSync(dirname(targetPath), { recursive: true }); writeFileSync(targetPath, manifest, "utf8"); mkdirSync(dirname(statePath), { recursive: true }); writeFileSync(statePath, `${JSON.stringify({ version: 1, kind: "UniDeskHostReleaseState", serviceRef, sourceCommit, digest, digestRef, }, null, 2)}\n`, "utf8"); } else { removeFile(targetPath); removeFile(statePath); } run("git", ["config", "user.name", required(author.name, "delivery.gitops.author.name")], worktree); run("git", ["config", "user.email", required(author.email, "delivery.gitops.author.email")], worktree); run("git", ["add", "-A"], worktree); const changed = run("git", ["diff", "--cached", "--quiet"], worktree, true).status !== 0; if (changed) { const commitAction = action === "build" ? "deploy" : "remove"; run("git", ["commit", "-m", `unidesk-host: ${commitAction} ${serviceRef} ${sourceCommit.slice(0, 12)}`], worktree); } run("git", ["remote", "set-url", "origin", writeUrl], worktree); let pushAttempts = 0; if (changed) { let pushed = false; for (let attempt = 1; attempt <= 3; attempt += 1) { pushAttempts = attempt; const push = run("git", ["push", "origin", `HEAD:refs/heads/${branch}`], worktree, true); if (push.status === 0) { pushed = true; break; } run("git", ["fetch", "origin", branch], worktree); const rebase = run("git", ["rebase", `origin/${branch}`], worktree, true); if (rebase.status !== 0) { run("git", ["rebase", "--abort"], worktree, true); throw new Error(`GitOps rebase failed after push attempt ${attempt}`); } } if (!pushed) throw new Error("GitOps push failed after 3 attempts"); } const head = run("git", ["rev-parse", "HEAD"], worktree, !enabled && !changed); const gitopsCommit = head.status === 0 ? head.stdout.trim() : null; process.stdout.write(`${JSON.stringify({ ok: true, phase: "gitops-publish", action, reason, status: action === "build" ? "built" : action === "skip" ? "skipped" : "disabled", imageStatus: action === "build" ? "built" : action === "skip" ? "skipped" : "disabled", sourceCommit, baselineSourceCommit, runtimeSourceCommit, digest, digestRef, gitopsCommit, changed, pushAttempts, valuesPrinted: false, })}\n`); } if (!process.execArgv.includes("--check")) main();