#!/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 = /^(?[ \t]*)(?(?:[A-Za-z_][A-Za-z0-9_]*)?VERSION)=\$\{\k:-(?[^}]*)\}/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 };