Files
ForgeFlow/scripts/publish-binary-release.cjs
T
Jens 255aec83d4
Managed validation / full (pull_request) Successful in 31s
Preserve publisher settings contract
2026-08-30 00:42:39 +02:00

274 lines
9.1 KiB
JavaScript

"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 { normalizeBaseUrl } = require("../src/shared/validation.cjs");
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 readOptionalConfig(configPath) {
try {
return JSON.parse(await fs.readFile(configPath, "utf8"));
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
}
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 = (await readOptionalConfig(configPath)) || {};
const actionsToken = String(
process.env.GITEA_TOKEN || process.env.FORGEFLOW_RELEASE_TOKEN || "",
).trim();
let token = actionsToken;
if (!token) {
if (!config.gitea?.encryptedToken) {
throw new Error(
`No release token was supplied and no encrypted Gitea token was found in ${configPath}. Sign in to Gitea once from ForgeFlow or run from Gitea Actions with GITEA_TOKEN.`,
);
}
token = safeStorage.decryptString(
Buffer.from(config.gitea.encryptedToken, "base64"),
);
}
const configuredBaseUrl =
process.env.FORGEFLOW_RELEASE_BASE_URL || config.gitea?.baseUrl;
if (!configuredBaseUrl) {
throw new Error(
"No Gitea release base URL was supplied. Set FORGEFLOW_RELEASE_BASE_URL or configure Gitea in ForgeFlow.",
);
}
const baseUrl = normalizeBaseUrl(configuredBaseUrl);
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"],
[`ForgeFlow-${version}-release-manifest.json`, "application/json"],
[`ForgeFlow-${version}-release-manifest.json.sig`, "application/octet-stream"],
]) {
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`,
`ForgeFlow-${version}-release-manifest.json`,
`ForgeFlow-${version}-release-manifest.json.sig`,
];
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);
}
});