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
+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", () => {
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 });