#!/usr/bin/env node const fs = require("fs"); const https = require("https"); const path = require("path"); const cp = require("child_process"); const repo = path.resolve(__dirname, ".."); const releaseRoot = path.resolve( process.env.CLUSTERFLUX_PUBLIC_RELEASE_DIR || path.join(repo, "target/public-release") ); const manifestPath = path.join(releaseRoot, "public-release-manifest.json"); const reportPath = path.join( repo, "target/acceptance/public-release-forgejo-release.json" ); const forgejoUrl = ( process.env.CLUSTERFLUX_FORGEJO_URL || "https://git.michelpaulissen.com" ).replace(/\/+$/, ""); const token = process.env.CLUSTERFLUX_FORGEJO_TOKEN; let owner = process.env.CLUSTERFLUX_PUBLIC_REPO_OWNER; let repoName = process.env.CLUSTERFLUX_PUBLIC_REPO_NAME; function requireEnv(name, value) { if (!value) { throw new Error(`${name} is required`); } } function commandOutput(command, args) { try { return cp .execFileSync(command, args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }) .trim(); } catch (_) { return null; } } function expectedSourceCommit() { return ( process.env.CLUSTERFLUX_ACCEPTANCE_COMMIT || commandOutput("git", ["rev-parse", "HEAD"]) || "unknown" ); } function parseForgejoRepoIdentity(remote) { if (!remote) { return null; } let pathname = remote; try { pathname = new URL(remote).pathname; } catch (_) { const scpLike = /^[^@/]+@[^:]+:(.+)$/.exec(remote); if (scpLike) { pathname = scpLike[1]; } } const parts = pathname .replace(/^\/+/, "") .replace(/\.git$/, "") .split("/") .filter(Boolean); if (parts.length < 2) { return null; } return { owner: parts[parts.length - 2], repoName: parts[parts.length - 1], }; } function resolveRepoIdentity(manifest) { if (owner && repoName) { return; } const inferred = parseForgejoRepoIdentity( manifest.public_repo_url || manifest.public_repo_remote || process.env.CLUSTERFLUX_PUBLIC_REPO_REMOTE ); owner = owner || (inferred && inferred.owner); repoName = repoName || (inferred && inferred.repoName); if (!owner || !repoName) { throw new Error( "CLUSTERFLUX_PUBLIC_REPO_OWNER and CLUSTERFLUX_PUBLIC_REPO_NAME are required when the manifest does not contain a parseable Forgejo repository URL" ); } } function publicTreeAlreadyPushed(manifest) { return ( (manifest.public_tree_publish && manifest.public_tree_publish.pushed === true) || process.env.CLUSTERFLUX_PUBLIC_TREE_ALREADY_PUSHED === "1" ); } function publicTreeCommit(manifest) { return ( (manifest.public_tree_publish && manifest.public_tree_publish.commit) || process.env.CLUSTERFLUX_PUBLIC_TREE_COMMIT || process.env.CLUSTERFLUX_PUBLIC_RELEASE_TARGET || null ); } function apiPath(pathname) { return `/api/v1${pathname}`; } function request(method, pathname, { body, headers = {} } = {}) { const url = new URL(apiPath(pathname), forgejoUrl); const payload = body === undefined ? null : Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body)); const requestHeaders = { Accept: "application/json", Authorization: `token ${token}`, ...headers, }; if (payload) { requestHeaders["Content-Length"] = payload.length; if (!requestHeaders["Content-Type"]) { requestHeaders["Content-Type"] = "application/json"; } } return new Promise((resolve, reject) => { const req = https.request( url, { method, headers: requestHeaders, }, (res) => { const chunks = []; res.on("data", (chunk) => chunks.push(chunk)); res.on("end", () => { const text = Buffer.concat(chunks).toString("utf8"); let parsed = null; if (text.trim()) { try { parsed = JSON.parse(text); } catch (_) { parsed = text; } } if (res.statusCode < 200 || res.statusCode >= 300) { reject( new Error( `${method} ${url.pathname} failed with ${res.statusCode}: ${text}` ) ); return; } resolve({ status: res.statusCode, body: parsed }); }); } ); req.on("error", reject); if (payload) req.write(payload); req.end(); }); } function multipartFile(fieldName, file) { const boundary = `clusterflux-${Date.now()}-${Math.random().toString(16).slice(2)}`; const name = path.basename(file); const header = Buffer.from( `--${boundary}\r\n` + `Content-Disposition: form-data; name="${fieldName}"; filename="${name}"\r\n` + "Content-Type: application/octet-stream\r\n\r\n" ); const footer = Buffer.from(`\r\n--${boundary}--\r\n`); return { body: Buffer.concat([header, fs.readFileSync(file), footer]), contentType: `multipart/form-data; boundary=${boundary}`, }; } async function existingRelease(tagName) { const releases = await request( "GET", `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases?limit=100` ); return (releases.body || []).find((release) => release.tag_name === tagName) || null; } async function createOrReuseRelease(manifest) { const tagName = manifest.release_name; const found = await existingRelease(tagName); if (found) { return { release: found, created: false }; } const targetCommitish = publicTreeCommit(manifest) || "main"; const body = [ "Clusterflux public release.", "", `Default hosted coordinator endpoint: ${manifest.default_hosted_coordinator_endpoint}`, `DNS publication state: ${manifest.dns_publication_state}`, `Resolver override: ${manifest.resolver_override}`, `Public tree identity: ${manifest.public_tree_identity}`, `Source commit: ${manifest.source_commit}`, ].join("\n"); const created = await request( "POST", `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases`, { body: { tag_name: tagName, target_commitish: targetCommitish, name: tagName, body, draft: false, prerelease: false, }, } ); return { release: created.body, created: true }; } async function loadRelease(releaseId) { const response = await request( "GET", `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases/${releaseId}` ); return response.body; } async function uploadAsset(release, asset) { const { body, contentType } = multipartFile("attachment", asset.file); const response = await request( "POST", `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases/${release.id}/assets?name=${encodeURIComponent(asset.name)}`, { body, headers: { "Content-Type": contentType, }, } ); return response.body; } function existingAssetByName(release, name) { return Array.isArray(release.assets) ? release.assets.find((asset) => asset.name === name) || null : null; } async function main() { requireEnv("CLUSTERFLUX_FORGEJO_TOKEN", token); if (!fs.existsSync(manifestPath)) { throw new Error(`missing public release manifest: ${manifestPath}`); } const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); resolveRepoIdentity(manifest); if (manifest.kind !== "clusterflux-public-release") { throw new Error(`unexpected public release manifest kind: ${manifest.kind}`); } if (manifest.source_commit !== expectedSourceCommit()) { throw new Error( "public release manifest is stale; regenerate it for the current acceptance commit before publishing the Forgejo Release" ); } if ( !publicTreeAlreadyPushed(manifest) ) { throw new Error( "public tree must be pushed before publishing the Forgejo Release; run prepare-public-release.js with CLUSTERFLUX_PUBLISH_PUBLIC_TREE=1" ); } if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) { throw new Error("public release manifest has no assets"); } const { release: releaseResult, created } = await createOrReuseRelease(manifest); const release = await loadRelease(releaseResult.id); const uploaded = []; const reused = []; for (const asset of manifest.assets) { if (!fs.existsSync(asset.file)) { throw new Error(`missing release asset: ${asset.file}`); } const existing = existingAssetByName(release, asset.name); if (existing) { reused.push(existing); continue; } uploaded.push(await uploadAsset(release, asset)); } fs.mkdirSync(path.dirname(reportPath), { recursive: true }); const report = { kind: "clusterflux-public-release-forgejo-release", forgejo_url: forgejoUrl, owner, repo: repoName, release_id: release.id, release_name: release.name || manifest.release_name, tag_name: release.tag_name || manifest.release_name, release_created: created, default_hosted_coordinator_endpoint: manifest.default_hosted_coordinator_endpoint, public_tree_identity: manifest.public_tree_identity, public_tree_commit: publicTreeCommit(manifest), source_commit: manifest.source_commit, uploaded_assets: uploaded.map((asset) => ({ id: asset.id, name: asset.name, size: asset.size, browser_download_url: asset.browser_download_url, })), reused_assets: reused.map((asset) => ({ id: asset.id, name: asset.name, size: asset.size, browser_download_url: asset.browser_download_url, })), }; fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); console.log( JSON.stringify( { report: reportPath, uploaded: uploaded.length, reused: reused.length }, null, 2 ) ); } main().catch((error) => { console.error(error.stack || error.message); process.exit(1); });