diff --git a/.github/scripts/release-body.mjs b/.github/scripts/release-body.mjs new file mode 100644 index 0000000..89260e9 --- /dev/null +++ b/.github/scripts/release-body.mjs @@ -0,0 +1,149 @@ +// .github/scripts/release-body.mjs +// +// 推 v* 标签触发发版时,Release 页的标题与正文由这里拼,格式固定成四段: +// +// {package.json 的 description} +// +// ## 本次变更 +// - <提交标题>… ← 没有手写说明时,从上一个 tag 列到当前 tag +// +// ## 安装 +// ```powershell +// cd +// npm install @ +// ``` +// +// ## 链接 +// - npm / 提交对比 +// +// 想给某一版写人话,加一个 release-notes/v<版本>.md 就够了: +// 第一行是 `# 标题` → 顶替默认标题 v<版本>,其余原样当「本次变更」; +// 第一行不是标题 → 整份文件都当「本次变更」。 +// 没有这个文件就退回列提交。骨架别在 workflow 里手改,改这里,测试会跟着红。 + +import { pathToFileURL } from 'node:url'; + +// 这些提交标题对读 Release 的人没有信息量,列出来只是噪音。 +const SKIP_SUBJECTS = [ + /^Merge (pull request|branch|remote-tracking|tag)\b/i, + /^Revert "Merge/i, + /^chore:\s*发版\s*v?\d/i, +]; + +export function pickCommits(subjects = []) { + const out = []; + for (const raw of subjects) { + const subject = String(raw ?? '').trim(); + if (!subject) continue; + if (SKIP_SUBJECTS.some((re) => re.test(subject))) continue; + if (out.includes(subject)) continue; + out.push(subject); + } + return out; +} + +export function parseNotes(raw = '') { + const text = String(raw ?? '').replace(/\r\n?/g, '\n').trim(); + if (!text) return { title: '', body: '' }; + const lines = text.split('\n'); + const first = lines[0].trim(); + if (/^#\s+\S/.test(first)) { + return { title: first.replace(/^#\s+/, '').trim(), body: lines.slice(1).join('\n').trim() }; + } + return { title: '', body: text }; +} + +export function repoUrlFrom(pkg = {}) { + const raw = typeof pkg?.repository === 'string' ? pkg.repository : pkg?.repository?.url; + const url = String(raw ?? '').trim(); + if (!url) return ''; + return url.replace(/^git\+/, '').replace(/\.git$/, '').replace(/\/+$/, ''); +} + +export function buildReleaseBody(options = {}) { + const name = String(options.name ?? '').trim(); + const version = String(options.version ?? '').trim(); + const tag = String(options.tag ?? '').trim() || (version ? 'v' + version : ''); + const description = String(options.description ?? '').trim(); + const repoUrl = String(options.repoUrl ?? '').trim().replace(/\/+$/, ''); + const previousTag = String(options.previousTag ?? '').trim(); + const notes = parseNotes(options.notes); + const title = notes.title || tag || version; + + const changes = notes.body + ? [notes.body] + : pickCommits(options.commits).map((subject) => '- ' + subject); + + const lines = []; + if (description) lines.push(description, ''); + lines.push('## 本次变更', ''); + if (changes.length) lines.push(...changes, ''); + else lines.push('- 这一版没有可列的提交(大概是重打了同一个 tag)。', ''); + lines.push('## 安装', '', '```powershell', 'cd ', 'npm install ' + name + '@' + version, '```', ''); + lines.push('装完重启 DSH;profile 的 `package.json` 里要把 `' + name + '` 登记进 `dsh.profile.bundles`。', ''); + lines.push('## 链接', ''); + if (name && version) lines.push('- npm:https://www.npmjs.com/package/' + name + '/v/' + version); + if (repoUrl && previousTag && tag) lines.push('- 提交对比:' + repoUrl + '/compare/' + previousTag + '...' + tag); + else if (repoUrl && tag) lines.push('- 提交列表:' + repoUrl + '/commits/' + tag); + + return { title, body: lines.join('\n').trim() + '\n' }; +} + +function parseArgs(argv = []) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (!arg.startsWith('--')) continue; + const key = arg.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) out[key] = true; + else { + out[key] = next; + i++; + } + } + return out; +} + +async function main(argv) { + const fs = await import('node:fs'); + const path = await import('node:path'); + const args = parseArgs(argv); + const pkgPath = typeof args.pkg === 'string' ? args.pkg : 'package.json'; + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + const version = (typeof args.version === 'string' && args.version) || String(pkg.version ?? ''); + const tag = (typeof args.tag === 'string' && args.tag) || 'v' + version; + const commitsFile = typeof args['commits-file'] === 'string' ? args['commits-file'] : ''; + const commits = commitsFile && fs.existsSync(commitsFile) + ? fs.readFileSync(commitsFile, 'utf8').split('\n') + : []; + const notesFile = typeof args['notes-file'] === 'string' + ? args['notes-file'] + : path.join('release-notes', 'v' + version + '.md'); + const notes = notesFile && fs.existsSync(notesFile) ? fs.readFileSync(notesFile, 'utf8') : ''; + + const result = buildReleaseBody({ + name: pkg.name, + version, + tag, + description: pkg.description, + repoUrl: repoUrlFrom(pkg), + previousTag: typeof args.previous === 'string' ? args.previous : '', + commits, + notes, + }); + + if (typeof args.out === 'string') fs.writeFileSync(args.out, result.body); + else process.stdout.write(result.body); + if (typeof args['title-out'] === 'string') fs.writeFileSync(args['title-out'], result.title + '\n'); + + console.log('release body ' + result.body.length + ' 字节 / 标题「' + result.title + '」/ 列了 ' + pickCommits(commits).length + ' 条提交' + (notes ? '(用了手写说明)' : '')); +} + +const invoked = process.argv[1] ? pathToFileURL(process.argv[1]).href : ''; +if (import.meta.url === invoked) { + main(process.argv.slice(2)).catch((err) => { + console.error(err?.message || String(err)); + process.exitCode = 1; + }); +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d1e1083 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,201 @@ +name: release + +# 推 v* 标签就发版:先跑双平台测试,再校验 tag 与 package.json 版本一致, +# 然后 npm publish,最后用 .github/scripts/release-body.mjs 拼好的正文建 Release。 +# 正文格式固定成「描述 / 本次变更 / 安装 / 链接」四段,别在 workflow 里手改文案。 +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: '要发版的标签(必须已经存在,例如 v0.9.1)' + required: true + +permissions: + contents: write + id-token: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + +jobs: + test: + name: test (node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ['20', '22'] + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref_name }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + # 与 test.yml 同一套装法:--omit=peer 是必须的,装上 peer 会盖掉 + # rules-remote 的降级路径,测到的就不是真实形状了。 + - name: 安装依赖 + run: npm install --omit=peer --no-audit --no-fund --package-lock=false + + - name: 跑单测 + shell: bash + run: | + set -o pipefail + npm test 2>&1 | tee test.log + status=${PIPESTATUS[0]} + if [ "$status" -ne 0 ]; then + while IFS= read -r line; do + echo "::error::$line" + done < <(grep -E "^(========== FAIL|FAIL |[0-9]+ 套失败)" test.log | head -20) + fi + exit "$status" + + publish: + name: publish to npm + needs: [test] + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + published: ${{ steps.check.outputs.published }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref_name }} + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + + - name: 读版本号 + id: version + run: | + ver="$(node -p "require('./package.json').version")" + echo "version=$ver" >> "$GITHUB_OUTPUT" + echo "package.json version = $ver" + + # 标签写错是发版最常见的翻车方式:v0.9.0 的标签挂着 0.8.0 的代码, + # 发出去之后 registry 上那个版本号就永远对不上了(不能重发)。 + - name: 校验 tag 与 package.json 版本一致 + run: | + tag="${RELEASE_TAG#v}" + ver="$(node -p "require('./package.json').version")" + echo "tag=$tag version=$ver" + if [ "$tag" != "$ver" ]; then + echo "::error::tag ${RELEASE_TAG} does not match package.json version $ver" + exit 1 + fi + + - name: 查 registry 上是否已有这个版本 + id: check + run: | + ver="$(node -p "require('./package.json').version")" + name="$(node -p "require('./package.json').name")" + echo "checking $name@$ver on the registry" + if npm view "$name@$ver" version >/dev/null 2>&1; then + echo "$ver 已经发过了,跳过 publish" + echo "published=false" >> "$GITHUB_OUTPUT" + else + echo "published=true" >> "$GITHUB_OUTPUT" + fi + + - name: 发到 npm + if: steps.check.outputs.published == 'true' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "::error::仓库没有配 NPM_TOKEN,发不出去" + exit 1 + fi + npm publish --access public --provenance + + - name: 发完回查一次 + if: steps.check.outputs.published == 'true' + run: | + ver="$(node -p "require('./package.json').version")" + name="$(node -p "require('./package.json').name")" + # registry 有缓存延迟,给它 30 秒 + for i in $(seq 1 10); do + if npm view "$name@$ver" version >/dev/null 2>&1; then + echo "$name@$ver 已在 registry 上" + exit 0 + fi + sleep 3 + done + echo "::error::npm publish 报告成功,但 registry 上查不到 $name@$ver" + exit 1 + + release: + name: create GitHub release + # 必须等 publish:发不出去就不该先挂出一个 Release + needs: [test, publish] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref_name }} + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: 收集本次变更的提交 + id: commits + run: | + # 找上一个 tag 作为比较起点;没有就列全部历史。 + prev="$(git describe --tags --abbrev=0 "${RELEASE_TAG}^" 2>/dev/null || true)" + if [ -n "$prev" ]; then + echo "上一个 tag: $prev" + git log --pretty=format:'%s' "$prev..${RELEASE_TAG}" > commits.txt + else + echo "没有上一个 tag,列出全部历史" + git log --pretty=format:'%s' "$RELEASE_TAG" > commits.txt + fi + echo "previous=$prev" >> "$GITHUB_OUTPUT" + echo "共 $(wc -l < commits.txt) 条提交" + + # 补发老标签时,那个 tag 的树里可能还没有这个脚本(脚本是后加的), + # 就从默认分支取一份;取不到才退化成 GitHub 自动生成的说明。 + - name: 取正文生成脚本 + run: | + if [ ! -f .github/scripts/release-body.mjs ]; then + mkdir -p .github/scripts + git show "origin/${{ github.event.repository.default_branch }}:.github/scripts/release-body.mjs" > .github/scripts/release-body.mjs + echo "已从默认分支取到 release-body.mjs" + fi + + - name: 生成 Release 标题与正文 + run: | + node .github/scripts/release-body.mjs \ + --pkg package.json \ + --version "${{ needs.publish.outputs.version }}" \ + --tag "${RELEASE_TAG}" \ + --previous "${{ steps.commits.outputs.previous }}" \ + --commits-file commits.txt \ + --out release-body.md \ + --title-out release-title.txt + + - name: 建 Release + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then + echo "Release ${RELEASE_TAG} 已存在,不再重复建" + exit 0 + fi + gh release create "${RELEASE_TAG}" \ + --title "$(cat release-title.txt)" \ + --notes-file release-body.md \ + --verify-tag diff --git a/README.md b/README.md index 90166c5..fa42ee1 100644 --- a/README.md +++ b/README.md @@ -219,11 +219,11 @@ sttArgs: ["<插件目录>/stt-whisper.py", "--file", "{file}", "--out", "{out}"] ```bash npm install # 只是跑测试用的开发依赖(@deepseek-ai/schemastery) -npm test # 一次跑完六套单测 -npm test -- travel # 只跑指定的(rules / cycle / plugin / travel / luckin / remote) +npm test # 一次跑完八套单测 +npm test -- travel # 只跑指定的(rules / cycle / plugin / care / travel / luckin / remote / release-body) ``` -六套单测都在 `tests/`,全部直接驱动 `lib/` 里的生产函数(不 mock 掉被测对象),覆盖: +八套单测都在 `tests/`,全部直接驱动 `lib/` 里的生产函数(不 mock 掉被测对象),覆盖: | 套件 | 管什么 | |---|---| @@ -233,11 +233,55 @@ npm test -- travel # 只跑指定的(rules / cycle / plugin / travel / lucki | `test-remote.mjs` | 宿主行服务全方法(stub cordis ctx 直测)+ 学习链路 + 出行/瑞幸接线 | | `test-travel.mjs` | 高德 key 收敛与打码、MCP 探针(真起 127.0.0.1 假 server)、常用坐标、`placesPrompt` | | `test-luckin.mjs` | 瑞幸 token 收敛、到期推算、探针带 `Authorization` 且**绝不试调**(防误下单) | +| `test-care.mjs` | 生活关怀层:模式判定、名字表、渲染器降级、注册顺序、单条失败不连坐 | +| `test-release-body.mjs` | Release 正文骨架:提交筛选、手写说明解析、四段格式、CLI 落盘 | 不联网:所有 MCP 交互都打本机 `127.0.0.1` 上的假服务端;临时文件一律落系统临时目录,不往仓库里写。 CI 在 `.github/workflows/test.yml`:每次 push 与 PR 都跑 Node 20 / 22 两档。安装依赖特意用 `npm install --omit=peer`——只装 `schemastery`,另外三个 peer 故意缺席,因为 `rules-remote` 的降级路径正是测试要覆盖的形状(装上反而测不到)。 +### 发版 + +推一个 `v*` 标签就会自动发版(`.github/workflows/release.yml`): + +```bash +npm version patch --no-git-tag-version # 或 minor / major,改 package.json +git commit -am "chore: 发版 0.9.1" +git push +git tag -a v0.9.1 -m "v0.9.1" +git push origin v0.9.1 # ← 这一步触发整个流程 +``` + +流程分三段,任何一段红了后面都不跑: + +1. **test** —— Node 20 / 22 双档跑全套单测,和 `test.yml` 用同一套装法; +2. **publish** —— 先校验「标签版本 == `package.json` 版本」(对不上直接红,因为 npm 上的版本号发出去就改不了),再查 registry 上有没有这个版本(有就跳过),然后 `npm publish --access public --provenance`,发完回查一次确认真的能查到; +3. **release** —— 从上一个标签列提交,拼正文,建 GitHub Release。 + +Release 的正文格式固定成四段,由 `.github/scripts/release-body.mjs` 生成: + +````markdown + + +## 本次变更 +- feat: … +- fix: … ← 自动列上一个 tag 到本 tag 的提交,merge 与「发版」提交会被过滤 + +## 安装 +```powershell +cd +npm install dsh-cura@0.9.1 +``` + +## 链接 +- npm:https://www.npmjs.com/package/dsh-cura/v/0.9.1 +- 提交对比:…/compare/v0.9.0...v0.9.1 +```` + +想给某一版写人话(推荐),加一个 `release-notes/v0.9.1.md`:第一行写 `# 标题`,标题就用它、其余进「本次变更」;不写标题就整份进正文。有这个文件就不再列提交。 + +需要仓库里配 `NPM_TOKEN`(Settings → Secrets → Actions),权限是 npm 的 Automation token;`--provenance` 需要 workflow 的 `id-token: write`,已经在文件里给了。 + ## Roadmap - [x] 情境→表达规则库:LLM 从聊天记录抽取 `{"situation","style"}` 规则入库,按情境检索注入(MaiBot 1.x 模式) diff --git a/release-notes/README.md b/release-notes/README.md new file mode 100644 index 0000000..6b78a92 --- /dev/null +++ b/release-notes/README.md @@ -0,0 +1,26 @@ +# release-notes/ + +这里放「某一版想用人话说明」的说明稿,文件名固定 `v<版本>.md`(例:`v0.9.1.md`)。 + +发版时 `.github/scripts/release-body.mjs` 会按 `package.json` 的版本号自动找这个文件: + +- 第一行是 `# 标题` → Release 标题用它,其余内容进「本次变更」; +- 第一行不是标题 → 整份内容都进「本次变更」,标题仍是 `v<版本>`。 + +有这个文件就不再自动列提交。没有的话,正文会退化成「上一个 tag 到本 tag 的提交清单」。 + +格式建议(四段由脚本补齐,这里只写中间那段): + +```markdown +# v0.9.1 — 一句话说清这版是什么 + +## 生活关怀层不再绑在 QQ 上 + +说清为什么改、改完什么变了、不想用怎么退回去。 + +## 修掉一个会打崩整轮的问题 + +用户视角的现象 → 根因 → 现在的行为。 +``` + +写给人看,不要写成 commit 列表。 diff --git a/tests/all.mjs b/tests/all.mjs index cde1a66..d6e209d 100644 --- a/tests/all.mjs +++ b/tests/all.mjs @@ -16,6 +16,7 @@ const SUITES = [ ['travel', 'test-travel.mjs'], ['luckin', 'test-luckin.mjs'], ['remote', 'test-remote.mjs'], + ['release-body', 'test-release-body.mjs'], ]; const only = process.argv.slice(2); diff --git a/tests/test-release-body.mjs b/tests/test-release-body.mjs new file mode 100644 index 0000000..a93e797 --- /dev/null +++ b/tests/test-release-body.mjs @@ -0,0 +1,155 @@ +// tests/test-release-body.mjs — Release 正文骨架(真驱动 .github/scripts/release-body.mjs) +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as R from '../.github/scripts/release-body.mjs'; + +let pass = 0, fail = 0; +function ok(name, cond, detail) { + if (cond) { pass++; console.log('PASS ' + name); } + else { fail++; console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); } +} +function eq(name, got, want) { ok(name + ' = ' + JSON.stringify(want), JSON.stringify(got) === JSON.stringify(want), 'got ' + JSON.stringify(got)); } +function has(name, haystack, needle) { ok(name, String(haystack).includes(needle), '找不到 ' + JSON.stringify(needle) + '\n' + String(haystack).slice(0, 400)); } + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, '..'); + +// ---------- 1. 提交筛选 ---------- +eq('合进主干的 merge 提交不列', R.pickCommits(['Merge pull request #3 from x/y']), []); +eq('Merge branch 也不列', R.pickCommits(['Merge branch \'main\' into feat']), []); +ok('chore: 发版 不列', R.pickCommits(['chore: 发版 0.9.0']).length === 0); +ok('英文 chore: release 保留(不猜)', R.pickCommits(['chore: release 1.0.0']).length === 1); +eq('空行/空白丢掉', R.pickCommits(['', ' ', '\t']), []); +eq('重复提交只留一条', R.pickCommits(['feat: a', 'feat: a', 'fix: b']), ['feat: a', 'fix: b']); +eq('保持原顺序', R.pickCommits(['fix: b', 'feat: a']), ['fix: b', 'feat: a']); +eq('非数组入参不炸', R.pickCommits(undefined), []); +eq('数字/null 混入也能过', R.pickCommits([null, undefined, 'feat: a']), ['feat: a']); + +// ---------- 2. 手写说明解析 ---------- +eq('空文件 → 无标题无正文', R.parseNotes(''), { title: '', body: '' }); +eq('纯空白 → 无标题无正文', R.parseNotes(' \n\n '), { title: '', body: '' }); +eq('首行 # 标题被摘出来', R.parseNotes('# v0.9.0 — 生活关怀层走到所有会话\n\n正文'), { title: 'v0.9.0 — 生活关怀层走到所有会话', body: '正文' }); +eq('首行不是标题就整份当正文', R.parseNotes('## 子标题\n内容'), { title: '', body: '## 子标题\n内容' }); +eq('CRLF 归一', R.parseNotes('# T\r\n\r\nA\r\nB'), { title: 'T', body: 'A\nB' }); +eq('# 后面没字不算标题', R.parseNotes('#\n内容'), { title: '', body: '#\n内容' }); + +// ---------- 3. 仓库地址归一 ---------- +eq('git+ 前缀与 .git 后缀去掉', R.repoUrlFrom({ repository: { url: 'git+https://github.com/slow-stack/dsh-cura.git' } }), 'https://github.com/slow-stack/dsh-cura'); +eq('字符串形式也认', R.repoUrlFrom({ repository: 'https://github.com/a/b.git' }), 'https://github.com/a/b'); +eq('尾部斜杠去掉', R.repoUrlFrom({ repository: { url: 'https://github.com/a/b/' } }), 'https://github.com/a/b'); +eq('没有仓库地址返回空串', R.repoUrlFrom({}), ''); +eq('空对象不炸', R.repoUrlFrom(), ''); + +// ---------- 4. 正文骨架 ---------- +const base = { + name: 'dsh-cura', + version: '0.9.0', + tag: 'v0.9.0', + description: '生活关怀层 + QQ 伴生增强', + repoUrl: 'https://github.com/slow-stack/dsh-cura', + previousTag: 'v0.8.0', + commits: ['Merge pull request #3 from x/y', 'feat: 把照看生活的能力解耦', 'chore: 发版 0.9.0', 'fix: 关掉变量插值'], +}; +const out = R.buildReleaseBody(base); +eq('默认标题就是 tag', out.title, 'v0.9.0'); + +const body = out.body; +const order = ['## 本次变更', '## 安装', '## 链接'].map((s) => body.indexOf(s)); +ok('三段都在', order.every((i) => i >= 0), JSON.stringify(order)); +ok('三段顺序固定', order[0] < order[1] && order[1] < order[2], JSON.stringify(order)); +has('描述在最前面', body.split('\n')[0], '生活关怀层 + QQ 伴生增强'); +has('只列真正的提交', body, '- feat: 把照看生活的能力解耦'); +has('也列 fix', body, '- fix: 关掉变量插值'); +ok('merge 与发版提交没混进去', !body.includes('Merge pull request') && !body.includes('chore: 发版')); +has('安装给的是带版本号的包名', body, 'npm install dsh-cura@0.9.0'); +has('安装块用 powershell 高亮', body, '```powershell'); +has('提醒重启 DSH', body, '重启 DSH'); +has('提醒登记 bundles', body, 'dsh.profile.bundles'); +has('npm 版本页链接', body, 'https://www.npmjs.com/package/dsh-cura/v/0.9.0'); +has('有上一个 tag 就给对比链接', body, 'https://github.com/slow-stack/dsh-cura/compare/v0.8.0...v0.9.0'); +ok('正文结尾只有一个换行', body.endsWith('\n') && !body.endsWith('\n\n'), JSON.stringify(body.slice(-8))); +ok('没有残留占位符', !/undefined|null|\$\{/.test(body), body.slice(0, 300)); + +// ---------- 5. 边界 ---------- +const noPrev = R.buildReleaseBody({ ...base, previousTag: '' }); +has('没有上一个 tag 时退回提交列表链接', noPrev.body, '/commits/v0.9.0'); +ok('没有上一个 tag 时不出现 compare', !noPrev.body.includes('/compare/')); + +const noRepo = R.buildReleaseBody({ ...base, repoUrl: '' }); +ok('没有仓库地址时链接段不硬塞', !noRepo.body.includes('/compare/') && !noRepo.body.includes('/commits/')); +has('但 npm 链接还在', noRepo.body, 'npmjs.com/package/dsh-cura'); + +const noCommits = R.buildReleaseBody({ ...base, commits: [] }); +has('没有可列提交时说明一句', noCommits.body, '没有可列的提交'); + +const noDesc = R.buildReleaseBody({ ...base, description: '' }); +ok('没有描述时正文不以空行开头', noDesc.body.startsWith('## 本次变更'), noDesc.body.slice(0, 40)); + +const withNotes = R.buildReleaseBody({ ...base, notes: '# v0.9.0 — 生活关怀层走到所有会话\n\n手写的正文\n\n- 一条' }); +eq('手写说明的标题顶替默认标题', withNotes.title, 'v0.9.0 — 生活关怀层走到所有会话'); +has('手写正文进「本次变更」', withNotes.body, '手写的正文'); +ok('有手写说明就不再列提交', !withNotes.body.includes('- feat: 把照看生活的能力解耦')); + +const notesNoTitle = R.buildReleaseBody({ ...base, notes: '- 手写一条' }); +eq('没写标题就还是用 tag 当标题', notesNoTitle.title, 'v0.9.0'); +has('没写标题时整份都是变更内容', notesNoTitle.body, '- 手写一条'); +ok('没写标题时也不列提交', !notesNoTitle.body.includes('把照看生活的能力解耦')); + +const noTag = R.buildReleaseBody({ ...base, tag: '' }); +eq('没给 tag 就用 v+version', noTag.title, 'v0.9.0'); + +// ---------- 6. 真实跑一遍 CLI(workflow 里就是这么调的) ---------- +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cura-release-')); +const commitsFile = path.join(dir, 'commits.txt'); +const bodyFile = path.join(dir, 'body.md'); +const titleFile = path.join(dir, 'title.txt'); +fs.writeFileSync(commitsFile, 'feat: 真跑一条\nMerge pull request #9 from x/y\n'); +const { spawnSync } = await import('node:child_process'); +const cli = spawnSync(process.execPath, [ + path.join(ROOT, '.github/scripts/release-body.mjs'), + '--pkg', path.join(ROOT, 'package.json'), + '--version', '9.9.9', + '--tag', 'v9.9.9', + '--previous', 'v9.9.8', + '--commits-file', commitsFile, + '--notes-file', path.join(dir, 'nope.md'), + '--out', bodyFile, + '--title-out', titleFile, +], { encoding: 'utf8' }); +eq('CLI 退出码 0', cli.status, 0); +ok('CLI 把正文写进文件', fs.existsSync(bodyFile) && fs.readFileSync(bodyFile, 'utf8').includes('## 安装')); +has('CLI 用 package.json 的包名', fs.readFileSync(bodyFile, 'utf8'), 'npm install dsh-cura@9.9.9'); +eq('CLI 写标题文件', fs.readFileSync(titleFile, 'utf8').trim(), 'v9.9.9'); +ok('CLI 的正文里没有 merge 提交', !fs.readFileSync(bodyFile, 'utf8').includes('Merge pull request')); +ok('CLI 打印了一行摘要', /release body \d+ 字节/.test(cli.stdout || ''), cli.stdout); + +// 说明文件存在时确实被读进去(workflow 默认路径就是 release-notes/v.md) +const notesDir = path.join(dir, 'release-notes'); +fs.mkdirSync(notesDir); +fs.writeFileSync(path.join(notesDir, 'v9.9.9.md'), '# 九点九版\n\n手写\n'); +const cli2 = spawnSync(process.execPath, [ + path.join(ROOT, '.github/scripts/release-body.mjs'), + '--pkg', path.join(ROOT, 'package.json'), + '--version', '9.9.9', + '--commits-file', commitsFile, + '--out', path.join(dir, 'body2.md'), + '--title-out', path.join(dir, 'title2.txt'), +], { encoding: 'utf8', cwd: dir }); +eq('默认路径的说明文件被自动找到', fs.readFileSync(path.join(dir, 'title2.txt'), 'utf8').trim(), '九点九版'); + +// ---------- 7. 接线静态断言(workflow 真的这么用) ---------- +const yml = fs.readFileSync(path.join(ROOT, '.github/workflows/release.yml'), 'utf8'); +ok('release.yml 由 v* 标签触发', /tags:\s*\n\s*-\s*'v\*'/.test(yml), yml.slice(0, 200)); +ok('release.yml 调了 release-body.mjs', /release-body\.mjs/.test(yml)); +ok('release.yml 用 --notes-file 而不是 --generate-notes', /gh release create/.test(yml) && /--notes-file/.test(yml) && !/--generate-notes/.test(yml)); +ok('release.yml 校 tag 与 package.json 版本一致', /tag="\$\{RELEASE_TAG#v\}"/.test(yml) && /if \[ "\$tag" != "\$ver" \]; then/.test(yml) && /does not match package.json version/.test(yml)); +ok('release.yml 发版带 provenance', /npm publish[^\n]*--provenance/.test(yml)); +ok('release.yml 用 NPM_TOKEN', /secrets\.NPM_TOKEN/.test(yml)); +ok('release.yml 先测试后发版', /needs:\s*\[test\]/.test(yml)); +ok('Release 等 publish 完成才建', /needs:\s*\[test, publish\]/.test(yml)); +ok('重复打同一个 tag 不会重复建 Release', /gh release view/.test(yml) && /已存在/.test(yml)); + +console.log('\n' + (fail === 0 ? 'all release body tests passed ✓' : fail + ' 项失败')); +if (fail) process.exitCode = 1;