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
+1 -1
View File
@@ -1,6 +1,6 @@
# Desktop builds and releases
For this fork’s Linux and Windows 0.6.0 release on Gitea, see [Gitea releases](gitea-release.md).
For this fork’s Linux and Windows 0.6.2 release on Gitea, see [Gitea releases](gitea-release.md).
The three-platform GitHub process below documents the upstream reference.
## Platforms
+61 -2
View File
@@ -2,7 +2,8 @@
The release destination is
https://git.sorokin-oleg-edu.xyz/sorokin.oleg.edu/learnharness.
Version **0.6.1 is Linux x64 AppImage-only**; follow the dedicated procedure below.
Version **0.6.2 targets Linux x64 AppImage and Windows x64 NSIS**; follow the
dedicated procedure below. Version 0.6.1 was Linux x64 AppImage-only.
The historical **0.6.0 release targets Linux x64 and Windows x64**, with each platform built
and verified on its native operating system. The upstream GitHub workflow and three-platform packaging
configuration remain available as reference in `.github/workflows/release.yml`
@@ -19,7 +20,65 @@ Gitea's `latest` alias resolves the most recent published, non-prerelease releas
Drafts do not become updates. The repository and published downloads must be
public; no API credential is embedded in the application.
## 0.6.1: Linux x64 AppImage-only upload and verification
## 0.6.2: Linux AppImage and Windows installer
Build both platforms from the same release commit with Node 26. Use a clean
checkout on each native operating system so local credentials, environment
files, application data, and older packages cannot enter the standalone payload.
The Windows installer retains the existing unsigned per-user NSIS configuration.
After dependency installation, run the typecheck, deterministic tests, lint,
native renderer tests, and standalone runtime smoke checks. Include
`tests/desktop-logger.test.ts` under each platform's bundled Electron Node to
exercise the broken-pipe and retention regressions in the shipped runtime.
Curate the standalone payload with `node scripts/build-server.mjs`, then package:
```sh
# Native Linux checkout
npx electron-builder --config electron-builder.gitea.yml --linux AppImage --x64 --publish never
# Native Windows checkout
npx electron-builder --config electron-builder.gitea.yml --win --x64 --publish never
```
Verify each packaged updater with `scripts/gitea-release.mjs verify-package` and
run the provider, server, and browser-helper smoke checks against that package's
own standalone tree and executable. Copy only these six assets into a staging
directory (the checksum command creates the final file):
- `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`
```sh
node scripts/gitea-release.mjs checksums /path/to/staging --appimage-windows
node scripts/gitea-release.mjs verify /path/to/staging --appimage-windows
```
This explicit scope preserves the historical Linux defaults and AppImage-only
mode. It rejects extra staged files, checks both installer headers and updater
feeds against the actual bytes, regenerates the Windows blockmap with the pinned
packager to verify every chunk and full installer coverage, and verifies the
combined SHA-256 manifest. It includes no Debian, RPM, or macOS packages.
Commit and push the release source and its matching annotated `v0.6.2` tag.
Supply the Gitea API token privately, then prepare and verify the draft:
```sh
node scripts/gitea-release.mjs upload /path/to/staging --appimage-windows
node scripts/gitea-release.mjs verify-remote /path/to/staging --appimage-windows
```
The uploader reads notes from `docs/release-0.6.2.md`, requires the remote tag,
keeps the release a stable draft, and downloads all six attachments to compare
their SHA-256 hashes with the validated local files. It refuses published or
ambiguous releases and mismatched existing attachments. Publication follows
human review of the completed draft.
## Historical 0.6.1: Linux x64 AppImage-only upload and verification
This release has exactly three attachments:
+33
View File
@@ -0,0 +1,33 @@
# LearnHarness 0.6.2
## English
This release fixes a critical desktop logging bug that could consume hundreds of
gigabytes of disk space when the application's output pipe closed.
- Stops the repeated `EPIPE` exception loop after a launcher or terminal closes
its output pipe. Uncaught errors are recorded directly to the log file.
- Keeps desktop diagnostics within two files of 5 MiB each, for a maximum of
10 MiB. Existing oversized logs are trimmed on startup, retaining recent data.
- Handles output backpressure, full disks, and log rotation failures without
starting another logging failure loop.
Packages: Linux x64 AppImage and Windows x64 NSIS installer. Both receive updates
from this fork's Gitea releases. The Windows installer remains unsigned.
## Русский
Выпуск исправляет критическую ошибку журналирования: после закрытия канала
вывода приложение могло занять сотни гигабайт на диске.
- Устранён цикл повторных исключений `EPIPE` после закрытия канала вывода
терминалом или программой запуска. Необработанные ошибки записываются
непосредственно в файл журнала.
- Журналы ограничены двумя файлами по 5 МиБ, всего до 10 МиБ. При запуске
прежние слишком большие журналы сокращаются с сохранением последних данных.
- Переполнение канала вывода, заполненный диск и ошибки ротации журналов
больше не вызывают новый цикл ошибок журналирования.
Пакеты: AppImage для Linux x64 и установщик NSIS для Windows x64. Обе версии
получают обновления из выпусков этого форка в Gitea. Установщик Windows
по-прежнему не подписан.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "learnharness",
"version": "0.6.1",
"version": "0.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "learnharness",
"version": "0.6.1",
"version": "0.6.2",
"license": "AGPL-3.0-only",
"dependencies": {
"@earendil-works/pi-agent-core": "1.4.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "learnharness",
"version": "0.6.1",
"version": "0.6.2",
"license": "AGPL-3.0-only",
"author": {
"name": "Amos Blomqvist",
+56 -24
View File
@@ -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; });
+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, []);
}
});