"use strict"; const fs = require("node:fs/promises"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); const { app, safeStorage } = require("electron"); const root = path.resolve(__dirname, ".."); const configuredUserData = process.env.FORGEFLOW_USER_DATA || path.join(app.getPath("appData"), "forgeflow"); app.setPath("userData", path.resolve(configuredUserData)); function safeRepositoryPart(value, label) { const text = String(value || "").trim(); if (!/^[a-zA-Z0-9_.-]+$/.test(text)) { throw new Error(`${label} contains unsupported characters.`); } return text; } async function api(baseUrl, token, pathname, options = {}) { const response = await fetch(`${baseUrl}/api/v1${pathname}`, { ...options, headers: { Accept: "application/json", Authorization: `token ${token}`, ...(options.headers || {}), }, signal: AbortSignal.timeout(options.timeout || 180_000), }); const text = await response.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = text; } if (!response.ok) { throw new Error( `Gitea returned HTTP ${response.status}: ${data?.message || text || response.statusText}`, ); } return data; } app.whenReady().then(async () => { try { const manifest = JSON.parse( await fs.readFile(path.join(root, "package.json"), "utf8"), ); const configPath = path.join(configuredUserData, "forgeflow-config.json"); const config = JSON.parse(await fs.readFile(configPath, "utf8")); if (!config?.gitea?.encryptedToken) { throw new Error( `No encrypted Gitea token was found in ${configPath}. Sign in to Gitea once from ForgeFlow first.`, ); } const token = safeStorage.decryptString( Buffer.from(config.gitea.encryptedToken, "base64"), ); const baseUrl = String(config.gitea.baseUrl || "").replace(/\/+$/, ""); if (!/^https?:\/\//i.test(baseUrl)) { throw new Error("The configured Gitea base URL is invalid."); } const owner = safeRepositoryPart( process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens", "Release repository owner", ); const repo = safeRepositoryPart( process.env.FORGEFLOW_RELEASE_REPO || config.updates?.repo || "ForgeFlow", "Release repository name", ); const branch = safeRepositoryPart( process.env.FORGEFLOW_RELEASE_BRANCH || config.updates?.branch || "main", "Release branch", ); const version = manifest.version; const tag = `v${version}`; const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", }).trim(); const remote = execFileSync( "git", ["ls-remote", "origin", `refs/heads/${branch}`], { cwd: root, encoding: "utf8" }, ) .trim() .split(/\s+/)[0]; if (commit !== remote) { throw new Error( `Local HEAD is not the published origin/${branch} commit. Push the exact source before publishing binaries.`, ); } const notesPath = path.join(root, "docs", `RELEASE_NOTES_${version}.md`); const body = await fs.readFile(notesPath, "utf8"); let release; try { release = await api( baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`, ); } catch (error) { if (!/HTTP 404/.test(error.message)) throw error; release = await api( baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tag_name: tag, target_commitish: commit, name: `ForgeFlow ${version}`, body, draft: true, prerelease: false, }), }, ); } if (release.draft !== true) { release = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draft: true }), }); } const binaries = [ path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`), path.join(root, "dist", `ForgeFlow-Portable-${version}-win-x64.exe`), ]; for (const binaryPath of binaries) { const binaryName = path.basename(binaryPath); const binary = await fs.readFile(binaryPath); const checksumPath = `${binaryPath}.sha256`; const checksumName = `${binaryName}.sha256`; const checksum = await fs.readFile(checksumPath); for (const [name, bytes, type] of [ [binaryName, binary, "application/vnd.microsoft.portable-executable"], [checksumName, checksum, "text/plain"], ]) { const existing = (release.assets || []).find( (asset) => asset.name === name, ); if (existing && Number(existing.size) === bytes.length) { console.log(`SKIP ${name} already published`); continue; } if (existing) { await api( baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets/${existing.id}`, { method: "DELETE" }, ); } const form = new FormData(); form.append("attachment", new Blob([bytes], { type }), name); const uploaded = await api( baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { method: "POST", body: form, timeout: 300_000, }, ); release.assets = [ ...(release.assets || []).filter((asset) => asset.name !== name), uploaded, ]; console.log(`PASS published ${name}`); } } for (const [name, type] of [ [`ForgeFlow-${version}-provenance.json`, "application/json"], [`ForgeFlow-${version}-sbom.cdx.json`, "application/vnd.cyclonedx+json"], ]) { const bytes = await fs.readFile(path.join(root, "dist", name)); const existing = (release.assets || []).find((asset) => asset.name === name); if (existing) await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets/${existing.id}`, { method: "DELETE" }); const form = new FormData(); form.append("attachment", new Blob([bytes], { type }), name); const uploaded = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { method: "POST", body: form, timeout: 300_000 }); release.assets = [...(release.assets || []).filter((asset) => asset.name !== name), uploaded]; } const requiredAssets = [ ...binaries.flatMap((binaryPath) => [path.basename(binaryPath), `${path.basename(binaryPath)}.sha256`]), `ForgeFlow-${version}-provenance.json`, `ForgeFlow-${version}-sbom.cdx.json`, ]; const missingAssets = requiredAssets.filter((name) => !(release.assets || []).some((asset) => asset.name === name)); if (missingAssets.length) throw new Error(`Release remains draft because required assets are missing: ${missingAssets.join(", ")}`); release = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draft: false }), }); console.log( `PASS ForgeFlow ${version} binary release published to ${owner}/${repo} for ${commit.slice(0, 7)}`, ); app.exit(0); } catch (error) { console.error(`FAIL ${error.message}`); app.exit(1); } });