clusterflux-public/scripts/public-release-preflight.js
Clusterflux release 4d75257c6e Public release release-fcace1d13a4b
Source commit: fcace1d13a4b4100c5fe95267d06de68ee6675af

Public tree identity: sha256:b2360c9d6946d43f92b7ba46de29f954a5306997fa223361e920cd15236c9d9b
2026-07-20 15:24:05 +02:00

438 lines
15 KiB
JavaScript
Executable file

#!/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")
);
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-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() {
const head = commandOutput("git", ["rev-parse", "HEAD"]);
assert(head && /^[0-9a-f]{40}$/u.test(head), "preflight requires an exact Git HEAD");
const asserted = process.env.CLUSTERFLUX_ACCEPTANCE_COMMIT;
if (asserted) {
assert.strictEqual(asserted, head, "acceptance commit must match Git HEAD");
}
return head;
}
function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function sha256File(file) {
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
}
function sha256Buffer(buffer) {
return crypto.createHash("sha256").update(buffer).digest("hex");
}
function readArchiveMember(archive, member) {
return cp.execFileSync("tar", ["-xOzf", archive, `./${member}`], {
cwd: repo,
encoding: null,
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
}
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);
for (const asset of manifest.assets ?? []) {
asset.file = path.isAbsolute(asset.file)
? asset.file
: path.resolve(path.dirname(manifestPath), asset.file);
}
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");
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 binaryAsset = manifest.assets.find((asset) =>
asset.name.startsWith("clusterflux-public-binaries-")
);
const hostedBinaryAsset = manifest.assets.find((asset) =>
asset.name.startsWith("clusterflux-hosted-")
);
assert(binaryAsset, "manifest must include the exact candidate binary archive");
assert(hostedBinaryAsset, "manifest must include the exact hosted binary archive");
for (const [name, digest] of Object.entries(manifest.binary_digests)) {
const archive = name === "clusterflux-hosted-service" ? hostedBinaryAsset : binaryAsset;
const binary = readArchiveMember(archive.file, `bin/${name}`);
assert.strictEqual(
`sha256:${sha256Buffer(binary)}`,
digest,
`candidate archive binary ${name} does not match the manifest`
);
}
assert(
manifest.final_evidence && manifest.final_evidence.complete === true,
"release publication requires complete final result and transcript evidence"
);
const evidenceAsset = manifest.assets.find((asset) =>
asset.name.startsWith("clusterflux-public-evidence-")
);
assert(evidenceAsset, "manifest must include the final evidence archive");
const binding = JSON.parse(
readArchiveMember(evidenceAsset.file, "EVIDENCE_BINDING.json").toString("utf8")
);
assert.strictEqual(binding.source_commit, currentSourceCommit);
assert.strictEqual(binding.source_tree_digest, manifest.source_tree_digest);
assert.strictEqual(binding.public_tree_identity, manifest.public_tree_identity);
assert.deepStrictEqual(binding.binary_digests, manifest.binary_digests);
assert(binding.final_evidence && binding.final_evidence.complete === true);
assert(Array.isArray(binding.included_evidence));
const includedEvidence = new Map(
binding.included_evidence.map((entry) => [entry.name, entry.sha256])
);
for (const required of ["FINAL_RELEASE_RESULT.json", "FINAL_RELEASE_TRANSCRIPT.txt"]) {
assert(includedEvidence.has(required), `final evidence archive is missing ${required}`);
const contents = readArchiveMember(evidenceAsset.file, required);
assert.strictEqual(
sha256Buffer(contents),
includedEvidence.get(required),
`final evidence digest mismatch for ${required}`
);
}
const finalResult = JSON.parse(
readArchiveMember(evidenceAsset.file, "FINAL_RELEASE_RESULT.json").toString("utf8")
);
assert.strictEqual(finalResult.source_commit, currentSourceCommit);
assert.strictEqual(finalResult.source_tree_digest, manifest.source_tree_digest);
assert.strictEqual(finalResult.public_tree_identity, manifest.public_tree_identity);
assert.deepStrictEqual(finalResult.binary_digests, manifest.binary_digests);
assert.strictEqual(
manifest.release_candidate?.mode,
"finalized-exact",
"publication requires exact candidate finalization"
);
assert.strictEqual(finalResult.release_binding?.source_commit, currentSourceCommit);
assert.strictEqual(
finalResult.release_binding?.source_tree_digest,
manifest.source_tree_digest
);
assert.strictEqual(
finalResult.release_binding?.public_tree_identity,
manifest.public_tree_identity
);
assert.deepStrictEqual(
finalResult.release_binding?.binary_digests,
manifest.binary_digests
);
assert.deepStrictEqual(
finalResult.release_candidate,
manifest.release_candidate
);
assert.strictEqual(finalResult.acceptance_result, "passed");
assert.deepStrictEqual(finalResult.scenario_skips, []);
assert(
finalResult.release_binding?.deployment &&
finalResult.release_binding?.configuration &&
finalResult.release_binding?.proxy_configuration,
"final evidence must bind deployment, runtime configuration, and proxy configuration identities"
);
if (manifest.candidate_configuration_identity) {
assert.strictEqual(
finalResult.release_binding.configuration.evidence_identity,
manifest.candidate_configuration_identity,
"live configuration identity differs from the candidate binding"
);
}
if (manifest.candidate_proxy_configuration_identity) {
assert.strictEqual(
finalResult.release_binding.proxy_configuration.evidence_identity,
manifest.candidate_proxy_configuration_identity,
"live proxy configuration identity differs from the candidate binding"
);
}
assert(
Array.isArray(finalResult.named_scenarios) &&
finalResult.named_scenarios.length === 25 &&
finalResult.named_scenarios.every((scenario) => scenario.status === "passed"),
"all twenty-five named final checks must be present and passed"
);
assert(
Array.isArray(finalResult.strict_requirement_ledger) &&
finalResult.strict_requirement_ledger.length > 0 &&
finalResult.strict_requirement_ledger.every((requirement) => requirement.passed === true),
"the strict final requirement ledger must be present and passed"
);
const finalTranscript = readArchiveMember(
evidenceAsset.file,
"FINAL_RELEASE_TRANSCRIPT.txt"
).toString("utf8");
for (const identity of [
currentSourceCommit,
manifest.source_tree_digest,
manifest.public_tree_identity,
...Object.values(manifest.binary_digests),
]) {
assert(finalTranscript.includes(identity), `final transcript is missing identity ${identity}`);
}
const evidence = [
staleEvidence(
path.join(acceptanceRoot, "public-release-forgejo-release.json"),
currentSourceCommit
),
staleEvidence(
path.join(acceptanceRoot, "public-release-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-e2e.json"),
currentSourceCommit
),
staleEvidence(
path.join(acceptanceRoot, "public-release-final.json"),
currentSourceCommit
),
];
const report = {
kind: "clusterflux-public-release-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,
final_evidence: {
complete: true,
binding,
},
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_SERVICE_ADDR") === "set" &&
envState("CLUSTERFLUX_PUBLIC_RELEASE_BROWSER_OPEN_COMMAND") === "set"
? "ready"
: "pending",
env: {
CLUSTERFLUX_PUBLIC_RELEASE_SERVICE_ADDR: envState(
"CLUSTERFLUX_PUBLIC_RELEASE_SERVICE_ADDR"
),
CLUSTERFLUX_PUBLIC_RELEASE_BROWSER_OPEN_COMMAND: envState(
"CLUSTERFLUX_PUBLIC_RELEASE_BROWSER_OPEN_COMMAND"
),
},
},
public_release_e2e: {
status:
envState("CLUSTERFLUX_PUBLIC_RELEASE_E2E") === "set" &&
process.env.CLUSTERFLUX_PUBLIC_RELEASE_E2E === "1" &&
envState("CLUSTERFLUX_PUBLIC_RELEASE_BROWSER_OPEN_COMMAND") === "set"
? "ready"
: "pending",
env: {
CLUSTERFLUX_PUBLIC_RELEASE_E2E:
process.env.CLUSTERFLUX_PUBLIC_RELEASE_E2E || "unset",
CLUSTERFLUX_PUBLIC_RELEASE_BROWSER_OPEN_COMMAND: envState(
"CLUSTERFLUX_PUBLIC_RELEASE_BROWSER_OPEN_COMMAND"
),
},
},
final_evidence: {
status:
envState("CLUSTERFLUX_PUBLIC_RELEASE_FINAL") === "set" &&
process.env.CLUSTERFLUX_PUBLIC_RELEASE_FINAL === "1"
? "ready"
: "pending",
env: {
CLUSTERFLUX_PUBLIC_RELEASE_FINAL:
process.env.CLUSTERFLUX_PUBLIC_RELEASE_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));