From 18f0ae7cacb21ed285e215a12420fe2edf3ba24f Mon Sep 17 00:00:00 2001 From: lbb00 Date: Mon, 14 Sep 2026 16:20:20 +0800 Subject: [PATCH 1/3] chore(release): adopt changesets for automated publishing Replace the manual GitHub-Release-triggers-publish flow with Changesets: merging a version PR to main now drives the release instead of a human cutting a Release and picking a dist-tag by hand. @devicekit/devices and @devicekit/frame stay fixed together since they've always shipped as one version. Retires npm-dist-tag.mjs and its test (dist-tag resolution is now changesets' job) and the merge-base guard in publish.yml (push-to-main triggers can't point at an arbitrary branch the way a Release could), while keeping the main-branch restriction under a renamed release-guard.test.mjs. --- .changeset/README.md | 5 + .changeset/config.json | 11 + .github/workflows/publish.yml | 73 ----- .github/workflows/release.yml | 46 +++ CHANGELOG.md | 4 +- CONTRIBUTING.md | 53 ++-- package.json | 4 +- pnpm-lock.yaml | 283 ++++++++++++++++-- scripts/npm-dist-tag.mjs | 75 ----- scripts/npm-dist-tag.test.mjs | 62 ---- ...-guard.test.mjs => release-guard.test.mjs} | 0 11 files changed, 356 insertions(+), 260 deletions(-) create mode 100644 .changeset/README.md create mode 100644 .changeset/config.json delete mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/release.yml delete mode 100644 scripts/npm-dist-tag.mjs delete mode 100644 scripts/npm-dist-tag.test.mjs rename scripts/{publish-guard.test.mjs => release-guard.test.mjs} (100%) diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..23db17c --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,5 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a tool to manage versioning and changelogs. Read the full documentation at https://changesets.dev. + +We also have a quick list of common questions to get you started engaging with this project in our [FAQ](https://changesets.dev/faq). diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..6c6e838 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@4.0.0/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [["@devicekit/devices", "@devicekit/frame"]], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index aaedbd6..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: Publish - -on: - release: - types: [published] - workflow_dispatch: - inputs: - tag: - # Manual dispatch only ever runs against main (see job-level `if:` - # below) — this picks the npm dist-tag for that publish, not the ref. - description: npm dist-tag to publish under (manual runs only work from main) - type: choice - options: - - latest - - next - default: latest - -permissions: - contents: read - id-token: write - -jobs: - publish: - runs-on: ubuntu-latest - # release: a GitHub Release can point at any branch or old commit, so this - # alone doesn't prove main — the "Refuse commits that are not on main" - # step below is what actually guards npm. - # workflow_dispatch: restrict to main so a feature branch can't be - # manually published to npm. - if: github.event_name == 'release' || github.ref == 'refs/heads/main' - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - - - name: Refuse commits that are not on main - # A Release can point at any branch or old commit; only history that - # main already contains may reach npm. - run: | - git fetch --no-tags --depth=2147483647 origin main - git merge-base --is-ancestor "$GITHUB_SHA" origin/main || { - echo "::error::$GITHUB_SHA is not an ancestor of origin/main; refusing to publish" - exit 1 - } - - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 - - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version-file: .node-version - cache: pnpm - registry-url: https://registry.npmjs.org - - - run: pnpm install --frozen-lockfile - - - run: pnpm build - - - run: pnpm test - - - run: pnpm run verify:pack - - - name: Resolve npm dist-tag - id: dist-tag - run: node scripts/npm-dist-tag.mjs - env: - EVENT_NAME: ${{ github.event_name }} - REQUESTED_TAG: ${{ inputs.tag }} - RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} - - - run: pnpm publish -r --access public --provenance --no-git-checks --tag "$NPM_TAG" - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - # The version strings actually being published decide the tag, not - # just the release checkbox / dispatch input — see scripts/npm-dist-tag.mjs. - NPM_TAG: ${{ steps.dist-tag.outputs.tag }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b293ede --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,46 @@ +name: Release + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: write + pull-requests: write + id-token: write + +jobs: + release: + runs-on: ubuntu-latest + # push: already only fires on main. workflow_dispatch: restrict to main so + # a feature branch can't be manually dispatched into a publish. + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version-file: .node-version + cache: pnpm + registry-url: https://registry.npmjs.org + + - name: Check npm CLI version + run: npm -v + + - run: pnpm install --frozen-lockfile + + - uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 + with: + publish-script: pnpm release + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # pnpm release runs `changeset publish` directly, not `pnpm publish`, + # so there is no `--provenance` flag to pass; this env var is how + # npm's provenance attestation gets turned on for that path instead. + NPM_CONFIG_PROVENANCE: "true" diff --git a/CHANGELOG.md b/CHANGELOG.md index b201816..259445a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +Changes after 0.4.0 are recorded per package by Changesets, in +[`packages/devices/CHANGELOG.md`](packages/devices/CHANGELOG.md) and +[`packages/frame/CHANGELOG.md`](packages/frame/CHANGELOG.md). ## [0.4.0] - 2026-09-14 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44a4af2..e2dc935 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,10 @@ pnpm --filter @devicekit/frame demo - `pnpm lint` must report zero warnings. - Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/): `feat: ...`, `fix: ...`, `docs: ...`, `chore: ...`, etc. +- Any user-facing change to `@devicekit/devices` or `@devicekit/frame` needs a + changeset: run `pnpm changeset` and follow the prompts. It writes a small + Markdown file describing the change and its semver bump; CI checks it in + along with the PR. ## Adding a new device @@ -39,35 +43,26 @@ entry, so run `pnpm --filter @devicekit/devices test` after adding one. - Keep a PR focused on one change. - CI must be green (build, test, lint, check-types) before merge. -- Update `CHANGELOG.md` under `## [Unreleased]` for user-facing changes. +- Include a changeset (see above) for any user-facing change. ## Releasing -Bump `version` in the package's `package.json`, then turn the accumulated -`## [Unreleased]` section of `CHANGELOG.md` into `## [x.y.z] - YYYY-MM-DD` and -open a fresh, empty `## [Unreleased]` above it. Merge to main and publish a -GitHub Release; the Publish workflow does the rest. - -The dist-tag a version publishes under is decided by the version strings -themselves, not just the Release's pre-release checkbox or a manual dispatch -input: any version with a semver prerelease segment (`1.2.0-beta.1`) always -goes out as `next`, a Release marked pre-release goes out as `next` even for -a stable version number, and asking for `latest` on a prerelease version -fails the workflow instead of publishing it. See `scripts/npm-dist-tag.mjs` -for the exact rule and `scripts/npm-dist-tag.test.mjs` for the cases it -covers. - -Before publishing, the workflow also runs `pnpm run verify:pack`: it builds -every package, packs it the way `pnpm publish` would, unpacks the tarball, -and loads it from a directory outside the workspace — catching an `exports` -map that points at a file the build never produced, which `pnpm test` alone -would not, since the workspace's own `exports` point straight at `src/*.ts`. - -A Release pointing at a commit that isn't on `main` (e.g. cut from a feature -branch or an old commit) is refused before anything is published. - -A manual `workflow_dispatch` run of Publish only does anything on `main` — -the job is a no-op on any other branch, so a dispatch from a feature branch -can't push a build to npm. Every `uses:` in the workflows is pinned to a -commit SHA rather than a mutable tag; Dependabot opens the PRs that move -those pins forward. +Releases are automated with [Changesets](https://github.com/changesets/changesets) +— there's nothing to bump or publish by hand. Merging a PR with changesets +into `main` makes the Release workflow open (or update) a "Version Packages" +PR that applies the accumulated changesets: bumping `@devicekit/devices` and +`@devicekit/frame` together (they're a fixed group, so they always ship the +same version) and writing each package's own `CHANGELOG.md`. Merging that PR +is what actually publishes to npm. + +Before publishing, the same `pnpm release` script the workflow runs also runs +`pnpm run verify:pack`: it builds every package, packs it the way `pnpm +publish` would, unpacks the tarball, and loads it from a directory outside +the workspace — catching an `exports` map that points at a file the build +never produced, which `pnpm test` alone would not, since the workspace's own +`exports` point straight at `src/*.ts`. + +The Release workflow only does anything on `main` — a manual +`workflow_dispatch` run is a no-op on any other branch. Every `uses:` in the +workflows is pinned to a commit SHA rather than a mutable tag; Dependabot +opens the PRs that move those pins forward. diff --git a/package.json b/package.json index 4b20127..95906a6 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,11 @@ "test:coverage": "pnpm -r test:coverage", "lint": "oxlint .", "verify:pack": "pnpm -r build && node scripts/verify-pack.mjs", - "images": "node scripts/render-readme-images.mjs" + "images": "node scripts/render-readme-images.mjs", + "release": "pnpm build && pnpm test && pnpm run verify:pack && changeset publish" }, "devDependencies": { + "@changesets/cli": "^3.0.2", "oxfmt": "0.67.0", "oxlint": "1.82.0", "typescript": "5.9.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0963800..39aedea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + '@changesets/cli': + specifier: ^3.0.2 + version: 3.0.2 oxfmt: specifier: 0.67.0 version: 0.67.0 @@ -28,7 +31,7 @@ importers: version: 5.9.2 vitest: specifier: ^4.1.4 - version: 4.1.11(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1)(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1)(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) packages/frame: dependencies: @@ -47,13 +50,13 @@ importers: version: 18.3.7(@types/react@18.3.31) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.1.1(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) '@vitest/coverage-v8': specifier: ^4.1.4 version: 4.1.11(vitest@4.1.11) astro: specifier: 7.3.2 - version: 7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(jiti@2.7.0) + version: 7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(jiti@2.7.0)(yaml@2.9.1) jsdom: specifier: ^29.0.2 version: 29.1.1 @@ -68,10 +71,10 @@ importers: version: 5.9.2 vite: specifier: ^8.0.16 - version: 8.2.2(esbuild@0.28.2)(jiti@2.7.0) + version: 8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) vitest: specifier: ^4.1.4 - version: 4.1.11(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1)(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1)(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) packages: @@ -247,6 +250,67 @@ packages: resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} engines: {node: '>=18'} + '@changesets/apply-release-plan@8.1.0': + resolution: {integrity: sha512-M93HOGyX3ssg6He3b5NotaHtQVu6uajHS6UIQ28xKt2RzskCy73ayX4I914qBKjTJmrE4YFlELQjfcqxbmGLJw==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/assemble-release-plan@7.0.0': + resolution: {integrity: sha512-oEW8BxdA604kGGtDSCiHr5w9Tv4UWe9I2k61IBNZzCOE1kbYaJj4v+lFQNgcEZFkUc2pV/+hASErGDvpJOZCTg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/changelog-git@1.0.0': + resolution: {integrity: sha512-3Dst2Ime2Op5nd4XmWJLPIgp11ZFqJqSkVug9izK6TDcIV4YlhPS4ECbEVR+eGI0bk0r1ItogD4j2Oli87bJrA==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/cli@3.0.2': + resolution: {integrity: sha512-t/omGJj/I+Jv0kmJAkj5cstYEdUQiJnpip2F+2m3F4lQ3kAZ3o8Exep4/Fm1qq4rvw6ovlhJvZNrKdwLJ4lkuQ==} + engines: {node: ^22.11 || ^24 || >=26, npm: '>=10.9.0', pnpm: '>=10.0.0', yarn: '>=4.5.2'} + hasBin: true + + '@changesets/config@4.0.0': + resolution: {integrity: sha512-mw95/YrkOuhZZxfnVAA4bSXOFUi+KlhzOBTM8C4x777NhUU6HWIl9Z+K+nME+E4PVsv5NQVQwTfiHihAS1A/ow==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/errors@1.0.0': + resolution: {integrity: sha512-ElN/mEzn6zmETgjwf5MclCMa9ef59sAR0lfO8VSYIsiRvbC2FbLB/92EoYw10Sl0kGixxHFiJZUSv7dA+YpR8g==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/format@0.1.2': + resolution: {integrity: sha512-Caez5XtNXCFS/G5bwyav3wuXL0tMxVd2ZGbaumWbzN08tyzO21asCw7JZhNtVsAZDCvDRUzZN+Iit9SyRITSYA==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/get-dependents-graph@3.0.0': + resolution: {integrity: sha512-ji/t5wFA1zREKXRUePE6Qi+Qu2UgxCeSSGQrphezwvQZrp49B7sJ+8+wvM0tA7zPeSxYKCojDy3WWgrl+s+awg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/git@4.0.1': + resolution: {integrity: sha512-6vWpIAC4LkpmlqaIu37ViT5enyd9+uBwAiGqCGhASvkSHBgx4PrtTGXWTMFYpDmZbjgyonyVgMciPrW4MqtJsA==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/parse@1.0.0': + resolution: {integrity: sha512-P0iaMb9p9CRYZiTgAllEIF9AUMQHIy1G72tKlcIqJp61icZSsKQNiOPdxAMZG8m/DvZwt/oz5xEbTpike//dWg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/pre@3.0.0': + resolution: {integrity: sha512-Zm/6YliV/a2oeWTqHJf6KxLrQwgcK1i/BRDl2m0EKZvbnxV5fG9QRhwJJGshjsZTUTS6dkfURQ2K6aAvgNw/3Q==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/read@1.0.1': + resolution: {integrity: sha512-nzvSC6RcTiWf/dsuwZFXpLzGGsRHYCL68U3uHV7srsulU93aVWY7u2GEJiDZ+4GIIElfaW5EqtKC0UHroY+LTQ==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/should-skip-package@1.0.0': + resolution: {integrity: sha512-pwqoJmbONn1XgXmZXPEExgAaT+HdZLjALFTDgIm+PnS5KeO2nLtzA2/Q+4aMFY14kFMuXKG30MObhvDzWgzDgg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/types@7.0.0': + resolution: {integrity: sha512-c5GoiQyt3pxiXjrWSNoP8/GRf4kG+VnKzovx1OQM8dYYALlSwgedmkPmJ+ZqGxqwg9D3Bkj85Uo4KLd5BN3A0w==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/write@1.0.1': + resolution: {integrity: sha512-q/ThtP9gcnEP6xlv7LrY26C2mHqdGBti/MmOVngt/M/oIGYkssmQGxPK9WzBNt2juVcH/vml2WQ+ra8LXYOTaA==} + engines: {node: ^22.11 || ^24 || >=26} + '@clack/core@1.5.0': resolution: {integrity: sha512-zNikCcd8BbcEvzzG1sbXFrRHFk5kHPrpwZwksPvf9qyQO1Teb7JaXaOAxXZei9nZLDW0gaZawiuTCji88bTBhw==} engines: {node: '>= 20.12.0'} @@ -624,6 +688,18 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@manypkg/find-root@3.1.0': + resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==} + engines: {node: '>=20.0.0'} + + '@manypkg/get-packages@3.1.0': + resolution: {integrity: sha512-0TbBVyvPrP7xGYBI/cP8UP+yl/z+HtbTttAD7FMAJgn/kXOTwh5/60TsqP9ZYY710forNfyV0N8P/IE/ujGZJg==} + engines: {node: '>=20.0.0'} + + '@manypkg/tools@2.1.2': + resolution: {integrity: sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ==} + engines: {node: '>=20.0.0'} + '@napi-rs/wasm-runtime@1.2.4': resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -865,6 +941,10 @@ packages: cpu: [x64] os: [win32] + '@pnpm/deps.graph-sequencer@1100.0.1': + resolution: {integrity: sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A==} + engines: {node: '>=22.13'} + '@rolldown/binding-android-arm-eabi@1.2.7': resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1165,6 +1245,10 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1390,6 +1474,13 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + human-id@4.2.1: + resolution: {integrity: sha512-zPGsiS+dWoTZtZ4AtpA9Y+BdSFSNWvnouNlWNoUFyAM6xHOHmdCvqO3k8AIbdamCOv4gUFUVNPf6rJFfc4UiJw==} + hasBin: true + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -1421,6 +1512,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1443,6 +1537,9 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + launch-editor@2.14.1: + resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -1762,6 +1859,10 @@ packages: '@types/node': optional: true + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + engines: {node: '>= 0.4'} + shiki@4.4.3: resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} engines: {node: '>=20'} @@ -2088,6 +2189,11 @@ packages: xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@22.0.0: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} @@ -2269,6 +2375,110 @@ snapshots: dependencies: fontkitten: 1.0.3 + '@changesets/apply-release-plan@8.1.0': + dependencies: + '@changesets/config': 4.0.0 + '@changesets/format': 0.1.2 + '@changesets/git': 4.0.1 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + import-meta-resolve: 4.2.0 + jsonc-parser: 3.3.1 + semver: 7.8.5 + + '@changesets/assemble-release-plan@7.0.0': + dependencies: + '@changesets/errors': 1.0.0 + '@changesets/get-dependents-graph': 3.0.0 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + semver: 7.8.5 + + '@changesets/changelog-git@1.0.0': + dependencies: + '@changesets/types': 7.0.0 + + '@changesets/cli@3.0.2': + dependencies: + '@changesets/apply-release-plan': 8.1.0 + '@changesets/assemble-release-plan': 7.0.0 + '@changesets/changelog-git': 1.0.0 + '@changesets/config': 4.0.0 + '@changesets/errors': 1.0.0 + '@changesets/get-dependents-graph': 3.0.0 + '@changesets/git': 4.0.1 + '@changesets/pre': 3.0.0 + '@changesets/read': 1.0.1 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + '@changesets/write': 1.0.1 + '@clack/prompts': 1.8.0 + '@manypkg/get-packages': 3.1.0 + '@pnpm/deps.graph-sequencer': 1100.0.1 + cac: 7.0.0 + import-meta-resolve: 4.2.0 + launch-editor: 2.14.1 + package-manager-detector: 1.8.0 + semver: 7.8.5 + tinyexec: 1.3.1 + + '@changesets/config@4.0.0': + dependencies: + '@changesets/get-dependents-graph': 3.0.0 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + '@manypkg/get-packages': 3.1.0 + picomatch: 4.0.7 + + '@changesets/errors@1.0.0': {} + + '@changesets/format@0.1.2': + dependencies: + package-manager-detector: 1.8.0 + tinyexec: 1.3.1 + + '@changesets/get-dependents-graph@3.0.0': + dependencies: + '@changesets/types': 7.0.0 + semver: 7.8.5 + + '@changesets/git@4.0.1': + dependencies: + '@changesets/errors': 1.0.0 + '@changesets/types': 7.0.0 + '@manypkg/get-packages': 3.1.0 + picomatch: 4.0.7 + tinyexec: 1.3.1 + + '@changesets/parse@1.0.0': + dependencies: + '@changesets/types': 7.0.0 + yaml: 2.9.1 + + '@changesets/pre@3.0.0': + dependencies: + '@changesets/errors': 1.0.0 + '@changesets/types': 7.0.0 + '@manypkg/get-packages': 3.1.0 + + '@changesets/read@1.0.1': + dependencies: + '@changesets/git': 4.0.1 + '@changesets/parse': 1.0.0 + '@changesets/types': 7.0.0 + + '@changesets/should-skip-package@1.0.0': + dependencies: + '@changesets/types': 7.0.0 + + '@changesets/types@7.0.0': {} + + '@changesets/write@1.0.1': + dependencies: + '@changesets/format': 0.1.2 + '@changesets/types': 7.0.0 + human-id: 4.2.1 + '@clack/core@1.5.0': dependencies: fast-wrap-ansi: 0.2.2 @@ -2522,6 +2732,21 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@manypkg/find-root@3.1.0': + dependencies: + '@manypkg/tools': 2.1.2 + + '@manypkg/get-packages@3.1.0': + dependencies: + '@manypkg/find-root': 3.1.0 + '@manypkg/tools': 2.1.2 + + '@manypkg/tools@2.1.2': + dependencies: + jju: 1.4.0 + tinyglobby: 0.2.17 + yaml: 2.9.1 + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -2647,6 +2872,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.82.0': optional: true + '@pnpm/deps.graph-sequencer@1100.0.1': {} + '@rolldown/binding-android-arm-eabi@1.2.7': optional: true @@ -2804,10 +3031,10 @@ snapshots: '@ungap/structured-clone@1.4.0': {} - '@vitejs/plugin-react@6.1.1(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0))': + '@vitejs/plugin-react@6.1.1(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.2(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': dependencies: @@ -2821,7 +3048,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1)(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)) + vitest: 4.1.11(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1)(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) '@vitest/expect@4.1.11': dependencies: @@ -2832,13 +3059,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0))': + '@vitest/mocker@4.1.11(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) '@vitest/pretty-format@4.1.11': dependencies: @@ -2893,7 +3120,7 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 - astro@7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(jiti@2.7.0): + astro@7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(jiti@2.7.0)(yaml@2.9.1): dependencies: '@astrojs/compiler-rs': 0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) '@astrojs/internal-helpers': 0.11.0 @@ -2943,8 +3170,8 @@ snapshots: ultrahtml: 1.7.0 unifont: 0.7.5 unstorage: 1.17.5 - vite: 8.2.2(esbuild@0.28.2)(jiti@2.7.0) - vitefu: 1.1.3(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)) + vite: 8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) + vitefu: 1.1.3(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.5.4 @@ -2994,6 +3221,8 @@ snapshots: boolbase@1.0.0: {} + cac@7.0.0: {} + ccount@2.0.1: {} chai@6.2.2: {} @@ -3225,6 +3454,10 @@ snapshots: http-cache-semantics@4.2.0: {} + human-id@4.2.1: {} + + import-meta-resolve@4.2.0: {} + iron-webcrypto@1.2.1: {} is-docker@4.0.0: {} @@ -3249,6 +3482,8 @@ snapshots: jiti@2.7.0: optional: true + jju@1.4.0: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -3285,6 +3520,11 @@ snapshots: jsonc-parser@3.3.1: {} + launch-editor@2.14.1: + dependencies: + picocolors: 1.1.1 + shell-quote: 1.10.0 + lightningcss-android-arm64@1.33.0: optional: true @@ -3643,6 +3883,8 @@ snapshots: '@img/sharp-win32-x64': 0.35.4 optional: true + shell-quote@1.10.0: {} + shiki@4.4.3: dependencies: '@shikijs/core': 4.4.3 @@ -3799,7 +4041,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0): + vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -3810,15 +4052,16 @@ snapshots: esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 + yaml: 2.9.1 - vitefu@1.1.3(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)): + vitefu@1.1.3(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)): optionalDependencies: - vite: 8.2.2(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) - vitest@4.1.11(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1)(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)): + vitest@4.1.11(@vitest/coverage-v8@4.1.11)(jsdom@29.1.1)(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)) + '@vitest/mocker': 4.1.11(vite@8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -3835,7 +4078,7 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.2.2(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) @@ -3870,6 +4113,8 @@ snapshots: xxhash-wasm@1.1.0: {} + yaml@2.9.1: {} + yargs-parser@22.0.0: {} yocto-queue@1.2.2: {} diff --git a/scripts/npm-dist-tag.mjs b/scripts/npm-dist-tag.mjs deleted file mode 100644 index c966a5e..0000000 --- a/scripts/npm-dist-tag.mjs +++ /dev/null @@ -1,75 +0,0 @@ -// resolveDistTag() is the decision publish.yml's NPM_TAG defers to. Two -// independent signals can mean "prerelease" — a version string with a -// semver prerelease segment (1.0.0-beta.1) and the release's own prerelease -// checkbox — and either one routes the publish to `next` regardless of what -// the other says, because `npm install` resolving a half-baked version by -// default is worse than a workflow that has to be told `next` explicitly. -import { appendFileSync, readdirSync, readFileSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' - -function isPrereleaseVersion(version) { - return /^\d+\.\d+\.\d+-/.test(version) -} - -/** - * @param {{ versions: string[], requested: string, eventName: string, prerelease: boolean }} input - * @returns {'latest' | 'next'} - */ -export function resolveDistTag({ versions, requested, eventName, prerelease }) { - const prereleaseVersions = versions.filter(isPrereleaseVersion) - - if (requested === 'latest' && prereleaseVersions.length > 0) { - throw new Error( - `Refusing to publish "latest": ${prereleaseVersions.join(', ')} carries a prerelease version segment.`, - ) - } - - if (prereleaseVersions.length > 0) return 'next' - if (requested === 'next' || requested === 'latest') return requested - if (eventName === 'release') return prerelease ? 'next' : 'latest' - return 'latest' -} - -// --- CLI entry: reads the workspace's package versions and CI's env, then -// writes `tag=...` to $GITHUB_OUTPUT (and stdout, for a human reading logs). -function readWorkspaceVersions(rootDir) { - const packagesDir = path.join(rootDir, 'packages') - const versions = [] - for (const entry of readdirSync(packagesDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue - const pkgPath = path.join(packagesDir, entry.name, 'package.json') - try { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) - if (typeof pkg.version === 'string') versions.push(pkg.version) - } catch { - // No package.json (or an unparseable one) under this directory — not a workspace package. - } - } - return versions -} - -function main() { - const rootDir = fileURLToPath(new URL('..', import.meta.url)) - const versions = readWorkspaceVersions(rootDir) - const tag = resolveDistTag({ - versions, - requested: process.env.REQUESTED_TAG ?? '', - eventName: process.env.EVENT_NAME ?? '', - prerelease: process.env.RELEASE_PRERELEASE === 'true', - }) - - console.log(`dist-tag: ${tag} (versions: ${versions.join(', ') || '(none found)'})`) - - const outputFile = process.env.GITHUB_OUTPUT - if (outputFile) appendFileSync(outputFile, `tag=${tag}\n`) -} - -if (import.meta.url === pathToFileURL(process.argv[1]).href) { - try { - main() - } catch (err) { - console.error(err instanceof Error ? err.message : err) - process.exitCode = 1 - } -} diff --git a/scripts/npm-dist-tag.test.mjs b/scripts/npm-dist-tag.test.mjs deleted file mode 100644 index 96ff593..0000000 --- a/scripts/npm-dist-tag.test.mjs +++ /dev/null @@ -1,62 +0,0 @@ -// resolveDistTag() is the decision publish.yml's NPM_TAG should defer to. -// The workflow today only reads the release's prerelease checkbox / the -// workflow_dispatch input — it never looks at the version strings actually -// being published, so a prerelease version can be tagged `latest` if the -// checkbox was left unticked. These cases pin the contract: any published -// version with a semver prerelease segment must go out as `next`, and asking -// for `latest` anyway is a mistake worth failing loudly on rather than -// publishing silently wrong. -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { resolveDistTag } from './npm-dist-tag.mjs' - -test('a plain release of stable versions with no requested tag publishes latest', () => { - const tag = resolveDistTag({ - versions: ['1.2.0', '0.1.0'], - requested: '', - eventName: 'release', - prerelease: false, - }) - assert.equal(tag, 'latest') -}) - -test('a prerelease version with no requested tag falls back to next, not latest', () => { - const tag = resolveDistTag({ - versions: ['0.2.0-beta.1'], - requested: '', - eventName: 'release', - prerelease: false, - }) - assert.equal(tag, 'next') -}) - -test('a prerelease version explicitly requested as latest is refused', () => { - assert.throws(() => { - resolveDistTag({ - versions: ['0.2.0-beta.1'], - requested: 'latest', - eventName: 'workflow_dispatch', - prerelease: false, - }) - }) -}) - -test('stable versions from a release marked pre-release still publish as next', () => { - const tag = resolveDistTag({ - versions: ['1.2.0'], - requested: '', - eventName: 'release', - prerelease: true, - }) - assert.equal(tag, 'next') -}) - -test('a manual dispatch requesting next publishes next even for stable versions', () => { - const tag = resolveDistTag({ - versions: ['1.2.0'], - requested: 'next', - eventName: 'workflow_dispatch', - prerelease: false, - }) - assert.equal(tag, 'next') -}) diff --git a/scripts/publish-guard.test.mjs b/scripts/release-guard.test.mjs similarity index 100% rename from scripts/publish-guard.test.mjs rename to scripts/release-guard.test.mjs From bf36fe58440b4d7577cb11f883dc4e7fe5966d9e Mon Sep 17 00:00:00 2001 From: lbb00 Date: Mon, 14 Sep 2026 16:20:47 +0800 Subject: [PATCH 2/3] fix: rewrite release-guard.test.mjs content for release.yml The prior commit renamed publish-guard.test.mjs to release-guard.test.mjs but the git mv happened before the content rewrite got staged, so it was committed with its old publish.yml-parsing content under the new filename. --- scripts/release-guard.test.mjs | 104 ++++++++------------------------- 1 file changed, 24 insertions(+), 80 deletions(-) diff --git a/scripts/release-guard.test.mjs b/scripts/release-guard.test.mjs index bb9e0e9..441973a 100644 --- a/scripts/release-guard.test.mjs +++ b/scripts/release-guard.test.mjs @@ -1,17 +1,15 @@ -// publish.yml is the only thing standing between a GitHub Release and npm. -// The job's `if:` guards workflow_dispatch to main, but a *Release* can be cut -// against any branch or an old commit — the ref check does nothing for that -// trigger. The step that actually protects it is a `git merge-base -// --is-ancestor` check against origin/main, and it only protects anything if -// it runs before the workflow installs anything or talks to npm. This has no -// registry-published equivalent to lean on (unlike scripts/npm-dist-tag.mjs's -// pure resolveDistTag()) because the guard's safety is entirely about *step -// order* inside the workflow YAML, so this test parses that file directly. +// release.yml triggers on every push to main plus a manual workflow_dispatch. +// The push trigger already can't fire from another branch, but +// workflow_dispatch can be run from any branch in the Actions UI, so the +// job's `if:` is what stops a dispatch from a feature branch reaching npm. +// Unlike the old publish.yml (triggered by a GitHub Release, which can point +// at any branch or an old commit), there is no separate step-order guard to +// check here — the ref check alone is sufficient for both triggers. // // No YAML parser is a dependency of this repo (checked via `pnpm ls -r`), so // this is a line-based parser scoped to this one file's structure: a single -// `jobs.publish.steps` list, one item per `- ` at 6-space indent, each step's -// `run:` either inline or a `|` block scalar. It is not a general YAML parser. +// `jobs.release` block with a top-level `if:`. It is not a general YAML +// parser. import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' @@ -20,85 +18,31 @@ import path from 'node:path' const scriptsDir = path.dirname(fileURLToPath(import.meta.url)) const repoRoot = path.resolve(scriptsDir, '..') -const workflowPath = path.join(repoRoot, '.github/workflows/publish.yml') +const workflowPath = path.join(repoRoot, '.github/workflows/release.yml') -function parsePublishJob(text) { +function parseReleaseJob(text) { const lines = text.split('\n') const ifLine = lines.find((line) => /^ {4}if:\s*/.test(line)) const jobIf = ifLine ? ifLine.replace(/^ {4}if:\s*/, '').trim() : null - const stepsIndex = lines.findIndex((line) => /^ {4}steps:\s*$/.test(line)) - assert.ok(stepsIndex !== -1, 'could not find jobs.publish.steps in publish.yml') - const region = lines.slice(stepsIndex + 1) - - const stepStarts = [] - region.forEach((line, i) => { - if (/^ {6}-\s/.test(line)) stepStarts.push(i) - }) - assert.ok(stepStarts.length > 0, 'found no steps under jobs.publish.steps') - - const steps = stepStarts.map((start, idx) => { - const end = idx + 1 < stepStarts.length ? stepStarts[idx + 1] : region.length - const block = region.slice(start, end) - - let runText = '' - for (let i = 0; i < block.length; i++) { - const line = block[i] - const match = line.match(/^(\s*)(?:-\s+)?run:\s*(.*)$/) - if (!match) continue - const [, indent, inline] = match - const keyIndent = indent.length - if (inline && !/^[|>][-+0-9]*$/.test(inline.trim())) { - // `- run: pnpm install --frozen-lockfile` — the whole command is on this line. - runText = inline.trim() - } else { - // `run: |` (or `>`) — a block scalar; gather every more-indented line - // that follows, until the block dedents back to the key's own level. - const collected = [] - for (let j = i + 1; j < block.length; j++) { - const next = block[j] - if (next.trim() === '') continue - const nextIndent = next.match(/^(\s*)/)[1].length - if (nextIndent <= keyIndent) break - collected.push(next.trim()) - } - runText = collected.join('\n') - } - break - } - - return { runText } - }) - - return { jobIf, steps } + return { jobIf } } -test('jobs.publish.if still restricts workflow_dispatch to main', () => { - const { jobIf } = parsePublishJob(readFileSync(workflowPath, 'utf8')) - assert.ok(jobIf, 'expected jobs.publish.if to be present') +test('jobs.release.if restricts the job to main', () => { + const { jobIf } = parseReleaseJob(readFileSync(workflowPath, 'utf8')) + assert.ok(jobIf, 'expected jobs.release.if to be present') assert.match(jobIf, /refs\/heads\/main/) }) -test('a step runs `git merge-base --is-ancestor` against origin/main and GITHUB_SHA before install or publish', () => { - const { steps } = parsePublishJob(readFileSync(workflowPath, 'utf8')) - - const mergeBaseIndex = steps.findIndex( - (s) => s.runText.includes('git merge-base --is-ancestor') && s.runText.includes('GITHUB_SHA') && s.runText.includes('origin/main'), - ) - const installIndex = steps.findIndex((s) => s.runText.includes('pnpm install')) - const publishIndex = steps.findIndex((s) => s.runText.includes('pnpm publish')) - - assert.notEqual(mergeBaseIndex, -1, 'no step runs git merge-base --is-ancestor against GITHUB_SHA and origin/main') - assert.notEqual(installIndex, -1, 'no step runs pnpm install') - assert.notEqual(publishIndex, -1, 'no step runs pnpm publish') +test('the workflow triggers on push to main and workflow_dispatch', () => { + const text = readFileSync(workflowPath, 'utf8') + const onIndex = text.indexOf('\non:') + assert.notEqual(onIndex, -1, 'expected an `on:` trigger block') + const jobsIndex = text.indexOf('\njobs:') + const onBlock = text.slice(onIndex, jobsIndex === -1 ? undefined : jobsIndex) - assert.ok( - mergeBaseIndex < installIndex, - `the merge-base guard (step ${mergeBaseIndex}) must run before pnpm install (step ${installIndex}), or a release from a stale branch already has code on disk before being refused`, - ) - assert.ok( - mergeBaseIndex < publishIndex, - `the merge-base guard (step ${mergeBaseIndex}) must run before pnpm publish (step ${publishIndex}), or the check does not actually prevent the publish it is meant to block`, - ) + assert.match(onBlock, /push:/) + assert.match(onBlock, /branches:\s*\[main\]/) + assert.match(onBlock, /workflow_dispatch:/) }) From 3eda6abbde55f42639be89b3f21d1638dfcaee08 Mon Sep 17 00:00:00 2001 From: lbb00 Date: Mon, 14 Sep 2026 16:50:19 +0800 Subject: [PATCH 3/3] chore(release): switch to pure OIDC trusted publishing Gate the release job behind the npm-release GitHub environment and drop the NPM_TOKEN/provenance env vars now that both packages have npm trusted publisher entries configured for release.yml. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/release.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b293ede..0b64991 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,9 @@ permissions: jobs: release: runs-on: ubuntu-latest + # Must match the "Environment name" configured on each package's npm + # trusted publisher entry, or OIDC-based publishing is refused. + environment: npm-release # push: already only fires on main. workflow_dispatch: restrict to main so # a feature branch can't be manually dispatched into a publish. if: github.ref == 'refs/heads/main' @@ -38,9 +41,3 @@ jobs: - uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 with: publish-script: pnpm release - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - # pnpm release runs `changeset publish` directly, not `pnpm publish`, - # so there is no `--provenance` flag to pass; this env var is how - # npm's provenance attestation gets turned on for that path instead. - NPM_CONFIG_PROVENANCE: "true"