| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228 |
- import { existsSync } from 'node:fs'
- import { cp, mkdtemp, readFile, rm, stat } from 'node:fs/promises'
- import { dirname, join, resolve } from 'node:path'
- import { tmpdir } from 'node:os'
- import { spawnSync } from 'node:child_process'
- import { fileURLToPath } from 'node:url'
- const scriptDir = dirname(fileURLToPath(import.meta.url))
- const sourceRoot = resolve(scriptDir, '..')
- const args = process.argv.slice(2)
- function readArg(name, fallback) {
- const index = args.indexOf(name)
- return index >= 0 ? args[index + 1] : fallback
- }
- const snapshotRoot = resolve(
- sourceRoot,
- readArg('--snapshot-dir', process.env.FILE_VIEWER_BRANCH_CUTOVER_DIR || '.release/branch-cutover')
- )
- const remoteName = readArg('--remote', process.env.FILE_VIEWER_BRANCH_CUTOVER_REMOTE || 'origin')
- const pushEnabled = args.includes('--push')
- const skipBackup = args.includes('--skip-backup')
- const backupBranchArg = readArg('--backup-branch', '')
- async function readJson(path) {
- return JSON.parse(await readFile(path, 'utf8'))
- }
- async function assertDirectory(path, label = path) {
- if (!existsSync(path)) {
- throw new Error(`Missing directory: ${label}`)
- }
- const info = await stat(path)
- if (!info.isDirectory()) {
- throw new Error(`Not a directory: ${label}`)
- }
- }
- async function assertFile(path, label = path) {
- if (!existsSync(path)) {
- throw new Error(`Missing file: ${label}`)
- }
- const info = await stat(path)
- if (!info.isFile()) {
- throw new Error(`Not a file: ${label}`)
- }
- }
- function run(command, commandArgs, options = {}) {
- const printable = `${[command, ...commandArgs].join(' ')}`
- console.log(`$ ${printable}`)
- const result = spawnSync(command, commandArgs, {
- cwd: options.cwd || sourceRoot,
- encoding: 'utf8',
- stdio: options.capture ? 'pipe' : 'inherit'
- })
- if (result.status !== 0) {
- throw new Error(`Command failed: ${printable}\n${result.stderr || result.stdout || ''}`)
- }
- return result.stdout?.trim() || ''
- }
- function sanitizeBranchSegment(value) {
- return value
- .replace(/[^a-zA-Z0-9._/-]+/g, '-')
- .replace(/\/+/g, '/')
- .replace(/^-+|-+$/g, '')
- }
- function defaultBackupBranch(version, sourceCommit) {
- const timestamp = new Date()
- .toISOString()
- .replace(/[-:]/g, '')
- .replace(/\.\d{3}Z$/, 'Z')
- return `workspace/pre-branch-cutover-v${version}-${sourceCommit}-${timestamp}`
- }
- function stagingRefForTarget(backupBranch, targetBranch) {
- return `refs/file-viewer-cutover/${backupBranch}/${targetBranch}`
- }
- function readRemoteHead(remoteRefs, branch) {
- const suffix = `refs/heads/${branch}`
- const line = remoteRefs
- .split('\n')
- .map(value => value.trim())
- .find(value => value.endsWith(suffix))
- return line ? line.split(/\s+/)[0] : ''
- }
- async function materializeSnapshot(target) {
- const targetDir = join(snapshotRoot, target.snapshotDir)
- await assertDirectory(targetDir, `${target.branch} snapshot`)
- await assertFile(join(targetDir, 'package.json'), `${target.branch} package.json`)
- await assertFile(join(targetDir, 'BRANCH_ROLE.md'), `${target.branch} BRANCH_ROLE.md`)
- const workDir = await mkdtemp(join(tmpdir(), `file-viewer-cutover-${target.branch}-`))
- await cp(targetDir, workDir, {
- recursive: true,
- force: true,
- filter: source => !source.split('/').includes('.git')
- })
- run('git', ['init', '--initial-branch', target.branch], { cwd: workDir })
- run('git', ['add', '-A'], { cwd: workDir })
- run(
- 'git',
- [
- '-c',
- 'user.name=Flyfish Release Bot',
- '-c',
- 'user.email=release@flyfish.dev',
- 'commit',
- '-m',
- `chore: cut over ${target.branch} to ${target.role}`
- ],
- { cwd: workDir }
- )
- const commit = run('git', ['rev-parse', '--short', 'HEAD'], { cwd: workDir, capture: true })
- const tree = run('git', ['rev-parse', 'HEAD^{tree}'], { cwd: workDir, capture: true })
- return {
- ...target,
- workDir,
- commit,
- tree
- }
- }
- async function cleanupMaterializedTargets(materializedTargets) {
- await Promise.all(
- materializedTargets.map(target => rm(target.workDir, { recursive: true, force: true }))
- )
- }
- await assertDirectory(snapshotRoot, 'branch cutover snapshot root')
- run('node', ['scripts/prepare-branch-cutover.mjs', '--verify-only'])
- const summary = await readJson(join(snapshotRoot, 'branch-cutover-summary.json'))
- const branchRoles = await readJson(join(sourceRoot, 'ecosystem', 'branch-roles.json'))
- const rootPackage = await readJson(join(sourceRoot, 'package.json'))
- const sourceCommit = run('git', ['rev-parse', '--short', 'HEAD'], { capture: true })
- const remoteUrl = run('git', ['remote', 'get-url', remoteName], { capture: true })
- if (remoteUrl !== branchRoles.sourceRemote.url) {
- throw new Error(
- `Remote ${remoteName} points to ${remoteUrl}, expected private aggregate remote ${branchRoles.sourceRemote.url}`
- )
- }
- const workingTreeStatus = run('git', ['status', '--porcelain'], { capture: true })
- if (workingTreeStatus && pushEnabled) {
- throw new Error('Working tree must be clean before applying branch cutover.')
- }
- if (workingTreeStatus && !pushEnabled) {
- console.log('Working tree has uncommitted changes; continuing because this is a dry run.')
- }
- const remoteRefs = run('git', ['ls-remote', '--heads', remoteName], { capture: true })
- const backupBranch = sanitizeBranchSegment(
- backupBranchArg || defaultBackupBranch(rootPackage.version, sourceCommit)
- )
- const mainRemoteHead = readRemoteHead(remoteRefs, 'main')
- const mainTree = run('git', ['rev-parse', 'HEAD^{tree}'], { capture: true })
- const targets = summary.targets.map(target => ({
- ...target,
- currentRemoteHead: readRemoteHead(remoteRefs, target.branch)
- }))
- const materializedTargets = []
- try {
- for (const target of targets) {
- materializedTargets.push(await materializeSnapshot(target))
- }
- console.log('')
- console.log(pushEnabled ? 'Branch cutover push plan:' : 'Branch cutover dry-run plan:')
- console.log(`Remote: ${remoteName} -> ${remoteUrl}`)
- console.log(`Backup branch: ${skipBackup ? '(skipped)' : backupBranch}`)
- console.log(
- `main\tprivate-aggregate-workspace\t${rootPackage.name}\told=${mainRemoteHead || '(new)'}\tnew=${sourceCommit}\ttree=${mainTree}`
- )
- for (const target of materializedTargets) {
- console.log(
- `${target.branch}\t${target.role}\t${target.packageName}\told=${target.currentRemoteHead || '(new)'}\tnew=${target.commit}\ttree=${target.tree}`
- )
- }
- if (!pushEnabled) {
- console.log('')
- console.log('Dry run only. Re-run with --push to update remote branches.')
- } else {
- run('git', ['fetch', remoteName, `+refs/heads/*:refs/remotes/${remoteName}/*`])
- if (!skipBackup) {
- for (const [branch, head] of [
- ['main', mainRemoteHead],
- ...materializedTargets.map(target => [target.branch, target.currentRemoteHead])
- ]) {
- if (head) {
- run('git', ['push', remoteName, `${head}:refs/heads/${backupBranch}/${branch}`])
- }
- }
- }
- const mainPushArgs = ['push']
- if (mainRemoteHead) {
- mainPushArgs.push(`--force-with-lease=refs/heads/main:${mainRemoteHead}`)
- }
- mainPushArgs.push(remoteName, 'HEAD:refs/heads/main')
- run('git', mainPushArgs)
- for (const target of materializedTargets) {
- const stagingRef = stagingRefForTarget(backupBranch, target.branch)
- run('git', ['push', sourceRoot, `HEAD:${stagingRef}`], { cwd: target.workDir })
- try {
- const pushArgs = ['push']
- if (target.currentRemoteHead) {
- pushArgs.push(`--force-with-lease=refs/heads/${target.branch}:${target.currentRemoteHead}`)
- }
- pushArgs.push(remoteName, `${stagingRef}:refs/heads/${target.branch}`)
- run('git', pushArgs)
- } finally {
- run('git', ['update-ref', '-d', stagingRef])
- }
- }
- run('git', ['fetch', remoteName, 'main', 'v2', 'v3'])
- console.log('Branch cutover pushed successfully.')
- }
- } finally {
- await cleanupMaterializedTargets(materializedTargets)
- }
|