Compare commits

...
4 Commits
14 changed files with 581 additions and 69 deletions
+3 -3
View File
@@ -12,7 +12,7 @@
<a href="https://learnharness.ai">Download</a> · <a href="https://learnharness.ai">Download</a> ·
<a href="#how-it-works">How it works</a> · <a href="#how-it-works">How it works</a> ·
<a href="docs/development.md">Documentation</a> · <a href="docs/development.md">Documentation</a> ·
<a href="https://github.com/amosblomqvist/learnharness/issues">Issues</a> · <a href="https://git.sorokin-oleg-edu.xyz/sorokin.oleg.edu/learnharness/issues">Issues</a> ·
<a href="https://discord.gg/5tCHHQEB2e">Discord</a> <a href="https://discord.gg/5tCHHQEB2e">Discord</a>
</p> </p>
@@ -43,7 +43,7 @@ Future teaching sessions can read the graph instead of starting from scratch. Pr
## Download and get started ## Download and get started
**[Download from learnharness.ai](https://learnharness.ai)** · [GitHub Releases](https://github.com/amosblomqvist/learnharness/releases) **[Download from learnharness.ai](https://learnharness.ai)** · [Gitea Releases](https://git.sorokin-oleg-edu.xyz/sorokin.oleg.edu/learnharness/releases)
- **macOS (Apple Silicon):** signed and notarized DMG. Drag the app to Applications. - **macOS (Apple Silicon):** signed and notarized DMG. Drag the app to Applications.
- **Windows (x64):** run the installer. It is currently unsigned; Windows may show an “unknown publisher” or SmartScreen warning. - **Windows (x64):** run the installer. It is currently unsigned; Windows may show an “unknown publisher” or SmartScreen warning.
@@ -72,7 +72,7 @@ npm test # deterministic, no LLM, no network
See the [development guide](docs/development.md), [architecture](docs/architecture.md), and [desktop release guide](docs/desktop-release.md). See the [development guide](docs/development.md), [architecture](docs/architecture.md), and [desktop release guide](docs/desktop-release.md).
Questions, bug reports, and feature requests: [GitHub Issues](https://github.com/amosblomqvist/learnharness/issues). Questions, bug reports, and feature requests: [Gitea Issues](https://git.sorokin-oleg-edu.xyz/sorokin.oleg.edu/learnharness/issues).
## License ## License
+2 -2
View File
@@ -1,6 +1,6 @@
# Desktop builds and releases # 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. The three-platform GitHub process below documents the upstream reference.
## Platforms ## Platforms
@@ -15,7 +15,7 @@ Windows signing is deliberately disabled for this first release. SmartScreen may
The custom app bar stays on all three platforms. macOS keeps its left-side traffic lights; Windows/Linux use Electron's native right-side Window Controls Overlay, with CSS space reserved from the overlay's actual geometry. Windows/Linux hide the native menu until Alt is pressed. macOS keeps its system menu. The custom app bar stays on all three platforms. macOS keeps its left-side traffic lights; Windows/Linux use Electron's native right-side Window Controls Overlay, with CSS space reserved from the overlay's actual geometry. Windows/Linux hide the native menu until Alt is pressed. macOS keeps its system menu.
Icons are per-platform (2026-10-13): macOS compiles the Icon Composer document `build/icon.icon` via actool into `Assets.car` (macOS 26 Liquid Glass) plus a fallback icns that `scripts/after-pack.cjs` replaces with the hand-packed full-res `build/icon.icns` (actool's caps at 256px); Windows embeds `build/icon.ico` and Linux uses `build/icon.png`, both generated from the flat design (`build/icon-flat.svg`) by `node scripts/make-icons.mjs`. Building the mac icon needs **macOS 26+ and Xcode 26+** (`actool`): Xcode 26's asset compiler crashes on macOS 15 because its host frameworks lack required symbols. `desktop:dist` injects `DEVELOPER_DIR` locally; release packaging uses `macos-26` and selects Xcode via setup-xcode. The older-OS fallback icon remains included. Linux's desktop identity is `learnharness.desktop`. Public package-maintainer metadata uses the owner's GitHub no-reply address; support goes through [GitHub Issues](https://github.com/amosblomqvist/learnharness/issues). Icons are per-platform (2026-10-13): macOS compiles the Icon Composer document `build/icon.icon` via actool into `Assets.car` (macOS 26 Liquid Glass) plus a fallback icns that `scripts/after-pack.cjs` replaces with the hand-packed full-res `build/icon.icns` (actool's caps at 256px); Windows embeds `build/icon.ico` and Linux uses `build/icon.png`, both generated from the flat design (`build/icon-flat.svg`) by `node scripts/make-icons.mjs`. Building the mac icon needs **macOS 26+ and Xcode 26+** (`actool`): Xcode 26's asset compiler crashes on macOS 15 because its host frameworks lack required symbols. `desktop:dist` injects `DEVELOPER_DIR` locally; release packaging uses `macos-26` and selects Xcode via setup-xcode. The older-OS fallback icon remains included. Linux's desktop identity is `learnharness.desktop`. Public package-maintainer metadata uses the owner's GitHub no-reply address; support goes through [Gitea Issues](https://git.sorokin-oleg-edu.xyz/sorokin.oleg.edu/learnharness/issues).
## Build pipeline ## Build pipeline
+63 -4
View File
@@ -1,8 +1,9 @@
# Gitea releases for this fork # Gitea releases for this fork
The release destination is The release destination is
https://git.sorokin-oleg-edu.xyz/replicated/learnharness. 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 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 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` configuration remain available as reference in `.github/workflows/release.yml`
@@ -12,14 +13,72 @@ Official fork packages use `electron-builder.gitea.yml`, which inherits the
upstream packaging and replaces its update origin with: upstream packaging and replaces its update origin with:
``` ```
https://git.sorokin-oleg-edu.xyz/replicated/learnharness/releases/download/latest https://git.sorokin-oleg-edu.xyz/sorokin.oleg.edu/learnharness/releases/download/latest
``` ```
Gitea's `latest` alias resolves the most recent published, non-prerelease release. Gitea's `latest` alias resolves the most recent published, non-prerelease release.
Drafts do not become updates. The repository and published downloads must be Drafts do not become updates. The repository and published downloads must be
public; no API credential is embedded in the application. 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: 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
по-прежнему не подписан.
+1 -1
View File
@@ -5,4 +5,4 @@ publish:
provider: generic provider: generic
# Gitea resolves the latest published, non-prerelease release here. # Gitea resolves the latest published, non-prerelease release here.
# Drafts remain private and do not become an update before publication. # Drafts remain private and do not become an update before publication.
url: https://git.sorokin-oleg-edu.xyz/replicated/learnharness/releases/download/latest url: https://git.sorokin-oleg-edu.xyz/sorokin.oleg.edu/learnharness/releases/download/latest
+116
View File
@@ -0,0 +1,116 @@
/** Bounded desktop diagnostics. Error paths must never write to a failed pipe. */
const fs = require("node:fs");
const path = require("node:path");
const MAX_LOG_BYTES = 5 * 1024 * 1024;
function createDesktopLogger({ directory, stdout, stderr, filesystem = fs, maxFileBytes = MAX_LOG_BYTES }) {
let initialized = false;
let stdoutEnabled = Boolean(stdout);
let writing = false;
const location = () => path.join(directory(), "main.log");
// Read only the retained tail, even when an older release left a huge file.
function trim(file) {
let handle;
try {
handle = filesystem.openSync(file, "r+");
const size = filesystem.fstatSync(handle).size;
if (size <= maxFileBytes) return;
const tail = Buffer.alloc(maxFileBytes);
let read = 0;
while (read < tail.length) {
const count = filesystem.readSync(handle, tail, read, tail.length - read, size - tail.length + read);
if (!count) break;
read += count;
}
filesystem.writeFileSync(handle, tail.subarray(0, read));
filesystem.ftruncateSync(handle, read);
} catch (error) {
if (error.code !== "ENOENT") throw error;
} finally {
if (handle !== undefined) filesystem.closeSync(handle);
}
}
function initialize() {
try {
filesystem.mkdirSync(directory(), { recursive: true });
trim(location());
trim(`${location()}.1`);
initialized = true;
} catch {
// Diagnostics are best effort; never report a logger failure via itself.
}
}
function record(line) {
const prefix = Buffer.from(`[${new Date().toISOString()}] `);
const body = Buffer.from(String(line));
if (prefix.length + body.length + 1 <= maxFileBytes) return Buffer.concat([prefix, body, Buffer.from("\n")]);
const marker = Buffer.from("[truncated] ");
const tail = body.subarray(body.length - (maxFileBytes - prefix.length - marker.length - 1));
// A retained suffix can start inside a UTF-8 character.
let start = 0;
while (start < tail.length && (tail[start] & 0xc0) === 0x80) start++;
return Buffer.concat([prefix, marker, tail.subarray(start), Buffer.from("\n")]);
}
function append(entry) {
try {
if (!initialized) initialize();
if (!initialized) return;
const file = location();
let size = 0;
try { size = filesystem.statSync(file).size; } catch (error) { if (error.code !== "ENOENT") throw error; }
if (size + entry.length > maxFileBytes) {
filesystem.rmSync(`${file}.1`, { force: true });
filesystem.renameSync(file, `${file}.1`);
}
filesystem.appendFileSync(file, entry);
} catch {
// Includes ENOSPC and rotation failures. Do not fall back to a pipe.
}
}
function log(line, { fileOnly = false } = {}) {
if (writing) return;
writing = true;
try {
const entry = record(line);
append(entry);
if (!fileOnly && stdoutEnabled && !stdout.destroyed && !stdout.writableEnded) {
try {
// A false return means backpressure: skip mirroring until drained.
if (!stdout.write(entry, (error) => { if (error) disableStdout(error); })) stdoutEnabled = false;
} catch (error) {
disableStdout(error);
}
}
} catch {
// Formatting must not escape into the uncaught-exception handler either.
} finally {
writing = false;
}
}
let stdoutFailed = false;
function disableStdout(error) {
stdoutEnabled = false;
if (stdoutFailed) return;
stdoutFailed = true;
append(record(`stdout disabled: ${error?.code ?? error?.message ?? "stream error"}`));
}
stdout?.on("error", disableStdout);
stdout?.on("drain", () => { if (!stdoutFailed) stdoutEnabled = true; });
// Console / dependency writes to stderr can fail asynchronously as well.
let stderrFailed = false;
stderr?.on("error", (error) => {
if (stderrFailed) return;
stderrFailed = true;
log(`stderr disabled: ${error?.code ?? error?.message ?? "stream error"}`, { fileOnly: true });
});
return { log, initialize };
}
module.exports = { createDesktopLogger, MAX_LOG_BYTES };
+12 -21
View File
@@ -37,6 +37,7 @@ const fs = require("node:fs");
const http = require("node:http"); const http = require("node:http");
const net = require("node:net"); const net = require("node:net");
const path = require("node:path"); const path = require("node:path");
const { createDesktopLogger } = require("./desktop-logger.cjs");
// Mirrors SIDEBAR_AUTOCLOSE in components/sidebar.tsx — keep in sync (the // Mirrors SIDEBAR_AUTOCLOSE in components/sidebar.tsx — keep in sync (the
// renderer owns the number; this is the IPC widen target). // renderer owns the number; this is the IPC widen target).
@@ -143,13 +144,20 @@ let subscriptionChild = null;
let desktopAPISecret = null; let desktopAPISecret = null;
let reopenWindow = null; let reopenWindow = null;
const trustedAppWindows = new Set(); const trustedAppWindows = new Set();
const desktopLogger = createDesktopLogger({
directory: () => path.join(app.getPath("userData"), "logs"),
stdout: process.stdout,
stderr: process.stderr,
filesystem: fs,
});
// A GUI shell must never die to a stray exception (EPIPE dialogs, a race // A GUI shell must never die to a stray exception (EPIPE dialogs, a race
// in an event handler): the window + child server are the product, and // in an event handler): the window + child server are the product, and
// anything main-side is recoverable by logging it. Log-and-continue. // anything main-side is recoverable by logging it. Log-and-continue.
process.on("uncaughtException", (err) => { process.on("uncaughtException", (err) => {
try { try {
log(`uncaught: ${err?.stack ?? err}`); // File only: writing to stdout here used to turn EPIPE into a tight loop.
desktopLogger.log(`uncaught: ${err?.stack ?? err}`, { fileOnly: true });
} catch { } catch {
/* nothing left to write to */ /* nothing left to write to */
} }
@@ -177,25 +185,7 @@ function freePort() {
* still win over the file inside the child (its documented rule). */ * still win over the file inside the child (its documented rule). */
function log(line) { function log(line) {
const stamp = new Date().toISOString(); desktopLogger.log(line);
// The stdout write is guarded (EPIPE hardening, 2026-10-10): a packaged
// app launched with a consumer-owned pipe (a launcher that exits, a
// piped run) must not die on its next log line when the pipe breaks.
// The file below stays the durable channel.
try {
console.log(`[lh ${stamp}] ${line}`);
} catch {
/* stdout gone — userData/logs/main.log still carries it */
}
// Belt and braces: also append to userData/logs so a misbehaving child
// can be diagnosed after the window is gone. Best effort only.
try {
const dir = path.join(app.getPath("userData"), "logs");
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(path.join(dir, "main.log"), `[${stamp}] ${line}\n`);
} catch {
/* best effort */
}
} }
function startServer(port) { function startServer(port) {
@@ -656,6 +646,7 @@ async function main() {
process.on("SIGHUP", killServer); process.on("SIGHUP", killServer);
await app.whenReady(); await app.whenReady();
desktopLogger.initialize();
// Dev mode: point at a running `next dev` server (LH_DEV_URL) — no // Dev mode: point at a running `next dev` server (LH_DEV_URL) — no
// spawn, no packaging, full HMR. The rest of this shell still applies // spawn, no packaging, full HMR. The rest of this shell still applies
@@ -771,4 +762,4 @@ async function main() {
checkForUpdates(); checkForUpdates();
setInterval(() => checkForUpdates(), UPDATE_CHECK_INTERVAL_MS); setInterval(() => checkForUpdates(), UPDATE_CHECK_INTERVAL_MS);
} }
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "learnharness", "name": "learnharness",
"version": "0.6.1", "version": "0.6.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "learnharness", "name": "learnharness",
"version": "0.6.1", "version": "0.6.2",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@earendil-works/pi-agent-core": "1.4.0", "@earendil-works/pi-agent-core": "1.4.0",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "learnharness", "name": "learnharness",
"version": "0.6.1", "version": "0.6.2",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"author": { "author": {
"name": "Amos Blomqvist", "name": "Amos Blomqvist",
"email": "255718537+amosblomqvist@users.noreply.github.com" "email": "255718537+amosblomqvist@users.noreply.github.com"
}, },
"homepage": "https://github.com/amosblomqvist/learnharness", "homepage": "https://git.sorokin-oleg-edu.xyz/sorokin.oleg.edu/learnharness",
"desktopName": "learnharness.desktop", "desktopName": "learnharness.desktop",
"description": "An agent harness for learning: adaptive teaching, practice, and a persistent knowledge graph.", "description": "An agent harness for learning: adaptive teaching, practice, and a persistent knowledge graph.",
"type": "module", "type": "module",
+57 -25
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 assert from "node:assert/strict";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
@@ -9,12 +9,13 @@ import path from "node:path";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { gunzipSync } from "node:zlib";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const yaml = require("js-yaml"); const yaml = require("js-yaml");
const ROOT = path.resolve(import.meta.dirname, ".."); const ROOT = path.resolve(import.meta.dirname, "..");
export const SERVER = "https://git.sorokin-oleg-edu.xyz"; export const SERVER = "https://git.sorokin-oleg-edu.xyz";
export const REPOSITORY = "replicated/learnharness"; export const REPOSITORY = "sorokin.oleg.edu/learnharness";
export const UPDATE_URL = `${SERVER}/${REPOSITORY}/releases/download/latest`; export const UPDATE_URL = `${SERVER}/${REPOSITORY}/releases/download/latest`;
const API = `${SERVER}/api/v1/repos/${REPOSITORY}`; const API = `${SERVER}/api/v1/repos/${REPOSITORY}`;
@@ -22,6 +23,13 @@ export function expectedLinuxAssets(version, { appimageOnly = false } = {}) {
assert.match(version, /^\d+\.\d+\.\d+$/, "release version must be a stable semver"); 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"]; 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") { export async function fileDigest(file, algorithm, encoding = "hex") {
const hash = createHash(algorithm); const hash = createHash(algorithm);
for await (const chunk of fs.createReadStream(file)) hash.update(chunk); 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 = {}) { 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 = []; const lines = [];
for (const name of names) { for (const name of names) {
const file = path.join(directory, name); const file = path.join(directory, name);
@@ -48,7 +56,11 @@ export async function verifyPackagedUpdater(directory) {
} }
export async function verifyLocal(directory, version, options = {}) { 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(); const inventory = new Map();
for (const name of names) { for (const name of names) {
const file = path.join(directory, name); 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`); 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") }); inventory.set(name, { name, size: stat.size, sha256: await fileDigest(file, "sha256") });
} }
for (const name of names.filter((name) => /\.(AppImage|deb)$/.test(name))) { 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("!<arch>\n"); 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"); 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(); } 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")); for (const feedName of ["latest-linux.yml", ...(options.appimageWindows ? ["latest.yml"] : [])]) {
assert.equal(feed?.version, version, "update metadata version differs from the package"); const feed = yaml.load(await fsp.readFile(path.join(directory, feedName), "utf8"));
assert(Array.isArray(feed.files), "update metadata must contain files"); assert.equal(feed?.version, version, "update metadata version differs from the package");
const installers = names.filter((name) => /\.(AppImage|deb)$/.test(name)); assert(Array.isArray(feed.files), "update metadata must contain files");
assert.deepEqual(feed.files.map((entry) => entry.url).sort(), [...installers].sort(), "update feed must reference exactly the Linux installers by filename"); const installers = names.filter((name) => (feedName === "latest.yml" ? /\.exe$/ : /\.(AppImage|deb)$/).test(name));
for (const entry of feed.files) { assert.deepEqual(feed.files.map((entry) => entry.url).sort(), [...installers].sort(), "update feed must reference exactly the platform installers by filename");
assert.equal(entry.size, inventory.get(entry.url)?.size, `${entry.url} update size differs`); for (const entry of feed.files) {
assert.equal(entry.sha512, await fileDigest(path.join(directory, entry.url), "sha512", "base64"), `${entry.url} update checksum differs`); 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"); 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"); 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()]; return [...inventory.values()];
@@ -157,24 +183,30 @@ async function upload(directory, version, options = {}) {
async function main() { async function main() {
const [command, ...args] = process.argv.slice(2); const [command, ...args] = process.argv.slice(2);
const appimageOnly = args.includes("--appimage-only"); 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"); const appimageWindows = args.includes("--appimage-windows");
assert(args.filter((arg) => arg === "--appimage-only").length <= 1, "duplicate --appimage-only option"); const scopeFlags = ["--appimage-only", "--appimage-windows"];
assert(args.every((arg) => !arg.startsWith("--") || arg === "--appimage-only"), "unknown release option"); for (const flag of scopeFlags) {
const positional = args.filter((arg) => arg !== "--appimage-only"); 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"); assert(positional.length <= 1, "expected at most one release directory");
const directory = positional[0] ?? "dist"; const directory = positional[0] ?? "dist";
const { version } = JSON.parse(await fsp.readFile(path.join(ROOT, "package.json"), "utf8")); 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-package") await verifyPackagedUpdater(directory);
else if (command === "verify") console.log(JSON.stringify(await verifyLocal(directory, version, { appimageOnly }), null, 2)); else if (command === "verify") console.log(JSON.stringify(await verifyLocal(directory, version, options), null, 2));
else if (command === "upload") await upload(directory, version, { appimageOnly }); else if (command === "upload") await upload(directory, version, options);
else if (command === "verify-remote") { 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}`); const release = await selectDraft(`v${version}`);
assert(release, "draft not found"); assert(release, "draft not found");
await verifyRemote(release, inventory, authorization()); await verifyRemote(release, inventory, authorization());
console.log(`Verified draft: ${release.html_url}`); 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) { if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
main().catch((error) => { console.error(error.message); process.exitCode = 1; }); main().catch((error) => { console.error(error.message); process.exitCode = 1; });
+198
View File
@@ -0,0 +1,198 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { EventEmitter, once } from "node:events";
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { describe, it } from "node:test";
import type { TestContext } from "node:test";
import { fileURLToPath } from "node:url";
import { createContext, runInContext } from "node:vm";
const require = createRequire(import.meta.url);
const { createDesktopLogger, MAX_LOG_BYTES } = require("../electron/desktop-logger.cjs");
const mainFile = fileURLToPath(new URL("../electron/main.cjs", import.meta.url));
const mainRequire = createRequire(mainFile);
function directory(t: TestContext) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-desktop-logger-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
return dir;
}
function retained(dir: string, cap: number) {
const names = fs.readdirSync(dir).sort();
assert.ok(names.length <= 2);
for (const name of names) {
assert.ok(["main.log", "main.log.1"].includes(name));
assert.ok(fs.statSync(path.join(dir, name)).size <= cap, `${name} exceeds the byte cap`);
}
return ["main.log.1", "main.log"].filter(name => names.includes(name)).map(name => fs.readFileSync(path.join(dir, name), "utf8")).join("");
}
class Output extends EventEmitter {
writes = 0;
write() { this.writes++; return true; }
}
describe("bounded desktop logs", () => {
it("rotates repeated floods within two 5 MiB files and keeps the latest entries", (t) => {
const dir = directory(t);
assert.equal(MAX_LOG_BYTES, 5 * 1024 * 1024);
const logger = createDesktopLogger({ directory: () => dir });
for (let i = 0; i < 35; i++) logger.log(`${i} ${"x".repeat(1024 * 1024)}`);
logger.log("most recent diagnostic");
const text = retained(dir, MAX_LOG_BYTES);
assert.match(text, /most recent diagnostic\n$/);
assert.doesNotMatch(text, /\] 0 /);
});
it("trims both existing files on startup using a bounded tail read", (t) => {
const dir = directory(t);
for (const name of ["main.log", "main.log.1"]) {
const file = path.join(dir, name);
// Unix creates a sparse hole automatically. Windows can allocate the
// entire gap, so keep that fixture small while still exceeding the cap.
const legacySize = process.platform === "win32" ? MAX_LOG_BYTES * 3 : 200 * 1024 ** 3;
const fd = fs.openSync(file, "w");
fs.writeSync(fd, Buffer.from(`recent ${name}\n`), 0, Buffer.byteLength(`recent ${name}\n`), legacySize);
fs.closeSync(fd);
}
let largestRead = 0;
const filesystem = { ...fs, readSync: (...args: Parameters<typeof fs.readSync>) => {
largestRead = Math.max(largestRead, (args[1] as Buffer).byteLength);
return fs.readSync(...args);
} };
const logger = createDesktopLogger({ directory: () => dir, filesystem });
logger.initialize();
assert.ok(largestRead <= MAX_LOG_BYTES);
assert.match(retained(dir, MAX_LOG_BYTES), /recent main.log\n$/);
assert.match(fs.readFileSync(path.join(dir, "main.log.1"), "utf8"), /recent main.log.1\n$/);
logger.log("after restart");
createDesktopLogger({ directory: () => dir }).log("after second restart");
assert.match(retained(dir, MAX_LOG_BYTES), /after second restart\n$/);
});
it("caps one oversized Unicode entry by bytes, retaining valid UTF-8 and its suffix", (t) => {
const dir = directory(t);
const logger = createDesktopLogger({ directory: () => dir, maxFileBytes: 256 });
logger.log(`${"🙂".repeat(1000)}latest`);
const text = retained(dir, 256);
assert.match(text, /\[truncated\]/);
assert.match(text, /latest\n$/);
assert.ok(!text.includes("�"));
});
it("disables stdout permanently on asynchronous errors, with one file-only diagnostic", (t) => {
const dir = directory(t);
const stdout = new Output();
const stderr = new Output();
const logger = createDesktopLogger({ directory: () => dir, stdout, stderr });
logger.log("before failure");
const error = Object.assign(new Error("broken pipe"), { code: "EPIPE" });
stdout.emit("error", error);
stdout.emit("drain");
stdout.emit("error", error);
stderr.emit("error", error);
stderr.emit("error", error);
logger.log("after failure");
assert.equal(stdout.writes, 1);
const text = retained(dir, MAX_LOG_BYTES);
assert.equal(text.match(/stdout disabled/g)?.length, 1);
assert.equal(text.match(/stderr disabled/g)?.length, 1);
assert.match(text, /after failure/);
});
it("handles synchronous write failures and bounds buffering under backpressure", (t) => {
const dir = directory(t);
const stdout = new Output();
stdout.write = () => { stdout.writes++; throw Object.assign(new Error("closed"), { code: "EPIPE" }); };
const logger = createDesktopLogger({ directory: () => dir, stdout });
assert.doesNotThrow(() => { logger.log("first"); logger.log("second"); });
assert.equal(stdout.writes, 1);
const slow = new Output();
slow.write = () => { slow.writes++; return false; };
const slowLogger = createDesktopLogger({ directory: () => dir, stdout: slow });
for (let i = 0; i < 100; i++) slowLogger.log(`slow ${i}`);
assert.equal(slow.writes, 1);
slow.emit("drain");
slowLogger.log("drained");
assert.equal(slow.writes, 2);
});
it("never propagates full-disk or rotation errors or mirrors file-only errors", (t) => {
const dir = directory(t);
const stdout = new Output();
const logger = createDesktopLogger({ directory: () => dir, stdout, filesystem: {
...fs, appendFileSync() { throw Object.assign(new Error("disk full"), { code: "ENOSPC" }); },
} });
assert.doesNotThrow(() => logger.log("uncaught: diagnostic", { fileOnly: true }));
assert.equal(stdout.writes, 0);
const file = path.join(dir, "main.log");
fs.writeFileSync(file, "old\n".repeat(60));
const blockedRotation = createDesktopLogger({ directory: () => dir, maxFileBytes: 256, filesystem: {
...fs, renameSync() { throw new Error("rotation denied"); },
} });
assert.doesNotThrow(() => blockedRotation.log("new".repeat(100)));
assert.equal(fs.statSync(file).size, 240, "a failed rotation must not append beyond the cap");
});
});
describe("desktop shell logging integration", () => {
it("records uncaught errors without touching stdout", (t) => {
const dir = directory(t);
const handlers = new Map<string, (error: Error) => void>();
const stdout = new Output();
const context = createContext({
require: (id: string) => id === "electron" ? { app: { setName() {}, getPath: () => dir, requestSingleInstanceLock: () => false, quit() {} } } : mainRequire(id),
process: { argv: [], env: {}, stdout, stderr: new Output(), on: (name: string, handler: (error: Error) => void) => handlers.set(name, handler) },
__dirname: path.dirname(mainFile),
});
runInContext(fs.readFileSync(mainFile, "utf8"), context, { filename: mainFile });
handlers.get("uncaughtException")!(new Error("original failure"));
assert.equal(stdout.writes, 0);
assert.match(retained(path.join(dir, "logs"), MAX_LOG_BYTES), /uncaught: Error: original failure/);
});
it("survives a real closed stdout pipe, records EPIPE once, and exits normally", async (t) => {
const dir = directory(t);
const source = `
const fs = require('node:fs');
const vm = require('node:vm');
const mainFile = ${JSON.stringify(mainFile)};
const mainRequire = require('node:module').createRequire(mainFile);
const context = vm.createContext({
require: id => id === 'electron' ? {app: {setName() {}, getPath: () => ${JSON.stringify(dir)}, requestSingleInstanceLock: () => false, quit() {}}} : mainRequire(id),
process, console, __dirname: require('node:path').dirname(mainFile)
});
vm.runInContext(fs.readFileSync(mainFile, 'utf8'), context, {filename: mainFile});
process.once('message', () => {
vm.runInContext('log("trigger broken pipe")', context);
setTimeout(() => {
vm.runInContext('log("still alive after broken pipe")', context);
process.disconnect();
}, 100);
});
process.send('ready');
`;
const child = spawn(process.execPath, ["-e", source], { stdio: ["ignore", "pipe", "pipe", "ipc"] });
t.after(() => { if (child.exitCode === null) child.kill("SIGKILL"); });
const deadline = setTimeout(() => child.kill("SIGKILL"), 5000);
t.after(() => clearTimeout(deadline));
let stderr = "";
child.stderr!.on("data", chunk => { stderr = (stderr + chunk).slice(-4000); });
const exited = once(child, "exit");
await Promise.race([once(child, "message"), exited.then(() => { throw new Error(`Fixture exited before ready: ${stderr}`); })]);
child.stdout!.destroy();
child.send("start");
const [code, signal] = await exited;
assert.equal(signal, null, stderr);
assert.equal(code, 0, stderr);
const text = retained(path.join(dir, "logs"), MAX_LOG_BYTES);
assert.equal(text.match(/EPIPE/g)?.length, 1);
assert.doesNotMatch(text, /uncaught:/);
assert.match(text, /still alive after broken pipe/);
assert.ok(Buffer.byteLength(text) < 1024, "a closed pipe must not produce a log flood");
});
});
+1 -1
View File
@@ -166,7 +166,7 @@ describe("desktop packaging policy", () => {
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
assert.equal(pkg.author.name, "Amos Blomqvist"); assert.equal(pkg.author.name, "Amos Blomqvist");
assert.equal(pkg.author.email, "255718537+amosblomqvist@users.noreply.github.com"); assert.equal(pkg.author.email, "255718537+amosblomqvist@users.noreply.github.com");
assert.equal(pkg.homepage, "https://github.com/amosblomqvist/learnharness"); assert.equal(pkg.homepage, "https://git.sorokin-oleg-edu.xyz/sorokin.oleg.edu/learnharness");
assert.equal(pkg.desktopName, "learnharness.desktop"); assert.equal(pkg.desktopName, "learnharness.desktop");
}); });
}); });
+90 -7
View File
@@ -6,7 +6,8 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { after, test } from "node:test"; import { after, test } from "node:test";
import { createRequire } from "node:module"; 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-")); const root = await fs.mkdtemp(path.join(os.tmpdir(), "lh-gitea-gate-"));
after(() => fs.rm(root, { recursive: true, force: true })); after(() => fs.rm(root, { recursive: true, force: true }));
@@ -26,7 +27,7 @@ async function fixture(name: string, appimageOnly = false, fixtureVersion = vers
test("Linux release inventory and Gitea feed match the current release scope", () => { test("Linux release inventory and Gitea feed match the current release scope", () => {
assert.deepEqual(names, ["LearnHarness-0.4.0-x86_64.AppImage", "LearnHarness-0.4.0-amd64.deb", "latest-linux.yml", "SHA256SUMS"]); assert.deepEqual(names, ["LearnHarness-0.4.0-x86_64.AppImage", "LearnHarness-0.4.0-amd64.deb", "latest-linux.yml", "SHA256SUMS"]);
assert.equal(UPDATE_URL, "https://git.sorokin-oleg-edu.xyz/replicated/learnharness/releases/download/latest"); assert.equal(UPDATE_URL, "https://git.sorokin-oleg-edu.xyz/sorokin.oleg.edu/learnharness/releases/download/latest");
assert.throws(() => expectedLinuxAssets("0.4.0-preview")); assert.throws(() => expectedLinuxAssets("0.4.0-preview"));
}); });
test("local verification checks actual installer bytes, updater hashes, sizes and checksum manifest", async () => { test("local verification checks actual installer bytes, updater hashes, sizes and checksum manifest", async () => {
@@ -61,7 +62,7 @@ test("empty files and arbitrary downloads cannot pass as installers", async () =
test("remote verification downloads and hashes every draft attachment, and refuses published or incomplete releases", async () => { test("remote verification downloads and hashes every draft attachment, and refuses published or incomplete releases", async () => {
const directory = await fixture("remote"); const directory = await fixture("remote");
const inventory = await verifyLocal(directory, version); const inventory = await verifyLocal(directory, version);
const assets = inventory.map((asset) => ({ ...asset, browser_download_url: `https://git.sorokin-oleg-edu.xyz/replicated/learnharness/releases/download/v0.4.0/${asset.name}` })); const assets = inventory.map((asset) => ({ ...asset, browser_download_url: `https://git.sorokin-oleg-edu.xyz/sorokin.oleg.edu/learnharness/releases/download/v0.4.0/${asset.name}` }));
const release = { draft: true, prerelease: false, assets }; const release = { draft: true, prerelease: false, assets };
const request = async (input: URL | RequestInfo) => { const request = async (input: URL | RequestInfo) => {
const url = input instanceof URL ? input : new URL(String(input)); const url = input instanceof URL ? input : new URL(String(input));
@@ -184,10 +185,10 @@ function mockedReleaseCommand(directory: string, command: string, options: strin
const { version } = JSON.parse(await fs.readFile(${JSON.stringify(path.resolve(import.meta.dirname, "../package.json"))}, "utf8")); const { version } = JSON.parse(await fs.readFile(${JSON.stringify(path.resolve(import.meta.dirname, "../package.json"))}, "utf8"));
const tag = "v" + version; const tag = "v" + version;
const server = "https://git.sorokin-oleg-edu.xyz"; const server = "https://git.sorokin-oleg-edu.xyz";
const api = "/api/v1/repos/replicated/learnharness"; const api = "/api/v1/repos/sorokin.oleg.edu/learnharness";
const calls = { api: [], uploads: [], downloads: [], edits: [] }; 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 + "/replicated/learnharness/releases/download/" + tag + "/" + 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" }; 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(); if (scenario === "missing-asset") release.assets.pop();
if (scenario === "duplicate-asset") release.assets.push(assets[0]); if (scenario === "duplicate-asset") release.assets.push(assets[0]);
@@ -228,7 +229,7 @@ function mockedReleaseCommand(directory: string, command: string, options: strin
return json(asset); return json(asset);
} }
if (method === "GET" && url.pathname === api + "/releases/1") return json(release); if (method === "GET" && url.pathname === api + "/releases/1") return json(release);
} else if (method === "GET" && url.pathname.startsWith("/replicated/learnharness/releases/download/" + tag + "/")) { } else if (method === "GET" && url.pathname.startsWith("/sorokin.oleg.edu/learnharness/releases/download/" + tag + "/")) {
const name = decodeURIComponent(path.basename(url.pathname)); const name = decodeURIComponent(path.basename(url.pathname));
assert(files.includes(name), "download outside expected fixture"); assert(files.includes(name), "download outside expected fixture");
calls.downloads.push(name); calls.downloads.push(name);
@@ -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 fs.writeFile(file, JSON.stringify({ provider: "generic", url: UPDATE_URL }));
await verifyPackagedUpdater(directory); 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, []);
}
});
+1 -1
View File
@@ -51,7 +51,7 @@ test("Gitea updater identity and release tooling are preserved", () => {
const config = source("electron-builder.gitea.yml"); const config = source("electron-builder.gitea.yml");
assert.match(config, /extends: \.\/electron-builder\.yml/); assert.match(config, /extends: \.\/electron-builder\.yml/);
assert.match(config, /provider: generic/); assert.match(config, /provider: generic/);
assert.match(config, /url: https:\/\/server2\.sorokin-oleg-edu\.xyz\/replicated\/learnharness\/releases\/download\/latest/); assert.match(config, /url: https:\/\/git\.sorokin-oleg-edu\.xyz\/sorokin\.oleg\.edu\/learnharness\/releases\/download\/latest/);
assert.ok(tracked.has("scripts/gitea-release.mjs")); assert.ok(tracked.has("scripts/gitea-release.mjs"));
}); });