prepare-branch-cutover.mjs 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. import { existsSync } from 'node:fs'
  2. import { cp, mkdir, readFile, 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. const scriptDir = dirname(fileURLToPath(import.meta.url))
  7. const sourceRoot = resolve(scriptDir, '..')
  8. const args = process.argv.slice(2)
  9. function readArg(name, fallback) {
  10. const index = args.indexOf(name)
  11. return index >= 0 ? args[index + 1] : fallback
  12. }
  13. const outputRoot = resolve(
  14. sourceRoot,
  15. readArg('--out-dir', process.env.FILE_VIEWER_BRANCH_CUTOVER_DIR || '.release/branch-cutover')
  16. )
  17. const exportRoot = join(outputRoot, 'exports')
  18. const verifyOnly = args.includes('--verify-only')
  19. async function readJson(path) {
  20. return JSON.parse(await readFile(path, 'utf8'))
  21. }
  22. async function writeJson(path, value) {
  23. await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
  24. }
  25. async function writeText(path, value) {
  26. await writeFile(path, value, 'utf8')
  27. }
  28. async function removePath(path) {
  29. await rm(path, { recursive: true, force: true })
  30. }
  31. async function assertDirectory(path, label = path) {
  32. if (!existsSync(path)) {
  33. throw new Error(`Missing directory: ${label}`)
  34. }
  35. const info = await stat(path)
  36. if (!info.isDirectory()) {
  37. throw new Error(`Not a directory: ${label}`)
  38. }
  39. }
  40. async function assertFile(path, label = path) {
  41. if (!existsSync(path)) {
  42. throw new Error(`Missing file: ${label}`)
  43. }
  44. const info = await stat(path)
  45. if (!info.isFile()) {
  46. throw new Error(`Not a file: ${label}`)
  47. }
  48. }
  49. function run(command, commandArgs, options = {}) {
  50. const printable = `${[command, ...commandArgs].join(' ')}`
  51. console.log(`$ ${printable}`)
  52. const result = spawnSync(command, commandArgs, {
  53. cwd: options.cwd || sourceRoot,
  54. encoding: 'utf8',
  55. stdio: options.capture ? 'pipe' : 'inherit'
  56. })
  57. if (result.status !== 0) {
  58. throw new Error(`Command failed: ${printable}\n${result.stderr || result.stdout || ''}`)
  59. }
  60. return result.stdout?.trim() || ''
  61. }
  62. const wrappers = await readJson(join(sourceRoot, 'ecosystem', 'wrappers.json'))
  63. const branchRoles = await readJson(join(sourceRoot, 'ecosystem', 'branch-roles.json'))
  64. const rootPackage = await readJson(join(sourceRoot, 'package.json'))
  65. const vue27 = wrappers.wrappers.find(wrapper => wrapper.id === 'vue2.7')
  66. const vue3 = wrappers.wrappers.find(wrapper => wrapper.id === 'vue3')
  67. if (!vue27 || !vue3) {
  68. throw new Error('Missing vue2.7 or vue3 wrapper metadata in ecosystem/wrappers.json')
  69. }
  70. const sourceBranch = run('git', ['branch', '--show-current'], { capture: true })
  71. const sourceCommit = run('git', ['rev-parse', '--short', 'HEAD'], { capture: true })
  72. const cutoverTargets = [
  73. {
  74. branch: 'v2',
  75. role: 'vue2.7-component',
  76. sourceDir: join(exportRoot, vue27.repository),
  77. packageName: vue27.packageName,
  78. repository: vue27.repository,
  79. publicRepositories: {
  80. github: vue27.github,
  81. gitee: vue27.gitee
  82. },
  83. compatibilityPackages: vue27.historicalPackages || []
  84. },
  85. {
  86. branch: 'v3',
  87. role: 'vue3-component',
  88. sourceDir: join(exportRoot, vue3.repository),
  89. packageName: vue3.packageName,
  90. repository: vue3.repository,
  91. publicRepositories: {
  92. github: vue3.github,
  93. gitee: vue3.gitee
  94. },
  95. compatibilityPackages: vue3.historicalPackages || []
  96. }
  97. ]
  98. function branchDirectoryName(target) {
  99. return `${target.branch}-${target.role}`
  100. }
  101. async function writeBranchReadme(target, targetDir) {
  102. const lines = [
  103. '# Branch Cutover Snapshot',
  104. '',
  105. `- Branch: \`${target.branch}\``,
  106. `- Role: \`${target.role}\``,
  107. `- Package: \`${target.packageName}\``,
  108. `- Source baseline: \`${sourceBranch}@${sourceCommit}\``,
  109. `- Public GitHub: ${target.publicRepositories.github}`,
  110. `- Public Gitee: ${target.publicRepositories.gitee}`,
  111. ''
  112. ]
  113. if (target.compatibilityPackages.length) {
  114. lines.push(`Compatibility npm aliases: ${target.compatibilityPackages.map(name => `\`${name}\``).join(', ')}`, '')
  115. }
  116. lines.push(
  117. 'This folder is a dry-run file tree for the private aggregate repository branch cutover.',
  118. 'It is generated from the current monorepo source package export and is safe to inspect before replacing any remote branch.',
  119. ''
  120. )
  121. await writeText(join(targetDir, 'BRANCH_ROLE.md'), `${lines.join('\n')}\n`)
  122. }
  123. async function copyBranchSnapshot(target) {
  124. const targetDir = join(outputRoot, branchDirectoryName(target))
  125. await removePath(targetDir)
  126. await cp(target.sourceDir, targetDir, {
  127. recursive: true,
  128. force: true,
  129. filter: source => !source.split('/').includes('.git')
  130. })
  131. await writeBranchReadme(target, targetDir)
  132. await writeJson(join(targetDir, 'branch-cutover-manifest.json'), {
  133. schemaVersion: 1,
  134. generatedAt: new Date().toISOString(),
  135. branch: target.branch,
  136. role: target.role,
  137. packageName: target.packageName,
  138. packageVersion: rootPackage.version,
  139. sourceBranch,
  140. sourceCommit,
  141. repository: target.repository,
  142. publicRepositories: target.publicRepositories,
  143. compatibilityPackages: target.compatibilityPackages,
  144. aggregateRemote: branchRoles.sourceRemote.url
  145. })
  146. return targetDir
  147. }
  148. async function verifySnapshot(target) {
  149. const targetDir = join(outputRoot, branchDirectoryName(target))
  150. await assertDirectory(targetDir, `${target.branch} snapshot`)
  151. await assertFile(join(targetDir, 'package.json'), `${target.branch} package.json`)
  152. await assertFile(join(targetDir, 'README.md'), `${target.branch} README.md`)
  153. await assertFile(join(targetDir, 'README.en.md'), `${target.branch} README.en.md`)
  154. await assertFile(join(targetDir, 'LICENSE'), `${target.branch} LICENSE`)
  155. await assertFile(join(targetDir, 'BRANCH_ROLE.md'), `${target.branch} BRANCH_ROLE.md`)
  156. await assertFile(join(targetDir, 'branch-cutover-manifest.json'), `${target.branch} branch-cutover-manifest.json`)
  157. const packageJson = await readJson(join(targetDir, 'package.json'))
  158. if (packageJson.name !== target.packageName) {
  159. throw new Error(`${target.branch} package name mismatch: ${packageJson.name} !== ${target.packageName}`)
  160. }
  161. if (JSON.stringify(packageJson).includes('workspace:')) {
  162. throw new Error(`${target.branch} snapshot leaks workspace dependency ranges`)
  163. }
  164. if (existsSync(join(targetDir, 'node_modules')) || existsSync(join(targetDir, 'dist'))) {
  165. throw new Error(`${target.branch} snapshot must not include node_modules or dist`)
  166. }
  167. }
  168. if (!verifyOnly) {
  169. await removePath(outputRoot)
  170. await mkdir(exportRoot, { recursive: true })
  171. run('node', ['scripts/sync-wrapper-readmes.mjs'])
  172. run('node', ['scripts/sync-wrapper-repos.mjs', '--out-dir', exportRoot, '--id=vue2.7'])
  173. run('node', ['scripts/sync-wrapper-repos.mjs', '--out-dir', exportRoot, '--id=vue3'])
  174. for (const target of cutoverTargets) {
  175. await assertDirectory(target.sourceDir, `${target.branch} exported source`)
  176. await copyBranchSnapshot(target)
  177. }
  178. await writeJson(join(outputRoot, 'branch-cutover-summary.json'), {
  179. schemaVersion: 1,
  180. generatedAt: new Date().toISOString(),
  181. sourceBranch,
  182. sourceCommit,
  183. aggregateRemote: branchRoles.sourceRemote.url,
  184. mainBranch: {
  185. branch: 'main',
  186. role: 'private-aggregate-workspace',
  187. packageName: rootPackage.name,
  188. sourceCommit,
  189. note: 'Private Gitea main remains the complete original aggregate workspace and is not generated as a core-only snapshot.'
  190. },
  191. targets: cutoverTargets.map(target => ({
  192. branch: target.branch,
  193. role: target.role,
  194. packageName: target.packageName,
  195. snapshotDir: branchDirectoryName(target),
  196. publicRepositories: target.publicRepositories,
  197. compatibilityPackages: target.compatibilityPackages
  198. }))
  199. })
  200. }
  201. for (const target of cutoverTargets) {
  202. await verifySnapshot(target)
  203. }
  204. await assertFile(join(outputRoot, 'branch-cutover-summary.json'), 'branch-cutover-summary.json')
  205. console.log(`Prepared branch cutover snapshots in ${outputRoot}`)
  206. for (const target of cutoverTargets) {
  207. console.log(`${target.branch}\t${target.role}\t${target.packageName}\t${join(outputRoot, branchDirectoryName(target))}`)
  208. }