feat: Added offline AppImage-only release verification and built the...
- scripts/gitea-release.mjs - tests/gitea-release.test.ts - dist/LearnHarness-0.6.0-x86_64.AppImage - dist/latest-linux.yml - dist/SHA256SUMS - dist/linux-unpacked/resources/app-update.yml - dist/linux-unpacked/resources/server/standalone/server.js GSD context: - Milestone: M001 - Merge upstream v0.4.0 into the Gitea fork - Slice: S03 - Build and verify Linux desktop - Task: T01 - Added offline AppImage-only release verification and built the local Linux x64 LearnHarness 0.6.0 AppImage without publishing. GSD-Task: S03/T01
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -11,14 +12,15 @@ 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) {
|
||||
async function fixture(name: string, appimageOnly = false, fixtureVersion = version) {
|
||||
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);
|
||||
const fixtureNames = expectedLinuxAssets(fixtureVersion, { appimageOnly });
|
||||
const contents = [Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.from("AppImage fixture")]), ...(!appimageOnly ? [Buffer.from("!<arch>\nDebian fixture")] : [])];
|
||||
for (let i = 0; i < contents.length; i++) await fs.writeFile(path.join(directory, fixtureNames[i]!), contents[i]!);
|
||||
const entries = contents.map((data, i) => ({ url: fixtureNames[i], size: data.length, sha512: createHash("sha512").update(data).digest("base64") }));
|
||||
await fs.writeFile(path.join(directory, "latest-linux.yml"), JSON.stringify({ version: fixtureVersion, files: entries, path: entries[0]!.url, sha512: entries[0]!.sha512 }));
|
||||
await writeChecksums(directory, fixtureVersion, { appimageOnly });
|
||||
return directory;
|
||||
}
|
||||
|
||||
@@ -84,6 +86,93 @@ test("release configuration preserves packaging while replacing the upstream upd
|
||||
assert(!workflow.on.pull_request, "untrusted pull requests must not run release publishing");
|
||||
assert.equal(workflow.permissions.releases, "write");
|
||||
});
|
||||
const scopedVersion = "0.6.0";
|
||||
const scopedOptions = { appimageOnly: true };
|
||||
const scopedNames = expectedLinuxAssets(scopedVersion, scopedOptions);
|
||||
|
||||
test("AppImage-only offline scope succeeds without deb; default full-release scope still requires deb", async () => {
|
||||
assert.deepEqual(scopedNames, ["LearnHarness-0.6.0-x86_64.AppImage", "latest-linux.yml", "SHA256SUMS"]);
|
||||
const directory = await fixture("scoped-healthy", true, scopedVersion);
|
||||
assert.deepEqual((await verifyLocal(directory, scopedVersion, scopedOptions)).map((asset) => asset.name), scopedNames);
|
||||
await assert.rejects(verifyLocal(directory, scopedVersion), /ENOENT.*amd64\.deb/);
|
||||
await assert.rejects(writeChecksums(directory, scopedVersion), /ENOENT.*amd64\.deb/);
|
||||
const full = await fixture("full-current", false, scopedVersion);
|
||||
assert.equal((await verifyLocal(full, scopedVersion)).length, 4);
|
||||
await assert.rejects(verifyLocal(full, scopedVersion, scopedOptions), /reference exactly/);
|
||||
});
|
||||
|
||||
for (const [label, mutate, expected] of [
|
||||
["version", (feed: any) => { feed.version = "0.4.0"; }, /metadata version/],
|
||||
["missing-files", (feed: any) => { delete feed.files; }, /must contain files/],
|
||||
["duplicate", (feed: any) => { feed.files.push({ ...feed.files[0] }); }, /reference exactly/],
|
||||
["foreign-url", (feed: any) => { feed.files[0].url = `https://other.example/${scopedNames[0]}`; }, /reference exactly/],
|
||||
["traversal", (feed: any) => { feed.files[0].url = `../${scopedNames[0]}`; }, /reference exactly/],
|
||||
["absolute-path", (feed: any) => { feed.files[0].url = `/tmp/${scopedNames[0]}`; }, /reference exactly/],
|
||||
["wrong-filename", (feed: any) => { feed.files[0].url = "LearnHarness-0.4.0-x86_64.AppImage"; }, /reference exactly/],
|
||||
["sha512", (feed: any) => { feed.files[0].sha512 = "bad"; }, /update checksum/],
|
||||
["size", (feed: any) => { feed.files[0].size++; }, /update size/],
|
||||
["legacy-path", (feed: any) => { feed.path = "https://other.example/payload"; }, /legacy updater path/],
|
||||
["legacy-hash", (feed: any) => { feed.sha512 = "bad"; }, /legacy updater checksum/],
|
||||
] as const) {
|
||||
test(`AppImage-only rejects ${label} metadata even with regenerated SHA256SUMS`, async () => {
|
||||
const directory = await fixture(`scoped-${label}`, true, scopedVersion);
|
||||
const file = path.join(directory, "latest-linux.yml");
|
||||
const feed = JSON.parse(await fs.readFile(file, "utf8"));
|
||||
mutate(feed);
|
||||
await fs.writeFile(file, JSON.stringify(feed));
|
||||
await writeChecksums(directory, scopedVersion, scopedOptions);
|
||||
await assert.rejects(verifyLocal(directory, scopedVersion, scopedOptions), expected);
|
||||
});
|
||||
}
|
||||
|
||||
test("AppImage-only rejects malformed YAML and stale SHA256 manifest", async () => {
|
||||
const directory = await fixture("scoped-malformed", true, scopedVersion);
|
||||
await fs.writeFile(path.join(directory, "SHA256SUMS"), "0".repeat(64) + ` ${scopedNames[0]}\n`);
|
||||
await assert.rejects(verifyLocal(directory, scopedVersion, scopedOptions), /SHA256SUMS/);
|
||||
await fs.writeFile(path.join(directory, "latest-linux.yml"), "version: [unclosed");
|
||||
await writeChecksums(directory, scopedVersion, scopedOptions);
|
||||
await assert.rejects(verifyLocal(directory, scopedVersion, scopedOptions), /unexpected end/);
|
||||
});
|
||||
|
||||
for (const kind of ["missing", "empty", "directory", "symlink", "corrupt"] as const) {
|
||||
test(`AppImage-only fails closed on ${kind} installer`, async () => {
|
||||
const directory = await fixture(`scoped-installer-${kind}`, true, scopedVersion);
|
||||
const file = path.join(directory, scopedNames[0]!);
|
||||
await fs.rm(file);
|
||||
if (kind === "empty") await fs.writeFile(file, "");
|
||||
if (kind === "directory") await fs.mkdir(file);
|
||||
if (kind === "symlink") await fs.symlink("latest-linux.yml", file);
|
||||
if (kind === "corrupt") {
|
||||
await fs.writeFile(file, "<html>download failed</html>");
|
||||
await writeChecksums(directory, scopedVersion, scopedOptions);
|
||||
} else await assert.rejects(writeChecksums(directory, scopedVersion, scopedOptions), /ENOENT|nonempty regular file/);
|
||||
await assert.rejects(verifyLocal(directory, scopedVersion, scopedOptions), /ENOENT|nonempty regular file|invalid installer header/);
|
||||
});
|
||||
}
|
||||
|
||||
test("CLI retains positional directory for scoped offline commands and rejects remote scope before auth or network", 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], {
|
||||
encoding: "utf8", timeout: 10_000,
|
||||
});
|
||||
for (const command of ["checksums", "verify"]) {
|
||||
const result = run(command, directory, "--appimage-only");
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
}
|
||||
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"]) {
|
||||
const result = run(command, directory, "--appimage-only");
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /supported only by offline/);
|
||||
}
|
||||
for (const args of [["--unknown"], ["--appimage-only", "--appimage-only"], [directory, "extra"]]) {
|
||||
assert.equal(run("verify", ...args).status, 1);
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user