import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; const proofDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof"; const signoffDir = process.env.PROJECTBRAIN_SIGNOFF_ARCHIVE_DIR ?? "checkpoints/release-signoff"; const deploymentProofDir = process.env.PROJECTBRAIN_DEPLOY_PROOF_ARCHIVE_DIR ?? "checkpoints/deployment-proof"; const dossierDir = process.env.PROJECTBRAIN_RELEASE_DOSSIER_DIR ?? "checkpoints/release-dossier"; const baseUrl = process.env.PROJECTBRAIN_DEPLOY_URL ?? process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:1209"; const gitRemote = process.env.PROJECTBRAIN_RELEASE_REMOTE ?? "gitea"; const gitBranch = process.env.PROJECTBRAIN_RELEASE_BRANCH ?? "main"; const sshTarget = process.env.PROJECTBRAIN_DEPLOY_SSH_TARGET ?? "root@127.0.0.1"; const deployPath = process.env.PROJECTBRAIN_DEPLOY_PATH ?? "/mnt/pool/appdata/brain"; const sshKey = process.env.PROJECTBRAIN_DEPLOY_SSH_KEY ?? path.join(os.homedir(), ".ssh", "widefrog_unraid_deploy"); const requestedTag = process.env.PROJECTBRAIN_RELEASE_TAG; const shouldWrite = process.argv.includes("--write") || process.env.PROJECTBRAIN_TAG_GATE_WRITE === "1"; const explicitOutputArg = process.argv.find((arg) => arg.startsWith("--out=")); const explicitOutputPath = explicitOutputArg?.slice("--out=".length) || process.env.PROJECTBRAIN_TAG_GATE_OUT; const run = (command, args, options = {}) => spawnSync(command, args, { encoding: "utf8", shell: process.platform === "win32", ...options, }); const getGitOutput = (args) => { const result = run("git", args); return { ok: result.status === 0, value: result.stdout.trim(), error: result.stderr.trim(), }; }; const parseField = (text, label) => { const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m")); return match?.[1]?.trim() ?? "unknown"; }; const displayPath = (filePath) => filePath.split(path.sep).join("/"); const listMarkdownFiles = (archiveDir) => { if (!fs.existsSync(archiveDir)) return []; return fs.readdirSync(archiveDir) .filter((fileName) => fileName.endsWith(".md")) .map((fileName) => path.join(archiveDir, fileName)) .filter((filePath) => fs.statSync(filePath).isFile()); }; const sortNewestFirst = (items) => items.sort((a, b) => { const generatedDelta = Date.parse(b.generated) - Date.parse(a.generated); if (Number.isFinite(generatedDelta) && generatedDelta !== 0) return generatedDelta; return b.fileName.localeCompare(a.fileName); }); const parseArtifact = (filePath, type) => { const text = fs.readFileSync(filePath, "utf8"); const commit = type === "deployment" ? parseField(text, "Local commit") : parseField(text, "Commit"); return { type, filePath, displayPath: displayPath(filePath), fileName: path.basename(filePath), generated: parseField(text, "Generated"), commit, verdict: parseField(text, "Verdict"), freshness: type === "deployment" ? parseField(text, "Commit freshness") : parseField(text, "Freshness"), }; }; const findCurrentArtifact = (archiveDir, type, currentCommit) => { const artifacts = sortNewestFirst(listMarkdownFiles(archiveDir).map((filePath) => parseArtifact(filePath, type))); return artifacts.find((artifact) => { if (artifact.commit !== currentCommit) return false; if (type === "proof") return artifact.verdict === "PASS"; if (type === "signoff") return artifact.freshness === "current"; if (type === "deployment") return artifact.verdict === "PASS" && artifact.freshness === "current"; if (type === "dossier") return artifact.verdict === "PASS"; return false; }); }; const checkHttp = async (urlPath) => { try { const response = await fetch(`${baseUrl}${urlPath}`); const body = await response.text(); return { ok: response.ok, status: String(response.status), body: body.slice(0, 220).replace(/\s+/g, " ").trim(), }; } catch (error) { return { ok: false, status: "unreachable", body: error instanceof Error ? error.message : String(error), }; } }; const runSsh = (remoteCommand) => { if (!fs.existsSync(sshKey)) { return { ok: false, stdout: "", stderr: `SSH key not found: ${sshKey}`, }; } const result = spawnSync("ssh", [ "-i", sshKey, "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", sshTarget, remoteCommand, ], { encoding: "utf8", shell: false, }); return { ok: result.status === 0, stdout: result.stdout.trim(), stderr: result.stderr.trim(), }; }; const createDefaultTagName = (commit) => { const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, ""); return `projectbrain-rc-${stamp}-${commit}`; }; const createDefaultReportPath = (commit) => { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); return `checkpoints/tag-gate/projectbrain-tag-ready-${commit}-${stamp}.md`; }; const currentCommit = getGitOutput(["rev-parse", "--short", "HEAD"]).value || "unknown"; const fullCurrentCommit = getGitOutput(["rev-parse", "HEAD"]).value || "unknown"; const status = getGitOutput(["status", "--porcelain"]); const fetchRemote = run("git", ["fetch", gitRemote, gitBranch]); const remoteRef = `${gitRemote}/${gitBranch}`; const remoteCommit = getGitOutput(["rev-parse", "--short", remoteRef]).value || "unknown"; const remoteFullCommit = getGitOutput(["rev-parse", remoteRef]).value || "unknown"; const health = await checkHttp("/api/health"); const remoteShell = runSsh(`git -C ${deployPath} rev-parse --short HEAD 2>/dev/null || echo unknown`); const towerCommit = remoteShell.stdout.trim() || "unknown"; const tagName = requestedTag || createDefaultTagName(currentCommit); const reportPath = explicitOutputPath || createDefaultReportPath(currentCommit); const proof = findCurrentArtifact(proofDir, "proof", currentCommit); const signoff = findCurrentArtifact(signoffDir, "signoff", currentCommit); const deployment = findCurrentArtifact(deploymentProofDir, "deployment", currentCommit); const dossier = findCurrentArtifact(dossierDir, "dossier", currentCommit); const gates = [ { label: "Tracked worktree clean", ok: status.ok && status.value.trim() === "", detail: status.value.trim() || "clean", }, { label: `Fetched ${gitRemote}/${gitBranch}`, ok: fetchRemote.status === 0, detail: fetchRemote.status === 0 ? "fetched" : fetchRemote.stderr.trim(), }, { label: "Gitea remote matches local commit", ok: fullCurrentCommit === remoteFullCommit, detail: `${currentCommit} local / ${remoteCommit} remote`, }, { label: "Tower deployment matches local commit", ok: towerCommit === currentCommit, detail: `${currentCommit} local / ${towerCommit} tower`, }, { label: "Live health reachable", ok: health.ok, detail: `${health.status} ${health.body}`, }, { label: "Current passing release proof exists", ok: Boolean(proof), detail: proof?.displayPath ?? "missing", }, { label: "Current operator signoff exists", ok: Boolean(signoff), detail: signoff?.displayPath ?? "missing", }, { label: "Current passing deployment proof exists", ok: Boolean(deployment), detail: deployment?.displayPath ?? "missing", }, { label: "Current release evidence dossier exists", ok: Boolean(dossier), detail: dossier?.displayPath ?? "missing", }, ]; const passed = gates.every((gate) => gate.ok); const lines = [ "# ProjectBrain Release Tag Ready Gate", "", `Generated: ${new Date().toISOString()}`, `Commit: ${currentCommit}`, `Remote: ${remoteRef}`, `Tower URL: ${baseUrl}`, `Suggested tag: ${tagName}`, `Verdict: ${passed ? "PASS" : "ATTENTION REQUIRED"}`, "", "## Gates", "", "| Gate | Result | Detail |", "| --- | --- | --- |", ...gates.map((gate) => `| ${gate.label} | ${gate.ok ? "PASS" : "FAIL"} | ${String(gate.detail).replace(/\|/g, "\\|")} |`), "", "## Tag Commands", "", "Review the suggested tag name before running these commands:", "", "```bash", `git tag -a ${tagName} -m "ProjectBrain release ${tagName}"`, `git push ${gitRemote} ${tagName}`, "```", "", "## Recovery", "", "```bash", "PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF=1 PROJECTBRAIN_RELEASE_DOSSIER=1 pnpm run release:bundle", "pnpm run release:tag-gate", "```", ].join("\n"); console.log(lines); if (shouldWrite) { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${lines}\n`, "utf8"); console.log(`\nRelease tag ready report written to ${reportPath}`); } if (!passed) { process.exitCode = 1; }