257 lines
8.5 KiB
JavaScript
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;
|
|
}
|