release: prepare v0.6.1 AppImage for new Gitea host

This commit is contained in:
2026-11-01 02:43:15 +03:00
parent 3377f4726b
commit a369bba4ec
7 changed files with 252 additions and 19 deletions
+80 -3
View File
@@ -1,8 +1,9 @@
# Gitea releases for this fork # Gitea releases for this fork
The release destination is The release destination is
https://server2.sorokin-oleg-edu.xyz/replicated/learnharness. https://git.sorokin-oleg-edu.xyz/replicated/learnharness.
Version **0.6.0 targets Linux x64 and Windows x64**, with each platform built 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 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` configuration remain available as reference in `.github/workflows/release.yml`
and `electron-builder.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: 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. Gitea's `latest` alias resolves the most recent published, non-prerelease release.
Drafts do not become updates. The repository and published downloads must be Drafts do not become updates. The repository and published downloads must be
public; no API credential is embedded in the application. 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 ## Preparing Linux packages
Use Node **26**, matching upstream's release gates. Build from a clean checkout Use Node **26**, matching upstream's release gates. Build from a clean checkout
+6
View File
@@ -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.
+1 -1
View File
@@ -5,4 +5,4 @@ publish:
provider: generic provider: generic
# Gitea resolves the latest published, non-prerelease release here. # Gitea resolves the latest published, non-prerelease release here.
# Drafts remain private and do not become an update before publication. # 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
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "learnharness", "name": "learnharness",
"version": "0.6.0", "version": "0.6.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "learnharness", "name": "learnharness",
"version": "0.6.0", "version": "0.6.1",
"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",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "learnharness", "name": "learnharness",
"version": "0.6.0", "version": "0.6.1",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"author": { "author": {
"name": "Amos Blomqvist", "name": "Amos Blomqvist",
+7 -7
View File
@@ -13,7 +13,7 @@ import { pathToFileURL } from "node:url";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const yaml = require("js-yaml"); const yaml = require("js-yaml");
const ROOT = path.resolve(import.meta.dirname, ".."); 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 REPOSITORY = "replicated/learnharness";
export const UPDATE_URL = `${SERVER}/${REPOSITORY}/releases/download/latest`; export const UPDATE_URL = `${SERVER}/${REPOSITORY}/releases/download/latest`;
const API = `${SERVER}/api/v1/repos/${REPOSITORY}`; const API = `${SERVER}/api/v1/repos/${REPOSITORY}`;
@@ -125,8 +125,8 @@ export async function selectDraft(tag) {
} }
return matches[0] ?? null; return matches[0] ?? null;
} }
async function upload(directory, version) { async function upload(directory, version, options = {}) {
const inventory = await verifyLocal(directory, version); const inventory = await verifyLocal(directory, version, options);
const tag = `v${version}`; const tag = `v${version}`;
// The tag must already exist remotely; never create a release from a moving branch. // The tag must already exist remotely; never create a release from a moving branch.
const tagged = await api(`/tags/${encodeURIComponent(tag)}`); const tagged = await api(`/tags/${encodeURIComponent(tag)}`);
@@ -157,7 +157,7 @@ async function upload(directory, version) {
async function main() { async function main() {
const [command, ...args] = process.argv.slice(2); const [command, ...args] = process.argv.slice(2);
const appimageOnly = args.includes("--appimage-only"); 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.filter((arg) => arg === "--appimage-only").length <= 1, "duplicate --appimage-only option");
assert(args.every((arg) => !arg.startsWith("--") || arg === "--appimage-only"), "unknown release option"); assert(args.every((arg) => !arg.startsWith("--") || arg === "--appimage-only"), "unknown release option");
const positional = args.filter((arg) => arg !== "--appimage-only"); const positional = args.filter((arg) => arg !== "--appimage-only");
@@ -167,14 +167,14 @@ async function main() {
if (command === "checksums") await writeChecksums(directory, version, { appimageOnly }); if (command === "checksums") await writeChecksums(directory, version, { appimageOnly });
else if (command === "verify-package") await verifyPackagedUpdater(directory); 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 === "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") { else if (command === "verify-remote") {
const inventory = await verifyLocal(directory, version); const inventory = await verifyLocal(directory, version, { appimageOnly });
const release = await selectDraft(`v${version}`); const release = await selectDraft(`v${version}`);
assert(release, "draft not found"); assert(release, "draft not found");
await verifyRemote(release, inventory, authorization()); await verifyRemote(release, inventory, authorization());
console.log(`Verified draft: ${release.html_url}`); 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) { if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
main().catch((error) => { console.error(error.message); process.exitCode = 1; }); main().catch((error) => { console.error(error.message); process.exitCode = 1; });
+155 -5
View File
@@ -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", () => { 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.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")); assert.throws(() => expectedLinuxAssets("0.4.0-preview"));
}); });
test("local verification checks actual installer bytes, updater hashes, sizes and checksum manifest", async () => { 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 () => { test("remote verification downloads and hashes every draft attachment, and refuses published or incomplete releases", async () => {
const directory = await fixture("remote"); const directory = await fixture("remote");
const inventory = await verifyLocal(directory, version); 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 release = { draft: true, prerelease: false, assets };
const request = async (input: URL | RequestInfo) => { const request = async (input: URL | RequestInfo) => {
const url = input instanceof URL ? input : new URL(String(input)); 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 pkg = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url), "utf8"));
const directory = await fixture("scoped-cli", true, pkg.version); 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], { 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); const defaultResult = run("verify", directory);
assert.equal(defaultResult.status, 1); assert.equal(defaultResult.status, 1);
assert.match(defaultResult.stderr, /amd64\.deb/); 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"); const result = run(command, directory, "--appimage-only");
assert.equal(result.status, 1); 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"]]) { for (const args of [["--unknown"], ["--appimage-only", "--appimage-only"], [directory, "extra"]]) {
assert.equal(run("verify", ...args).status, 1); 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 () => { test("packaged updater verification refuses inherited GitHub or incorrect generic destinations", async () => {
const directory = path.join(root, "package"); const directory = path.join(root, "package");
await fs.mkdir(path.join(directory, "resources"), { recursive: true }); await fs.mkdir(path.join(directory, "resources"), { recursive: true });