Files
projectbrain-Public/scripts/release-tag-ready-gate.mjs
T

257 lines
8.5 KiB
JavaScript

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;
}