161 lines
9.1 KiB
JavaScript
161 lines
9.1 KiB
JavaScript
/** 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://server2.sorokin-oleg-edu.xyz";
|
|
export const REPOSITORY = "replicated/learnharness";
|
|
export const UPDATE_URL = `${SERVER}/${REPOSITORY}/releases/download/latest`;
|
|
const API = `${SERVER}/api/v1/repos/${REPOSITORY}`;
|
|
|
|
export function expectedLinuxAssets(version) {
|
|
assert.match(version, /^\d+\.\d+\.\d+$/, "release version must be a stable semver");
|
|
return [`LearnHarness-${version}-x86_64.AppImage`, `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) {
|
|
const names = expectedLinuxAssets(version).filter((name) => name !== "SHA256SUMS");
|
|
const lines = [];
|
|
for (const name of names) lines.push(`${await fileDigest(path.join(directory, name), "sha256")} ${name}`);
|
|
await fsp.writeFile(path.join(directory, "SHA256SUMS"), lines.join("\n") + "\n");
|
|
}
|
|
|
|
export async function verifyLocal(directory, version) {
|
|
const names = expectedLinuxAssets(version);
|
|
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, magic] of [[names[0], Buffer.from([0x7f, 0x45, 0x4c, 0x46])], [names[1], Buffer.from("!<arch>\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, "0.4.0 is a stable release");
|
|
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) {
|
|
const inventory = await verifyLocal(directory, version);
|
|
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, directory = "dist"] = process.argv.slice(2);
|
|
const { version } = JSON.parse(await fsp.readFile(path.join(ROOT, "package.json"), "utf8"));
|
|
if (command === "checksums") await writeChecksums(directory, version);
|
|
else if (command === "verify") console.log(JSON.stringify(await verifyLocal(directory, version), null, 2));
|
|
else if (command === "upload") await upload(directory, version);
|
|
else if (command === "verify-remote") {
|
|
const inventory = await verifyLocal(directory, version);
|
|
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]");
|
|
}
|
|
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
|
main().catch((error) => { console.error(error.message); process.exitCode = 1; });
|
|
}
|