release-ecosystem-packages.mjs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import { existsSync } from 'node:fs'
  2. import { mkdir, rm, stat, writeFile } from 'node:fs/promises'
  3. import { dirname, join, resolve } from 'node:path'
  4. import { spawnSync } from 'node:child_process'
  5. import { fileURLToPath } from 'node:url'
  6. import {
  7. collectPackageEntrypoints,
  8. ecosystemPackageManifestEntry,
  9. loadEcosystemReleaseContext
  10. } from './lib/ecosystem-packages.mjs'
  11. const scriptDir = dirname(fileURLToPath(import.meta.url))
  12. const sourceRoot = resolve(scriptDir, '..')
  13. const args = process.argv.slice(2)
  14. const readArg = (name, fallback) => {
  15. const index = args.indexOf(name)
  16. return index >= 0 ? args[index + 1] : fallback
  17. }
  18. const mode = args.includes('--publish')
  19. ? 'publish'
  20. : args.includes('--list')
  21. ? 'list'
  22. : 'pack'
  23. const dryRun = args.includes('--dry-run')
  24. const preflight = args.includes('--preflight')
  25. const clean = args.includes('--clean')
  26. const skipExisting = !args.includes('--no-skip-existing')
  27. const npmRegistry = readArg(
  28. '--registry',
  29. process.env.FILE_VIEWER_NPM_REGISTRY ||
  30. process.env.NPM_CONFIG_REGISTRY ||
  31. process.env.npm_config_registry ||
  32. 'https://registry.npmjs.org/'
  33. )
  34. const packDir = resolve(
  35. sourceRoot,
  36. readArg('--pack-dir', process.env.FILE_VIEWER_ECOSYSTEM_PACK_DIR || '.release/ecosystem')
  37. )
  38. const { rootPackage, entries } = await loadEcosystemReleaseContext(sourceRoot)
  39. function run(command, commandArgs, cwd = sourceRoot) {
  40. console.log(`$ ${[command, ...commandArgs].join(' ')}`)
  41. const result = spawnSync(command, commandArgs, {
  42. cwd,
  43. encoding: 'utf8',
  44. stdio: 'inherit'
  45. })
  46. if (result.status !== 0) {
  47. throw new Error(`Command failed: ${command} ${commandArgs.join(' ')}`)
  48. }
  49. }
  50. function runPublish(command, commandArgs, cwd = sourceRoot) {
  51. console.log(`$ ${[command, ...commandArgs].join(' ')}`)
  52. const result = spawnSync(command, commandArgs, {
  53. cwd,
  54. stdio: 'inherit'
  55. })
  56. if (result.status === 0) {
  57. return 'published'
  58. }
  59. throw new Error(`Command failed: ${command} ${commandArgs.join(' ')}`)
  60. }
  61. function capture(command, commandArgs, cwd = sourceRoot) {
  62. const result = spawnSync(command, commandArgs, {
  63. cwd,
  64. encoding: 'utf8',
  65. stdio: 'pipe'
  66. })
  67. return {
  68. ok: result.status === 0,
  69. status: result.status,
  70. stdout: (result.stdout || '').trim(),
  71. stderr: (result.stderr || '').trim()
  72. }
  73. }
  74. function verifyNpmAuthentication() {
  75. const result = capture('npm', ['whoami', '--registry', npmRegistry])
  76. if (!result.ok || !result.stdout) {
  77. throw new Error(
  78. [
  79. 'npm authentication is required before publishing ecosystem packages.',
  80. 'Run `npm login` or `npm adduser` in an interactive terminal, complete MFA/passkey verification, then rerun `pnpm release:ecosystem:publish`.',
  81. result.stderr || result.stdout
  82. ]
  83. .filter(Boolean)
  84. .join('\n')
  85. )
  86. }
  87. console.log(`npm authenticated as ${result.stdout} on ${npmRegistry}`)
  88. }
  89. function isPackageVersionPublished(entry) {
  90. const result = capture(
  91. 'npm',
  92. ['view', `${entry.packageName}@${entry.version}`, 'version', '--registry', npmRegistry]
  93. )
  94. return result.ok && result.stdout.trim() === entry.version
  95. }
  96. async function assertDirectory(path, label = path) {
  97. if (!existsSync(path)) {
  98. throw new Error(`Missing directory: ${label}`)
  99. }
  100. const info = await stat(path)
  101. if (!info.isDirectory()) {
  102. throw new Error(`Not a directory: ${label}`)
  103. }
  104. }
  105. async function assertFile(path, label = path) {
  106. if (!existsSync(path)) {
  107. throw new Error(`Missing file: ${label}`)
  108. }
  109. const info = await stat(path)
  110. if (!info.isFile()) {
  111. throw new Error(`Not a file: ${label}`)
  112. }
  113. }
  114. async function verifyPackageEntrypoints(entry, { requireFiles }) {
  115. for (const field of ['main', 'module', 'types']) {
  116. if (!entry.packageJson[field]) {
  117. throw new Error(`${entry.packageName} is missing package.json ${field}`)
  118. }
  119. }
  120. if (!entry.packageJson.exports?.['.']) {
  121. throw new Error(`${entry.packageName} is missing package.json exports["."]`)
  122. }
  123. if (!requireFiles) {
  124. return
  125. }
  126. for (const entrypoint of collectPackageEntrypoints(entry.packageJson)) {
  127. await assertFile(
  128. join(entry.absoluteDir, entrypoint),
  129. `${entry.packageName} entrypoint ${entrypoint}. Run pnpm release:ecosystem:build before packing or publishing`
  130. )
  131. }
  132. }
  133. async function verifyPackage(entry, options) {
  134. await assertDirectory(entry.absoluteDir, entry.packageDir)
  135. await assertFile(join(entry.absoluteDir, 'package.json'), `${entry.packageDir}/package.json`)
  136. await assertFile(join(entry.absoluteDir, 'README.md'), `${entry.packageDir}/README.md`)
  137. await assertFile(join(entry.absoluteDir, 'README.en.md'), `${entry.packageDir}/README.en.md`)
  138. if (entry.packageJson.private === true) {
  139. throw new Error(`${entry.packageName} is private and cannot be released`)
  140. }
  141. if (entry.version !== rootPackage.version) {
  142. throw new Error(`${entry.packageName} version ${entry.version} does not match root version ${rootPackage.version}`)
  143. }
  144. if (entry.packageJson.publishConfig?.access !== 'public') {
  145. throw new Error(`${entry.packageName} must publish with access=public`)
  146. }
  147. await verifyPackageEntrypoints(entry, options)
  148. }
  149. const names = new Set()
  150. for (const entry of entries) {
  151. if (names.has(entry.packageName)) {
  152. throw new Error(`Duplicate release package: ${entry.packageName}`)
  153. }
  154. names.add(entry.packageName)
  155. await verifyPackage(entry, { requireFiles: mode !== 'list' && !preflight })
  156. }
  157. if (mode === 'list') {
  158. console.log(JSON.stringify({
  159. version: rootPackage.version,
  160. packages: entries.map(entry => ({
  161. ...ecosystemPackageManifestEntry(entry),
  162. tarballName: entry.tarballName
  163. }))
  164. }, null, 2))
  165. process.exit(0)
  166. }
  167. if (mode === 'pack') {
  168. if (clean) {
  169. await rm(packDir, { recursive: true, force: true })
  170. }
  171. await mkdir(packDir, { recursive: true })
  172. for (const entry of entries) {
  173. run('pnpm', ['-C', entry.packageDir, 'pack', '--pack-destination', packDir])
  174. await assertFile(join(packDir, entry.tarballName), `${entry.packageName} tarball`)
  175. }
  176. const manifest = {
  177. version: rootPackage.version,
  178. generatedAt: new Date().toISOString(),
  179. packageCount: entries.length,
  180. packages: entries.map(ecosystemPackageManifestEntry)
  181. }
  182. await writeFile(
  183. join(packDir, 'npm-release-manifest.json'),
  184. `${JSON.stringify(manifest, null, 2)}\n`,
  185. 'utf8'
  186. )
  187. run('node', ['scripts/verify-ecosystem-tarballs.mjs', '--pack-dir', packDir])
  188. console.log(`Packed ${entries.length} ecosystem packages into ${packDir}`)
  189. }
  190. if (mode === 'publish') {
  191. if (!dryRun) {
  192. verifyNpmAuthentication()
  193. }
  194. if (preflight) {
  195. console.log(`Publish preflight passed for ${entries.length} ecosystem packages.`)
  196. process.exit(0)
  197. }
  198. let publishedCount = 0
  199. let skippedCount = 0
  200. for (const entry of entries) {
  201. if (!dryRun && skipExisting && isPackageVersionPublished(entry)) {
  202. console.log(`Skipping ${entry.packageName}@${entry.version}: already published on ${npmRegistry}`)
  203. skippedCount += 1
  204. continue
  205. }
  206. const publishArgs = [
  207. '-C',
  208. entry.packageDir,
  209. 'publish',
  210. '--access',
  211. 'public',
  212. '--no-git-checks',
  213. '--ignore-scripts',
  214. '--registry',
  215. npmRegistry
  216. ]
  217. if (dryRun) {
  218. publishArgs.push('--dry-run')
  219. }
  220. const publishResult = runPublish('pnpm', publishArgs)
  221. if (publishResult === 'already-published') {
  222. console.log(`Skipping ${entry.packageName}@${entry.version}: registry reports the version is already published.`)
  223. skippedCount += 1
  224. } else {
  225. publishedCount += 1
  226. }
  227. }
  228. console.log(
  229. dryRun
  230. ? `Dry-run published ${publishedCount} ecosystem packages.`
  231. : `Published ${publishedCount} ecosystem packages; skipped ${skippedCount} already published packages.`
  232. )
  233. }