verify-ecosystem-tarballs.mjs 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. import { existsSync } from 'node:fs'
  2. import { dirname, join, resolve } from 'node:path'
  3. import { spawnSync } from 'node:child_process'
  4. import { fileURLToPath } from 'node:url'
  5. import {
  6. collectPackageEntrypoints,
  7. loadEcosystemReleaseContext
  8. } from './lib/ecosystem-packages.mjs'
  9. const scriptDir = dirname(fileURLToPath(import.meta.url))
  10. const sourceRoot = resolve(scriptDir, '..')
  11. const args = process.argv.slice(2)
  12. const readArg = (name, fallback) => {
  13. const index = args.indexOf(name)
  14. return index >= 0 ? args[index + 1] : fallback
  15. }
  16. const packDirArg = readArg('--pack-dir', null)
  17. const packDir = packDirArg ? resolve(sourceRoot, packDirArg) : null
  18. const mode = packDir ? 'tarball' : 'dry-run'
  19. const { entries } = await loadEcosystemReleaseContext(sourceRoot)
  20. const webGlobalPackages = new Set([
  21. '@flyfish-group/file-viewer-web',
  22. '@file-viewer/web'
  23. ])
  24. const webGlobalBundle = 'dist/flyfish-file-viewer-web.iife.js'
  25. function run(command, commandArgs, options = {}) {
  26. const result = spawnSync(command, commandArgs, {
  27. cwd: options.cwd || sourceRoot,
  28. encoding: 'utf8',
  29. stdio: options.capture ? 'pipe' : 'inherit',
  30. env: {
  31. ...process.env,
  32. npm_config_loglevel: 'error'
  33. }
  34. })
  35. if (result.status !== 0) {
  36. throw new Error(`Command failed: ${command} ${commandArgs.join(' ')}`)
  37. }
  38. return result.stdout || ''
  39. }
  40. function normalizePackPath(path) {
  41. return path.replace(/^package\//, '').replace(/\\/g, '/')
  42. }
  43. function parseNpmPackJson(output, packageName) {
  44. const jsonStart = output.indexOf('[')
  45. if (jsonStart === -1) {
  46. throw new Error(`${packageName} npm pack dry-run did not return JSON output`)
  47. }
  48. const parsed = JSON.parse(output.slice(jsonStart))
  49. if (!Array.isArray(parsed) || parsed.length !== 1) {
  50. throw new Error(`${packageName} npm pack dry-run returned an unexpected payload`)
  51. }
  52. return parsed[0]
  53. }
  54. function readDryRunPack(entry) {
  55. const output = run('npm', ['pack', '--dry-run', '--json'], {
  56. cwd: entry.absoluteDir,
  57. capture: true
  58. })
  59. const payload = parseNpmPackJson(output, entry.packageName)
  60. return {
  61. packageJson: entry.packageJson,
  62. files: payload.files.map(file => normalizePackPath(file.path))
  63. }
  64. }
  65. function readPackedTarball(entry) {
  66. const tarballPath = join(packDir, entry.tarballName)
  67. if (!existsSync(tarballPath)) {
  68. throw new Error(`Missing packed tarball for ${entry.packageName}: ${tarballPath}`)
  69. }
  70. const listing = run('tar', ['-tzf', tarballPath], { capture: true })
  71. const packageJsonSource = run('tar', ['-xOf', tarballPath, 'package/package.json'], {
  72. capture: true
  73. })
  74. return {
  75. packageJson: JSON.parse(packageJsonSource),
  76. files: listing
  77. .split('\n')
  78. .filter(Boolean)
  79. .map(normalizePackPath)
  80. }
  81. }
  82. function hasWorkspaceDependency(packageJson) {
  83. const dependencyGroups = [
  84. packageJson.dependencies,
  85. packageJson.devDependencies,
  86. packageJson.peerDependencies,
  87. packageJson.optionalDependencies
  88. ]
  89. return dependencyGroups.some(group =>
  90. Object.values(group || {}).some(version => typeof version === 'string' && version.startsWith('workspace:'))
  91. )
  92. }
  93. function isAllowedBinScript(entry, path) {
  94. return Object.values(entry.packageJson.bin || {})
  95. .filter(value => typeof value === 'string')
  96. .map(value => value.replace(/^\.\//, ''))
  97. .includes(path)
  98. }
  99. function sourceEntrypoints(entry) {
  100. const allowed = new Set(
  101. collectPackageEntrypoints(entry.packageJson)
  102. .map(value => value.replace(/^\.\//, ''))
  103. .filter(value => value.startsWith('src/'))
  104. )
  105. if (entry.wrapper?.entryFormats?.includes('svelte-component')) {
  106. allowed.add('src/controller.ts')
  107. }
  108. return allowed
  109. }
  110. function assertNoForbiddenPackFiles(entry, files) {
  111. const allowedSourceEntrypoints = sourceEntrypoints(entry)
  112. const forbiddenPatterns = [
  113. { pattern: /^packages\//, reason: 'workspace package directory' },
  114. { pattern: /^docs\//, reason: 'documentation source directory' },
  115. { pattern: /^public\//, reason: 'public source directory' },
  116. { pattern: /^ecosystem\//, reason: 'ecosystem source metadata' },
  117. { pattern: /^\.release\//, reason: 'local release staging output' },
  118. { pattern: /^node_modules\//, reason: 'installed dependency output' },
  119. { pattern: /^\.git\//, reason: 'git metadata' },
  120. { pattern: /(^|\/)\.DS_Store$/, reason: 'macOS metadata' },
  121. { pattern: /(^|\/)\.env(\.|$)/, reason: 'environment secret file' },
  122. { pattern: /(^|\/)tsconfig(?:\..*)?\.json$/, reason: 'TypeScript build config' },
  123. { pattern: /(^|\/).*\.tsbuildinfo$/, reason: 'TypeScript incremental state' },
  124. { pattern: /(^|\/)(vite|rollup|webpack|eslint|prettier)\.config\./, reason: 'build tooling config' },
  125. { pattern: /(^|\/)(pnpm-lock|package-lock|yarn)\.lock$/, reason: 'workspace lockfile' },
  126. { pattern: /(^|\/).*\.map$/, reason: 'source map' }
  127. ]
  128. for (const file of files) {
  129. if (file.startsWith('src/') && (!entry.publicSource || !allowedSourceEntrypoints.has(file))) {
  130. throw new Error(`${entry.packageName} tarball includes undeclared source file: ${file}`)
  131. }
  132. if (file.startsWith('scripts/') && !isAllowedBinScript(entry, file)) {
  133. throw new Error(`${entry.packageName} tarball includes non-bin script ${file}`)
  134. }
  135. const match = forbiddenPatterns.find(rule => rule.pattern.test(file))
  136. if (match) {
  137. throw new Error(`${entry.packageName} tarball includes forbidden ${match.reason}: ${file}`)
  138. }
  139. }
  140. }
  141. function assertEntrypointsPacked(entry, files) {
  142. const packedFiles = new Set(files)
  143. for (const entrypoint of collectPackageEntrypoints(entry.packageJson)) {
  144. if (entrypoint.includes('*')) {
  145. continue
  146. }
  147. const normalized = entrypoint.replace(/^\.\//, '')
  148. if (!packedFiles.has(normalized)) {
  149. throw new Error(`${entry.packageName} tarball is missing entrypoint ${entrypoint}`)
  150. }
  151. }
  152. }
  153. function assertWebGlobalBundlePacked(entry, files) {
  154. if (!webGlobalPackages.has(entry.packageName)) {
  155. return
  156. }
  157. if (!new Set(files).has(webGlobalBundle)) {
  158. throw new Error(`${entry.packageName} tarball is missing browser global bundle ${webGlobalBundle}`)
  159. }
  160. }
  161. function assertRequiredDocs(entry, files) {
  162. const packedFiles = new Set(files)
  163. for (const requiredFile of ['package.json', 'README.md', 'README.en.md']) {
  164. if (!packedFiles.has(requiredFile)) {
  165. throw new Error(`${entry.packageName} tarball is missing ${requiredFile}`)
  166. }
  167. }
  168. }
  169. function verifyPack(entry, pack) {
  170. assertRequiredDocs(entry, pack.files)
  171. assertNoForbiddenPackFiles(entry, pack.files)
  172. assertWebGlobalBundlePacked(entry, pack.files)
  173. if (mode === 'tarball') {
  174. assertEntrypointsPacked(entry, pack.files)
  175. if (hasWorkspaceDependency(pack.packageJson)) {
  176. throw new Error(`${entry.packageName} packed package.json still contains workspace: dependencies`)
  177. }
  178. }
  179. }
  180. let checkedFileCount = 0
  181. for (const entry of entries) {
  182. const pack = mode === 'tarball' ? readPackedTarball(entry) : readDryRunPack(entry)
  183. verifyPack(entry, pack)
  184. checkedFileCount += pack.files.length
  185. }
  186. console.log(
  187. `[ecosystem-tarballs] Verified ${entries.length} ${mode === 'tarball' ? 'packed tarball' : 'npm dry-run'} package${entries.length === 1 ? '' : 's'} and ${checkedFileCount} file entries.`
  188. )