From c950b9cbfba88e7ba7b1fcde2952758b7e088247 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Tue, 3 Nov 2026 15:22:40 +0300 Subject: [PATCH] release: prepare 0.6.2 Linux and Windows packages --- docs/desktop-release.md | 2 +- docs/gitea-release.md | 63 ++++++++++++++++++++++++++- docs/release-0.6.2.md | 33 ++++++++++++++ package-lock.json | 4 +- package.json | 2 +- scripts/gitea-release.mjs | 80 ++++++++++++++++++++++++---------- tests/gitea-release.test.ts | 87 ++++++++++++++++++++++++++++++++++++- 7 files changed, 239 insertions(+), 32 deletions(-) create mode 100644 docs/release-0.6.2.md diff --git a/docs/desktop-release.md b/docs/desktop-release.md index 4efc882..af80010 100644 --- a/docs/desktop-release.md +++ b/docs/desktop-release.md @@ -1,6 +1,6 @@ # Desktop builds and releases -For this fork’s Linux and Windows 0.6.0 release on Gitea, see [Gitea releases](gitea-release.md). +For this fork’s Linux and Windows 0.6.2 release on Gitea, see [Gitea releases](gitea-release.md). The three-platform GitHub process below documents the upstream reference. ## Platforms diff --git a/docs/gitea-release.md b/docs/gitea-release.md index f839df5..900a45a 100644 --- a/docs/gitea-release.md +++ b/docs/gitea-release.md @@ -2,7 +2,8 @@ The release destination is https://git.sorokin-oleg-edu.xyz/sorokin.oleg.edu/learnharness. -Version **0.6.1 is Linux x64 AppImage-only**; follow the dedicated procedure below. +Version **0.6.2 targets Linux x64 AppImage and Windows x64 NSIS**; follow the +dedicated procedure below. Version 0.6.1 was Linux x64 AppImage-only. 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` @@ -19,7 +20,65 @@ Gitea's `latest` alias resolves the most recent published, non-prerelease releas 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 +## 0.6.2: Linux AppImage and Windows installer + +Build both platforms from the same release commit with Node 26. Use a clean +checkout on each native operating system so local credentials, environment +files, application data, and older packages cannot enter the standalone payload. +The Windows installer retains the existing unsigned per-user NSIS configuration. + +After dependency installation, run the typecheck, deterministic tests, lint, +native renderer tests, and standalone runtime smoke checks. Include +`tests/desktop-logger.test.ts` under each platform's bundled Electron Node to +exercise the broken-pipe and retention regressions in the shipped runtime. +Curate the standalone payload with `node scripts/build-server.mjs`, then package: + +```sh +# Native Linux checkout +npx electron-builder --config electron-builder.gitea.yml --linux AppImage --x64 --publish never + +# Native Windows checkout +npx electron-builder --config electron-builder.gitea.yml --win --x64 --publish never +``` + +Verify each packaged updater with `scripts/gitea-release.mjs verify-package` and +run the provider, server, and browser-helper smoke checks against that package's +own standalone tree and executable. Copy only these six assets into a staging +directory (the checksum command creates the final file): + +- `LearnHarness-0.6.2-x86_64.AppImage` +- `LearnHarness-0.6.2-x64-setup.exe` +- `LearnHarness-0.6.2-x64-setup.exe.blockmap` +- `latest-linux.yml` +- `latest.yml` +- `SHA256SUMS` + +```sh +node scripts/gitea-release.mjs checksums /path/to/staging --appimage-windows +node scripts/gitea-release.mjs verify /path/to/staging --appimage-windows +``` + +This explicit scope preserves the historical Linux defaults and AppImage-only +mode. It rejects extra staged files, checks both installer headers and updater +feeds against the actual bytes, regenerates the Windows blockmap with the pinned +packager to verify every chunk and full installer coverage, and verifies the +combined SHA-256 manifest. It includes no Debian, RPM, or macOS packages. + +Commit and push the release source and its matching annotated `v0.6.2` tag. +Supply the Gitea API token privately, then prepare and verify the draft: + +```sh +node scripts/gitea-release.mjs upload /path/to/staging --appimage-windows +node scripts/gitea-release.mjs verify-remote /path/to/staging --appimage-windows +``` + +The uploader reads notes from `docs/release-0.6.2.md`, requires the remote tag, +keeps the release a stable draft, and downloads all six attachments to compare +their SHA-256 hashes with the validated local files. It refuses published or +ambiguous releases and mismatched existing attachments. Publication follows +human review of the completed draft. + +## Historical 0.6.1: Linux x64 AppImage-only upload and verification This release has exactly three attachments: diff --git a/docs/release-0.6.2.md b/docs/release-0.6.2.md new file mode 100644 index 0000000..c334a9c --- /dev/null +++ b/docs/release-0.6.2.md @@ -0,0 +1,33 @@ +# LearnHarness 0.6.2 + +## English + +This release fixes a critical desktop logging bug that could consume hundreds of +gigabytes of disk space when the application's output pipe closed. + +- Stops the repeated `EPIPE` exception loop after a launcher or terminal closes + its output pipe. Uncaught errors are recorded directly to the log file. +- Keeps desktop diagnostics within two files of 5 MiB each, for a maximum of + 10 MiB. Existing oversized logs are trimmed on startup, retaining recent data. +- Handles output backpressure, full disks, and log rotation failures without + starting another logging failure loop. + +Packages: Linux x64 AppImage and Windows x64 NSIS installer. Both receive updates +from this fork's Gitea releases. The Windows installer remains unsigned. + +## Русский + +Выпуск исправляет критическую ошибку журналирования: после закрытия канала +вывода приложение могло занять сотни гигабайт на диске. + +- Устранён цикл повторных исключений `EPIPE` после закрытия канала вывода + терминалом или программой запуска. Необработанные ошибки записываются + непосредственно в файл журнала. +- Журналы ограничены двумя файлами по 5 МиБ, всего до 10 МиБ. При запуске + прежние слишком большие журналы сокращаются с сохранением последних данных. +- Переполнение канала вывода, заполненный диск и ошибки ротации журналов + больше не вызывают новый цикл ошибок журналирования. + +Пакеты: AppImage для Linux x64 и установщик NSIS для Windows x64. Обе версии +получают обновления из выпусков этого форка в Gitea. Установщик Windows +по-прежнему не подписан. diff --git a/package-lock.json b/package-lock.json index 9a0cb37..9d8f2f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "learnharness", - "version": "0.6.1", + "version": "0.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "learnharness", - "version": "0.6.1", + "version": "0.6.2", "license": "AGPL-3.0-only", "dependencies": { "@earendil-works/pi-agent-core": "1.4.0", diff --git a/package.json b/package.json index 2cc94d8..0545089 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "learnharness", - "version": "0.6.1", + "version": "0.6.2", "license": "AGPL-3.0-only", "author": { "name": "Amos Blomqvist", diff --git a/scripts/gitea-release.mjs b/scripts/gitea-release.mjs index 6a73c70..f7881f0 100644 --- a/scripts/gitea-release.mjs +++ b/scripts/gitea-release.mjs @@ -1,4 +1,4 @@ -/** Prepare and verify this fork's Linux release; publication is always a separate human action. */ +/** Prepare and verify this fork's desktop release; publication is a separate human action. */ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import fs from "node:fs"; @@ -9,6 +9,7 @@ import path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { pathToFileURL } from "node:url"; +import { gunzipSync } from "node:zlib"; const require = createRequire(import.meta.url); const yaml = require("js-yaml"); @@ -22,6 +23,13 @@ export function expectedLinuxAssets(version, { appimageOnly = false } = {}) { assert.match(version, /^\d+\.\d+\.\d+$/, "release version must be a stable semver"); return [`LearnHarness-${version}-x86_64.AppImage`, ...(!appimageOnly ? [`LearnHarness-${version}-amd64.deb`] : []), "latest-linux.yml", "SHA256SUMS"]; } +export function expectedReleaseAssets(version, { appimageOnly = false, appimageWindows = false } = {}) { + assert(!(appimageOnly && appimageWindows), "release scopes are mutually exclusive"); + const linux = expectedLinuxAssets(version, { appimageOnly: appimageOnly || appimageWindows }); + if (!appimageWindows) return linux; + const exe = `LearnHarness-${version}-x64-setup.exe`; + return [linux[0], exe, `${exe}.blockmap`, "latest-linux.yml", "latest.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); @@ -29,7 +37,7 @@ export async function fileDigest(file, algorithm, encoding = "hex") { } export async function writeChecksums(directory, version, options = {}) { - const names = expectedLinuxAssets(version, options).filter((name) => name !== "SHA256SUMS"); + const names = expectedReleaseAssets(version, options).filter((name) => name !== "SHA256SUMS"); const lines = []; for (const name of names) { const file = path.join(directory, name); @@ -48,7 +56,11 @@ export async function verifyPackagedUpdater(directory) { } export async function verifyLocal(directory, version, options = {}) { - const names = expectedLinuxAssets(version, options); + const names = expectedReleaseAssets(version, options); + if (options.appimageWindows) { + const staged = (await fsp.readdir(directory, { withFileTypes: true })).filter((entry) => !entry.isDirectory()).map((entry) => entry.name); + assert.deepEqual(staged.sort(), [...names].sort(), "release directory must contain exactly the six approved assets"); + } const inventory = new Map(); for (const name of names) { const file = path.join(directory, name); @@ -56,22 +68,36 @@ export async function verifyLocal(directory, version, options = {}) { 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 of names.filter((name) => /\.(AppImage|deb)$/.test(name))) { - const magic = name.endsWith(".AppImage") ? Buffer.from([0x7f, 0x45, 0x4c, 0x46]) : Buffer.from("!\n"); + for (const name of names.filter((name) => /\.(AppImage|deb|exe)$/.test(name))) { + const magic = name.endsWith(".AppImage") ? Buffer.from([0x7f, 0x45, 0x4c, 0x46]) : Buffer.from(name.endsWith(".exe") ? "MZ" : "!\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`); + for (const feedName of ["latest-linux.yml", ...(options.appimageWindows ? ["latest.yml"] : [])]) { + const feed = yaml.load(await fsp.readFile(path.join(directory, feedName), "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) => (feedName === "latest.yml" ? /\.exe$/ : /\.(AppImage|deb)$/).test(name)); + assert.deepEqual(feed.files.map((entry) => entry.url).sort(), [...installers].sort(), "update feed must reference exactly the platform 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"); + } + if (options.appimageWindows) { + // Regenerate with the pinned packager's streaming chunker: this checks + // every block checksum and full EXE coverage, rather than just its shape. + const { buildBlockMap } = require("app-builder-lib/out/targets/blockmap/blockmap.js"); + const exe = names.find((name) => name.endsWith(".exe")); + const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), "lh-blockmap-verify-")); + try { + const regenerated = path.join(temporary, "installer.blockmap"); + await buildBlockMap(path.join(directory, exe), "gzip", regenerated); + assert.deepEqual(JSON.parse(gunzipSync(await fsp.readFile(path.join(directory, `${exe}.blockmap`))).toString()), JSON.parse(gunzipSync(await fsp.readFile(regenerated)).toString()), "Windows blockmap differs from the installer bytes"); + } finally { await fsp.rm(temporary, { recursive: true, force: true }); } } - 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()]; @@ -157,24 +183,30 @@ async function upload(directory, version, options = {}) { async function main() { const [command, ...args] = process.argv.slice(2); const appimageOnly = args.includes("--appimage-only"); - 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"); + const appimageWindows = args.includes("--appimage-windows"); + const scopeFlags = ["--appimage-only", "--appimage-windows"]; + for (const flag of scopeFlags) { + assert(!args.includes(flag) || ["checksums", "verify", "upload", "verify-remote"].includes(command), `${flag} is supported only by checksums, verify, upload and verify-remote`); + assert(args.filter((arg) => arg === flag).length <= 1, `duplicate ${flag} option`); + } + assert(!(appimageOnly && appimageWindows), "release scopes are mutually exclusive"); + assert(args.every((arg) => !arg.startsWith("--") || scopeFlags.includes(arg)), "unknown release option"); + const positional = args.filter((arg) => !scopeFlags.includes(arg)); assert(positional.length <= 1, "expected at most one release directory"); const directory = positional[0] ?? "dist"; const { version } = JSON.parse(await fsp.readFile(path.join(ROOT, "package.json"), "utf8")); - if (command === "checksums") await writeChecksums(directory, version, { appimageOnly }); + const options = { appimageOnly, appimageWindows }; + if (command === "checksums") await writeChecksums(directory, version, options); 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, { appimageOnly }); + else if (command === "verify") console.log(JSON.stringify(await verifyLocal(directory, version, options), null, 2)); + else if (command === "upload") await upload(directory, version, options); else if (command === "verify-remote") { - const inventory = await verifyLocal(directory, version, { appimageOnly }); + const inventory = await verifyLocal(directory, version, options); 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] [--appimage-only], 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|--appimage-windows], 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 3eaae32..7cca8ed 100644 --- a/tests/gitea-release.test.ts +++ b/tests/gitea-release.test.ts @@ -6,7 +6,8 @@ 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, verifyPackagedUpdater, UPDATE_URL } from "../scripts/gitea-release.mjs"; +import { gzipSync, gunzipSync } from "node:zlib"; +import { expectedLinuxAssets, expectedReleaseAssets, writeChecksums, verifyLocal, verifyRemote, verifyPackagedUpdater, 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 })); @@ -186,7 +187,7 @@ function mockedReleaseCommand(directory: string, command: string, options: strin const server = "https://git.sorokin-oleg-edu.xyz"; const api = "/api/v1/repos/sorokin.oleg.edu/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 files = (await fs.readdir(directory)).filter(name => /\.(AppImage|deb|exe|blockmap)$/.test(name) || ["latest-linux.yml", "latest.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 + "/sorokin.oleg.edu/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(); @@ -334,3 +335,85 @@ test("packaged updater verification refuses inherited GitHub or incorrect generi await fs.writeFile(file, JSON.stringify({ provider: "generic", url: UPDATE_URL })); await verifyPackagedUpdater(directory); }); + +const desktopOptions = { appimageWindows: true }; +async function desktopFixture(name: string, fixtureVersion = "0.6.2") { + const directory = await fixture(name, true, fixtureVersion); + const exe = `LearnHarness-${fixtureVersion}-x64-setup.exe`; + const bytes = Buffer.from(`MZ${"synthetic installer content ".repeat(2000)}`); + await fs.writeFile(path.join(directory, exe), bytes); + const require = createRequire(import.meta.url); + await require("app-builder-lib/out/targets/blockmap/blockmap.js").buildBlockMap(path.join(directory, exe), "gzip", path.join(directory, `${exe}.blockmap`)); + const entry = { url: exe, size: bytes.length, sha512: createHash("sha512").update(bytes).digest("base64") }; + await fs.writeFile(path.join(directory, "latest.yml"), JSON.stringify({ version: fixtureVersion, files: [entry], path: exe, sha512: entry.sha512 })); + await writeChecksums(directory, fixtureVersion, desktopOptions); + return directory; +} + +test("AppImage and Windows release has exactly the six approved assets and exclusive scopes", async () => { + assert.deepEqual(expectedReleaseAssets("0.6.2", desktopOptions), [ + "LearnHarness-0.6.2-x86_64.AppImage", "LearnHarness-0.6.2-x64-setup.exe", "LearnHarness-0.6.2-x64-setup.exe.blockmap", + "latest-linux.yml", "latest.yml", "SHA256SUMS", + ]); + assert.throws(() => expectedReleaseAssets("0.6.2", { ...desktopOptions, appimageOnly: true }), /mutually exclusive/); + assert.deepEqual(expectedReleaseAssets(version), names); + const directory = await desktopFixture("desktop-healthy"); + assert.equal((await verifyLocal(directory, "0.6.2", desktopOptions)).length, 6); + await fs.writeFile(path.join(directory, "stale.deb"), "stale asset"); + await assert.rejects(verifyLocal(directory, "0.6.2", desktopOptions), /exactly the six approved/); +}); + +for (const feedName of ["latest-linux.yml", "latest.yml"]) { + for (const mutation of ["version", "path", "hash", "size", "duplicate", "legacy"] as const) { + test(`combined release refuses ${feedName} ${mutation} drift despite fresh SHA256SUMS`, async () => { + const directory = await desktopFixture(`desktop-${feedName}-${mutation}`); + const file = path.join(directory, feedName); + const feed = JSON.parse(await fs.readFile(file, "utf8")); + if (mutation === "version") feed.version = "0.6.1"; + if (mutation === "path") feed.files[0].url = "../" + feed.files[0].url; + if (mutation === "hash") feed.files[0].sha512 = "bad"; + if (mutation === "size") feed.files[0].size++; + if (mutation === "duplicate") feed.files.push({ ...feed.files[0] }); + if (mutation === "legacy") feed.path = "unexpected.exe"; + await fs.writeFile(file, JSON.stringify(feed)); + await writeChecksums(directory, "0.6.2", desktopOptions); + await assert.rejects(verifyLocal(directory, "0.6.2", desktopOptions), /metadata version|reference exactly|update checksum|update size|legacy updater path/); + }); + } +} + +for (const mutation of ["checksum", "coverage", "corrupt"] as const) { + test(`combined release checks Windows blockmap ${mutation} against actual EXE bytes`, async () => { + const directory = await desktopFixture(`desktop-blockmap-${mutation}`); + const file = path.join(directory, "LearnHarness-0.6.2-x64-setup.exe.blockmap"); + const map = JSON.parse(gunzipSync(await fs.readFile(file)).toString()); + if (mutation === "checksum") map.files[0].checksums[0] = "bad"; + if (mutation === "coverage") map.files[0].sizes[0]--; + await fs.writeFile(file, mutation === "corrupt" ? "not gzip" : gzipSync(JSON.stringify(map))); + await writeChecksums(directory, "0.6.2", desktopOptions); + await assert.rejects(verifyLocal(directory, "0.6.2", desktopOptions), /Windows blockmap differs|incorrect header/); + }); +} + +test("combined release uploads and downloads exactly six assets and keeps the release a draft", async () => { + const pkg = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url), "utf8")); + const directory = await desktopFixture("desktop-api", pkg.version); + const expected = expectedReleaseAssets(pkg.version, desktopOptions).sort(); + for (const [command, scenario] of [["upload", "new"], ["upload", "draft"], ["verify-remote", "draft"]]) { + const result = mockedReleaseCommand(directory, command!, ["--appimage-windows"], scenario); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.calls.downloads.sort(), expected); + assert.deepEqual(result.calls.uploads.sort(), scenario === "new" ? expected : []); + assert(result.calls.edits.every(edit => edit.draft)); + } + for (const scenario of ["extra-asset", "missing-asset", "corrupt", "published"]) { + const result = mockedReleaseCommand(directory, "verify-remote", ["--appimage-windows"], scenario); + assert.equal(result.status, 1, result.stderr); + assert(result.calls.api.every(call => call.startsWith("GET "))); + } + for (const options of [["--appimage-windows", "--appimage-only"], ["--appimage-windows", "--appimage-windows"]]) { + const result = mockedReleaseCommand(directory, "upload", options); + assert.equal(result.status, 1); + assert.deepEqual(result.calls.api, []); + } +});