Add scripts/update-versions to bump pinned install script versions
Scans dot_local/bin/executable_install-* for pinned VERSION defaults, reconstructs each script's own GitHub tag naming scheme from its download URL, and rewrites the default to the latest matching release. Runs under node/bun/deno via fetch and node: builtins only.main
parent
1a11841bfc
commit
fbc5eab5e4
@ -0,0 +1,441 @@
|
||||
#!/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 };
|
||||
Loading…
Reference in New Issue