fix(release): never roll the latest updater feed back to an older tag
This commit is contained in:
parent
524d390bc2
commit
5fef311575
3 changed files with 303 additions and 12 deletions
152
scripts/ci/lib/latest-feed-guard.test.mjs
Normal file
152
scripts/ci/lib/latest-feed-guard.test.mjs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// node --test scripts/ci/lib/latest-feed-guard.test.mjs
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import {
|
||||
compareSemver,
|
||||
decideLatestFeedUpdate,
|
||||
parseUpdateMetadataVersion,
|
||||
publishLatestAlias,
|
||||
readPublishedFeedVersion,
|
||||
} from "./latest-feed-guard.mjs";
|
||||
|
||||
const LATEST_YML_191 = [
|
||||
"version: 1.9.1",
|
||||
"files:",
|
||||
" - url: D3RO-Voice-Setup-1.9.1.exe",
|
||||
" sha512: abc",
|
||||
" size: 123",
|
||||
"path: D3RO-Voice-Setup-1.9.1.exe",
|
||||
"sha512: abc",
|
||||
"releaseDate: '2026-09-20T00:00:00.000Z'",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
test("parseUpdateMetadataVersion reads the top-level version of electron-builder metadata", () => {
|
||||
assert.equal(parseUpdateMetadataVersion(LATEST_YML_191), "1.9.1");
|
||||
assert.equal(parseUpdateMetadataVersion("version: '1.10.0'\n"), "1.10.0");
|
||||
assert.equal(parseUpdateMetadataVersion('version: "2.0.0-beta.1"\r\n'), "2.0.0-beta.1");
|
||||
assert.equal(parseUpdateMetadataVersion("files: []\n"), null);
|
||||
assert.equal(parseUpdateMetadataVersion("version: garbage\n"), null);
|
||||
assert.equal(parseUpdateMetadataVersion(""), null);
|
||||
});
|
||||
|
||||
test("compareSemver orders numerically, not lexically, and ranks stable above prerelease", () => {
|
||||
assert.ok(compareSemver("1.10.0", "1.9.1") > 0);
|
||||
assert.ok(compareSemver("1.9.0", "1.9.1") < 0);
|
||||
assert.equal(compareSemver("v1.9.1", "1.9.1"), 0);
|
||||
assert.ok(compareSemver("2.0.0", "2.0.0-beta.1") > 0);
|
||||
assert.ok(compareSemver("2.0.0-alpha.1", "2.0.0-beta.1") < 0);
|
||||
assert.throws(() => compareSemver("x", "1.0.0"));
|
||||
});
|
||||
|
||||
test("decideLatestFeedUpdate refuses to roll the latest alias back to an older tag", () => {
|
||||
assert.deepEqual(decideLatestFeedUpdate({ publishingVersion: "1.9.0", publishedVersion: "1.9.1" }), {
|
||||
update: false,
|
||||
reason: "newer-published",
|
||||
});
|
||||
assert.deepEqual(decideLatestFeedUpdate({ publishingVersion: "1.9.1", publishedVersion: "1.9.0" }), {
|
||||
update: true,
|
||||
reason: "older-published",
|
||||
});
|
||||
assert.deepEqual(decideLatestFeedUpdate({ publishingVersion: "1.9.1", publishedVersion: "1.9.1" }), {
|
||||
update: true,
|
||||
reason: "same-version",
|
||||
});
|
||||
assert.deepEqual(decideLatestFeedUpdate({ publishingVersion: "1.9.1", publishedVersion: undefined }), {
|
||||
update: true,
|
||||
reason: "no-feed",
|
||||
});
|
||||
assert.deepEqual(decideLatestFeedUpdate({ publishingVersion: "1.9.1", publishedVersion: null }), {
|
||||
update: true,
|
||||
reason: "unreadable-feed",
|
||||
});
|
||||
assert.throws(() => decideLatestFeedUpdate({ publishingVersion: "nope", publishedVersion: "1.0.0" }));
|
||||
});
|
||||
|
||||
function fakeFetch(status, body = "") {
|
||||
const calls = [];
|
||||
const impl = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return new Response(status === 404 ? "not found" : body, { status });
|
||||
};
|
||||
return { impl, calls };
|
||||
}
|
||||
|
||||
test("readPublishedFeedVersion maps 404 to no feed, 200 to the parsed version, and fails closed otherwise", async () => {
|
||||
const missing = fakeFetch(404);
|
||||
assert.equal(await readPublishedFeedVersion({ url: "https://x/latest/latest.yml", fetchImpl: missing.impl }), undefined);
|
||||
assert.match(missing.calls[0].url, /^https:\/\/x\/latest\/latest\.yml\?ts=\d+$/);
|
||||
assert.equal(missing.calls[0].init.cache, "no-store");
|
||||
|
||||
const present = fakeFetch(200, LATEST_YML_191);
|
||||
assert.equal(await readPublishedFeedVersion({ url: "https://x/latest/latest.yml", fetchImpl: present.impl }), "1.9.1");
|
||||
|
||||
const broken = fakeFetch(200, "not yaml");
|
||||
assert.equal(await readPublishedFeedVersion({ url: "https://x/latest/latest.yml", fetchImpl: broken.impl }), null);
|
||||
|
||||
const failing = fakeFetch(502, "bad gateway");
|
||||
await assert.rejects(
|
||||
readPublishedFeedVersion({ url: "https://x/latest/latest.yml", fetchImpl: failing.impl }),
|
||||
/HTTP 502/,
|
||||
);
|
||||
});
|
||||
|
||||
test("regression: retrying an older tag's job does not overwrite latest.yml or update-policy.json", async () => {
|
||||
const uploaded = [];
|
||||
const result = await publishLatestAlias({
|
||||
publishingVersion: "1.9.0",
|
||||
files: [{ name: "D3RO-Voice-Setup-1.9.0.exe" }, { name: "latest.yml" }, { name: "update-policy.json" }],
|
||||
readPublishedVersion: async () => "1.9.1",
|
||||
upload: async (file) => {
|
||||
uploaded.push(file.name);
|
||||
},
|
||||
});
|
||||
assert.equal(result.update, false);
|
||||
assert.equal(result.publishedVersion, "1.9.1");
|
||||
assert.deepEqual(uploaded, []);
|
||||
});
|
||||
|
||||
test("publishLatestAlias uploads every file in the given order for a newer tag", async () => {
|
||||
const uploaded = [];
|
||||
const result = await publishLatestAlias({
|
||||
publishingVersion: "1.9.1",
|
||||
files: [{ name: "setup.exe" }, { name: "latest.yml" }, { name: "update-policy.json" }],
|
||||
readPublishedVersion: async () => "1.9.0",
|
||||
upload: async (file) => {
|
||||
uploaded.push(file.name);
|
||||
},
|
||||
});
|
||||
assert.equal(result.update, true);
|
||||
assert.deepEqual(uploaded, ["setup.exe", "latest.yml", "update-policy.json"]);
|
||||
});
|
||||
|
||||
test("publishLatestAlias uploads nothing when the published version cannot be read", async () => {
|
||||
const uploaded = [];
|
||||
await assert.rejects(
|
||||
publishLatestAlias({
|
||||
publishingVersion: "1.9.1",
|
||||
files: [{ name: "latest.yml" }],
|
||||
readPublishedVersion: async () => {
|
||||
throw new Error("HTTP 500");
|
||||
},
|
||||
upload: async (file) => {
|
||||
uploaded.push(file.name);
|
||||
},
|
||||
}),
|
||||
/HTTP 500/,
|
||||
);
|
||||
assert.deepEqual(uploaded, []);
|
||||
});
|
||||
|
||||
test("the Forgejo publisher routes every latest-alias upload through the guard", () => {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL("../publish-forgejo-release.mjs", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /publishLatestAlias\(/);
|
||||
// latest/ 경로에 직접 업로드하는 호출이 guard 밖에 남아 있으면 안 된다.
|
||||
const directLatestUploads = [...source.matchAll(/uploadToRegistry\([^;]*?packageLatestUrl/gs)].length;
|
||||
assert.equal(directLatestUploads, 1, "only the guarded upload callback may target packageLatestUrl");
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue