Skip to content

Commit ae7721c

Browse files
committed
feat: certify npm releases across operating systems [policy]
Change-Id: I1826efe4bf8457db7fd976b779aade310a8d2c2b
1 parent fcc4641 commit ae7721c

14 files changed

Lines changed: 503 additions & 22 deletions

File tree

.changeset/calm-spiders-test.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@openagentpack/sdk": minor
3+
"@openagentpack/cli": minor
4+
"@openagentpack/playground": minor
5+
---
6+
7+
Require maintained Node.js releases (22 or newer) and certify published packages on Linux, Windows, and macOS before creating a GitHub Release.

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ jobs:
121121
strategy:
122122
fail-fast: false
123123
matrix:
124-
node: [20, 24]
124+
node: [22, 24]
125125
steps:
126126
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
127127
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Package compatibility canary
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
- cron: "41 5 * * 3"
7+
8+
concurrency:
9+
group: package-compatibility-canary
10+
cancel-in-progress: true
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
node-current:
17+
name: npm latest / ${{ matrix.os }} / Node 26
18+
runs-on: ${{ matrix.os }}
19+
strategy:
20+
fail-fast: false
21+
matrix:
22+
os: [ubuntu-latest, windows-latest, macos-latest]
23+
steps:
24+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
25+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
26+
with:
27+
bun-version: "1.3.5"
28+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
29+
with:
30+
node-version: "26"
31+
package-manager-cache: false
32+
- run: npm install --global npm@12.0.1
33+
- name: Install and execute npm latest
34+
run: bun scripts/release/consumer-smoke.ts smoke --version latest

.github/workflows/release.yml

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,14 @@ concurrency:
2222
cancel-in-progress: false
2323

2424
jobs:
25-
publish:
25+
preflight:
2626
if: vars.NPM_RELEASE_ENABLED == 'true' && inputs.confirm == 'PUBLISH'
2727
runs-on: ubuntu-latest
28-
environment: npm-release
28+
outputs:
29+
channel: ${{ steps.release.outputs.channel }}
30+
version: ${{ steps.release.outputs.version }}
2931
permissions:
30-
contents: write
31-
id-token: write
32+
contents: read
3233
steps:
3334
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
3435
with:
@@ -63,14 +64,46 @@ jobs:
6364
- name: Verify release
6465
run: bun run verify:release
6566

67+
publish:
68+
needs: preflight
69+
runs-on: ubuntu-latest
70+
environment: npm-release
71+
permissions:
72+
contents: write
73+
id-token: write
74+
steps:
75+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
76+
with:
77+
fetch-depth: 0
78+
79+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
80+
with:
81+
bun-version: "1.3.5"
82+
83+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
84+
with:
85+
node-version: "24"
86+
registry-url: https://registry.npmjs.org
87+
package-manager-cache: false
88+
89+
- name: Install trusted-publishing npm CLI
90+
run: |
91+
npm install --global npm@12.0.1
92+
npm --version
93+
94+
- name: Build publishable packages
95+
run: |
96+
bun install --frozen-lockfile
97+
bun run build:packages
98+
6699
- name: Publish packages with npm Trusted Publishing
67100
env:
68101
NPM_CONFIG_PROVENANCE: "true"
69102
run: bun run release:publish
70103

71104
- name: Create immutable Git tag
72105
env:
73-
VERSION: ${{ steps.release.outputs.version }}
106+
VERSION: ${{ needs.preflight.outputs.version }}
74107
run: |
75108
tag="v${VERSION}"
76109
if git rev-parse --verify --quiet "refs/tags/${tag}"; then
@@ -85,11 +118,62 @@ jobs:
85118
git push origin "${tag}"
86119
fi
87120
121+
registry-ready:
122+
needs: [preflight, publish]
123+
runs-on: ubuntu-latest
124+
permissions:
125+
contents: read
126+
steps:
127+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
128+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
129+
with:
130+
bun-version: "1.3.5"
131+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
132+
with:
133+
node-version: "24"
134+
package-manager-cache: false
135+
- run: npm install --global npm@12.0.1
136+
- name: Wait for the fixed release group to reach npm
137+
run: bun scripts/release/consumer-smoke.ts wait --version "${{ needs.preflight.outputs.version }}"
138+
139+
post-release-consumer:
140+
name: Consumer / ${{ matrix.os }} / Node ${{ matrix.node }}
141+
needs: [preflight, registry-ready]
142+
runs-on: ${{ matrix.os }}
143+
permissions:
144+
contents: read
145+
strategy:
146+
fail-fast: false
147+
matrix:
148+
os: [ubuntu-latest, windows-latest, macos-latest]
149+
node: [22, 24]
150+
steps:
151+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
152+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
153+
with:
154+
bun-version: "1.3.5"
155+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
156+
with:
157+
node-version: ${{ matrix.node }}
158+
package-manager-cache: false
159+
- run: npm install --global npm@12.0.1
160+
- name: Install and execute the public packages
161+
run: bun scripts/release/consumer-smoke.ts smoke --version "${{ needs.preflight.outputs.version }}"
162+
163+
finalize-release:
164+
needs: [preflight, publish, post-release-consumer]
165+
runs-on: ubuntu-latest
166+
permissions:
167+
contents: write
168+
steps:
169+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
170+
with:
171+
fetch-depth: 0
88172
- name: Create GitHub Release
89173
env:
90174
GH_TOKEN: ${{ github.token }}
91-
VERSION: ${{ steps.release.outputs.version }}
92-
CHANNEL: ${{ steps.release.outputs.channel }}
175+
VERSION: ${{ needs.preflight.outputs.version }}
176+
CHANNEL: ${{ needs.preflight.outputs.channel }}
93177
run: |
94178
tag="v${VERSION}"
95179
if gh release view "${tag}" >/dev/null 2>&1; then

docs/contributing/release.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Choose the SemVer impact and describe the change for the generated changelog. On
4141
5. Select `release/0.1.0-beta` in the workflow branch dropdown, choose `beta`, type `PUBLISH`, and run it.
4242
6. Approve the `npm-release` Environment deployment after reviewing the commit and job summary.
4343

44-
The publish workflow validates that the selected branch, package version, and channel agree. It publishes with the npm `beta` dist-tag, creates an immutable `v0.1.0-beta.N` tag, and creates a GitHub prerelease.
44+
The publish workflow validates that the selected branch, package version, and channel agree. It publishes with the npm `beta` dist-tag and creates the immutable `v0.1.0-beta.N` tag. It then installs that exact version from the public npm registry on Linux, Windows, and macOS under Node.js 22 and 24. The GitHub prerelease is created only after all six consumer jobs pass.
4545

4646
For another beta, merge fixes and their changesets into `main`, then rerun **Prepare Beta Release** for the same series. The workflow merges `main` into the beta branch and calculates the next beta. Never merge the beta branch back into `main`; delete it after the stable release.
4747

@@ -52,15 +52,29 @@ For another beta, merge fixes and their changesets into `main`, then rerun **Pre
5252
3. Select `main`, choose `stable`, type `PUBLISH`, and run it.
5353
4. Approve the `npm-release` Environment deployment after reviewing the exact package version.
5454

55-
Only a clean `X.Y.Z` version on `main` can publish to npm's `latest` tag. A successful run creates the matching immutable Git tag and GitHub Release. Publishing is idempotent: packages whose exact version already exists are skipped, so a partially failed run can be retried.
55+
Only a clean `X.Y.Z` version on `main` can publish to npm's `latest` tag. Publishing creates the matching immutable Git tag, waits for all three packages to become visible in the public registry, and runs the six-job consumer matrix. The GitHub Release is the final certification step and is created only after that matrix passes. Publishing is idempotent: packages whose exact version already exists are skipped, so a partially failed publish can be retried from the same commit.
56+
57+
## Post-release consumer verification
58+
59+
The release is not considered complete when `npm publish` returns successfully. The workflow must also prove that a clean external consumer can install and execute the public packages from npm:
60+
61+
| Runner | Required Node.js versions |
62+
|---|---|
63+
| Ubuntu | 22, 24 |
64+
| Windows | 22, 24 |
65+
| macOS | 22, 24 |
66+
67+
Each matrix job installs the exact version independently, verifies npm registry signatures and provenance, loads every public SDK entry point, runs CLI help/version/offline validation, and starts the Playground over HTTP. It does not consume workspace packages, local tarballs, or build artifacts from the publish job.
68+
69+
The `Package compatibility canary` workflow also installs npm's `latest` release on all three operating systems under Node.js 26 every Wednesday. Canary failures do not affect an existing release, but they should be triaged before Node.js 26 becomes part of the supported LTS matrix.
5670

5771
## Local verification
5872

5973
```bash
6074
bun run verify:release
6175
```
6276

63-
This runs the full repository checks, builds the packages, performs `npm publish --dry-run`, and installs the packed artifacts into clean Node.js 20 and 24 consumer projects. To inspect only the package tarballs:
77+
This runs the full repository checks, builds the packages, performs a registry-independent `npm pack --dry-run`, and installs the packed artifacts as a clean external consumer. CI runs that package smoke under Node.js 22 and 24. To inspect only the package tarballs:
6478

6579
```bash
6680
bun run build:packages
@@ -91,5 +105,7 @@ After installing the CLI, run `agents --help`. A beta user returns to stable wit
91105

92106
- If npm authentication fails, compare the Trusted Publisher repository, workflow filename, and Environment character-for-character with the table above.
93107
- If a package published before another failed, rerun the same workflow from the same commit. Already-published exact versions are skipped.
108+
- If all packages published but a post-release consumer job fails, keep the immutable tag, do not unpublish or move the tag, and do not create the GitHub Release. Fix the compatibility issue and publish a new patch version; npm package versions cannot be overwritten.
109+
- Registry visibility is retried for five minutes before it is classified as a release failure. Retry the same workflow only when npm propagation, rather than package compatibility, was the cause.
94110
- If a version tag already points at another commit, stop. Tags are immutable; investigate the repository history instead of moving or deleting the tag.
95111
- If **Prepare Beta Release** reports no unreleased changesets, add a changeset on `main` before preparing another beta.

docs/contributing/release.zh-CN.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ bun run changeset
4141
5. 在 workflow branch 下拉框选择 `release/0.1.0-beta`,channel 选择 `beta`,输入 `PUBLISH`,然后运行。
4242
6. 检查提交和 job 信息后,批准 `npm-release` Environment deployment。
4343

44-
发布工作流会同时校验分支、包版本和 channel。通过后使用 npm 的 `beta` dist-tag 发布,创建不可变的 `v0.1.0-beta.N` Git tag,并创建 GitHub prerelease。
44+
发布工作流会同时校验分支、包版本和 channel。通过后使用 npm 的 `beta` dist-tag 发布,并创建不可变的 `v0.1.0-beta.N` Git tag。随后,它会在 Linux、Windows、macOS 的 Node.js 22 和 24 环境中,从公共 npm registry 安装该精确版本。只有六个消费者 job 全部通过,才创建 GitHub prerelease。
4545

4646
需要下一个 Beta 时,把修复及其 changeset 合并到 `main`,再对同一个版本系列运行 **Prepare Beta Release**。工作流会把 `main` 合入 Beta 分支并计算下一个 Beta。不要把 Beta 分支反向合并到 `main`;稳定版发布后删除它。
4747

@@ -52,15 +52,29 @@ bun run changeset
5252
3. 选择 `main`,channel 选择 `stable`,输入 `PUBLISH`,然后运行。
5353
4. 检查准确版本后,批准 `npm-release` Environment deployment。
5454

55-
只有 `main` 上不含预发布后缀的 `X.Y.Z` 才能发布到 npm 的 `latest`。成功后会创建对应的不可变 Git tag 和正式 GitHub Release。发布过程可安全重试:已存在的精确包版本会被跳过。
55+
只有 `main` 上不含预发布后缀的 `X.Y.Z` 才能发布到 npm 的 `latest`。发布后会创建对应的不可变 Git tag,等待三个包在公共 registry 中全部可见,再执行六个消费者 job。正式 GitHub Release 是最后的发布认证,只有矩阵全部通过后才创建。发布过程可安全重试:已经存在的精确包版本会被跳过。
56+
57+
## 发布后消费者验证
58+
59+
`npm publish` 返回成功并不代表 release 已完成。工作流还必须证明一个全新的外部消费者可以从 npm 安装并执行公开包:
60+
61+
| Runner | 必测 Node.js 版本 |
62+
|---|---|
63+
| Ubuntu | 22、24 |
64+
| Windows | 22、24 |
65+
| macOS | 22、24 |
66+
67+
每个矩阵 job 都会独立安装精确版本,验证 npm registry 签名和 provenance,加载 SDK 的所有公开入口,执行 CLI help/version/离线 validate,并通过 HTTP 启动 Playground。验证过程不会使用 workspace 包、本地 tarball 或 publish job 的构建产物。
68+
69+
`Package compatibility canary` 工作流还会在每周三使用三个操作系统和 Node.js 26 验证 npm 的 `latest` 版本。Canary 失败不会影响已经完成的 release,但应在 Node.js 26 进入正式 LTS 支持矩阵前完成处置。
5670

5771
## 本地验证
5872

5973
```bash
6074
bun run verify:release
6175
```
6276

63-
它会运行完整仓库检查、构建包、执行 `npm publish --dry-run`并把 tarball 安装进干净的 Node.js 20/24 消费者项目。只检查 tarball 时可以运行:
77+
它会运行完整仓库检查、构建包、执行不依赖 registry 版本状态的 `npm pack --dry-run`并以全新外部消费者的方式安装 tarball。CI 会在 Node.js 22 和 24 下执行这项 package smoke。只检查 tarball 时可以运行:
6478

6579
```bash
6680
bun run build:packages
@@ -91,5 +105,7 @@ npm install @openagentpack/sdk
91105

92106
- npm 认证失败:逐字检查 Trusted Publisher 的仓库、workflow 文件名和 Environment 是否与上表一致。
93107
- 部分包成功、部分包失败:从同一个提交重新运行同一个工作流,已发布的精确版本会被跳过。
108+
- 所有包都已发布,但发布后消费者 job 失败:保留不可变 tag,不执行 unpublish、不移动 tag,也不创建 GitHub Release。修复兼容性问题后发布新的 patch 版本;npm 上的版本不能被覆盖。
109+
- registry 可见性会重试五分钟,之后才判定 release 失败。只有确认失败原因是 npm 同步延迟而不是包兼容性时,才从同一个提交重试。
94110
- 版本 tag 已指向其他提交:立即停止。tag 必须保持不可变,应排查历史,不能移动或删除 tag。
95111
- **Prepare Beta Release** 提示没有未发布 changeset:先在 `main` 添加 changeset,再准备下一个 Beta。

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"url": "https://github.com/modelstudioai/OpenAgentPack/issues"
2121
},
2222
"engines": {
23-
"node": ">=20"
23+
"node": ">=22"
2424
},
2525
"type": "module",
2626
"main": "./dist/src/program.js",

packages/playground/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"url": "https://github.com/modelstudioai/OpenAgentPack/issues"
2020
},
2121
"engines": {
22-
"node": ">=20"
22+
"node": ">=22"
2323
},
2424
"type": "module",
2525
"bin": {

packages/sdk/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"url": "https://github.com/modelstudioai/OpenAgentPack/issues"
2121
},
2222
"engines": {
23-
"node": ">=20"
23+
"node": ">=22"
2424
},
2525
"type": "module",
2626
"main": "./dist/index.js",

scripts/open-source.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ describe("open-source repository invariants", () => {
5252
expect(changesets.fixed).toContainEqual(manifests.map((manifest) => manifest.name));
5353
});
5454

55+
test("public packages require a maintained Node.js baseline", () => {
56+
for (const pkg of ["sdk", "playground", "cli"]) {
57+
const manifest = JSON.parse(readFileSync(resolve(root, `packages/${pkg}/package.json`), "utf8")) as {
58+
engines?: { node?: string };
59+
};
60+
expect(manifest.engines?.node).toBe(">=22");
61+
}
62+
});
63+
5564
test("published packages have no runtime dependency on private workspaces", () => {
5665
const packageFiles = publicWorktreeFilesMatching(
5766
(file) => isVisiblePath(file) && (file === "package.json" || file.endsWith("/package.json")),
@@ -140,6 +149,15 @@ describe("open-source repository invariants", () => {
140149
expect(workflow).toContain("workflow_dispatch:");
141150
expect(workflow).toContain('git config user.name "github-actions[bot]"');
142151
expect(workflow).toContain("github-actions[bot]@users.noreply.github.com");
152+
expect(workflow).toContain("registry-ready:");
153+
expect(workflow).toContain("post-release-consumer:");
154+
expect(workflow).toContain("os: [ubuntu-latest, windows-latest, macos-latest]");
155+
expect(workflow).toContain("node: [22, 24]");
156+
expect(workflow).toContain("needs: [preflight, publish, post-release-consumer]");
157+
const consumerGate = workflow.indexOf("post-release-consumer:");
158+
const finalize = workflow.indexOf("finalize-release:");
159+
expect(consumerGate).toBeGreaterThan(-1);
160+
expect(finalize).toBeGreaterThan(consumerGate);
143161
});
144162

145163
test("public worktree has no high-confidence secrets or internal machine references", () => {

0 commit comments

Comments
 (0)