audit-ecosystem-status.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. import { spawnSync } from 'node:child_process'
  2. import { dirname, join, resolve } from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. import { loadEcosystemReleaseContext, readJson } from './lib/ecosystem-packages.mjs'
  5. import { normalizeReleaseError } from './lib/release-error-normalizer.mjs'
  6. import { describeReleaseGaps } from './lib/release-gap-classifier.mjs'
  7. import { comparePublicMirrorTrees } from './lib/git-remote-tree.mjs'
  8. const scriptDir = dirname(fileURLToPath(import.meta.url))
  9. const sourceRoot = resolve(scriptDir, '..')
  10. const argv = process.argv.slice(2)
  11. const args = new Set(argv)
  12. const strict = args.has('--strict')
  13. const fast = args.has('--fast')
  14. function readArg(name, fallback) {
  15. const index = argv.indexOf(name)
  16. return index >= 0 ? argv[index + 1] : fallback
  17. }
  18. function readNumberArg(name, fallback) {
  19. const value = Number(readArg(name, fallback))
  20. if (!Number.isFinite(value) || value <= 0) {
  21. throw new Error(`${name} must be a positive number, got ${value}`)
  22. }
  23. return value
  24. }
  25. const defaultGitTimeout = fast ? 5_000 : 20_000
  26. const defaultNpmTimeout = fast ? 8_000 : 30_000
  27. const defaultGhTimeout = fast ? 8_000 : 20_000
  28. const gitTimeout = readNumberArg(
  29. '--git-timeout-ms',
  30. process.env.FILE_VIEWER_AUDIT_GIT_TIMEOUT_MS || defaultGitTimeout
  31. )
  32. const npmTimeout = readNumberArg(
  33. '--npm-timeout-ms',
  34. process.env.FILE_VIEWER_AUDIT_NPM_TIMEOUT_MS || defaultNpmTimeout
  35. )
  36. const ghTimeout = readNumberArg(
  37. '--gh-timeout-ms',
  38. process.env.FILE_VIEWER_AUDIT_GH_TIMEOUT_MS || defaultGhTimeout
  39. )
  40. const { rootPackage, wrapperManifest, entries } = await loadEcosystemReleaseContext(sourceRoot)
  41. const branchRoles = await readJson(join(sourceRoot, 'ecosystem', 'branch-roles.json'))
  42. const publicRepoDir = resolve(
  43. sourceRoot,
  44. readArg('--public-repo-dir', process.env.FILE_VIEWER_PUBLIC_REPO_DIR || '../file-viewer-public')
  45. )
  46. function run(command, commandArgs, options = {}) {
  47. const result = spawnSync(command, commandArgs, {
  48. cwd: options.cwd ?? sourceRoot,
  49. encoding: 'utf8',
  50. stdio: 'pipe',
  51. timeout: options.timeout ?? 20_000
  52. })
  53. const errorMessage = result.error instanceof Error ? result.error.message : ''
  54. return {
  55. ok: result.status === 0,
  56. status: result.status,
  57. signal: result.signal || '',
  58. stdout: (result.stdout || '').trim(),
  59. stderr: (result.stderr || '').trim(),
  60. error: errorMessage
  61. }
  62. }
  63. function firstRefHash(output) {
  64. return output.split('\n').find(Boolean)?.split('\t')[0] || ''
  65. }
  66. function lsRemoteHead(url, branch = 'main') {
  67. const result = run('git', ['ls-remote', url, `refs/heads/${branch}`], { timeout: gitTimeout })
  68. return {
  69. ok: result.ok && Boolean(firstRefHash(result.stdout)),
  70. hash: firstRefHash(result.stdout),
  71. error: result.ok ? '' : normalizeReleaseError(result.error || result.stderr || result.stdout || result.signal)
  72. }
  73. }
  74. function npmVersion(packageName) {
  75. const result = run('npm', ['view', packageName, 'version'], { timeout: npmTimeout })
  76. return {
  77. ok: result.ok && Boolean(result.stdout),
  78. version: result.stdout.replace(/^"|"$/g, ''),
  79. error: result.ok ? '' : normalizeReleaseError(result.error || result.stderr || result.stdout || result.signal)
  80. }
  81. }
  82. function ghRelease(tag) {
  83. const result = run('gh', [
  84. 'release',
  85. 'view',
  86. tag,
  87. '-R',
  88. 'flyfish-dev/file-viewer',
  89. '--json',
  90. 'tagName,url,assets'
  91. ], { timeout: ghTimeout })
  92. if (!result.ok) {
  93. return {
  94. ok: false,
  95. tag,
  96. url: '',
  97. assetCount: 0,
  98. hasManifest: false,
  99. hasStatus: false,
  100. hasSchema: false,
  101. error: normalizeReleaseError(result.error || result.stderr || result.stdout || result.signal)
  102. }
  103. }
  104. try {
  105. const release = JSON.parse(result.stdout)
  106. const assets = Array.isArray(release.assets) ? release.assets : []
  107. return {
  108. ok: true,
  109. tag: release.tagName,
  110. url: release.url,
  111. assetCount: assets.length,
  112. hasManifest: assets.some(asset => asset.name === 'release-manifest.json'),
  113. hasStatus: assets.some(asset => asset.name === 'release-status.json'),
  114. hasSchema: assets.some(asset => asset.name === 'release-status.schema.json'),
  115. error: ''
  116. }
  117. } catch (error) {
  118. return {
  119. ok: false,
  120. tag,
  121. url: '',
  122. assetCount: 0,
  123. hasManifest: false,
  124. hasStatus: false,
  125. hasSchema: false,
  126. error: normalizeReleaseError(error instanceof Error ? error.message : String(error))
  127. }
  128. }
  129. }
  130. function formatHash(value) {
  131. return value ? `\`${value.slice(0, 12)}\`` : '`missing`'
  132. }
  133. function okLabel(ok) {
  134. return ok ? 'ok' : 'missing'
  135. }
  136. function syncLabel(left, right, treeStatus) {
  137. if (!left.ok || !right.ok) {
  138. return 'missing'
  139. }
  140. if (left.hash === right.hash) {
  141. return 'ok'
  142. }
  143. if (treeStatus?.mode === 'tree') {
  144. return 'ok-tree'
  145. }
  146. if (treeStatus?.mode === 'unchecked') {
  147. return 'unchecked'
  148. }
  149. return 'stale'
  150. }
  151. function parseWorktrees(output) {
  152. return output
  153. .split(/\n(?=worktree )/)
  154. .map(block => block.trim())
  155. .filter(Boolean)
  156. .map(block => {
  157. const entry = {
  158. path: '',
  159. head: '',
  160. branch: ''
  161. }
  162. for (const line of block.split('\n')) {
  163. const [key, ...valueParts] = line.split(' ')
  164. const value = valueParts.join(' ')
  165. if (key === 'worktree') {
  166. entry.path = value
  167. }
  168. if (key === 'HEAD') {
  169. entry.head = value
  170. }
  171. if (key === 'branch') {
  172. entry.branch = value.replace(/^refs\/heads\//, '')
  173. }
  174. }
  175. return entry
  176. })
  177. }
  178. function worktreeStatus(path) {
  179. if (!path) {
  180. return {
  181. dirty: false,
  182. changeCount: 0
  183. }
  184. }
  185. const result = run('git', ['status', '--porcelain'], { cwd: path })
  186. const changes = result.stdout.split('\n').filter(Boolean)
  187. return {
  188. dirty: changes.length > 0,
  189. changeCount: changes.length
  190. }
  191. }
  192. const localBranch = run('git', ['branch', '--show-current']).stdout
  193. const sourceBranch = branchRoles.currentSourceBranch || localBranch
  194. const sourceHead = run('git', ['rev-parse', 'HEAD']).stdout
  195. const sourceRemoteHead = lsRemoteHead(branchRoles.sourceRemote.url, sourceBranch)
  196. const sourceHeadInSync = sourceRemoteHead.ok && sourceHead === sourceRemoteHead.hash
  197. const worktrees = parseWorktrees(run('git', ['worktree', 'list', '--porcelain']).stdout)
  198. const sourceBranchWorktree = worktrees.find(worktree => worktree.branch === sourceBranch) || null
  199. const sourceBranchWorktreeStatus = sourceBranchWorktree ? worktreeStatus(sourceBranchWorktree.path) : null
  200. const sourceBranchWorktreeInSync =
  201. !sourceBranchWorktree || (sourceRemoteHead.ok && sourceBranchWorktree.head === sourceRemoteHead.hash)
  202. const branchRows = branchRoles.branches.map(branch => ({
  203. ...branch,
  204. remote: lsRemoteHead(branchRoles.sourceRemote.url, branch.name)
  205. }))
  206. const publicGithubHead = lsRemoteHead(branchRoles.publicMainRepository.github, 'main')
  207. const publicGiteeHead = lsRemoteHead(branchRoles.publicMainRepository.gitee, 'main')
  208. const publicMainTreeStatus = comparePublicMirrorTrees({
  209. publicRepoDir,
  210. githubHead: publicGithubHead,
  211. giteeHead: publicGiteeHead,
  212. giteeUrl: branchRoles.publicMainRepository.gitee,
  213. branch: 'main',
  214. timeout: gitTimeout
  215. })
  216. const publicMainInSync = publicGithubHead.ok && publicGiteeHead.ok && publicMainTreeStatus.inSync
  217. const release = ghRelease(`v${rootPackage.version}`)
  218. const remoteTargets = [
  219. {
  220. id: 'core',
  221. packageName: wrapperManifest.corePackage.packageName,
  222. github: wrapperManifest.corePackage.github,
  223. gitee: wrapperManifest.corePackage.gitee
  224. },
  225. ...wrapperManifest.wrappers.map(wrapper => ({
  226. id: wrapper.id,
  227. packageName: wrapper.packageName,
  228. github: wrapper.github,
  229. gitee: wrapper.gitee
  230. }))
  231. ]
  232. const remoteRows = remoteTargets.map(target => {
  233. const github = lsRemoteHead(target.github)
  234. const gitee = lsRemoteHead(target.gitee)
  235. return {
  236. ...target,
  237. github,
  238. gitee
  239. }
  240. })
  241. const npmRows = entries.map(entry => ({
  242. packageName: entry.packageName,
  243. expectedVersion: entry.version,
  244. npm: npmVersion(entry.packageName)
  245. }))
  246. const failures = [
  247. !sourceRemoteHead.ok && `source remote ${branchRoles.sourceRemote.url} missing ${sourceBranch}`,
  248. sourceRemoteHead.ok &&
  249. !sourceHeadInSync &&
  250. `local HEAD ${sourceHead.slice(0, 12)} does not match source ${sourceBranch} ${sourceRemoteHead.hash.slice(0, 12)}`,
  251. ...branchRows.map(row => !row.remote.ok && `source remote missing branch ${row.name}`),
  252. !publicGithubHead.ok && 'open-source main GitHub repository missing main',
  253. !release.ok && `GitHub Release v${rootPackage.version} missing`,
  254. release.ok && !release.hasManifest && `GitHub Release v${rootPackage.version} missing release-manifest.json`,
  255. release.ok && !release.hasStatus && `GitHub Release v${rootPackage.version} missing release-status.json`,
  256. release.ok && !release.hasSchema && `GitHub Release v${rootPackage.version} missing release-status.schema.json`,
  257. ...remoteRows.flatMap(row => [
  258. !row.github.ok && `${row.id} GitHub repository missing`
  259. ]),
  260. ...npmRows.map(row => {
  261. if (!row.npm.ok) {
  262. return `${row.packageName} is not published to npm`
  263. }
  264. if (row.npm.version !== row.expectedVersion) {
  265. return `${row.packageName} npm version ${row.npm.version} !== ${row.expectedVersion}`
  266. }
  267. return false
  268. })
  269. ].filter(Boolean)
  270. const gapReport = describeReleaseGaps(failures)
  271. const nextActions = [
  272. failures.some(failure => failure.includes('npm')) &&
  273. 'Run `npm login` / passkey in an interactive terminal, then `pnpm release:ecosystem:publish`.',
  274. 'Use `pnpm release:channels:preflight -- --skip-external` for a fast local release gate.'
  275. ].filter(Boolean)
  276. console.log(`# File Viewer Ecosystem Status\n`)
  277. console.log(`Generated: ${new Date().toISOString()}`)
  278. console.log(`Workspace: \`${sourceRoot}\``)
  279. console.log(`Version target: \`${rootPackage.version}\`\n`)
  280. if (fast) {
  281. console.log(`Mode: \`fast\` (git ${gitTimeout}ms, npm ${npmTimeout}ms, gh ${ghTimeout}ms)\n`)
  282. }
  283. console.log(`## Aggregate Source\n`)
  284. console.log(`- Local checkout branch: \`${localBranch}\``)
  285. console.log(`- Source branch: \`${sourceBranch}\``)
  286. console.log(`- Local HEAD: ${formatHash(sourceHead)}`)
  287. console.log(
  288. `- Remote \`${branchRoles.sourceRemote.name}/${sourceBranch}\`: ${formatHash(sourceRemoteHead.hash)} (${sourceHeadInSync ? 'ok' : okLabel(sourceRemoteHead.ok)})\n`
  289. )
  290. console.log(`## Local Worktrees\n`)
  291. console.log(`| branch | path | head | status |`)
  292. console.log(`| --- | --- | --- | --- |`)
  293. for (const worktree of worktrees) {
  294. const status = worktreeStatus(worktree.path)
  295. const notes = []
  296. if (worktree.branch === sourceBranch && !sourceBranchWorktreeInSync) {
  297. notes.push('stale-source-worktree')
  298. }
  299. if (status.dirty) {
  300. notes.push(`${status.changeCount} local changes`)
  301. }
  302. console.log(
  303. `| \`${worktree.branch || '(detached)'}\` | \`${worktree.path}\` | ${formatHash(worktree.head)} | ${notes.length ? notes.join(', ') : 'clean'} |`
  304. )
  305. }
  306. if (sourceBranchWorktree && sourceBranchWorktreeStatus?.dirty) {
  307. console.log(
  308. `\n> Note: \`${sourceBranch}\` is checked out at \`${sourceBranchWorktree.path}\` with ${sourceBranchWorktreeStatus.changeCount} local change(s). Treat \`${sourceRoot}\` and remote \`${branchRoles.sourceRemote.name}/${sourceBranch}\` as the release audit source until that worktree is reconciled.`
  309. )
  310. }
  311. console.log()
  312. console.log(`## Source Branch Roles\n`)
  313. console.log(`| branch | role | package | remote |`)
  314. console.log(`| --- | --- | --- | --- |`)
  315. for (const row of branchRows) {
  316. console.log(
  317. `| \`${row.name}\` | ${row.role} | \`${row.packageName}\` | ${formatHash(row.remote.hash)} (${okLabel(row.remote.ok)}) |`
  318. )
  319. }
  320. console.log()
  321. console.log(`## Open-Source Main Repository\n`)
  322. console.log(`- GitHub main: ${formatHash(publicGithubHead.hash)} (${okLabel(publicGithubHead.ok)})`)
  323. console.log(`- Gitee main: ${formatHash(publicGiteeHead.hash)} (${syncLabel(publicGithubHead, publicGiteeHead, publicMainTreeStatus)})`)
  324. if (publicGithubHead.ok && publicGiteeHead.ok && publicGithubHead.hash !== publicGiteeHead.hash) {
  325. console.log(
  326. `- Mirror tree sync: ${publicMainTreeStatus.mode}${publicMainTreeStatus.referenceTree ? `, reference ${formatHash(publicMainTreeStatus.referenceTree)}` : ''}${publicMainTreeStatus.giteeTree ? `, gitee ${formatHash(publicMainTreeStatus.giteeTree)}` : ''}${publicMainTreeStatus.error ? `, ${publicMainTreeStatus.error}` : ''}`
  327. )
  328. }
  329. console.log(
  330. `- GitHub Release: \`${release.tag}\` (${okLabel(release.ok)}, assets: ${release.assetCount}, manifest: ${okLabel(release.hasManifest)}, status: ${okLabel(release.hasStatus)}, schema: ${okLabel(release.hasSchema)}${release.url ? `, ${release.url}` : ''})\n`
  331. )
  332. console.log(`## Core And Component Repositories\n`)
  333. console.log(`| id | package | GitHub | Gitee |`)
  334. console.log(`| --- | --- | --- | --- |`)
  335. for (const row of remoteRows) {
  336. console.log(
  337. `| ${row.id} | \`${row.packageName}\` | ${formatHash(row.github.hash)} (${okLabel(row.github.ok)}) | ${formatHash(row.gitee.hash)} (${okLabel(row.gitee.ok)}) |`
  338. )
  339. }
  340. console.log()
  341. console.log(`## npm Registry\n`)
  342. console.log(`| package | target | npm |`)
  343. console.log(`| --- | --- | --- |`)
  344. for (const row of npmRows) {
  345. console.log(
  346. `| \`${row.packageName}\` | \`${row.expectedVersion}\` | ${row.npm.ok ? `\`${row.npm.version}\`` : '`unpublished`'} |`
  347. )
  348. }
  349. console.log()
  350. if (failures.length) {
  351. console.log(`## Gap Summary\n`)
  352. console.log(`- Total: ${gapReport.summary.total}`)
  353. console.log(`- External blockers: ${gapReport.summary.externalBlockers}`)
  354. console.log(`- Local actionable: ${gapReport.summary.localActionable}`)
  355. console.log(`- External blocker channels: ${gapReport.summary.externalBlockerChannels.length ? gapReport.summary.externalBlockerChannels.join(', ') : 'none'}`)
  356. console.log()
  357. console.log(`| channel | count | external blocker |`)
  358. console.log(`| --- | --- | --- |`)
  359. for (const [channel, count] of Object.entries(gapReport.summary.byChannel).sort(([left], [right]) => left.localeCompare(right))) {
  360. const externalBlocker = gapReport.details.some(detail => detail.channel === channel && detail.externalBlocker)
  361. console.log(`| ${channel} | ${count} | ${externalBlocker ? 'yes' : 'no'} |`)
  362. }
  363. console.log()
  364. console.log(`## Remaining Gaps\n`)
  365. for (const detail of gapReport.details) {
  366. console.log(`- [${detail.channel}] ${detail.message}`)
  367. }
  368. console.log()
  369. }
  370. console.log(`## Next Actions\n`)
  371. for (const action of nextActions) {
  372. console.log(`- ${action}`)
  373. }
  374. console.log()
  375. if (strict && failures.length) {
  376. process.exitCode = 1
  377. }