// scripts/ci/create-release-tag.mjs // 릴리스 태그 생성 게이트. 버전 SSOT·CHANGELOG·작업트리 상태를 검증한 뒤 // annotated(기본) 또는 GPG 서명(--sign) 태그를 만든다. // // Usage: // node scripts/ci/create-release-tag.mjs --dry-run // node scripts/ci/create-release-tag.mjs // node scripts/ci/create-release-tag.mjs --sign // // 태그는 절대 이동·삭제하지 않는다. 잘못된 릴리스는 더 높은 patch로 forward-fix한다. // 생성 후 `git push origin vX.Y.Z`로 push한다. import { readFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..') const args = process.argv.slice(2) const dryRun = args.includes('--dry-run') const sign = args.includes('--sign') || args.includes('-s') const metadata = JSON.parse(readFileSync(join(root, 'release', 'product-version.json'), 'utf8')) if (!/^\d+\.\d+\.\d+$/.test(metadata.version)) { fail(`Only stable semver can be tagged: ${metadata.version}`) } const tag = `v${metadata.version}` const changelog = readFileSync(join(root, 'CHANGELOG.md'), 'utf8') const header = `## [${metadata.version}] - ${metadata.releaseDate}` if (!changelog.includes(header)) { fail(`CHANGELOG.md is missing the release section: "${header}"`) } const status = git(['status', '--porcelain']) if (status.stdout.trim() && !args.includes('--allow-dirty')) { fail('Working tree is dirty. Commit release surfaces first, or pass --allow-dirty for a local dry tag.') } const existing = git(['tag', '--list', tag]).stdout.trim() if (existing) { fail(`Tag ${tag} already exists. Releases are immutable — lift the version and re-tag.`) } const head = git(['rev-parse', 'HEAD']).stdout.trim() const message = `Release ${tag}` const tagArgs = sign ? ['tag', '-s', tag, '-m', message] : ['tag', '-a', tag, '-m', message] if (dryRun) { process.stdout.write( `[tag] dry-run — would create ${sign ? 'signed' : 'annotated'} ${tag} at ${head}\n` + `[tag] next: git push origin ${tag}\n`, ) process.exit(0) } const result = spawnSync('git', tagArgs, { cwd: root, stdio: 'inherit' }) if (result.status !== 0) { fail(`git ${tagArgs.join(' ')} failed (exit ${result.status})`) } process.stdout.write(`[tag] created ${tag}. Push with: git push origin ${tag}\n`) function git(commandArgs) { const result = spawnSync('git', commandArgs, { cwd: root, encoding: 'utf8' }) if (result.status !== 0) { fail(`git ${commandArgs.join(' ')} failed: ${result.stderr?.trim() || `exit ${result.status}`}`) } return result } function fail(message) { process.stderr.write(`[tag] ${message}\n`) process.exit(1) }