438 lines
15 KiB
JavaScript
Executable file
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));
|