From f83352a0c9e34322ca08c256d64f64afa861f4d8 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 27 May 2026 13:44:50 +0100 Subject: [PATCH 01/14] docs: consolidate lifecycle script notes into one section --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1347177..e1b7255 100644 --- a/README.md +++ b/README.md @@ -81,10 +81,8 @@ jobs: # The chained dispatch from `release` lands here as a `workflow_dispatch` # event on a `vX.Y.Z` tag ref. The `pack` job installs deps, runs # `pnpm pack` (or `npm pack`), and uploads the tarball as a workflow - # artifact. Lifecycle scripts (`prepack`, `prepare`, `postpack`) run - # here, in a job with `permissions: {}` and no `npm` environment. - # Manual recovery uses the same path. - # (Run workflow -> pick a `v*` tag). + # artifact. See "Lifecycle scripts" below for what runs where. Manual + # recovery uses the same path (Run workflow -> pick a `v*` tag). pack: if: github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest @@ -167,15 +165,20 @@ This subaction installs the package's dependencies, runs `pnpm pack --json` (if This subaction downloads the tarball uploaded by `uppt/pack` in the same workflow run and runs `npm stage publish ./.tgz` with OIDC authentication. The staged version then needs to be approved by a maintainer with 2FA on npmjs.com before it goes live. -> [!IMPORTANT] -> `prepublishOnly` is **not** invoked: `uppt/publish` publishes the prebuilt tarball with `--ignore-scripts`. Move any logic you previously had in `prepublishOnly` into `prepack` so it runs during `uppt/pack` and the output lands in the tarball. - | Input | Default | Description | | --- | --- | --- | | `node-version` | `24` | Node version for the scripts and for `npm stage publish`. Needs `--experimental-strip-types` (Node 22.6+, 24+ recommended). | | `npm-access` | `public` | npm access level (`public` or `restricted`). | | `files` | _(scan artifact)_ | Optional JSON array of tarball filenames to publish, as emitted by `uppt/pack`'s `files` output. When omitted, every `*.tgz` in the downloaded artifact is published. | +## Lifecycle scripts + +uppt runs your package's lifecycle scripts at one specific point and skips them everywhere else. The aim is to keep the runner that produces the tarball from executing more third-party code than it has to. + +- **During install** (inside `uppt/pack`): runs with `--ignore-scripts`. Your dependencies' `preinstall` / `install` / `postinstall` hooks do **not** fire, and neither does your own repo's `prepare`. This is deliberate: it's why a compromised transitive dependency can't run code on the publish runner. If your build genuinely needs a dependency's `postinstall` to have run, set `install: false` on `uppt/pack` and install yourself before the action runs. +- **During pack** (inside `uppt/pack`, after install): `prepack`, `prepare`, and `postpack` run. This is where your build belongs. +- **During publish** (inside `uppt/publish`): nothing runs. `prepublishOnly` is **not** invoked; the prebuilt tarball is published with `--ignore-scripts`. Move any logic you previously had in `prepublishOnly` into `prepack` so it runs during `uppt/pack` and the output lands in the tarball. + ## Prerequisites For `pr` to work you need: From 51a3c06a851e50f6ad94f5989008a4308943374b Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 27 May 2026 13:44:55 +0100 Subject: [PATCH 02/14] feat: add workspace resolution for monorepo support --- package.json | 4 +- pnpm-lock.yaml | 990 +++++++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 2 + scripts/_workspaces.ts | 171 +++++++ test/_workspaces.test.ts | 196 ++++++++ tsconfig.json | 2 +- 6 files changed, 1363 insertions(+), 2 deletions(-) create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/_workspaces.ts create mode 100644 test/_workspaces.test.ts diff --git a/package.json b/package.json index 0ea0fef..3787667 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,13 @@ "author": "Daniel Roe ", "license": "MIT", "scripts": { + "test": "vitest run", "test:types": "tsgo --noEmit" }, "devDependencies": { "@types/node": "24.12.4", - "@typescript/native-preview": "7.0.0-dev.20260527.2" + "@typescript/native-preview": "7.0.0-dev.20260527.2", + "vitest": "^3.2.4" }, "packageManager": "pnpm@11.5.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcd4e8d..a34839b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,321 @@ importers: '@typescript/native-preview': specifier: 7.0.0-dev.20260527.2 version: 7.0.0-dev.20260527.2 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@24.12.4) packages: + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} @@ -67,11 +379,420 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + snapshots: + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + '@types/node@24.12.4': dependencies: undici-types: 7.16.0 @@ -107,4 +828,273 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260527.2 '@typescript/native-preview-win32-x64': 7.0.0-dev.20260527.2 + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.3(@types/node@24.12.4))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.3(@types/node@24.12.4) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + js-tokens@9.0.1: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + undici-types@7.16.0: {} + + vite-node@3.2.4(@types/node@24.12.4): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.3(@types/node@24.12.4) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.3(@types/node@24.12.4): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.4 + fsevents: 2.3.3 + + vitest@3.2.4(@types/node@24.12.4): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.3(@types/node@24.12.4)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.3(@types/node@24.12.4) + vite-node: 3.2.4(@types/node@24.12.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.4 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..49c0ad7 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: false diff --git a/scripts/_workspaces.ts b/scripts/_workspaces.ts new file mode 100644 index 0000000..b3acd9f --- /dev/null +++ b/scripts/_workspaces.ts @@ -0,0 +1,171 @@ +// Workspace resolution for the lockstep monorepo path. +// +// The maintainer declares the publishable workspaces in the workflow +// file via the `packages` input on `uppt/pr` and `uppt/pack`. Each line +// is either a literal directory path or a glob (e.g. `packages/*`). +// Negated patterns (`!packages/playground`) are supported. + +import { existsSync, globSync, readFileSync, statSync } from 'node:fs' +import { resolve, relative, sep } from 'node:path' + +export interface Workspace { + /** Absolute path to the workspace directory. */ + dir: string + /** Path relative to the monorepo root, using forward slashes. */ + relDir: string + /** Value of `name` in the workspace `package.json`. */ + name: string + /** Value of `version` in the workspace `package.json`, or `null` if absent. */ + version: string | null +} + +interface RawPackageJson { + name?: string + version?: string + private?: boolean +} + +/** + * Parse a newline-separated `packages:` input. Blank lines and `#` + * comments are stripped; everything else is treated as a glob or + * literal path, with `!`-prefixed entries acting as excludes. + */ +export function parsePackagesInput (raw: string): string[] { + return raw + .split(/\r?\n/) + .map(line => line.replace(/#.*$/, '').trim()) + .filter(Boolean) +} + +/** + * Expand a list of glob patterns rooted at `rootDir` into directories + * that contain a `package.json`. Patterns starting with `!` are + * treated as exclusions. + */ +export function expandPackagePatterns (rootDir: string, patterns: string[]): string[] { + const positive: string[] = [] + const negative: string[] = [] + for (const p of patterns) { + if (p.startsWith('!')) negative.push(p.slice(1)) + else positive.push(p) + } + if (!positive.length) return [] + + const matched = new Set() + for (const pattern of positive) { + for (const match of globSync(pattern, { cwd: rootDir })) { + const abs = resolve(rootDir, match) + if (!isDirectoryWithPackageJson(abs)) continue + matched.add(abs) + } + } + for (const pattern of negative) { + for (const match of globSync(pattern, { cwd: rootDir })) { + matched.delete(resolve(rootDir, match)) + } + } + return [...matched].sort() +} + +function isDirectoryWithPackageJson (dir: string): boolean { + try { + if (!statSync(dir).isDirectory()) return false + } catch { + return false + } + return existsSync(resolve(dir, 'package.json')) +} + +/** + * Resolve the maintainer-declared `packages` input into concrete + * `Workspace` records. Any matched directory whose `package.json` + * is `"private": true` is silently dropped. + * + * Throws if any matched directory is missing a `name` field, or if + * the input is empty, or if no patterns matched anything. + */ +export function resolveWorkspaces (rootDir: string, packagesInput: string): Workspace[] { + const patterns = parsePackagesInput(packagesInput) + if (!patterns.length) { + throw new Error('`packages` input is empty: provide one path or glob per line.') + } + + const dirs = expandPackagePatterns(rootDir, patterns) + if (!dirs.length) { + throw new Error( + `\`packages\` input matched no directories with a package.json.\nPatterns:\n${patterns.map(p => ` - ${p}`).join('\n')}`, + ) + } + + const workspaces: Workspace[] = [] + for (const dir of dirs) { + const pkg = JSON.parse(readFileSync(resolve(dir, 'package.json'), 'utf8')) as RawPackageJson + if (pkg.private === true) continue + if (!pkg.name) { + throw new Error(`Workspace at ${relative(rootDir, dir) || '.'} has no "name" field in package.json.`) + } + workspaces.push({ + dir, + relDir: relative(rootDir, dir).split(sep).join('/') || '.', + name: pkg.name, + version: typeof pkg.version === 'string' ? pkg.version : null, + }) + } + return workspaces +} + +/** + * Resolve the current lockstep version for the repo. We try, in order: + * + * 1. The version implied by the latest semver-shaped tag, if one was + * passed in. Tags are the source of truth for "what is published". + * 2. The root `package.json#version`, if it exists and is semver. This + * covers single-package repos where the private root carries the + * canonical version. + * 3. The single version shared by every declared workspace, if they + * all agree. + * + * Throws when workspaces disagree on version, because that almost + * always means a half-finished manual bump, and silently picking one + * would produce a wrong release. + */ +export function resolveCurrentLockstepVersion ( + rootDir: string, + latestTagName: string | null, + workspaces: Workspace[], +): string { + if (latestTagName) { + const fromTag = latestTagName.replace(/^v/, '') + if (isSemver(fromTag)) return fromTag + } + + const rootPkgPath = resolve(rootDir, 'package.json') + if (existsSync(rootPkgPath)) { + const rootPkg = JSON.parse(readFileSync(rootPkgPath, 'utf8')) as { version?: string } + if (typeof rootPkg.version === 'string' && isSemver(rootPkg.version)) { + return rootPkg.version + } + } + + const versioned = workspaces.filter(ws => ws.version !== null) + if (versioned.length) { + const versions = new Set(versioned.map(ws => ws.version!)) + if (versions.size === 1) return [...versions][0]! + const detail = versioned + .map(ws => ` - ${ws.name}: ${ws.version}`) + .join('\n') + throw new Error( + 'Cannot determine current lockstep version: workspaces disagree.\n' + + 'Reconcile them to a single version (or set a `version` on the root package.json) before releasing.\n' + + detail, + ) + } + + throw new Error( + 'Cannot determine current lockstep version: no tag, no root version, no workspace with a version.', + ) +} + +export function isSemver (value: string): boolean { + return /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(value) +} diff --git a/test/_workspaces.test.ts b/test/_workspaces.test.ts new file mode 100644 index 0000000..5cb5696 --- /dev/null +++ b/test/_workspaces.test.ts @@ -0,0 +1,196 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + expandPackagePatterns, + isSemver, + parsePackagesInput, + resolveCurrentLockstepVersion, + resolveWorkspaces, +} from '../scripts/_workspaces.ts' + +let tmp: string + +beforeEach(() => { + tmp = mkdtempSync(resolve(tmpdir(), 'uppt-ws-')) +}) + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) +}) + +function writePackage (relDir: string, contents: Record) { + const dir = resolve(tmp, relDir) + mkdirSync(dir, { recursive: true }) + writeFileSync(resolve(dir, 'package.json'), JSON.stringify(contents, null, 2)) +} + +describe('parsePackagesInput', () => { + it('returns one entry per non-blank line', () => { + expect(parsePackagesInput('packages/a\npackages/b\n')).toEqual(['packages/a', 'packages/b']) + }) + + it('strips comments and blank lines', () => { + const input = ` + # publishable packages + packages/a + + packages/b # trailing + ` + expect(parsePackagesInput(input)).toEqual(['packages/a', 'packages/b']) + }) + + it('preserves negation prefix', () => { + expect(parsePackagesInput('packages/*\n!packages/playground')).toEqual([ + 'packages/*', + '!packages/playground', + ]) + }) +}) + +describe('expandPackagePatterns', () => { + it('expands a literal path', () => { + writePackage('packages/a', { name: 'a' }) + expect(expandPackagePatterns(tmp, ['packages/a'])).toEqual([resolve(tmp, 'packages/a')]) + }) + + it('expands a single-segment glob', () => { + writePackage('packages/a', { name: 'a' }) + writePackage('packages/b', { name: 'b' }) + expect(expandPackagePatterns(tmp, ['packages/*'])).toEqual([ + resolve(tmp, 'packages/a'), + resolve(tmp, 'packages/b'), + ]) + }) + + it('skips entries with no package.json', () => { + writePackage('packages/a', { name: 'a' }) + mkdirSync(resolve(tmp, 'packages/empty'), { recursive: true }) + expect(expandPackagePatterns(tmp, ['packages/*'])).toEqual([resolve(tmp, 'packages/a')]) + }) + + it('honours negation', () => { + writePackage('packages/a', { name: 'a' }) + writePackage('packages/b', { name: 'b' }) + expect(expandPackagePatterns(tmp, ['packages/*', '!packages/b'])).toEqual([ + resolve(tmp, 'packages/a'), + ]) + }) + + it('deduplicates overlapping patterns', () => { + writePackage('packages/a', { name: 'a' }) + expect(expandPackagePatterns(tmp, ['packages/a', 'packages/*'])).toEqual([ + resolve(tmp, 'packages/a'), + ]) + }) +}) + +describe('resolveWorkspaces', () => { + it('returns name/version/dir for each match', () => { + writePackage('packages/a', { name: 'a', version: '1.0.0' }) + writePackage('packages/b', { name: 'b', version: '1.0.0' }) + + const workspaces = resolveWorkspaces(tmp, 'packages/*') + expect(workspaces).toEqual([ + { dir: resolve(tmp, 'packages/a'), relDir: 'packages/a', name: 'a', version: '1.0.0' }, + { dir: resolve(tmp, 'packages/b'), relDir: 'packages/b', name: 'b', version: '1.0.0' }, + ]) + }) + + it('treats a missing version as null', () => { + writePackage('packages/a', { name: 'a' }) + expect(resolveWorkspaces(tmp, 'packages/a')[0]!.version).toBeNull() + }) + + it('throws when input is empty', () => { + expect(() => resolveWorkspaces(tmp, ' \n # comment only\n')).toThrowError(/empty/) + }) + + it('throws when no pattern matches', () => { + expect(() => resolveWorkspaces(tmp, 'packages/*')).toThrowError(/matched no directories/) + }) + + it('throws when a matched workspace has no name', () => { + writePackage('packages/a', { version: '1.0.0' }) + expect(() => resolveWorkspaces(tmp, 'packages/a')).toThrowError(/no "name" field/) + }) + + it('silently drops private workspaces matched by a glob', () => { + writePackage('packages/a', { name: 'a', version: '1.0.0' }) + writePackage('packages/playground', { name: 'playground', version: '1.0.0', private: true }) + const workspaces = resolveWorkspaces(tmp, 'packages/*') + expect(workspaces.map(ws => ws.name)).toEqual(['a']) + }) + + it('silently drops a private workspace matched by literal path', () => { + writePackage('packages/a', { name: 'a', version: '1.0.0' }) + writePackage('packages/playground', { name: 'playground', version: '1.0.0', private: true }) + const workspaces = resolveWorkspaces(tmp, 'packages/a\npackages/playground') + expect(workspaces.map(ws => ws.name)).toEqual(['a']) + }) + + it('does not require a name field on a private workspace', () => { + writePackage('packages/a', { name: 'a', version: '1.0.0' }) + writePackage('packages/playground', { private: true }) + const workspaces = resolveWorkspaces(tmp, 'packages/*') + expect(workspaces.map(ws => ws.name)).toEqual(['a']) + }) +}) + +describe('resolveCurrentLockstepVersion', () => { + it('prefers the latest tag', () => { + writePackage('.', { name: 'root', version: '0.5.0', private: true }) + writePackage('packages/a', { name: 'a', version: '0.4.0' }) + const workspaces = resolveWorkspaces(tmp, 'packages/a') + expect(resolveCurrentLockstepVersion(tmp, 'v1.2.3', workspaces)).toBe('1.2.3') + }) + + it('falls back to root version when no tag', () => { + writePackage('.', { name: 'root', version: '0.5.0', private: true }) + writePackage('packages/a', { name: 'a', version: '0.4.0' }) + const workspaces = resolveWorkspaces(tmp, 'packages/a') + expect(resolveCurrentLockstepVersion(tmp, null, workspaces)).toBe('0.5.0') + }) + + it('falls back to consensus across workspaces when root has no version', () => { + writePackage('.', { name: 'root', private: true }) + writePackage('packages/a', { name: 'a', version: '0.4.0' }) + writePackage('packages/b', { name: 'b', version: '0.4.0' }) + const workspaces = resolveWorkspaces(tmp, 'packages/*') + expect(resolveCurrentLockstepVersion(tmp, null, workspaces)).toBe('0.4.0') + }) + + it('throws when workspaces disagree and no tag/root version anchors them', () => { + writePackage('.', { name: 'root', private: true }) + writePackage('packages/a', { name: 'a', version: '0.4.0' }) + writePackage('packages/b', { name: 'b', version: '0.5.0' }) + const workspaces = resolveWorkspaces(tmp, 'packages/*') + expect(() => resolveCurrentLockstepVersion(tmp, null, workspaces)).toThrowError(/workspaces disagree/) + }) + + it('throws when nothing carries a version', () => { + writePackage('.', { name: 'root', private: true }) + writePackage('packages/a', { name: 'a' }) + const workspaces = resolveWorkspaces(tmp, 'packages/a') + expect(() => resolveCurrentLockstepVersion(tmp, null, workspaces)).toThrowError(/no tag, no root version/) + }) +}) + +describe('isSemver', () => { + it('accepts plain X.Y.Z', () => { + expect(isSemver('1.2.3')).toBe(true) + }) + + it('accepts prerelease and build metadata', () => { + expect(isSemver('1.2.3-rc.1')).toBe(true) + expect(isSemver('1.2.3+sha.abc')).toBe(true) + }) + + it('rejects non-semver', () => { + expect(isSemver('1.2')).toBe(false) + expect(isSemver('v1.2.3')).toBe(false) + expect(isSemver('not-a-version')).toBe(false) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index e1162fe..0377f53 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "esModuleInterop": true, "resolveJsonModule": true }, - "include": ["scripts/**/*.ts"] + "include": ["scripts/**/*.ts", "test/**/*.ts"] } From d5af346cf87f7e9a5b466e0b9074593e5be195ab Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 27 May 2026 13:55:55 +0100 Subject: [PATCH 03/14] refactor(pr): commit via git data api to support multi-file release commits --- scripts/update-changelog.ts | 152 +++++++++++++++++++++++------------- 1 file changed, 98 insertions(+), 54 deletions(-) diff --git a/scripts/update-changelog.ts b/scripts/update-changelog.ts index e9ebb3a..e458ab1 100644 --- a/scripts/update-changelog.ts +++ b/scripts/update-changelog.ts @@ -302,54 +302,106 @@ async function getReleaseBranchState ( return branchHead === baseInfo.commit.sha ? 'at-base' : 'has-bump' } -async function getPackageJsonBlobSha ( - repo: { owner: string, repo: string }, - ref: string, -): Promise { - const file = await gh<{ sha: string }>( - `/repos/${repo.owner}/${repo.repo}/contents/package.json?ref=${encodeURIComponent(ref)}`, - { requireAuth: true }, - ) - return file.sha +interface FileToCommit { + /** Path relative to the repo root, using forward slashes. */ + path: string + /** Raw UTF-8 contents to write at that path. */ + content: string } -async function createReleaseBranchWithBump ( +/** + * Land one atomic commit on `opts.branch` containing every file in + * `opts.files`, using the Git Data API. Creates the branch at `opts.base` + * if it doesn't exist yet. The resulting commit has the branch's current + * tip as its sole parent (or `opts.base`'s tip, if the branch was just + * created), so the ref fast-forwards. + */ +async function commitFilesToBranch ( repo: { owner: string, repo: string }, - opts: { base: string, branch: string, message: string, contentBase64: string }, + opts: { base: string, branch: string, message: string, files: FileToCommit[] }, ): Promise { - const baseInfo = await gh<{ commit: { sha: string } }>( - `/repos/${repo.owner}/${repo.repo}/branches/${encodeURIComponent(opts.base)}`, + if (!opts.files.length) { + throw new Error('commitFilesToBranch: refusing to commit with no files') + } + + let parentSha: string + try { + const branchInfo = await gh<{ commit: { sha: string } }>( + `/repos/${repo.owner}/${repo.repo}/branches/${encodeURIComponent(opts.branch)}`, + { requireAuth: true }, + ) + parentSha = branchInfo.commit.sha + } catch (err) { + if (!(err instanceof Error) || !/-> 404\b/.test(err.message)) throw err + const baseInfo = await gh<{ commit: { sha: string } }>( + `/repos/${repo.owner}/${repo.repo}/branches/${encodeURIComponent(opts.base)}`, + { requireAuth: true }, + ) + await gh(`/repos/${repo.owner}/${repo.repo}/git/refs`, { + method: 'POST', + requireAuth: true, + body: JSON.stringify({ + ref: `refs/heads/${opts.branch}`, + sha: baseInfo.commit.sha, + }), + }) + parentSha = baseInfo.commit.sha + } + + const parentCommit = await gh<{ tree: { sha: string } }>( + `/repos/${repo.owner}/${repo.repo}/git/commits/${parentSha}`, { requireAuth: true }, ) - const blobSha = await getPackageJsonBlobSha(repo, opts.base) - await gh(`/repos/${repo.owner}/${repo.repo}/git/refs`, { - method: 'POST', - requireAuth: true, - body: JSON.stringify({ - ref: `refs/heads/${opts.branch}`, - sha: baseInfo.commit.sha, - }), - }) + const blobs = await Promise.all(opts.files.map(async (file) => { + const blob = await gh<{ sha: string }>( + `/repos/${repo.owner}/${repo.repo}/git/blobs`, + { + method: 'POST', + requireAuth: true, + body: JSON.stringify({ + content: Buffer.from(file.content, 'utf8').toString('base64'), + encoding: 'base64', + }), + }, + ) + return { path: file.path, sha: blob.sha } + })) - await commitBumpToExistingBranch(repo, { ...opts, blobSha }) -} + const tree = await gh<{ sha: string }>( + `/repos/${repo.owner}/${repo.repo}/git/trees`, + { + method: 'POST', + requireAuth: true, + body: JSON.stringify({ + base_tree: parentCommit.tree.sha, + tree: blobs.map(b => ({ + path: b.path, + mode: '100644', + type: 'blob', + sha: b.sha, + })), + }), + }, + ) -async function commitBumpToExistingBranch ( - repo: { owner: string, repo: string }, - opts: { base: string, branch: string, message: string, contentBase64: string, blobSha?: string }, -): Promise { - const blobSha = opts.blobSha ?? await getPackageJsonBlobSha(repo, opts.branch) + const commit = await gh<{ sha: string }>( + `/repos/${repo.owner}/${repo.repo}/git/commits`, + { + method: 'POST', + requireAuth: true, + body: JSON.stringify({ + message: opts.message, + tree: tree.sha, + parents: [parentSha], + }), + }, + ) - await gh(`/repos/${repo.owner}/${repo.repo}/contents/package.json`, { - method: 'PUT', + await gh(`/repos/${repo.owner}/${repo.repo}/git/refs/heads/${opts.branch}`, { + method: 'PATCH', requireAuth: true, - body: JSON.stringify({ - message: opts.message, - content: opts.contentBase64, - sha: blobSha, - branch: opts.branch, - }), + body: JSON.stringify({ sha: commit.sha }), }) } @@ -462,25 +514,17 @@ async function main () { if (!process.env.GITHUB_TOKEN) { throw new Error('GITHUB_TOKEN is required to create the release branch') } - pkg.version = newVersion - const nextPkg = JSON.stringify(pkg, null, 2) + '\n' - const contentBase64 = Buffer.from(nextPkg, 'utf8').toString('base64') - if (state === 'missing') { - await createReleaseBranchWithBump(repo, { - base: baseBranch, - branch: releaseBranch, - message: `v${newVersion}`, - contentBase64, - }) - } else { + if (state === 'at-base') { console.log(`Branch ${releaseBranch} exists at base HEAD with no bump; recovering by committing.`) - await commitBumpToExistingBranch(repo, { - base: baseBranch, - branch: releaseBranch, - message: `v${newVersion}`, - contentBase64, - }) } + pkg.version = newVersion + const nextPkg = JSON.stringify(pkg, null, 2) + '\n' + await commitFilesToBranch(repo, { + base: baseBranch, + branch: releaseBranch, + message: `v${newVersion}`, + files: [{ path: 'package.json', content: nextPkg }], + }) } } From 7576a61a61307d6e04310b75e523eb5bd62ce838 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 27 May 2026 17:24:43 +0100 Subject: [PATCH 04/14] feat(pr): add lockstep version resolution from workspaces --- scripts/_workspaces.ts | 65 +++++++++++++++------------------------- test/_workspaces.test.ts | 56 ++++++++++++++++++---------------- 2 files changed, 54 insertions(+), 67 deletions(-) diff --git a/scripts/_workspaces.ts b/scripts/_workspaces.ts index b3acd9f..3b9c25e 100644 --- a/scripts/_workspaces.ts +++ b/scripts/_workspaces.ts @@ -115,54 +115,37 @@ export function resolveWorkspaces (rootDir: string, packagesInput: string): Work } /** - * Resolve the current lockstep version for the repo. We try, in order: + * Resolve the current lockstep version for a monorepo from its + * workspaces. Workspaces are the source of truth: if they all agree on + * a single semver version, that's the lockstep version. Anything else + * is an error. * - * 1. The version implied by the latest semver-shaped tag, if one was - * passed in. Tags are the source of truth for "what is published". - * 2. The root `package.json#version`, if it exists and is semver. This - * covers single-package repos where the private root carries the - * canonical version. - * 3. The single version shared by every declared workspace, if they - * all agree. - * - * Throws when workspaces disagree on version, because that almost - * always means a half-finished manual bump, and silently picking one - * would produce a wrong release. + * The root `package.json#version` and the latest tag are deliberately + * *not* consulted here. The root may legitimately be at `0.0.0` or + * have no version at all; the tag may have drifted from the workspaces + * via a manual publish. Trusting either over the workspaces produces + * surprising releases. */ -export function resolveCurrentLockstepVersion ( - rootDir: string, - latestTagName: string | null, - workspaces: Workspace[], -): string { - if (latestTagName) { - const fromTag = latestTagName.replace(/^v/, '') - if (isSemver(fromTag)) return fromTag - } - - const rootPkgPath = resolve(rootDir, 'package.json') - if (existsSync(rootPkgPath)) { - const rootPkg = JSON.parse(readFileSync(rootPkgPath, 'utf8')) as { version?: string } - if (typeof rootPkg.version === 'string' && isSemver(rootPkg.version)) { - return rootPkg.version - } - } - - const versioned = workspaces.filter(ws => ws.version !== null) - if (versioned.length) { - const versions = new Set(versioned.map(ws => ws.version!)) - if (versions.size === 1) return [...versions][0]! - const detail = versioned - .map(ws => ` - ${ws.name}: ${ws.version}`) - .join('\n') +export function lockstepVersionFromWorkspaces (workspaces: Workspace[]): string { + const allVersions = workspaces.map(ws => ws.version) + if (allVersions.every(v => v === null)) { throw new Error( - 'Cannot determine current lockstep version: workspaces disagree.\n' - + 'Reconcile them to a single version (or set a `version` on the root package.json) before releasing.\n' - + detail, + 'No listed workspace has a `version` field. Lockstep releases need every workspace to share a single semver version.', ) } + const distinct = new Set(allVersions.map(v => (v !== null && isSemver(v)) ? v : null)) + if (distinct.size === 1 && !distinct.has(null)) { + return [...distinct][0]! + } + + const detail = workspaces + .map(ws => ` - ${ws.name}: ${ws.version ?? ''}`) + .join('\n') throw new Error( - 'Cannot determine current lockstep version: no tag, no root version, no workspace with a version.', + 'Workspaces do not agree on a single version. uppt currently supports lockstep releases only: ' + + 'every listed package must share the same semver version. Reconcile them before releasing.\n' + + detail, ) } diff --git a/test/_workspaces.test.ts b/test/_workspaces.test.ts index 5cb5696..9c12433 100644 --- a/test/_workspaces.test.ts +++ b/test/_workspaces.test.ts @@ -6,8 +6,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { expandPackagePatterns, isSemver, + lockstepVersionFromWorkspaces, parsePackagesInput, - resolveCurrentLockstepVersion, resolveWorkspaces, } from '../scripts/_workspaces.ts' @@ -139,42 +139,46 @@ describe('resolveWorkspaces', () => { }) }) -describe('resolveCurrentLockstepVersion', () => { - it('prefers the latest tag', () => { - writePackage('.', { name: 'root', version: '0.5.0', private: true }) - writePackage('packages/a', { name: 'a', version: '0.4.0' }) - const workspaces = resolveWorkspaces(tmp, 'packages/a') - expect(resolveCurrentLockstepVersion(tmp, 'v1.2.3', workspaces)).toBe('1.2.3') +describe('lockstepVersionFromWorkspaces', () => { + it('returns the shared version when all workspaces agree', () => { + writePackage('packages/a', { name: 'a', version: '1.2.3' }) + writePackage('packages/b', { name: 'b', version: '1.2.3' }) + const workspaces = resolveWorkspaces(tmp, 'packages/*') + expect(lockstepVersionFromWorkspaces(workspaces)).toBe('1.2.3') }) - it('falls back to root version when no tag', () => { - writePackage('.', { name: 'root', version: '0.5.0', private: true }) - writePackage('packages/a', { name: 'a', version: '0.4.0' }) + it('ignores the root package.json entirely', () => { + writePackage('.', { name: 'root', version: '9.9.9', private: true }) + writePackage('packages/a', { name: 'a', version: '1.2.3' }) const workspaces = resolveWorkspaces(tmp, 'packages/a') - expect(resolveCurrentLockstepVersion(tmp, null, workspaces)).toBe('0.5.0') + expect(lockstepVersionFromWorkspaces(workspaces)).toBe('1.2.3') }) - it('falls back to consensus across workspaces when root has no version', () => { - writePackage('.', { name: 'root', private: true }) - writePackage('packages/a', { name: 'a', version: '0.4.0' }) - writePackage('packages/b', { name: 'b', version: '0.4.0' }) + it('throws when workspaces disagree', () => { + writePackage('packages/a', { name: 'a', version: '1.2.3' }) + writePackage('packages/b', { name: 'b', version: '1.2.4' }) const workspaces = resolveWorkspaces(tmp, 'packages/*') - expect(resolveCurrentLockstepVersion(tmp, null, workspaces)).toBe('0.4.0') + expect(() => lockstepVersionFromWorkspaces(workspaces)).toThrowError(/do not agree on a single version/) + }) + + it('throws when no workspace has a semver version', () => { + writePackage('packages/a', { name: 'a' }) + const workspaces = resolveWorkspaces(tmp, 'packages/a') + expect(() => lockstepVersionFromWorkspaces(workspaces)).toThrowError(/No listed workspace has a `version` field/) }) - it('throws when workspaces disagree and no tag/root version anchors them', () => { - writePackage('.', { name: 'root', private: true }) - writePackage('packages/a', { name: 'a', version: '0.4.0' }) - writePackage('packages/b', { name: 'b', version: '0.5.0' }) + it('throws when one workspace is missing a version', () => { + writePackage('packages/a', { name: 'a', version: '1.2.3' }) + writePackage('packages/b', { name: 'b' }) const workspaces = resolveWorkspaces(tmp, 'packages/*') - expect(() => resolveCurrentLockstepVersion(tmp, null, workspaces)).toThrowError(/workspaces disagree/) + expect(() => lockstepVersionFromWorkspaces(workspaces)).toThrowError(/do not agree on a single version/) }) - it('throws when nothing carries a version', () => { - writePackage('.', { name: 'root', private: true }) - writePackage('packages/a', { name: 'a' }) - const workspaces = resolveWorkspaces(tmp, 'packages/a') - expect(() => resolveCurrentLockstepVersion(tmp, null, workspaces)).toThrowError(/no tag, no root version/) + it('throws when a workspace carries a non-semver version string', () => { + writePackage('packages/a', { name: 'a', version: '1.2.3' }) + writePackage('packages/b', { name: 'b', version: 'next' }) + const workspaces = resolveWorkspaces(tmp, 'packages/*') + expect(() => lockstepVersionFromWorkspaces(workspaces)).toThrowError(/do not agree on a single version/) }) }) From 29ed8e89a29342f064fc33b460ef363d27e7ad15 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 28 May 2026 14:14:06 +0100 Subject: [PATCH 05/14] feat(pr): bump every workspace in lockstep when packages input is set --- package.json | 1 + pr/action.yml | 5 ++ scripts/update-changelog.ts | 96 +++++++++++++++++++++--- test/update-changelog.test.ts | 137 ++++++++++++++++++++++++++++++++++ 4 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 test/update-changelog.test.ts diff --git a/package.json b/package.json index 3787667..bb195ca 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "Composite GitHub Action to release PRs from conventional commits, tag on merge, publish to npm via OIDC.", "author": "Daniel Roe ", "license": "MIT", + "type": "module", "scripts": { "test": "vitest run", "test:types": "tsgo --noEmit" diff --git a/pr/action.yml b/pr/action.yml index 3f5cd2b..b80164b 100644 --- a/pr/action.yml +++ b/pr/action.yml @@ -23,6 +23,10 @@ inputs: description: 'Whether the action should run `actions/checkout` itself. Set to `false` if the caller has already checked out with `fetch-depth: 0`.' required: false default: 'true' + packages: + description: 'Newline-separated list of publishable workspace directories, relative to the repo root. Each line is a path or glob (e.g. `packages/*`); `!`-prefixed entries are excluded; workspaces with `"private": true` are skipped. Omit for single-package repos.' + required: false + default: '' runs: using: composite @@ -57,4 +61,5 @@ runs: env: GITHUB_TOKEN: ${{ inputs.token }} RELEASE_BASE: ${{ inputs.base-branch }} + PACKAGES: ${{ inputs.packages }} run: node --experimental-strip-types ${{ github.action_path }}/../scripts/update-changelog.ts diff --git a/scripts/update-changelog.ts b/scripts/update-changelog.ts index e458ab1..b588709 100644 --- a/scripts/update-changelog.ts +++ b/scripts/update-changelog.ts @@ -13,6 +13,9 @@ // GITHUB_REPOSITORY "owner/repo" (set automatically inside Actions) // DRY_RUN if set, skip git push and GitHub writes // RELEASE_BASE override base branch (default: current branch) +// PACKAGES newline-separated list of publishable workspace +// paths/globs; when set, the release bumps every +// resolved workspace's package.json in lockstep import process from 'node:process' import { execFileSync } from 'node:child_process' @@ -20,6 +23,8 @@ import { Buffer } from 'node:buffer' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { lockstepVersionFromWorkspaces, resolveWorkspaces, type Workspace } from './_workspaces.ts' + interface Commit { shortHash: string message: string @@ -420,6 +425,50 @@ async function isReleaseMergeCommit ( } } +/** + * Build the set of `package.json` files to write in the release commit. + * + * Single-package mode: just the root, bumped to `newVersion`. + * + * Monorepo mode: every listed workspace is rewritten to `newVersion`. + * The root is included only if its current `version` exactly matches + * the lockstep version; otherwise it's left untouched (it might be + * `0.0.0`, absent, or deliberately frozen, and none of those are uppt's + * business). When independent versioning lands, this is the place that + * decides which workspaces get a bump on a given release. + */ +export function buildBumpFileSet (opts: { + monorepo: boolean + workspaces: Workspace[] + rootPkg: Record + currentVersion: string + newVersion: string +}): Array<{ path: string, content: string }> { + const files: Array<{ path: string, content: string }> = [] + + if (!opts.monorepo) { + const updated = { ...opts.rootPkg, version: opts.newVersion } + files.push({ path: 'package.json', content: JSON.stringify(updated, null, 2) + '\n' }) + return files + } + + let rootCoveredByWorkspaces = false + for (const ws of opts.workspaces) { + const wsPkg = JSON.parse(readFileSync(resolve(ws.dir, 'package.json'), 'utf8')) as Record + wsPkg.version = opts.newVersion + const path = ws.relDir === '.' ? 'package.json' : `${ws.relDir}/package.json` + if (path === 'package.json') rootCoveredByWorkspaces = true + files.push({ path, content: JSON.stringify(wsPkg, null, 2) + '\n' }) + } + + if (!rootCoveredByWorkspaces && opts.rootPkg.version === opts.currentVersion) { + const updated = { ...opts.rootPkg, version: opts.newVersion } + files.push({ path: 'package.json', content: JSON.stringify(updated, null, 2) + '\n' }) + } + + return files +} + async function main () { const dryRun = Boolean(process.env.DRY_RUN) const repo = getRepo() @@ -442,10 +491,24 @@ async function main () { return } - const pkgPath = resolve(process.cwd(), 'package.json') - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) + const packagesInput = process.env.PACKAGES?.trim() ?? '' + const monorepo = packagesInput.length > 0 + const workspaces: Workspace[] = monorepo + ? resolveWorkspaces(process.cwd(), packagesInput) + : [] + + const rootPkgPath = resolve(process.cwd(), 'package.json') + const rootPkg = JSON.parse(readFileSync(rootPkgPath, 'utf8')) + + const currentVersion = monorepo + ? lockstepVersionFromWorkspaces(workspaces) + : rootPkg.version + if (typeof currentVersion !== 'string') { + throw new Error('Cannot determine current version: root package.json has no `version` field. Set one, or use the `packages` input to release a monorepo.') + } + const bump = determineBump(commits) - const newVersion = incVersion(pkg.version, bump) + const newVersion = incVersion(currentVersion, bump) const releaseBranch = `release/v${newVersion}` const changelog = formatChangelog(commits, { @@ -455,7 +518,10 @@ async function main () { toRef: releaseBranch, }) - console.log(`Current: ${pkg.version} -> ${newVersion} (${bump})`) + console.log(`Current: ${currentVersion} -> ${newVersion} (${bump})`) + if (monorepo) { + console.log(`Workspaces (${workspaces.length}): ${workspaces.map(ws => ws.name).join(', ')}`) + } console.log(`Base branch: ${baseBranch}`) console.log(`Release branch: ${releaseBranch}`) console.log(`Commits: ${commits.length}`) @@ -517,13 +583,18 @@ async function main () { if (state === 'at-base') { console.log(`Branch ${releaseBranch} exists at base HEAD with no bump; recovering by committing.`) } - pkg.version = newVersion - const nextPkg = JSON.stringify(pkg, null, 2) + '\n' + const files = buildBumpFileSet({ + monorepo, + workspaces, + rootPkg, + currentVersion, + newVersion, + }) await commitFilesToBranch(repo, { base: baseBranch, branch: releaseBranch, message: `v${newVersion}`, - files: [{ path: 'package.json', content: nextPkg }], + files, }) } } @@ -603,7 +674,10 @@ async function main () { } } -main().catch((err) => { - console.error(err) - process.exit(1) -}) +// Run as a script, not when imported by tests. +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/test/update-changelog.test.ts b/test/update-changelog.test.ts new file mode 100644 index 0000000..052e97e --- /dev/null +++ b/test/update-changelog.test.ts @@ -0,0 +1,137 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { buildBumpFileSet } from '../scripts/update-changelog.ts' +import { resolveWorkspaces } from '../scripts/_workspaces.ts' + +let tmp: string + +beforeEach(() => { + tmp = mkdtempSync(resolve(tmpdir(), 'uppt-bump-')) +}) + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) +}) + +function writePackage (relDir: string, contents: Record) { + const dir = resolve(tmp, relDir) + mkdirSync(dir, { recursive: true }) + writeFileSync(resolve(dir, 'package.json'), JSON.stringify(contents, null, 2)) +} + +describe('buildBumpFileSet', () => { + describe('single-package mode', () => { + it('rewrites the root package.json with the new version', () => { + const files = buildBumpFileSet({ + monorepo: false, + workspaces: [], + rootPkg: { name: 'pkg', version: '1.2.3' }, + currentVersion: '1.2.3', + newVersion: '1.2.4', + }) + expect(files).toEqual([ + { path: 'package.json', content: `${JSON.stringify({ name: 'pkg', version: '1.2.4' }, null, 2)}\n` }, + ]) + }) + + it('preserves other root fields', () => { + const files = buildBumpFileSet({ + monorepo: false, + workspaces: [], + rootPkg: { name: 'pkg', version: '1.2.3', description: 'hello', private: false }, + currentVersion: '1.2.3', + newVersion: '1.2.4', + }) + const written = JSON.parse(files[0]!.content) + expect(written).toEqual({ name: 'pkg', version: '1.2.4', description: 'hello', private: false }) + }) + }) + + describe('monorepo mode', () => { + it('rewrites every workspace package.json', () => { + writePackage('packages/a', { name: 'a', version: '1.2.3' }) + writePackage('packages/b', { name: 'b', version: '1.2.3' }) + const workspaces = resolveWorkspaces(tmp, 'packages/*') + + const files = buildBumpFileSet({ + monorepo: true, + workspaces, + rootPkg: { name: 'root', private: true }, + currentVersion: '1.2.3', + newVersion: '1.2.4', + }) + + const byPath = Object.fromEntries(files.map(f => [f.path, JSON.parse(f.content)])) + expect(byPath['packages/a/package.json']).toMatchObject({ name: 'a', version: '1.2.4' }) + expect(byPath['packages/b/package.json']).toMatchObject({ name: 'b', version: '1.2.4' }) + }) + + it('leaves the root alone when it has no version', () => { + writePackage('packages/a', { name: 'a', version: '1.2.3' }) + const workspaces = resolveWorkspaces(tmp, 'packages/a') + + const files = buildBumpFileSet({ + monorepo: true, + workspaces, + rootPkg: { name: 'root', private: true }, + currentVersion: '1.2.3', + newVersion: '1.2.4', + }) + + expect(files.map(f => f.path)).toEqual(['packages/a/package.json']) + }) + + it('bumps the root when its version equals the current lockstep', () => { + writePackage('packages/a', { name: 'a', version: '1.2.3' }) + const workspaces = resolveWorkspaces(tmp, 'packages/a') + + const files = buildBumpFileSet({ + monorepo: true, + workspaces, + rootPkg: { name: 'root', version: '1.2.3', private: true }, + currentVersion: '1.2.3', + newVersion: '1.2.4', + }) + + const rootFile = files.find(f => f.path === 'package.json')! + expect(JSON.parse(rootFile.content)).toMatchObject({ name: 'root', version: '1.2.4', private: true }) + }) + + it('leaves the root alone when its version differs from the lockstep', () => { + writePackage('packages/a', { name: 'a', version: '1.2.3' }) + const workspaces = resolveWorkspaces(tmp, 'packages/a') + + const files = buildBumpFileSet({ + monorepo: true, + workspaces, + rootPkg: { name: 'root', version: '0.0.0', private: true }, + currentVersion: '1.2.3', + newVersion: '1.2.4', + }) + + expect(files.map(f => f.path)).toEqual(['packages/a/package.json']) + }) + + it('preserves unrelated workspace fields', () => { + writePackage('packages/a', { name: 'a', version: '1.2.3', dependencies: { foo: 'workspace:^' } }) + const workspaces = resolveWorkspaces(tmp, 'packages/a') + + const files = buildBumpFileSet({ + monorepo: true, + workspaces, + rootPkg: { name: 'root', private: true }, + currentVersion: '1.2.3', + newVersion: '1.2.4', + }) + + expect(JSON.parse(files[0]!.content)).toEqual({ + name: 'a', + version: '1.2.4', + dependencies: { foo: 'workspace:^' }, + }) + }) + }) +}) From a66ec7114508d03ecc515a941cf8157d62665d11 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 28 May 2026 22:03:27 +0100 Subject: [PATCH 06/14] feat(pack): pack each listed workspace when packages input is set --- pack/action.yml | 1 + scripts/pack.ts | 33 ++++++++++++++++++++++++--------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/pack/action.yml b/pack/action.yml index f54eb3b..a8935ec 100644 --- a/pack/action.yml +++ b/pack/action.yml @@ -99,6 +99,7 @@ runs: shell: bash env: PACK_OUT_DIR: ${{ runner.temp }}/uppt-pack + PACKAGES: ${{ inputs.packages }} run: node --experimental-strip-types ${{ github.action_path }}/../scripts/pack.ts - name: Upload tarball artifact diff --git a/scripts/pack.ts b/scripts/pack.ts index f2e29ab..0d8ebcc 100644 --- a/scripts/pack.ts +++ b/scripts/pack.ts @@ -15,18 +15,24 @@ // a workflow artifact. // GITHUB_OUTPUT set by the runner; receives `files=`. // GITHUB_REF must be `refs/tags/v*` (set automatically) +// PACKAGES newline-separated list of publishable workspace +// paths/globs; when set, the script packs each +// listed workspace instead of the root. import process from 'node:process' import { execFileSync } from 'node:child_process' import { appendFileSync, existsSync, mkdirSync, statSync } from 'node:fs' import { basename, resolve } from 'node:path' -function runCapture (cmd: string, args: string[]): string { - console.log('$', cmd, ...args) +import { resolveWorkspaces } from './_workspaces.ts' + +function runCapture (cmd: string, args: string[], cwd?: string): string { + console.log('$', cmd, ...args, cwd ? `(cwd: ${cwd})` : '') return execFileSync(cmd, args, { stdio: ['ignore', 'pipe', 'inherit'], encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, + cwd, }) } @@ -70,16 +76,25 @@ function main () { mkdirSync(outDir, { recursive: true }) const hasPnpmLock = existsSync(resolve(process.cwd(), 'pnpm-lock.yaml')) + const packagesInput = process.env.PACKAGES?.trim() ?? '' + const targets = packagesInput.length + ? resolveWorkspaces(process.cwd(), packagesInput).map(ws => ({ name: ws.name, cwd: ws.dir })) + : [{ name: '', cwd: process.cwd() }] - let stdout: string - if (hasPnpmLock) { - stdout = runCapture('pnpm', ['pack', '--pack-destination', outDir, '--json']) - } else { - stdout = runCapture('npm', ['pack', '--pack-destination', outDir, '--json', '--silent']) + const filenames: string[] = [] + for (const target of targets) { + const stdout = hasPnpmLock + ? runCapture('pnpm', ['pack', '--pack-destination', outDir, '--json'], target.cwd) + : runCapture('npm', ['pack', '--pack-destination', outDir, '--json', '--silent'], target.cwd) + const packed = parseFilenames(stdout) + for (const filename of packed) { + if (filenames.includes(filename)) { + throw new Error(`Pack tool produced duplicate tarball '${filename}' (from ${target.name}); workspace package names and versions must be unique.`) + } + filenames.push(filename) + } } - const filenames = parseFilenames(stdout) - for (const filename of filenames) { const tarballPath = resolve(outDir, filename) if (!existsSync(tarballPath)) { From 4a80a4201aa84e39840fad1eede7e3bf3efec5c2 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 30 May 2026 07:43:15 +0100 Subject: [PATCH 07/14] docs: document monorepo lockstep support --- README.md | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e1b7255..a02e2e3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ The aim of **uppt** is to make a very simple, secure release workflow for mainta ### Set up your package for trusted publishing on npmjs.com -1. Visit `https://npmjs.com//settings` and add a new trusted publisher entry, pointing at your repo and the `release.yml` workflow, with the `npm stage publish` permission chip. Set the 'Environment name' to 'npm'. +1. Visit `https://npmjs.com//settings` and add a new trusted publisher entry, pointing at your repo and the `release.yml` workflow, with the `npm stage publish` permission chip. Set the 'Environment name' to 'npm'. In a monorepo, repeat this once per published package, pointing each entry at the same workflow and environment. > [!NOTE] > [Staged publishing](https://docs.npmjs.com/staged-publishing/) requires you to approve the publish before it goes live. @@ -135,6 +135,7 @@ Whenever you push to the default branch, this action parses conventional commits | `base-branch` | default branch | Base branch for the release PR. | | `node-version` | `24` | Node version for the scripts. Needs `--experimental-strip-types` (Node 22.6+, 24+ recommended). | | `checkout` | `true` | Set to `false` if the caller has already checked out with `fetch-depth: 0`. | +| `packages` | _(unset)_ | Newline-separated list of publishable workspace directories (paths or globs, e.g. `packages/*`). When set, uppt operates in monorepo lockstep mode. See [Monorepo support](#monorepo-support). | ### Creates a release (`danielroe/uppt/release`) @@ -156,6 +157,7 @@ This subaction installs the package's dependencies, runs `pnpm pack --json` (if | `node-version` | `24` | Node version for the scripts. Needs `--experimental-strip-types` (Node 22.6+, 24+ recommended). Ignored when `install` is `false`. | | `checkout` | `true` | Set to `false` if the caller has already checked out the tag ref. | | `install` | `true` | Set to `false` to handle `actions/setup-node` and dependency installation yourself. Useful when you want a pinned package manager version, a cached `node_modules`, or a hardened install policy. When `false`, the caller must put `node`, `npm`, and any package manager on PATH before `uppt/pack` runs. | +| `packages` | _(unset)_ | Newline-separated list of publishable workspace directories (paths or globs). Must match the value passed to `uppt/pr`. See [Monorepo support](#monorepo-support). | | Output | Description | | --- | --- | @@ -179,6 +181,41 @@ uppt runs your package's lifecycle scripts at one specific point and skips them - **During pack** (inside `uppt/pack`, after install): `prepack`, `prepare`, and `postpack` run. This is where your build belongs. - **During publish** (inside `uppt/publish`): nothing runs. `prepublishOnly` is **not** invoked; the prebuilt tarball is published with `--ignore-scripts`. Move any logic you previously had in `prepublishOnly` into `prepack` so it runs during `uppt/pack` and the output lands in the tarball. +## Monorepo support + +uppt supports lockstep monorepos: every publishable package shares a single version, gets bumped together, lands under one `vX.Y.Z` tag, and is staged in one workflow run. + +Declare the publishable workspaces by passing the same `packages:` input to both `uppt/pr` and `uppt/pack`. Each line is a directory path or a glob; `!`-prefixed entries are excluded; workspaces whose `package.json` has `"private": true` are silently skipped, so playgrounds and example apps stay out of npm. + +```yaml + pr: + # ... + steps: + - uses: danielroe/uppt/pr@ + with: + token: ${{ secrets.GITHUB_TOKEN }} + packages: | + packages/* + !packages/playground + + pack: + # ... + steps: + - uses: danielroe/uppt/pack@ + with: + packages: | + packages/* + !packages/playground +``` + +The lockstep version comes from the workspaces themselves: every listed package must agree on a single semver `version`, and that's the version uppt bumps from. The root `package.json#version` (if present) is only bumped when it already matches the lockstep version, so a `0.0.0` or absent root version is left untouched. + +> [!IMPORTANT] +> The `packages:` value on `uppt/pr` and `uppt/pack` must match. If they diverge, the release PR and the published tarballs will cover different sets of packages. + +> [!NOTE] +> Independent versioning (per-package tags and cadence) is not yet supported. Track [#9](https://github.com/danielroe/uppt/issues/9) if you need it. + ## Prerequisites For `pr` to work you need: From 7616a0ef383457e3d544bb5c96fe9c8f04d4b7ca Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 30 May 2026 07:53:45 +0100 Subject: [PATCH 08/14] fix(pack): declare missing `packages` input --- pack/action.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pack/action.yml b/pack/action.yml index a8935ec..51c6f38 100644 --- a/pack/action.yml +++ b/pack/action.yml @@ -19,6 +19,10 @@ inputs: description: 'Whether the action should install the package''s dependencies itself. Set to `false` if the caller has already installed (e.g. with a pinned package manager version, a cached `node_modules`, or a hardened install policy). When `false`, the action will not run `actions/setup-node` either; the caller is responsible for putting `node`, `npm`, and any package manager on PATH before `uppt/pack` runs.' required: false default: 'true' + packages: + description: 'Newline-separated list of publishable workspace directories, relative to the repo root. Each line is a path or glob (e.g. `packages/*`); `!`-prefixed entries are excluded; workspaces with `"private": true` are skipped. Must match the value passed to `uppt/pr`. Omit for single-package repos.' + required: false + default: '' outputs: files: From d0532ef88fde015c623f068344541dfd20a7239d Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 30 May 2026 07:53:55 +0100 Subject: [PATCH 09/14] ci: run vitest suite alongside type check --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3efdc3..7ae3148 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: - name: ๐Ÿ“ฆ Install dependencies run: pnpm install --frozen-lockfile --ignore-scripts + - name: ๐Ÿงช Test + run: pnpm test + - name: ๐Ÿ’ช Test types run: pnpm test:types From a1f602b3c3a2e78768d28abf2d45753598b9b34a Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 30 May 2026 07:54:04 +0100 Subject: [PATCH 10/14] docs: note pnpm-workspace.yaml must list every `packages:` entry --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index a02e2e3..6734b7b 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,9 @@ The lockstep version comes from the workspaces themselves: every listed package > [!IMPORTANT] > The `packages:` value on `uppt/pr` and `uppt/pack` must match. If they diverge, the release PR and the published tarballs will cover different sets of packages. +> [!IMPORTANT] +> If you use pnpm, every workspace you list under `packages:` must also be listed in your `pnpm-workspace.yaml`. `pnpm pack` resolves `workspace:` and `catalog:` specifiers via the workspace graph, so a directory missing from `pnpm-workspace.yaml` will produce a tarball with unresolved specifiers (or fail outright). + > [!NOTE] > Independent versioning (per-package tags and cadence) is not yet supported. Track [#9](https://github.com/danielroe/uppt/issues/9) if you need it. From d49838d51cb46f20141872863a1e847d28face91 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 30 May 2026 07:55:12 +0100 Subject: [PATCH 11/14] refactor(pr): thread parsed workspace package.json through Workspace --- scripts/_workspaces.ts | 4 ++++ scripts/update-changelog.ts | 3 +-- test/_workspaces.test.ts | 22 ++++++++++++++++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/scripts/_workspaces.ts b/scripts/_workspaces.ts index 3b9c25e..93e27db 100644 --- a/scripts/_workspaces.ts +++ b/scripts/_workspaces.ts @@ -17,12 +17,15 @@ export interface Workspace { name: string /** Value of `version` in the workspace `package.json`, or `null` if absent. */ version: string | null + /** Parsed contents of the workspace `package.json`. */ + pkg: Record } interface RawPackageJson { name?: string version?: string private?: boolean + [key: string]: unknown } /** @@ -109,6 +112,7 @@ export function resolveWorkspaces (rootDir: string, packagesInput: string): Work relDir: relative(rootDir, dir).split(sep).join('/') || '.', name: pkg.name, version: typeof pkg.version === 'string' ? pkg.version : null, + pkg, }) } return workspaces diff --git a/scripts/update-changelog.ts b/scripts/update-changelog.ts index b588709..123289a 100644 --- a/scripts/update-changelog.ts +++ b/scripts/update-changelog.ts @@ -454,8 +454,7 @@ export function buildBumpFileSet (opts: { let rootCoveredByWorkspaces = false for (const ws of opts.workspaces) { - const wsPkg = JSON.parse(readFileSync(resolve(ws.dir, 'package.json'), 'utf8')) as Record - wsPkg.version = opts.newVersion + const wsPkg = { ...ws.pkg, version: opts.newVersion } const path = ws.relDir === '.' ? 'package.json' : `${ws.relDir}/package.json` if (path === 'package.json') rootCoveredByWorkspaces = true files.push({ path, content: JSON.stringify(wsPkg, null, 2) + '\n' }) diff --git a/test/_workspaces.test.ts b/test/_workspaces.test.ts index 9c12433..2141a54 100644 --- a/test/_workspaces.test.ts +++ b/test/_workspaces.test.ts @@ -94,11 +94,29 @@ describe('resolveWorkspaces', () => { const workspaces = resolveWorkspaces(tmp, 'packages/*') expect(workspaces).toEqual([ - { dir: resolve(tmp, 'packages/a'), relDir: 'packages/a', name: 'a', version: '1.0.0' }, - { dir: resolve(tmp, 'packages/b'), relDir: 'packages/b', name: 'b', version: '1.0.0' }, + { + dir: resolve(tmp, 'packages/a'), + relDir: 'packages/a', + name: 'a', + version: '1.0.0', + pkg: { name: 'a', version: '1.0.0' }, + }, + { + dir: resolve(tmp, 'packages/b'), + relDir: 'packages/b', + name: 'b', + version: '1.0.0', + pkg: { name: 'b', version: '1.0.0' }, + }, ]) }) + it('exposes the parsed package.json on each workspace', () => { + writePackage('packages/a', { name: 'a', version: '1.0.0', dependencies: { foo: '^1' } }) + const [ws] = resolveWorkspaces(tmp, 'packages/a') + expect(ws!.pkg).toEqual({ name: 'a', version: '1.0.0', dependencies: { foo: '^1' } }) + }) + it('treats a missing version as null', () => { writePackage('packages/a', { name: 'a' }) expect(resolveWorkspaces(tmp, 'packages/a')[0]!.version).toBeNull() From 4bd6f2b226363a7eeef9ee16a251af8c4fbd25be Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 30 May 2026 07:55:45 +0100 Subject: [PATCH 12/14] feat(pr): error when a literal `packages` entry matches no directory --- scripts/_workspaces.ts | 17 +++++++++++++++++ test/_workspaces.test.ts | 13 +++++++++++++ 2 files changed, 30 insertions(+) diff --git a/scripts/_workspaces.ts b/scripts/_workspaces.ts index 93e27db..cbaff63 100644 --- a/scripts/_workspaces.ts +++ b/scripts/_workspaces.ts @@ -44,6 +44,12 @@ export function parsePackagesInput (raw: string): string[] { * Expand a list of glob patterns rooted at `rootDir` into directories * that contain a `package.json`. Patterns starting with `!` are * treated as exclusions. + * + * Literal (non-glob) patterns are required to match a real workspace + * directory: a typo like `packages/aa` in an otherwise-valid list would + * otherwise be silently dropped. Glob patterns are allowed to match + * nothing on their own; the aggregate "matched no directories" check in + * `resolveWorkspaces` covers the case where every pattern misses. */ export function expandPackagePatterns (rootDir: string, patterns: string[]): string[] { const positive: string[] = [] @@ -56,10 +62,17 @@ export function expandPackagePatterns (rootDir: string, patterns: string[]): str const matched = new Set() for (const pattern of positive) { + let hit = false for (const match of globSync(pattern, { cwd: rootDir })) { const abs = resolve(rootDir, match) if (!isDirectoryWithPackageJson(abs)) continue matched.add(abs) + hit = true + } + if (!hit && !isGlob(pattern)) { + throw new Error( + `\`packages\` entry "${pattern}" did not match a directory with a package.json. Fix the path or remove the entry.`, + ) } } for (const pattern of negative) { @@ -70,6 +83,10 @@ export function expandPackagePatterns (rootDir: string, patterns: string[]): str return [...matched].sort() } +function isGlob (pattern: string): boolean { + return /[*?[\]{}]/.test(pattern) +} + function isDirectoryWithPackageJson (dir: string): boolean { try { if (!statSync(dir).isDirectory()) return false diff --git a/test/_workspaces.test.ts b/test/_workspaces.test.ts index 2141a54..1bcdcd4 100644 --- a/test/_workspaces.test.ts +++ b/test/_workspaces.test.ts @@ -85,6 +85,19 @@ describe('expandPackagePatterns', () => { resolve(tmp, 'packages/a'), ]) }) + + it('throws when a literal entry matches no directory', () => { + writePackage('packages/a', { name: 'a' }) + expect(() => expandPackagePatterns(tmp, ['packages/a', 'packages/typo'])) + .toThrowError(/"packages\/typo" did not match/) + }) + + it('does not throw when a glob entry matches nothing', () => { + writePackage('packages/a', { name: 'a' }) + expect(expandPackagePatterns(tmp, ['packages/a', 'apps/*'])).toEqual([ + resolve(tmp, 'packages/a'), + ]) + }) }) describe('resolveWorkspaces', () => { From faf6315e8565510953ed6e425e146a8eab699080 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 30 May 2026 07:56:07 +0100 Subject: [PATCH 13/14] fix(pr): refuse to bump prerelease and build-metadata versions --- scripts/update-changelog.ts | 14 +++++++++++--- test/update-changelog.test.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/scripts/update-changelog.ts b/scripts/update-changelog.ts index 123289a..6150466 100644 --- a/scripts/update-changelog.ts +++ b/scripts/update-changelog.ts @@ -166,9 +166,17 @@ function determineBump (commits: Commit[]): 'major' | 'minor' | 'patch' { return 'patch' } -function incVersion (version: string, bump: 'major' | 'minor' | 'patch'): string { - const match = version.match(/^(\d+)\.(\d+)\.(\d+)/) - if (!match) throw new Error(`Cannot parse version: ${version}`) +export function incVersion (version: string, bump: 'major' | 'minor' | 'patch'): string { + // uppt does not (yet) model prerelease or build-metadata releases. + // Silently rolling `1.2.3-rc.1` forward to `1.2.4` would lose the + // prerelease line, which is almost never what a maintainer wants. + // Refuse and let them reconcile. + const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/) + if (!match) { + throw new Error( + `Cannot bump version "${version}": expected strict "X.Y.Z" semver. uppt does not currently support prerelease or build-metadata versions.`, + ) + } let [, major, minor, patch] = match.map(Number) as [number, number, number, number, number] if (bump === 'major') { major += 1; minor = 0; patch = 0 } else if (bump === 'minor') { minor += 1; patch = 0 } diff --git a/test/update-changelog.test.ts b/test/update-changelog.test.ts index 052e97e..03d2f03 100644 --- a/test/update-changelog.test.ts +++ b/test/update-changelog.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { buildBumpFileSet } from '../scripts/update-changelog.ts' +import { buildBumpFileSet, incVersion } from '../scripts/update-changelog.ts' import { resolveWorkspaces } from '../scripts/_workspaces.ts' let tmp: string @@ -22,6 +22,32 @@ function writePackage (relDir: string, contents: Record) { writeFileSync(resolve(dir, 'package.json'), JSON.stringify(contents, null, 2)) } +describe('incVersion', () => { + it('bumps patch', () => { + expect(incVersion('1.2.3', 'patch')).toBe('1.2.4') + }) + + it('bumps minor and resets patch', () => { + expect(incVersion('1.2.3', 'minor')).toBe('1.3.0') + }) + + it('bumps major and resets minor and patch', () => { + expect(incVersion('1.2.3', 'major')).toBe('2.0.0') + }) + + it('throws on a prerelease version', () => { + expect(() => incVersion('1.2.3-rc.1', 'patch')).toThrowError(/strict "X\.Y\.Z" semver/) + }) + + it('throws on a version with build metadata', () => { + expect(() => incVersion('1.2.3+sha.abc', 'patch')).toThrowError(/strict "X\.Y\.Z" semver/) + }) + + it('throws on a non-semver string', () => { + expect(() => incVersion('not-a-version', 'patch')).toThrowError(/strict "X\.Y\.Z" semver/) + }) +}) + describe('buildBumpFileSet', () => { describe('single-package mode', () => { it('rewrites the root package.json with the new version', () => { From 152cb4b3e6e2bb858ae1076fb5cbad805d55f868 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 30 May 2026 07:56:15 +0100 Subject: [PATCH 14/14] docs: clarify private workspaces are dropped even when listed explicitly --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6734b7b..0281b8a 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ uppt runs your package's lifecycle scripts at one specific point and skips them uppt supports lockstep monorepos: every publishable package shares a single version, gets bumped together, lands under one `vX.Y.Z` tag, and is staged in one workflow run. -Declare the publishable workspaces by passing the same `packages:` input to both `uppt/pr` and `uppt/pack`. Each line is a directory path or a glob; `!`-prefixed entries are excluded; workspaces whose `package.json` has `"private": true` are silently skipped, so playgrounds and example apps stay out of npm. +Declare the publishable workspaces by passing the same `packages:` input to both `uppt/pr` and `uppt/pack`. Each line is a directory path or a glob; `!`-prefixed entries are excluded; workspaces whose `package.json` has `"private": true` are silently skipped (even when listed by an exact path), so playgrounds and example apps stay out of npm. ```yaml pr: