write-release-status-report.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. import { existsSync } from 'node:fs'
  2. import { mkdir, readFile, writeFile } from 'node:fs/promises'
  3. import { dirname, join, resolve } from 'node:path'
  4. import { spawn, spawnSync } from 'node:child_process'
  5. import { fileURLToPath } from 'node:url'
  6. import { loadEcosystemReleaseContext, readJson } from './lib/ecosystem-packages.mjs'
  7. import { normalizeReleaseError } from './lib/release-error-normalizer.mjs'
  8. import { describeReleaseGaps } from './lib/release-gap-classifier.mjs'
  9. import { comparePublicMirrorTrees } from './lib/git-remote-tree.mjs'
  10. const scriptDir = dirname(fileURLToPath(import.meta.url))
  11. const sourceRoot = resolve(scriptDir, '..')
  12. const argv = process.argv.slice(2)
  13. const args = new Set(argv)
  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 publicRepoDir = resolve(
  26. sourceRoot,
  27. readArg('--public-repo-dir', process.env.FILE_VIEWER_PUBLIC_REPO_DIR || '../file-viewer-public')
  28. )
  29. const outFile = resolve(
  30. sourceRoot,
  31. readArg('--out-file', join(publicRepoDir, 'artifacts', 'release-status.json'))
  32. )
  33. const fast = args.has('--fast')
  34. const gitTimeout = readNumberArg('--git-timeout-ms', fast ? 5_000 : 20_000)
  35. const npmTimeout = readNumberArg('--npm-timeout-ms', fast ? 8_000 : 30_000)
  36. const ghTimeout = readNumberArg('--gh-timeout-ms', fast ? 8_000 : 20_000)
  37. const npmRegistry = readArg(
  38. '--npm-registry',
  39. process.env.FILE_VIEWER_NPM_REGISTRY || 'https://registry.npmjs.org/'
  40. )
  41. const { rootPackage, wrapperManifest, entries } = await loadEcosystemReleaseContext(sourceRoot)
  42. const branchRoles = await readJson(join(sourceRoot, 'ecosystem', 'branch-roles.json'))
  43. function run(command, commandArgs, options = {}) {
  44. const result = spawnSync(command, commandArgs, {
  45. cwd: options.cwd ?? sourceRoot,
  46. encoding: 'utf8',
  47. stdio: 'pipe',
  48. timeout: options.timeout ?? 20_000
  49. })
  50. const error = result.error instanceof Error ? result.error.message : ''
  51. return {
  52. ok: result.status === 0,
  53. status: result.status,
  54. signal: result.signal || '',
  55. stdout: (result.stdout || '').trim(),
  56. stderr: (result.stderr || '').trim(),
  57. error
  58. }
  59. }
  60. function runAsync(command, commandArgs, options = {}) {
  61. return new Promise(resolve => {
  62. const child = spawn(command, commandArgs, {
  63. cwd: options.cwd ?? sourceRoot,
  64. env: process.env,
  65. stdio: ['ignore', 'pipe', 'pipe']
  66. })
  67. let stdout = ''
  68. let stderr = ''
  69. let timedOut = false
  70. child.stdout.setEncoding('utf8')
  71. child.stderr.setEncoding('utf8')
  72. child.stdout.on('data', chunk => {
  73. stdout += chunk
  74. })
  75. child.stderr.on('data', chunk => {
  76. stderr += chunk
  77. })
  78. const timer = setTimeout(() => {
  79. timedOut = true
  80. child.kill('SIGTERM')
  81. }, options.timeout ?? 20_000)
  82. child.on('error', error => {
  83. clearTimeout(timer)
  84. resolve({
  85. ok: false,
  86. status: null,
  87. signal: '',
  88. stdout: stdout.trim(),
  89. stderr: stderr.trim(),
  90. error: error.message
  91. })
  92. })
  93. child.on('close', (status, signal) => {
  94. clearTimeout(timer)
  95. resolve({
  96. ok: status === 0 && !timedOut,
  97. status,
  98. signal: signal || '',
  99. stdout: stdout.trim(),
  100. stderr: stderr.trim(),
  101. error: timedOut ? `Timed out after ${options.timeout ?? 20_000}ms` : ''
  102. })
  103. })
  104. })
  105. }
  106. function firstRefHash(output) {
  107. return output.split('\n').find(Boolean)?.split('\t')[0] || ''
  108. }
  109. function commandError(result) {
  110. return result.ok
  111. ? ''
  112. : normalizeReleaseError(result.error || result.stderr || result.stdout || result.signal || 'unknown error')
  113. }
  114. async function lsRemoteHead(url, branch = 'main') {
  115. const result = await runAsync('git', ['ls-remote', url, `refs/heads/${branch}`], { timeout: gitTimeout })
  116. const hash = firstRefHash(result.stdout)
  117. return {
  118. url,
  119. branch,
  120. ok: result.ok && Boolean(hash),
  121. hash,
  122. error: result.ok && hash ? '' : commandError(result) || 'missing ref'
  123. }
  124. }
  125. async function npmPackageVersion(packageName) {
  126. const result = await runAsync('npm', ['view', packageName, 'version', '--registry', npmRegistry], {
  127. timeout: npmTimeout
  128. })
  129. return {
  130. ok: result.ok && Boolean(result.stdout),
  131. version: result.stdout.replace(/^"|"$/g, ''),
  132. error: result.ok && result.stdout ? '' : commandError(result) || 'unpublished'
  133. }
  134. }
  135. async function githubRelease(tag) {
  136. const result = await runAsync('gh', [
  137. 'release',
  138. 'view',
  139. tag,
  140. '-R',
  141. 'flyfish-dev/file-viewer',
  142. '--json',
  143. 'tagName,url,assets'
  144. ], { timeout: ghTimeout })
  145. if (!result.ok) {
  146. return {
  147. ok: false,
  148. tag,
  149. url: '',
  150. assetCount: 0,
  151. hasManifest: false,
  152. hasStatus: false,
  153. hasSchema: false,
  154. error: commandError(result)
  155. }
  156. }
  157. try {
  158. const release = JSON.parse(result.stdout)
  159. const assets = Array.isArray(release.assets) ? release.assets : []
  160. return {
  161. ok: true,
  162. tag: release.tagName,
  163. url: release.url,
  164. assetCount: assets.length,
  165. hasManifest: assets.some(asset => asset.name === 'release-manifest.json'),
  166. hasStatus: assets.some(asset => asset.name === 'release-status.json'),
  167. hasSchema: assets.some(asset => asset.name === 'release-status.schema.json'),
  168. error: ''
  169. }
  170. } catch (error) {
  171. return {
  172. ok: false,
  173. tag,
  174. url: '',
  175. assetCount: 0,
  176. hasManifest: false,
  177. hasStatus: false,
  178. hasSchema: false,
  179. error: normalizeReleaseError(error instanceof Error ? error.message : String(error))
  180. }
  181. }
  182. }
  183. function localWorktree() {
  184. return {
  185. branch: run('git', ['branch', '--show-current']).stdout,
  186. commit: run('git', ['rev-parse', 'HEAD']).stdout,
  187. shortCommit: run('git', ['rev-parse', '--short', 'HEAD']).stdout,
  188. dirty: Boolean(run('git', ['status', '--porcelain']).stdout)
  189. }
  190. }
  191. const sourceBranch = branchRoles.currentSourceBranch || 'main'
  192. const local = localWorktree()
  193. const sourceBaselineNote =
  194. 'Private Gitea main is the complete original aggregate workspace. The local checkout branch is only the execution context and can be transitional during branch cutover.'
  195. const componentTargets = [
  196. {
  197. id: 'core',
  198. packageName: wrapperManifest.corePackage.packageName,
  199. github: wrapperManifest.corePackage.github,
  200. gitee: wrapperManifest.corePackage.gitee
  201. },
  202. ...wrapperManifest.wrappers.map(wrapper => ({
  203. id: wrapper.id,
  204. packageName: wrapper.packageName,
  205. github: wrapper.github,
  206. gitee: wrapper.gitee
  207. }))
  208. ]
  209. const [sourceRemote, publicGithub, publicGitee, release, componentRepositories, npmPackages] =
  210. await Promise.all([
  211. lsRemoteHead(branchRoles.sourceRemote.url, sourceBranch),
  212. lsRemoteHead(branchRoles.publicMainRepository.github, 'main'),
  213. lsRemoteHead(branchRoles.publicMainRepository.gitee, 'main'),
  214. githubRelease(`v${rootPackage.version}`),
  215. Promise.all(componentTargets.map(async target => {
  216. const [github, gitee] = await Promise.all([
  217. lsRemoteHead(target.github),
  218. lsRemoteHead(target.gitee)
  219. ])
  220. return {
  221. id: target.id,
  222. packageName: target.packageName,
  223. github,
  224. gitee
  225. }
  226. })),
  227. Promise.all(entries.map(async entry => {
  228. const registry = await npmPackageVersion(entry.packageName)
  229. return {
  230. packageName: entry.packageName,
  231. expectedVersion: entry.version,
  232. publishedVersion: registry.version || null,
  233. ok: registry.ok && registry.version === entry.version,
  234. error: registry.ok && registry.version === entry.version ? '' : registry.error || `expected ${entry.version}`
  235. }
  236. }))
  237. ])
  238. const publicMainTreeStatus = comparePublicMirrorTrees({
  239. publicRepoDir,
  240. githubHead: publicGithub,
  241. giteeHead: publicGitee,
  242. giteeUrl: branchRoles.publicMainRepository.gitee,
  243. branch: 'main',
  244. timeout: gitTimeout
  245. })
  246. const publicMainInSync = publicGithub.ok && publicGitee.ok && publicMainTreeStatus.inSync
  247. const gaps = [
  248. !sourceRemote.ok && `source remote ${sourceRemote.url} missing ${sourceBranch}`,
  249. sourceRemote.ok &&
  250. local.commit !== sourceRemote.hash &&
  251. `local HEAD ${local.shortCommit} does not match ${branchRoles.sourceRemote.name}/${sourceBranch} ${sourceRemote.hash.slice(0, 12)}`,
  252. local.dirty && 'local source worktree has uncommitted changes',
  253. !publicGithub.ok && 'open-source main GitHub repository missing main',
  254. !release.ok && `GitHub Release v${rootPackage.version} missing`,
  255. release.ok && !release.hasManifest && `GitHub Release v${rootPackage.version} missing release-manifest.json`,
  256. release.ok && !release.hasStatus && `GitHub Release v${rootPackage.version} missing release-status.json`,
  257. release.ok && !release.hasSchema && `GitHub Release v${rootPackage.version} missing release-status.schema.json`,
  258. ...componentRepositories.flatMap(row => [
  259. !row.github.ok && `${row.id} GitHub repository missing`
  260. ]),
  261. ...npmPackages.map(row => !row.ok && `${row.packageName} npm ${row.publishedVersion || 'unpublished'} !== ${row.expectedVersion}`)
  262. ].filter(Boolean)
  263. const gapReport = describeReleaseGaps(gaps)
  264. const nextActions = [
  265. gaps.some(gap => gap.includes(' npm ')) &&
  266. 'Run `npm login` / passkey in an interactive terminal, then `pnpm release:ecosystem:publish`.',
  267. gaps.some(gap => gap.includes('GitHub Release')) &&
  268. 'Create or update the GitHub Release assets, then rerun `pnpm release:public`.',
  269. gaps.some(gap => gap.includes('worktree') || gap.includes('local HEAD')) &&
  270. 'Commit and push the local source/public repository changes, then regenerate the release status report.',
  271. !gaps.length && 'Release channels are aligned. Keep this report attached to the GitHub Release for downstream verification.'
  272. ].filter(Boolean)
  273. const report = {
  274. schemaVersion: 1,
  275. generatedAt: new Date().toISOString(),
  276. version: rootPackage.version,
  277. sourcePolicy: 'private-complete-original-workspace',
  278. openSourcePolicy: 'public-open-source-main-repository',
  279. sourceBaseline: {
  280. repository: branchRoles.sourceRemote.url,
  281. branch: sourceBranch,
  282. policy: 'private-complete-original-workspace',
  283. authoritativeCommit: sourceRemote.hash || '',
  284. localBranch: local.branch,
  285. localCommit: local.commit,
  286. inSync: sourceRemote.ok && local.commit === sourceRemote.hash,
  287. note: sourceBaselineNote
  288. },
  289. local,
  290. sourceRemote,
  291. openSourceMain: {
  292. github: publicGithub,
  293. gitee: publicGitee,
  294. inSync: publicMainInSync,
  295. syncMode: publicMainTreeStatus.mode,
  296. treeChecked: publicMainTreeStatus.checked,
  297. referenceTree: publicMainTreeStatus.referenceTree,
  298. giteeTree: publicMainTreeStatus.giteeTree,
  299. treeError: publicMainTreeStatus.error,
  300. reportHashNote:
  301. 'The open-source main repository mirrors the current public source tree and records the private source commit in the public commit body for traceability. The observed remote hash can trail until that mirror commit is pushed. Gitee can intentionally use a shallow snapshot commit; inSync is true when the file tree matches even if commit hashes differ. Use pnpm audit:ecosystem-status for live remote heads.'
  302. },
  303. githubRelease: release,
  304. componentRepositories,
  305. npmPackages,
  306. gaps,
  307. gapSummary: gapReport.summary,
  308. gapDetails: gapReport.details,
  309. nextActions
  310. }
  311. if (existsSync(outFile)) {
  312. const previous = JSON.parse(await readFile(outFile, 'utf8'))
  313. const previousComparable = {
  314. ...previous,
  315. generatedAt: report.generatedAt
  316. }
  317. if (JSON.stringify(previousComparable) === JSON.stringify(report)) {
  318. report.generatedAt = previous.generatedAt
  319. }
  320. }
  321. await mkdir(dirname(outFile), { recursive: true })
  322. await writeFile(outFile, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
  323. console.log(`Wrote release status report to ${outFile} with ${gaps.length} gap${gaps.length === 1 ? '' : 's'}.`)