Source commit: 6d1a121b0a8a636aac95998fe5d15c24c78eb7d0 Public tree identity: sha256:2bc22807f5b838286678b65d3f550b8ae4714ae673622041d4c2516a7c5b7926
294 lines
9.2 KiB
JavaScript
Executable file
294 lines
9.2 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 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");
|
|
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-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,
|
|
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));
|