Files

176 lines
5.8 KiB
JavaScript

import { execFile, spawn } from 'node:child_process'
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { promisify } from 'node:util'
import { inspectLocalWindowsRelease } from './shared/windows.mjs'
import {
compareReleaseVersions,
createReleaseTag,
parseReleaseTag,
RELEASE_VERSION_PATTERN
} from './tag.mjs'
import { createReleaseStageDeadline } from './shared/stage-deadline.mjs'
const execFileAsync = promisify(execFile)
const ROOT = process.cwd()
const args = process.argv.slice(2)
const valueAfter = (name) => {
const index = args.indexOf(name)
if (index < 0) return undefined
const value = args[index + 1]
if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`)
return value
}
const VALUE_OPTIONS = new Set(['--platform', '--version', '--notes'])
for (let index = 0; index < args.length; index += 1) {
const argument = args[index]
if (VALUE_OPTIONS.has(argument)) {
const value = args[index + 1]
if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value`)
index += 1
continue
}
throw new Error(`unknown argument: ${argument}`)
}
const version = valueAfter('--version') || ''
const platform = valueAfter('--platform') || ''
const releaseTag =
RELEASE_VERSION_PATTERN.test(version) && platform ? createReleaseTag(platform, version) : ''
const notes = valueAfter('--notes') || `Ncurator ${releaseTag}`
const pnpmExecutable = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const stageDeadline = createReleaseStageDeadline('release preparation')
if (!RELEASE_VERSION_PATTERN.test(version) || !['mac', 'windows'].includes(platform)) {
throw new Error(
'usage: node scripts/release/prepare.mjs --platform <mac|windows> --version <1.2.3> [--notes <text>]'
)
}
const run = (command, commandArgs) =>
new Promise((resolve, reject) => {
const child = stageDeadline.track(
spawn(command, commandArgs, {
cwd: ROOT,
env: process.env,
stdio: 'inherit',
shell: process.platform === 'win32' && /\.(cmd|bat)$/iu.test(command)
})
)
child.once('error', reject)
child.once('exit', (code, signal) => {
if (code === 0) resolve()
else reject(new Error(`${command} exited with ${code ?? signal}`))
})
})
const branch = (
await execFileAsync('git', ['branch', '--show-current'], { cwd: ROOT })
).stdout.trim()
if (branch !== 'main') throw new Error(`desktop releases must be prepared on main, not ${branch}`)
const { stdout: worktreeStatus } = await execFileAsync('git', ['status', '--porcelain'], {
cwd: ROOT
})
if (worktreeStatus.trim()) throw new Error('desktop releases require a clean worktree')
await run('git', ['fetch', '--tags', 'origin', 'main'])
const [{ stdout: localHead }, { stdout: remoteHead }] = await Promise.all([
execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: ROOT }),
execFileAsync('git', ['rev-parse', 'origin/main'], { cwd: ROOT })
])
if (localHead.trim() !== remoteHead.trim()) {
throw new Error('local main must exactly match origin/main before preparing a release')
}
const packagePath = path.join(ROOT, 'package.json')
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'))
const { stdout: platformTagsOutput } = await execFileAsync(
'git',
['tag', '--list', `${platform}-v*`],
{ cwd: ROOT }
)
const platformVersions = platformTagsOutput
.split(/\r?\n/u)
.filter(Boolean)
.map((tag) => parseReleaseTag(tag, platform).version)
.sort(compareReleaseVersions)
const latestPlatformVersion = platformVersions.at(-1)
if (latestPlatformVersion && compareReleaseVersions(version, latestPlatformVersion) <= 0) {
throw new Error(
`new ${platform} version ${version} must be higher than ${latestPlatformVersion}; abort an unpublished tag with pnpm release:abort --tag ${platform}-v${latestPlatformVersion} before reusing a version`
)
}
const tagExists = await execFileAsync('git', ['rev-parse', '--verify', '--quiet', releaseTag], {
cwd: ROOT
})
.then(() => true)
.catch(() => false)
if (tagExists) {
throw new Error(
`tag already exists: ${releaseTag}; abort it with pnpm release:abort --tag ${releaseTag} if it was never published live`
)
}
if (platform === 'windows') {
const localInspection = await inspectLocalWindowsRelease(ROOT)
if (!localInspection.ready) {
throw new Error(
`local Windows release is unavailable:\n- ${localInspection.issues.join('\n- ')}`
)
}
}
const sqliteMigrationLockScript = path.join(
ROOT,
'scripts',
'release',
'shared',
'sqlite-migration-lock.mjs'
)
await run(process.execPath, [sqliteMigrationLockScript])
await Promise.all([
run(pnpmExecutable, ['typecheck:node']),
run(pnpmExecutable, ['typecheck:web']),
run(process.execPath, [path.join(ROOT, 'scripts', 'build', 'sidecar', 'ensure.mjs')])
])
await run(process.execPath, [
path.join(ROOT, 'scripts', 'test', 'workflow.mjs'),
'--type',
'backend',
'--exclude-group',
'live'
])
await run(process.execPath, [sqliteMigrationLockScript, '--promote-current'])
packageJson.version = version
await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8')
await run('git', [
'add',
'--',
'package.json',
'src/main/services/storage/sqlite/migrations/released-migrations.json'
])
await run('git', ['commit', '--allow-empty', '-m', `release: prepare ${releaseTag}`])
await run('git', ['tag', '-a', releaseTag, '-m', notes])
await run('git', ['push', '--atomic', 'origin', 'main', releaseTag])
process.stdout.write(`[release] prepared and pushed ${releaseTag}\n`)
if (platform === 'mac') {
process.stdout.write(`[release] ${releaseTag} prepared for independent macOS release\n`)
process.exit(0)
}
await run(process.execPath, [
path.join(ROOT, 'scripts', 'release', 'windows.mjs'),
'--tag',
releaseTag,
'--prepare-only'
])