diff --git a/.changeset/pre.json b/.changeset/pre.json index 7a9b869..e37cc8d 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -1,5 +1,5 @@ { - "mode": "pre", + "mode": "exit", "tag": "beta", "initialVersions": { "@openagentpack/server": "0.0.1", diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 69f55a6..7e1e31d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,12 +1,12 @@ # Core infrastructure requires maintainer review. -/.github/ @modelstudioai/openagentpack-maintainers -/scripts/ @modelstudioai/openagentpack-maintainers -/package.json @modelstudioai/openagentpack-maintainers -/bun.lock @modelstudioai/openagentpack-maintainers +/.github/ @heimanba +/scripts/ @heimanba +/package.json @heimanba +/bun.lock @heimanba # Workspace package boundaries. -/packages/sdk/src/internal/providers/interface.ts @modelstudioai/openagentpack-maintainers -/packages/sdk/src/internal/providers/capabilities.ts @modelstudioai/openagentpack-maintainers -/packages/sdk/src/internal/providers/registry.ts @modelstudioai/openagentpack-maintainers -/packages/sdk/src/internal/providers/base-client.ts @modelstudioai/openagentpack-maintainers -/packages/sdk/src/internal/types/ @modelstudioai/openagentpack-maintainers +/packages/sdk/src/internal/providers/interface.ts @heimanba +/packages/sdk/src/internal/providers/capabilities.ts @heimanba +/packages/sdk/src/internal/providers/registry.ts @heimanba +/packages/sdk/src/internal/providers/base-client.ts @heimanba +/packages/sdk/src/internal/types/ @heimanba diff --git a/.github/workflows/prepare-beta.yml b/.github/workflows/prepare-beta.yml new file mode 100644 index 0000000..d491518 --- /dev/null +++ b/.github/workflows/prepare-beta.yml @@ -0,0 +1,81 @@ +name: Prepare Beta Release + +on: + workflow_dispatch: + inputs: + version: + description: Stable SemVer series, for example 0.1.0 + required: true + type: string + +concurrency: + group: prepare-beta-${{ inputs.version }} + cancel-in-progress: false + +jobs: + prepare: + if: github.ref_name == 'main' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.3.5" + + - name: Validate requested version + env: + VERSION: ${{ inputs.version }} + run: | + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::version must be a stable SemVer such as 0.1.0" + exit 1 + fi + + - name: Prepare isolated beta branch + env: + VERSION: ${{ inputs.version }} + run: | + branch="release/${VERSION}-beta" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin main + if git ls-remote --exit-code --heads origin "${branch}" >/dev/null 2>&1; then + git fetch origin "${branch}" + git checkout --branch "${branch}" --track "origin/${branch}" + git merge --no-edit origin/main + else + git checkout --branch "${branch}" origin/main + bunx changeset pre enter beta + fi + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Calculate next beta version + run: bun run release:version + + - name: Validate calculated beta version + id: release + run: >- + bun run scripts/release/channel.ts validate + --channel beta + --ref "release/${{ inputs.version }}-beta" + --expected "${{ inputs.version }}" + --output "$GITHUB_OUTPUT" + + - name: Commit and push beta branch + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + if git diff --quiet && git diff --cached --quiet; then + echo "::error::No unreleased changesets are available for the next beta." + exit 1 + fi + git add --all + git commit --message "chore: prepare ${VERSION}" + git push origin HEAD diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 0000000..1a19adf --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,36 @@ +name: Release PR + +on: + push: + branches: [main] + +concurrency: + group: release-pr + cancel-in-progress: false + +jobs: + version: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.3.5" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Create or update stable Release PR + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 + with: + version: bun run release:version + commit: "chore: release packages" + title: "chore: release packages" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe575c5..914ecd9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,27 +1,34 @@ -name: Release +name: Publish npm on: workflow_dispatch: - push: - branches: [main] + inputs: + channel: + description: npm channel to publish + required: true + type: choice + options: + - beta + - stable + confirm: + description: Type PUBLISH to confirm + required: true + type: string concurrency: - group: ${{ github.workflow }}-${{ github.ref }} # Publishing three packages is not atomic. Never cancel a run after the first - # package may already have reached npm; the publish script is also idempotent - # so a failed run can safely be retried. + # package may already have reached npm; exact versions are safe to retry. + group: npm-publish cancel-in-progress: false jobs: - release: - # Missing repository variables resolve to an empty string, so a newly - # created repository cannot publish until maintainers explicitly opt in. - if: vars.NPM_RELEASE_ENABLED == 'true' + publish: + if: vars.NPM_RELEASE_ENABLED == 'true' && inputs.confirm == 'PUBLISH' runs-on: ubuntu-latest + environment: npm-release permissions: contents: write id-token: write - pull-requests: write steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: @@ -43,22 +50,48 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Validate channel, branch, and package versions + id: release + run: >- + bun run scripts/release/channel.ts validate + --channel "${{ inputs.channel }}" + --ref "${{ github.ref_name }}" + --output "$GITHUB_OUTPUT" + - name: Verify release - env: - BASE: ${{ github.event.before }} run: bun run verify:release - - name: Create Release PR or Publish - uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 - with: - version: bun run release:version - publish: bun run release:publish - commit: "chore: release packages" - title: "chore: release packages" + - name: Publish packages with npm Trusted Publishing env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Bootstrap only: set this secret for the first publication, then remove - # and revoke it after npm Trusted Publishers are configured. With the - # secret absent, npm authenticates through OIDC trusted publishing. - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: "true" + run: bun run release:publish + + - name: Create immutable Git tag + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + tag="v${VERSION}" + if git rev-parse --verify --quiet "refs/tags/${tag}"; then + test "$(git rev-list -n 1 "${tag}")" = "$GITHUB_SHA" || { + echo "::error::${tag} already points to another commit" + exit 1 + } + else + git tag --annotate "${tag}" --message "${tag}" + git push origin "${tag}" + fi + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.release.outputs.version }} + CHANNEL: ${{ steps.release.outputs.channel }} + run: | + tag="v${VERSION}" + if gh release view "${tag}" >/dev/null 2>&1; then + echo "GitHub Release ${tag} already exists; nothing to do." + elif test "${CHANNEL}" = beta; then + gh release create "${tag}" --verify-tag --generate-notes --prerelease --title "${tag}" + else + gh release create "${tag}" --verify-tag --generate-notes --title "${tag}" + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 910bcf9..bf01e89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ Thanks for your interest in improving OpenAgentPack. By participating you agree |-------|-----| | Dev setup, verification, package boundaries | [docs/contributing/development.md](./docs/contributing/development.md) | | Adding a new provider | [docs/contributing/provider-development.md](./docs/contributing/provider-development.md) | -| npm release workflow | [docs/contributing/release.md](./docs/contributing/release.md) | +| npm release workflow | [English](./docs/contributing/release.md) · [简体中文](./docs/contributing/release.zh-CN.md) | | Architecture and how it works | [docs/architecture/how-it-works.md](./docs/architecture/how-it-works.md) | ## Quick start diff --git a/README.md b/README.md index 74b1ef0..6626f1f 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ npm install -g @openagentpack/cli ``` This provides the `agents` command. To run from source instead, see [Contributing](./CONTRIBUTING.md). +Beta testers can install `@openagentpack/cli@beta`; see the [release guide](./docs/contributing/release.md#what-users-install) for version pinning and switching back to stable. ## Provider support diff --git a/README.zh-CN.md b/README.zh-CN.md index 98e0b09..b2314b8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -136,6 +136,7 @@ npm install -g @openagentpack/cli ``` 安装后即可使用 `agents` 命令。若想从源码运行,见 [贡献指南](./CONTRIBUTING.md)。 +Beta 用户可以安装 `@openagentpack/cli@beta`;固定版本及切回稳定版的方法见 [发布指南](./docs/contributing/release.zh-CN.md#用户如何安装)。 ## Provider 支持 diff --git a/docs/contributing/release.md b/docs/contributing/release.md index 0b31f5e..fae72f2 100644 --- a/docs/contributing/release.md +++ b/docs/contributing/release.md @@ -1,64 +1,95 @@ -# Release +# npm releases -How `@openagentpack/sdk`, `@openagentpack/playground`, and `@openagentpack/cli` are published. This is a concise summary of the full procedure in [`packages/sdk/docs/release.md`](../../packages/sdk/docs/release.md). +OpenAgentPack publishes `@openagentpack/sdk`, `@openagentpack/playground`, and `@openagentpack/cli` as one fixed version group. Real publishing happens only in GitHub Actions; local commands can build and dry-run the tarballs but cannot publish them. -## Principles +## One-time repository setup -- npm packages publish **only** from GitHub Actions on `modelstudioai/OpenAgentPack`, via the `release.yml` workflow. -- Publishing uses **npm Trusted Publishing** (OIDC) — no long-lived write `NPM_TOKEN` is kept. -- Every release is gated by `bun run verify:release`, which runs the full check suite, package builds, `npm publish --dry-run`, and a packed-consumer smoke (importing every SDK entry point, running `agents --version`, and booting the Playground) on Node.js 20 and 24. -- Packages publish in topological order: `sdk → playground → cli`. +An organization owner must allow GitHub Actions to create pull requests in the organization Actions settings; then enable the matching option in **Repository Settings → Actions → General**. The Release PR workflow uses the repository `GITHUB_TOKEN`, not a personal token. -## First-time bootstrap +Create a GitHub Environment named `npm-release`. Add required reviewers so a maintainer must approve every npm publish job. Do not store an npm token in this environment. GitHub may expose reviewer protection only after the repository is public or on a qualifying private-repository plan. -npm only allows configuring a Trusted Publisher for a package that already exists. Before the first release: +On npmjs.com, open the settings for each of the three packages and configure the same Trusted Publisher: -1. Create a short-lived granular npm token (allow creating public packages under `@openagentpack`) and save it as the GitHub Actions secret `NPM_TOKEN`. -2. The repository Actions variable `NPM_RELEASE_ENABLED` is intentionally absent while open-source review is pending. After approval, set it to `true` and manually dispatch the Release workflow to publish the verified prereleases with the `beta` dist-tag. -3. On each of the three npm packages, configure the Trusted Publisher: Provider `GitHub Actions`, org `modelstudioai`, repo `OpenAgentPack`, workflow `release.yml`, allowed action `npm publish`. -4. Delete the `NPM_TOKEN` secret, revoke the bootstrap token, require 2FA, and disallow traditional token publishing on all three packages. -5. Publish a subsequent beta through OIDC and verify the provenance link. +| npm setting | Value | +|---|---| +| Provider | GitHub Actions | +| Organization | `modelstudioai` | +| Repository | `OpenAgentPack` | +| Workflow filename | `release.yml` | +| Environment | `npm-release` | -Requires npm CLI 11.5.1+ and Node.js 22.14+. The Release workflow uses Node.js 24, pins the npm CLI, and grants `id-token: write`. +The workflow uses a GitHub-hosted runner, Node.js 24, npm 12, `id-token: write`, and provenance. No `NPM_TOKEN` or `NODE_AUTH_TOKEN` is required. The packages must already exist before npm lets you add a Trusted Publisher. -## Day-to-day releases +Only after the Trusted Publishers and Environment reviewers are ready, create the repository Actions variable `NPM_RELEASE_ENABLED` with value `true`. Its absence is a kill switch: publish jobs are skipped even if somebody manually dispatches the workflow. -Create a changeset in a feature PR: +## Changes required in every feature PR + +Add a changeset whenever a user-visible package change is made: ```bash bun run changeset ``` -After merge to `main`, the Release workflow creates/updates a version PR, runs full verification, updates versions and changelogs, and publishes the three packages with OIDC and provenance. If a precise version already exists it is skipped, so a partially-failed release can be safely re-run. +Choose the SemVer impact and describe the change for the generated changelog. Once merged, the **Release PR** workflow creates or updates a stable version PR on `main`. It never publishes npm packages. -## Beta / pre-release +## Publish a beta -```bash -bunx changeset pre enter beta -bun run changeset -# ... merge version PR ... -bunx changeset pre exit -``` +1. In GitHub, open **Actions → Prepare Beta Release → Run workflow**. +2. Keep the workflow branch set to `main`, enter the target stable series (for example `0.1.0`), and run it. +3. The workflow creates or updates `release/0.1.0-beta`, consumes the available changesets, and commits the next version such as `0.1.0-beta.0`. +4. Open **Actions → Publish npm → Run workflow**. +5. Select `release/0.1.0-beta` in the workflow branch dropdown, choose `beta`, type `PUBLISH`, and run it. +6. Approve the `npm-release` Environment deployment after reviewing the commit and job summary. -Pre-release versions derive the npm dist-tag from the SemVer identifier (e.g. `1.0.1-beta.5` → `beta`), so `latest` is never occupied by a pre-release. +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. -## Local verification +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. -Local machines never perform a real publish — only verify what would be published: +## Publish a stable release + +1. Review and merge the automated `chore: release packages` PR. +2. Open **Actions → Publish npm → Run workflow**. +3. Select `main`, choose `stable`, type `PUBLISH`, and run it. +4. Approve the `npm-release` Environment deployment after reviewing the exact package version. + +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. + +## Local verification ```bash bun run verify:release -# or check the packed packages directly: +``` + +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: + +```bash bun run build:packages -bun run release:publish -- --dry-run --tag beta +bun run release:publish -- --dry-run ``` -The publish script temporarily rewrites `./src/*.ts` exports to `./dist/*.js`, resolves `workspace:` dependencies to real versions, and drops the root Apache-2.0 `LICENSE` into each tarball — restoring the workspace in `finally`. +The publish script blocks any real publish outside GitHub Actions. + +## What users install + +```bash +# Stable CLI (npm latest) +npm install --global @openagentpack/cli + +# Beta CLI (npm beta) +npm install --global @openagentpack/cli@beta + +# Pin or test an exact version without a global install +npx @openagentpack/cli@0.1.0-beta.0 --version + +# SDK +npm install @openagentpack/sdk +``` -## Troubleshooting +After installing the CLI, run `agents --help`. A beta user returns to stable with `npm install --global @openagentpack/cli@latest`. -- `ENEEDAUTH` — for the first release, confirm the temporary `NPM_TOKEN` secret exists and allows creating a scoped public package. After bootstrap, confirm the Trusted Publisher org/repo/workflow match exactly and the workflow has `id-token: write`. -- Incomplete package contents — run `bun run verify:release` and confirm the dry-run output includes `README.md`, `LICENSE`, `package.json`, and built `dist/`. -- Leftover `package.json`/`LICENSE` changes — the publish script restores these on normal exit; if killed forcefully, check the workspace and remove only the temporary package-local `LICENSE`. +## Recovery -See [`packages/sdk/docs/release.md`](../../packages/sdk/docs/release.md) for the full procedure and troubleshooting. +- If npm authentication fails, compare the Trusted Publisher repository, workflow filename, and Environment character-for-character with the table above. +- If a package published before another failed, rerun the same workflow from the same commit. Already-published exact versions are skipped. +- If a version tag already points at another commit, stop. Tags are immutable; investigate the repository history instead of moving or deleting the tag. +- If **Prepare Beta Release** reports no unreleased changesets, add a changeset on `main` before preparing another beta. diff --git a/docs/contributing/release.zh-CN.md b/docs/contributing/release.zh-CN.md new file mode 100644 index 0000000..0e231c0 --- /dev/null +++ b/docs/contributing/release.zh-CN.md @@ -0,0 +1,95 @@ +# npm 发布流程 + +OpenAgentPack 将 `@openagentpack/sdk`、`@openagentpack/playground` 和 `@openagentpack/cli` 作为同一版本组发布。真实发布只能在 GitHub Actions 中执行;本地只能构建和 dry-run,不能把包发布到 npm。 + +## 一次性配置 + +组织管理员需要先在组织的 Actions 设置中允许 GitHub Actions 创建 Pull Request,再在 **仓库 Settings → Actions → General** 打开对应选项。Release PR 工作流使用仓库自带的 `GITHUB_TOKEN`,不需要个人 token。 + +然后创建名为 `npm-release` 的 Environment,并配置 Required reviewers,让每次 npm 发布都必须由维护者批准。不要在这个 Environment 中保存 npm token。GitHub 可能只会在仓库公开后,或符合条件的私有仓库套餐中开放审批规则。 + +在 npmjs.com 分别进入三个包的设置,为每个包配置相同的 Trusted Publisher: + +| npm 配置项 | 值 | +|---|---| +| Provider | GitHub Actions | +| Organization | `modelstudioai` | +| Repository | `OpenAgentPack` | +| Workflow filename | `release.yml` | +| Environment | `npm-release` | + +工作流使用 GitHub-hosted runner、Node.js 24、npm 12、`id-token: write` 和 provenance,不需要 `NPM_TOKEN` 或 `NODE_AUTH_TOKEN`。npm 只允许为已存在的包配置 Trusted Publisher。 + +只有 Trusted Publisher 和 Environment 审批人全部配置完成后,才创建值为 `true` 的仓库 Actions variable `NPM_RELEASE_ENABLED`。如果这个变量不存在,即使有人手动运行工作流,发布 job 也会被跳过。 + +## 每个功能 PR 要做什么 + +只要改动会影响用户使用的 npm 包,就在 PR 中添加 changeset: + +```bash +bun run changeset +``` + +选择 SemVer 影响范围并填写 changelog。PR 合并后,**Release PR** 工作流会在 `main` 上创建或更新稳定版版本 PR,但绝不会直接发布 npm 包。 + +## 发布 Beta + +1. 打开 GitHub 的 **Actions → Prepare Beta Release → Run workflow**。 +2. workflow branch 保持 `main`,输入目标稳定版系列,例如 `0.1.0`,然后运行。 +3. 工作流会创建或更新 `release/0.1.0-beta`,消费现有 changeset,并提交下一个版本,例如 `0.1.0-beta.0`。 +4. 打开 **Actions → Publish npm → Run workflow**。 +5. 在 workflow branch 下拉框选择 `release/0.1.0-beta`,channel 选择 `beta`,输入 `PUBLISH`,然后运行。 +6. 检查提交和 job 信息后,批准 `npm-release` Environment deployment。 + +发布工作流会同时校验分支、包版本和 channel。通过后使用 npm 的 `beta` dist-tag 发布,创建不可变的 `v0.1.0-beta.N` Git tag,并创建 GitHub prerelease。 + +需要下一个 Beta 时,把修复及其 changeset 合并到 `main`,再对同一个版本系列运行 **Prepare Beta Release**。工作流会把 `main` 合入 Beta 分支并计算下一个 Beta。不要把 Beta 分支反向合并到 `main`;稳定版发布后删除它。 + +## 发布稳定版 + +1. 审核并合并自动生成的 `chore: release packages` PR。 +2. 打开 **Actions → Publish npm → Run workflow**。 +3. 选择 `main`,channel 选择 `stable`,输入 `PUBLISH`,然后运行。 +4. 检查准确版本后,批准 `npm-release` Environment deployment。 + +只有 `main` 上不含预发布后缀的 `X.Y.Z` 才能发布到 npm 的 `latest`。成功后会创建对应的不可变 Git tag 和正式 GitHub Release。发布过程可安全重试:已存在的精确包版本会被跳过。 + +## 本地验证 + +```bash +bun run verify:release +``` + +它会运行完整仓库检查、构建包、执行 `npm publish --dry-run`,并把 tarball 安装进干净的 Node.js 20/24 消费者项目。只检查 tarball 时可以运行: + +```bash +bun run build:packages +bun run release:publish -- --dry-run +``` + +发布脚本会阻止在 GitHub Actions 以外执行真实发布。 + +## 用户如何安装 + +```bash +# 稳定版 CLI(npm latest) +npm install --global @openagentpack/cli + +# Beta CLI(npm beta) +npm install --global @openagentpack/cli@beta + +# 固定或临时体验某个精确版本 +npx @openagentpack/cli@0.1.0-beta.0 --version + +# SDK +npm install @openagentpack/sdk +``` + +安装 CLI 后运行 `agents --help`。Beta 用户可用 `npm install --global @openagentpack/cli@latest` 切回稳定版。 + +## 故障恢复 + +- npm 认证失败:逐字检查 Trusted Publisher 的仓库、workflow 文件名和 Environment 是否与上表一致。 +- 部分包成功、部分包失败:从同一个提交重新运行同一个工作流,已发布的精确版本会被跳过。 +- 版本 tag 已指向其他提交:立即停止。tag 必须保持不可变,应排查历史,不能移动或删除 tag。 +- **Prepare Beta Release** 提示没有未发布 changeset:先在 `main` 添加 changeset,再准备下一个 Beta。 diff --git a/package.json b/package.json index a7b1a71..9e49a3c 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,6 @@ "release:version": "bun run scripts/release/version.ts", "release:publish": "bun run scripts/release/publish.ts", "release:preflight": "bun run verify:release", - "release": "bun run release:preflight && bun run release:publish", - "release:beta": "bun run changeset && bun run release:version && bun run build:packages && bun run release:publish -- --no-provenance --tag beta", "link": "bun run build:packages && cd packages/cli && npm link" }, "devDependencies": { diff --git a/packages/sdk/docs/release.md b/packages/sdk/docs/release.md index 619bafc..9ebc927 100644 --- a/packages/sdk/docs/release.md +++ b/packages/sdk/docs/release.md @@ -1,106 +1,3 @@ # 发布流程 -本文档说明 `@openagentpack/sdk`、`@openagentpack/playground` 和 `@openagentpack/cli` 的发布流程。 - -## 发布原则 - -- npm 包的首次 bootstrap 与后续正式发布都只从 GitHub 仓库 [`modelstudioai/OpenAgentPack`](https://github.com/modelstudioai/OpenAgentPack) 的 `.github/workflows/release.yml` 执行。 -- GitHub Actions 使用 npm Trusted Publishing(OIDC),不保存长期写权限 `NPM_TOKEN`。 -- 发布前必须通过 `bun run verify:release`,其中包含完整校验、包构建、`npm publish --dry-run`,以及真实 tarball 的外部消费者安装与运行 smoke。 -- npm 包按 `sdk → playground → cli` 的拓扑顺序发布。 - -## 首次发布与一次性配置 - -npm 只允许为**已经存在**的包配置 Trusted Publisher。三个包首次发布前,创建一个仅用于 bootstrap 的 npm granular access token(允许创建并发布 `@openagentpack/*` 公共包),保存为 GitHub Actions secret `NPM_TOKEN`。首次发布仍由 `release.yml` 执行,并显式启用 provenance;不要在本地开发机直接发布首版。 - -仓库默认不定义 Actions 变量 `NPM_RELEASE_ENABLED`,因此开源审核期间即使 workflow 文件进入 `main` 也不会发布。审核通过并完成 GitHub 保护设置后,将该变量设为 `true`,再手动 dispatch Release workflow 完成首次发布。此后保留该变量,日常发布继续由 `main` push 驱动。 - -首次发布成功后,立即执行以下步骤: - -在 npmjs.com 上分别打开三个包的设置,把 Trusted Publisher 配置为: - -| 字段 | 值 | -|---|---| -| Provider | GitHub Actions | -| Organization | `modelstudioai` | -| Repository | `OpenAgentPack` | -| Workflow filename | `release.yml` | -| Allowed action | `npm publish` | - -Trusted Publishing 要求 npm CLI 11.5.1+ 和 Node.js 22.14+。Release workflow 使用 Node.js 24 和固定版本的 npm CLI,并授予 `id-token: write`。 - -配置完成后: - -1. 从 GitHub Actions secrets 删除 `NPM_TOKEN`; -2. 在 npm 撤销 bootstrap token; -3. 将三个包的 Publishing access 设置为要求 2FA 并禁止传统 token; -4. 触发下一次 beta 发布,确认 npm 页面显示来自 `release.yml` 的 provenance。 - -此后 workflow 中空的 `NODE_AUTH_TOKEN` 不参与认证,npm 使用 GitHub OIDC。不要为日常发布重新创建长期 token。 - -## 日常发布 - -在功能 PR 中创建 changeset: - -```bash -bun run changeset -``` - -合并到 `main` 后,Release workflow 会: - -1. 创建或更新版本 PR; -2. 在版本 PR 合并后运行完整发布校验; -3. 更新版本与 changelog; -4. 使用 OIDC 发布三个 npm 包,并自动生成 provenance。 - -## 本地校验 - -本地不执行正式发布,只验证将要发布的内容: - -```bash -bun run verify:release -``` - -也可以单独检查发布包: - -```bash -bun run build:packages -bun run release:publish -- --dry-run --tag beta -``` - -发布脚本会临时完成三件事,并在 `finally` 中恢复工作区: - -- 把开发期的 `./src/*.ts` exports 重写为发布用的 `./dist/*.js`; -- 把 `workspace:` 依赖解析为对应工作区包的实际版本,避免把工作区协议发布给 npm 消费者; -- 把仓库根目录的 Apache-2.0 `LICENSE` 放入每个 npm tarball。 - -随后 package smoke 会用 `--engine-strict` 把三个 tarball 安装到临时空项目,导入 SDK 的所有公开入口、执行 `agents --version`,并启动 Playground 验证其 HTML shell。CI 在 Node.js 20 和 24 上各运行一次,因此包的 `engines` 声明和传递依赖要求必须同时成立。 - -真实发布会先查询每个精确包版本;版本已经存在时会跳过,因此三个包中途发布失败后可以安全重跑。预发布版本会从 SemVer 标识自动推导 npm dist-tag(例如 `1.0.1-beta.5` 使用 `beta`),避免占用 `latest`。Release workflow 禁止 `cancel-in-progress`,避免新 push 在发布中途取消任务。 - -## Beta / 预发布 - -```bash -bunx changeset pre enter beta -bun run changeset -``` - -版本 PR 合并后,预发布版本使用 `beta` dist-tag。预发布阶段结束时执行: - -```bash -bunx changeset pre exit -``` - -## 故障排除 - -### `ENEEDAUTH` - -首次发布时确认临时 `NPM_TOKEN` secret 存在且允许创建 scoped public package。后续发布则确认 npm Trusted Publisher 中的 organization、repository 和 workflow filename 与本页完全一致,并确认 workflow 具有 `id-token: write`。 - -### 包内容不完整 - -运行 `bun run verify:release`,检查 dry-run 输出中是否同时包含 `README.md`、`LICENSE`、`package.json` 和构建后的 `dist/`。 - -### 本地 package.json 或 LICENSE 残留改动 - -正常退出时发布脚本会自动恢复。如果进程被强制终止,检查工作区并仅移除发布脚本临时生成的包内 `LICENSE` 文件。 +三个公开 npm 包共用仓库级发布流程。维护者请阅读 [`docs/contributing/release.zh-CN.md`](../../../docs/contributing/release.zh-CN.md),其中包含 GitHub Environment、npm Trusted Publisher、Beta、稳定版及故障恢复的完整操作说明。 diff --git a/scripts/open-source.test.ts b/scripts/open-source.test.ts index 9c76001..2ae6169 100644 --- a/scripts/open-source.test.ts +++ b/scripts/open-source.test.ts @@ -129,9 +129,13 @@ describe("open-source repository invariants", () => { expect(unpinned).toEqual([]); }); - test("npm release is opt-in and cannot be cancelled mid-publish", () => { + test("npm publishing is manual, approval-gated, and cannot be cancelled mid-publish", () => { const workflow = readFileSync(resolve(root, ".github/workflows/release.yml"), "utf8"); - expect(workflow).toContain("if: vars.NPM_RELEASE_ENABLED == 'true'"); + expect(workflow).not.toMatch(/\npush:/); + expect(workflow).toContain("environment: npm-release"); + expect(workflow).toContain("vars.NPM_RELEASE_ENABLED == 'true'"); + expect(workflow).toContain("inputs.confirm == 'PUBLISH'"); + expect(workflow).toContain("id-token: write"); expect(workflow).toContain("cancel-in-progress: false"); expect(workflow).toContain("workflow_dispatch:"); }); diff --git a/scripts/release/channel.test.ts b/scripts/release/channel.test.ts new file mode 100644 index 0000000..19d6634 --- /dev/null +++ b/scripts/release/channel.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { commonReleaseVersion, validateReleaseIdentity } from "./channel.ts"; + +describe("release channel guard", () => { + test("accepts stable versions only on main", () => { + expect(validateReleaseIdentity("stable", "main", "1.2.3")).toEqual({ + channel: "stable", + version: "1.2.3", + distTag: "latest", + }); + expect(() => validateReleaseIdentity("stable", "release/1.2.3-beta", "1.2.3")).toThrow("main"); + expect(() => validateReleaseIdentity("stable", "main", "1.2.3-beta.0")).toThrow("X.Y.Z"); + }); + + test("binds beta versions to their isolated release branch", () => { + expect(validateReleaseIdentity("beta", "release/1.2.3-beta", "1.2.3-beta.4", "1.2.3")).toEqual({ + channel: "beta", + version: "1.2.3-beta.4", + distTag: "beta", + }); + expect(() => validateReleaseIdentity("beta", "main", "1.2.3-beta.0")).toThrow("release/X.Y.Z-beta"); + expect(() => validateReleaseIdentity("beta", "release/1.2.3-beta", "1.2.4-beta.0")).toThrow("1.2.3-beta.N"); + expect(() => validateReleaseIdentity("beta", "release/1.2.3-beta", "1.2.3-beta.0", "2.0.0")).toThrow( + "does not match", + ); + }); + + test("requires all fixed-group package versions to match", () => { + expect(commonReleaseVersion(["1.0.0", "1.0.0", "1.0.0"])).toBe("1.0.0"); + expect(() => commonReleaseVersion(["1.0.0", "1.0.1"])).toThrow("must match"); + expect(() => commonReleaseVersion([])).toThrow("no release package versions"); + }); +}); diff --git a/scripts/release/channel.ts b/scripts/release/channel.ts new file mode 100644 index 0000000..f79a1d2 --- /dev/null +++ b/scripts/release/channel.ts @@ -0,0 +1,83 @@ +import { appendFileSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +export type ReleaseChannel = "beta" | "stable"; + +export interface ReleaseIdentity { + channel: ReleaseChannel; + version: string; + distTag: "beta" | "latest"; +} + +const root = resolve(import.meta.dirname, "../.."); +const releasePackages = ["sdk", "playground", "cli"] as const; +const stableVersion = /^[0-9]+\.[0-9]+\.[0-9]+$/; + +export function releasePackageVersions(): string[] { + return releasePackages.map((pkg) => { + const manifest = JSON.parse(readFileSync(resolve(root, "packages", pkg, "package.json"), "utf8")) as { + version?: string; + }; + if (!manifest.version) throw new Error(`packages/${pkg}/package.json has no version`); + return manifest.version; + }); +} + +export function commonReleaseVersion(versions: readonly string[]): string { + if (versions.length === 0) throw new Error("no release package versions found"); + const unique = [...new Set(versions)]; + if (unique.length !== 1) throw new Error(`release package versions must match; found: ${unique.join(", ")}`); + return unique[0]; +} + +export function validateReleaseIdentity( + channel: ReleaseChannel, + ref: string, + version: string, + expectedBase?: string, +): ReleaseIdentity { + if (channel === "stable") { + if (ref !== "main") throw new Error(`stable releases must run from main, not ${ref}`); + if (!stableVersion.test(version)) throw new Error(`stable release version must be X.Y.Z; found ${version}`); + return { channel, version, distTag: "latest" }; + } + + const branch = /^release\/([0-9]+\.[0-9]+\.[0-9]+)-beta$/.exec(ref); + if (!branch) throw new Error(`beta releases must run from release/X.Y.Z-beta, not ${ref}`); + const base = branch[1]; + if (expectedBase && base !== expectedBase) { + throw new Error(`beta branch series ${base} does not match requested ${expectedBase}`); + } + const betaVersion = /^([0-9]+\.[0-9]+\.[0-9]+)-beta\.[0-9]+$/.exec(version); + if (!betaVersion || betaVersion[1] !== base) { + throw new Error(`beta package version must match ${base}-beta.N; found ${version}`); + } + return { channel, version, distTag: "beta" }; +} + +function option(name: string): string | undefined { + const index = process.argv.indexOf(`--${name}`); + return index === -1 ? undefined : process.argv[index + 1]; +} + +function main(): void { + if (process.argv[2] !== "validate") + throw new Error("usage: channel.ts validate --channel --ref "); + const channel = option("channel"); + const ref = option("ref"); + if (channel !== "beta" && channel !== "stable") throw new Error("--channel must be beta or stable"); + if (!ref) throw new Error("--ref is required"); + const identity = validateReleaseIdentity( + channel, + ref, + commonReleaseVersion(releasePackageVersions()), + option("expected"), + ); + const output = option("output"); + if (output) { + appendFileSync(output, `channel=${identity.channel}\nversion=${identity.version}\ndist-tag=${identity.distTag}\n`); + } + console.log(`Validated ${identity.channel} release ${identity.version} from ${ref} (npm tag: ${identity.distTag}).`); +} + +if (import.meta.main) main(); diff --git a/scripts/release/publish.test.ts b/scripts/release/publish.test.ts index 77cb65f..512ecac 100644 --- a/scripts/release/publish.test.ts +++ b/scripts/release/publish.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { inferDistTag, publishedPackageSpec, publishedVersionMatches, shouldSkipPublishedVersion } from "./publish.ts"; +import { + assertPublishEnvironment, + inferDistTag, + publishedPackageSpec, + publishedVersionMatches, + shouldSkipPublishedVersion, +} from "./publish.ts"; describe("release publish recovery", () => { test("uses an exact package version when querying npm", () => { @@ -24,4 +30,16 @@ describe("release publish recovery", () => { expect(inferDistTag("2.0.0-rc.1")).toBe("rc"); expect(inferDistTag("1.0.1")).toBeUndefined(); }); + + test("allows real publishing only inside GitHub Actions", () => { + const workflow = { + GITHUB_ACTIONS: "true", + GITHUB_WORKFLOW: "Publish npm", + GITHUB_EVENT_NAME: "workflow_dispatch", + }; + expect(() => assertPublishEnvironment(false, {})).toThrow("GitHub Actions"); + expect(() => assertPublishEnvironment(false, { ...workflow, GITHUB_WORKFLOW: "CI" })).toThrow("GitHub Actions"); + expect(() => assertPublishEnvironment(false, workflow)).not.toThrow(); + expect(() => assertPublishEnvironment(true, {})).not.toThrow(); + }); }); diff --git a/scripts/release/publish.ts b/scripts/release/publish.ts index 22fe829..95fbeba 100644 --- a/scripts/release/publish.ts +++ b/scripts/release/publish.ts @@ -1,7 +1,7 @@ /** * 按拓扑顺序发布所有包到 npm * 发布前自动将 exports 从 ./src/*.ts 重写为 ./dist/*.js,发布后恢复 - * 用法: bun run scripts/release/publish.ts [--dry-run] [--tag ] + * 用法: bun run scripts/release/publish.ts [--dry-run] */ import { existsSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; @@ -28,6 +28,30 @@ export function shouldSkipPublishedVersion(dryRun: boolean, published: boolean): return !dryRun && published; } +export interface PublishEnvironment { + GITHUB_ACTIONS?: string; + GITHUB_WORKFLOW?: string; + GITHUB_EVENT_NAME?: string; +} + +export function assertPublishEnvironment( + dryRun: boolean, + environment: PublishEnvironment = { + GITHUB_ACTIONS: process.env.GITHUB_ACTIONS, + GITHUB_WORKFLOW: process.env.GITHUB_WORKFLOW, + GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME, + }, +): void { + if ( + !dryRun && + (environment.GITHUB_ACTIONS !== "true" || + environment.GITHUB_WORKFLOW !== "Publish npm" || + environment.GITHUB_EVENT_NAME !== "workflow_dispatch") + ) { + throw new Error("Real npm publishing is allowed only from the GitHub Actions Publish npm workflow."); + } +} + export function inferDistTag(version: string): string | undefined { const prerelease = version.match(/^[0-9]+\.[0-9]+\.[0-9]+-([0-9A-Za-z-]+)(?:\.|$)/); return prerelease?.[1]; @@ -117,11 +141,9 @@ function trackedFileContents(path: string): string | undefined { function main(): number { const args = process.argv.slice(2); const dryRun = args.includes("--dry-run"); - const noProvenance = args.includes("--no-provenance"); - const tagIdx = args.indexOf("--tag"); - const tag = tagIdx !== -1 ? args[tagIdx + 1] : undefined; + assertPublishEnvironment(dryRun); - console.log(`Publishing packages${dryRun ? " (dry-run)" : ""}${tag ? ` with tag: ${tag}` : ""}...\n`); + console.log(`Publishing packages${dryRun ? " (dry-run)" : ""}...\n`); const versions = workspaceVersions(); for (const pkg of PACKAGES) { @@ -136,10 +158,10 @@ function main(): number { continue; } - const publishTag = tag ?? inferDistTag(manifest.version); + const publishTag = inferDistTag(manifest.version); const cmd = ["npm", "publish", "--access", "public"]; if (dryRun) cmd.push("--dry-run"); - else if (!noProvenance) cmd.push("--provenance"); + else cmd.push("--provenance"); if (publishTag) cmd.push("--tag", publishTag); console.log(`\n--- Publishing ${manifest.name} ---`);