chore: release v1.21.0

This commit is contained in:
coso
2026-04-28 09:10:18 +08:00
parent 1c2f52927a
commit b35e74c8cb
297 changed files with 28665 additions and 15287 deletions
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const item = argv[index];
if (!item.startsWith("--")) {
continue;
}
const key = item.slice(2);
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
args[key] = "true";
continue;
}
args[key] = next;
index += 1;
}
return args;
}
function normalizeVersion(value) {
return String(value || "")
.trim()
.replace(/^v/, "");
}
function compareStableVersionsDesc(left, right) {
const leftParts = normalizeVersion(left)
.split(".")
.map((part) => Number(part));
const rightParts = normalizeVersion(right)
.split(".")
.map((part) => Number(part));
for (let index = 0; index < 3; index += 1) {
const diff = (rightParts[index] || 0) - (leftParts[index] || 0);
if (diff !== 0) {
return diff;
}
}
return normalizeVersion(right).localeCompare(normalizeVersion(left));
}
function extractKeys(value) {
if (Array.isArray(value)) {
return value.flatMap(extractKeys);
}
if (!value || typeof value !== "object") {
return [];
}
if (typeof value.key === "string") {
return [value.key];
}
if (Array.isArray(value.objects)) {
return value.objects.flatMap(extractKeys);
}
if (Array.isArray(value.result)) {
return value.result.flatMap(extractKeys);
}
return [];
}
function releaseVersionFromKey(key, channel = "stable") {
const escapedChannel = channel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = key.match(new RegExp(`^lime/${escapedChannel}/v([^/]+)/`));
return match ? match[1] : null;
}
function planR2ReleaseCleanup(options) {
const channel = options.channel || "stable";
const keep = Math.max(1, Number(options.keep || 3));
const keys = Array.from(new Set(options.keys || [])).sort();
const current = normalizeVersion(options.currentVersion);
const minimum = normalizeVersion(options.minimumSupportedVersion);
const versions = Array.from(
new Set(
keys.map((key) => releaseVersionFromKey(key, channel)).filter(Boolean),
),
).sort(compareStableVersionsDesc);
const protectedVersions = new Set(
versions.slice(0, keep).map(normalizeVersion),
);
if (current) {
protectedVersions.add(current);
}
if (minimum) {
protectedVersions.add(minimum);
}
const deleteKeys = keys.filter((key) => {
const version = releaseVersionFromKey(key, channel);
return version && !protectedVersions.has(normalizeVersion(version));
});
return {
deleteKeys,
protectedVersions: Array.from(protectedVersions).sort(
compareStableVersionsDesc,
),
versions,
};
}
function main() {
const args = parseArgs(process.argv.slice(2));
const inputPath = args.input;
if (!inputPath) {
throw new Error("--input is required");
}
const raw = JSON.parse(fs.readFileSync(path.resolve(inputPath), "utf8"));
const plan = planR2ReleaseCleanup({
channel: args.channel || process.env.LIME_RELEASE_CHANNEL || "stable",
currentVersion:
args.current || process.env.RELEASE_TAG || process.env.GITHUB_REF_NAME,
keep: args.keep || process.env.LIME_R2_KEEP_RELEASES || "3",
keys: extractKeys(raw),
minimumSupportedVersion:
args.minimum || process.env.LIME_MINIMUM_SUPPORTED_VERSION || "",
});
const outputPath = args.output ? path.resolve(args.output) : "";
if (outputPath) {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(
outputPath,
`${plan.deleteKeys.join("\n")}${plan.deleteKeys.length ? "\n" : ""}`,
);
} else {
process.stdout.write(
`${plan.deleteKeys.join("\n")}${plan.deleteKeys.length ? "\n" : ""}`,
);
}
}
const isCli =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isCli) {
main();
}
export { extractKeys, planR2ReleaseCleanup, releaseVersionFromKey };
+358
View File
@@ -0,0 +1,358 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const DEFAULT_REQUIRED_PLATFORMS = [
"darwin-aarch64",
"darwin-x86_64",
"windows-x86_64",
];
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const item = argv[index];
if (!item.startsWith("--")) {
continue;
}
const key = item.slice(2);
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
args[key] = "true";
continue;
}
args[key] = next;
index += 1;
}
return args;
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function writeJson(filePath, data) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`);
}
function walkFiles(rootDir) {
if (!fs.existsSync(rootDir)) {
return [];
}
const result = [];
const stack = [rootDir];
while (stack.length > 0) {
const current = stack.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
} else if (entry.isFile()) {
result.push(fullPath);
}
}
}
return result.sort();
}
function normalizeVersionTag(version) {
const normalized = String(version || "").trim();
if (!normalized) {
throw new Error("release version is required");
}
const withoutPrefix = normalized.replace(/^v/, "");
if (withoutPrefix.includes("-")) {
throw new Error(
`stable updater manifest does not accept prerelease version: ${normalized}`,
);
}
if (!/^\d+\.\d+\.\d+(?:\+\S+)?$/.test(withoutPrefix)) {
throw new Error(`release version must be stable semver: ${normalized}`);
}
return `v${withoutPrefix.split("+")[0]}`;
}
function normalizeStableVersion(version) {
return normalizeVersionTag(version).slice(1);
}
function normalizeBaseUrl(baseUrl) {
const normalized = String(baseUrl || "")
.trim()
.replace(/\/+$/, "");
if (!normalized) {
throw new Error("base URL is required");
}
return normalized;
}
function encodePathSegment(value) {
return encodeURIComponent(value).replace(
/[!'()*]/g,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
function basenameFromUrl(url) {
try {
const parsed = new URL(url);
return decodeURIComponent(path.posix.basename(parsed.pathname));
} catch {
return path.basename(String(url || ""));
}
}
function collectFilesByBasename(files) {
const byName = new Map();
for (const file of files) {
const name = path.basename(file);
if (!byName.has(name)) {
byName.set(name, []);
}
byName.get(name).push(file);
}
return byName;
}
function findLatestManifestFiles(files) {
return files.filter((file) => /^latest.*\.json$/i.test(path.basename(file)));
}
function resolveReferencedAssetFile(manifestFile, candidates) {
const manifestDir = path.dirname(manifestFile);
const assetCandidates = candidates.filter(
(candidate) => !/^latest.*\.json$/i.test(path.basename(candidate)),
);
return (
assetCandidates.find(
(candidate) => path.dirname(candidate) === manifestDir,
) || assetCandidates[0]
);
}
function buildPlatformAssetPath(platformKey, assetName) {
return `${encodePathSegment(platformKey)}/${encodePathSegment(assetName)}`;
}
function collectUpdaterManifest(options) {
const assetsDir = path.resolve(options.assetsDir);
const baseUrl = normalizeBaseUrl(options.baseUrl);
const channel = options.channel || "stable";
const versionTag = normalizeVersionTag(options.version);
const version = versionTag.slice(1);
const files = walkFiles(assetsDir);
const filesByBasename = collectFilesByBasename(files);
const latestFiles = findLatestManifestFiles(files);
const requiredPlatforms = (
options.requiredPlatforms || DEFAULT_REQUIRED_PLATFORMS
)
.map((value) => String(value).trim())
.filter(Boolean);
if (latestFiles.length === 0) {
throw new Error(`no latest*.json files found under ${assetsDir}`);
}
const platforms = {};
const referencedFiles = new Map();
let notes = options.notes || "";
let pubDate = options.pubDate || "";
for (const manifestFile of latestFiles) {
const manifest = readJson(manifestFile);
const manifestVersion = normalizeStableVersion(manifest.version);
if (manifestVersion !== version) {
throw new Error(
`${manifestFile} version ${manifest.version} does not match release ${versionTag}`,
);
}
if (!notes && typeof manifest.notes === "string") {
notes = manifest.notes;
}
if (!pubDate && typeof manifest.pub_date === "string") {
pubDate = manifest.pub_date;
}
for (const [platformKey, platform] of Object.entries(
manifest.platforms || {},
)) {
const sourceUrl = String(platform?.url || "").trim();
const signature = String(platform?.signature || "").trim();
if (!sourceUrl || !signature) {
throw new Error(
`${manifestFile} platform ${platformKey} has empty url or signature`,
);
}
if (platforms[platformKey]) {
throw new Error(
`${manifestFile} duplicates updater platform ${platformKey}`,
);
}
const assetName = basenameFromUrl(sourceUrl);
const candidates = filesByBasename.get(assetName) || [];
const assetFile = resolveReferencedAssetFile(manifestFile, candidates);
if (!assetFile) {
throw new Error(
`${manifestFile} platform ${platformKey} references missing asset: ${assetName}`,
);
}
const platformAssetPath = buildPlatformAssetPath(platformKey, assetName);
const targetUrl = `${baseUrl}/lime/${channel}/${versionTag}/${platformAssetPath}`;
platforms[platformKey] = {
signature,
url: targetUrl,
};
referencedFiles.set(platformAssetPath, {
assetName,
file: assetFile,
platformKey,
targetPath: platformAssetPath,
});
}
}
const missing = requiredPlatforms.filter((platform) => !platforms[platform]);
if (missing.length > 0) {
throw new Error(
`updater manifest missing required platforms: ${missing.join(", ")}`,
);
}
const manifest = {
version,
notes,
pub_date: pubDate || new Date().toISOString(),
platforms,
};
const r2UploadPlan = [
...Array.from(referencedFiles.values()).map((item) => ({
cacheControl: "public, max-age=31536000, immutable",
contentType: "application/octet-stream",
file: item.file,
key: `lime/${channel}/${versionTag}/${item.targetPath}`,
})),
].sort((a, b) => a.key.localeCompare(b.key));
return {
manifest,
referencedFiles: Array.from(referencedFiles.values())
.map((item) => item.file)
.sort(),
r2UploadPlan,
version,
versionTag,
};
}
function writeOutputs(result, outDir, channel) {
const outputDir = path.resolve(outDir);
const latestPath = path.join(outputDir, "latest.json");
const versionedLatestPath = path.join(
outputDir,
result.versionTag,
"latest.json",
);
const uploadPlanPath = path.join(outputDir, "r2-upload-plan.json");
const metadataPath = path.join(outputDir, "manifest-meta.json");
writeJson(latestPath, result.manifest);
writeJson(versionedLatestPath, result.manifest);
writeJson(uploadPlanPath, [
...result.r2UploadPlan,
{
cacheControl: "public, max-age=60, stale-while-revalidate=300",
contentType: "application/json",
file: versionedLatestPath,
key: `lime/${channel}/${result.versionTag}/latest.json`,
},
{
cacheControl: "public, max-age=60, stale-while-revalidate=300",
contentType: "application/json",
file: latestPath,
key: `lime/${channel}/latest.json`,
},
]);
writeJson(metadataPath, {
platformCount: Object.keys(result.manifest.platforms).length,
version: result.version,
versionTag: result.versionTag,
});
return {
latestPath,
metadataPath,
uploadPlanPath,
versionedLatestPath,
};
}
function loadNotes(args) {
if (args["notes-file"]) {
return fs.readFileSync(path.resolve(args["notes-file"]), "utf8").trim();
}
return process.env.RELEASE_BODY || "";
}
function main() {
const args = parseArgs(process.argv.slice(2));
const requiredPlatforms = (
args["required-platforms"] ||
process.env.REQUIRED_UPDATE_PLATFORMS ||
""
)
.split(",")
.map((value) => value.trim())
.filter(Boolean);
const channel = args.channel || process.env.LIME_RELEASE_CHANNEL || "stable";
const result = collectUpdaterManifest({
assetsDir: args["assets-dir"] || "release-assets",
baseUrl:
args["base-url"] ||
process.env.LIME_UPDATES_BASE_URL ||
"https://updates.limecloud.com",
channel,
notes: loadNotes(args),
pubDate: args["pub-date"] || process.env.RELEASE_PUB_DATE || "",
requiredPlatforms:
requiredPlatforms.length > 0
? requiredPlatforms
: DEFAULT_REQUIRED_PLATFORMS,
version:
args.version || process.env.RELEASE_TAG || process.env.GITHUB_REF_NAME,
});
const output = writeOutputs(
result,
args["out-dir"] || "release-updater",
channel,
);
console.log(
JSON.stringify(
{
...output,
platformCount: Object.keys(result.manifest.platforms).length,
version: result.version,
},
null,
2,
),
);
}
const isCli =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isCli) {
main();
}
export { collectUpdaterManifest, normalizeVersionTag, writeOutputs };
+220
View File
@@ -0,0 +1,220 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { planR2ReleaseCleanup } from "./plan-r2-release-cleanup.mjs";
import {
collectUpdaterManifest,
writeOutputs,
} from "./release-updater-manifest.mjs";
function writeJson(filePath, data) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`);
}
function writeFile(filePath, content = "asset") {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
describe("release updater manifest", () => {
it("聚合平台 latest.json 并改写为 R2 自域名 URL", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lime-release-"));
const assetsDir = path.join(root, "release-assets");
writeFile(path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz"));
writeFile(
path.join(assetsDir, "x86_64-apple-darwin", "Lime-x64.app.tar.gz"),
);
writeFile(path.join(assetsDir, "x86_64-pc-windows-msvc", "Lime.nsis.zip"));
writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), {
version: "1.20.0",
notes: "notes",
pub_date: "2026-04-28T00:00:00Z",
platforms: {
"darwin-aarch64": {
signature: "sig-arm",
url: "https://github.com/limecloud/lime/releases/download/v1.20.0/Lime.app.tar.gz",
},
},
});
writeJson(path.join(assetsDir, "x86_64-apple-darwin", "latest.json"), {
version: "1.20.0",
platforms: {
"darwin-x86_64": {
signature: "sig-x64",
url: "https://github.com/limecloud/lime/releases/download/v1.20.0/Lime-x64.app.tar.gz",
},
},
});
writeJson(path.join(assetsDir, "x86_64-pc-windows-msvc", "latest.json"), {
version: "1.20.0",
platforms: {
"windows-x86_64": {
signature: "sig-win",
url: "https://github.com/limecloud/lime/releases/download/v1.20.0/Lime.nsis.zip",
},
},
});
const result = collectUpdaterManifest({
assetsDir,
baseUrl: "https://updates.limecloud.com/",
channel: "stable",
requiredPlatforms: ["darwin-aarch64", "darwin-x86_64", "windows-x86_64"],
version: "v1.20.0",
});
expect(result.manifest.version).toBe("1.20.0");
expect(Object.keys(result.manifest.platforms).sort()).toEqual([
"darwin-aarch64",
"darwin-x86_64",
"windows-x86_64",
]);
expect(result.manifest.platforms["darwin-aarch64"].url).toBe(
"https://updates.limecloud.com/lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz",
);
expect(result.r2UploadPlan).toHaveLength(3);
const output = writeOutputs(result, path.join(root, "out"), "stable");
expect(fs.existsSync(output.latestPath)).toBe(true);
expect(fs.existsSync(output.versionedLatestPath)).toBe(true);
});
it("缺少必需平台时失败,避免发布半可用清单", () => {
const root = fs.mkdtempSync(
path.join(os.tmpdir(), "lime-release-missing-"),
);
const assetsDir = path.join(root, "release-assets");
writeFile(path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz"));
writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), {
version: "1.20.0",
platforms: {
"darwin-aarch64": {
signature: "sig-arm",
url: "https://example.com/Lime.app.tar.gz",
},
},
});
expect(() =>
collectUpdaterManifest({
assetsDir,
baseUrl: "https://updates.limecloud.com",
requiredPlatforms: ["darwin-aarch64", "windows-x86_64"],
version: "v1.20.0",
}),
).toThrow(/missing required platforms/);
});
it("平台 latest.json 版本必须和发布 tag 一致", () => {
const root = fs.mkdtempSync(
path.join(os.tmpdir(), "lime-release-version-"),
);
const assetsDir = path.join(root, "release-assets");
writeFile(path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz"));
writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), {
version: "1.19.0",
platforms: {
"darwin-aarch64": {
signature: "sig-arm",
url: "https://example.com/Lime.app.tar.gz",
},
},
});
expect(() =>
collectUpdaterManifest({
assetsDir,
baseUrl: "https://updates.limecloud.com",
requiredPlatforms: ["darwin-aarch64"],
version: "v1.20.0",
}),
).toThrow(/does not match release/);
});
it("同名跨平台 updater 包应写入独立 R2 路径", () => {
const root = fs.mkdtempSync(
path.join(os.tmpdir(), "lime-release-same-name-"),
);
const assetsDir = path.join(root, "release-assets");
writeFile(
path.join(assetsDir, "aarch64-apple-darwin", "Lime.app.tar.gz"),
"arm",
);
writeFile(
path.join(assetsDir, "x86_64-apple-darwin", "Lime.app.tar.gz"),
"x64",
);
writeJson(path.join(assetsDir, "aarch64-apple-darwin", "latest.json"), {
version: "1.20.0",
platforms: {
"darwin-aarch64": {
signature: "sig-arm",
url: "https://example.com/Lime.app.tar.gz",
},
},
});
writeJson(path.join(assetsDir, "x86_64-apple-darwin", "latest.json"), {
version: "1.20.0",
platforms: {
"darwin-x86_64": {
signature: "sig-x64",
url: "https://example.com/Lime.app.tar.gz",
},
},
});
const result = collectUpdaterManifest({
assetsDir,
baseUrl: "https://updates.limecloud.com",
channel: "stable",
requiredPlatforms: ["darwin-aarch64", "darwin-x86_64"],
version: "v1.20.0",
});
expect(result.manifest.platforms["darwin-aarch64"].url).toBe(
"https://updates.limecloud.com/lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz",
);
expect(result.manifest.platforms["darwin-x86_64"].url).toBe(
"https://updates.limecloud.com/lime/stable/v1.20.0/darwin-x86_64/Lime.app.tar.gz",
);
expect(result.r2UploadPlan.map((item) => item.key).sort()).toEqual([
"lime/stable/v1.20.0/darwin-aarch64/Lime.app.tar.gz",
"lime/stable/v1.20.0/darwin-x86_64/Lime.app.tar.gz",
]);
expect(
result.r2UploadPlan
.map((item) => fs.readFileSync(item.file, "utf8"))
.sort(),
).toEqual(["arm", "x64"]);
});
});
describe("R2 release cleanup", () => {
it("只删除超过保留窗口且未受保护的旧版本", () => {
const keys = [
"lime/stable/v1.20.0/latest.json",
"lime/stable/v1.20.0/Lime.nsis.zip",
"lime/stable/v1.19.0/Lime.nsis.zip",
"lime/stable/v1.18.0/Lime.nsis.zip",
"lime/stable/v1.17.0/Lime.nsis.zip",
"lime/stable/v1.16.0/Lime.nsis.zip",
];
const plan = planR2ReleaseCleanup({
currentVersion: "v1.20.0",
keep: 3,
keys,
minimumSupportedVersion: "v1.16.0",
});
expect(plan.deleteKeys).toEqual(["lime/stable/v1.17.0/Lime.nsis.zip"]);
expect(plan.protectedVersions).toContain("1.16.0");
});
});