Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d5fa94087 | ||
|
|
c7f6f5cf4b | ||
|
|
c950b9cbfb | ||
|
|
d23daa5578 | ||
|
|
c812d49250 |
@@ -12,7 +12,7 @@
|
||||
<a href="https://learnharness.ai">Download</a> ·
|
||||
<a href="#how-it-works">How it works</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>
|
||||
</p>
|
||||
|
||||
@@ -43,7 +43,7 @@ Future teaching sessions can read the graph instead of starting from scratch. Pr
|
||||
|
||||
## 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.
|
||||
- **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).
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
+63
-4
@@ -1,8 +1,9 @@
|
||||
# Gitea releases for this fork
|
||||
|
||||
The release destination is
|
||||
https://git.sorokin-oleg-edu.xyz/replicated/learnharness.
|
||||
Version **0.6.1 is Linux x64 AppImage-only**; follow the dedicated procedure below.
|
||||
https://git.sorokin-oleg-edu.xyz/sorokin.oleg.edu/learnharness.
|
||||
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`
|
||||
@@ -12,14 +13,72 @@ Official fork packages use `electron-builder.gitea.yml`, which inherits the
|
||||
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.
|
||||
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:
|
||||
|
||||
|
||||
@@ -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
|
||||
по-прежнему не подписан.
|
||||
@@ -5,4 +5,4 @@ publish:
|
||||
provider: generic
|
||||
# Gitea resolves the latest published, non-prerelease release here.
|
||||
# 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
|
||||
|
||||
@@ -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
@@ -37,6 +37,7 @@ const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
const { createDesktopLogger } = require("./desktop-logger.cjs");
|
||||
|
||||
// Mirrors SIDEBAR_AUTOCLOSE in components/sidebar.tsx — keep in sync (the
|
||||
// renderer owns the number; this is the IPC widen target).
|
||||
@@ -143,13 +144,20 @@ let subscriptionChild = null;
|
||||
let desktopAPISecret = null;
|
||||
let reopenWindow = null;
|
||||
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
|
||||
// in an event handler): the window + child server are the product, and
|
||||
// anything main-side is recoverable by logging it. Log-and-continue.
|
||||
process.on("uncaughtException", (err) => {
|
||||
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 {
|
||||
/* nothing left to write to */
|
||||
}
|
||||
@@ -177,25 +185,7 @@ function freePort() {
|
||||
* still win over the file inside the child (its documented rule). */
|
||||
|
||||
function log(line) {
|
||||
const stamp = new Date().toISOString();
|
||||
// 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 */
|
||||
}
|
||||
desktopLogger.log(line);
|
||||
}
|
||||
|
||||
function startServer(port) {
|
||||
@@ -656,6 +646,7 @@ async function main() {
|
||||
process.on("SIGHUP", killServer);
|
||||
|
||||
await app.whenReady();
|
||||
desktopLogger.initialize();
|
||||
|
||||
// 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
|
||||
@@ -771,4 +762,4 @@ async function main() {
|
||||
checkForUpdates();
|
||||
setInterval(() => checkForUpdates(), UPDATE_CHECK_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "learnharness",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"license": "AGPL-3.0-only",
|
||||
"author": {
|
||||
"name": "Amos Blomqvist",
|
||||
"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",
|
||||
"description": "An agent harness for learning: adaptive teaching, practice, and a persistent knowledge graph.",
|
||||
"type": "module",
|
||||
|
||||
+57
-25
@@ -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,12 +9,13 @@ 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");
|
||||
const ROOT = path.resolve(import.meta.dirname, "..");
|
||||
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`;
|
||||
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");
|
||||
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; });
|
||||
|
||||
@@ -129,7 +129,7 @@ describe("automatic-update preference gate", () => {
|
||||
f.run("setupAutoUpdater()");
|
||||
await f.check();
|
||||
assert.equal(f.updater.checks, 0);
|
||||
assert.deepEqual(f.settingsReads, ["/fixture/custom-user-data-dev/data/settings.json"]);
|
||||
assert.deepEqual(f.settingsReads, [path.join("/fixture/custom-user-data-dev", "data", "settings.json")]);
|
||||
await f.check(true);
|
||||
assert.equal(f.updater.checks, 1);
|
||||
assert.equal(f.settingsReads.length, 1);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -166,7 +166,7 @@ describe("desktop packaging policy", () => {
|
||||
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
||||
assert.equal(pkg.author.name, "Amos Blomqvist");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }));
|
||||
@@ -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", () => {
|
||||
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"));
|
||||
});
|
||||
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 () => {
|
||||
const directory = await fixture("remote");
|
||||
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 request = async (input: URL | RequestInfo) => {
|
||||
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 tag = "v" + version;
|
||||
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 files = (await fs.readdir(directory)).filter(name => name.endsWith(".AppImage") || name.endsWith(".deb") || ["latest-linux.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 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();
|
||||
if (scenario === "duplicate-asset") release.assets.push(assets[0]);
|
||||
@@ -228,7 +229,7 @@ function mockedReleaseCommand(directory: string, command: string, options: strin
|
||||
return json(asset);
|
||||
}
|
||||
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));
|
||||
assert(files.includes(name), "download outside expected fixture");
|
||||
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 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, []);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ test("Gitea updater identity and release tooling are preserved", () => {
|
||||
const config = source("electron-builder.gitea.yml");
|
||||
assert.match(config, /extends: \.\/electron-builder\.yml/);
|
||||
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"));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user