chore: prepare 0.4.0 Linux release on Gitea

This commit is contained in:
2026-10-25 17:33:37 +03:00
parent a50ca4b867
commit 674d4bd8c6
10 changed files with 479 additions and 4 deletions
+59
View File
@@ -0,0 +1,59 @@
name: Linux desktop release
# Manual until a native Linux runner is registered. The current 0.4.0
# packages are built locally using the identical gates documented below.
on:
workflow_dispatch:
inputs:
tag:
description: Existing stable version tag to package (for example v0.4.0)
required: true
type: string
permissions:
contents: read
releases: write
concurrency:
group: gitea-release-${{ inputs.tag }}
cancel-in-progress: false
jobs:
linux:
runs-on: ubuntu-24.04
timeout-minutes: 60
env:
NEXT_TELEMETRY_DISABLED: "1"
steps:
- uses: https://github.com/actions/checkout@v7
with:
ref: ${{ inputs.tag }}
persist-credentials: false
- uses: https://github.com/actions/setup-node@v7
with:
node-version: "26"
cache: npm
- name: Tag must match package.json
env:
RELEASE_TAG: ${{ inputs.tag }}
run: node --input-type=module -e 'import fs from "node:fs"; if (process.env.RELEASE_TAG !== `v${JSON.parse(fs.readFileSync("package.json")).version}`) process.exit(1)'
- run: npm ci
- run: npm run typecheck
- run: npm test
- run: npm run lint
- name: Native PDF and SVG renderers
run: npx electron --import tsx --test tests/pdf-native-render.test.ts tests/pdf-ops.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts
env:
NODE_ENV: test
ELECTRON_RUN_AS_NODE: "1"
- name: Build, smoke, package, and verify Linux installers
run: npm run desktop:release:linux
- name: Verify the packaged provider and demo runtimes
run: |
node scripts/smoke-provider-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone
node scripts/smoke-provider-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness
xvfb-run -a node scripts/smoke-demo-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness --browser
- name: Upload and verify the draft (never publish)
run: node scripts/gitea-release.mjs upload dist
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
+2 -1
View File
@@ -4,7 +4,8 @@ data/
*.log *.log
.DS_Store .DS_Store
*.tsbuildinfo *.tsbuildinfo
.env.local .env
.env.*
.memory/ .memory/
SCOPE.md SCOPE.md
# raw real-data import sources (user directive 2026-09-13: never commit) # raw real-data import sources (user directive 2026-09-13: never commit)
+3
View File
@@ -1,5 +1,8 @@
# Desktop builds and releases # Desktop builds and releases
For this fork’s Linux 0.4.0 release on Gitea, see [Gitea releases](gitea-release.md).
The three-platform GitHub process below documents the upstream reference.
## Platforms ## Platforms
| Platform | Build runner | Packages | In-app update | | Platform | Build runner | Packages | In-app update |
+103
View File
@@ -0,0 +1,103 @@
# Gitea releases for this fork
The release destination is
https://server2.sorokin-oleg-edu.xyz/replicated/learnharness.
Version **0.4.0 targets Linux x64**. Windows is deferred until a native Windows
builder is available. The upstream GitHub workflow and three-platform packaging
configuration remain available as reference in `.github/workflows/release.yml`
and `electron-builder.yml`.
Official fork packages use `electron-builder.gitea.yml`, which inherits the
upstream packaging and replaces its update origin with:
```
https://server2.sorokin-oleg-edu.xyz/replicated/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.
## Preparing Linux packages
Use Node **26**, matching upstream's release gates. Build from a clean checkout
of the intended release commit. Keep `.env*`, local keys, and application data out
of the build directory. Next can copy loaded environment files after tracing;
`build-server.mjs` refuses a payload containing them. A clean worktree avoids
loading these files in the first place.
```sh
npm ci
npm run typecheck
npm test
npm run lint
ELECTRON_RUN_AS_NODE=1 npx electron --import tsx --test tests/pdf-native-render.test.ts tests/pdf-ops.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts
npm run desktop:release:linux
```
The release script builds the standalone server, initializes providers offline,
curates the payload, boots it under Electron's bundled Node, packages AppImage
and Debian installers, creates SHA-256 checksums, and verifies update metadata
against the actual installer bytes.
Run the packaged-runtime gates too:
```sh
node scripts/smoke-provider-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone
node scripts/smoke-provider-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness
xvfb-run -a node scripts/smoke-demo-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness --browser
```
The Linux 0.4.0 release has exactly four attachments:
- `LearnHarness-0.4.0-x86_64.AppImage`
- `LearnHarness-0.4.0-amd64.deb`
- `latest-linux.yml`
- `SHA256SUMS`
## Preparing the Gitea draft
Commit and push the release source, then push its exact annotated tag:
```sh
git tag -a v0.4.0 -m 'LearnHarness 0.4.0'
git push gitea main v0.4.0
```
Supply `GITEA_TOKEN` only to the upload command. A token file may remain outside
the clean build checkout:
```sh
node --env-file=/path/to/private/release.env scripts/gitea-release.mjs upload dist
```
The tool requires the tag to exist in Gitea, creates or reuses one draft at that
tag, uploads missing assets, and downloads every attachment to verify its actual
SHA-256 against the local verified build. Existing mismatched attachments and
published releases are refused. It never publishes or changes repository
visibility. Release notes come from `docs/release-0.4.0.md`.
A separate verification can be repeated without changing the draft:
```sh
node --env-file=/path/to/private/release.env scripts/gitea-release.mjs verify-remote dist
```
Review the verified draft before publication. Only after publication will the
public `latest` feed and in-app automatic updates expose this version.
## Gitea Actions
`.gitea/workflows/release.yml` provides the same Linux gates through a manual
workflow with an existing release tag as input. It requires a native Linux x64
runner labeled `ubuntu-24.04`, the label used by upstream's Linux release job,
plus the native Electron dependencies and `xvfb-run`. There were no runners
registered when 0.4.0 was prepared, so its packages are built locally. A tag push
does not queue a job that cannot run.
The workflow uses the repository's ephemeral `GITEA_TOKEN` with release-write
permission only in the final upload step. It checks out the requested tag without
persisting credentials and leaves the result as a verified draft. Public pull
requests cannot trigger this publishing workflow. Add Windows only with a native
Windows build and its corresponding smoke checks; do not substitute a Linux
standalone payload into a Windows installer.
+58
View File
@@ -0,0 +1,58 @@
# LearnHarness 0.4.0
## English
LearnHarness now supports English and Russian throughout the interface and new
AI learning content. Switch languages in **Settings → General**. The interface
language and speech recognition language are independent and persist across
restarts; System default follows the browser or operating system, using English
for unsupported languages.
- Russian translations cover lessons, practice, the knowledge graph, setup,
settings, dialogs, tooltips, accessibility labels, notifications, exports,
desktop menus, and application error messages.
- New teaching, questions, plans, practice, graph descriptions, research,
diagrams, and interactive demos follow the interface language.
- Choose English or Russian speech recognition next to the microphone or in
General settings. A recording retains its language through reconnections;
changing the preference affects the next recording.
- Russian text uses a Cyrillic-capable reading font, correct plural forms,
localized numbers, and filenames that preserve Cyrillic characters.
- Language changes preserve other settings. Existing conversations and imported
learning material retain their original text.
This Gitea release targets **Linux x64**: AppImage and Debian packages. Windows
packaging is deferred until a native Windows builder is available. Automatic
updates for these packages use this fork's public Gitea releases.
To use live dictation, configure a Deepgram key under **Settings → Providers**.
To start AI sessions, connect an AI provider and select the required model tiers.
## Русский
LearnHarness теперь поддерживает английский и русский языки во всём интерфейсе
и в новых учебных материалах, создаваемых ИИ. Язык можно выбрать в разделе
**Настройки → Общие**. Язык интерфейса и язык распознавания речи выбираются
независимо и сохраняются после перезапуска. В режиме «Язык системы» используется
язык браузера или операционной системы; для неподдерживаемых языков — английский.
- Переведены занятия, практика, граф знаний, первоначальная настройка,
параметры приложения, диалоги, подсказки, подписи для программ чтения с экрана,
уведомления, экспорт, меню настольного приложения и сообщения об ошибках.
- Новые объяснения, вопросы, планы, практические задачи, описания в графе,
исследования, диаграммы и интерактивные демонстрации используют язык интерфейса.
- Английский или русский язык распознавания речи можно выбрать рядом с микрофоном
или в общих настройках. Запись сохраняет выбранный язык при восстановлении
соединения; изменение настройки применяется к следующей записи.
- Для русского текста добавлен шрифт с кириллицей, правильные формы множественного
числа, локализованное отображение чисел и поддержка кириллицы в именах файлов.
- Смена языка сохраняет остальные настройки. Существующие разговоры и
импортированные учебные материалы остаются на исходном языке.
Этот выпуск в Gitea предназначен для **Linux x64** и доступен в форматах AppImage
и Debian. Выпуск для Windows отложен до появления сборочной машины с Windows.
Автоматические обновления этих пакетов используют публичные выпуски этого форка
в Gitea.
Для диктовки добавьте ключ Deepgram в разделе **Настройки → Провайдеры**.
Для занятий с ИИ подключите провайдера и выберите модели для всех нужных уровней.
+7
View File
@@ -0,0 +1,7 @@
# Official packages for this fork. Upstream packaging stays in electron-builder.yml.
extends: ./electron-builder.yml
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://server2.sorokin-oleg-edu.xyz/replicated/learnharness/releases/download/latest
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "learnharness", "name": "learnharness",
"version": "0.3.7", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "learnharness", "name": "learnharness",
"version": "0.3.7", "version": "0.4.0",
"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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "learnharness", "name": "learnharness",
"version": "0.3.7", "version": "0.4.0",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"author": { "author": {
"name": "Amos Blomqvist", "name": "Amos Blomqvist",
@@ -19,6 +19,7 @@
"test": "NODE_ENV=test tsx --test tests/*.test.ts tests/*.test.tsx", "test": "NODE_ENV=test tsx --test tests/*.test.ts tests/*.test.tsx",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"desktop:release:linux": "npm run build && node scripts/smoke-provider-runtime.mjs && node scripts/build-server.mjs && node scripts/smoke-server.mjs --electron && electron-builder --config electron-builder.gitea.yml --linux --x64 --publish never && node scripts/gitea-release.mjs checksums dist && node scripts/gitea-release.mjs verify dist",
"desktop:dev": "concurrently -k \"next dev\" \"LH_DEV_URL=http://localhost:3000 electron .\"", "desktop:dev": "concurrently -k \"next dev\" \"LH_DEV_URL=http://localhost:3000 electron .\"",
"dev:fresh": "bash scripts/dev-fresh.sh", "dev:fresh": "bash scripts/dev-fresh.sh",
"desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/smoke-provider-runtime.mjs && node scripts/build-server.mjs && node scripts/smoke-server.mjs && DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64" "desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/smoke-provider-runtime.mjs && node scripts/build-server.mjs && node scripts/smoke-server.mjs && DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64"
+160
View File
@@ -0,0 +1,160 @@
/** Prepare and verify this fork's Linux release; publication is always a separate human action. */
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const yaml = require("js-yaml");
const ROOT = path.resolve(import.meta.dirname, "..");
export const SERVER = "https://server2.sorokin-oleg-edu.xyz";
export const REPOSITORY = "replicated/learnharness";
export const UPDATE_URL = `${SERVER}/${REPOSITORY}/releases/download/latest`;
const API = `${SERVER}/api/v1/repos/${REPOSITORY}`;
export function expectedLinuxAssets(version) {
assert.match(version, /^\d+\.\d+\.\d+$/, "release version must be a stable semver");
return [`LearnHarness-${version}-x86_64.AppImage`, `LearnHarness-${version}-amd64.deb`, "latest-linux.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);
return hash.digest(encoding);
}
export async function writeChecksums(directory, version) {
const names = expectedLinuxAssets(version).filter((name) => name !== "SHA256SUMS");
const lines = [];
for (const name of names) lines.push(`${await fileDigest(path.join(directory, name), "sha256")} ${name}`);
await fsp.writeFile(path.join(directory, "SHA256SUMS"), lines.join("\n") + "\n");
}
export async function verifyLocal(directory, version) {
const names = expectedLinuxAssets(version);
const inventory = new Map();
for (const name of names) {
const file = path.join(directory, name);
const stat = await fsp.lstat(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") });
}
for (const [name, magic] of [[names[0], Buffer.from([0x7f, 0x45, 0x4c, 0x46])], [names[1], Buffer.from("!<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`);
}
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()];
}
/** The downloaded bytes, not only API-reported sizes, must agree with the local verified package. */
export async function verifyRemote(release, localInventory, headers = {}, request = fetch) {
assert.equal(release.draft, true, "only a draft may be prepared or verified by this tool");
assert.equal(release.prerelease, false, "0.4.0 is a stable release");
assert.deepEqual(release.assets.map((asset) => asset.name).sort(), localInventory.map((asset) => asset.name).sort(), "remote draft has missing, duplicate, or unexpected assets");
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), "lh-release-verify-"));
try {
for (const expected of localInventory) {
const asset = release.assets.find((entry) => entry.name === expected.name);
assert.equal(asset.size, expected.size, `${expected.name} remote size differs`);
const url = new URL(asset.browser_download_url);
assert.equal(url.origin, SERVER, "release attachment points outside Gitea");
const response = await request(url, { headers, signal: AbortSignal.timeout(300_000) });
assert(response.ok && response.body, `cannot download ${expected.name}: HTTP ${response.status}`);
const file = path.join(temporary, expected.name);
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(file));
assert.equal(await fileDigest(file, "sha256"), expected.sha256, `${expected.name} remote bytes differ`);
}
} finally { await fsp.rm(temporary, { recursive: true, force: true }); }
}
function authorization() {
const token = process.env.GITEA_TOKEN || process.env.GITEA_ACCESS_TOKEN || process.env.GITEA_API_TOKEN;
assert(token, "GITEA_TOKEN is required for Gitea API operations");
return { Authorization: `token ${token}` };
}
async function api(endpoint, options = {}) {
const headers = { ...authorization(), ...options.headers };
const response = await fetch(`${API}${endpoint}`, { ...options, headers, signal: AbortSignal.timeout(300_000) });
if (!response.ok) throw new Error(`Gitea ${options.method ?? "GET"} ${endpoint}: HTTP ${response.status}`);
return response.status === 204 ? null : response.json();
}
export async function selectDraft(tag) {
const releases = [];
for (let page = 1; ; page++) {
const batch = await api(`/releases?limit=50&page=${page}`);
assert(Array.isArray(batch), "release list response is invalid");
releases.push(...batch);
if (batch.length < 50) break;
}
const matches = releases.filter((release) => release.tag_name === tag || release.tag_name === tag.slice(1));
assert(matches.length <= 1, `ambiguous releases for ${tag}`);
if (matches.length) {
assert.equal(matches[0].tag_name, tag, "use the exact versioned tag");
assert.equal(matches[0].draft, true, "refusing to modify an already published release");
}
return matches[0] ?? null;
}
async function upload(directory, version) {
const inventory = await verifyLocal(directory, version);
const tag = `v${version}`;
// The tag must already exist remotely; never create a release from a moving branch.
const tagged = await api(`/tags/${encodeURIComponent(tag)}`);
assert(tagged.commit?.sha, "release tag does not resolve to a commit");
let release = await selectDraft(tag);
const notes = await fsp.readFile(path.join(ROOT, "docs", `release-${version}.md`), "utf8");
const data = { tag_name: tag, target_commitish: tagged.commit.sha, name: `LearnHarness ${version}`, body: notes, draft: true, prerelease: false };
release = await api(release ? `/releases/${release.id}` : "/releases", { method: release ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) });
for (const expected of inventory) {
const existing = (release.assets ?? []).find((asset) => asset.name === expected.name);
if (existing) {
assert.equal(existing.size, expected.size, `${expected.name} already exists with different bytes; review the draft before replacing it`);
// Verify the bytes below, including an existing same-size attachment.
continue;
}
const bytes = await fsp.readFile(path.join(directory, expected.name));
const form = new FormData();
form.set("attachment", new Blob([bytes]), expected.name);
await api(`/releases/${release.id}/assets?name=${encodeURIComponent(expected.name)}`, { method: "POST", body: form });
console.log(`uploaded ${expected.name}`);
}
release = await api(`/releases/${release.id}`);
await verifyRemote(release, inventory, authorization());
console.log(`Verified draft: ${release.html_url}`);
console.log("Publication remains a separate review step.");
}
async function main() {
const [command, directory = "dist"] = process.argv.slice(2);
const { version } = JSON.parse(await fsp.readFile(path.join(ROOT, "package.json"), "utf8"));
if (command === "checksums") await writeChecksums(directory, version);
else if (command === "verify") console.log(JSON.stringify(await verifyLocal(directory, version), null, 2));
else if (command === "upload") await upload(directory, version);
else if (command === "verify-remote") {
const inventory = await verifyLocal(directory, version);
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]");
}
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
main().catch((error) => { console.error(error.message); process.exitCode = 1; });
}
+83
View File
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
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, 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 }));
const version = "0.4.0";
const names = expectedLinuxAssets(version);
async function fixture(name: string) {
const directory = path.join(root, name);
await fs.mkdir(directory);
const contents = [Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.from("AppImage fixture")]), Buffer.from("!<arch>\nDebian fixture")];
for (let i = 0; i < 2; i++) await fs.writeFile(path.join(directory, names[i]!), contents[i]!);
const entries = contents.map((data, i) => ({ url: names[i], size: data.length, sha512: createHash("sha512").update(data).digest("base64") }));
await fs.writeFile(path.join(directory, "latest-linux.yml"), JSON.stringify({ version, files: entries, path: entries[0]!.url, sha512: entries[0]!.sha512 }));
await writeChecksums(directory, version);
return directory;
}
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://server2.sorokin-oleg-edu.xyz/replicated/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 () => {
const directory = await fixture("healthy");
const inventory = await verifyLocal(directory, version);
assert.equal(inventory.length, 4);
await fs.appendFile(path.join(directory, names[0]!), "tampered");
await assert.rejects(verifyLocal(directory, version), /update size differs/);
});
test("updater cannot point at traversal paths, other versions, or substituted installer bytes", async () => {
for (const [name, change, expected] of [
["traversal", (feed: any) => { feed.files[0].url = "../outside.AppImage"; }, /reference exactly/],
["version", (feed: any) => { feed.version = "0.3.7"; }, /metadata version/],
["hash", (feed: any) => { feed.files[0].sha512 = "bad"; }, /update checksum/],
] as const) {
const directory = await fixture(name);
const file = path.join(directory, "latest-linux.yml");
const feed = JSON.parse(await fs.readFile(file, "utf8")); change(feed); await fs.writeFile(file, JSON.stringify(feed));
await writeChecksums(directory, version);
await assert.rejects(verifyLocal(directory, version), expected);
}
});
test("empty files and arbitrary downloads cannot pass as installers", async () => {
const directory = await fixture("format");
await fs.writeFile(path.join(directory, names[0]!), "<html>HTTP error</html>");
await writeChecksums(directory, version);
await assert.rejects(verifyLocal(directory, version), /invalid installer header/);
await fs.writeFile(path.join(directory, names[0]!), "");
await assert.rejects(verifyLocal(directory, version), /nonempty regular file/);
});
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://server2.sorokin-oleg-edu.xyz/replicated/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));
return new Response(await fs.readFile(path.join(directory, path.basename(url.pathname))));
};
await verifyRemote(release, inventory, {}, request as typeof fetch);
await assert.rejects(verifyRemote({ ...release, draft: false }, inventory), /only a draft/);
await assert.rejects(verifyRemote({ ...release, assets: assets.slice(1) }, inventory), /missing, duplicate/);
await assert.rejects(verifyRemote(release, inventory, {}, (async () => new Response("wrong bytes")) as typeof fetch), /remote bytes differ/);
await assert.rejects(verifyRemote({ ...release, assets: [{ ...assets[0], browser_download_url: "https://other.example/file" }, ...assets.slice(1)] }, inventory), /outside Gitea/);
});
test("release configuration preserves packaging while replacing the upstream update origin", async () => {
const require = createRequire(import.meta.url);
const yaml = require("js-yaml");
const config = yaml.load(await fs.readFile(new URL("../electron-builder.gitea.yml", import.meta.url), "utf8"));
assert.equal(config.extends, "./electron-builder.yml");
assert.deepEqual(config.publish, [{ provider: "generic", url: UPDATE_URL }]);
const workflow = yaml.load(await fs.readFile(new URL("../.gitea/workflows/release.yml", import.meta.url), "utf8"));
assert(workflow.on.workflow_dispatch);
assert(!workflow.on.pull_request, "untrusted pull requests must not run release publishing");
assert.equal(workflow.permissions.releases, "write");
});