From a369bba4ec9a042ab7a1cd3646952de6d757ec15 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 1 Nov 2026 02:43:15 +0300 Subject: [PATCH] release: prepare v0.6.1 AppImage for new Gitea host --- docs/gitea-release.md | 83 ++++++++++++++++++- docs/release-0.6.1.md | 6 ++ electron-builder.gitea.yml | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/gitea-release.mjs | 14 ++-- tests/gitea-release.test.ts | 160 ++++++++++++++++++++++++++++++++++-- 7 files changed, 252 insertions(+), 19 deletions(-) create mode 100644 docs/release-0.6.1.md diff --git a/docs/gitea-release.md b/docs/gitea-release.md index af67eb8..774c6a1 100644 --- a/docs/gitea-release.md +++ b/docs/gitea-release.md @@ -1,8 +1,9 @@ # Gitea releases for this fork The release destination is -https://server2.sorokin-oleg-edu.xyz/replicated/learnharness. -Version **0.6.0 targets Linux x64 and Windows x64**, with each platform built +https://git.sorokin-oleg-edu.xyz/replicated/learnharness. +Version **0.6.1 is Linux x64 AppImage-only**; follow the dedicated procedure below. +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` and `electron-builder.yml`. @@ -11,13 +12,89 @@ 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 +https://git.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. +## 0.6.1: Linux x64 AppImage-only upload and verification + +This release has exactly three attachments: + +- `LearnHarness-0.6.1-x86_64.AppImage` +- `latest-linux.yml` +- `SHA256SUMS` + +No Windows, macOS, Debian (`.deb`), or RPM packages belong in this release. +Use `docs/release-0.6.1.md` for the release notes. The procedures below this +section retain the historical 0.6.0/full-release instructions; do not use their +Debian/Windows asset inventory for 0.6.1. + +Stage only the three listed files for upload. Before upload, generate and verify +checksums using the existing helper's explicit offline AppImage-only mode: + +```sh +node scripts/gitea-release.mjs checksums dist --appimage-only +node scripts/gitea-release.mjs verify dist --appimage-only +``` + +Run these commands from the 0.6.1 source checkout. They validate the AppImage, +feed version, filename, size, SHA-512, and SHA-256 manifest. `SHA256SUMS` must +cover only the AppImage and `latest-linux.yml`. The offline verifier checks the +expected files, not the absence of unrelated files: separately check that the +upload selection contains exactly the three names above. Confirm the packaged +`resources/app-update.yml` has the generic provider and the current update URL +shown above before uploading. + +The unchanged `desktop:release:linux` script still builds AppImage and Debian +packages, so do not use it or the historical manual Actions workflow for this +AppImage-only release. Package explicitly with: + +```sh +electron-builder --config electron-builder.gitea.yml --linux AppImage --x64 --publish never +``` + +After building and verifying the copied server/runtime, verify the embedded +updater with `node scripts/gitea-release.mjs verify-package dist/linux-unpacked`. +The helper uses the current host and accepts `--appimage-only` for `checksums`, +`verify`, `upload`, and `verify-remote`; omitting this flag retains the historical +AppImage-plus-Debian inventory. Supply `GITEA_TOKEN` privately for API operations: + +```sh +node scripts/gitea-release.mjs upload dist --appimage-only +node scripts/gitea-release.mjs verify-remote dist --appimage-only +``` + +The uploader creates or reuses a draft and verifies downloaded attachment bytes; +it does not publish the release. Publication is a separate explicitly authorized +API action after these checks pass. + +After the release tag `v0.6.1` exists at the intended source commit in the current +Gitea repository, create or review a draft for that tag on +`git.sorokin-oleg-edu.xyz`. Set its notes from `docs/release-0.6.1.md` and explicitly +upload only the three listed files. Before publication: + +1. Confirm the draft tag is `v0.6.1` and its attachment inventory is exactly the + three names above, with sizes matching the locally verified files. +2. Download those three draft attachments into a separate directory and run + `node scripts/gitea-release.mjs verify /path/to/downloaded-assets --appimage-only` + from the 0.6.1 source checkout. Compare the downloaded `SHA256SUMS` byte-for-byte + with the locally verified manifest; stop on any mismatch. +3. Review the notes and asset exclusions. Publication is a separate, + explicitly authorized action; a draft does not update installed clients. + +After authorized publication, confirm the current host's public +`releases/download/latest/latest-linux.yml` resolves to version `0.6.1` and +references `LearnHarness-0.6.1-x86_64.AppImage` with the verified size and SHA-512. + +## Historical 0.6.0 / full-release instructions + +The following commands and asset inventories are retained for reference. Their +upload tooling still defaults to the old host; they are not the current 0.6.1 +upload procedure. + ## Preparing Linux packages Use Node **26**, matching upstream's release gates. Build from a clean checkout diff --git a/docs/release-0.6.1.md b/docs/release-0.6.1.md new file mode 100644 index 0000000..e2eacfa --- /dev/null +++ b/docs/release-0.6.1.md @@ -0,0 +1,6 @@ +# LearnHarness 0.6.1 + +- Merges upstream v0.4.0 while preserving the fork's localization, language preferences, dictation, OpenRouter speech-to-text, ChatGPT Fast, and quiz functionality. +- Moves the Gitea update host to `git.sorokin-oleg-edu.xyz`. +- Release assets are Linux x64 AppImage-only: `LearnHarness-0.6.1-x86_64.AppImage`, `latest-linux.yml`, and `SHA256SUMS`. +- No Windows, macOS, Debian (`.deb`), or RPM packages are included in this release. diff --git a/electron-builder.gitea.yml b/electron-builder.gitea.yml index 8248688..0ae5d7b 100644 --- a/electron-builder.gitea.yml +++ b/electron-builder.gitea.yml @@ -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://server2.sorokin-oleg-edu.xyz/replicated/learnharness/releases/download/latest + url: https://git.sorokin-oleg-edu.xyz/replicated/learnharness/releases/download/latest diff --git a/package-lock.json b/package-lock.json index 12c2038..9a0cb37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "learnharness", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "learnharness", - "version": "0.6.0", + "version": "0.6.1", "license": "AGPL-3.0-only", "dependencies": { "@earendil-works/pi-agent-core": "1.4.0", diff --git a/package.json b/package.json index a419130..c716312 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "learnharness", - "version": "0.6.0", + "version": "0.6.1", "license": "AGPL-3.0-only", "author": { "name": "Amos Blomqvist", diff --git a/scripts/gitea-release.mjs b/scripts/gitea-release.mjs index 4bd8c00..5c25619 100644 --- a/scripts/gitea-release.mjs +++ b/scripts/gitea-release.mjs @@ -13,7 +13,7 @@ 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 SERVER = "https://git.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}`; @@ -125,8 +125,8 @@ export async function selectDraft(tag) { } return matches[0] ?? null; } -async function upload(directory, version) { - const inventory = await verifyLocal(directory, version); +async function upload(directory, version, options = {}) { + const inventory = await verifyLocal(directory, version, options); 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)}`); @@ -157,7 +157,7 @@ async function upload(directory, version) { async function main() { const [command, ...args] = process.argv.slice(2); const appimageOnly = args.includes("--appimage-only"); - assert(!appimageOnly || ["checksums", "verify"].includes(command), "--appimage-only is supported only by offline checksums and verify"); + 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"); @@ -167,14 +167,14 @@ async function main() { if (command === "checksums") await writeChecksums(directory, version, { appimageOnly }); 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); + else if (command === "upload") await upload(directory, version, { appimageOnly }); else if (command === "verify-remote") { - const inventory = await verifyLocal(directory, version); + const inventory = await verifyLocal(directory, version, { appimageOnly }); 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 [dist-directory] [--appimage-only], upload|verify-remote [dist-directory], 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], 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; }); diff --git a/tests/gitea-release.test.ts b/tests/gitea-release.test.ts index 1634227..757b52a 100644 --- a/tests/gitea-release.test.ts +++ b/tests/gitea-release.test.ts @@ -26,7 +26,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://server2.sorokin-oleg-edu.xyz/replicated/learnharness/releases/download/latest"); + assert.equal(UPDATE_URL, "https://git.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 () => { @@ -61,7 +61,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://server2.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/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)); @@ -150,7 +150,7 @@ for (const kind of ["missing", "empty", "directory", "symlink", "corrupt"] as co }); } -test("CLI retains positional directory for scoped offline commands and rejects remote scope before auth or network", async () => { +test("CLI retains positional directory for scoped offline commands and rejects unsupported options", async () => { const pkg = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url), "utf8")); const directory = await fixture("scoped-cli", true, pkg.version); const run = (...args: string[]) => spawnSync(process.execPath, [path.resolve(import.meta.dirname, "../scripts/gitea-release.mjs"), ...args], { @@ -163,16 +163,166 @@ test("CLI retains positional directory for scoped offline commands and rejects r const defaultResult = run("verify", directory); assert.equal(defaultResult.status, 1); assert.match(defaultResult.stderr, /amd64\.deb/); - for (const command of ["upload", "verify-remote", "verify-package"]) { + for (const command of ["verify-package"]) { const result = run(command, directory, "--appimage-only"); assert.equal(result.status, 1); - assert.match(result.stderr, /supported only by offline/); + assert.match(result.stderr, /supported only by checksums, verify, upload and verify-remote/); } for (const args of [["--unknown"], ["--appimage-only", "--appimage-only"], [directory, "extra"]]) { assert.equal(run("verify", ...args).status, 1); } }); +// Each API command runs in an isolated process: no inherited credentials and no real fetch. +function mockedReleaseCommand(directory: string, command: string, options: string[] = [], scenario = "draft") { + const preload = ` + import assert from "node:assert/strict"; + import fs from "node:fs/promises"; + import path from "node:path"; + const directory = ${JSON.stringify(directory)}; + const scenario = ${JSON.stringify(scenario)}; + 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 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 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]); + if (scenario === "extra-asset") release.assets.push({ ...assets[0], name: "unexpected.deb" }); + process.env.GITEA_TOKEN = "mock-token-not-a-credential"; + process.on("exit", () => console.log("MOCK_CALLS=" + JSON.stringify(calls))); + globalThis.fetch = async (input, options = {}) => { + const url = new URL(String(input)); + assert.equal(url.origin, server, "unexpected network origin"); + const method = options.method ?? "GET"; + const json = value => Response.json(value); + if (url.pathname.startsWith(api + "/")) { + calls.api.push(method + " " + url.pathname + url.search); + assert.equal(options.headers.Authorization, "token mock-token-not-a-credential"); + if (method === "GET" && url.pathname === api + "/tags/" + tag) return json(scenario === "missing-tag" ? {} : { commit: { sha: "fixed-tag-sha" } }); + if (method === "GET" && url.pathname === api + "/releases") { + assert.equal(url.searchParams.get("page"), "1"); + return json(scenario === "new" ? [] : scenario === "ambiguous" ? [release, { ...release, id: 2, tag_name: version }] : scenario === "unversioned" ? [{ ...release, tag_name: version }] : [release]); + } + if (["POST", "PATCH"].includes(method) && [api + "/releases", api + "/releases/1"].includes(url.pathname)) { + const data = JSON.parse(options.body); + assert.equal(data.draft, true, "must never publish"); + assert.equal(data.prerelease, false); + assert.equal(data.tag_name, tag); + assert.equal(data.target_commitish, "fixed-tag-sha"); + calls.edits.push(data); + return json(release); + } + if (method === "POST" && url.pathname === api + "/releases/1/assets") { + const name = url.searchParams.get("name"); + const attachment = options.body.get("attachment"); + assert.equal(attachment.name, name); + assert.deepEqual(Buffer.from(await attachment.arrayBuffer()), await fs.readFile(path.join(directory, name))); + calls.uploads.push(name); + const asset = assets.find(asset => asset.name === name); + assert(asset); + release.assets.push(asset); + 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 + "/")) { + const name = decodeURIComponent(path.basename(url.pathname)); + assert(files.includes(name), "download outside expected fixture"); + calls.downloads.push(name); + const bytes = await fs.readFile(path.join(directory, name)); + if (scenario === "corrupt") bytes[0] ^= 1; + return new Response(bytes); + } + throw new Error("Unexpected mocked request: " + method + " " + url); + }; + `; + const result = spawnSync(process.execPath, ["--import", `data:text/javascript,${encodeURIComponent(preload)}`, path.resolve(import.meta.dirname, "../scripts/gitea-release.mjs"), command, directory, ...options], { + encoding: "utf8", timeout: 10_000, env: { NODE_ENV: "test" }, + }); + const trace = result.stdout.split("\n").find(line => line.startsWith("MOCK_CALLS=")); + assert(trace, result.stderr || String(result.error)); + return { ...result, calls: JSON.parse(trace.slice("MOCK_CALLS=".length)) as { api: string[]; uploads: string[]; downloads: string[]; edits: { draft: boolean }[] } }; +} + +for (const appimageOnly of [true, false]) { + test(`${appimageOnly ? "AppImage-only" : "default full-release"} upload and remote verification use exactly the validated inventory`, async () => { + const pkg = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url), "utf8")); + const directory = await fixture(`api-${appimageOnly ? "scoped" : "full"}`, appimageOnly, pkg.version); + const expected = expectedLinuxAssets(pkg.version, { appimageOnly }).sort(); + const options = appimageOnly ? ["--appimage-only"] : []; + const uploaded = mockedReleaseCommand(directory, "upload", options, "new"); + assert.equal(uploaded.status, 0, uploaded.stderr); + assert.deepEqual(uploaded.calls.uploads.sort(), expected); + assert.deepEqual(uploaded.calls.downloads.sort(), expected); + assert.equal(uploaded.calls.edits.length, 1); + assert(uploaded.calls.edits.every(edit => edit.draft)); + assert(uploaded.calls.api.some(call => call.startsWith("GET ") && call.includes("/tags/v"))); + assert.equal(uploaded.calls.api.filter(call => call.startsWith("POST ")).length, expected.length + 1); + const existing = mockedReleaseCommand(directory, "upload", options); + assert.equal(existing.status, 0, existing.stderr); + assert.deepEqual(existing.calls.uploads, []); + assert.deepEqual(existing.calls.downloads.sort(), expected); + assert.equal(existing.calls.edits.length, 1); + assert(existing.calls.api.some(call => call.startsWith("PATCH "))); + const verified = mockedReleaseCommand(directory, "verify-remote", options); + assert.equal(verified.status, 0, verified.stderr); + assert.deepEqual(verified.calls.downloads.sort(), expected); + assert(verified.calls.api.every(call => call.startsWith("GET "))); + assert.deepEqual(verified.calls.edits, []); + }); +} + +test("API commands preserve full-release defaults and reject invalid scopes before any request", async () => { + const pkg = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url), "utf8")); + const directory = await fixture("api-local-rejections", true, pkg.version); + for (const command of ["upload", "verify-remote"]) { + for (const options of [[], ["--unknown"], ["--appimage-only", "--appimage-only"], ["--appimage-only", "extra"]]) { + const result = mockedReleaseCommand(directory, command, options); + assert.equal(result.status, 1, result.stderr); + assert.deepEqual(result.calls.api, []); + assert.deepEqual(result.calls.downloads, []); + if (!options.length) assert.match(result.stderr, /amd64\.deb/); + } + } +}); + +test("AppImage-only API commands retain draft, tag, asset and same-size byte rejection gates", async () => { + const pkg = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url), "utf8")); + const directory = await fixture("api-remote-rejections", true, pkg.version); + for (const command of ["upload", "verify-remote"]) { + for (const [scenario, error] of [ + ["published", /already published/], ["ambiguous", /ambiguous releases/], ["unversioned", /exact versioned tag/], + ["corrupt", /remote bytes differ/], ["extra-asset", /missing, duplicate, or unexpected/], + ["duplicate-asset", /missing, duplicate, or unexpected/], ["prerelease", /release must be stable/], + ] as const) { + const result = mockedReleaseCommand(directory, command, ["--appimage-only"], scenario); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, error); + assert.deepEqual(result.calls.uploads, []); + if (["published", "ambiguous", "unversioned"].includes(scenario)) { + assert(result.calls.api.every(call => call.startsWith("GET "))); + assert.deepEqual(result.calls.downloads, []); + } + } + } + const missingTag = mockedReleaseCommand(directory, "upload", ["--appimage-only"], "missing-tag"); + assert.equal(missingTag.status, 1, missingTag.stderr); + assert.match(missingTag.stderr, /release tag does not resolve to a commit/); + assert.equal(missingTag.calls.api.length, 1); + assert(missingTag.calls.api[0]!.startsWith("GET ")); + assert.deepEqual(missingTag.calls.uploads, []); + assert.deepEqual(missingTag.calls.edits, []); + for (const [scenario, error] of [["new", /draft not found/], ["missing-asset", /missing, duplicate, or unexpected/]] as const) { + const result = mockedReleaseCommand(directory, "verify-remote", ["--appimage-only"], scenario); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, error); + assert(result.calls.api.every(call => call.startsWith("GET "))); + } +}); + test("packaged updater verification refuses inherited GitHub or incorrect generic destinations", async () => { const directory = path.join(root, "package"); await fs.mkdir(path.join(directory, "resources"), { recursive: true });