Files
config-center/scripts/update-runtime-catalog.mjs

129 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFile, writeFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { resolve, dirname } from 'node:path'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
export const SOURCES = Object.freeze({
node: 'https://nodejs.org/dist/index.json',
npm: 'https://api.github.com/repos/npm/cli/releases?per_page=100',
pnpm: 'https://api.github.com/repos/pnpm/pnpm/releases?per_page=100',
yarn: 'https://api.github.com/repos/yarnpkg/berry/releases?per_page=100',
hatch: 'https://raw.githubusercontent.com/pypa/hatch/b998d2b755bc0dca20054f96da8981532444fc2a/src/hatch/python/distributions.py',
})
const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex')
const stableVersion = /^v\d+\.\d+\.\d+$/
export function selectNodeVersions(rows, now) {
if (!Array.isArray(rows)) throw new Error('Invalid official Node index')
const counts = new Map()
const selected = rows.filter(row => {
if (!stableVersion.test(row.version) || !/^\d{4}-\d{2}-\d{2}$/.test(row.date)) return false
if (row.date > now.slice(0, 10)) return false
const major = Number(row.version.split('.')[0].slice(1))
if (major < 16 || (counts.get(major) ?? 0) >= 3) return false
counts.set(major, (counts.get(major) ?? 0) + 1)
return true
})
if (!selected.length) throw new Error('No stable Node releases')
return selected
}
export function selectPackageReleases(rows, name, now) {
if (!Array.isArray(rows)) throw new Error(`Invalid ${name} releases`)
const seen = new Set()
return rows.flatMap(row => {
if (row.draft || row.prerelease) return []
let tag = row.tag_name
if (name === 'yarn') {
const match = /^@yarnpkg\/cli\/(\d+\.\d+\.\d+)$/.exec(tag)
if (!match) return []
tag = `v${match[1]}`
}
if (!stableVersion.test(tag) || seen.has(tag)) return []
if (!row.published_at || !Number.isFinite(Date.parse(row.published_at)) || Date.parse(row.published_at) > Date.parse(now)) return []
seen.add(tag)
// Client-compatible field name; value is publication time, not commit creation time.
return [{ tag_name: tag, created_at: row.published_at }]
}).slice(0, 30)
}
export function verifyRecommended(recommended, nodeRows, sums, pythonRelease, now = new Date().toISOString()) {
const nowMs = Date.parse(now)
if (!Number.isFinite(nowMs)) throw new Error('Invalid verification time')
const publishedMs = Date.parse(pythonRelease.published_at)
if (!Number.isFinite(publishedMs) || publishedMs > nowMs) throw new Error('Python release unpublished or future publication date')
const node = nodeRows.find(row => row.version === `v${recommended.node.version}`)
if (!node || node.lts !== recommended.node.ltsCodename) throw new Error('Recommended Node release/LTS mismatch')
if (!/^\d{4}-\d{2}-\d{2}$/.test(node.date) || node.date > now.slice(0, 10)) throw new Error('Node release missing or future publication date')
if (pythonRelease.tag_name !== recommended.python.distTag || pythonRelease.draft || pythonRelease.prerelease) throw new Error('Python release tag/status mismatch')
const lines = new Map(sums.split('\n').flatMap(line => {
const match = /^([a-f0-9]{64})\s+\*?(\S+)$/.exec(line.trim())
return match ? [[match[2], match[1]]] : []
}))
const nodeSuffixes = { 'darwin-arm64': 'darwin-arm64.tar.gz', 'darwin-x64': 'darwin-x64.tar.gz', 'win32-x64': 'win-x64.zip', 'win32-arm64': 'win-arm64.zip', 'linux-x64': 'linux-x64.tar.gz', 'linux-arm64': 'linux-arm64.tar.gz' }
const pythonTriples = { 'darwin-arm64': 'aarch64-apple-darwin', 'darwin-x64': 'x86_64-apple-darwin', 'win32-x64': 'x86_64-pc-windows-msvc', 'win32-arm64': 'aarch64-pc-windows-msvc', 'linux-x64': 'x86_64-unknown-linux-gnu', 'linux-arm64': 'aarch64-unknown-linux-gnu' }
for (const platform of Object.keys(nodeSuffixes)) {
const path = recommended.node.archives[platform]
if (path !== `v${recommended.node.version}/node-v${recommended.node.version}-${nodeSuffixes[platform]}`) throw new Error('Unsafe Node archive path')
if (lines.get(path.split('/').at(-1)) !== recommended.node.sha256[platform]) throw new Error(`Node digest mismatch: ${platform}`)
}
for (const platform of Object.keys(pythonTriples)) {
const path = recommended.python.archives[platform]
if (path !== `${recommended.python.distTag}/cpython-${recommended.python.version}+${recommended.python.distTag}-${pythonTriples[platform]}-install_only_stripped.tar.gz`) throw new Error('Unsafe Python archive path')
const asset = pythonRelease.assets.find(row => row.name === path.split('/').at(-1))
if (!asset || asset.state !== 'uploaded' || asset.digest !== `sha256:${recommended.python.sha256[platform]}`) throw new Error(`Python digest mismatch: ${platform}`)
}
}
async function officialText(url) {
const allowed = new Set([...Object.values(SOURCES)])
const recommended = JSON.parse(await readFile(resolve(ROOT, 'runtimes/recommended.json'), 'utf8'))
allowed.add(`https://nodejs.org/dist/v${recommended.node.version}/SHASUMS256.txt`)
allowed.add(`https://api.github.com/repos/astral-sh/python-build-standalone/releases/tags/${recommended.python.distTag}`)
if (!allowed.has(url)) throw new Error(`Unapproved source: ${url}`)
const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(30000), headers: { 'User-Agent': 'config-center-runtime-audit' } })
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`)
return response.text()
}
async function main() {
const write = process.argv.includes('--write')
if (process.argv.slice(2).some(arg => arg !== '--write')) throw new Error('Usage: node scripts/update-runtime-catalog.mjs [--write]')
const recommended = JSON.parse(await readFile(resolve(ROOT, 'runtimes/recommended.json'), 'utf8'))
const old = JSON.parse(await readFile(resolve(ROOT, 'runtimes/versions-fallback.json'), 'utf8'))
const sumsUrl = `https://nodejs.org/dist/v${recommended.node.version}/SHASUMS256.txt`
const pythonUrl = `https://api.github.com/repos/astral-sh/python-build-standalone/releases/tags/${recommended.python.distTag}`
const urls = [...Object.values(SOURCES), sumsUrl, pythonUrl]
const texts = await Promise.all(urls.map(officialText))
const source = Object.fromEntries(urls.map((url, i) => [url, texts[i]]))
const nodes = JSON.parse(source[SOURCES.node])
const pythonRelease = JSON.parse(source[pythonUrl])
const now = new Date().toISOString()
verifyRecommended(recommended, nodes, source[sumsUrl], pythonRelease, now)
// The fallback mirrors bundled Hatch 1.16.5, not standalone's latest release.
// Fail closed if its existing rows no longer match that official distribution map.
for (const row of old.python) {
const expected = row.name.startsWith('pypy') ? `${row.name}-v${row.version}-` : `cpython-${row.version}`
const escapedName = row.name.replaceAll('.', '\\.')
const section = new RegExp(`^ ['\"]${escapedName}['\"]: \\{([\\s\\S]*?)(?=^ ['\"]|^\\})`, 'm').exec(source[SOURCES.hatch])
if (!section || !section[1].includes(expected)) throw new Error(`Hatch pin mismatch: ${row.name}`)
}
const next = { ...old, generatedAt: now, node: selectNodeVersions(nodes, now), pkgManagers: {} }
for (const name of ['npm', 'pnpm', 'yarn']) {
next.pkgManagers[name] = selectPackageReleases(JSON.parse(source[SOURCES[name]]), name, now)
if (!next.pkgManagers[name].length) throw new Error(`No stable ${name} releases`)
}
if (!next.node.some(row => row.version === `v${recommended.node.version}`)) throw new Error('Recommended Node absent from snapshot')
if (write) {
await writeFile(resolve(ROOT, 'runtimes/versions-fallback.json'), `${JSON.stringify(next, null, 2)}\n`)
for (const name of ['node', 'npm', 'pnpm', 'yarn']) {
const rows = name === 'node' ? next.node : next.pkgManagers[name]
const path = name === 'node' ? 'node-snapshot.md' : `package-managers/${name}-snapshot.md`
await writeFile(resolve(ROOT, 'docs/runtime-sources', path), `# ${name} 离线目录快照\n\n- 实际读取:${now}\n- 官方来源:[官方目录](${SOURCES[name]})\n- 数据位置:\`runtimes/versions-fallback.json#${name === 'node' ? 'node' : `pkgManagers.${name}`}\`\n- 目录条数:${rows.length}\n- 首条版本:${rows[0].version ?? rows[0].tag_name}\n- 本仓条目指纹(JSON.stringify、SHA-256):\`${hash(rows)}\`\n- 本轮读取的官方正文 SHA-256:\`${createHash('sha256').update(source[SOURCES[name]]).digest('hex')}\`\n\n筛选规则:${name === 'node' ? 'Node 16 及以上,每个大版本最多三条正式发布;保留上游顺序。不表示所有大版本仍受支持。' : '读取官方仓库最近 100 个 releases,排除 draft、prerelease、非正式版本标签及未来发布日期,最多 30 条;Yarn 只接受 @yarnpkg/cli 正式标签。created_at 字段保持客户端结构,值取 published_at。目录不代表当前平台安装测试已通过。'}\n`)
}
}
console.log(JSON.stringify({ write, generatedAt: now, node: next.node.length, packageManagers: Object.fromEntries(Object.entries(next.pkgManagers).map(([key, value]) => [key, value.length])), python: 'preserved: bundled Hatch 1.16.5 pin; recommended archive hashes verified against official manifests' }))
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(error => { console.error(error.message); process.exitCode = 1 })