You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

442 lines
15 KiB
JavaScript

#!/usr/bin/env node
// Bump the pinned VERSION defaults in dot_local/bin/executable_install-* to
// the latest upstream release, by reconstructing each script's own tag naming
// scheme from its existing URL + default value rather than assuming "vX.Y.Z".
//
// Runs under node, bun, or deno:
// node scripts/update-versions.mjs
// bun scripts/update-versions.mjs
// deno run --allow-net --allow-read --allow-write scripts/update-versions.mjs
import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..");
const BIN_DIR = path.join(REPO_ROOT, "dot_local", "bin");
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
const VERSION_RE = /^(?<indent>[ \t]*)(?<var>(?:[A-Za-z_][A-Za-z0-9_]*)?VERSION)=\$\{\k<var>:-(?<val>[^}]*)\}/gmd;
// Scripts whose download URL doesn't point at github.com/owner/repo directly,
// but whose upstream project is still tagged releases on GitHub. prefix/suffix
// reflect the *actual git tag* convention (e.g. "v1.2.3"), which may differ
// from how the download mirror names its own files.
const OVERRIDE_REPO = {
"install-helm": ["helm", "helm", "v", ""],
"install-cloud-sql-proxy": ["GoogleCloudPlatform", "cloud-sql-proxy", "v", ""],
"install-sqlc": ["sqlc-dev", "sqlc", "v", ""],
"install-gitea-home": ["go-gitea", "gitea", "v", ""],
"install-gitea-system": ["go-gitea", "gitea", "v", ""],
"install-pulumi": ["pulumi", "pulumi", "v", ""],
};
// Repos that publish so many unrelated releases (e.g. a separate weekly
// "edge" channel) that the pinned channel's latest tag can fall off the
// first page of results.
const EXTRA_PAGES = {
"linkerd/linkerd2": 5,
};
function githubHeaders() {
const headers = { "User-Agent": "update-versions", Accept: "application/vnd.github+json" };
if (GITHUB_TOKEN) headers.Authorization = `Bearer ${GITHUB_TOKEN}`;
return headers;
}
async function httpJson(url, headers) {
const res = await fetch(url, { headers: headers || { "User-Agent": "update-versions" } });
if (!res.ok) {
const err = new Error(`HTTP ${res.status}: ${res.statusText}`);
err.status = res.status;
throw err;
}
return res.json();
}
async function httpText(url) {
const res = await fetch(url, { headers: { "User-Agent": "update-versions" } });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return (await res.text()).trim();
}
async function fetchGithubTags(owner, repo) {
const pages = EXTRA_PAGES[`${owner}/${repo}`] || 1;
let tags = [];
try {
for (let page = 1; page <= pages; page++) {
const data = await httpJson(
`https://api.github.com/repos/${owner}/${repo}/releases?per_page=100&page=${page}`,
githubHeaders()
);
if (!data.length) break;
tags.push(...data.filter((r) => !r.draft).map((r) => r.tag_name));
}
} catch (e) {
if (e.status !== 404) throw e;
}
if (!tags.length) {
// Some projects (e.g. rvm) tag releases without using GitHub Releases.
const data = await httpJson(
`https://api.github.com/repos/${owner}/${repo}/tags?per_page=100`,
githubHeaders()
);
tags = data.map((t) => t.name);
}
return tags;
}
function parseNumericTuple(s) {
return (s.match(/\d+/g) || []).map(Number);
}
function compareTuples(a, b) {
const len = Math.max(a.length, b.length);
for (let i = 0; i < len; i++) {
const x = a[i] ?? -1;
const y = b[i] ?? -1;
if (x !== y) return x - y;
}
return 0;
}
function maxByTuple(candidates) {
return candidates.reduce((best, c) => (compareTuples(c[1], best[1]) > 0 ? c : best));
}
// Picks the newest tag matching prefix/suffix, preferring candidates with the
// same "shape" as the currently pinned version (dot-segment count) and the
// same leading (major) component, so a project running two release trains
// side by side (e.g. helm v3 vs v4, or a date-based tag scheme that changed
// format over time) doesn't get silently jumped to the wrong one.
//
// Returns { chosen, globalMax }: globalMax is the best across all shapes/
// majors, so callers can flag "a newer major exists" without auto-applying it.
function bestMatch(tags, prefix, suffix, currentMid) {
const currentShape = (currentMid.match(/\d+/g) || []).length;
const currentMajor = parseNumericTuple(currentMid)[0];
const candidates = [];
for (const tag of tags) {
if (!tag.startsWith(prefix)) continue;
let middle;
if (suffix) {
if (!tag.endsWith(suffix) || tag.length <= prefix.length + suffix.length) continue;
middle = tag.slice(prefix.length, tag.length - suffix.length);
} else {
middle = tag.slice(prefix.length);
}
// Reject pre-releases (rc/beta/alpha/etc): keep only plain N(.N)* tags.
if (!middle || !/^[0-9]+(?:\.[0-9]+)*$/.test(middle)) continue;
candidates.push([middle, parseNumericTuple(middle)]);
}
if (!candidates.length) return { chosen: null, globalMax: null };
const globalMax = maxByTuple(candidates)[0];
const sameShape = candidates.filter((c) => c[1].length === currentShape);
// A single-component scheme (e.g. lf's "r41", "r42") has no real
// major/minor split -- every release changes the "major", so pinning to it
// would make the tool a permanent no-op. Only apply the major-pin tier when
// there's an actual sub-structure to distinguish major from patch.
const sameMajor = currentShape > 1 ? sameShape.filter((c) => c[1][0] === currentMajor) : [];
for (const pool of [sameMajor, sameShape, candidates]) {
if (pool.length) return { chosen: maxByTuple(pool)[0], globalMax };
}
return { chosen: null, globalMax };
}
async function resolveKubectl() {
return (await httpText("https://dl.k8s.io/release/stable.txt")).replace(/^v/, "");
}
async function resolveGo() {
const data = await httpJson("https://go.dev/dl/?mode=json");
for (const rel of data) {
if (rel.stable) return rel.version.slice("go".length);
}
throw new Error("no stable go release found");
}
async function resolveZig() {
const data = await httpJson("https://ziglang.org/download/index.json");
const versions = Object.keys(data).filter((k) => /^[0-9]+(?:\.[0-9]+)*$/.test(k));
return versions.reduce((a, b) => (compareTuples(parseNumericTuple(a), parseNumericTuple(b)) >= 0 ? a : b));
}
async function resolveGoproxy(modulePath) {
const data = await httpJson(`https://proxy.golang.org/${modulePath}/@latest`);
return data.Version;
}
// Scripts that don't resolve to a plain github release lookup at all.
const SPECIAL_RESOLVERS = {
"install-kubectl": resolveKubectl,
"install-go-home": resolveGo,
"install-go-system": resolveGo,
"install-zig": resolveZig,
"install-protoc-gen-go-grpc": () => resolveGoproxy("google.golang.org/grpc/cmd/protoc-gen-go-grpc"),
};
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function findGithubRef(lines, varName) {
const braced = "${" + varName + "}";
const bareRe = new RegExp("\\$" + escapeRegex(varName) + "(?![A-Za-z0-9_])");
for (const line of lines) {
if (!line.includes(braced) && !bareRe.test(line)) continue;
if (line.includes("github.com")) {
const m = line.match(
/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:releases\/download|archive\/refs\/tags)\/([^/\s"']+)/
);
if (m && (m[3].includes(braced) || bareRe.test(m[3]))) return [m[1], m[2], m[3]];
}
if (line.includes("githubusercontent.com")) {
const m = line.match(/raw\.githubusercontent\.com\/([\w.-]+)\/([\w.-]+)\/([^/\s"']+)/);
if (m && (m[3].includes(braced) || bareRe.test(m[3]))) return [m[1], m[2], m[3]];
}
}
return null;
}
// Splits a path segment like "v${VERSION}" or "v$VERSION" or "bws-v${VERSION}"
// around the variable reference, returning [prefix, suffix].
function splitOnVarRef(tagexpr, varName) {
const braced = "${" + varName + "}";
const bareRe = new RegExp("\\$" + escapeRegex(varName) + "(?![A-Za-z0-9_])");
if (tagexpr.includes(braced)) {
const idx = tagexpr.indexOf(braced);
return [tagexpr.slice(0, idx), tagexpr.slice(idx + braced.length)];
}
const bm = bareRe.exec(tagexpr);
return [tagexpr.slice(0, bm.index), tagexpr.slice(bm.index + bm[0].length)];
}
function looksDynamic(val) {
return val.includes("$(") || val.includes("`") || !/\d/.test(val);
}
// First pass: no network. Returns { text, stem, tasks } where each task is
// an object describing one VERSION default and how to resolve it.
function parseFile(fileName, text) {
const lines = text.split("\n");
const stem = fileName.replace(/^executable_/, "");
const tasks = [];
for (const m of text.matchAll(VERSION_RE)) {
const varName = m.groups.var;
const val = m.groups.val;
const span = m.indices.groups.val;
const task = { var: varName, val, span };
if (looksDynamic(val)) {
task.status = "dynamic";
tasks.push(task);
continue;
}
if (SPECIAL_RESOLVERS[stem]) {
task.resolver = ["special", stem];
tasks.push(task);
continue;
}
let owner, repo, urlPrefix, urlSuffix;
const ref = findGithubRef(lines, varName);
if (ref) {
const [o, r, tagexpr] = ref;
owner = o;
repo = r;
[urlPrefix, urlSuffix] = splitOnVarRef(tagexpr, varName);
} else if (OVERRIDE_REPO[stem]) {
[owner, repo, urlPrefix, urlSuffix] = OVERRIDE_REPO[stem];
} else {
task.status = "no-resolver";
tasks.push(task);
continue;
}
const lead = (val.match(/^\D*/) || [""])[0];
const trail = (val.match(/\D*$/) || [""])[0];
const midVal = val.slice(lead.length, val.length - trail.length);
task.resolver = ["github", owner, repo];
task.lead = lead;
task.trail = trail;
task.midVal = midVal;
task.combinedPrefix = urlPrefix + lead;
task.combinedSuffix = trail + urlSuffix;
tasks.push(task);
}
return { stem, tasks };
}
function parseArgs(argv) {
const args = { filter: [], dryRun: false, verbose: false };
for (const a of argv) {
if (a === "-n" || a === "--dry-run") args.dryRun = true;
else if (a === "-v" || a === "--verbose") args.verbose = true;
else if (a === "-h" || a === "--help") {
console.log("usage: update-versions [-n|--dry-run] [-v|--verbose] [filter ...]");
process.exit(0);
} else args.filter.push(a);
}
return args;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (!GITHUB_TOKEN) {
console.error(
"note: no GITHUB_TOKEN/GH_TOKEN set; this checks ~60 repos and may hit " +
"GitHub's 60 req/hour unauthenticated rate limit\n"
);
}
let fileNames = (await readdir(BIN_DIR)).filter((f) => f.startsWith("executable_install-"));
fileNames.sort();
if (args.filter.length) {
fileNames = fileNames.filter((f) => args.filter.some((needle) => f.includes(needle)));
}
const parsed = [];
for (const fileName of fileNames) {
const text = await readFile(path.join(BIN_DIR, fileName), "utf8");
const { stem, tasks } = parseFile(fileName, text);
parsed.push({ fileName, text, stem, tasks });
}
const specialStems = new Set();
const repoKeys = new Set();
for (const { tasks } of parsed) {
for (const t of tasks) {
if (!t.resolver) continue;
if (t.resolver[0] === "special") specialStems.add(t.resolver[1]);
else repoKeys.add(`${t.resolver[1]}/${t.resolver[2]}`);
}
}
const specialResults = new Map();
const repoTags = new Map();
await Promise.all(
[...specialStems].map(async (stem) => {
try {
specialResults.set(stem, { ok: true, value: await SPECIAL_RESOLVERS[stem]() });
} catch (e) {
specialResults.set(stem, { ok: false, error: e.message });
}
})
);
await Promise.all(
[...repoKeys].map(async (key) => {
const [owner, repo] = key.split("/");
try {
repoTags.set(key, { ok: true, value: await fetchGithubTags(owner, repo) });
} catch (e) {
repoTags.set(key, { ok: false, error: `${e.status ? "HTTP " + e.status : "error"}: ${e.message}` });
}
})
);
let changedFiles = 0;
let rateLimited = false;
for (const { fileName, text, stem, tasks } of parsed) {
const edits = [];
const linesOut = [];
for (const task of tasks) {
const { var: varName, val } = task;
let status = task.status;
let newVal = null;
if (!status) {
const kind = task.resolver[0];
if (kind === "special") {
const result = specialResults.get(task.resolver[1]);
if (!result.ok) {
status = `error: ${result.error}`;
} else if (result.value === val) {
status = "current";
} else {
newVal = result.value;
status = "update";
}
} else {
const key = `${task.resolver[1]}/${task.resolver[2]}`;
const result = repoTags.get(key);
if (!result.ok) {
status = `error: ${result.error}`;
if (result.error.includes("403") || result.error.toLowerCase().includes("rate limit")) {
rateLimited = true;
}
} else {
const { chosen, globalMax } = bestMatch(result.value, task.combinedPrefix, task.combinedSuffix, task.midVal);
if (chosen === null) {
status = "no-match";
} else {
const candidate = task.lead + chosen + task.trail;
if (candidate === val) {
status = "current";
if (globalMax !== chosen) {
const globalCandidate = task.lead + globalMax + task.trail;
status = `current; newer major available: ${globalCandidate}`;
}
} else {
newVal = candidate;
status = "update";
}
}
}
}
}
if (status === "update") {
edits.push([task.span, newVal]);
linesOut.push(` ${varName}: ${val} -> ${newVal}`);
} else if (status === "current") {
if (args.verbose) linesOut.push(` ${varName}: ${val} (current)`);
} else if (status === "dynamic") {
if (args.verbose) linesOut.push(` ${varName}: ${val} (dynamic, skipped)`);
} else {
linesOut.push(` ${varName}: ${val} (${status})`);
}
}
if (linesOut.length) {
console.log(stem);
console.log(linesOut.join("\n"));
}
if (edits.length) {
changedFiles++;
if (!args.dryRun) {
let newText = text;
edits.sort((a, b) => b[0][0] - a[0][0]);
for (const [[start, end], newVal] of edits) {
newText = newText.slice(0, start) + newVal + newText.slice(end);
}
await writeFile(path.join(BIN_DIR, fileName), newText);
}
}
}
const verb = args.dryRun ? "would update" : "updated";
console.log(`\n${changedFiles} script(s) ${verb}.`);
if (rateLimited) {
console.error("Some lookups were rate-limited by GitHub; set GITHUB_TOKEN and re-run to pick up the rest.");
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}
export { parseFile, findGithubRef, splitOnVarRef, bestMatch, parseNumericTuple };