/** Prepare and verify this fork's Linux release; publication is always a separate human action. */ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import fs from "node:fs"; import fsp from "node:fs/promises"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { pathToFileURL } from "node:url"; const require = createRequire(import.meta.url); const yaml = require("js-yaml"); const ROOT = path.resolve(import.meta.dirname, ".."); export const SERVER = "https://git.sorokin-oleg-edu.xyz"; export const REPOSITORY = "sorokin.oleg.edu/learnharness"; export const UPDATE_URL = `${SERVER}/${REPOSITORY}/releases/download/latest`; const API = `${SERVER}/api/v1/repos/${REPOSITORY}`; export function expectedLinuxAssets(version, { appimageOnly = false } = {}) { assert.match(version, /^\d+\.\d+\.\d+$/, "release version must be a stable semver"); return [`LearnHarness-${version}-x86_64.AppImage`, ...(!appimageOnly ? [`LearnHarness-${version}-amd64.deb`] : []), "latest-linux.yml", "SHA256SUMS"]; } export async function fileDigest(file, algorithm, encoding = "hex") { const hash = createHash(algorithm); for await (const chunk of fs.createReadStream(file)) hash.update(chunk); return hash.digest(encoding); } export async function writeChecksums(directory, version, options = {}) { const names = expectedLinuxAssets(version, options).filter((name) => name !== "SHA256SUMS"); const lines = []; for (const name of names) { const file = path.join(directory, name); const stat = await fsp.lstat(file); assert(stat.isFile() && stat.size > 0, `${name} must be a nonempty regular file`); lines.push(`${await fileDigest(file, "sha256")} ${name}`); } await fsp.writeFile(path.join(directory, "SHA256SUMS"), lines.join("\n") + "\n"); } export async function verifyPackagedUpdater(directory) { const config = yaml.load(await fsp.readFile(path.join(directory, "resources", "app-update.yml"), "utf8")); assert.equal(config.provider, "generic", "packaged updater must use the Gitea generic provider"); assert.equal(config.url, UPDATE_URL, "packaged updater must use the public Gitea release feed"); assert(!config.owner && !config.repo, "packaged updater must not retain an upstream GitHub destination"); } export async function verifyLocal(directory, version, options = {}) { const names = expectedLinuxAssets(version, options); const inventory = new Map(); for (const name of names) { const file = path.join(directory, name); const stat = await fsp.lstat(file); assert(stat.isFile() && stat.size > 0, `${name} must be a nonempty regular file`); inventory.set(name, { name, size: stat.size, sha256: await fileDigest(file, "sha256") }); } for (const name of names.filter((name) => /\.(AppImage|deb)$/.test(name))) { const magic = name.endsWith(".AppImage") ? Buffer.from([0x7f, 0x45, 0x4c, 0x46]) : Buffer.from("!\n"); const handle = await fsp.open(path.join(directory, name), "r"); try { const header = Buffer.alloc(magic.length); await handle.read(header, 0, header.length, 0); assert(header.equals(magic), `${name} has an invalid installer header`); } finally { await handle.close(); } } const feed = yaml.load(await fsp.readFile(path.join(directory, "latest-linux.yml"), "utf8")); assert.equal(feed?.version, version, "update metadata version differs from the package"); assert(Array.isArray(feed.files), "update metadata must contain files"); const installers = names.filter((name) => /\.(AppImage|deb)$/.test(name)); assert.deepEqual(feed.files.map((entry) => entry.url).sort(), [...installers].sort(), "update feed must reference exactly the Linux installers by filename"); for (const entry of feed.files) { assert.equal(entry.size, inventory.get(entry.url)?.size, `${entry.url} update size differs`); assert.equal(entry.sha512, await fileDigest(path.join(directory, entry.url), "sha512", "base64"), `${entry.url} update checksum differs`); } assert.equal(feed.path, feed.files[0].url, "legacy updater path differs"); assert.equal(feed.sha512, feed.files[0].sha512, "legacy updater checksum differs"); const checksums = (await fsp.readFile(path.join(directory, "SHA256SUMS"), "utf8")).trim().split("\n"); assert.deepEqual(checksums, names.filter((name) => name !== "SHA256SUMS").map((name) => `${inventory.get(name).sha256} ${name}`), "SHA256SUMS does not match the release bytes"); return [...inventory.values()]; } /** The downloaded bytes, not only API-reported sizes, must agree with the local verified package. */ export async function verifyRemote(release, localInventory, headers = {}, request = fetch) { assert.equal(release.draft, true, "only a draft may be prepared or verified by this tool"); assert.equal(release.prerelease, false, "release must be stable"); assert.deepEqual(release.assets.map((asset) => asset.name).sort(), localInventory.map((asset) => asset.name).sort(), "remote draft has missing, duplicate, or unexpected assets"); const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), "lh-release-verify-")); try { for (const expected of localInventory) { const asset = release.assets.find((entry) => entry.name === expected.name); assert.equal(asset.size, expected.size, `${expected.name} remote size differs`); const url = new URL(asset.browser_download_url); assert.equal(url.origin, SERVER, "release attachment points outside Gitea"); const response = await request(url, { headers, signal: AbortSignal.timeout(300_000) }); assert(response.ok && response.body, `cannot download ${expected.name}: HTTP ${response.status}`); const file = path.join(temporary, expected.name); await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(file)); assert.equal(await fileDigest(file, "sha256"), expected.sha256, `${expected.name} remote bytes differ`); } } finally { await fsp.rm(temporary, { recursive: true, force: true }); } } function authorization() { const token = process.env.GITEA_TOKEN || process.env.GITEA_ACCESS_TOKEN || process.env.GITEA_API_TOKEN; assert(token, "GITEA_TOKEN is required for Gitea API operations"); return { Authorization: `token ${token}` }; } async function api(endpoint, options = {}) { const headers = { ...authorization(), ...options.headers }; const response = await fetch(`${API}${endpoint}`, { ...options, headers, signal: AbortSignal.timeout(300_000) }); if (!response.ok) throw new Error(`Gitea ${options.method ?? "GET"} ${endpoint}: HTTP ${response.status}`); return response.status === 204 ? null : response.json(); } export async function selectDraft(tag) { const releases = []; for (let page = 1; ; page++) { const batch = await api(`/releases?limit=50&page=${page}`); assert(Array.isArray(batch), "release list response is invalid"); releases.push(...batch); if (batch.length < 50) break; } const matches = releases.filter((release) => release.tag_name === tag || release.tag_name === tag.slice(1)); assert(matches.length <= 1, `ambiguous releases for ${tag}`); if (matches.length) { assert.equal(matches[0].tag_name, tag, "use the exact versioned tag"); assert.equal(matches[0].draft, true, "refusing to modify an already published release"); } return matches[0] ?? null; } async function upload(directory, version, options = {}) { const inventory = await verifyLocal(directory, version, options); const tag = `v${version}`; // The tag must already exist remotely; never create a release from a moving branch. const tagged = await api(`/tags/${encodeURIComponent(tag)}`); assert(tagged.commit?.sha, "release tag does not resolve to a commit"); let release = await selectDraft(tag); const notes = await fsp.readFile(path.join(ROOT, "docs", `release-${version}.md`), "utf8"); const data = { tag_name: tag, target_commitish: tagged.commit.sha, name: `LearnHarness ${version}`, body: notes, draft: true, prerelease: false }; release = await api(release ? `/releases/${release.id}` : "/releases", { method: release ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }); for (const expected of inventory) { const existing = (release.assets ?? []).find((asset) => asset.name === expected.name); if (existing) { assert.equal(existing.size, expected.size, `${expected.name} already exists with different bytes; review the draft before replacing it`); // Verify the bytes below, including an existing same-size attachment. continue; } const bytes = await fsp.readFile(path.join(directory, expected.name)); const form = new FormData(); form.set("attachment", new Blob([bytes]), expected.name); await api(`/releases/${release.id}/assets?name=${encodeURIComponent(expected.name)}`, { method: "POST", body: form }); console.log(`uploaded ${expected.name}`); } release = await api(`/releases/${release.id}`); await verifyRemote(release, inventory, authorization()); console.log(`Verified draft: ${release.html_url}`); console.log("Publication remains a separate review step."); } async function main() { const [command, ...args] = process.argv.slice(2); const appimageOnly = args.includes("--appimage-only"); assert(!appimageOnly || ["checksums", "verify", "upload", "verify-remote"].includes(command), "--appimage-only is supported only by checksums, verify, upload and verify-remote"); assert(args.filter((arg) => arg === "--appimage-only").length <= 1, "duplicate --appimage-only option"); assert(args.every((arg) => !arg.startsWith("--") || arg === "--appimage-only"), "unknown release option"); const positional = args.filter((arg) => arg !== "--appimage-only"); assert(positional.length <= 1, "expected at most one release directory"); const directory = positional[0] ?? "dist"; const { version } = JSON.parse(await fsp.readFile(path.join(ROOT, "package.json"), "utf8")); if (command === "checksums") await writeChecksums(directory, version, { appimageOnly }); else if (command === "verify-package") await verifyPackagedUpdater(directory); else if (command === "verify") console.log(JSON.stringify(await verifyLocal(directory, version, { appimageOnly }), null, 2)); else if (command === "upload") await upload(directory, version, { appimageOnly }); else if (command === "verify-remote") { const inventory = await verifyLocal(directory, version, { appimageOnly }); const release = await selectDraft(`v${version}`); assert(release, "draft not found"); await verifyRemote(release, inventory, authorization()); console.log(`Verified draft: ${release.html_url}`); } else throw new Error("Usage: node scripts/gitea-release.mjs checksums|verify|upload|verify-remote [dist-directory] [--appimage-only], or verify-package [unpacked-app-directory]"); } if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { main().catch((error) => { console.error(error.message); process.exitCode = 1; }); }