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/.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 @@ +--- +--- 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..9a5afbdefd --- /dev/null +++ b/.github/workflows/npm-release-trigger.yml @@ -0,0 +1,126 @@ +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 + +# 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: + # 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 + persist-credentials: false + - 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. + # --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: + 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 + 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 + 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 --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' + 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..04f490cdd3 --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,99 @@ +# 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 (`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. +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` (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 + authentication and disallow tokens" and revoke old automation tokens. +- **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 584df72a68..cce02b4966 100644 --- a/package.json +++ b/package.json @@ -18,13 +18,18 @@ "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": "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", "@changesets/cli": "^2.29.8", "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/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..15784b9aec 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) @@ -23,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 @@ -670,6 +676,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 +693,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 +727,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 +1943,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'} @@ -3293,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'} @@ -4047,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: @@ -4056,12 +4067,17 @@ 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: '@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 @@ -4089,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: @@ -4114,7 +4130,11 @@ 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: + dataloader: 2.2.3 '@changesets/get-release-plan@4.0.14': dependencies: @@ -4170,6 +4190,8 @@ snapshots: '@changesets/types@6.1.0': {} + '@changesets/types@7.0.0': {} + '@changesets/write@0.4.0': dependencies: '@changesets/types': 6.1.0 @@ -4680,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' @@ -4810,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 @@ -5265,6 +5287,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 @@ -6423,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 @@ -6861,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: @@ -6970,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 new file mode 100755 index 0000000000..b29a6e150d --- /dev/null +++ b/scripts/configure-npm-trust.sh @@ -0,0 +1,54 @@ +#!/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). +# 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) + +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' || return 1 + done +} + +# 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 [ -n "$package" ] && PACKAGES+=("$package"); done < <(printf '%s' "$names" | 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: npx npm@11.15.0 trust list " diff --git a/scripts/prepare-npm-snapshot.mjs b/scripts/prepare-npm-snapshot.mjs new file mode 100644 index 0000000000..5b14848eaa --- /dev/null +++ b/scripts/prepare-npm-snapshot.mjs @@ -0,0 +1,110 @@ +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' + +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. + +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.', + ) + // 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, +// 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..5fbd62932d --- /dev/null +++ b/scripts/prepare-npm-snapshot.test.mjs @@ -0,0 +1,52 @@ +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', + 'x', + 'x.x', + 'v1.x', + 'v3', + ]) { + 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..643ca1c47e --- /dev/null +++ b/scripts/stage-npm-packages.mjs @@ -0,0 +1,217 @@ +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}` + // A tagged but unpublished version was staged by an earlier run and still awaits approval. + const tagged = createGitTags && hasGitTag(tag) + const published = isPublished(releasePackage) + return { releasePackage, tag, tagged, published } + }) + const packagesToStage = releaseStates.filter((state) => !state.tagged && !state.published) + + 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', + }) + 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) { + console.log(`New tag: ${releasePackage.name}@${releasePackage.version}`) + } + } + + 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); all are 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) { + 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..09e4a36694 --- /dev/null +++ b/scripts/stage-npm-packages.test.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + approvalSummary, + 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', + }), + ) +}) + +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/) +})