1import * as _ from 'lodash-es' 3import MagicString from 'magic-string' 6import ts from 'typescript' 10const preReleasePattern = /alpha\d*|a\d+$|beta\d*|rc\d+|dev|nightly|snapshot|preview|canary|prerelease|unstable|experimental/i 12const parseVersion = (tag: string) => { 13 const cleaned = tag.replace(/^v/, '').replace(/^multiarch-v?/, '') 14 const match = cleaned.match(/^(\d+)\.(\d+)\.?(\d*)/) 15 if (!match) return null 17 major: parseInt(match[1], 10), 18 minor: parseInt(match[2], 10), 19 patch: match[3] ? parseInt(match[3], 10) : 0, 24const compareVersions = (a: {major: number, minor: number, patch: number}, b: {major: number, minor: number, patch: number}) => { 25 if (a.major !== b.major) return b.major - a.major 26 if (a.minor !== b.minor) return b.minor - a.minor 27 return b.patch - a.patch 30const fetchDockerHubTags = async (imageName: string) => { 31 const [namespace, repo] = imageName.split('/') 32 const allTags: string[] = [] 33 let url: string | null = `https://hub.docker.com/v2/repositories/${namespace}/${repo}/tags?page_size=100` 35 const resp: Response = await fetch(url, {signal: AbortSignal.timeout(15000)}) 37 const json = await resp.json() 38 allTags.push(..._.map(json.results, 'name')) 39 if (allTags.length >= 500) break 45const fetchGithubReleaseTags = async ({owner, repo}: {owner: string, repo: string}) => { 46 const url = `https://api.github.com/repos/${owner}/${repo}/releases?per_page=100` 47 const resp = await fetch(url, {signal: AbortSignal.timeout(15000)}) 49 const json = await resp.json() 50 return _.map(json, 'tag_name') 53const fetchGcrTags = async (imagePath: string) => { 54 const url = `https://gcr.io/v2/${imagePath}/tags/list` 55 const resp = await fetch(url, {signal: AbortSignal.timeout(15000)}) 57 const json = await resp.json() 58 return json.tags || [] 61// pins that carry only a version (image lives here); github overrides an underivable tag source 62const versionOnlyPins: Record<string, {image: string, github?: string}> = { 63 rethV: {image: 'ghcr.io/paradigmxyz/reth'}, 64 nimbusV: {image: 'statusim/nimbus-eth2'}, 65 nethermindV: {image: 'nethermind/nethermind'}, 66 prysmVersion: {image: 'gcr.io/prysmaticlabs/prysm/beacon-chain'}, 67 ingressNginxVersion: {image: 'kubernetes/ingress-nginx', github: 'kubernetes/ingress-nginx'}, 70const fetchTagsForImage = ({image, github}: {image: string, github?: string}) => { 71 const ghSlug = github ?? (image.startsWith('ghcr.io/') ? image.slice('ghcr.io/'.length) : null) 73 const [owner, repo] = ghSlug.split('/') 74 return fetchGithubReleaseTags({owner, repo}) 76 if (image.startsWith('gcr.io/')) return fetchGcrTags(image.slice('gcr.io/'.length)) 77 return fetchDockerHubTags(image) 80const generalizeDigits = (s: string) => _.escapeRegExp(s).replace(/\d+/g, '\\d+') 82// matcher derived from the current pin: same prefix/suffix shape (v, -jdk21, ...), any version core 83const tagShapeRe = (currentTag: string) => { 84 const m = currentTag.match(/^([^0-9]*)([0-9]+(?:\.[0-9]+)*)(.*)$/) 86 const [, pre, verCore, post] = m 87 const verRe = verCore.split('.').map(() => '\\d+').join('\\.') 88 return new RegExp(`${generalizeDigits(pre)}${verRe}${generalizeDigits(post)}`) 91const findLatestMatchingTag = (tags: string[], shape: RegExp) => { 92 let best: {major: number, minor: number, patch: number, original: string} | null = null 93 for (const tag of tags) { 94 if (preReleasePattern.test(tag)) continue 95 const m = tag.match(shape) 97 const parsed = parseVersion(m[0]) 99 if (!best || compareVersions(parsed, best) < 0) best = {...parsed, original: m[0]} 101 return best?.original ?? null 104const fetchLatestTagForPin = async ({image, currentTag, github}: {image: string, currentTag: string, github?: string}) => { 105 const tags = await fetchTagsForImage({image, github}) 106 return findLatestMatchingTag(tags, tagShapeRe(currentTag)) 109type pinCandidate = {name: string, start: number, end: number, value: string} 111// ts.createSourceFile handles .mjs and .mts alike (acorn chokes on `import type`) 112const collectPinCandidates = ({contents, ptPath}: {contents: string, ptPath: absFileDirPath}) => { 114 const candidates: pinCandidate[] = [] 115 const visit = (node: ts.Node) => { 116 if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) 117 && node.initializer && ts.isStringLiteral(node.initializer)) { 118 candidates.push({name: node.name.text, start: node.initializer.getStart(sf), end: node.initializer.getEnd(), value: node.initializer.text}) 119 } else if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && ts.isStringLiteral(node.initializer)) { 120 candidates.push({name: node.name.text, start: node.initializer.getStart(sf), end: node.initializer.getEnd(), value: node.initializer.text}) 122 ts.forEachChild(node, visit) 128const findTrailingDateComment = (contents: string, nodeEnd: number): {start: number, end: number} | null => { 129 const nlIdx = contents.indexOf('\n', nodeEnd) 130 const lineEnd = nlIdx === -1 ? contents.length : nlIdx 131 const after = contents.slice(nodeEnd, lineEnd) 132 const m = after.match(/\/\/\s*\d{6}\s*$/) 133 if (!m || m.index === undefined) return null 134 return {start: nodeEnd + m.index, end: nodeEnd + m.index + m[0].length} 137export const doUpdateImgs = async ({filePath}: {filePath: string}) => { 138 const contents = fs.readFileSync(filePath, 'utf8') 139 const s = new MagicString(contents) 141 const dateStr = luxNow().toFormat('yyLLdd') 143 const candidates = collectPinCandidates({contents, ptPath: filePath as absFileDirPath}) 145 for (const {name, start, end, value: currentVal} of candidates) { 146 const comment = findTrailingDateComment(contents, end) 147 if (!comment) continue 148 let image: string | undefined, currentTag: string, github: string | undefined 150 if (currentVal.includes(':')) { 151 const colonIdx = currentVal.lastIndexOf(':') 152 image = currentVal.substring(0, colonIdx) 153 currentTag = currentVal.substring(colonIdx + 1) 155 const pin = versionOnlyPins[name] 157 ;({image, github} = pin) 158 currentTag = currentVal 161 const latestTag = await fetchLatestTagForPin({image, currentTag, github}) 162 if (!latestTag || currentTag === latestTag) continue 164 const newVal = currentVal.includes(':') ? `${image}:${latestTag}` : latestTag 165 s.overwrite(start, end, `'${newVal}'`) 166 s.overwrite(comment.start, comment.end, `// ${dateStr}`) 167 updates.push(`${image.split('/').pop()}=${latestTag}`) 170 if (updates.length === 0) { 171 console.log('all images up to date') 175 fs.writeFileSync(filePath, s.toString()) 176 console.log(`updated: ${updates.join(' ')}`) 179export const updateimgAction = (importMetaUrl: string) => { 180 const updateimg = async () => { 181 await doUpdateImgs({filePath: new URL(importMetaUrl).pathname}) 183 updateimg.cliDescript = 'fetch latest stable docker tags and update sync file'