235 lines
7.8 KiB
JavaScript
235 lines
7.8 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawnSync } from "node:child_process";
|
|
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
|
|
const args = process.argv.slice(2);
|
|
const manifestsOnly = args.includes("--manifests-only");
|
|
const positional = args.filter((arg) => arg !== "--manifests-only");
|
|
const [distRootArg, releaseTag] = positional;
|
|
|
|
if (!distRootArg || !releaseTag) {
|
|
console.error("Usage: node scripts/release/publish-electron-assets.mjs [--manifests-only] <dist-root> <release-tag>");
|
|
process.exit(2);
|
|
}
|
|
|
|
const repo = process.env.GITHUB_REPOSITORY;
|
|
if (!repo) {
|
|
console.error("GITHUB_REPOSITORY is required.");
|
|
process.exit(2);
|
|
}
|
|
|
|
const distRoot = resolve(distRootArg);
|
|
const outputDir = resolve(process.env.RUNNER_TEMP || ".", "openwork-electron-manifests");
|
|
mkdirSync(outputDir, { recursive: true });
|
|
|
|
function walk(dir) {
|
|
const entries = [];
|
|
for (const entry of readdirSync(dir)) {
|
|
const path = join(dir, entry);
|
|
const stat = statSync(path);
|
|
if (stat.isDirectory()) entries.push(...walk(path));
|
|
else if (stat.isFile()) entries.push(path);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function isUpdaterManifest(path) {
|
|
return /^(?:latest|cloud|enterprise).*\.ya?ml$/.test(basename(path));
|
|
}
|
|
|
|
function isReleaseAsset(path) {
|
|
if (isUpdaterManifest(path)) return false;
|
|
if (!basename(path).startsWith("openwork-")) return false;
|
|
return /\.(AppImage|blockmap|dmg|exe|rpm|zip)$/i.test(path) || /\.tar\.gz$/i.test(path);
|
|
}
|
|
|
|
function runGh(args) {
|
|
const result = spawnSync("gh", args, { stdio: "inherit", encoding: "utf8" });
|
|
if (result.status !== 0) process.exit(result.status ?? 1);
|
|
}
|
|
|
|
function parseManifest(path) {
|
|
const raw = readFileSync(path, "utf8");
|
|
const parsed = { files: [] };
|
|
let currentFile = null;
|
|
|
|
for (const line of raw.split(/\r?\n/)) {
|
|
const topLevel = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/);
|
|
if (topLevel) {
|
|
const [, key, value] = topLevel;
|
|
if (key !== "files") parsed[key] = unquoteYamlScalar(value);
|
|
currentFile = null;
|
|
continue;
|
|
}
|
|
|
|
const fileStart = line.match(/^\s*-\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/);
|
|
if (fileStart) {
|
|
const [, key, value] = fileStart;
|
|
currentFile = {};
|
|
currentFile[key] = parseYamlScalar(value);
|
|
parsed.files.push(currentFile);
|
|
continue;
|
|
}
|
|
|
|
const fileProp = line.match(/^\s{4}([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/);
|
|
if (fileProp && currentFile) {
|
|
const [, key, value] = fileProp;
|
|
currentFile[key] = parseYamlScalar(value);
|
|
}
|
|
}
|
|
|
|
if (!parsed.version) throw new Error(`Missing version in ${path}`);
|
|
if (!Array.isArray(parsed.files) || parsed.files.length === 0) {
|
|
throw new Error(`Missing files in ${path}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function unquoteYamlScalar(value) {
|
|
const trimmed = String(value || "").trim();
|
|
return trimmed.replace(/^['"]|['"]$/g, "");
|
|
}
|
|
|
|
function parseYamlScalar(value) {
|
|
const unquoted = unquoteYamlScalar(value);
|
|
if (/^\d+$/.test(unquoted)) return Number(unquoted);
|
|
return unquoted;
|
|
}
|
|
|
|
function quoteYamlScalar(value) {
|
|
const string = String(value ?? "");
|
|
if (!string || /[:#\[\]{}&,*!|>'"%@`\s]/.test(string)) {
|
|
return `'${string.replace(/'/g, "''")}'`;
|
|
}
|
|
return string;
|
|
}
|
|
|
|
function stringifyManifest(manifest) {
|
|
const lines = [`version: ${quoteYamlScalar(manifest.version)}`, "files:"];
|
|
for (const file of manifest.files) {
|
|
lines.push(` - url: ${quoteYamlScalar(file.url)}`);
|
|
for (const [key, value] of Object.entries(file)) {
|
|
if (key === "url") continue;
|
|
lines.push(` ${key}: ${typeof value === "number" ? value : quoteYamlScalar(value)}`);
|
|
}
|
|
}
|
|
if (manifest.releaseDate) lines.push(`releaseDate: ${quoteYamlScalar(manifest.releaseDate)}`);
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
function sortFiles(files) {
|
|
const rank = (url) => {
|
|
const value = String(url || "");
|
|
if (value.includes("arm64")) return 0;
|
|
if (value.includes("x64") || value.includes("x86_64")) return 1;
|
|
return 2;
|
|
};
|
|
return [...files].sort((left, right) => {
|
|
const byRank = rank(left.url) - rank(right.url);
|
|
if (byRank !== 0) return byRank;
|
|
const leftExt = extname(String(left.url || ""));
|
|
const rightExt = extname(String(right.url || ""));
|
|
if (leftExt === ".zip" && rightExt !== ".zip") return -1;
|
|
if (rightExt === ".zip" && leftExt !== ".zip") return 1;
|
|
return String(left.url || "").localeCompare(String(right.url || ""));
|
|
});
|
|
}
|
|
|
|
function mergeManifests(name, paths) {
|
|
const manifests = paths.map(parseManifest);
|
|
const version = manifests[0].version;
|
|
for (const manifest of manifests) {
|
|
if (manifest.version !== version) {
|
|
throw new Error(`${name} has mixed versions: ${version} and ${manifest.version}`);
|
|
}
|
|
}
|
|
|
|
const filesByUrl = new Map();
|
|
for (const manifest of manifests) {
|
|
for (const file of manifest.files) {
|
|
if (!file?.url) throw new Error(`${name} contains a file entry without url.`);
|
|
filesByUrl.set(file.url, file);
|
|
}
|
|
}
|
|
|
|
const releaseDates = manifests.map((manifest) => manifest.releaseDate).filter(Boolean).sort();
|
|
const merged = {
|
|
version,
|
|
files: sortFiles([...filesByUrl.values()]),
|
|
};
|
|
if (releaseDates.length) merged.releaseDate = releaseDates[releaseDates.length - 1];
|
|
return merged;
|
|
}
|
|
|
|
function validateManifest(name, manifest) {
|
|
const distribution = name.startsWith("cloud")
|
|
? "cloud"
|
|
: name.startsWith("enterprise")
|
|
? "enterprise"
|
|
: "public";
|
|
const feedName = name.replace(/^(?:cloud|enterprise)(?=\.|-)/, "latest");
|
|
const urls = manifest.files.map((file) => String(file.url || ""));
|
|
const expectedPrefix = distribution === "public" ? "openwork-" : `openwork-${distribution}-`;
|
|
if (
|
|
urls.some((url) => !url.startsWith(expectedPrefix))
|
|
|| (distribution === "public" && urls.some((url) => /^openwork-(?:cloud|enterprise)-/.test(url)))
|
|
) {
|
|
throw new Error(`${name} contains an artifact from a different desktop distribution.`);
|
|
}
|
|
if (feedName === "latest-mac.yml") {
|
|
for (const arch of ["mac-arm64", "mac-x64"]) {
|
|
if (!urls.some((url) => url.includes(arch))) {
|
|
throw new Error(`${name} is missing ${arch} artifacts.`);
|
|
}
|
|
}
|
|
}
|
|
if (feedName === "latest.yml") {
|
|
for (const arch of ["win-arm64", "win-x64"]) {
|
|
if (!urls.some((url) => url.includes(arch))) {
|
|
throw new Error(`${name} is missing ${arch} artifacts.`);
|
|
}
|
|
}
|
|
}
|
|
if (feedName === "latest-linux.yml" && urls.some((url) => url.includes("arm64"))) {
|
|
throw new Error(`${name} should remain the Linux x64 feed; arm64 belongs in latest-linux-arm64.yml.`);
|
|
}
|
|
if (feedName === "latest-linux-arm64.yml" && !urls.some((url) => url.includes("arm64"))) {
|
|
throw new Error(`${name} is missing Linux arm64 artifacts.`);
|
|
}
|
|
}
|
|
|
|
const files = walk(distRoot);
|
|
const releaseAssets = files.filter(isReleaseAsset);
|
|
const manifestsByName = new Map();
|
|
|
|
for (const path of files.filter(isUpdaterManifest)) {
|
|
const name = basename(path);
|
|
const current = manifestsByName.get(name) || [];
|
|
current.push(path);
|
|
manifestsByName.set(name, current);
|
|
}
|
|
|
|
if (!manifestsOnly && releaseAssets.length === 0) {
|
|
console.error(`No Electron release assets found under ${distRoot}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (manifestsByName.size === 0) {
|
|
console.error(`No Electron updater manifests found under ${distRoot}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!manifestsOnly) {
|
|
runGh(["release", "upload", releaseTag, ...releaseAssets, "--repo", repo, "--clobber"]);
|
|
}
|
|
|
|
for (const [name, paths] of [...manifestsByName.entries()].sort()) {
|
|
const manifest = mergeManifests(name, paths);
|
|
validateManifest(name, manifest);
|
|
const outputPath = join(outputDir, name);
|
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
writeFileSync(outputPath, stringifyManifest(manifest), "utf8");
|
|
runGh(["release", "upload", releaseTag, `${outputPath}#${name}`, "--repo", repo, "--clobber"]);
|
|
}
|