Files
openwork/scripts/release/purge-legacy-releases.mjs
T

178 lines
6.4 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync, spawnSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const MAX_BUFFER = 128 * 1024 * 1024;
const RELEASE_GROUPS = [
{ group: "alpha", pattern: /^alpha-macos-v\d/ },
{ group: "dev", pattern: /^v[0-9a-f]{7}-dev$/ },
{ group: "channel-alias", pattern: /^(knoppers|kitkat)-\d/ },
{ group: "channel-build", pattern: /^(canary|experimental)-macos-v\d/ },
];
const SIDECAR_GROUPS = [
{ group: "sidecar-orchestrator", pattern: /^openwork-orchestrator-v\d/ },
{ group: "sidecar-openwrk", pattern: /^openwrk-v\d/ },
];
export function selectReleasesToPurge(releases, { includeSidecarBundles, protectedTags }) {
const protection = new Set(protectedTags);
const groups = includeSidecarBundles
? [...RELEASE_GROUPS, ...SIDECAR_GROUPS]
: RELEASE_GROUPS;
return releases.flatMap((release) => {
const tag = release.tag_name;
if (
typeof tag !== "string"
|| tag === "alpha-macos-latest"
|| /^v\d+\.\d+\.\d+/.test(tag)
|| protection.has(tag)
) {
return [];
}
const matchedGroup = groups.find(({ pattern }) => pattern.test(tag));
return matchedGroup ? [{ tag, group: matchedGroup.group }] : [];
});
}
function parsePaginatedArrays(output) {
const parsed = JSON.parse(output);
if (!Array.isArray(parsed)) throw new Error("Expected a JSON array from gh api.");
if (parsed.every(Array.isArray)) return parsed.flat();
if (parsed.every((item) => !Array.isArray(item))) return parsed;
throw new Error("Expected gh api pagination output to contain arrays of releases.");
}
function parseArgs(args) {
const options = { execute: false, includeSidecarBundles: false };
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--execute") {
options.execute = true;
continue;
}
if (arg === "--include-sidecar-bundles") {
options.includeSidecarBundles = true;
continue;
}
if (["--repo", "--out"].includes(arg)) {
const value = args[index + 1];
if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value.`);
options[arg.slice(2)] = value;
index += 1;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
if (!options.repo) {
throw new Error("Usage: node scripts/release/purge-legacy-releases.mjs --repo <owner/name> [--execute] [--include-sidecar-bundles] [--out <path>]");
}
return options;
}
async function readManifestProtectedTags(repo) {
const url = `https://github.com/${repo}/releases/download/alpha-macos-latest/latest-mac.yml`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Unable to fetch the live alpha manifest (${response.status} ${response.statusText}); refusing to continue.`);
}
const manifest = await response.text();
const tags = new Set();
for (const match of manifest.matchAll(/\/releases\/download\/([^/\s"'?#]+)\//g)) {
tags.add(decodeURIComponent(match[1]));
}
return tags;
}
function listReleases(repo) {
const output = execFileSync(
"gh",
["api", "--paginate", "--slurp", `repos/${repo}/releases?per_page=100`],
{ encoding: "utf8", maxBuffer: MAX_BUFFER },
);
return parsePaginatedArrays(output);
}
function printPlan(selections, groupNames, protectedMatches, manifestTags) {
const counts = new Map(groupNames.map((group) => [group, 0]));
for (const { group } of selections) counts.set(group, counts.get(group) + 1);
console.log("Group\tCount");
for (const [group, count] of counts) console.log(`${group}\t${count}`);
console.log(`total\t${selections.length}`);
console.log("Live alpha manifest release tags:");
console.log(manifestTags.size > 0 ? [...manifestTags].sort().map((tag) => ` ${tag}`).join("\n") : " (none)");
console.log("Protected matching tags excluded from deletion:");
console.log(protectedMatches.length > 0 ? protectedMatches.map((tag) => ` ${tag}`).join("\n") : " (none)");
}
function writePlan(path, selections, releases) {
const releasesByTag = new Map(releases.map((release) => [release.tag_name, release]));
const rows = selections.map(({ tag, group }) => {
const release = releasesByTag.get(tag);
return `${tag}\t${group}\t${release.created_at}`;
});
writeFileSync(path, `tag\tgroup\tcreated_at\n${rows.join("\n")}${rows.length ? "\n" : ""}`, "utf8");
console.log(`Wrote ${selections.length} release(s) to ${path}.`);
}
const delay = (milliseconds) => new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
async function main() {
const options = parseArgs(process.argv.slice(2));
const manifestTags = await readManifestProtectedTags(options.repo);
const releases = listReleases(options.repo);
const selectionOptions = {
includeSidecarBundles: options.includeSidecarBundles,
protectedTags: manifestTags,
};
const selections = selectReleasesToPurge(releases, selectionOptions);
const withoutManifestProtection = selectReleasesToPurge(releases, {
...selectionOptions,
protectedTags: [],
});
const selectedTags = new Set(selections.map(({ tag }) => tag));
const protectedMatches = withoutManifestProtection
.map(({ tag }) => tag)
.filter((tag) => !selectedTags.has(tag));
const groupNames = [
...RELEASE_GROUPS.map(({ group }) => group),
...(options.includeSidecarBundles ? SIDECAR_GROUPS.map(({ group }) => group) : []),
];
printPlan(selections, groupNames, protectedMatches, manifestTags);
if (options.out) writePlan(options.out, selections, releases);
if (!options.execute) {
console.log("Dry run only; pass --execute to delete these releases and tags.");
return;
}
let deleted = 0;
let failed = 0;
for (let index = 0; index < selections.length; index += 1) {
const { tag, group } = selections[index];
console.log(`Deleting ${tag} (${group})`);
const result = spawnSync(
"gh",
["release", "delete", tag, "--repo", options.repo, "--cleanup-tag", "--yes"],
{ stdio: "inherit" },
);
if (result.status === 0) deleted += 1;
else failed += 1;
if (index < selections.length - 1) await delay(150);
}
console.log(`Deletion complete: ${deleted} deleted, ${failed} failed.`);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}