clusterflux-public/scripts/prepare-public-release-dryrun.js
Disasmer release dry run 8b11b88146 Public dry run dryrun-8bfdd8cef231
Source commit: 8bfdd8cef23135fc1d8c9f3d74c54444b500cca8

Public tree identity: sha256:d501c4af6dfe7b1b52000bc1f5c256e001440f81f3c1e00e3615a8a84da21693
2026-07-01 15:35:27 +02:00

500 lines
15 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.DISASMER_PUBLIC_RELEASE_DIR ||
path.join(repo, "target/public-release-dryrun")
);
const publicTree = path.join(outputRoot, "public-tree");
const assetsDir = path.join(outputRoot, "assets");
const stagingDir = path.join(outputRoot, "staging");
const defaultOperatorEndpoint = "https://disasmer.michelpaulissen.com";
const forgejoHost = "git.michelpaulissen.com";
const filteredOut = ["private", "experiments", ".git", "target"];
const publicRepoBranch = process.env.DISASMER_PUBLIC_REPO_BRANCH || "main";
const publicBinaries = [
"disasmer",
"disasmer-coordinator",
"disasmer-node",
"disasmer-debug-dap",
];
function commandOutput(command, args, options = {}) {
try {
return cp
.execFileSync(command, args, {
cwd: repo,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
...options,
})
.trim();
} catch (_) {
return null;
}
}
function run(command, args, options = {}) {
cp.execFileSync(command, args, {
cwd: repo,
stdio: "inherit",
...options,
});
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function copyFilteredTree(src, dest, 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;
const topLevel = childRelative.split(path.sep)[0];
if (filteredOut.includes(topLevel)) {
continue;
}
const from = path.join(src, entry.name);
const to = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyFilteredTree(from, to, 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() {
for (const excluded of ["private", "experiments"]) {
if (fs.existsSync(path.join(publicTree, excluded))) {
throw new Error(`${excluded}/ leaked into the dry-run public tree`);
}
}
}
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 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", "--workspace", "--bins", "--release"], {
cwd: publicTree,
});
}
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(publicTree, "target", "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,
`disasmer-public-binaries-${releaseName}-${platformName()}.tar.gz`
);
tarGz(archive, stageRoot, ["."]);
return archive;
}
function stageSourceAsset(releaseName) {
const archive = path.join(assetsDir, `disasmer-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}.tar.gz`
);
tarGz(archive, publicTree, ["vscode-extension"]);
return archive;
}
function resolverInstructions() {
if (process.env.DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS) {
return process.env.DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS.trim();
}
if (process.env.DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY) {
return [
"Add the controlled hosts entry supplied for this dry run:",
"",
"```",
process.env.DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY.trim(),
"```",
].join("\n");
}
if (process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_IP) {
return [
"Add this controlled hosts entry for the dry run:",
"",
"```",
`${process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_IP} disasmer.michelpaulissen.com`,
"```",
].join("\n");
}
return [
"Use the controlled resolver or hosts entry supplied with the invitation so",
"`disasmer.michelpaulissen.com` points at the deployment host.",
].join("\n");
}
function writeGettingStartedAsset(releaseName, publicTreeIdentity, resolution) {
const file = path.join(assetsDir, `DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-${releaseName}.md`);
fs.writeFileSync(
file,
`# Disasmer Public Dry Run
This dry run is a real Disasmer deployment for selected external users. It is
intentionally not published in public DNS yet.
## Controlled resolution
${resolution}
## Install
1. Download the binary archive for your platform from the Forgejo Release at
\`git.michelpaulissen.com\`.
2. Verify it against \`SHA256SUMS\`.
3. Extract the archive and put the \`bin/\` directory on your \`PATH\`.
4. Download the VS Code extension archive if you want the debugger view.
## Connect
Use the default operator endpoint:
\`\`\`bash
disasmer login --browser
disasmer bundle inspect --project examples/launch-build-demo
\`\`\`
Attach your own node to the operator address supplied by the invitation:
\`\`\`bash
disasmer node attach --coordinator disasmer.michelpaulissen.com:443 --enrollment-grant <grant> --public-key <public-key>
\`\`\`
Then run the flagship workflow from the filtered public repository:
\`\`\`bash
disasmer run --project examples/launch-build-demo build
\`\`\`
The dry-run public tree identity is \`${publicTreeIdentity}\`.
The release name is \`${releaseName}\`.
`,
"utf8"
);
return file;
}
function writeInviteAsset(releaseName, publicTreeIdentity, resolution) {
const file = path.join(assetsDir, `DISASMER_PUBLIC_DRYRUN_INVITE-${releaseName}.md`);
const publicRepo =
process.env.DISASMER_PUBLIC_REPO_URL ||
"https://git.michelpaulissen.com/<owner>/<public-repo>";
const releaseUrl =
process.env.DISASMER_FORGEJO_RELEASE_URL ||
"the Forgejo Release attached to the public repository";
fs.writeFileSync(
file,
`# Disasmer Public Dry Run Invite
This invite is for selected external users, such as friends helping test the
MVP release experience. The deployment is real and externally reachable, but it
is intentionally not broadly discoverable yet because
\`disasmer.michelpaulissen.com\` is not published in public DNS.
## Links
- Public repository: ${publicRepo}
- Release downloads: ${releaseUrl}
- Default operator endpoint: ${defaultOperatorEndpoint}
- Public tree identity: ${publicTreeIdentity}
- Release name: ${releaseName}
## Controlled resolution
${resolution}
## First run
1. Download the binary archive for your platform and \`SHA256SUMS\` from the
Forgejo Release.
2. Extract the archive and put \`bin/\` on your \`PATH\`.
3. Run \`disasmer login --browser\`.
4. Attach your node with the enrollment grant supplied out of band.
5. Run \`disasmer run --project examples/launch-build-demo build\` from the
public repository checkout.
`,
"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: "disasmer-filtered-public-tree",
source_commit: sourceCommit,
release_name: releaseName,
filtered_out: ["private/**", "experiments/**", ".git", "target"],
forgejo_host: forgejoHost,
default_operator_endpoint: defaultOperatorEndpoint,
};
fs.writeFileSync(
path.join(publicTree, "DISASMER_PUBLIC_TREE.json"),
`${JSON.stringify(provenance, null, 2)}\n`
);
}
function publishPublicTree(releaseName, sourceCommit, publicTreeIdentity) {
const remote =
process.env.DISASMER_PUBLIC_REPO_REMOTE ||
process.env.DISASMER_PUBLIC_REPO_URL ||
null;
const enabled = boolEnv("DISASMER_PUBLISH_PUBLIC_TREE");
const result = {
enabled,
remote,
branch: publicRepoBranch,
commit: null,
pushed: false,
};
if (!enabled) {
return result;
}
if (!remote) {
throw new Error(
"DISASMER_PUBLISH_PUBLIC_TREE requires DISASMER_PUBLIC_REPO_REMOTE or DISASMER_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", ["checkout", "-B", publicRepoBranch], { cwd: publicTree });
run("git", ["config", "user.name", "Disasmer release dry run"], {
cwd: publicTree,
});
run("git", ["config", "user.email", "release-dryrun@disasmer.invalid"], {
cwd: publicTree,
});
run("git", ["add", "."], { cwd: publicTree });
run(
"git",
[
"commit",
"-m",
`Public dry run ${releaseName}`,
"-m",
`Source commit: ${sourceCommit}`,
"-m",
`Public tree identity: ${publicTreeIdentity}`,
],
{ cwd: publicTree }
);
result.commit = commandOutput("git", ["rev-parse", "HEAD"], { cwd: publicTree });
run("git", ["remote", "add", "public", remote], { cwd: publicTree });
const pushArgs = ["push", "public", `HEAD:${publicRepoBranch}`];
if (boolEnv("DISASMER_PUBLIC_REPO_PUSH_FORCE_WITH_LEASE")) {
pushArgs.splice(1, 0, "--force-with-lease");
}
run("git", pushArgs, { cwd: publicTree });
result.pushed = true;
return result;
}
function main() {
const sourceCommit =
process.env.DISASMER_ACCEPTANCE_COMMIT ||
commandOutput("git", ["rev-parse", "HEAD"]) ||
"unknown";
const shortCommit = sourceCommit === "unknown" ? "unknown" : sourceCommit.slice(0, 12);
const releaseName = process.env.DISASMER_PUBLIC_RELEASE_NAME || `dryrun-${shortCommit}`;
const sourceStatus = commandOutput("git", ["status", "--short"]);
const sourceTreeClean = sourceStatus === null ? null : sourceStatus === "";
fs.rmSync(outputRoot, { recursive: true, force: true });
ensureDir(publicTree);
ensureDir(assetsDir);
ensureDir(stagingDir);
copyFilteredTree(repo, publicTree);
assertFilteredTree();
writePublicTreeProvenance(sourceCommit, releaseName);
const publicTreeIdentity = hashTree(publicTree);
const sourceArchive = stageSourceAsset(releaseName);
run("node", ["scripts/public-release-dryrun-contract-smoke.js"], {
cwd: publicTree,
});
const publicTreePublish = publishPublicTree(
releaseName,
sourceCommit,
publicTreeIdentity
);
buildPublicBinaries();
const binaryArchive = stageBinaryAssets(releaseName);
const extensionArchive = stageExtensionAsset();
const resolution = resolverInstructions();
const gettingStarted = writeGettingStartedAsset(
releaseName,
publicTreeIdentity,
resolution
);
const invite = writeInviteAsset(releaseName, publicTreeIdentity, resolution);
const assets = [
sourceArchive,
binaryArchive,
extensionArchive,
gettingStarted,
invite,
];
const sha256Sums = writeSha256Sums(assets);
const manifest = {
kind: "disasmer-public-release-dryrun",
release_name: releaseName,
source_commit: sourceCommit,
source_tree_clean: sourceTreeClean,
public_tree_identity: publicTreeIdentity,
public_tree: publicTree,
filtered_out: ["private/**", "experiments/**", ".git", "target"],
forgejo_host: forgejoHost,
public_repo_url: process.env.DISASMER_PUBLIC_REPO_URL || publicTreePublish.remote,
public_repo_remote: process.env.DISASMER_PUBLIC_REPO_REMOTE || null,
public_tree_publish: publicTreePublish,
forgejo_release_url: process.env.DISASMER_FORGEJO_RELEASE_URL || null,
default_operator_endpoint: defaultOperatorEndpoint,
dns_publication_state:
process.env.DISASMER_DNS_PUBLICATION_STATE || "not-published",
resolver_override:
process.env.DISASMER_RESOLVER_OVERRIDE || "required-for-dry-run",
platform: platformName(),
tool_versions: {
node: process.version,
rustc: commandOutput("rustc", ["--version"]) || null,
cargo: commandOutput("cargo", ["--version"]) || null,
tar: commandOutput("tar", ["--version"]) || null,
},
commands: [
"node scripts/public-release-dryrun-contract-smoke.js",
...(publicTreePublish.enabled
? [`git push public HEAD:${publicRepoBranch}`]
: []),
"cargo build --workspace --bins --release",
],
assets: [...assets, sha256Sums].map((asset) => ({
file: asset,
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 dry run 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();