Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
name: Release

on:
push:
tags: ['v*']

permissions:
contents: read

concurrency:
# Serialize versions as well as retries; never cancel a partial npm release.
group: npm-release
cancel-in-progress: false

env:
RELEASE_TAG: ${{ github.ref_name }}

jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: pnpm
- name: Validate tag, release set and main ancestry
run: |
git merge-base --is-ancestor HEAD origin/main
test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")"
node scripts/npm-release.mjs check "$RELEASE_TAG"
- run: pnpm install --frozen-lockfile
- run: pnpm run check
- run: pnpm run test:release
# Preserve the project's prepublishOnly work: format, tsc, adapters,
# library tests against dist, and the three-framework example suite.
- run: pnpm run prepublishOnly
- run: pnpm run test:cov
- name: Pack workspace protocols and validate all public entries
run: node scripts/npm-release.mjs pack "$RELEASE_TAG" "$RUNNER_TEMP/npm-release"
- name: Install tarballs in a clean consumer and smoke test
run: node scripts/npm-release-smoke.mjs "$RELEASE_TAG" "$RUNNER_TEMP/npm-release"
- name: Verify lifecycle did not modify tagged sources
run: git diff --exit-code
- uses: actions/upload-artifact@v4
with:
name: npm-release
path: ${{ runner.temp }}/npm-release/
overwrite: true
if-no-files-found: error
retention-days: 30

publish:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v4
with:
# Omit registry-url: OIDC needs no token placeholder or NPM_TOKEN.
node-version: '24'
- name: Check npm OIDC support and release commit
run: |
git merge-base --is-ancestor HEAD origin/main
test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")"
node scripts/npm-release.mjs runtime
node scripts/npm-release.mjs check "$RELEASE_TAG"
- uses: actions/download-artifact@v4
with:
name: npm-release
path: ${{ runner.temp }}/npm-release/
# Each of the five npm packages must trust yourtion/node-erest/release.yml.
# No GitHub environment is used; leave that npm field blank.
- name: Publish validated tarballs, then complete GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/npm-release.mjs publish "$RELEASE_TAG" "$RUNNER_TEMP/npm-release"
10 changes: 10 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ jobs:
- name: Run code format check
run: pnpm run check

- name: Test release validation and retry behavior
run: pnpm run test:release

- name: Build project
run: pnpm run build

Expand All @@ -49,6 +52,13 @@ jobs:
- name: Run examples tests
run: pnpm --filter erest-example test

- name: Validate publishable tarballs and consumer imports
if: matrix.node-version == '24.x'
run: |
RELEASE_TAG="v$(node --print "require('./package.json').version")"
node scripts/npm-release.mjs pack "$RELEASE_TAG" "$RUNNER_TEMP/npm-release"
node scripts/npm-release-smoke.mjs "$RELEASE_TAG" "$RUNNER_TEMP/npm-release"

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
if: matrix.node-version == '20.x'
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ packages/ # v3.0:框架适配器实现独立子包(npm scope @er
- 根包 `erest` 与受影响的子包(`packages/erest-{express,koa,leizmweb,gen}/`)的 `version` 字段一起改,保持一致(`pnpm publish -r` 整体发布,版本不齐会乱)。
- 语义化版本:bug 修复/文档/非 breaking 新增 → patch(x.y.Z);新增向后兼容能力 → minor(x.Y.0);breaking → major(X.0.0)。
- 同步在 `MIGRATION.md` 加对应版本章节(即使非 breaking 也要登记新增/修复项)。
- 发版用 `pnpm run publish:all`(`pnpm publish -r --no-git-checks`,`prepublishOnly` 会先跑 format + test)。
- 默认通过 `.github/workflows/release.yml` 自动发版:版本 PR 合入 `main` 且 CI 通过后,维护者推送匹配的 `vX.Y.Z` tag。每个 npm 包须先绑定 Trusted Publisher,详见 `RELEASING.md`。
- `publish:all` 仅保留为有发布凭据的明确手动操作;不要通过 `postpublish` 自动推 Git 分支或 tag。自动流程显式执行 `prepublishOnly`,再发布验证过的同一批 workspace-aware tarball。
- 接入或修改 CI 不代表获准实际发版;只有用户明确授权后才推送发布 tag 或调用真实 publish。
- 反例:只改了 `Reply.markSent()` 实现 + MIGRATION 登记,却忘了把 `3.2.0` 改成 `3.2.1` —— 这种「代码动了版本没动」会让下游无法通过版本号判断是否包含修复,必须避免。

> API 用法(registerTyped/bind/hooks/schema.register 等)与测试/构建命令见 README.md,此处不再重复。
9 changes: 9 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,15 @@ instead」)。`@koa/router` 是官方维护的继任包,API 与 koa-router *
- 新增 `stryker.config.json` 与 `pnpm run mutation` / `mutation:incremental` 脚本(vitest runner + perTest 覆盖分析)。
- 变异分数从 44.93% 提升至 89.7%+(新增约 270 个内容级断言用例,全部融入现有测试文件)。

### 发布与验证

- 根包 `erest` 与 `@erest/express`、`@erest/koa`、`@erest/leizmweb`、`@erest/gen` 统一为 `3.2.3`,按仓库约定整体发布;adapter 的 `workspace:^` peer 在 pnpm 打包时转换为 `erest: ^3.2.3`。
- 新增增量变异测试 CI:PR 读取兼容主分支基线,主分支成功后保存;`pnpm run mutation:full` 可强制全量重建。
- 源码测试在 `ISLIB` 模式将 adapter 的 `erest` 导入解析到源码,支持无 `dist` 的洁净环境。
- 已合入代码通过 604 个库测试、13 个示例测试和覆盖率门槛;完整 3,070 个变异的分数为 90.10%,无超时或运行错误。
- 新增 tag 触发的 npm OIDC 自动发布:校验统一版本及 `main` 归属,验证五个 tarball 和消费端导入,按依赖顺序发布,全部成功后完成 GitHub Release;重跑跳过已发布的精确版本。
- 四个子包补齐仓库元数据以支持 provenance。移除 `postpublish` 自动推送分支/tag 的副作用,维护者按 `RELEASING.md` 配置每个包的 Trusted Publisher 后显式推送发版 tag。

## v3.2.2 — 文档展示返回结果 schema(Swagger / Postman)

### 新增(非 breaking)
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,14 @@ PR 不写共享基线,主分支运行不会被后续提交主动取消。
静态变异或未跟踪的环境变化可能需要手动全量重跑。当前未设置最低变异分数门禁,作业成功仍可能
存在存活变异;此工作流也不会自动配置分支保护或 required status check。

## 自动发布

推送与五个包版本一致、且指向 `main` 提交的 `vX.Y.Z` tag 后,GitHub Actions 会检查、构建、
打包并通过 npm Trusted Publishing(OIDC)发布,全部 npm 包成功后创建 GitHub Release。
正式版走 `latest`,预发布走 `next`;普通 PR 不发布,不需要 `NPM_TOKEN`。
维护者需先为每个 npm 包绑定 `yourtion/node-erest` 的 `release.yml`,environment 留空。
一次性配置、发版命令、产物校验和失败重跑见 [RELEASING.md](RELEASING.md)。

## API 文档

线上文档站(typedoc 产物)由 CI 自动构建部署:
Expand Down
83 changes: 83 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# npm 自动发布

仓库通过 GitHub 托管 runner 的 `.github/workflows/release.yml` 使用 npm Trusted Publishing(OIDC)。
不需要 `NPM_TOKEN`,也不在 GitHub 保存 npm 长期凭据。workflow 使用 Node 24,并检查实际 npm
版本至少为 11.5.1(OIDC 要求 Node 至少 22.14.0)。公开仓库的公开包通过 OIDC 发布时,npm 自动生成 provenance。

## 一次性 npm 配置

维护者须登录 npm,分别进入以下五个包的 Settings → Trusted Publisher,选择 GitHub Actions。
它们已经在 npm 上存在,无需新包引导流程。仅合入 workflow 不会替代 npm 后台绑定;本仓库无法从代码确认绑定状态。

| npm 包 | Organization or user | Repository | Workflow filename | Environment name |
| --- | --- | --- | --- | --- |
| `erest` | `yourtion` | `node-erest` | `release.yml` | 留空 |
| `@erest/express` | `yourtion` | `node-erest` | `release.yml` | 留空 |
| `@erest/koa` | `yourtion` | `node-erest` | `release.yml` | 留空 |
| `@erest/leizmweb` | `yourtion` | `node-erest` | `release.yml` | 留空 |
| `@erest/gen` | `yourtion` | `node-erest` | `release.yml` | 留空 |

workflow 名称填写文件名 `release.yml`,不要填写显示名称 `Release` 或完整路径。如果 npm 界面
提供 Allowed actions,须允许直接 `npm publish`;本流程不使用 staged publishing。
本 workflow 没有 GitHub environment,所以 npm 的 Environment name 留空。将来添加 environment
或重命名 workflow 时,要同步修改每个包的绑定。

四个子包的 `repository.url` 与根包一致,且包含各自的 `repository.directory`。provenance
要求仓库 URL 与实际发布仓库匹配。不要用 token 掩盖 OIDC 配置错误,也不要将凭据写入仓库或日志。
官方说明:[Trusted publishers](https://docs.npmjs.com/trusted-publishers/)。

## 正式发版

1. 将根包和四个公开子包的版本统一更新,更新 `MIGRATION.md`,通过 PR 合入 `main`。
2. 确认该提交的 CI 通过,且上一次发版已经完成。拉取 `main` 后创建并推送精确 tag:

```bash
git switch main
git pull --ff-only
pnpm run tag
git push origin v3.2.3
```

示例用于 `3.2.3`;以后将推送的 tag 替换为五个 manifest 中的实际版本。`pnpm run tag`
只创建本地 tag,不执行 npm 发布。也可用 `git tag v<version>` 创建 tag。
3. 在 Actions → Release 查看结果。只有推送 tag 触发发布,普通 PR/main push 不发布。

tag 必须是严格的 `vX.Y.Z`,支持例如 `v3.3.0-rc.1` 的预发布,拒绝 npm 无法原样保留的
`+build` 元数据。tag 必须指向 `main` 中已有的提交,annotated 和轻量 tag 都会检查实际提交。
所有待发包的版本必须与 tag 一致;private 的 `examples` 永不发布。

正式版显式使用 `latest`;预发布显式使用 `next`,GitHub Release 标记为 prerelease。
发布作业全局串行且不主动取消。GitHub concurrency 不保证 FIFO 且只保留一个 pending,
因此不要同时积压多个版本;逐个确认完成后再发下一个版本。

## 校验、产物与重跑

只读验证 job 在安装项目依赖前校验 tag/版本/main 归属,再运行:

- frozen-lockfile 安装、`check`、`test:release`;
- 显式执行 `prepublishOnly`(format、根包与子包构建、发布产物测试及示例测试)和覆盖率门槛;
- pnpm workspace-aware pack,检查每个真实 tarball 的 manifest、exports/main/types、CLI shebang/执行权限及内部依赖;
- 在临时消费项目安装五个 tarball,验证核心与三框架 adapter 的 ESM 导入以及 codegen CLI 入口;
- 确认 lifecycle 未改动 tag 的受跟踪源码。

产物以 `npm-release` Actions artifact 保留 30 天,包含五个 `.tgz` 和记录 SHA-256 的
`release.json`。发布 job 重新校验全部产物的身份、内容哈希和内部依赖,使用 npm CLI 发布同一批
tarball;不重新 pack 或构建。pnpm 负责 workspace 协议转换,不能直接用 npm 在 workspace
目录打包。声明与实际文件必须一致;通配符 exports 需要先扩展验证器。

先发布依赖,再发布消费者;当前顺序为 `erest`、三个 adapter、`@erest/gen`。每个包先查询
精确版本,已存在则跳过,只有结构化 npm `E404` 才视为缺失。认证、网络、限流、publish 错误均
终止流程。全部必需包完成后才创建或完成 GitHub Release,notes 由 GitHub 生成;tarball 保留在
Actions artifact,不额外附加 GitHub Release assets。

失败后修正外部配置并在原 tag 的 Actions run 中选择 **Re-run all jobs**。已发布的包会跳过,
继续剩余包;npm 全部成功但 GitHub 失败时,重跑只补 GitHub Release,不重复上传 npm 版本。
重新构建的文件仍须经过完整校验。npm 版本不可覆盖:若需要修改发布代码,必须使用新版本和新 tag,
不要移动既有 tag。没有自动回滚已发布包的操作。

PR CI 会运行同一套发布工具测试,并在 Node 24 下真实 pack 和安装消费验证,但不会访问发布 API。
unit test 对 npm/GitHub 发布、404/权限/网络错误及部分失败使用 mock;本地 pack/smoke 成功不代表
真实 OIDC 认证已配置或真实发布已发生。真实端到端认证只能由后台绑定后的首次 tag 发布验证。

原 `publish:all` 保留用于有 npm 发布凭据的明确手动场景;它不等于 OIDC 自动流程。
`postpublish` 不再推送 Git 分支或 tag,避免本地发布生命周期意外触发自动发布。
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"test": "npm run build && npm run build:packages && vitest run && pnpm --filter erest-example test",
"test:lib": "export ISLIB=1 && vitest run",
"test:cov": "export ISLIB=1 && vitest run --coverage",
"test:release": "node --test scripts/npm-release.test.mjs",
"mutation": "export ISLIB=1 && stryker run",
"mutation:full": "export ISLIB=1 && stryker run --force",
"mutation:incremental": "export ISLIB=1 && stryker run --incremental",
Expand All @@ -34,7 +35,6 @@
"build:packages": "pnpm --filter @erest/express --filter @erest/koa --filter @erest/leizmweb --filter @erest/gen build",
"publish:all": "pnpm publish -r --no-git-checks --access public",
"prepublishOnly": "npm run format && npm run test",
"postpublish": "npm run tag && git push && git push --tags",
"prepare": "husky"
},
"repository": {
Expand Down
7 changes: 6 additions & 1 deletion packages/erest-express/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
{
"name": "@erest/express",
"version": "3.2.2",
"version": "3.2.3",
"repository": {
"type": "git",
"url": "git+https://github.com/yourtion/node-erest.git",
"directory": "packages/erest-express"
},
"description": "Express adapter for erest",
"type": "module",
"main": "./dist/index.js",
Expand Down
7 changes: 6 additions & 1 deletion packages/erest-gen/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
{
"name": "@erest/gen",
"version": "3.2.2",
"version": "3.2.3",
"repository": {
"type": "git",
"url": "git+https://github.com/yourtion/node-erest.git",
"directory": "packages/erest-gen"
},
"description": "erest codegen CLI:从 Zod schema 生成 handler 骨架等(实验性,独立于 erest 主版本演进)",
"type": "module",
"bin": {
Expand Down
7 changes: 6 additions & 1 deletion packages/erest-koa/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
{
"name": "@erest/koa",
"version": "3.2.2",
"version": "3.2.3",
"repository": {
"type": "git",
"url": "git+https://github.com/yourtion/node-erest.git",
"directory": "packages/erest-koa"
},
"description": "Koa adapter for erest",
"type": "module",
"main": "./dist/index.js",
Expand Down
7 changes: 6 additions & 1 deletion packages/erest-leizmweb/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
{
"name": "@erest/leizmweb",
"version": "3.2.2",
"version": "3.2.3",
"repository": {
"type": "git",
"url": "git+https://github.com/yourtion/node-erest.git",
"directory": "packages/erest-leizmweb"
},
"description": "@leizm/web adapter for erest",
"type": "module",
"main": "./dist/index.js",
Expand Down
34 changes: 34 additions & 0 deletions scripts/npm-release-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { execute, output, readBundle } from './npm-release.mjs';

const [tag, destination] = process.argv.slice(2);
if (!destination) throw new Error('Usage: npm-release-smoke.mjs v<version> <bundle-directory>');
const packages = readBundle(tag, destination);
const consumer = mkdtempSync(resolve(tmpdir(), 'erest-release-consumer-'));
try {
writeFileSync(resolve(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' }));
output(execute('npm', [
'install', '--ignore-scripts', '--no-audit', '--no-fund',
...packages.map((pkg) => pkg.tarball),
], { cwd: consumer }));
writeFileSync(resolve(consumer, 'smoke.mjs'), `
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
const core = await import('erest');
assert.equal(typeof core.ERest, 'function');
assert.equal(core.z.string().parse('published package'), 'published package');
for (const name of ['@erest/express', '@erest/koa', '@erest/leizmweb']) {
const adapter = await import(name);
assert.equal(typeof adapter.createERest, 'function', name);
}
const cli = spawnSync('node_modules/.bin/erest-gen', [], { encoding: 'utf8' });
assert.equal(cli.status, 1);
assert.match(cli.stdout + cli.stderr, /erest-gen handler --from/);
console.log('All five packed packages: consumer smoke passed');
`);
console.log(output(execute(process.execPath, ['smoke.mjs'], { cwd: consumer })));
} finally {
rmSync(consumer, { recursive: true, force: true });
}
Loading
Loading