#!/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.DISASMER_PUBLIC_RELEASE_DIR || path.join(repo, "target/public-release-dryrun") ); const acceptanceRoot = path.join(repo, "target/acceptance"); const manifestPath = process.env.DISASMER_PUBLIC_RELEASE_MANIFEST || path.join(releaseRoot, "public-release-manifest.json"); const reportPath = process.env.DISASMER_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 expectedSourceCommit() { return ( process.env.DISASMER_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"]); 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 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, "disasmer-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( manifest.public_tree_publish && manifest.public_tree_publish.pushed, true, "filtered public tree must be pushed to Forgejo before release publication" ); const publicRepoRemote = manifest.public_repo_url || manifest.public_repo_remote; assert(publicRepoRemote, "manifest must record public repository URL or remote"); const remoteMain = remoteHead(publicRepoRemote); assert.strictEqual( remoteMain, manifest.public_tree_publish.commit, "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, "public-release-dryrun-e2e.json"), currentSourceCommit ), staleEvidence( path.join(acceptanceRoot, "public-release-dryrun-final.json"), currentSourceCommit ), ]; const report = { kind: "disasmer-public-release-dryrun-preflight", source_commit: currentSourceCommit, release_name: manifest.release_name, public_tree_commit: manifest.public_tree_publish.commit, 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("DISASMER_FORGEJO_TOKEN") === "set" ? "ready" : "pending", env: { DISASMER_FORGEJO_TOKEN: envState("DISASMER_FORGEJO_TOKEN"), }, }, live_service_smoke: { status: envState("DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR") === "set" && envState("DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL") === "set" && envState("DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE") === "set" ? "ready" : "pending", env: { DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR: envState( "DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR" ), DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL: envState( "DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL" ), DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE: envState( "DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE" ), }, }, public_release_e2e: { status: envState("DISASMER_PUBLIC_RELEASE_DRYRUN_E2E") === "set" && process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E === "1" && envState("DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL") === "set" && envState("DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE") === "set" ? "ready" : "pending", env: { DISASMER_PUBLIC_RELEASE_DRYRUN_E2E: process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E || "unset", DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL: envState( "DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL" ), DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE: envState( "DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE" ), }, }, final_evidence: { status: envState("DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL") === "set" && process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL === "1" ? "ready" : "pending", env: { DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL: process.env.DISASMER_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));