#!/usr/bin/env node const assert = require("assert"); const cp = require("child_process"); const crypto = require("crypto"); const fs = require("fs"); const path = require("path"); const repo = path.resolve(__dirname, ".."); const releaseRoot = path.resolve( process.env.CLUSTERFLUX_PUBLIC_RELEASE_DIR || path.join(repo, "target/public-release-dryrun") ); const acceptanceRoot = path.join(repo, "target/acceptance"); const manifestPath = process.env.CLUSTERFLUX_PUBLIC_RELEASE_MANIFEST || path.join(releaseRoot, "public-release-manifest.json"); const reportPath = process.env.CLUSTERFLUX_PUBLIC_RELEASE_PREFLIGHT_REPORT || path.join(acceptanceRoot, "public-release-dryrun-preflight.json"); function commandOutput(command, args, options = {}) { try { return cp .execFileSync(command, args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...options, }) .trim(); } catch (_) { return null; } } function nonInteractiveEnv(extra = {}) { return { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: process.env.GIT_ASKPASS || "/bin/false", SSH_ASKPASS: process.env.SSH_ASKPASS || "/bin/false", GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || "ssh -o BatchMode=yes -o NumberOfPasswordPrompts=0", ...extra, }; } function expectedSourceCommit() { return ( process.env.CLUSTERFLUX_ACCEPTANCE_COMMIT || commandOutput("git", ["rev-parse", "HEAD"]) || "unknown" ); } function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); } function sha256File(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); } function parseSha256Sums(file) { const sums = new Map(); for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) { if (!line.trim()) continue; const match = /^([0-9a-f]{64})\s+(.+)$/.exec(line.trim()); assert(match, `malformed SHA256SUMS line: ${line}`); sums.set(path.basename(match[2]), match[1]); } return sums; } function remoteHead(remote) { const output = commandOutput("git", ["ls-remote", remote, "HEAD", "refs/heads/main"], { env: nonInteractiveEnv(), timeout: Number(process.env.CLUSTERFLUX_PUBLIC_REPO_REMOTE_TIMEOUT_MS || 30000), }); if (!output) return null; const lines = output.split(/\r?\n/).filter(Boolean); const head = lines.find((line) => line.endsWith("\tHEAD")) || lines[0]; return head && head.split(/\s+/)[0]; } function publicTreeAlreadyPushed(manifest) { return ( (manifest.public_tree_publish && manifest.public_tree_publish.pushed === true) || process.env.CLUSTERFLUX_PUBLIC_TREE_ALREADY_PUSHED === "1" ); } function publicTreePushSource(manifest) { return manifest.public_tree_publish && manifest.public_tree_publish.pushed === true ? "manifest" : "external-env"; } function publicRepoRemoteForManifest(manifest) { return ( manifest.public_repo_url || manifest.public_repo_remote || process.env.CLUSTERFLUX_PUBLIC_REPO_REMOTE || process.env.CLUSTERFLUX_PUBLIC_REPO_URL || null ); } function publicTreeCommitForManifest(manifest) { return ( (manifest.public_tree_publish && manifest.public_tree_publish.commit) || process.env.CLUSTERFLUX_PUBLIC_TREE_COMMIT || process.env.CLUSTERFLUX_PUBLIC_RELEASE_TARGET || null ); } function envState(name) { return process.env[name] ? "set" : "unset"; } function staleEvidence(file, currentSourceCommit) { if (!fs.existsSync(file)) { return { file, status: "missing", source_commit: null, release_name: null }; } const evidence = readJson(file); if (!evidence.source_commit) { return { file, status: "unversioned", source_commit: null, release_name: evidence.release_name || null, }; } return { file, status: evidence.source_commit === currentSourceCommit ? "current" : "stale", source_commit: evidence.source_commit, release_name: evidence.release_name || null, }; } assert(fs.existsSync(manifestPath), `missing public release manifest: ${manifestPath}`); const manifest = readJson(manifestPath); const currentSourceCommit = expectedSourceCommit(); const currentTreeStatus = commandOutput("git", ["status", "--short"]) || ""; assert.strictEqual( currentTreeStatus, "", "public release preflight requires a clean source tree" ); assert.strictEqual(manifest.kind, "clusterflux-public-release-dryrun"); assert.strictEqual( manifest.source_commit, currentSourceCommit, "public release manifest must be regenerated for the current acceptance commit" ); assert.strictEqual(manifest.source_tree_clean, true, "public release prep must start clean"); assert.strictEqual( publicTreeAlreadyPushed(manifest), true, "filtered public tree must be pushed to Forgejo before release publication" ); const publicRepoRemote = publicRepoRemoteForManifest(manifest); assert(publicRepoRemote, "manifest must record public repository URL or remote"); const publicTreeCommit = publicTreeCommitForManifest(manifest); const remoteMain = remoteHead(publicRepoRemote); assert(remoteMain, "Forgejo public repository main branch must be readable"); if (publicTreeCommit) { assert.strictEqual( remoteMain, publicTreeCommit, "Forgejo public repository main branch must match the prepared public tree commit" ); } assert(Array.isArray(manifest.assets) && manifest.assets.length > 0, "manifest assets missing"); const checksumAsset = manifest.assets.find((asset) => asset.name === "SHA256SUMS"); assert(checksumAsset, "manifest must include SHA256SUMS"); assert(fs.existsSync(checksumAsset.file), `missing checksum asset: ${checksumAsset.file}`); const checksums = parseSha256Sums(checksumAsset.file); const assets = manifest.assets.map((asset) => { assert(fs.existsSync(asset.file), `missing release asset: ${asset.file}`); const actual = sha256File(asset.file); const expected = checksums.get(asset.name); if (asset.name !== "SHA256SUMS") { assert.strictEqual(actual, expected, `checksum mismatch for ${asset.name}`); } return { name: asset.name, file: asset.file, bytes: fs.statSync(asset.file).size, sha256: actual, }; }); const evidence = [ staleEvidence( path.join(acceptanceRoot, "public-release-dryrun-forgejo-release.json"), currentSourceCommit ), staleEvidence( path.join(acceptanceRoot, "public-release-dryrun-service.json"), currentSourceCommit ), staleEvidence( path.join(acceptanceRoot, "hosted-client-compat.json"), currentSourceCommit ), staleEvidence( path.join(acceptanceRoot, "core-coordinator-compat.json"), currentSourceCommit ), staleEvidence( path.join(acceptanceRoot, "public-release-dryrun-e2e.json"), currentSourceCommit ), staleEvidence( path.join(acceptanceRoot, "public-release-dryrun-final.json"), currentSourceCommit ), ]; const report = { kind: "clusterflux-public-release-dryrun-preflight", source_commit: currentSourceCommit, release_name: manifest.release_name, public_tree_commit: publicTreeCommit || remoteMain, public_tree_push_source: publicTreePushSource(manifest), public_repo_url: publicRepoRemote, public_repo_remote_head: remoteMain, source_tree_clean: currentTreeStatus === "", local_assets_ready: true, assets, evidence, external_gates: { forgejo_release_publication: { status: envState("CLUSTERFLUX_FORGEJO_TOKEN") === "set" ? "ready" : "pending", env: { CLUSTERFLUX_FORGEJO_TOKEN: envState("CLUSTERFLUX_FORGEJO_TOKEN"), }, }, live_service_smoke: { status: envState("CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR") === "set" && envState("CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND") === "set" ? "ready" : "pending", env: { CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR: envState( "CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR" ), CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND: envState( "CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND" ), }, }, public_release_e2e: { status: envState("CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_E2E") === "set" && process.env.CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_E2E === "1" && envState("CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND") === "set" ? "ready" : "pending", env: { CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_E2E: process.env.CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_E2E || "unset", CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND: envState( "CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND" ), }, }, final_evidence: { status: envState("CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_FINAL") === "set" && process.env.CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_FINAL === "1" ? "ready" : "pending", env: { CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_FINAL: process.env.CLUSTERFLUX_PUBLIC_RELEASE_DRYRUN_FINAL || "unset", }, }, }, }; fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); console.log(JSON.stringify({ report: reportPath, release_name: report.release_name }, null, 2));