214 lines
7.9 KiB
JavaScript
214 lines
7.9 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 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);
|
|
}
|
|
});
|