From 849fce068c093353e1effacf26bc28077d1c8171 Mon Sep 17 00:00:00 2001 From: Taylan Pince Date: Wed, 14 Oct 2026 11:02:38 +0200 Subject: [PATCH 1/5] ci: stage npm releases via changesets and OIDC trusted publishing Mirror the staged publishing flow from oms-wallet-typescript-sdk: - Release workflow opens a changesets Release / Deploy PR; merging it runs the release gates, packs every unpublished package with pnpm and sends the tarballs to `npm stage publish` (OIDC, no NPM_TOKEN). A maintainer approves each stage with 2FA. - Workflow-dispatch snapshot path stages all packages under a non-semver dist-tag. - Staging script orders packages dependencies-first (no fixed group here) and is covered by node:test suites run in CI. - Add repository.directory to every published package (required for provenance), .nvmrc (Node 22), changelog-github, shared changeset check. - scripts/configure-npm-trust.sh registers the workflow as a stage-only trusted publisher for all master and v2 packages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qDHk3SaPaDGpPTU1jjLpz --- .changeset/config.json | 9 +- .../actions/install-dependencies/action.yml | 2 +- .github/workflows/changeset-check-trigger.yml | 13 ++ .github/workflows/npm-release-trigger.yml | 99 +++++++++ .github/workflows/tests.yml | 1 + .nvmrc | 1 + PUBLISHING.md | 94 ++++++++ package.json | 5 +- packages/services/api/package.json | 6 +- packages/services/builder/package.json | 6 +- packages/services/guard/package.json | 6 +- .../services/identity-instrument/package.json | 5 + packages/services/indexer/package.json | 6 +- packages/services/marketplace/package.json | 6 +- packages/services/metadata/package.json | 6 +- packages/services/relayer/package.json | 6 +- packages/services/userdata/package.json | 6 +- packages/utils/abi/package.json | 6 +- packages/wallet/core/package.json | 5 + packages/wallet/dapp-client/package.json | 5 + packages/wallet/primitives/package.json | 5 + packages/wallet/wdk/package.json | 5 + pnpm-lock.yaml | 31 +++ scripts/configure-npm-trust.sh | 39 ++++ scripts/prepare-npm-snapshot.mjs | 107 +++++++++ scripts/prepare-npm-snapshot.test.mjs | 39 ++++ scripts/stage-npm-packages.mjs | 206 ++++++++++++++++++ scripts/stage-npm-packages.test.mjs | 90 ++++++++ 28 files changed, 802 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/changeset-check-trigger.yml create mode 100644 .github/workflows/npm-release-trigger.yml create mode 100644 .nvmrc create mode 100644 PUBLISHING.md create mode 100755 scripts/configure-npm-trust.sh create mode 100644 scripts/prepare-npm-snapshot.mjs create mode 100644 scripts/prepare-npm-snapshot.test.mjs create mode 100644 scripts/stage-npm-packages.mjs create mode 100644 scripts/stage-npm-packages.test.mjs diff --git a/.changeset/config.json b/.changeset/config.json index 4f8345f464..46f38f1a2a 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,10 +1,15 @@ { "$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json", - "changelog": "@changesets/cli/changelog", + "changelog": [ + "@changesets/changelog-github", + { + "repo": "0xsequence/sequence.js" + } + ], "commit": false, "fixed": [], "linked": [], - "access": "restricted", + "access": "public", "baseBranch": "master", "updateInternalDependencies": "patch", "ignore": ["@0xsequence/wallet-primitives-cli", "docs", "web"] diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml index ca81d1a40a..1eeeaf9355 100644 --- a/.github/actions/install-dependencies/action.yml +++ b/.github/actions/install-dependencies/action.yml @@ -7,7 +7,7 @@ runs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version-file: .nvmrc - name: Setup PNPM uses: pnpm/action-setup@v3 diff --git a/.github/workflows/changeset-check-trigger.yml b/.github/workflows/changeset-check-trigger.yml new file mode 100644 index 0000000000..03230ded0e --- /dev/null +++ b/.github/workflows/changeset-check-trigger.yml @@ -0,0 +1,13 @@ +name: Changeset check + +on: + pull_request: + branches: [master] + +permissions: + contents: read + pull-requests: write + +jobs: + check: + uses: 0xPolygon/pipelines/.github/workflows/apps-changeset-check.yml@main diff --git a/.github/workflows/npm-release-trigger.yml b/.github/workflows/npm-release-trigger.yml new file mode 100644 index 0000000000..1ade87bbf9 --- /dev/null +++ b/.github/workflows/npm-release-trigger.yml @@ -0,0 +1,99 @@ +name: Release + +# Staged npm publishing via changesets + OIDC trusted publishing. See PUBLISHING.md. +# The v2 branch has its own copy of this file (same name, so the same npm trusted publisher +# applies) that stages under the `v2-legacy` dist-tag. + +on: + push: + branches: [master] + workflow_dispatch: + inputs: + snapshot_tag: + description: 'npm dist-tag for snapshot (e.g. canary, pre-3.0.14). Non-semver values only.' + required: true + default: 'snapshot' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + id-token: write # required for npm OIDC trusted publishing + steps: + - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + id: app-token + with: + app-id: ${{ secrets.CHANGESET_RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.CHANGESET_RELEASE_BOT_APP_PRIVATE_KEY }} + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + cache: pnpm + registry-url: 'https://registry.npmjs.org' + - name: Install npm with staged-publishing support + run: npm install --global npm@11.15.0 + # The release gates run the wallet tests, which need a local anvil fork. + - uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1 + with: + version: v1.5.0 + # The release PR changes package manifests without regenerating the lockfile. Link + # co-released workspace packages locally so the not-yet-public versions still install. + - run: pnpm install --no-frozen-lockfile --config.link-workspace-packages=true + - name: Create release PR or stage versioned packages + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 + with: + title: 'changesets: Release / Deploy' + commit: 'release: version packages' + commitMode: github-api + # When a merged release PR has consumed all changesets, this command packs with + # pnpm (rewriting workspace: ranges) and stages the tarballs with npm. Its + # `New tag:` output lets the action create package tags and GitHub Releases. + publish: pnpm run ci:stage-release + createGithubReleases: true + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + + snapshot: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # required for npm OIDC trusted publishing + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + cache: pnpm + registry-url: 'https://registry.npmjs.org' + - name: Install npm with staged-publishing support + run: npm install --global npm@11.15.0 + - uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1 + with: + version: v1.5.0 + - run: pnpm install --no-frozen-lockfile --config.link-workspace-packages=true + - name: Version snapshot + env: + GITHUB_TOKEN: ${{ github.token }} + SNAPSHOT_TAG: ${{ inputs.snapshot_tag }} + run: node scripts/prepare-npm-snapshot.mjs + - name: Stage snapshot packages + env: + NPM_STAGE_CREATE_GIT_TAGS: 'false' + NPM_STAGE_TAG: ${{ inputs.snapshot_tag }} + run: pnpm run ci:stage-release diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bb22f4c721..1be53cad4d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,6 +36,7 @@ jobs: - name: Start Anvil in background run: anvil --fork-url https://nodes.sequence.app/arbitrum & - run: pnpm build + - run: pnpm test:release - run: pnpm test # NOTE: if you'd like to see example of how to run diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000..2bd5a0a98a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000000..7a88152543 --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,94 @@ +# Publishing + +> **Publishing is CI-only. Never publish or stage from a local machine.** Do not run `changeset version`, +> `changeset publish`, `npm publish`, `npm stage publish`, `pnpm publish`, or the internal `ci:stage-release` +> script yourself. A local release bypasses the signed release commit and npm OIDC trusted publishing. Your job as +> a contributor is to land a changeset (see "Day-to-day") and let CI prepare the release; an npm maintainer +> performs the final 2FA approval. + +Releases are driven by [changesets](https://github.com/changesets/changesets). Packages are versioned +independently (there is no `fixed` group); `updateInternalDependencies: patch` bumps dependents when a workspace +dependency is released. + +## Day-to-day: add a changeset + +Every PR that changes files inside a published package must include a changeset. From the repo root: + +```bash +pnpm exec changeset +``` + +Pick the packages and bump type and write a user-facing changelog entry. For changes with no consumer impact +(internal refactors, chores), record an empty changeset instead: + +```bash +pnpm exec changeset add --empty +``` + +The `Changeset check` CI job fails a PR that touches a package without one. + +## Release flow (automated) + +1. Merging PRs with changesets into `master` triggers the `Release` workflow + (`.github/workflows/npm-release-trigger.yml`), which opens or updates a **`changesets: Release / Deploy`** PR. + That PR applies the pending changesets, bumps package versions, and updates each `CHANGELOG.md`. +2. Review and merge that Release PR. +3. On merge, the same workflow uses npm `11.15.0` and runs the release gates (build, typecheck, lint, release + script tests, and the full test suite against an anvil fork). It then packs every publishable package whose + version is not yet on npm with pnpm, which rewrites `workspace:` ranges to real versions, and fails if any + `workspace:` range remains. +4. The workflow sends the tarballs to `npm stage publish` under the `latest` dist-tag via **OIDC trusted + publishing** (no `NPM_TOKEN`). After each package stages, Changesets pushes its package tag (e.g. + `@0xsequence/wallet-core@3.0.14`) and creates a GitHub Release from its `CHANGELOG.md` entry. +5. An npm maintainer inspects and approves every staged package with 2FA. **Approve in the order listed in the + workflow run summary** (dependencies first, e.g. `wallet-primitives` before `wallet-core` before `wallet-wdk`) + so each approved package's dependencies are already installable. + +Version-bump commits are signed by GitHub's GPG key (`commitMode: github-api`). The package tags and GitHub +Releases record that CI staged the exact release artifacts; they do not mean npm approval has finished. + +### Approve a staged release + +On npmjs.com, open each package's **Staged Packages** tab and approve, or from the CLI: + +```bash +npm stage list @0xsequence/wallet-core +npm stage view +npm stage approve # prompts for 2FA +``` + +If one tarball stages and a later tarball fails, do not merge another release. The workflow creates the +successful packages' tags before failing; after fixing the cause, rerun that workflow and the release helper will +skip the tagged stages. If staging succeeded but its package tag was not created, reject that partial stage with +`npm stage reject ` before retrying because staging reserves the package version. + +## Prerelease / snapshot builds + +To prepare a throwaway prerelease under a non-`latest` npm dist-tag, manually dispatch the **Release** workflow — +this is still CI, not a local release: + +1. GitHub → **Actions** → **Release** → **Run workflow** (on `master` or a feature branch). +2. Set the **`snapshot_tag`** input to a non-semver dist-tag (e.g. `canary`, `pre-3.0.14`). + +The workflow versions **every** publishable package as `0.0.0--` on the runner (never committed), +so snapshot packages only depend on each other, then packs and stages them. The snapshot path skips git tags and +GitHub Releases, so **`snapshot_tag` must be a lowercase, non-version tag other than `latest` or `v2-legacy`**. An +npm maintainer must approve the stages before consumers can install them with +`pnpm add @0xsequence/wallet-wdk@`. + +## v2 (`v2-legacy`) + +The `v2` branch has its own `npm-release-trigger.yml` with the same flow, except it stages under the +**`v2-legacy`** dist-tag so v2 releases never move `latest` (which belongs to v3). The workflow file name is the +same on both branches, so one trusted publisher configuration per package covers both. + +## One-time setup (admins) + +- **npm trusted publishers:** run `./scripts/configure-npm-trust.sh` (needs npm ≥ 11.15, an npm login with + publish rights, and 2FA). It registers `0xsequence/sequence.js` + `npm-release-trigger.yml` as a **stage-only** + trusted publisher (`--allow-stage-publish`, no `--allow-publish`) for every package on `master` and `v2`, so CI + can stage but never publish directly. Afterwards, set each package's publishing access to "Require two-factor + authentication and disallow tokens" and revoke old automation tokens. +- **GitHub:** install the changesets release bot GitHub App on this repository and add its + `CHANGESET_RELEASE_BOT_APP_ID` and `CHANGESET_RELEASE_BOT_APP_PRIVATE_KEY` as repository (or organization) + secrets. Commits from the app's token trigger CI on the release PR, which the default `GITHUB_TOKEN` would not. diff --git a/package.json b/package.json index 584df72a68..90f29270f9 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,12 @@ "test:anvil": "anvil --fork-url https://nodes.sequence.app/arbitrum", "clean": "turbo clean", "deps:lint": "syncpack lint --dependency-types prod,dev", - "deps:fix": "syncpack fix" + "deps:fix": "syncpack fix", + "test:release": "node --test scripts/*.test.mjs", + "ci:stage-release": "pnpm build && pnpm typecheck && pnpm lint && pnpm test:release && pnpm test:local && node scripts/stage-npm-packages.mjs" }, "devDependencies": { + "@changesets/changelog-github": "^1.0.1", "@changesets/cli": "^2.29.8", "lefthook": "^2.1.6", "prettier": "^3.8.3", diff --git a/packages/services/api/package.json b/packages/services/api/package.json index 03f54e7c13..a742f80449 100644 --- a/packages/services/api/package.json +++ b/packages/services/api/package.json @@ -2,7 +2,11 @@ "name": "@0xsequence/api", "version": "3.0.13", "description": "api sub-package for Sequence", - "repository": "https://github.com/0xsequence/sequence.js/tree/master/packages/services/api", + "repository": { + "type": "git", + "url": "git+https://github.com/0xsequence/sequence.js.git", + "directory": "packages/services/api" + }, "author": "Sequence Platforms ULC", "license": "Apache-2.0", "publishConfig": { diff --git a/packages/services/builder/package.json b/packages/services/builder/package.json index 57087ac034..3c032ec3be 100644 --- a/packages/services/builder/package.json +++ b/packages/services/builder/package.json @@ -2,7 +2,11 @@ "name": "@0xsequence/builder", "version": "3.0.13", "description": "builder sub-package for Sequence", - "repository": "https://github.com/0xsequence/sequence.js/tree/master/packages/services/builder", + "repository": { + "type": "git", + "url": "git+https://github.com/0xsequence/sequence.js.git", + "directory": "packages/services/builder" + }, "author": "Sequence Platforms ULC", "license": "Apache-2.0", "publishConfig": { diff --git a/packages/services/guard/package.json b/packages/services/guard/package.json index 03decfa29b..996ff90656 100644 --- a/packages/services/guard/package.json +++ b/packages/services/guard/package.json @@ -2,7 +2,11 @@ "name": "@0xsequence/guard", "version": "3.0.13", "description": "guard sub-package for Sequence", - "repository": "https://github.com/0xsequence/sequence.js/tree/master/packages/services/guard", + "repository": { + "type": "git", + "url": "git+https://github.com/0xsequence/sequence.js.git", + "directory": "packages/services/guard" + }, "author": "Sequence Platforms ULC", "license": "Apache-2.0", "type": "module", diff --git a/packages/services/identity-instrument/package.json b/packages/services/identity-instrument/package.json index ce2ad9314e..4f4a30701a 100644 --- a/packages/services/identity-instrument/package.json +++ b/packages/services/identity-instrument/package.json @@ -2,6 +2,11 @@ "name": "@0xsequence/identity-instrument", "version": "3.0.13", "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/0xsequence/sequence.js.git", + "directory": "packages/services/identity-instrument" + }, "type": "module", "publishConfig": { "access": "public" diff --git a/packages/services/indexer/package.json b/packages/services/indexer/package.json index 1678c8cd7c..eff530635d 100644 --- a/packages/services/indexer/package.json +++ b/packages/services/indexer/package.json @@ -2,7 +2,11 @@ "name": "@0xsequence/indexer", "version": "3.0.13", "description": "indexer sub-package for Sequence", - "repository": "https://github.com/0xsequence/sequence.js/tree/master/packages/services/indexer", + "repository": { + "type": "git", + "url": "git+https://github.com/0xsequence/sequence.js.git", + "directory": "packages/services/indexer" + }, "author": "Sequence Platforms ULC", "license": "Apache-2.0", "publishConfig": { diff --git a/packages/services/marketplace/package.json b/packages/services/marketplace/package.json index addd769857..8de0fc4ef9 100644 --- a/packages/services/marketplace/package.json +++ b/packages/services/marketplace/package.json @@ -2,7 +2,11 @@ "name": "@0xsequence/marketplace", "version": "3.0.13", "description": "marketplace sub-package for Sequence", - "repository": "https://github.com/0xsequence/sequence.js/tree/master/packages/services/marketplace", + "repository": { + "type": "git", + "url": "git+https://github.com/0xsequence/sequence.js.git", + "directory": "packages/services/marketplace" + }, "author": "Sequence Platforms ULC", "license": "Apache-2.0", "publishConfig": { diff --git a/packages/services/metadata/package.json b/packages/services/metadata/package.json index 9995864edf..923203427c 100644 --- a/packages/services/metadata/package.json +++ b/packages/services/metadata/package.json @@ -5,7 +5,11 @@ "access": "public" }, "description": "metadata sub-package for Sequence", - "repository": "https://github.com/0xsequence/sequence.js/tree/master/packages/services/metadata", + "repository": { + "type": "git", + "url": "git+https://github.com/0xsequence/sequence.js.git", + "directory": "packages/services/metadata" + }, "author": "Sequence Platforms ULC", "license": "Apache-2.0", "type": "module", diff --git a/packages/services/relayer/package.json b/packages/services/relayer/package.json index 6bf55acc9d..851eda7a39 100644 --- a/packages/services/relayer/package.json +++ b/packages/services/relayer/package.json @@ -6,7 +6,11 @@ "access": "public" }, "description": "relayer sub-package for Sequence", - "repository": "https://github.com/0xsequence/sequence.js/tree/master/packages/services/relayer", + "repository": { + "type": "git", + "url": "git+https://github.com/0xsequence/sequence.js.git", + "directory": "packages/services/relayer" + }, "author": "Sequence Platforms ULC", "license": "Apache-2.0", "scripts": { diff --git a/packages/services/userdata/package.json b/packages/services/userdata/package.json index 196cf33965..f773d7c0fb 100644 --- a/packages/services/userdata/package.json +++ b/packages/services/userdata/package.json @@ -2,7 +2,11 @@ "name": "@0xsequence/userdata", "version": "3.0.13", "description": "userdata sub-package for Sequence", - "repository": "https://github.com/0xsequence/sequence.js/tree/master/packages/services/userdata", + "repository": { + "type": "git", + "url": "git+https://github.com/0xsequence/sequence.js.git", + "directory": "packages/services/userdata" + }, "author": "Sequence Platforms ULC", "license": "Apache-2.0", "publishConfig": { diff --git a/packages/utils/abi/package.json b/packages/utils/abi/package.json index 431c06bf6b..0a4673a1ee 100644 --- a/packages/utils/abi/package.json +++ b/packages/utils/abi/package.json @@ -2,7 +2,11 @@ "name": "@0xsequence/abi", "version": "3.0.13", "description": "abi sub-package for Sequence", - "repository": "https://github.com/0xsequence/sequence.js/tree/master/packages/utils/abi", + "repository": { + "type": "git", + "url": "git+https://github.com/0xsequence/sequence.js.git", + "directory": "packages/utils/abi" + }, "author": "Sequence Platforms ULC", "license": "Apache-2.0", "publishConfig": { diff --git a/packages/wallet/core/package.json b/packages/wallet/core/package.json index 22fec4468b..f7944c2889 100644 --- a/packages/wallet/core/package.json +++ b/packages/wallet/core/package.json @@ -2,6 +2,11 @@ "name": "@0xsequence/wallet-core", "version": "3.0.13", "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/0xsequence/sequence.js.git", + "directory": "packages/wallet/core" + }, "type": "module", "publishConfig": { "access": "public" diff --git a/packages/wallet/dapp-client/package.json b/packages/wallet/dapp-client/package.json index b758d9fc36..be9c05e3dd 100644 --- a/packages/wallet/dapp-client/package.json +++ b/packages/wallet/dapp-client/package.json @@ -2,6 +2,11 @@ "name": "@0xsequence/dapp-client", "version": "3.1.2", "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/0xsequence/sequence.js.git", + "directory": "packages/wallet/dapp-client" + }, "type": "module", "publishConfig": { "access": "public" diff --git a/packages/wallet/primitives/package.json b/packages/wallet/primitives/package.json index 501de27153..4a7052fc04 100644 --- a/packages/wallet/primitives/package.json +++ b/packages/wallet/primitives/package.json @@ -2,6 +2,11 @@ "name": "@0xsequence/wallet-primitives", "version": "3.0.13", "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/0xsequence/sequence.js.git", + "directory": "packages/wallet/primitives" + }, "type": "module", "publishConfig": { "access": "public" diff --git a/packages/wallet/wdk/package.json b/packages/wallet/wdk/package.json index dc739fbb27..a00752727d 100644 --- a/packages/wallet/wdk/package.json +++ b/packages/wallet/wdk/package.json @@ -2,6 +2,11 @@ "name": "@0xsequence/wallet-wdk", "version": "3.1.2", "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/0xsequence/sequence.js.git", + "directory": "packages/wallet/wdk" + }, "type": "module", "publishConfig": { "access": "public" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9de5e60bff..34da039624 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: devDependencies: + '@changesets/changelog-github': + specifier: ^1.0.1 + version: 1.0.1 '@changesets/cli': specifier: ^2.29.8 version: 2.29.8(@types/node@25.3.0) @@ -670,6 +673,10 @@ packages: '@changesets/changelog-git@0.2.1': resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + '@changesets/changelog-github@1.0.1': + resolution: {integrity: sha512-QUcrV7878yHlWPXXPA6gqSxNKwtVHCd1K22L7ZAoJw2yGy8K4QvjwOStD75+1Q9KD9ZSUgGS94HuYdd7HOVtpA==} + engines: {node: ^22.11 || ^24 || >=26} + '@changesets/cli@2.29.8': resolution: {integrity: sha512-1weuGZpP63YWUYjay/E84qqwcnt5yJMM0tep10Up7Q5cS/DGe2IZ0Uj3HNMxGhCINZuR7aO9WBMdKnPit5ZDPA==} hasBin: true @@ -683,6 +690,10 @@ packages: '@changesets/get-dependents-graph@2.1.3': resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==} + '@changesets/get-github-info@1.0.1': + resolution: {integrity: sha512-ifLQCky/ZtDgZ4xCiUMjnLZSJ8xk52iIzMJbBBPlZWjr2CiTNTaTDddIVWKicrUAdWuLWL7PUw2fNa1yPwLpIg==} + engines: {node: ^22.11 || ^24 || >=26} + '@changesets/get-release-plan@4.0.14': resolution: {integrity: sha512-yjZMHpUHgl4Xl5gRlolVuxDkm4HgSJqT93Ri1Uz8kGrQb+5iJ8dkXJ20M2j/Y4iV5QzS2c5SeTxVSKX+2eMI0g==} @@ -713,6 +724,10 @@ packages: '@changesets/types@6.1.0': resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} + '@changesets/types@7.0.0': + resolution: {integrity: sha512-c5GoiQyt3pxiXjrWSNoP8/GRf4kG+VnKzovx1OQM8dYYALlSwgedmkPmJ+ZqGxqwg9D3Bkj85Uo4KLd5BN3A0w==} + engines: {node: ^22.11 || ^24 || >=26} + '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} @@ -1925,6 +1940,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dataloader@2.2.3: + resolution: {integrity: sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -4062,6 +4080,11 @@ snapshots: dependencies: '@changesets/types': 6.1.0 + '@changesets/changelog-github@1.0.1': + dependencies: + '@changesets/get-github-info': 1.0.1 + '@changesets/types': 7.0.0 + '@changesets/cli@2.29.8(@types/node@25.3.0)': dependencies: '@changesets/apply-release-plan': 7.0.14 @@ -4116,6 +4139,10 @@ snapshots: picocolors: 1.1.1 semver: 7.7.3 + '@changesets/get-github-info@1.0.1': + dependencies: + dataloader: 2.2.3 + '@changesets/get-release-plan@4.0.14': dependencies: '@changesets/assemble-release-plan': 6.0.9 @@ -4170,6 +4197,8 @@ snapshots: '@changesets/types@6.1.0': {} + '@changesets/types@7.0.0': {} + '@changesets/write@0.4.0': dependencies: '@changesets/types': 6.1.0 @@ -5265,6 +5294,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dataloader@2.2.3: {} + debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 diff --git a/scripts/configure-npm-trust.sh b/scripts/configure-npm-trust.sh new file mode 100755 index 0000000000..80f705b2da --- /dev/null +++ b/scripts/configure-npm-trust.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# One-time admin setup: registers .github/workflows/npm-release-trigger.yml as a stage-only +# npm trusted publisher for every package published from master (v3) or v2 (v2-legacy). +# Requires npm >= 11.15, an npm login with publish rights and 2FA. Enable "skip 2FA for the +# next 5 minutes" on the first prompt so the loop does not ask again. See PUBLISHING.md. +set -uo pipefail + +REPO="0xsequence/sequence.js" +WORKFLOW="npm-release-trigger.yml" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +packages_on() { + local ref="$1" + git -C "$ROOT" ls-tree -r --name-only "$ref" -- packages | + grep -E '^packages/([^/]+/)?[^/]+/package\.json$' | + while read -r manifest; do + git -C "$ROOT" show "$ref:$manifest" | jq -r 'select(.private != true and .name != null) | .name' + done +} + +git -C "$ROOT" fetch --quiet origin master v2 +PACKAGES=() +while read -r package; do PACKAGES+=("$package"); done < <({ packages_on origin/master; packages_on origin/v2; } | sort -u) + +echo "Configuring stage-only trusted publishing ($REPO, $WORKFLOW) for ${#PACKAGES[@]} packages." +failed=() +for package in "${PACKAGES[@]}"; do + echo "--- $package" + if ! npm trust github "$package" --repo "$REPO" --file "$WORKFLOW" --allow-stage-publish --yes; then + failed+=("$package") + fi + sleep 2 # npm rate-limits trust changes +done + +if [ ${#failed[@]} -gt 0 ]; then + echo "Failed for: ${failed[*]}" >&2 + exit 1 +fi +echo "Done. Verify with: npm trust list " diff --git a/scripts/prepare-npm-snapshot.mjs b/scripts/prepare-npm-snapshot.mjs new file mode 100644 index 0000000000..6c9afa933e --- /dev/null +++ b/scripts/prepare-npm-snapshot.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { readFile, unlink, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +// Versions every publishable package as `0.0.0--` on the CI runner (never +// committed) so stage-npm-packages.mjs can stage a throwaway snapshot. See PUBLISHING.md. + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const changesetDirectory = path.join(repositoryRoot, '.changeset') + +export function validateSnapshotTag(tag) { + assert(tag, 'SNAPSHOT_TAG is required.') + assert.notEqual(tag, 'latest', "The snapshot tag must not be 'latest'.") + assert.notEqual(tag, 'v2-legacy', "The snapshot tag must not be 'v2-legacy'.") + assert( + /^[a-z][a-z0-9._-]*$/.test(tag), + 'The snapshot tag must start with a lowercase letter and contain only lowercase letters, numbers, dots, underscores, or hyphens.', + ) + assert(!/^v?\d+(?:\.\d+){0,2}(?:[-+].*)?$/.test(tag), 'The snapshot tag must not look like a release version.') +} + +// Every publishable package is bumped so snapshot packages only ever depend on each other, +// never on a workspace version that is not on npm. +export function snapshotChangeset(packageNames) { + const releases = packageNames.map((name) => `'${name}': patch`).join('\n') + return `---\n${releases}\n---\n\nCreate a temporary CI snapshot release.\n` +} + +export function assertSnapshotVersions(before, after, tag) { + assert(after.length > 0, 'No publishable packages were found.') + assert.deepEqual( + after.map(({ name }) => name).sort(), + before.map(({ name }) => name).sort(), + 'The publishable package set changed unexpectedly.', + ) + + for (const currentPackage of after) { + const previousPackage = before.find(({ name }) => name === currentPackage.name) + assert.notEqual( + currentPackage.version, + previousPackage.version, + `Snapshot versioning did not change ${currentPackage.name}.`, + ) + assert( + currentPackage.version.includes(`-${tag}-`), + `${currentPackage.name} did not receive the requested '${tag}' snapshot version.`, + ) + } +} + +function runChecked(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: 'inherit', + ...options, + }) + if (result.error) throw result.error + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}.`) + } + return result +} + +async function readReleasePackages() { + const result = runChecked('pnpm', ['--recursive', 'list', '--depth', '-1', '--json'], { stdio: 'pipe' }) + const workspaces = JSON.parse(result.stdout).filter((workspace) => workspace.private !== true) + return Promise.all( + workspaces.map(async (workspace) => { + const manifest = JSON.parse(await readFile(path.join(workspace.path, 'package.json'), 'utf8')) + return { name: manifest.name, version: manifest.version } + }), + ) +} + +async function main() { + const tag = process.env.SNAPSHOT_TAG + validateSnapshotTag(tag) + + const before = await readReleasePackages() + const temporaryChangeset = path.join(changesetDirectory, `ci-snapshot-${process.pid}.md`) + await writeFile(temporaryChangeset, snapshotChangeset(before.map(({ name }) => name)), { + encoding: 'utf8', + flag: 'wx', + }) + + try { + runChecked('pnpm', ['exec', 'changeset', 'version', '--snapshot', tag]) + } finally { + await unlink(temporaryChangeset).catch((error) => { + if (error.code !== 'ENOENT') throw error + }) + } + + const after = await readReleasePackages() + assertSnapshotVersions(before, after, tag) + console.log(`Prepared snapshot versions:\n${after.map(({ name, version }) => ` ${name}@${version}`).join('\n')}`) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} diff --git a/scripts/prepare-npm-snapshot.test.mjs b/scripts/prepare-npm-snapshot.test.mjs new file mode 100644 index 0000000000..8ebfe6138c --- /dev/null +++ b/scripts/prepare-npm-snapshot.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { assertSnapshotVersions, snapshotChangeset, validateSnapshotTag } from './prepare-npm-snapshot.mjs' + +test('accepts safe snapshot tags', () => { + for (const tag of ['snapshot', 'canary', 'pre-3.0.14']) { + assert.doesNotThrow(() => validateSnapshotTag(tag)) + } +}) + +test('rejects release dist-tags, version-like, and malformed snapshot tags', () => { + for (const tag of ['', 'latest', 'v2-legacy', '3.0.14', 'v3.0.14', '3.0.14-beta.1', 'beta tag', 'Beta']) { + assert.throws(() => validateSnapshotTag(tag)) + } +}) + +test('builds a changeset that bumps every publishable package', () => { + assert.equal( + snapshotChangeset(['@0xsequence/wallet-core', '@0xsequence/abi']), + "---\n'@0xsequence/wallet-core': patch\n'@0xsequence/abi': patch\n---\n\nCreate a temporary CI snapshot release.\n", + ) +}) + +test('requires every publishable package to receive the requested snapshot version', () => { + const before = [ + { name: '@0xsequence/wallet-core', version: '3.0.14' }, + { name: '@0xsequence/wallet-wdk', version: '3.1.2' }, + ] + const after = before.map(({ name }) => ({ name, version: '0.0.0-canary-20261014000000' })) + + assert.doesNotThrow(() => assertSnapshotVersions(before, after, 'canary')) + assert.throws(() => assertSnapshotVersions(before, before, 'canary'), /Snapshot versioning did not change/) + assert.throws( + () => assertSnapshotVersions(before, [after[0], before[1]], 'canary'), + /did not change @0xsequence\/wallet-wdk/, + ) + assert.throws(() => assertSnapshotVersions(before, [after[0]], 'canary'), /package set changed/) +}) diff --git a/scripts/stage-npm-packages.mjs b/scripts/stage-npm-packages.mjs new file mode 100644 index 0000000000..69127605d2 --- /dev/null +++ b/scripts/stage-npm-packages.mjs @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { appendFile, mkdtemp, readFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +// Packs every publishable workspace package whose version is not yet on npm and sends the +// tarballs to `npm stage publish` via OIDC trusted publishing. CI-only: see PUBLISHING.md. + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +const dependencyFields = ['dependencies', 'peerDependencies', 'optionalDependencies'] + +export function selectReleasePackages(workspaces) { + const releasePackages = workspaces.filter((workspace) => workspace.private !== true) + assert(releasePackages.length > 0, 'No publishable workspace packages were found.') + for (const workspace of releasePackages) { + assert(workspace.name && workspace.version, `Publishable package at ${workspace.path} needs a name and version.`) + } + const names = releasePackages.map((workspace) => workspace.name) + assert.equal(new Set(names).size, names.length, 'Publishable package names must be unique.') + return releasePackages +} + +// Orders packages so that every package comes after the workspace packages it depends on. +// Maintainers approve staged packages in this order, so dependencies become installable first. +export function orderByDependencies(releasePackages) { + const names = new Set(releasePackages.map(({ name }) => name)) + const remaining = releasePackages.map((releasePackage) => ({ + releasePackage, + dependsOn: new Set( + (releasePackage.dependsOn ?? []).filter((name) => names.has(name) && name !== releasePackage.name), + ), + })) + const ordered = [] + const placed = new Set() + while (remaining.length > 0) { + const index = remaining.findIndex(({ dependsOn }) => [...dependsOn].every((name) => placed.has(name))) + assert( + index !== -1, + `Workspace dependency cycle between: ${remaining.map((r) => r.releasePackage.name).join(', ')}`, + ) + const [{ releasePackage }] = remaining.splice(index, 1) + ordered.push(releasePackage) + placed.add(releasePackage.name) + } + return ordered +} + +export function workspaceDependencyNames(manifest) { + return [...new Set(dependencyFields.flatMap((field) => Object.keys(manifest[field] ?? {})))] +} + +export function findWorkspaceReferences(value, currentPath = 'package.json') { + if (typeof value === 'string') { + return value.startsWith('workspace:') ? [currentPath] : [] + } + if (Array.isArray(value)) { + return value.flatMap((item, index) => findWorkspaceReferences(item, `${currentPath}[${index}]`)) + } + if (value && typeof value === 'object') { + return Object.entries(value).flatMap(([key, item]) => findWorkspaceReferences(item, `${currentPath}.${key}`)) + } + return [] +} + +export function assertTrustedPublishingEnvironment(environment) { + assert.equal(environment.GITHUB_ACTIONS, 'true', 'Staged publishing is CI-only and must run in GitHub Actions.') + assert( + environment.ACTIONS_ID_TOKEN_REQUEST_TOKEN && environment.ACTIONS_ID_TOKEN_REQUEST_URL, + 'GitHub Actions OIDC is unavailable; the job needs id-token: write permission.', + ) +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: repositoryRoot, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + ...options, + }) + if (result.error) throw result.error + return result +} + +function runChecked(command, args, options = {}) { + const result = run(command, args, options) + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}.\n${result.stderr ?? ''}`) + } + return result +} + +function hasGitTag(tag) { + const result = run('git', ['show-ref', '--verify', '--quiet', `refs/tags/${tag}`]) + if (result.status !== 0 && result.status !== 1) { + throw new Error(`Unable to check git tag ${tag}.`) + } + return result.status === 0 +} + +function isPublished({ name, version }) { + const packageSpec = `${name}@${version}` + const result = run('npm', ['view', packageSpec, 'version', '--json']) + if (result.status === 0) { + // npm prints nothing (exit 0) when the package exists but the version does not. + return result.stdout.trim() !== '' && JSON.parse(result.stdout) === version + } + if (/E404|404 Not Found/.test(`${result.stdout}\n${result.stderr}`)) { + return false + } + throw new Error(`Unable to determine whether ${packageSpec} is published.\n${result.stderr}`) +} + +function tarballName({ name, version }) { + return `${name.replace(/^@/, '').replaceAll('/', '-')}-${version}.tgz` +} + +async function packPackage(releasePackage, outputDirectory) { + const tarballPath = path.join(outputDirectory, tarballName(releasePackage)) + runChecked('pnpm', ['--dir', releasePackage.path, 'pack', '--out', tarballPath], { + stdio: 'inherit', + }) + + const manifestResult = runChecked('tar', ['-xOf', tarballPath, 'package/package.json']) + const packedManifest = JSON.parse(manifestResult.stdout) + assert.equal(packedManifest.name, releasePackage.name, 'Packed package name changed unexpectedly.') + assert.equal(packedManifest.version, releasePackage.version, 'Packed package version changed unexpectedly.') + assert.deepEqual( + findWorkspaceReferences(packedManifest), + [], + `pnpm did not rewrite every workspace: reference in ${releasePackage.name}.`, + ) + return tarballPath +} + +async function readReleasePackages() { + const workspacesResult = runChecked('pnpm', ['--recursive', 'list', '--depth', '-1', '--json']) + const releasePackages = selectReleasePackages(JSON.parse(workspacesResult.stdout)) + const withDependencies = await Promise.all( + releasePackages.map(async (releasePackage) => { + const manifest = JSON.parse(await readFile(path.join(releasePackage.path, 'package.json'), 'utf8')) + return { ...releasePackage, dependsOn: workspaceDependencyNames(manifest) } + }), + ) + return orderByDependencies(withDependencies) +} + +async function writeSummary(lines) { + if (!process.env.GITHUB_STEP_SUMMARY) return + await appendFile(process.env.GITHUB_STEP_SUMMARY, `${lines.join('\n')}\n`, 'utf8') +} + +async function main() { + assertTrustedPublishingEnvironment(process.env) + const distTag = process.env.NPM_STAGE_TAG || 'latest' + const createGitTags = process.env.NPM_STAGE_CREATE_GIT_TAGS !== 'false' + + const releasePackages = await readReleasePackages() + const releaseStates = releasePackages.map((releasePackage) => { + const tag = `${releasePackage.name}@${releasePackage.version}` + const tagged = createGitTags && hasGitTag(tag) + const published = tagged ? false : isPublished(releasePackage) + return { releasePackage, tag, tagged, published } + }) + const packagesToStage = releaseStates.filter((state) => !state.tagged && !state.published) + + if (packagesToStage.length === 0) { + console.log('No unstaged package versions were found.') + await writeSummary(['### npm staged publishing', '', 'No package versions needed staging.']) + return + } + + const outputDirectory = await mkdtemp(path.join(os.tmpdir(), 'sequence-npm-stage-')) + const stagedPackages = [] + for (const { releasePackage } of packagesToStage) { + const tarballPath = await packPackage(releasePackage, outputDirectory) + runChecked('npm', ['stage', 'publish', tarballPath, '--tag', distTag, '--access', 'public', '--provenance'], { + stdio: 'inherit', + }) + stagedPackages.push(releasePackage) + // changesets/action v1 creates this package's tag and GitHub Release from this line, + // even if a later package fails. A retry can then skip the already-staged package by tag. + if (createGitTags) { + console.log(`New tag: ${releasePackage.name}@${releasePackage.version}`) + } + } + + await writeSummary([ + '### npm staged publishing', + '', + 'Approve in this order (dependencies first):', + '', + ...stagedPackages.map(({ name, version }) => `1. \`${name}@${version}\` is staged under \`${distTag}\`.`), + '', + 'A maintainer must approve every staged package on npm with 2FA before it is installable.', + ]) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} diff --git a/scripts/stage-npm-packages.test.mjs b/scripts/stage-npm-packages.test.mjs new file mode 100644 index 0000000000..f001144f39 --- /dev/null +++ b/scripts/stage-npm-packages.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + assertTrustedPublishingEnvironment, + findWorkspaceReferences, + orderByDependencies, + selectReleasePackages, + workspaceDependencyNames, +} from './stage-npm-packages.mjs' + +test('selects only publishable workspace packages', () => { + const workspaces = [ + { name: 'sequence-core', version: null, private: true, path: '/repo' }, + { name: '@0xsequence/wallet-core', version: '3.0.14', private: false, path: '/repo/packages/wallet/core' }, + { name: '@0xsequence/abi', version: '3.0.13', path: '/repo/packages/utils/abi' }, + ] + assert.deepEqual( + selectReleasePackages(workspaces).map(({ name }) => name), + ['@0xsequence/wallet-core', '@0xsequence/abi'], + ) +}) + +test('rejects publishable packages without a version', () => { + assert.throws( + () => selectReleasePackages([{ name: '@0xsequence/abi', path: '/repo/packages/utils/abi' }]), + /needs a name and version/, + ) +}) + +test('rejects an empty release set', () => { + assert.throws(() => selectReleasePackages([{ name: 'root', private: true }]), /No publishable/) +}) + +test('orders packages after the workspace packages they depend on', () => { + const ordered = orderByDependencies([ + { name: 'wdk', dependsOn: ['core', 'primitives', 'ox'] }, + { name: 'core', dependsOn: ['primitives'] }, + { name: 'abi', dependsOn: [] }, + { name: 'primitives', dependsOn: [] }, + ]) + assert.deepEqual( + ordered.map(({ name }) => name), + ['abi', 'primitives', 'core', 'wdk'], + ) +}) + +test('rejects workspace dependency cycles', () => { + assert.throws( + () => + orderByDependencies([ + { name: 'a', dependsOn: ['b'] }, + { name: 'b', dependsOn: ['a'] }, + ]), + /dependency cycle/, + ) +}) + +test('collects runtime dependency names but not devDependencies', () => { + assert.deepEqual( + workspaceDependencyNames({ + dependencies: { a: 'workspace:^' }, + peerDependencies: { b: '^1.0.0', a: 'workspace:^' }, + devDependencies: { c: 'workspace:*' }, + }), + ['a', 'b'], + ) +}) + +test('finds nested workspace protocol references in packed manifests', () => { + assert.deepEqual( + findWorkspaceReferences({ + peerDependencies: { sdk: 'workspace:^' }, + publishConfig: { dependencies: { sdk: '^1.2.3' } }, + }), + ['package.json.peerDependencies.sdk'], + ) +}) + +test('requires GitHub Actions OIDC before staging', () => { + assert.throws(() => assertTrustedPublishingEnvironment({}), /CI-only/) + assert.throws(() => assertTrustedPublishingEnvironment({ GITHUB_ACTIONS: 'true' }), /OIDC is unavailable/) + assert.doesNotThrow(() => + assertTrustedPublishingEnvironment({ + GITHUB_ACTIONS: 'true', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'token', + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://example.test/oidc', + }), + ) +}) From 59d12490a722588a1f0a2647f71e784d1d004d7b Mon Sep 17 00:00:00 2001 From: Taylan Pince Date: Wed, 14 Oct 2026 11:07:25 +0200 Subject: [PATCH 2/5] chore: add empty changeset for release tooling Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qDHk3SaPaDGpPTU1jjLpz --- .changeset/quick-roses-greet.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/quick-roses-greet.md diff --git a/.changeset/quick-roses-greet.md b/.changeset/quick-roses-greet.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/quick-roses-greet.md @@ -0,0 +1,2 @@ +--- +--- From 10c3b051b056e255bb185f7aaa8116238086a721 Mon Sep 17 00:00:00 2001 From: Taylan Pince Date: Wed, 14 Oct 2026 11:52:57 +0200 Subject: [PATCH 3/5] ci(release): keep release bot token away from install and build code Apply the 0xPolygon/pipelines fix for GHSA-rxhc-j8g6-h4jp, which the oms-wallet-typescript-sdk workflow this was modelled on predates: - Check out with persist-credentials: false and install with --ignore-scripts, so no token sits in .git/config while dependency code runs. - Run the release gates (build, typecheck, lint, tests) on release-PR merges before the app token is minted; ci:stage-release now only packs and stages. - Mint the token last (create-github-app-token v3.2.0), scoped to contents and pull-requests write, for changesets/action only. Default-deny workflow permissions; snapshot job gets pull-requests: read. - Document the 0xsequence-owned release bot app; Polygon's app is private to the 0xPolygon org. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qDHk3SaPaDGpPTU1jjLpz --- .github/workflows/npm-release-trigger.yml | 43 ++++++++++++++++++----- PUBLISHING.md | 19 ++++++---- package.json | 3 +- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/.github/workflows/npm-release-trigger.yml b/.github/workflows/npm-release-trigger.yml index 1ade87bbf9..9a5afbdefd 100644 --- a/.github/workflows/npm-release-trigger.yml +++ b/.github/workflows/npm-release-trigger.yml @@ -18,24 +18,25 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false +# Default-deny: each job declares its own permissions. +permissions: {} + jobs: release: if: github.event_name == 'push' runs-on: ubuntu-latest + timeout-minutes: 60 permissions: contents: write pull-requests: write id-token: write # required for npm OIDC trusted publishing steps: - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 - id: app-token - with: - app-id: ${{ secrets.CHANGESET_RELEASE_BOT_APP_ID }} - private-key: ${{ secrets.CHANGESET_RELEASE_BOT_APP_PRIVATE_KEY }} + # Nothing pushes over git (changesets github-api mode writes through the API with the + # app token below), so do not persist the read-only job token into .git/config. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - token: ${{ steps.app-token.outputs.token }} + persist-credentials: false - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -50,7 +51,29 @@ jobs: version: v1.5.0 # The release PR changes package manifests without regenerating the lockfile. Link # co-released workspace packages locally so the not-yet-public versions still install. - - run: pnpm install --no-frozen-lockfile --config.link-workspace-packages=true + # --ignore-scripts: releasing needs no install-time script, and this job holds id-token: write. + - run: pnpm install --no-frozen-lockfile --config.link-workspace-packages=true --ignore-scripts + - name: Detect release PR merge + id: pending + run: | + if find .changeset -name '*.md' ! -name README.md | grep -q .; then + echo "changesets=true" >> "$GITHUB_OUTPUT" + else + echo "changesets=false" >> "$GITHUB_OUTPUT" + fi + # Only a merged release PR (no pending changesets) stages packages. Run the gates before + # the release bot token exists so build and test code never sees it. + - name: Release gates + if: steps.pending.outputs.changesets == 'false' + run: pnpm run ci:release-gates + # Mint the app token last, scoped to what changesets/action uses, so only that step has it. + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: app-token + with: + app-id: ${{ secrets.CHANGESET_RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.CHANGESET_RELEASE_BOT_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write - name: Create release PR or stage versioned packages uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: @@ -68,8 +91,10 @@ jobs: snapshot: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 60 permissions: contents: read + pull-requests: read # changelog-github PR lookups id-token: write # required for npm OIDC trusted publishing steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -86,12 +111,14 @@ jobs: - uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1 with: version: v1.5.0 - - run: pnpm install --no-frozen-lockfile --config.link-workspace-packages=true + - run: pnpm install --no-frozen-lockfile --config.link-workspace-packages=true --ignore-scripts - name: Version snapshot env: GITHUB_TOKEN: ${{ github.token }} SNAPSHOT_TAG: ${{ inputs.snapshot_tag }} run: node scripts/prepare-npm-snapshot.mjs + - name: Release gates + run: pnpm run ci:release-gates - name: Stage snapshot packages env: NPM_STAGE_CREATE_GIT_TAGS: 'false' diff --git a/PUBLISHING.md b/PUBLISHING.md index 7a88152543..29800cfeda 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -33,10 +33,11 @@ The `Changeset check` CI job fails a PR that touches a package without one. (`.github/workflows/npm-release-trigger.yml`), which opens or updates a **`changesets: Release / Deploy`** PR. That PR applies the pending changesets, bumps package versions, and updates each `CHANGELOG.md`. 2. Review and merge that Release PR. -3. On merge, the same workflow uses npm `11.15.0` and runs the release gates (build, typecheck, lint, release - script tests, and the full test suite against an anvil fork). It then packs every publishable package whose - version is not yet on npm with pnpm, which rewrites `workspace:` ranges to real versions, and fails if any - `workspace:` range remains. +3. On merge, the same workflow uses npm `11.15.0` and runs the release gates (`ci:release-gates`: build, + typecheck, lint, release script tests, and the full test suite against an anvil fork) before the release bot + token is minted, so build and test code never sees it. It then packs every publishable package whose version + is not yet on npm with pnpm, which rewrites `workspace:` ranges to real versions, and fails if any `workspace:` + range remains. 4. The workflow sends the tarballs to `npm stage publish` under the `latest` dist-tag via **OIDC trusted publishing** (no `NPM_TOKEN`). After each package stages, Changesets pushes its package tag (e.g. `@0xsequence/wallet-core@3.0.14`) and creates a GitHub Release from its `CHANGELOG.md` entry. @@ -89,6 +90,10 @@ same on both branches, so one trusted publisher configuration per package covers trusted publisher (`--allow-stage-publish`, no `--allow-publish`) for every package on `master` and `v2`, so CI can stage but never publish directly. Afterwards, set each package's publishing access to "Require two-factor authentication and disallow tokens" and revoke old automation tokens. -- **GitHub:** install the changesets release bot GitHub App on this repository and add its - `CHANGESET_RELEASE_BOT_APP_ID` and `CHANGESET_RELEASE_BOT_APP_PRIVATE_KEY` as repository (or organization) - secrets. Commits from the app's token trigger CI on the release PR, which the default `GITHUB_TOKEN` would not. +- **GitHub:** the release workflow authenticates as the **0xsequence Changesets Release Bot** GitHub App, owned + by the `0xsequence` org (Polygon's `0xpolygon-changesets-release-bot` is private to `0xPolygon` and cannot be + installed here). The app needs repository permissions **Contents: read & write** and **Pull requests: read & + write**, no webhook, and is installed on this repository only. Its App ID and a private key are stored as the + repository secrets `CHANGESET_RELEASE_BOT_APP_ID` and `CHANGESET_RELEASE_BOT_APP_PRIVATE_KEY`. The app (not the + default `GITHUB_TOKEN`) is required so the release PR triggers CI and its commits are signed. To rotate the key, + generate a new one in the app settings, update the secret, then delete the old key. diff --git a/package.json b/package.json index 90f29270f9..20542abb40 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "deps:lint": "syncpack lint --dependency-types prod,dev", "deps:fix": "syncpack fix", "test:release": "node --test scripts/*.test.mjs", - "ci:stage-release": "pnpm build && pnpm typecheck && pnpm lint && pnpm test:release && pnpm test:local && node scripts/stage-npm-packages.mjs" + "ci:stage-release": "node scripts/stage-npm-packages.mjs", + "ci:release-gates": "pnpm build && pnpm typecheck && pnpm lint && pnpm test:release && pnpm test:local" }, "devDependencies": { "@changesets/changelog-github": "^1.0.1", From f30651a7c0bd2a06447a2d48d8474a9d74693f1a Mon Sep 17 00:00:00 2001 From: Taylan Pince Date: Wed, 14 Oct 2026 12:09:54 +0200 Subject: [PATCH 4/5] fix(release): run configure-npm-trust with npm 11.15.0 via npx `--allow-stage-publish` only exists in npm >= 11.15; the npm bundled with Node 24.14 is 11.11 and rejects it with EUSAGE. Pin the same npm the release workflow uses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qDHk3SaPaDGpPTU1jjLpz --- PUBLISHING.md | 2 +- scripts/configure-npm-trust.sh | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/PUBLISHING.md b/PUBLISHING.md index 29800cfeda..04f490cdd3 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -85,7 +85,7 @@ same on both branches, so one trusted publisher configuration per package covers ## One-time setup (admins) -- **npm trusted publishers:** run `./scripts/configure-npm-trust.sh` (needs npm ≥ 11.15, an npm login with +- **npm trusted publishers:** run `./scripts/configure-npm-trust.sh` (runs npm 11.15.0 via `npx`; needs an npm login with publish rights, and 2FA). It registers `0xsequence/sequence.js` + `npm-release-trigger.yml` as a **stage-only** trusted publisher (`--allow-stage-publish`, no `--allow-publish`) for every package on `master` and `v2`, so CI can stage but never publish directly. Afterwards, set each package's publishing access to "Require two-factor diff --git a/scripts/configure-npm-trust.sh b/scripts/configure-npm-trust.sh index 80f705b2da..af6f5f32a5 100755 --- a/scripts/configure-npm-trust.sh +++ b/scripts/configure-npm-trust.sh @@ -1,13 +1,16 @@ #!/usr/bin/env bash # One-time admin setup: registers .github/workflows/npm-release-trigger.yml as a stage-only # npm trusted publisher for every package published from master (v3) or v2 (v2-legacy). -# Requires npm >= 11.15, an npm login with publish rights and 2FA. Enable "skip 2FA for the -# next 5 minutes" on the first prompt so the loop does not ask again. See PUBLISHING.md. +# Runs npm 11.15.0 via npx (the local npm may predate --allow-stage-publish). Requires an npm +# login with publish rights and 2FA. Enable "skip 2FA for the next 5 minutes" on the first +# prompt so the loop does not ask again. See PUBLISHING.md. set -uo pipefail REPO="0xsequence/sequence.js" WORKFLOW="npm-release-trigger.yml" ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# Same npm the release workflow uses; --allow-stage-publish needs npm >= 11.15. +NPM=(npx --yes npm@11.15.0) packages_on() { local ref="$1" @@ -26,7 +29,7 @@ echo "Configuring stage-only trusted publishing ($REPO, $WORKFLOW) for ${#PACKAG failed=() for package in "${PACKAGES[@]}"; do echo "--- $package" - if ! npm trust github "$package" --repo "$REPO" --file "$WORKFLOW" --allow-stage-publish --yes; then + if ! "${NPM[@]}" trust github "$package" --repo "$REPO" --file "$WORKFLOW" --allow-stage-publish --yes; then failed+=("$package") fi sleep 2 # npm rate-limits trust changes @@ -36,4 +39,4 @@ if [ ${#failed[@]} -gt 0 ]; then echo "Failed for: ${failed[*]}" >&2 exit 1 fi -echo "Done. Verify with: npm trust list " +echo "Done. Verify with: npx npm@11.15.0 trust list " From a51149fc5bd20c73b5b04869a70737cdcffa2d5e Mon Sep 17 00:00:00 2001 From: Taylan Pince Date: Wed, 14 Oct 2026 12:44:37 +0200 Subject: [PATCH 5/5] fix(release): address Codex review on staged publishing - prepare-npm-snapshot: reject snapshot tags npm parses as semver ranges (`x`, `v1.x`, `v3`) via semver.validRange, before the release gates run. - stage-npm-packages: the approval summary now lists every unpublished version in dependency order, including ones staged by an earlier, partially failed run, so dependencies are approved first on retries. - configure-npm-trust: a failed fetch, missing ref or empty package list is fatal instead of reporting success. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qDHk3SaPaDGpPTU1jjLpz --- package.json | 1 + pnpm-lock.yaml | 33 ++++++++--------------- scripts/configure-npm-trust.sh | 18 ++++++++++--- scripts/prepare-npm-snapshot.mjs | 5 +++- scripts/prepare-npm-snapshot.test.mjs | 15 ++++++++++- scripts/stage-npm-packages.mjs | 39 +++++++++++++++++---------- scripts/stage-npm-packages.test.mjs | 27 +++++++++++++++++++ 7 files changed, 97 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index 20542abb40..cce02b4966 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "lefthook": "^2.1.6", "prettier": "^3.8.3", "rimraf": "^6.1.3", + "semver": "^7", "syncpack": "^14.3.1", "turbo": "^2.9.14", "typescript": "^6.0.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34da039624..15784b9aec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: rimraf: specifier: ^6.1.3 version: 6.1.3 + semver: + specifier: ^7 + version: 7.8.5 syncpack: specifier: ^14.3.1 version: 14.3.1 @@ -3311,16 +3314,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - hasBin: true - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -4065,7 +4058,7 @@ snapshots: outdent: 0.5.0 prettier: 2.8.8 resolve-from: 5.0.0 - semver: 7.7.3 + semver: 7.8.5 '@changesets/assemble-release-plan@6.0.9': dependencies: @@ -4074,7 +4067,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 - semver: 7.7.3 + semver: 7.8.5 '@changesets/changelog-git@0.2.1': dependencies: @@ -4112,7 +4105,7 @@ snapshots: package-manager-detector: 0.2.11 picocolors: 1.1.1 resolve-from: 5.0.0 - semver: 7.7.3 + semver: 7.8.5 spawndamnit: 3.0.1 term-size: 2.2.1 transitivePeerDependencies: @@ -4137,7 +4130,7 @@ snapshots: '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 picocolors: 1.1.1 - semver: 7.7.3 + semver: 7.8.5 '@changesets/get-github-info@1.0.1': dependencies: @@ -4709,7 +4702,7 @@ snapshots: js-yaml: 4.1.1 ora: 4.1.1 rimraf: 3.0.2 - semver: 7.7.3 + semver: 7.8.5 update-check: 1.5.4 transitivePeerDependencies: - '@types/node' @@ -4839,7 +4832,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.50.0 debug: 4.4.3(supports-color@5.5.0) minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.8.5 tinyglobby: 0.2.15 ts-api-utils: 2.1.0(typescript@6.0.3) typescript: 6.0.3 @@ -6454,7 +6447,7 @@ snapshots: ignore-by-default: 1.0.1 minimatch: 10.2.2 pstree.remy: 1.1.8 - semver: 7.7.4 + semver: 7.8.5 simple-update-notifier: 2.0.0 supports-color: 5.5.0 touch: 3.1.1 @@ -6892,10 +6885,6 @@ snapshots: semver@6.3.1: {} - semver@7.7.3: {} - - semver@7.7.4: {} - semver@7.8.5: {} sentence-case@2.1.1: @@ -7001,7 +6990,7 @@ snapshots: simple-update-notifier@2.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 slash@3.0.0: {} diff --git a/scripts/configure-npm-trust.sh b/scripts/configure-npm-trust.sh index af6f5f32a5..b29a6e150d 100755 --- a/scripts/configure-npm-trust.sh +++ b/scripts/configure-npm-trust.sh @@ -12,18 +12,30 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" # Same npm the release workflow uses; --allow-stage-publish needs npm >= 11.15. NPM=(npx --yes npm@11.15.0) +die() { + echo "configure-npm-trust: $*" >&2 + exit 1 +} + packages_on() { local ref="$1" git -C "$ROOT" ls-tree -r --name-only "$ref" -- packages | grep -E '^packages/([^/]+/)?[^/]+/package\.json$' | while read -r manifest; do - git -C "$ROOT" show "$ref:$manifest" | jq -r 'select(.private != true and .name != null) | .name' + git -C "$ROOT" show "$ref:$manifest" | jq -r 'select(.private != true and .name != null) | .name' || return 1 done } -git -C "$ROOT" fetch --quiet origin master v2 +# Stale or missing refs would configure the wrong package set, so every step here is fatal. +git -C "$ROOT" fetch --quiet origin master v2 || die "could not fetch origin/master and origin/v2" +names="" +for ref in origin/master origin/v2; do + ref_names="$(packages_on "$ref")" || die "could not list packages on $ref" + [ -n "$ref_names" ] || die "no publishable packages found on $ref" + names+="$ref_names"$'\n' +done PACKAGES=() -while read -r package; do PACKAGES+=("$package"); done < <({ packages_on origin/master; packages_on origin/v2; } | sort -u) +while read -r package; do [ -n "$package" ] && PACKAGES+=("$package"); done < <(printf '%s' "$names" | sort -u) echo "Configuring stage-only trusted publishing ($REPO, $WORKFLOW) for ${#PACKAGES[@]} packages." failed=() diff --git a/scripts/prepare-npm-snapshot.mjs b/scripts/prepare-npm-snapshot.mjs index 6c9afa933e..5b14848eaa 100644 --- a/scripts/prepare-npm-snapshot.mjs +++ b/scripts/prepare-npm-snapshot.mjs @@ -4,6 +4,8 @@ import { readFile, unlink, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' +import semver from 'semver' + // Versions every publishable package as `0.0.0--` on the CI runner (never // committed) so stage-npm-packages.mjs can stage a throwaway snapshot. See PUBLISHING.md. @@ -18,7 +20,8 @@ export function validateSnapshotTag(tag) { /^[a-z][a-z0-9._-]*$/.test(tag), 'The snapshot tag must start with a lowercase letter and contain only lowercase letters, numbers, dots, underscores, or hyphens.', ) - assert(!/^v?\d+(?:\.\d+){0,2}(?:[-+].*)?$/.test(tag), 'The snapshot tag must not look like a release version.') + // npm rejects any dist-tag it can parse as a semver range (e.g. '1.2.3', 'v1.x', 'x'). + assert.equal(semver.validRange(tag), null, 'The snapshot tag must not be a version or semver range.') } // Every publishable package is bumped so snapshot packages only ever depend on each other, diff --git a/scripts/prepare-npm-snapshot.test.mjs b/scripts/prepare-npm-snapshot.test.mjs index 8ebfe6138c..5fbd62932d 100644 --- a/scripts/prepare-npm-snapshot.test.mjs +++ b/scripts/prepare-npm-snapshot.test.mjs @@ -10,7 +10,20 @@ test('accepts safe snapshot tags', () => { }) test('rejects release dist-tags, version-like, and malformed snapshot tags', () => { - for (const tag of ['', 'latest', 'v2-legacy', '3.0.14', 'v3.0.14', '3.0.14-beta.1', 'beta tag', 'Beta']) { + for (const tag of [ + '', + 'latest', + 'v2-legacy', + '3.0.14', + 'v3.0.14', + '3.0.14-beta.1', + 'beta tag', + 'Beta', + 'x', + 'x.x', + 'v1.x', + 'v3', + ]) { assert.throws(() => validateSnapshotTag(tag)) } }) diff --git a/scripts/stage-npm-packages.mjs b/scripts/stage-npm-packages.mjs index 69127605d2..643ca1c47e 100644 --- a/scripts/stage-npm-packages.mjs +++ b/scripts/stage-npm-packages.mjs @@ -160,26 +160,22 @@ async function main() { const releasePackages = await readReleasePackages() const releaseStates = releasePackages.map((releasePackage) => { const tag = `${releasePackage.name}@${releasePackage.version}` + // A tagged but unpublished version was staged by an earlier run and still awaits approval. const tagged = createGitTags && hasGitTag(tag) - const published = tagged ? false : isPublished(releasePackage) + const published = isPublished(releasePackage) return { releasePackage, tag, tagged, published } }) const packagesToStage = releaseStates.filter((state) => !state.tagged && !state.published) - if (packagesToStage.length === 0) { - console.log('No unstaged package versions were found.') - await writeSummary(['### npm staged publishing', '', 'No package versions needed staging.']) - return - } - - const outputDirectory = await mkdtemp(path.join(os.tmpdir(), 'sequence-npm-stage-')) - const stagedPackages = [] + const outputDirectory = + packagesToStage.length > 0 ? await mkdtemp(path.join(os.tmpdir(), 'sequence-npm-stage-')) : null + const stagedNow = new Set() for (const { releasePackage } of packagesToStage) { const tarballPath = await packPackage(releasePackage, outputDirectory) runChecked('npm', ['stage', 'publish', tarballPath, '--tag', distTag, '--access', 'public', '--provenance'], { stdio: 'inherit', }) - stagedPackages.push(releasePackage) + stagedNow.add(releasePackage.name) // changesets/action v1 creates this package's tag and GitHub Release from this line, // even if a later package fails. A retry can then skip the already-staged package by tag. if (createGitTags) { @@ -187,15 +183,30 @@ async function main() { } } - await writeSummary([ + const summary = approvalSummary(releaseStates, stagedNow, distTag) + console.log(summary.join('\n')) + await writeSummary(summary) +} + +// Lists every unpublished version of the release in dependency order, including versions an +// earlier (partially failed) run staged, so maintainers approve dependencies first. +export function approvalSummary(releaseStates, stagedNow, distTag) { + const pending = releaseStates.filter((state) => !state.published) + if (pending.length === 0) { + return ['### npm staged publishing', '', 'No package versions needed staging.'] + } + return [ '### npm staged publishing', '', - 'Approve in this order (dependencies first):', + `Approve in this order (dependencies first); all are staged under \`${distTag}\`:`, '', - ...stagedPackages.map(({ name, version }) => `1. \`${name}@${version}\` is staged under \`${distTag}\`.`), + ...pending.map(({ releasePackage: { name, version } }) => { + const origin = stagedNow.has(name) ? 'staged by this run' : 'staged by an earlier run' + return `1. \`${name}@${version}\` (${origin})` + }), '', 'A maintainer must approve every staged package on npm with 2FA before it is installable.', - ]) + ] } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/scripts/stage-npm-packages.test.mjs b/scripts/stage-npm-packages.test.mjs index f001144f39..09e4a36694 100644 --- a/scripts/stage-npm-packages.test.mjs +++ b/scripts/stage-npm-packages.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { + approvalSummary, assertTrustedPublishingEnvironment, findWorkspaceReferences, orderByDependencies, @@ -88,3 +89,29 @@ test('requires GitHub Actions OIDC before staging', () => { }), ) }) + +test('approval summary keeps earlier-run stages in dependency order and skips published versions', () => { + const state = (name, published) => ({ releasePackage: { name, version: '3.0.14' }, published }) + const summary = approvalSummary( + [state('primitives', false), state('abi', true), state('core', false), state('wdk', false)], + new Set(['core', 'wdk']), + 'latest', + ) + assert.deepEqual( + summary.filter((line) => line.startsWith('1. ')), + [ + '1. `primitives@3.0.14` (staged by an earlier run)', + '1. `core@3.0.14` (staged by this run)', + '1. `wdk@3.0.14` (staged by this run)', + ], + ) +}) + +test('approval summary reports when nothing is pending', () => { + const summary = approvalSummary( + [{ releasePackage: { name: 'abi', version: '3.0.14' }, published: true }], + new Set(), + 'latest', + ) + assert.match(summary.join('\n'), /No package versions needed staging/) +})