1253 lines
41 KiB
JavaScript
Executable file
1253 lines
41 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
|
|
const crypto = require("crypto");
|
|
const cp = require("child_process");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
|
|
const repo = path.resolve(__dirname, "..");
|
|
const outputRoot = path.resolve(
|
|
process.env.CLUSTERFLUX_PUBLIC_RELEASE_DIR ||
|
|
path.join(repo, "target/public-release")
|
|
);
|
|
const publicTree = path.join(outputRoot, "public-tree");
|
|
const assetsDir = path.join(outputRoot, "assets");
|
|
const stagingDir = path.join(outputRoot, "staging");
|
|
const publicBuildTarget = path.resolve(
|
|
process.env.CLUSTERFLUX_PUBLIC_BUILD_TARGET_DIR ||
|
|
path.join(outputRoot, "cargo-target")
|
|
);
|
|
const hostedBuildTarget = path.resolve(
|
|
process.env.CLUSTERFLUX_HOSTED_BUILD_TARGET_DIR ||
|
|
path.join(outputRoot, "hosted-cargo-target")
|
|
);
|
|
const candidateManifestPath = process.env.CLUSTERFLUX_RELEASE_CANDIDATE_MANIFEST
|
|
? path.resolve(process.env.CLUSTERFLUX_RELEASE_CANDIDATE_MANIFEST)
|
|
: null;
|
|
const defaultHostedCoordinatorEndpoint = "https://clusterflux.michelpaulissen.com";
|
|
const forgejoHost = "git.michelpaulissen.com";
|
|
const args = new Set(process.argv.slice(2));
|
|
const includeForgejoWorkflows =
|
|
args.has("--include-forgejo-workflows") ||
|
|
/^(1|true|yes)$/i.test(process.env.CLUSTERFLUX_INCLUDE_FORGEJO_WORKFLOWS || "");
|
|
const filteredTopLevel = ["private", "internal", "experiments", ".git", "target"];
|
|
const filteredDirectoryNames = [".clusterflux"];
|
|
const archiveIgnoredPathFallbacks = [
|
|
"target",
|
|
".clusterflux",
|
|
"vscode-extension/node_modules",
|
|
"scripts/containers-home",
|
|
];
|
|
const publicRepoBranch = process.env.CLUSTERFLUX_PUBLIC_REPO_BRANCH || "main";
|
|
const publicBinaries = [
|
|
"clusterflux",
|
|
"clusterflux-coordinator",
|
|
"clusterflux-node",
|
|
"clusterflux-debug-dap",
|
|
];
|
|
const hostedBinary = "clusterflux-hosted-service";
|
|
|
|
function commandOutput(command, args, options = {}) {
|
|
try {
|
|
const execOptions = {
|
|
cwd: repo,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
...options,
|
|
};
|
|
execOptions.env = commandEnv(command, options.env);
|
|
return cp
|
|
.execFileSync(command, args, execOptions)
|
|
.trim();
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
const execOptions = {
|
|
cwd: repo,
|
|
stdio: "inherit",
|
|
...options,
|
|
};
|
|
execOptions.env = commandEnv(command, options.env);
|
|
cp.execFileSync(command, args, execOptions);
|
|
}
|
|
|
|
function nonInteractiveGitEnv(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 commandEnv(command, env) {
|
|
if (command !== "git") {
|
|
return env;
|
|
}
|
|
return nonInteractiveGitEnv(env);
|
|
}
|
|
|
|
function ensureDir(dir) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
function filteredOutPatterns() {
|
|
return [
|
|
"private/**",
|
|
"internal/**",
|
|
"experiments/**",
|
|
".git",
|
|
"target",
|
|
"git-ignored source paths",
|
|
"root/*.md except README.md",
|
|
"**/.clusterflux/**",
|
|
...(includeForgejoWorkflows ? [] : [".forgejo/**"]),
|
|
];
|
|
}
|
|
|
|
function gitIgnoredSourcePaths() {
|
|
const output = commandOutput("git", [
|
|
"ls-files",
|
|
"--others",
|
|
"--ignored",
|
|
"--exclude-standard",
|
|
"--directory",
|
|
"-z",
|
|
]);
|
|
if (output === null) {
|
|
return archiveIgnoredPathFallbacks;
|
|
}
|
|
return [
|
|
...new Set([
|
|
...archiveIgnoredPathFallbacks,
|
|
...output
|
|
.split("\0")
|
|
.filter(Boolean)
|
|
.map((relativePath) =>
|
|
relativePath.replaceAll("\\", "/").replace(/\/$/, "")
|
|
),
|
|
]),
|
|
];
|
|
}
|
|
|
|
function isGitIgnored(relativePath, ignoredSourcePaths) {
|
|
const normalized = relativePath.replaceAll(path.sep, "/");
|
|
return ignoredSourcePaths.some(
|
|
(ignored) => normalized === ignored || normalized.startsWith(`${ignored}/`)
|
|
);
|
|
}
|
|
|
|
function isFilteredRootMarkdown(relativePath, entry) {
|
|
return (
|
|
!relativePath.includes(path.sep) &&
|
|
(entry.isFile() || entry.isSymbolicLink()) &&
|
|
path.extname(entry.name).toLowerCase() === ".md" &&
|
|
!["README.md", "SECURITY.md"].includes(entry.name)
|
|
);
|
|
}
|
|
|
|
function shouldFilter(entry, relativePath) {
|
|
const parts = relativePath.split(path.sep).filter(Boolean);
|
|
const topLevel = parts[0];
|
|
if (filteredTopLevel.includes(topLevel)) return true;
|
|
if (parts.some((part) => filteredDirectoryNames.includes(part))) return true;
|
|
if (topLevel === ".forgejo" && !includeForgejoWorkflows) return true;
|
|
if (isFilteredRootMarkdown(relativePath, entry)) return true;
|
|
return false;
|
|
}
|
|
|
|
function copyFilteredTree(src, dest, ignoredSourcePaths, relative = "") {
|
|
ensureDir(dest);
|
|
const entries = fs
|
|
.readdirSync(src, { withFileTypes: true })
|
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
|
|
for (const entry of entries) {
|
|
const childRelative = relative ? path.join(relative, entry.name) : entry.name;
|
|
if (
|
|
shouldFilter(entry, childRelative) ||
|
|
isGitIgnored(childRelative, ignoredSourcePaths)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const from = path.join(src, entry.name);
|
|
const to = path.join(dest, entry.name);
|
|
if (entry.isDirectory()) {
|
|
copyFilteredTree(from, to, ignoredSourcePaths, childRelative);
|
|
} else if (entry.isSymbolicLink()) {
|
|
fs.symlinkSync(fs.readlinkSync(from), to);
|
|
} else if (entry.isFile()) {
|
|
fs.copyFileSync(from, to);
|
|
fs.chmodSync(to, fs.statSync(from).mode & 0o777);
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertFilteredTree(ignoredSourcePaths) {
|
|
for (const excluded of ["private", "internal", "experiments"]) {
|
|
if (fs.existsSync(path.join(publicTree, excluded))) {
|
|
throw new Error(`${excluded}/ leaked into the public tree`);
|
|
}
|
|
}
|
|
for (const file of walkFiles(publicTree)) {
|
|
if (file.split(path.sep).includes(".clusterflux")) {
|
|
throw new Error(`generated .clusterflux view state leaked into the public tree: ${file}`);
|
|
}
|
|
}
|
|
if (!includeForgejoWorkflows && fs.existsSync(path.join(publicTree, ".forgejo"))) {
|
|
throw new Error(".forgejo/ leaked into the host-neutral public tree");
|
|
}
|
|
if (!fs.existsSync(path.join(publicTree, "README.md"))) {
|
|
throw new Error("product README.md is missing from the public tree");
|
|
}
|
|
for (const ignored of ignoredSourcePaths) {
|
|
if (fs.existsSync(path.join(publicTree, ...ignored.split("/")))) {
|
|
throw new Error(`Git-ignored source path leaked into the public tree: ${ignored}`);
|
|
}
|
|
}
|
|
for (const entry of fs.readdirSync(publicTree, { withFileTypes: true })) {
|
|
if (isFilteredRootMarkdown(entry.name, entry)) {
|
|
throw new Error(`internal root Markdown file leaked into the public tree: ${entry.name}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function walkFiles(root, relative = "") {
|
|
const dir = path.join(root, relative);
|
|
const entries = fs
|
|
.readdirSync(dir, { withFileTypes: true })
|
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
const files = [];
|
|
for (const entry of entries) {
|
|
const childRelative = relative ? path.join(relative, entry.name) : entry.name;
|
|
if (entry.isDirectory()) {
|
|
files.push(...walkFiles(root, childRelative));
|
|
} else if (entry.isFile()) {
|
|
files.push(childRelative);
|
|
} else if (entry.isSymbolicLink()) {
|
|
files.push(childRelative);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function hashTree(root) {
|
|
const hash = crypto.createHash("sha256");
|
|
for (const file of walkFiles(root)) {
|
|
const absolute = path.join(root, file);
|
|
const stat = fs.lstatSync(absolute);
|
|
hash.update(file.replaceAll(path.sep, "/"));
|
|
hash.update("\0");
|
|
hash.update(String(stat.mode & 0o777));
|
|
hash.update("\0");
|
|
if (stat.isSymbolicLink()) {
|
|
hash.update("symlink");
|
|
hash.update("\0");
|
|
hash.update(fs.readlinkSync(absolute));
|
|
} else {
|
|
hash.update(fs.readFileSync(absolute));
|
|
}
|
|
hash.update("\0");
|
|
}
|
|
return `sha256:${hash.digest("hex")}`;
|
|
}
|
|
|
|
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 sourceTreeDigest(sourceCommit) {
|
|
const archive = cp.execFileSync("git", ["archive", "--format=tar", sourceCommit], {
|
|
cwd: repo,
|
|
encoding: null,
|
|
maxBuffer: 128 * 1024 * 1024,
|
|
stdio: ["ignore", "pipe", "inherit"],
|
|
env: nonInteractiveGitEnv(),
|
|
});
|
|
return `sha256:${sha256Buffer(archive)}`;
|
|
}
|
|
|
|
function evidenceIdentity(value) {
|
|
const serialized = JSON.stringify(value === undefined ? null : value);
|
|
return `sha256:${sha256Buffer(Buffer.from(serialized, "utf8"))}`;
|
|
}
|
|
|
|
function sanitizeEvidence(value, key = "") {
|
|
const secretKey = /(?:token|secret|password|cookie|authorization|private_key)/iu.test(
|
|
key
|
|
);
|
|
if (secretKey && typeof value === "string") return "[redacted]";
|
|
if (secretKey && Array.isArray(value)) return ["[redacted]"];
|
|
if (Array.isArray(value)) {
|
|
return value.map((entry) => sanitizeEvidence(entry));
|
|
}
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([childKey, childValue]) => [
|
|
childKey,
|
|
sanitizeEvidence(childValue, childKey),
|
|
])
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function renderFinalTranscript(result) {
|
|
const lines = [
|
|
"Clusterflux final release transcript",
|
|
`Source commit: ${result.source_commit}`,
|
|
`Source tree digest: ${result.source_tree_digest}`,
|
|
`Public tree identity: ${result.public_tree_identity}`,
|
|
`Acceptance result recorded: ${result.timestamps.acceptance_result_recorded_at}`,
|
|
`Evidence packaged: ${result.timestamps.evidence_packaged_at}`,
|
|
`Deployment generation: ${result.deployment.system_generation}`,
|
|
`Deployment identity: ${result.deployment.identity}`,
|
|
`Configuration identity: ${result.configuration_identity}`,
|
|
"",
|
|
"Commands:",
|
|
...result.release_commands.map((command) => `- ${command}`),
|
|
"",
|
|
"Binary digests:",
|
|
...Object.entries(result.binary_digests).map(
|
|
([name, digest]) => `- ${name}: ${digest}`
|
|
),
|
|
"",
|
|
"Named scenarios:",
|
|
...result.named_scenarios.map(
|
|
(scenario) =>
|
|
`- ${scenario.id}: ${scenario.status} (${scenario.duration_ms} ms)`
|
|
),
|
|
"",
|
|
"Strict requirement ledger:",
|
|
...result.strict_requirement_ledger.map(
|
|
(requirement) =>
|
|
`- ${requirement.id}: ${requirement.passed ? "passed" : "failed"}`
|
|
),
|
|
"",
|
|
`Scenario skips: ${JSON.stringify(result.scenario_skips)}`,
|
|
`Acceptance result: ${result.acceptance_result}`,
|
|
"",
|
|
"Failure-injection and complete machine evidence:",
|
|
JSON.stringify(result, null, 2),
|
|
"",
|
|
];
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
function tarGz(output, cwd, inputs) {
|
|
run("tar", ["-czf", output, "-C", cwd, ...inputs]);
|
|
}
|
|
|
|
function platformName() {
|
|
return `${os.platform()}-${os.arch()}`;
|
|
}
|
|
|
|
function binaryName(name) {
|
|
return process.platform === "win32" ? `${name}.exe` : name;
|
|
}
|
|
|
|
function buildPublicBinaries() {
|
|
run("cargo", ["build", "--locked", "--workspace", "--bins", "--release", "--jobs", "2"], {
|
|
cwd: publicTree,
|
|
env: { ...process.env, CARGO_TARGET_DIR: publicBuildTarget },
|
|
});
|
|
}
|
|
|
|
function buildHostedBinary() {
|
|
run(
|
|
"cargo",
|
|
[
|
|
"build",
|
|
"--locked",
|
|
"--manifest-path",
|
|
"private/hosted-policy/Cargo.toml",
|
|
"--bin",
|
|
hostedBinary,
|
|
"--release",
|
|
"--jobs",
|
|
"2",
|
|
],
|
|
{
|
|
cwd: repo,
|
|
env: { ...process.env, CARGO_TARGET_DIR: hostedBuildTarget },
|
|
}
|
|
);
|
|
}
|
|
|
|
function stageBinaryAssets(releaseName) {
|
|
const stageRoot = path.join(stagingDir, "binaries");
|
|
const binDir = path.join(stageRoot, "bin");
|
|
fs.rmSync(stageRoot, { recursive: true, force: true });
|
|
ensureDir(binDir);
|
|
|
|
for (const binary of publicBinaries) {
|
|
const fileName = binaryName(binary);
|
|
const built = path.join(publicBuildTarget, "release", fileName);
|
|
if (!fs.existsSync(built)) {
|
|
throw new Error(`expected release binary ${built}`);
|
|
}
|
|
const staged = path.join(binDir, fileName);
|
|
fs.copyFileSync(built, staged);
|
|
fs.chmodSync(staged, 0o755);
|
|
}
|
|
|
|
const archive = path.join(
|
|
assetsDir,
|
|
`clusterflux-public-binaries-${releaseName}-${platformName()}.tar.gz`
|
|
);
|
|
tarGz(archive, stageRoot, ["."]);
|
|
return archive;
|
|
}
|
|
|
|
function stageHostedBinaryAsset(releaseName) {
|
|
const stageRoot = path.join(stagingDir, "hosted-binary");
|
|
const binDir = path.join(stageRoot, "bin");
|
|
fs.rmSync(stageRoot, { recursive: true, force: true });
|
|
ensureDir(binDir);
|
|
const fileName = binaryName(hostedBinary);
|
|
const built = path.join(hostedBuildTarget, "release", fileName);
|
|
if (!fs.existsSync(built)) {
|
|
throw new Error(`expected hosted release binary ${built}`);
|
|
}
|
|
const staged = path.join(binDir, fileName);
|
|
fs.copyFileSync(built, staged);
|
|
fs.chmodSync(staged, 0o755);
|
|
const archive = path.join(
|
|
assetsDir,
|
|
`clusterflux-hosted-${releaseName}-${platformName()}.tar.gz`
|
|
);
|
|
tarGz(archive, stageRoot, ["."]);
|
|
return archive;
|
|
}
|
|
|
|
function publicBinaryDigests() {
|
|
return Object.fromEntries(
|
|
publicBinaries.map((binary) => {
|
|
const fileName = binaryName(binary);
|
|
const file = path.join(publicBuildTarget, "release", fileName);
|
|
if (!fs.existsSync(file)) throw new Error(`missing built release binary ${file}`);
|
|
return [fileName, `sha256:${sha256File(file)}`];
|
|
})
|
|
);
|
|
}
|
|
|
|
function candidateBinaryDigests() {
|
|
const fileName = binaryName(hostedBinary);
|
|
const file = path.join(hostedBuildTarget, "release", fileName);
|
|
if (!fs.existsSync(file)) throw new Error(`missing hosted release binary ${file}`);
|
|
return {
|
|
...publicBinaryDigests(),
|
|
[fileName]: `sha256:${sha256File(file)}`,
|
|
};
|
|
}
|
|
|
|
function reuseCandidateBinaries(candidate, releaseName) {
|
|
assertCandidateManifest(candidate);
|
|
const asset = candidate.assets.find((entry) =>
|
|
entry.name.startsWith("clusterflux-public-binaries-")
|
|
);
|
|
if (!asset || !fs.existsSync(asset.file)) {
|
|
throw new Error("release candidate binary archive is missing");
|
|
}
|
|
if (sha256File(asset.file) !== asset.sha256) {
|
|
throw new Error("release candidate binary archive digest changed after candidate creation");
|
|
}
|
|
const archive = path.join(
|
|
assetsDir,
|
|
`clusterflux-public-binaries-${releaseName}-${platformName()}.tar.gz`
|
|
);
|
|
fs.copyFileSync(asset.file, archive);
|
|
const extractRoot = path.join(stagingDir, "candidate-binaries");
|
|
fs.rmSync(extractRoot, { recursive: true, force: true });
|
|
ensureDir(extractRoot);
|
|
run("tar", ["-xzf", archive, "-C", extractRoot]);
|
|
for (const name of publicBinaries.map(binaryName)) {
|
|
const digest = candidate.binary_digests[name];
|
|
const binary = path.join(extractRoot, "bin", name);
|
|
if (!fs.existsSync(binary) || `sha256:${sha256File(binary)}` !== digest) {
|
|
throw new Error(`release candidate binary ${name} does not match ${digest}`);
|
|
}
|
|
}
|
|
return archive;
|
|
}
|
|
|
|
function reuseCandidateHostedBinary(candidate, releaseName) {
|
|
assertCandidateManifest(candidate);
|
|
const asset = candidate.assets.find((entry) =>
|
|
entry.name.startsWith("clusterflux-hosted-")
|
|
);
|
|
if (!asset || !fs.existsSync(asset.file)) {
|
|
throw new Error("release candidate hosted binary archive is missing");
|
|
}
|
|
if (sha256File(asset.file) !== asset.sha256) {
|
|
throw new Error("release candidate hosted archive digest changed after creation");
|
|
}
|
|
const archive = path.join(
|
|
assetsDir,
|
|
`clusterflux-hosted-${releaseName}-${platformName()}.tar.gz`
|
|
);
|
|
fs.copyFileSync(asset.file, archive);
|
|
const extractRoot = path.join(stagingDir, "candidate-hosted-binary");
|
|
fs.rmSync(extractRoot, { recursive: true, force: true });
|
|
ensureDir(extractRoot);
|
|
run("tar", ["-xzf", archive, "-C", extractRoot]);
|
|
const name = binaryName(hostedBinary);
|
|
const binary = path.join(extractRoot, "bin", name);
|
|
const digest = candidate.binary_digests[name];
|
|
if (!fs.existsSync(binary) || `sha256:${sha256File(binary)}` !== digest) {
|
|
throw new Error(`release candidate hosted binary ${name} does not match ${digest}`);
|
|
}
|
|
return archive;
|
|
}
|
|
|
|
function reuseCandidateExtension(candidate) {
|
|
assertCandidateManifest(candidate);
|
|
const asset = candidate.assets.find((entry) => entry.name.endsWith(".vsix"));
|
|
if (!asset || !fs.existsSync(asset.file)) {
|
|
throw new Error("release candidate VSIX is missing");
|
|
}
|
|
const digest = sha256File(asset.file);
|
|
if (
|
|
digest !== asset.sha256 ||
|
|
digest !== candidate.release_candidate.extension_sha256
|
|
) {
|
|
throw new Error("release candidate VSIX digest changed after candidate creation");
|
|
}
|
|
const archive = path.join(assetsDir, asset.name);
|
|
fs.copyFileSync(asset.file, archive);
|
|
return archive;
|
|
}
|
|
|
|
function assertCandidateManifest(candidate) {
|
|
if (candidate.kind !== "clusterflux-public-release") {
|
|
throw new Error("release candidate manifest has the wrong kind");
|
|
}
|
|
if (candidate.release_candidate?.mode !== "built-once") {
|
|
throw new Error("release finalization requires a built-once candidate manifest");
|
|
}
|
|
if (!candidate.binary_digests || !Object.keys(candidate.binary_digests).length) {
|
|
throw new Error("release candidate manifest omitted binary digests");
|
|
}
|
|
if (!candidate.binary_digests[binaryName(hostedBinary)]) {
|
|
throw new Error("release candidate omitted the hosted service binary digest");
|
|
}
|
|
if (!candidate.release_candidate.hosted_binary_archive_sha256) {
|
|
throw new Error("release candidate omitted the hosted service archive digest");
|
|
}
|
|
if (!candidate.release_candidate.extension_sha256) {
|
|
throw new Error("release candidate omitted the VSIX digest");
|
|
}
|
|
}
|
|
|
|
function stageEvidenceAsset(
|
|
releaseName,
|
|
sourceCommit,
|
|
sourceDigest,
|
|
publicTreeIdentity,
|
|
binaryDigests,
|
|
candidateBinding,
|
|
candidateConfigurationIdentity,
|
|
candidateProxyConfigurationIdentity
|
|
) {
|
|
const stage = path.join(stagingDir, "evidence");
|
|
fs.rmSync(stage, { recursive: true, force: true });
|
|
ensureDir(stage);
|
|
const evidenceRoot = path.join(repo, "target/acceptance");
|
|
const included = [];
|
|
for (const name of ["public-environment.json", "wasmtime-assignment.json"]) {
|
|
const source = path.join(evidenceRoot, name);
|
|
if (!fs.existsSync(source)) continue;
|
|
fs.copyFileSync(source, path.join(stage, name));
|
|
included.push(name);
|
|
}
|
|
const acceptancePath = path.resolve(
|
|
process.env.CLUSTERFLUX_FINAL_RESULT_PATH ||
|
|
path.join(evidenceRoot, "cli-happy-path-live.json")
|
|
);
|
|
const legacyIncompleteEvidenceEnv = [
|
|
"CLUSTERFLUX",
|
|
"ALLOW",
|
|
"INCOMPLETE",
|
|
"RELEASE",
|
|
"EVIDENCE",
|
|
].join("_");
|
|
if (process.env[legacyIncompleteEvidenceEnv]) {
|
|
throw new Error(
|
|
"the legacy incomplete-evidence environment switch is unsupported; use CLUSTERFLUX_RELEASE_STAGE=candidate or final"
|
|
);
|
|
}
|
|
const releaseStage =
|
|
process.env.CLUSTERFLUX_RELEASE_STAGE ||
|
|
(candidateManifestPath ? "final" : "candidate");
|
|
if (!new Set(["candidate", "final"]).has(releaseStage)) {
|
|
throw new Error("CLUSTERFLUX_RELEASE_STAGE must be candidate or final");
|
|
}
|
|
const allowIncomplete = releaseStage === "candidate";
|
|
let finalEvidence = {
|
|
complete: false,
|
|
result: null,
|
|
transcript: null,
|
|
acceptance_result_recorded_at: null,
|
|
deployment_identity: null,
|
|
configuration_identity: null,
|
|
};
|
|
|
|
if (fs.existsSync(acceptancePath)) {
|
|
const liveResult = JSON.parse(fs.readFileSync(acceptancePath, "utf8"));
|
|
if (liveResult.source_commit === sourceCommit) {
|
|
if (liveResult.acceptance_result !== "passed") {
|
|
throw new Error("final live acceptance result is not passed");
|
|
}
|
|
if (liveResult.source_tree_digest !== sourceDigest) {
|
|
throw new Error("final live acceptance source-tree digest does not match the candidate");
|
|
}
|
|
if (liveResult.public_tree_identity !== publicTreeIdentity) {
|
|
throw new Error("final live acceptance public-tree identity does not match the candidate");
|
|
}
|
|
if (
|
|
JSON.stringify(liveResult.binary_digests) !== JSON.stringify(binaryDigests) ||
|
|
JSON.stringify(liveResult.release_binding?.binary_digests) !==
|
|
JSON.stringify(binaryDigests)
|
|
) {
|
|
throw new Error("final live acceptance binaries do not match the exact candidate");
|
|
}
|
|
if (liveResult.release_binding?.source_commit !== sourceCommit) {
|
|
throw new Error("final live acceptance release binding has the wrong source commit");
|
|
}
|
|
if (!Array.isArray(liveResult.scenario_skips) || liveResult.scenario_skips.length) {
|
|
throw new Error("final live acceptance result contains unresolved scenario skips");
|
|
}
|
|
if (
|
|
!Array.isArray(liveResult.named_scenarios) ||
|
|
liveResult.named_scenarios.length !== 25 ||
|
|
liveResult.named_scenarios.some((scenario) => scenario.status !== "passed")
|
|
) {
|
|
throw new Error("all twenty-five named final live checks must pass");
|
|
}
|
|
if (
|
|
!liveResult.release_binding?.deployment ||
|
|
!liveResult.release_binding?.configuration ||
|
|
!liveResult.release_binding?.proxy_configuration
|
|
) {
|
|
throw new Error(
|
|
"final live evidence must bind deployment, runtime configuration, and proxy configuration identities"
|
|
);
|
|
}
|
|
if (
|
|
!Array.isArray(liveResult.strict_requirement_ledger) ||
|
|
!liveResult.strict_requirement_ledger.length ||
|
|
liveResult.strict_requirement_ledger.some((requirement) => requirement.passed !== true)
|
|
) {
|
|
throw new Error("the strict final requirement ledger must be complete and passed");
|
|
}
|
|
const deploymentGeneration =
|
|
process.env.CLUSTERFLUX_DEPLOYMENT_SYSTEM_GENERATION ||
|
|
liveResult.release_binding?.deployment?.system_generation ||
|
|
null;
|
|
if (!deploymentGeneration && !allowIncomplete) {
|
|
throw new Error(
|
|
"CLUSTERFLUX_DEPLOYMENT_SYSTEM_GENERATION is required for final evidence"
|
|
);
|
|
}
|
|
const recordedAt = fs.statSync(acceptancePath).mtime.toISOString();
|
|
const packagedAt = new Date().toISOString();
|
|
const sanitized = sanitizeEvidence(liveResult);
|
|
const finalResult = {
|
|
...sanitized,
|
|
kind: "clusterflux-final-release-result",
|
|
source_commit: sourceCommit,
|
|
source_tree_digest: sourceDigest,
|
|
public_tree_identity: publicTreeIdentity,
|
|
binary_digests: binaryDigests,
|
|
release_candidate: candidateBinding,
|
|
deployment: {
|
|
system_generation: deploymentGeneration,
|
|
identity: evidenceIdentity(liveResult.release_binding?.deployment),
|
|
},
|
|
configuration_identity: evidenceIdentity(
|
|
liveResult.release_binding?.configuration
|
|
),
|
|
timestamps: {
|
|
acceptance_result_recorded_at: recordedAt,
|
|
evidence_packaged_at: packagedAt,
|
|
},
|
|
release_commands: [
|
|
"nix develop -c ./scripts/acceptance-private.sh",
|
|
"nix develop -c ./scripts/acceptance-public.sh",
|
|
"node scripts/cli-happy-path-live-smoke.js (strict production-shaped environment)",
|
|
],
|
|
};
|
|
const resultName = "FINAL_RELEASE_RESULT.json";
|
|
const transcriptName = "FINAL_RELEASE_TRANSCRIPT.txt";
|
|
const resultPath = path.join(stage, resultName);
|
|
const transcriptPath = path.join(stage, transcriptName);
|
|
fs.writeFileSync(resultPath, `${JSON.stringify(finalResult, null, 2)}\n`);
|
|
fs.writeFileSync(transcriptPath, renderFinalTranscript(finalResult));
|
|
included.push(resultName, transcriptName);
|
|
finalEvidence = {
|
|
complete: true,
|
|
result: {
|
|
name: resultName,
|
|
sha256: sha256File(resultPath),
|
|
},
|
|
transcript: {
|
|
name: transcriptName,
|
|
sha256: sha256File(transcriptPath),
|
|
},
|
|
acceptance_result_recorded_at: recordedAt,
|
|
deployment_identity: finalResult.deployment.identity,
|
|
configuration_identity: finalResult.configuration_identity,
|
|
};
|
|
} else if (!allowIncomplete) {
|
|
throw new Error(
|
|
`final live acceptance commit ${liveResult.source_commit} does not match ${sourceCommit}`
|
|
);
|
|
}
|
|
} else if (!allowIncomplete) {
|
|
throw new Error(`missing final live acceptance result: ${acceptancePath}`);
|
|
}
|
|
|
|
const binding = {
|
|
kind: "clusterflux-release-evidence-binding",
|
|
source_commit: sourceCommit,
|
|
source_tree_digest: sourceDigest,
|
|
public_tree_identity: publicTreeIdentity,
|
|
binary_digests: binaryDigests,
|
|
release_candidate: candidateBinding,
|
|
candidate_configuration_identity: candidateConfigurationIdentity,
|
|
candidate_proxy_configuration_identity: candidateProxyConfigurationIdentity,
|
|
configuration_generation:
|
|
process.env.CLUSTERFLUX_DEPLOYMENT_SYSTEM_GENERATION || null,
|
|
final_evidence: finalEvidence,
|
|
included_evidence: included.map((name) => ({
|
|
name,
|
|
sha256: sha256File(path.join(stage, name)),
|
|
})),
|
|
};
|
|
fs.writeFileSync(
|
|
path.join(stage, "EVIDENCE_BINDING.json"),
|
|
`${JSON.stringify(binding, null, 2)}\n`
|
|
);
|
|
const archive = path.join(
|
|
assetsDir,
|
|
`clusterflux-public-evidence-${releaseName}.tar.gz`
|
|
);
|
|
tarGz(archive, stage, ["."]);
|
|
return { archive, finalEvidence };
|
|
}
|
|
|
|
function stageSourceAsset(releaseName) {
|
|
const archive = path.join(assetsDir, `clusterflux-public-source-${releaseName}.tar.gz`);
|
|
tarGz(archive, publicTree, ["."]);
|
|
return archive;
|
|
}
|
|
|
|
function stageExtensionAsset() {
|
|
const extensionRoot = path.join(publicTree, "vscode-extension");
|
|
const packageJson = JSON.parse(
|
|
fs.readFileSync(path.join(extensionRoot, "package.json"), "utf8")
|
|
);
|
|
const archive = path.join(
|
|
assetsDir,
|
|
`${packageJson.name}-${packageJson.version}.vsix`
|
|
);
|
|
run("npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], {
|
|
cwd: extensionRoot,
|
|
});
|
|
run(
|
|
path.join(extensionRoot, "node_modules", ".bin", "vsce"),
|
|
[
|
|
"package",
|
|
"--allow-missing-repository",
|
|
"--skip-license",
|
|
"--out",
|
|
archive,
|
|
],
|
|
{ cwd: extensionRoot }
|
|
);
|
|
run("node", ["scripts/vscode-extension-smoke.js"], { cwd: publicTree });
|
|
run("node", ["scripts/vscode-f5-smoke.js"], { cwd: publicTree });
|
|
return archive;
|
|
}
|
|
|
|
function resolverInstructions() {
|
|
if (process.env.CLUSTERFLUX_PUBLIC_RELEASE_RESOLVER_INSTRUCTIONS) {
|
|
return process.env.CLUSTERFLUX_PUBLIC_RELEASE_RESOLVER_INSTRUCTIONS.trim();
|
|
}
|
|
if (process.env.CLUSTERFLUX_PUBLIC_RELEASE_HOSTS_ENTRY) {
|
|
return [
|
|
"Add the controlled hosts entry supplied for this release:",
|
|
"",
|
|
"```",
|
|
process.env.CLUSTERFLUX_PUBLIC_RELEASE_HOSTS_ENTRY.trim(),
|
|
"```",
|
|
].join("\n");
|
|
}
|
|
if (process.env.CLUSTERFLUX_PUBLIC_RELEASE_IP) {
|
|
return [
|
|
"Add this controlled hosts entry for the release:",
|
|
"",
|
|
"```",
|
|
`${process.env.CLUSTERFLUX_PUBLIC_RELEASE_IP} clusterflux.michelpaulissen.com`,
|
|
"```",
|
|
].join("\n");
|
|
}
|
|
return [
|
|
"`clusterflux.michelpaulissen.com` should resolve through public DNS. If it",
|
|
"does not resolve yet, wait for DNS propagation or use the fallback hosts",
|
|
"entry supplied with the invitation.",
|
|
].join("\n");
|
|
}
|
|
|
|
function writeGettingStartedAsset(releaseName, publicTreeIdentity, resolution) {
|
|
const file = path.join(assetsDir, `CLUSTERFLUX_GETTING_STARTED-${releaseName}.md`);
|
|
fs.writeFileSync(
|
|
file,
|
|
`# Clusterflux Getting Started
|
|
|
|
Use the public repository and release downloads with the hosted coordinator at
|
|
\`https://clusterflux.michelpaulissen.com\`.
|
|
|
|
## DNS
|
|
|
|
${resolution}
|
|
|
|
## Install
|
|
|
|
1. Download the binary archive for your platform from the Forgejo release.
|
|
2. Check the archive against \`SHA256SUMS\`.
|
|
3. Extract it and put \`bin/\` on your \`PATH\`.
|
|
4. Install \`clusterflux-vscode-*.vsix\` when you want the VS Code debugger:
|
|
|
|
\`\`\`bash
|
|
code --install-extension clusterflux-vscode-*.vsix
|
|
\`\`\`
|
|
|
|
## Sign in and run
|
|
|
|
\`\`\`bash
|
|
clusterflux login --browser
|
|
clusterflux auth status
|
|
clusterflux project list
|
|
clusterflux bundle inspect --project examples/hello-build
|
|
\`\`\`
|
|
|
|
The CLI opens the server-provided Authentik authorization URL. State, nonce,
|
|
PKCE, provider code exchange, and userinfo remain on the hosted service. The CLI
|
|
stores only the resulting scoped Clusterflux session.
|
|
|
|
Create a node enrollment grant, attach the node, and start the worker:
|
|
|
|
\`\`\`bash
|
|
clusterflux node enroll --project-id <project-id> --json
|
|
clusterflux node attach --project-id <project-id> --node workstation \\
|
|
--enrollment-grant "$ENROLLMENT_GRANT"
|
|
clusterflux-node --coordinator https://clusterflux.michelpaulissen.com \\
|
|
--tenant <tenant> --project-id <project-id> --node workstation \\
|
|
--project-root "$PWD" --worker --emit-ready
|
|
\`\`\`
|
|
|
|
Then run the workflow:
|
|
|
|
\`\`\`bash
|
|
clusterflux run --project examples/hello-build build
|
|
\`\`\`
|
|
|
|
Public tree identity: \`${publicTreeIdentity}\`
|
|
Release name: \`${releaseName}\`
|
|
`,
|
|
"utf8"
|
|
);
|
|
return file;
|
|
}
|
|
|
|
function writeReleaseNotesAsset(releaseName, publicTreeIdentity, resolution) {
|
|
const file = path.join(assetsDir, `CLUSTERFLUX_RELEASE_NOTES-${releaseName}.md`);
|
|
const publicRepo =
|
|
process.env.CLUSTERFLUX_PUBLIC_REPO_URL ||
|
|
"https://git.michelpaulissen.com/michel/clusterflux-public";
|
|
const releaseUrl =
|
|
process.env.CLUSTERFLUX_FORGEJO_RELEASE_URL ||
|
|
"the Forgejo release attached to the public repository";
|
|
fs.writeFileSync(
|
|
file,
|
|
`# Clusterflux Release Notes
|
|
|
|
Use this release with the public Clusterflux repository and hosted coordinator.
|
|
|
|
## Links
|
|
|
|
- Public repository: ${publicRepo}
|
|
- Release downloads: ${releaseUrl}
|
|
- Hosted coordinator: ${defaultHostedCoordinatorEndpoint}
|
|
- Public tree identity: ${publicTreeIdentity}
|
|
- Release name: ${releaseName}
|
|
|
|
## DNS
|
|
|
|
${resolution}
|
|
|
|
## First run
|
|
|
|
1. Download the archive for your platform and \`SHA256SUMS\`.
|
|
2. Extract the archive and put \`bin/\` on your \`PATH\`.
|
|
3. Optionally install the VS Code extension archive.
|
|
4. Run \`clusterflux login --browser\`.
|
|
5. Run \`clusterflux node enroll\`, attach your node, and start the worker.
|
|
6. Run \`clusterflux run --project examples/hello-build build\`.
|
|
`,
|
|
"utf8"
|
|
);
|
|
return file;
|
|
}
|
|
|
|
function writeSha256Sums(assets) {
|
|
const sumsPath = path.join(assetsDir, "SHA256SUMS");
|
|
const lines = assets
|
|
.map((asset) => `${sha256File(asset)} ${path.basename(asset)}`)
|
|
.sort()
|
|
.join("\n");
|
|
fs.writeFileSync(sumsPath, `${lines}\n`);
|
|
return sumsPath;
|
|
}
|
|
|
|
function boolEnv(name) {
|
|
return /^(1|true|yes)$/i.test(process.env[name] || "");
|
|
}
|
|
|
|
function writePublicTreeProvenance(sourceCommit, releaseName) {
|
|
const provenance = {
|
|
kind: "clusterflux-filtered-public-tree",
|
|
source_commit: sourceCommit,
|
|
release_name: releaseName,
|
|
filtered_out: filteredOutPatterns(),
|
|
public_export: {
|
|
host_neutral: !includeForgejoWorkflows,
|
|
include_forgejo_workflows: includeForgejoWorkflows,
|
|
},
|
|
forgejo_host: forgejoHost,
|
|
default_hosted_coordinator_endpoint: defaultHostedCoordinatorEndpoint,
|
|
};
|
|
fs.writeFileSync(
|
|
path.join(publicTree, "CLUSTERFLUX_PUBLIC_TREE.json"),
|
|
`${JSON.stringify(provenance, null, 2)}\n`
|
|
);
|
|
}
|
|
|
|
function publishPublicTree(releaseName, sourceCommit, publicTreeIdentity) {
|
|
const remote =
|
|
process.env.CLUSTERFLUX_PUBLIC_REPO_REMOTE ||
|
|
process.env.CLUSTERFLUX_PUBLIC_REPO_URL ||
|
|
null;
|
|
const enabled = boolEnv("CLUSTERFLUX_PUBLISH_PUBLIC_TREE");
|
|
const result = {
|
|
enabled,
|
|
remote,
|
|
branch: publicRepoBranch,
|
|
commit: null,
|
|
pushed: false,
|
|
};
|
|
|
|
if (!enabled) {
|
|
return result;
|
|
}
|
|
if (!remote) {
|
|
throw new Error(
|
|
"CLUSTERFLUX_PUBLISH_PUBLIC_TREE requires CLUSTERFLUX_PUBLIC_REPO_REMOTE or CLUSTERFLUX_PUBLIC_REPO_URL"
|
|
);
|
|
}
|
|
if (!remote.includes(forgejoHost)) {
|
|
throw new Error(`public repo remote must point at ${forgejoHost}: ${remote}`);
|
|
}
|
|
|
|
run("git", ["init"], { cwd: publicTree });
|
|
run("git", ["config", "user.name", "Clusterflux release"], {
|
|
cwd: publicTree,
|
|
});
|
|
run("git", ["config", "user.email", "release@clusterflux.invalid"], {
|
|
cwd: publicTree,
|
|
});
|
|
run("git", ["add", "."], { cwd: publicTree });
|
|
const tree = commandOutput("git", ["write-tree"], { cwd: publicTree });
|
|
run("git", ["remote", "add", "public", remote], { cwd: publicTree });
|
|
run(
|
|
"git",
|
|
[
|
|
"fetch",
|
|
"public",
|
|
`refs/heads/${publicRepoBranch}:refs/remotes/public/${publicRepoBranch}`,
|
|
],
|
|
{ cwd: publicTree }
|
|
);
|
|
const parent = commandOutput(
|
|
"git",
|
|
["rev-parse", `refs/remotes/public/${publicRepoBranch}`],
|
|
{ cwd: publicTree }
|
|
);
|
|
result.commit = commandOutput(
|
|
"git",
|
|
[
|
|
"commit-tree",
|
|
tree,
|
|
"-p",
|
|
parent,
|
|
"-m",
|
|
`Public release ${releaseName}`,
|
|
"-m",
|
|
`Source commit: ${sourceCommit}`,
|
|
"-m",
|
|
`Public tree identity: ${publicTreeIdentity}`,
|
|
],
|
|
{ cwd: publicTree }
|
|
);
|
|
run("git", ["update-ref", `refs/heads/${publicRepoBranch}`, result.commit], {
|
|
cwd: publicTree,
|
|
});
|
|
run("git", ["push", "public", `${result.commit}:${publicRepoBranch}`], {
|
|
cwd: publicTree,
|
|
});
|
|
result.pushed = true;
|
|
return result;
|
|
}
|
|
|
|
function main() {
|
|
const sourceCommit = commandOutput("git", ["rev-parse", "HEAD"]);
|
|
if (!sourceCommit || !/^[0-9a-f]{40}$/u.test(sourceCommit)) {
|
|
throw new Error("the public release requires an exact Git HEAD object id");
|
|
}
|
|
const acceptanceCommit = process.env.CLUSTERFLUX_ACCEPTANCE_COMMIT?.trim();
|
|
if (acceptanceCommit && acceptanceCommit !== sourceCommit) {
|
|
throw new Error(
|
|
`CLUSTERFLUX_ACCEPTANCE_COMMIT ${acceptanceCommit} does not match source HEAD ${sourceCommit}`
|
|
);
|
|
}
|
|
const shortCommit = sourceCommit.slice(0, 12);
|
|
const releaseName = process.env.CLUSTERFLUX_PUBLIC_RELEASE_NAME || `release-${shortCommit}`;
|
|
const sourceDigest = sourceTreeDigest(sourceCommit);
|
|
const candidate = candidateManifestPath
|
|
? JSON.parse(fs.readFileSync(candidateManifestPath, "utf8"))
|
|
: null;
|
|
if (candidate) {
|
|
for (const asset of candidate.assets ?? []) {
|
|
asset.file = path.isAbsolute(asset.file)
|
|
? asset.file
|
|
: path.resolve(path.dirname(candidateManifestPath), asset.file);
|
|
}
|
|
}
|
|
const releaseStage =
|
|
process.env.CLUSTERFLUX_RELEASE_STAGE ||
|
|
(candidateManifestPath ? "final" : "candidate");
|
|
if (releaseStage === "final" && !candidate) {
|
|
throw new Error("final release stage requires CLUSTERFLUX_RELEASE_CANDIDATE_MANIFEST");
|
|
}
|
|
if (releaseStage === "candidate" && candidate) {
|
|
throw new Error("candidate release stage cannot consume a prior candidate manifest");
|
|
}
|
|
if (
|
|
releaseStage === "candidate" &&
|
|
(!process.env.CLUSTERFLUX_CANDIDATE_CONFIGURATION_IDENTITY ||
|
|
!process.env.CLUSTERFLUX_CANDIDATE_PROXY_CONFIGURATION_IDENTITY)
|
|
) {
|
|
throw new Error(
|
|
"candidate release stage requires configuration and proxy configuration identities"
|
|
);
|
|
}
|
|
if (candidate) {
|
|
assertCandidateManifest(candidate);
|
|
if (path.dirname(candidateManifestPath) === outputRoot) {
|
|
throw new Error("candidate and finalized release must use different output directories");
|
|
}
|
|
if (candidate.source_commit !== sourceCommit) {
|
|
throw new Error("release candidate source commit does not match Git HEAD");
|
|
}
|
|
if (candidate.source_tree_digest !== sourceDigest) {
|
|
throw new Error("release candidate source tree digest does not match Git HEAD");
|
|
}
|
|
if (candidate.release_name !== releaseName) {
|
|
throw new Error("release candidate name does not match the finalized release name");
|
|
}
|
|
}
|
|
const sourceStatus = commandOutput("git", ["status", "--short"]);
|
|
if (sourceStatus === null) {
|
|
throw new Error("the public release must be prepared from a Git checkout");
|
|
}
|
|
if (sourceStatus !== "") {
|
|
throw new Error("the public release must be prepared from a clean source tree");
|
|
}
|
|
const sourceTreeClean = true;
|
|
|
|
fs.rmSync(outputRoot, { recursive: true, force: true });
|
|
ensureDir(publicTree);
|
|
ensureDir(assetsDir);
|
|
ensureDir(stagingDir);
|
|
|
|
const ignoredSourcePaths = gitIgnoredSourcePaths();
|
|
copyFilteredTree(repo, publicTree, ignoredSourcePaths);
|
|
assertFilteredTree(ignoredSourcePaths);
|
|
writePublicTreeProvenance(sourceCommit, releaseName);
|
|
const publicTreeIdentity = hashTree(publicTree);
|
|
if (candidate && candidate.public_tree_identity !== publicTreeIdentity) {
|
|
throw new Error("release candidate public tree identity changed before finalization");
|
|
}
|
|
const sourceArchive = stageSourceAsset(releaseName);
|
|
|
|
run("scripts/check-old-name.sh", [], { cwd: publicTree });
|
|
run("node", ["scripts/check-docs.js"], { cwd: publicTree });
|
|
run("scripts/check-code-size.sh", [], { cwd: publicTree });
|
|
const publicTreePublish = candidate
|
|
? candidate.public_tree_publish
|
|
: publishPublicTree(releaseName, sourceCommit, publicTreeIdentity);
|
|
let binaryArchive;
|
|
let hostedBinaryArchive;
|
|
let extensionArchive;
|
|
let binaryDigests;
|
|
let candidateBinding;
|
|
if (candidate) {
|
|
binaryArchive = reuseCandidateBinaries(candidate, releaseName);
|
|
hostedBinaryArchive = reuseCandidateHostedBinary(candidate, releaseName);
|
|
extensionArchive = reuseCandidateExtension(candidate);
|
|
binaryDigests = candidate.binary_digests;
|
|
candidateBinding = {
|
|
mode: "finalized-exact",
|
|
manifest: path.relative(outputRoot, candidateManifestPath).split(path.sep).join("/"),
|
|
manifest_sha256: sha256File(candidateManifestPath),
|
|
binary_archive_sha256: sha256File(binaryArchive),
|
|
hosted_binary_archive_sha256: sha256File(hostedBinaryArchive),
|
|
extension_sha256: sha256File(extensionArchive),
|
|
};
|
|
} else {
|
|
buildPublicBinaries();
|
|
buildHostedBinary();
|
|
binaryArchive = stageBinaryAssets(releaseName);
|
|
hostedBinaryArchive = stageHostedBinaryAsset(releaseName);
|
|
extensionArchive = stageExtensionAsset();
|
|
binaryDigests = candidateBinaryDigests();
|
|
candidateBinding = {
|
|
mode: "built-once",
|
|
binary_archive_sha256: sha256File(binaryArchive),
|
|
hosted_binary_archive_sha256: sha256File(hostedBinaryArchive),
|
|
extension_sha256: sha256File(extensionArchive),
|
|
};
|
|
}
|
|
const candidateConfigurationIdentity =
|
|
candidate?.candidate_configuration_identity ||
|
|
process.env.CLUSTERFLUX_CANDIDATE_CONFIGURATION_IDENTITY ||
|
|
null;
|
|
const candidateProxyConfigurationIdentity =
|
|
candidate?.candidate_proxy_configuration_identity ||
|
|
process.env.CLUSTERFLUX_CANDIDATE_PROXY_CONFIGURATION_IDENTITY ||
|
|
null;
|
|
const evidence = stageEvidenceAsset(
|
|
releaseName,
|
|
sourceCommit,
|
|
sourceDigest,
|
|
publicTreeIdentity,
|
|
binaryDigests,
|
|
candidateBinding,
|
|
candidateConfigurationIdentity,
|
|
candidateProxyConfigurationIdentity
|
|
);
|
|
const evidenceArchive = evidence.archive;
|
|
const resolution = resolverInstructions();
|
|
const gettingStarted = writeGettingStartedAsset(
|
|
releaseName,
|
|
publicTreeIdentity,
|
|
resolution
|
|
);
|
|
const invite = writeReleaseNotesAsset(releaseName, publicTreeIdentity, resolution);
|
|
const assets = [
|
|
sourceArchive,
|
|
binaryArchive,
|
|
hostedBinaryArchive,
|
|
evidenceArchive,
|
|
extensionArchive,
|
|
gettingStarted,
|
|
invite,
|
|
];
|
|
const sha256Sums = writeSha256Sums(assets);
|
|
|
|
const manifest = {
|
|
kind: "clusterflux-public-release",
|
|
release_name: releaseName,
|
|
source_commit: sourceCommit,
|
|
source_tree_digest: sourceDigest,
|
|
source_tree_clean: sourceTreeClean,
|
|
public_tree_identity: publicTreeIdentity,
|
|
public_tree: publicTree,
|
|
filtered_out: filteredOutPatterns(),
|
|
public_export: {
|
|
host_neutral: !includeForgejoWorkflows,
|
|
include_forgejo_workflows: includeForgejoWorkflows,
|
|
},
|
|
forgejo_host: forgejoHost,
|
|
public_repo_url:
|
|
process.env.CLUSTERFLUX_PUBLIC_REPO_URL ||
|
|
candidate?.public_repo_url ||
|
|
publicTreePublish.remote,
|
|
public_repo_remote:
|
|
process.env.CLUSTERFLUX_PUBLIC_REPO_REMOTE || candidate?.public_repo_remote || null,
|
|
public_tree_publish: publicTreePublish,
|
|
forgejo_release_url: process.env.CLUSTERFLUX_FORGEJO_RELEASE_URL || null,
|
|
default_hosted_coordinator_endpoint: defaultHostedCoordinatorEndpoint,
|
|
dns_publication_state:
|
|
process.env.CLUSTERFLUX_DNS_PUBLICATION_STATE || "published",
|
|
resolver_override:
|
|
process.env.CLUSTERFLUX_RESOLVER_OVERRIDE || "none-required-public-dns",
|
|
platform: platformName(),
|
|
binary_digests: binaryDigests,
|
|
release_candidate: candidateBinding,
|
|
candidate_configuration_identity: candidateConfigurationIdentity,
|
|
candidate_proxy_configuration_identity: candidateProxyConfigurationIdentity,
|
|
configuration_generation:
|
|
process.env.CLUSTERFLUX_DEPLOYMENT_SYSTEM_GENERATION || null,
|
|
final_evidence: evidence.finalEvidence,
|
|
tool_versions: {
|
|
node: process.version,
|
|
rustc: commandOutput("rustc", ["--version"]) || null,
|
|
cargo: commandOutput("cargo", ["--version"]) || null,
|
|
tar: commandOutput("tar", ["--version"]) || null,
|
|
},
|
|
commands: [
|
|
"scripts/check-old-name.sh",
|
|
"node scripts/check-docs.js",
|
|
"scripts/check-code-size.sh",
|
|
...(publicTreePublish.enabled
|
|
? [`git push public HEAD:${publicRepoBranch}`]
|
|
: []),
|
|
...(candidate
|
|
? ["finalize exact public and hosted binaries from CLUSTERFLUX_RELEASE_CANDIDATE_MANIFEST"]
|
|
: [
|
|
"cargo build --locked --workspace --bins --release --jobs 2",
|
|
"cargo build --locked --manifest-path private/hosted-policy/Cargo.toml --bin clusterflux-hosted-service --release --jobs 2",
|
|
]),
|
|
],
|
|
assets: [...assets, sha256Sums].map((asset) => ({
|
|
file: path.relative(outputRoot, asset).split(path.sep).join("/"),
|
|
name: path.basename(asset),
|
|
sha256: sha256File(asset),
|
|
})),
|
|
notes: [
|
|
"Upload the assets to the Forgejo Release for the filtered public repository.",
|
|
"The real service deployment and full e2e release remain separate acceptance evidence.",
|
|
],
|
|
};
|
|
|
|
const manifestPath = path.join(outputRoot, "public-release-manifest.json");
|
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
console.log(JSON.stringify({ manifest: manifestPath, assets: assetsDir }, null, 2));
|
|
}
|
|
|
|
main();
|