chore: prepare 0.4.0 Linux release on Gitea
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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 в разделе **Настройки → Провайдеры**.
|
||||
Для занятий с ИИ подключите провайдера и выберите модели для всех нужных уровней.
|
||||
@@ -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
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -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; });
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user