#!/usr/bin/env node /* * Upload built desktop artifacts to a Gitea/Forgejo release (Codeberg IS Forgejo, and * git.utn.lol is Gitea — same API) so electron-updater (generic provider -> * .../releases/latest/download/) can find them. * * Run AFTER `pnpm run build` from apps/desktop. Defaults target Codeberg: * CODEBERG_TOKEN=xxxx node scripts/release-codeberg.mjs [tag] * Target a different forge (e.g. the git.utn.lol Gitea) via env overrides: * FORGE_API=https://git.utn.lol/api/v1 FORGE_OWNER=enki FORGE_REPO=blap \ * FORGE_TOKEN=xxxx node scripts/release-codeberg.mjs [tag] * * - tag defaults to "v" from package.json. * - Uploads latest.yml / latest-linux.yml + installers + blockmaps. * - Replaces assets of the same name so re-runs are idempotent. * - The release is created non-draft / non-prerelease so the "latest" alias resolves * to it (that's what the updater feed URL points at). */ import { readFile, readdir } from "node:fs/promises"; import { statSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { basename, join } from "node:path"; import { fileURLToPath } from "node:url"; const OWNER = process.env.FORGE_OWNER ?? "Enkisu"; const REPO = process.env.FORGE_REPO ?? "Blap"; const API = process.env.FORGE_API ?? "https://codeberg.org/api/v1"; const token = process.env.FORGE_TOKEN ?? process.env.CODEBERG_TOKEN; if (!token) { console.error("A token is required: set FORGE_TOKEN (or CODEBERG_TOKEN) to an access token with repo scope."); process.exit(1); } const here = fileURLToPath(new URL(".", import.meta.url)); const pkg = JSON.parse(await readFile(join(here, "..", "package.json"), "utf8")); const tag = process.argv[2] ?? `v${pkg.version}`; const distDir = join(here, "..", "dist"); const authHeaders = { Authorization: `token ${token}` }; // Channel files electron-updater needs + the installers themselves. // electron-updater channel files (always the current build) — uploaded regardless. const CHANNEL_FILES = new Set(["latest.yml", "latest-linux.yml"]); // Installers for this version. The version filter avoids grabbing leftover // artifacts from previous builds sitting in dist/. const INSTALLER_EXT = /\.(exe|exe\.blockmap|deb|flatpak|pacman|tar\.gz)$/; async function api(path, init = {}) { const res = await fetch(`${API}${path}`, { ...init, headers: { ...authHeaders, ...(init.headers ?? {}) } }); if (!res.ok && res.status !== 404) { throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`); } return res; } async function getOrCreateRelease(tagName, { prerelease = false } = {}) { let res = await api(`/repos/${OWNER}/${REPO}/releases/tags/${encodeURIComponent(tagName)}`); if (res.status === 404) { res = await api(`/repos/${OWNER}/${REPO}/releases`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tag_name: tagName, name: tagName, draft: false, prerelease }), }); console.log(`Created release ${tagName}`); } else { console.log(`Reusing existing release ${tagName}`); } return res.json(); } async function main() { const version = pkg.version; const files = (await readdir(distDir)).filter( (f) => CHANNEL_FILES.has(f) || (f.includes(version) && INSTALLER_EXT.test(f)), ); if (files.length === 0) { console.error(`No matching artifacts in ${distDir}. Run \`pnpm run build\` first.`); process.exit(1); } const release = await getOrCreateRelease(tag); await syncAssets(release, files); // Forgejo/Codeberg has NO GitHub-style /releases/latest/download/ route, so // app-update.yml points at a rolling release on the fixed tag "updates" — // re-point its channel file + Windows installer at the build we just shipped. const updaterFiles = files.filter((f) => f === "latest.yml" || /\.(exe|exe\.blockmap)$/.test(f)); const updates = await getOrCreateRelease("updates", { prerelease: true }); await syncAssets(updates, updaterFiles); const webBase = API.replace(/\/api\/v1\/?$/, ""); console.log(`\nDone. Updater feed: ${webBase}/${OWNER}/${REPO}/releases/download/updates/latest.yml`); } async function syncAssets(release, files) { const existing = new Map((release.assets ?? []).map((a) => [a.name, a])); for (const file of files) { const name = basename(file); const filePath = join(distDir, file); const size = statSync(filePath).size; const already = existing.get(name); // Resume: skip assets already uploaded at the same size (lets a re-run // finish where it left off instead of re-uploading everything). Channel // files are tiny and change meaning per build — always replace those. if (already && already.size === size && !CHANNEL_FILES.has(name)) { console.log(`Skipping ${name} (already uploaded)`); continue; } if (already) { await api(`/repos/${OWNER}/${REPO}/releases/${release.id}/assets/${already.id}`, { method: "DELETE" }); } process.stdout.write(`Uploading ${name} (${(size / 1e6).toFixed(0)} MB)... `); // Upload via curl: Node's built-in fetch (undici) times out on large files // (UND_ERR_HEADERS_TIMEOUT). curl streams from disk with no such limit. execFileSync( "curl", [ "-sS", "--fail-with-body", "--retry", "4", "--retry-all-errors", "-X", "POST", "-H", `Authorization: token ${token}`, "-F", `attachment=@${filePath}`, `${API}/repos/${OWNER}/${REPO}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, ], { stdio: ["ignore", "ignore", "inherit"] }, ); console.log("done"); } } await main();