release: prepare 0.6.2 Linux and Windows packages

This commit is contained in:
2026-11-03 15:22:40 +03:00
parent d23daa5578
commit c950b9cbfb
7 changed files with 239 additions and 32 deletions
+85 -2
View File
@@ -6,7 +6,8 @@ import os from "node:os";
import path from "node:path";
import { after, test } from "node:test";
import { createRequire } from "node:module";
import { expectedLinuxAssets, writeChecksums, verifyLocal, verifyRemote, verifyPackagedUpdater, UPDATE_URL } from "../scripts/gitea-release.mjs";
import { gzipSync, gunzipSync } from "node:zlib";
import { expectedLinuxAssets, expectedReleaseAssets, writeChecksums, verifyLocal, verifyRemote, verifyPackagedUpdater, UPDATE_URL } from "../scripts/gitea-release.mjs";
const root = await fs.mkdtemp(path.join(os.tmpdir(), "lh-gitea-gate-"));
after(() => fs.rm(root, { recursive: true, force: true }));
@@ -186,7 +187,7 @@ function mockedReleaseCommand(directory: string, command: string, options: strin
const server = "https://git.sorokin-oleg-edu.xyz";
const api = "/api/v1/repos/sorokin.oleg.edu/learnharness";
const calls = { api: [], uploads: [], downloads: [], edits: [] };
const files = (await fs.readdir(directory)).filter(name => name.endsWith(".AppImage") || name.endsWith(".deb") || ["latest-linux.yml", "SHA256SUMS"].includes(name));
const files = (await fs.readdir(directory)).filter(name => /\.(AppImage|deb|exe|blockmap)$/.test(name) || ["latest-linux.yml", "latest.yml", "SHA256SUMS"].includes(name));
const assets = await Promise.all(files.map(async name => ({ name, size: (await fs.stat(path.join(directory, name))).size, browser_download_url: server + "/sorokin.oleg.edu/learnharness/releases/download/" + tag + "/" + name })));
const release = { id: 1, tag_name: tag, draft: scenario !== "published", prerelease: scenario === "prerelease", assets: scenario === "new" ? [] : [...assets], html_url: server + "/draft" };
if (scenario === "missing-asset") release.assets.pop();
@@ -334,3 +335,85 @@ test("packaged updater verification refuses inherited GitHub or incorrect generi
await fs.writeFile(file, JSON.stringify({ provider: "generic", url: UPDATE_URL }));
await verifyPackagedUpdater(directory);
});
const desktopOptions = { appimageWindows: true };
async function desktopFixture(name: string, fixtureVersion = "0.6.2") {
const directory = await fixture(name, true, fixtureVersion);
const exe = `LearnHarness-${fixtureVersion}-x64-setup.exe`;
const bytes = Buffer.from(`MZ${"synthetic installer content ".repeat(2000)}`);
await fs.writeFile(path.join(directory, exe), bytes);
const require = createRequire(import.meta.url);
await require("app-builder-lib/out/targets/blockmap/blockmap.js").buildBlockMap(path.join(directory, exe), "gzip", path.join(directory, `${exe}.blockmap`));
const entry = { url: exe, size: bytes.length, sha512: createHash("sha512").update(bytes).digest("base64") };
await fs.writeFile(path.join(directory, "latest.yml"), JSON.stringify({ version: fixtureVersion, files: [entry], path: exe, sha512: entry.sha512 }));
await writeChecksums(directory, fixtureVersion, desktopOptions);
return directory;
}
test("AppImage and Windows release has exactly the six approved assets and exclusive scopes", async () => {
assert.deepEqual(expectedReleaseAssets("0.6.2", desktopOptions), [
"LearnHarness-0.6.2-x86_64.AppImage", "LearnHarness-0.6.2-x64-setup.exe", "LearnHarness-0.6.2-x64-setup.exe.blockmap",
"latest-linux.yml", "latest.yml", "SHA256SUMS",
]);
assert.throws(() => expectedReleaseAssets("0.6.2", { ...desktopOptions, appimageOnly: true }), /mutually exclusive/);
assert.deepEqual(expectedReleaseAssets(version), names);
const directory = await desktopFixture("desktop-healthy");
assert.equal((await verifyLocal(directory, "0.6.2", desktopOptions)).length, 6);
await fs.writeFile(path.join(directory, "stale.deb"), "stale asset");
await assert.rejects(verifyLocal(directory, "0.6.2", desktopOptions), /exactly the six approved/);
});
for (const feedName of ["latest-linux.yml", "latest.yml"]) {
for (const mutation of ["version", "path", "hash", "size", "duplicate", "legacy"] as const) {
test(`combined release refuses ${feedName} ${mutation} drift despite fresh SHA256SUMS`, async () => {
const directory = await desktopFixture(`desktop-${feedName}-${mutation}`);
const file = path.join(directory, feedName);
const feed = JSON.parse(await fs.readFile(file, "utf8"));
if (mutation === "version") feed.version = "0.6.1";
if (mutation === "path") feed.files[0].url = "../" + feed.files[0].url;
if (mutation === "hash") feed.files[0].sha512 = "bad";
if (mutation === "size") feed.files[0].size++;
if (mutation === "duplicate") feed.files.push({ ...feed.files[0] });
if (mutation === "legacy") feed.path = "unexpected.exe";
await fs.writeFile(file, JSON.stringify(feed));
await writeChecksums(directory, "0.6.2", desktopOptions);
await assert.rejects(verifyLocal(directory, "0.6.2", desktopOptions), /metadata version|reference exactly|update checksum|update size|legacy updater path/);
});
}
}
for (const mutation of ["checksum", "coverage", "corrupt"] as const) {
test(`combined release checks Windows blockmap ${mutation} against actual EXE bytes`, async () => {
const directory = await desktopFixture(`desktop-blockmap-${mutation}`);
const file = path.join(directory, "LearnHarness-0.6.2-x64-setup.exe.blockmap");
const map = JSON.parse(gunzipSync(await fs.readFile(file)).toString());
if (mutation === "checksum") map.files[0].checksums[0] = "bad";
if (mutation === "coverage") map.files[0].sizes[0]--;
await fs.writeFile(file, mutation === "corrupt" ? "not gzip" : gzipSync(JSON.stringify(map)));
await writeChecksums(directory, "0.6.2", desktopOptions);
await assert.rejects(verifyLocal(directory, "0.6.2", desktopOptions), /Windows blockmap differs|incorrect header/);
});
}
test("combined release uploads and downloads exactly six assets and keeps the release a draft", async () => {
const pkg = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url), "utf8"));
const directory = await desktopFixture("desktop-api", pkg.version);
const expected = expectedReleaseAssets(pkg.version, desktopOptions).sort();
for (const [command, scenario] of [["upload", "new"], ["upload", "draft"], ["verify-remote", "draft"]]) {
const result = mockedReleaseCommand(directory, command!, ["--appimage-windows"], scenario);
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.calls.downloads.sort(), expected);
assert.deepEqual(result.calls.uploads.sort(), scenario === "new" ? expected : []);
assert(result.calls.edits.every(edit => edit.draft));
}
for (const scenario of ["extra-asset", "missing-asset", "corrupt", "published"]) {
const result = mockedReleaseCommand(directory, "verify-remote", ["--appimage-windows"], scenario);
assert.equal(result.status, 1, result.stderr);
assert(result.calls.api.every(call => call.startsWith("GET ")));
}
for (const options of [["--appimage-windows", "--appimage-only"], ["--appimage-windows", "--appimage-windows"]]) {
const result = mockedReleaseCommand(directory, "upload", options);
assert.equal(result.status, 1);
assert.deepEqual(result.calls.api, []);
}
});