#!/usr/bin/env node /** * release.mjs — S4-P12 版本发布流程(SemVer + CHANGELOG 驱动) * * 只做可逆的本地准备,不自动 push/tag/npm publish 到远端: * 决策冻结(本轮拍板):URL=GitHub Pages 默认域名(SITE_URL_BASE 配置化,占位禁正式发布);npm=本轮不公开发布,只做 pack 内容检查;WCAG=基线支持,不宣称正式 AA;跨端=历史差异告警、增量阻断(见 regression.yml);Card=整卡不可点击、无整卡 hover;ThemeSwitcher=6 预设色升契约,持久化键按实现现状; * 1) 从 CHANGELOG.md 解析最新 `## [x.y.z] - date` 段(Unreleased 不算版本) * 2) 校验 package.json 的 version 与之*一致*(不一致则报错退出,不自动改) * 3) 校验 CHANGELOG 该版本段非空;校验 dist 可构建(tools/build-dist.mjs --dry-run 若支持) * 4) 输出发布检查清单 + 建议的 tag/push 命令(由人执行) * * 另支持 --bump --date YYYY-MM-DD:把 [Unreleased] 提升为新版本段 *(改 CHANGELOG.md 与 package.json,需人复核后提交)。 * * 用法: * node tools/release.mjs # 检查当前版本是否可发布 * node tools/release.mjs --bump minor --date 2026-09-14 */ import { readFileSync, writeFileSync, existsSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); const arg = (k) => { const i = process.argv.indexOf(k); return i > -1 ? process.argv[i + 1] : null; }; const fail = (msg) => { console.error('FAIL: ' + msg); process.exit(1); }; const ok = (msg) => console.log('OK: ' + msg); function parseChangelog() { const src = readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8'); const vers = [...src.matchAll(/^## \[([0-9]+\.[0-9]+\.[0-9]+)\](?:\s*-\s*(\S+))?/gm)]; const hasUnreleased = /^## \[Unreleased\]/m.test(src); const unreleasedBody = hasUnreleased ? (src.split(/^## \[Unreleased\]/m)[1] || '').split(/^## \[/m)[0].trim() : ''; return { vers: vers.map((m) => ({ v: m[1], date: m[2] || '' })), hasUnreleased, unreleasedBody }; } function bump(v, kind) { const [a, b, c] = v.split('.').map(Number); if (kind === 'major') return `${a + 1}.0.0`; if (kind === 'minor') return `${a}.${b + 1}.0`; if (kind === 'patch') return `${a}.${b}.${c + 1}`; fail('未知 --bump 类型(major|minor|patch): ' + kind); return v; } /* ---------- --bump 模式 ---------- */ const bumpKind = arg('--bump'); if (bumpKind) { const date = arg('--date') || new Date().toISOString().slice(0, 10); const pkgPath = join(ROOT, 'package.json'); const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); const { hasUnreleased, unreleasedBody } = parseChangelog(); if (!hasUnreleased) fail('CHANGELOG.md 无 [Unreleased] 段,无内容可提升'); if (unreleasedBody.length < 10) fail('[Unreleased] 段为空或过短,先写变更条目再提升版本'); const next = bump(pkg.version, bumpKind); let cl = readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8'); cl = cl.replace(/^## \[Unreleased\]/m, `## [Unreleased]\n\n## [${next}] - ${date}`); writeFileSync(join(ROOT, 'CHANGELOG.md'), cl); pkg.version = next; writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); console.log(`bumped: ${next} (${date})`); console.log('请复核 CHANGELOG.md 与 package.json 后提交:'); console.log(` git add CHANGELOG.md package.json && git commit -m "chore(release): v${next}"`); process.exit(0); } /* ---------- 检查模式 ---------- */ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); const { vers, hasUnreleased, unreleasedBody } = parseChangelog(); if (!vers.length) fail('CHANGELOG.md 无任何版本段'); const latest = vers[0]; console.log(`package.json: ${pkg.version} / CHANGELOG 最新: [${latest.v}] ${latest.date}`); if (pkg.version !== latest.v) fail(`版本不一致:package.json=${pkg.version} vs CHANGELOG=[${latest.v}](用 --bump 提升,不要手改一边)`); ok('版本一致'); // Unreleased 有内容 = 还有未发布变更,提示而非报错 if (hasUnreleased && unreleasedBody.length > 10) { console.log(`NOTE: [Unreleased] 段有 ${unreleasedBody.length} 字符未发布变更,发版前确认是否纳入本次`); } else { ok('Unreleased 为空,无遗留变更'); } // dist 构建检查(只验证脚本存在且可跑;产物不入库) try { const { execSync } = await import('child_process'); execSync('node tools/build-dist.mjs --help', { cwd: ROOT, stdio: 'pipe', timeout: 30000 }); ok('build-dist 可调用'); } catch { console.log('NOTE: tools/build-dist.mjs 无 --help(不阻塞,构建时再验证产物)'); } const placeholders = []; for (const [f, c] of [['package.json', JSON.stringify(pkg)], ['build-site.ps1', (()=>{ try { return fs.readFileSync(join(ROOT,'build-site.ps1'),'utf8'); } catch { return ''; } })()]]) { if (/YOUR-ACCOUNT/.test(c)) placeholders.push(f); } if (!process.env.SITE_URL_BASE && /YOUR-ACCOUNT/.test(readFileSync(join(ROOT,'build-site.ps1'),'utf8'))) { console.log('NOTE: SITE_URL_BASE 未设置,build-site.ps1 将使用占位域名;正式发布前必须配置真实域名'); } if (placeholders.length) fail('正式发布前必须替换占位账号/域名:' + placeholders.join(', ')); ok('无 YOUR-ACCOUNT 占位(或仅构建警告范围内)'); try { const { execSync } = await import('child_process'); execSync('npm pack --dry-run', { cwd: ROOT, stdio: 'pipe', timeout: 60000 }); ok('npm pack --dry-run 通过(本轮不执行 npm publish)'); } catch (e) { fail('npm pack --dry-run 失败:' + String((e && e.message) || e).slice(0, 200)); } console.log(''); console.log('发布检查通过。建议的人工执行序列:'); console.log(` 1) git tag v${pkg.version}`); console.log(` 2) git push origin main v${pkg.version}`); console.log(' 3) npm run build && npm pack --dry-run(本轮决策:不执行 npm publish,如需公开发布另行决策)');