release: prepare 0.6.2 Linux and Windows packages
This commit is contained in:
+56
-24
@@ -1,4 +1,4 @@
|
||||
/** Prepare and verify this fork's Linux release; publication is always a separate human action. */
|
||||
/** Prepare and verify this fork's desktop release; publication is a separate human action. */
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
@@ -9,6 +9,7 @@ import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const yaml = require("js-yaml");
|
||||
@@ -22,6 +23,13 @@ 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 function expectedReleaseAssets(version, { appimageOnly = false, appimageWindows = false } = {}) {
|
||||
assert(!(appimageOnly && appimageWindows), "release scopes are mutually exclusive");
|
||||
const linux = expectedLinuxAssets(version, { appimageOnly: appimageOnly || appimageWindows });
|
||||
if (!appimageWindows) return linux;
|
||||
const exe = `LearnHarness-${version}-x64-setup.exe`;
|
||||
return [linux[0], exe, `${exe}.blockmap`, "latest-linux.yml", "latest.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);
|
||||
@@ -29,7 +37,7 @@ export async function fileDigest(file, algorithm, encoding = "hex") {
|
||||
}
|
||||
|
||||
export async function writeChecksums(directory, version, options = {}) {
|
||||
const names = expectedLinuxAssets(version, options).filter((name) => name !== "SHA256SUMS");
|
||||
const names = expectedReleaseAssets(version, options).filter((name) => name !== "SHA256SUMS");
|
||||
const lines = [];
|
||||
for (const name of names) {
|
||||
const file = path.join(directory, name);
|
||||
@@ -48,7 +56,11 @@ export async function verifyPackagedUpdater(directory) {
|
||||
}
|
||||
|
||||
export async function verifyLocal(directory, version, options = {}) {
|
||||
const names = expectedLinuxAssets(version, options);
|
||||
const names = expectedReleaseAssets(version, options);
|
||||
if (options.appimageWindows) {
|
||||
const staged = (await fsp.readdir(directory, { withFileTypes: true })).filter((entry) => !entry.isDirectory()).map((entry) => entry.name);
|
||||
assert.deepEqual(staged.sort(), [...names].sort(), "release directory must contain exactly the six approved assets");
|
||||
}
|
||||
const inventory = new Map();
|
||||
for (const name of names) {
|
||||
const file = path.join(directory, name);
|
||||
@@ -56,22 +68,36 @@ export async function verifyLocal(directory, version, options = {}) {
|
||||
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("!<arch>\n");
|
||||
for (const name of names.filter((name) => /\.(AppImage|deb|exe)$/.test(name))) {
|
||||
const magic = name.endsWith(".AppImage") ? Buffer.from([0x7f, 0x45, 0x4c, 0x46]) : Buffer.from(name.endsWith(".exe") ? "MZ" : "!<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`);
|
||||
for (const feedName of ["latest-linux.yml", ...(options.appimageWindows ? ["latest.yml"] : [])]) {
|
||||
const feed = yaml.load(await fsp.readFile(path.join(directory, feedName), "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) => (feedName === "latest.yml" ? /\.exe$/ : /\.(AppImage|deb)$/).test(name));
|
||||
assert.deepEqual(feed.files.map((entry) => entry.url).sort(), [...installers].sort(), "update feed must reference exactly the platform 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");
|
||||
}
|
||||
if (options.appimageWindows) {
|
||||
// Regenerate with the pinned packager's streaming chunker: this checks
|
||||
// every block checksum and full EXE coverage, rather than just its shape.
|
||||
const { buildBlockMap } = require("app-builder-lib/out/targets/blockmap/blockmap.js");
|
||||
const exe = names.find((name) => name.endsWith(".exe"));
|
||||
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), "lh-blockmap-verify-"));
|
||||
try {
|
||||
const regenerated = path.join(temporary, "installer.blockmap");
|
||||
await buildBlockMap(path.join(directory, exe), "gzip", regenerated);
|
||||
assert.deepEqual(JSON.parse(gunzipSync(await fsp.readFile(path.join(directory, `${exe}.blockmap`))).toString()), JSON.parse(gunzipSync(await fsp.readFile(regenerated)).toString()), "Windows blockmap differs from the installer bytes");
|
||||
} finally { await fsp.rm(temporary, { recursive: true, force: true }); }
|
||||
}
|
||||
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()];
|
||||
@@ -157,24 +183,30 @@ async function upload(directory, version, options = {}) {
|
||||
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");
|
||||
const appimageWindows = args.includes("--appimage-windows");
|
||||
const scopeFlags = ["--appimage-only", "--appimage-windows"];
|
||||
for (const flag of scopeFlags) {
|
||||
assert(!args.includes(flag) || ["checksums", "verify", "upload", "verify-remote"].includes(command), `${flag} is supported only by checksums, verify, upload and verify-remote`);
|
||||
assert(args.filter((arg) => arg === flag).length <= 1, `duplicate ${flag} option`);
|
||||
}
|
||||
assert(!(appimageOnly && appimageWindows), "release scopes are mutually exclusive");
|
||||
assert(args.every((arg) => !arg.startsWith("--") || scopeFlags.includes(arg)), "unknown release option");
|
||||
const positional = args.filter((arg) => !scopeFlags.includes(arg));
|
||||
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 });
|
||||
const options = { appimageOnly, appimageWindows };
|
||||
if (command === "checksums") await writeChecksums(directory, version, options);
|
||||
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") console.log(JSON.stringify(await verifyLocal(directory, version, options), null, 2));
|
||||
else if (command === "upload") await upload(directory, version, options);
|
||||
else if (command === "verify-remote") {
|
||||
const inventory = await verifyLocal(directory, version, { appimageOnly });
|
||||
const inventory = await verifyLocal(directory, version, options);
|
||||
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]");
|
||||
} else throw new Error("Usage: node scripts/gitea-release.mjs checksums|verify|upload|verify-remote [dist-directory] [--appimage-only|--appimage-windows], 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; });
|
||||
|
||||
Reference in New Issue
Block a user