apply-branch-cutover.mjs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import { existsSync } from 'node:fs'
  2. import { cp, mkdtemp, readFile, rm, stat } from 'node:fs/promises'
  3. import { dirname, join, resolve } from 'node:path'
  4. import { tmpdir } from 'node:os'
  5. import { spawnSync } from 'node:child_process'
  6. import { fileURLToPath } from 'node:url'
  7. const scriptDir = dirname(fileURLToPath(import.meta.url))
  8. const sourceRoot = resolve(scriptDir, '..')
  9. const args = process.argv.slice(2)
  10. function readArg(name, fallback) {
  11. const index = args.indexOf(name)
  12. return index >= 0 ? args[index + 1] : fallback
  13. }
  14. const snapshotRoot = resolve(
  15. sourceRoot,
  16. readArg('--snapshot-dir', process.env.FILE_VIEWER_BRANCH_CUTOVER_DIR || '.release/branch-cutover')
  17. )
  18. const remoteName = readArg('--remote', process.env.FILE_VIEWER_BRANCH_CUTOVER_REMOTE || 'origin')
  19. const pushEnabled = args.includes('--push')
  20. const skipBackup = args.includes('--skip-backup')
  21. const backupBranchArg = readArg('--backup-branch', '')
  22. async function readJson(path) {
  23. return JSON.parse(await readFile(path, 'utf8'))
  24. }
  25. async function assertDirectory(path, label = path) {
  26. if (!existsSync(path)) {
  27. throw new Error(`Missing directory: ${label}`)
  28. }
  29. const info = await stat(path)
  30. if (!info.isDirectory()) {
  31. throw new Error(`Not a directory: ${label}`)
  32. }
  33. }
  34. async function assertFile(path, label = path) {
  35. if (!existsSync(path)) {
  36. throw new Error(`Missing file: ${label}`)
  37. }
  38. const info = await stat(path)
  39. if (!info.isFile()) {
  40. throw new Error(`Not a file: ${label}`)
  41. }
  42. }
  43. function run(command, commandArgs, options = {}) {
  44. const printable = `${[command, ...commandArgs].join(' ')}`
  45. console.log(`$ ${printable}`)
  46. const result = spawnSync(command, commandArgs, {
  47. cwd: options.cwd || sourceRoot,
  48. encoding: 'utf8',
  49. stdio: options.capture ? 'pipe' : 'inherit'
  50. })
  51. if (result.status !== 0) {
  52. throw new Error(`Command failed: ${printable}\n${result.stderr || result.stdout || ''}`)
  53. }
  54. return result.stdout?.trim() || ''
  55. }
  56. function sanitizeBranchSegment(value) {
  57. return value
  58. .replace(/[^a-zA-Z0-9._/-]+/g, '-')
  59. .replace(/\/+/g, '/')
  60. .replace(/^-+|-+$/g, '')
  61. }
  62. function defaultBackupBranch(version, sourceCommit) {
  63. const timestamp = new Date()
  64. .toISOString()
  65. .replace(/[-:]/g, '')
  66. .replace(/\.\d{3}Z$/, 'Z')
  67. return `workspace/pre-branch-cutover-v${version}-${sourceCommit}-${timestamp}`
  68. }
  69. function stagingRefForTarget(backupBranch, targetBranch) {
  70. return `refs/file-viewer-cutover/${backupBranch}/${targetBranch}`
  71. }
  72. function readRemoteHead(remoteRefs, branch) {
  73. const suffix = `refs/heads/${branch}`
  74. const line = remoteRefs
  75. .split('\n')
  76. .map(value => value.trim())
  77. .find(value => value.endsWith(suffix))
  78. return line ? line.split(/\s+/)[0] : ''
  79. }
  80. async function materializeSnapshot(target) {
  81. const targetDir = join(snapshotRoot, target.snapshotDir)
  82. await assertDirectory(targetDir, `${target.branch} snapshot`)
  83. await assertFile(join(targetDir, 'package.json'), `${target.branch} package.json`)
  84. await assertFile(join(targetDir, 'BRANCH_ROLE.md'), `${target.branch} BRANCH_ROLE.md`)
  85. const workDir = await mkdtemp(join(tmpdir(), `file-viewer-cutover-${target.branch}-`))
  86. await cp(targetDir, workDir, {
  87. recursive: true,
  88. force: true,
  89. filter: source => !source.split('/').includes('.git')
  90. })
  91. run('git', ['init', '--initial-branch', target.branch], { cwd: workDir })
  92. run('git', ['add', '-A'], { cwd: workDir })
  93. run(
  94. 'git',
  95. [
  96. '-c',
  97. 'user.name=Flyfish Release Bot',
  98. '-c',
  99. 'user.email=release@flyfish.dev',
  100. 'commit',
  101. '-m',
  102. `chore: cut over ${target.branch} to ${target.role}`
  103. ],
  104. { cwd: workDir }
  105. )
  106. const commit = run('git', ['rev-parse', '--short', 'HEAD'], { cwd: workDir, capture: true })
  107. const tree = run('git', ['rev-parse', 'HEAD^{tree}'], { cwd: workDir, capture: true })
  108. return {
  109. ...target,
  110. workDir,
  111. commit,
  112. tree
  113. }
  114. }
  115. async function cleanupMaterializedTargets(materializedTargets) {
  116. await Promise.all(
  117. materializedTargets.map(target => rm(target.workDir, { recursive: true, force: true }))
  118. )
  119. }
  120. await assertDirectory(snapshotRoot, 'branch cutover snapshot root')
  121. run('node', ['scripts/prepare-branch-cutover.mjs', '--verify-only'])
  122. const summary = await readJson(join(snapshotRoot, 'branch-cutover-summary.json'))
  123. const branchRoles = await readJson(join(sourceRoot, 'ecosystem', 'branch-roles.json'))
  124. const rootPackage = await readJson(join(sourceRoot, 'package.json'))
  125. const sourceCommit = run('git', ['rev-parse', '--short', 'HEAD'], { capture: true })
  126. const remoteUrl = run('git', ['remote', 'get-url', remoteName], { capture: true })
  127. if (remoteUrl !== branchRoles.sourceRemote.url) {
  128. throw new Error(
  129. `Remote ${remoteName} points to ${remoteUrl}, expected private aggregate remote ${branchRoles.sourceRemote.url}`
  130. )
  131. }
  132. const workingTreeStatus = run('git', ['status', '--porcelain'], { capture: true })
  133. if (workingTreeStatus && pushEnabled) {
  134. throw new Error('Working tree must be clean before applying branch cutover.')
  135. }
  136. if (workingTreeStatus && !pushEnabled) {
  137. console.log('Working tree has uncommitted changes; continuing because this is a dry run.')
  138. }
  139. const remoteRefs = run('git', ['ls-remote', '--heads', remoteName], { capture: true })
  140. const backupBranch = sanitizeBranchSegment(
  141. backupBranchArg || defaultBackupBranch(rootPackage.version, sourceCommit)
  142. )
  143. const mainRemoteHead = readRemoteHead(remoteRefs, 'main')
  144. const mainTree = run('git', ['rev-parse', 'HEAD^{tree}'], { capture: true })
  145. const targets = summary.targets.map(target => ({
  146. ...target,
  147. currentRemoteHead: readRemoteHead(remoteRefs, target.branch)
  148. }))
  149. const materializedTargets = []
  150. try {
  151. for (const target of targets) {
  152. materializedTargets.push(await materializeSnapshot(target))
  153. }
  154. console.log('')
  155. console.log(pushEnabled ? 'Branch cutover push plan:' : 'Branch cutover dry-run plan:')
  156. console.log(`Remote: ${remoteName} -> ${remoteUrl}`)
  157. console.log(`Backup branch: ${skipBackup ? '(skipped)' : backupBranch}`)
  158. console.log(
  159. `main\tprivate-aggregate-workspace\t${rootPackage.name}\told=${mainRemoteHead || '(new)'}\tnew=${sourceCommit}\ttree=${mainTree}`
  160. )
  161. for (const target of materializedTargets) {
  162. console.log(
  163. `${target.branch}\t${target.role}\t${target.packageName}\told=${target.currentRemoteHead || '(new)'}\tnew=${target.commit}\ttree=${target.tree}`
  164. )
  165. }
  166. if (!pushEnabled) {
  167. console.log('')
  168. console.log('Dry run only. Re-run with --push to update remote branches.')
  169. } else {
  170. run('git', ['fetch', remoteName, `+refs/heads/*:refs/remotes/${remoteName}/*`])
  171. if (!skipBackup) {
  172. for (const [branch, head] of [
  173. ['main', mainRemoteHead],
  174. ...materializedTargets.map(target => [target.branch, target.currentRemoteHead])
  175. ]) {
  176. if (head) {
  177. run('git', ['push', remoteName, `${head}:refs/heads/${backupBranch}/${branch}`])
  178. }
  179. }
  180. }
  181. const mainPushArgs = ['push']
  182. if (mainRemoteHead) {
  183. mainPushArgs.push(`--force-with-lease=refs/heads/main:${mainRemoteHead}`)
  184. }
  185. mainPushArgs.push(remoteName, 'HEAD:refs/heads/main')
  186. run('git', mainPushArgs)
  187. for (const target of materializedTargets) {
  188. const stagingRef = stagingRefForTarget(backupBranch, target.branch)
  189. run('git', ['push', sourceRoot, `HEAD:${stagingRef}`], { cwd: target.workDir })
  190. try {
  191. const pushArgs = ['push']
  192. if (target.currentRemoteHead) {
  193. pushArgs.push(`--force-with-lease=refs/heads/${target.branch}:${target.currentRemoteHead}`)
  194. }
  195. pushArgs.push(remoteName, `${stagingRef}:refs/heads/${target.branch}`)
  196. run('git', pushArgs)
  197. } finally {
  198. run('git', ['update-ref', '-d', stagingRef])
  199. }
  200. }
  201. run('git', ['fetch', remoteName, 'main', 'v2', 'v3'])
  202. console.log('Branch cutover pushed successfully.')
  203. }
  204. } finally {
  205. await cleanupMaterializedTargets(materializedTargets)
  206. }