diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 570ca59..f339716 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -68,6 +68,7 @@ catbus/ ├── scripts/ │ ├── golden/ # 调用上游 Python 生成对拍数据 │ ├── smoke.mjs # CI 冒烟测试 +│ ├── release-notes.mjs # 从 CHANGELOG 生成 GitHub Release 的说明 │ └── fetch-*-models.mjs # 取回模型 ├── tests/ # vitest;golden/

/ 放对拍数据,e2e/ 放在线测试 └── references/ # 上游仓库的只读副本,不进版本控制 diff --git a/.github/TESTING.md b/.github/TESTING.md index dbb04a0..0b664d8 100644 --- a/.github/TESTING.md +++ b/.github/TESTING.md @@ -249,7 +249,7 @@ npm run test:e2e -- -t '^x ' # X 的平台 id 太短,-t x 会连 xhs、 ### 4.1 CI 做了什么 -[.github/workflows/ci.yml](workflows/ci.yml) 在推送到 `master`、提交 PR 时触发;推送 `v*` tag 时,[release.yml](workflows/release.yml) 也会先调用它跑完整 CI,再发布 CI 里打好、测过的同一份 tarball。 +[.github/workflows/ci.yml](workflows/ci.yml) 在推送到 `master`、提交 PR 时触发;推送 `v*` tag 时,[release.yml](workflows/release.yml) 也会先调用它跑完整 CI,再发布 CI 里打好、测过的同一份 tarball,并从 CHANGELOG 生成 GitHub Release(发版步骤见 [AGENTS.md 7.2](../AGENTS.md#72-打包与分发))。 | job | 环境 | 步骤 | |---|---|---| diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7d07ab..8bd32ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,8 @@ name: release -# 推送 v* tag 发布。先跑完整 CI(含全部目标系统的冒烟),再发布 CI 里打好、测过的同一份 tarball。 +# 推送 v* tag 发布。先跑完整 CI(含全部目标系统的冒烟),再发布 CI 里打好、测过的同一份 tarball,最后建 GitHub Release。 # 发布顺序:@cv-cat/catbus-assets-jd、@cv-cat/catbus-assets-ocr(该版本还没发布时)→ catbus-cli(AGENTS 7.2)。 +# 每一步发布前都先查这个版本是否已经在 npm 上,失败后重跑这个 job 不会重复发布。 on: push: tags: ['v*'] @@ -11,14 +12,30 @@ permissions: id-token: write jobs: + # 不用等 CI 跑完就能发现的问题,先查 + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: tag 与版本号一致 + run: | + version=$(node -p "require('./package.json').version") + test "v$version" = "$GITHUB_REF_NAME" || { echo "tag $GITHUB_REF_NAME 与 package.json 的 $version 不一致"; exit 1; } + - name: CHANGELOG 有这个版本 + run: node scripts/release-notes.mjs "${GITHUB_REF_NAME#v}" > /dev/null + ci: uses: ./.github/workflows/ci.yml publish: - needs: ci + needs: [check, ci] runs-on: ubuntu-latest + permissions: + contents: write # 建 GitHub Release + id-token: write # npm trusted publishing(OIDC)与 provenance env: - # 用 NPM_TOKEN 发布;在 npm 上配好 trusted publishing 后可以删掉这个 secret,走 OIDC。 + # 在 npm 上给三个包配好 trusted publishing 后,npm 优先用 OIDC,用不了才退回这个 token; + # 走 OIDC 成功发布过一次后,删掉这一行和仓库的 NPM_TOKEN secret。 NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # npm 只给公开仓库生成 provenance:仓库公开时带 --provenance,私有时不带 PROVENANCE: ${{ !github.event.repository.private && '--provenance' || '' }} @@ -28,29 +45,34 @@ jobs: with: node-version: 24 registry-url: https://registry.npmjs.org + # trusted publishing 需要 npm 11.5.1 以上 - run: npm install -g npm@latest - - name: tag 与版本号一致 - run: | - version=$(node -p "require('./package.json').version") - test "v$version" = "$GITHUB_REF_NAME" || { echo "tag $GITHUB_REF_NAME 与 package.json 的 $version 不一致"; exit 1; } - uses: actions/download-artifact@v8 with: name: packages - - name: publish @cv-cat/catbus-assets-jd + - name: publish run: | - version=$(node -p "require('./packages/assets-jd/package.json').version") - if npm view "@cv-cat/catbus-assets-jd@$version" version >/dev/null 2>&1; then - echo "@cv-cat/catbus-assets-jd@$version 已发布,跳过" - else - npm publish "./cv-cat-catbus-assets-jd-$version.tgz" --access public $PROVENANCE - fi - - name: publish @cv-cat/catbus-assets-ocr + # $1 是 package.json 所在目录;tarball 名与 npm pack 一致:@scope/name → scope-name-<版本>.tgz + publish() { + name=$(node -p "require('./$1/package.json').name") + version=$(node -p "require('./$1/package.json').version") + if npm view "$name@$version" version >/dev/null 2>&1; then + echo "$name@$version 已发布,跳过" + else + npm publish "./$(echo "$name" | sed 's#^@##; s#/#-#')-$version.tgz" --access public $PROVENANCE + fi + } + publish packages/assets-jd + publish packages/assets-ocr + publish . + - name: GitHub Release + env: + GH_TOKEN: ${{ github.token }} run: | - version=$(node -p "require('./packages/assets-ocr/package.json').version") - if npm view "@cv-cat/catbus-assets-ocr@$version" version >/dev/null 2>&1; then - echo "@cv-cat/catbus-assets-ocr@$version 已发布,跳过" + version=${GITHUB_REF_NAME#v} + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + echo "Release $GITHUB_REF_NAME 已存在,跳过" else - npm publish "./cv-cat-catbus-assets-ocr-$version.tgz" --access public $PROVENANCE + node scripts/release-notes.mjs "$version" > notes.md + gh release create "$GITHUB_REF_NAME" --verify-tag --title "catbus $version" --notes-file notes.md fi - - name: publish catbus-cli - run: npm publish ./catbus-cli-*.tgz --access public $PROVENANCE diff --git a/AGENTS.md b/AGENTS.md index 72f6dcb..db7232c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -754,9 +754,12 @@ stdout 只输出结果。日志、提示、进度、二维码一律输出到 std - darwin-arm64、darwin-x64; - linux-x64、linux-arm64,glibc 和 musl 都要支持。 - CI 在全部目标系统上跑冒烟测试,musl 用 Alpine。**任何一个目标系统缺预编译包的依赖都不能用。** -- 发布:推送 `v*` tag 触发 `.github/workflows/release.yml`,先跑完整 CI,再发布 CI 里测过的同一份 tarball。 - - 顺序:先发 assets-jd、assets-ocr(该版本还没发布时),再发 `catbus-cli`。 - - 需要仓库 secret `NPM_TOKEN`(有发布权限的 npm token);在 npm 上配好 trusted publishing 后可以改用 OIDC。 +- 发布: + 1. 改 `package.json` 的版本号。模型变了时同时改 `packages/assets-*/package.json` 的版本,并把主包里钉住的依赖改成同一个版本(测试会检查)。 + 2. 在 `CHANGELOG.md` 写好 `## [<版本>] - <日期>` 一节。 + 3. 推送 `v<版本>` tag,触发 `.github/workflows/release.yml`:先检查 tag 与版本号一致、CHANGELOG 里有这个版本;跑完整 CI;发布 CI 里测过的同一份 tarball;最后用 `scripts/release-notes.mjs` 从 CHANGELOG 生成 GitHub Release。 + - 顺序:先发 assets-jd、assets-ocr,再发 `catbus-cli`。已经在 npm 上的版本跳过,失败后重跑不会重复发布。 + - 认证:npm 上给三个包配了 trusted publishing(GitHub Actions,`cv-cat/catbus`,`release.yml`,允许 `npm publish`)时走 OIDC,否则用仓库 secret `NPM_TOKEN`。trusted publishing 配好后两天内要成功发布一次,否则失效。 - 仓库公开时发布带 `--provenance`,私有时不带(npm 只给公开仓库生成 provenance),由 release.yml 按仓库可见性自动判断。 - **Docker 镜像**(可选):`Dockerfile` 走与 npm 发布相同的路径,先打出三个包,再在 `node:<版本>-bookworm-slim` 里全局安装这三个 tarball;`CATBUS_HOME=/data`(卷),工作目录 `/work`;默认以 root 运行(挂进来的目录都能写),Linux 上可以 `--user "$(id -u):$(id -g)"` 配合挂自己的数据目录。`compose.yaml` 提供 `catbus`(一次性命令)和 `live-listen`(长时间监听直播)两个服务。CI 的 `.github/workflows/docker.yml` 在 x64 与 arm64 上构建并跑 `version` / `doctor`,不推送到任何镜像仓库。 - 以后要提供 SDK 时,通过 `package.json` 的 `exports` 暴露,现在不做。 diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs new file mode 100644 index 0000000..4fb539c --- /dev/null +++ b/scripts/release-notes.mjs @@ -0,0 +1,30 @@ +// 从 CHANGELOG.md 取出某个版本的说明,作为 GitHub Release 的正文写到 stdout:开头是安装命令,相对链接换成仓库里的绝对地址。 +// 用法:node scripts/release-notes.mjs <版本号>。CHANGELOG 里没有这个版本时退出码 1,release.yml 在发布前用它检查。 +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +const BLOB = 'https://github.com/cv-cat/catbus/blob/master/' + +const version = process.argv[2] +if (!version) { + console.error('用法:node scripts/release-notes.mjs <版本号>') + process.exit(2) +} + +const changelog = readFileSync(fileURLToPath(new URL('../CHANGELOG.md', import.meta.url)), 'utf8').replace(/\r\n/g, '\n') +const section = changelog.split(/^## /m).find((s) => s.startsWith(`[${version}]`)) +if (!section) { + console.error(`CHANGELOG.md 里没有 ## [${version}],先写好这个版本的更新日志再打 tag`) + process.exit(1) +} + +const body = section + .split('\n') + .slice(1) + .filter((line) => !/^\[[^\]]+\]: /.test(line)) + .join('\n') + .replace(/^### /gm, '## ') + .replace(/\]\((?!https?:|#)([^)]+)\)/g, (_, path) => `](${BLOB}${path})`) + .trim() + +process.stdout.write(`\`\`\`bash\nnpm i -g catbus-cli@${version}\n\`\`\`\n\n${body}\n\n完整的更新日志见 [CHANGELOG.md](${BLOB}CHANGELOG.md)。\n`) diff --git a/tests/registry.test.ts b/tests/registry.test.ts index 3479aca..2acac66 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -170,10 +170,12 @@ describe('docs/capabilities.md', () => { }) describe('package.json', () => { - it('精确钉住 @cv-cat/catbus-assets-jd 的版本(AGENTS 7.2)', () => { + it('精确钉住两个模型包的版本(AGENTS 7.2)', () => { const read = (p: string) => JSON.parse(readFileSync(new URL(p, import.meta.url), 'utf8')) const main = read('../package.json') - const assets = read('../packages/assets-jd/package.json') - expect(main.dependencies[assets.name]).toBe(assets.version) + for (const dir of ['assets-jd', 'assets-ocr']) { + const assets = read(`../packages/${dir}/package.json`) + expect(main.dependencies[assets.name], assets.name).toBe(assets.version) + } }) })