#!/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:9443"; 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.DISASMER_INCLUDE_FORGEJO_WORKFLOWS || ""); const filteredTopLevel = ["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 filteredOutPatterns() { return [ "private/**", "experiments/**", ".git", "target", "/*.md", ...(includeForgejoWorkflows ? [] : [".forgejo/**"]), ]; } function isRootMarkdown(relativePath, entry) { return ( !relativePath.includes(path.sep) && (entry.isFile() || entry.isSymbolicLink()) && path.extname(entry.name).toLowerCase() === ".md" ); } function shouldFilter(entry, relativePath) { const topLevel = relativePath.split(path.sep)[0]; if (filteredTopLevel.includes(topLevel)) return true; if (topLevel === ".forgejo" && !includeForgejoWorkflows) return true; if (isRootMarkdown(relativePath, entry)) return true; return false; } 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; if (shouldFilter(entry, childRelative)) { 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`); } } if (!includeForgejoWorkflows && fs.existsSync(path.join(publicTree, ".forgejo"))) { throw new Error(".forgejo/ leaked into the host-neutral dry-run public tree"); } for (const entry of fs.readdirSync(publicTree, { withFileTypes: true })) { if (isRootMarkdown(entry.name, entry)) { throw new Error(`root Markdown file leaked into the dry-run 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 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}.vsix` ); run( "npx", [ "--yes", "@vscode/vsce", "package", "--allow-missing-repository", "--out", archive, ], { cwd: path.join(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 [ "`disasmer.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, `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. If public DNS has not propagated yet, use the fallback resolution instructions below. The default operator is the private hosted coordinator at \`https://disasmer.michelpaulissen.com:9443\`. The word \`public\` in this dry run refers to the public Forgejo repository, release downloads, selected-user network access, and public client protocol compatibility. It does not mean the server is the standalone open-source/public coordinator. ## DNS ${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 \`disasmer-vscode-*.vsix\` if you want the debugger and Disasmer side views, then install it: \`\`\`bash code --install-extension disasmer-vscode-*.vsix \`\`\` ## Connect Use the default operator endpoint: \`\`\`bash disasmer login --browser disasmer bundle inspect --project examples/launch-build-demo \`\`\` \`disasmer login --browser\` opens your browser through the barebones \`https://disasmer.michelpaulissen.com\` login site and returns to the CLI through a local callback. Enroll a node identity with the grant supplied by the invitation: \`\`\`bash disasmer node attach --coordinator https://disasmer.michelpaulissen.com:9443 --enrollment-grant --public-key \`\`\` That command proves the identity/enrollment path and then exits. To actually run work for the flagship workflow, leave a worker process running in another terminal. Use a fresh enrollment grant, or skip the attach command above and use the worker command directly: \`\`\`bash disasmer-node --coordinator https://disasmer.michelpaulissen.com:9443 --tenant --project-id --node --public-key --enrollment-grant --worker --emit-ready \`\`\` Then run the flagship workflow from the filtered public repository while that worker is still running: \`\`\`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//"; 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 advertised yet. The default operator behind this invite is the private hosted coordinator at \`https://disasmer.michelpaulissen.com:9443\`. The public part is the Forgejo repository, release downloads, network-reachable dry run, and public client protocol used by the binaries; the hosted server is not the standalone public coordinator. If public DNS has not propagated yet, make sure \`disasmer.michelpaulissen.com\` resolves to the deployment host before running the CLI. ## Links - Public repository: ${publicRepo} - Release downloads: ${releaseUrl} - Default operator endpoint: ${defaultOperatorEndpoint} - Public tree identity: ${publicTreeIdentity} - Release name: ${releaseName} ## DNS ${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. Optionally install \`disasmer-vscode-*.vsix\` with \`code --install-extension disasmer-vscode-*.vsix\`. 4. Run \`disasmer login --browser\`; it opens the barebones \`https://disasmer.michelpaulissen.com\` login site and returns to the CLI through a local callback. 5. Enroll a node identity with the enrollment grant supplied out of band. 6. Start a long-lived worker with a fresh enrollment grant, or use the worker command directly instead of step 5: \`disasmer-node --coordinator https://disasmer.michelpaulissen.com:9443 --tenant --project-id --node --public-key --enrollment-grant --worker --emit-ready\`. 7. Run \`disasmer run --project examples/launch-build-demo build\` from the public repository checkout while the worker process is still running. `, "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: filteredOutPatterns(), public_export: { host_neutral: !includeForgejoWorkflows, include_forgejo_workflows: includeForgejoWorkflows, }, 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")) { run( "git", [ "fetch", "public", `refs/heads/${publicRepoBranch}:refs/remotes/public/${publicRepoBranch}`, ], { cwd: publicTree } ); 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: filteredOutPatterns(), public_export: { host_neutral: !includeForgejoWorkflows, include_forgejo_workflows: includeForgejoWorkflows, }, 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 || "published", resolver_override: process.env.DISASMER_RESOLVER_OVERRIDE || "none-required-public-dns", 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();