diff --git a/scripts/update-versions.mjs b/scripts/update-versions.mjs new file mode 100755 index 0000000..0d8bf03 --- /dev/null +++ b/scripts/update-versions.mjs @@ -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 = /^(?[ \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 };