clusterflux-public/scripts/prepare-public-release.js
Clusterflux release f2ab484cb2 Public release release-020d19c19c9c
Source commit: 020d19c19c9c6b75314e0044cade95cee035ba1c

Public tree identity: sha256:e38aa07291caddab6e0707e002dae9d19d3643aabe9b5770dbd450c60dbd1030
2026-07-20 14:53:13 +02:00

1224 lines
40 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 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");
}
}
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 packageJson = JSON.parse(
fs.readFileSync(path.join(publicTree, "vscode-extension/package.json"), "utf8")
);
const archive = path.join(
assetsDir,
`${packageJson.name}-${packageJson.version}.vsix`
);
run(
"npx",
[
"--yes",
"@vscode/vsce",
"package",
"--allow-missing-repository",
"--skip-license",
"--out",
archive,
],
{ cwd: path.join(publicTree, "vscode-extension") }
);
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 binaryDigests;
let candidateBinding;
if (candidate) {
binaryArchive = reuseCandidateBinaries(candidate, releaseName);
hostedBinaryArchive = reuseCandidateHostedBinary(candidate, releaseName);
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),
};
} else {
buildPublicBinaries();
buildHostedBinary();
binaryArchive = stageBinaryAssets(releaseName);
hostedBinaryArchive = stageHostedBinaryAsset(releaseName);
binaryDigests = candidateBinaryDigests();
candidateBinding = {
mode: "built-once",
binary_archive_sha256: sha256File(binaryArchive),
hosted_binary_archive_sha256: sha256File(hostedBinaryArchive),
};
}
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 extensionArchive = stageExtensionAsset();
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();