🌳
pt0/deployF/dockerF/fetchLatestDockerTagAI.mts
1import * as _ from 'lodash-es'
2import fs from 'fs'
3import MagicString from 'magic-string'
6import ts from 'typescript'
8import type { absFileDirPath } from '../../ptDirF.mts'
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
16 return {
17 major: parseInt(match[1], 10),
18 minor: parseInt(match[2], 10),
19 patch: match[3] ? parseInt(match[3], 10) : 0,
20 original: tag,
21 }
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`
34 while (url) {
35 const resp: Response = await fetch(url, {signal: AbortSignal.timeout(15000)})
36 assertTruthy(resp.ok, {url, status: resp.status})
37 const json = await resp.json()
38 allTags.push(..._.map(json.results, 'name'))
39 if (allTags.length >= 500) break
40 url = json.next
41 }
42 return allTags
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)})
48 assertTruthy(resp.ok, {url, status: resp.status})
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)})
56 assertTruthy(resp.ok, {url, status: resp.status})
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)
72 if (ghSlug) {
73 const [owner, repo] = ghSlug.split('/')
74 return fetchGithubReleaseTags({owner, repo})
75 }
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]+)*)(.*)$/)
85 assertTruthy(m, {currentTag})
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)
96 if (!m) continue
97 const parsed = parseVersion(m[0])
98 if (!parsed) continue
99 if (!best || compareVersions(parsed, best) < 0) best = {...parsed, original: m[0]}
100 }
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}) => {
113 const sf = tsSrcFile({ptPath, contents})
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})
121 }
122 ts.forEachChild(node, visit)
123 }
124 visit(sf)
125 return candidates
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)
140 const updates = []
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)
154 } else {
155 const pin = versionOnlyPins[name]
156 if (!pin) continue
157 ;({image, github} = pin)
158 currentTag = currentVal
159 }
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}`)
168 }
170 if (updates.length === 0) {
171 console.log('all images up to date')
172 return
173 }
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})
182 }
183 updateimg.cliDescript = 'fetch latest stable docker tags and update sync file'
184 return updateimg