diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..279ce7d --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 320a543..a265fc6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,8 @@ data/ *.log .DS_Store *.tsbuildinfo -.env.local +.env +.env.* .memory/ SCOPE.md # raw real-data import sources (user directive 2026-09-13: never commit) diff --git a/docs/desktop-release.md b/docs/desktop-release.md index b061bf2..e2e2e7b 100644 --- a/docs/desktop-release.md +++ b/docs/desktop-release.md @@ -1,5 +1,8 @@ # 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 | Platform | Build runner | Packages | In-app update | diff --git a/docs/gitea-release.md b/docs/gitea-release.md new file mode 100644 index 0000000..cec19d8 --- /dev/null +++ b/docs/gitea-release.md @@ -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. diff --git a/docs/release-0.4.0.md b/docs/release-0.4.0.md new file mode 100644 index 0000000..0174dfb --- /dev/null +++ b/docs/release-0.4.0.md @@ -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 в разделе **Настройки → Провайдеры**. +Для занятий с ИИ подключите провайдера и выберите модели для всех нужных уровней. diff --git a/electron-builder.gitea.yml b/electron-builder.gitea.yml new file mode 100644 index 0000000..b54e987 --- /dev/null +++ b/electron-builder.gitea.yml @@ -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 diff --git a/package-lock.json b/package-lock.json index 7a91ffc..e9cda73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "learnharness", - "version": "0.3.7", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "learnharness", - "version": "0.3.7", + "version": "0.4.0", "license": "AGPL-3.0-only", "dependencies": { "@earendil-works/pi-agent-core": "1.4.0", diff --git a/package.json b/package.json index 1b0c649..ebe3997 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "learnharness", - "version": "0.3.7", + "version": "0.4.0", "license": "AGPL-3.0-only", "author": { "name": "Amos Blomqvist", @@ -19,6 +19,7 @@ "test": "NODE_ENV=test tsx --test tests/*.test.ts tests/*.test.tsx", "lint": "eslint .", "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 .\"", "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" diff --git a/scripts/gitea-release.mjs b/scripts/gitea-release.mjs new file mode 100644 index 0000000..5253237 --- /dev/null +++ b/scripts/gitea-release.mjs @@ -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("!\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; }); +} diff --git a/tests/gitea-release.test.ts b/tests/gitea-release.test.ts new file mode 100644 index 0000000..437debc --- /dev/null +++ b/tests/gitea-release.test.ts @@ -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("!\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]!), "HTTP error"); + 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"); +});