diff --git a/.claude/rules/package-management.md b/.claude/rules/package-management.md index 23c3672..893569f 100644 --- a/.claude/rules/package-management.md +++ b/.claude/rules/package-management.md @@ -4,7 +4,7 @@ This document defines the rules for managing dependencies in package.json. ## Package Manager: pnpm -This project uses **pnpm** for development. The `packageManager` field in `package.json` ensures the correct version is used (via Corepack). +This project uses **pnpm** for development. The `packageManager` field in `package.json` pins the exact version and is the single source of truth — pnpm itself follows it (`managePackageManagerVersions`, on by default since pnpm 10), so no Corepack is involved. See [The pnpm pin contract](#the-pnpm-pin-contract-packagemanager-as-single-source-of-truth). ```bash # Install dependencies @@ -99,7 +99,7 @@ The `pnpm-lock.yaml` file must always be committed. It provides additional repro ## Package Manager: pnpm 11 -This repo is pinned to **pnpm 11** via the `packageManager` field (corepack/`pnpm/action-setup` follow it, so CI and Docker use it automatically — no version is hardcoded anywhere else). +This repo is pinned to **pnpm 11** via the `packageManager` field — see [The pnpm pin contract](#the-pnpm-pin-contract-packagemanager-as-single-source-of-truth) for how CI and Docker consume it without corepack. pnpm 11 **no longer reads the `pnpm` field in `package.json`**, and `.npmrc` is auth/registry only. All pnpm-specific settings live in **`pnpm-workspace.yaml`**: @@ -109,6 +109,61 @@ pnpm 11 **no longer reads the `pnpm` field in `package.json`**, and `.npmrc` is `pnpm audit`: pnpm 10.x is broken (npm retired the legacy audit endpoint → HTTP 410); pnpm 11 uses the working bulk-advisory endpoint. `scripts/check.mjs` degrades the retired-endpoint failure to a non-blocking warning as a safety net, so `check` stays green + honest even if a future endpoint change lands. +## The pnpm pin contract: `packageManager` as single source of truth + +**Rule: the pnpm version is pinned in exactly one place — `package.json#packageManager`. Nothing may hardcode it, and nothing may rely on corepack.** + +```jsonc +{ + "packageManager": "pnpm@11.13.1+sha512.b2fc7683…", // THE pin: exact, with integrity hash + "engines": { "node": ">= 22", "pnpm": "^11.0.0" } // soft major gate — NOT a pin +} +``` + +**Corepack is not part of this contract.** It used to be the thing that read `packageManager`, but **Node >= 25 no longer ships it** — a build stage running `corepack enable` on such an image dies with `corepack: not found`. Nothing here needs it: pnpm follows the field itself (`managePackageManagerVersions`, on by default since pnpm 10), and `pnpm/action-setup` reads it directly. + +### How each consumer gets the pinned pnpm + +| Consumer | Mechanism | +|----------|-----------| +| Local dev | pnpm self-switches to the pinned version (`managePackageManagerVersions`) | +| GitHub Actions | `pnpm/action-setup@v6` with **no** `version:` input — it reads `packageManager` | +| Docker / other CI | the derive-line (below), once per pnpm-running stage | + +### The derive-line + +```dockerfile +# Provision the exact pnpm declared in package.json (single source of truth). +# No corepack: Node >= 25 no longer ships it. The +sha512 suffix is stripped; +# npm enforces registry integrity for the tarball itself. +RUN npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")" +``` + +`.split('+')[0]` reduces `pnpm@11.13.1+sha512.…` to the plain spec `npm install -g` accepts. Dropping the hash does not weaken integrity — npm verifies the tarball against the registry's own metadata. + +Two ordering rules, both enforced by the contract test: + +1. It must run **after** the `COPY` that puts `package.json` in the WORKDIR — it reads that file. +2. It must be repeated in **every** stage that runs `pnpm`. A global install in `deps` does not survive into `builder`; stages inherit only what is explicitly `COPY --from=`'d. + +### Rules + +| Rule | Why | +|------|-----| +| `packageManager` is exact (`x.y.z+sha512.`), never a range | Same fixed-version rule as dependencies; corepack rejects ranges outright | +| `engines.pnpm` tracks the pin's major (`^.0.0`) | A soft gate that warns pnpm 10 users. It is not a pin and cannot tell CI what to install | +| Never declare `devEngines.packageManager` | npm/npx abort with `EBADDEVENGINES`; corepack rejects ranges inside it | +| Never pass `version:` to `pnpm/action-setup` | Two sources drift; the action hard-errors on a mismatch | +| Never `RUN corepack …` | Absent on Node >= 25 | +| Never `npm install -g pnpm@` | That is a second pin — derive it instead | +| Monorepo: pin at the **workspace root** | The derive-line reads `/app/package.json`, which in monorepo mode (`API_DIR=projects/api`) is the root manifest. Missing there → `TypeError: Cannot read properties of undefined (reading 'split')` | + +### Enforcement + +`tests/unit/pnpm-pin-contract.spec.ts` asserts every rule above structurally (unit suite, no network), so a re-introduced `with: version: 11` or a stray `corepack enable` fails the build rather than drifting silently. A twelfth test proves the chain functionally — derive the spec, `npm install -g` it into a throwaway prefix, assert the binary reports the pinned version. It needs network + ~10 MB and is gated to `CI` / `PIN_PROVISION_TEST=1`. + +**When bumping pnpm:** run `pnpm self-update` (writes the field with a fresh hash), align `engines.pnpm` if the major moved, and commit `package.json` + `pnpm-lock.yaml` together. Nothing else needs touching — that is the point. + ## Overrides Package overrides live in the `overrides:` section of **`pnpm-workspace.yaml`** (they moved out of `package.json`'s `pnpm.overrides` in the pnpm 11 upgrade). They force transitive dependencies to a security-patched version. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d8df5db..1e97fa9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,9 +19,8 @@ jobs: uses: actions/checkout@v7 - name: Install pnpm + # No version input: the exact version is read from package.json's packageManager field. uses: pnpm/action-setup@v6 - with: - version: 11 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6f85269..a765b61 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,9 +18,8 @@ jobs: uses: actions/checkout@v7 - name: Install pnpm + # No version input: the exact version is read from package.json's packageManager field. uses: pnpm/action-setup@v6 - with: - version: 11 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 diff --git a/Dockerfile b/Dockerfile index 9c4881d..4265fbe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ FROM node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432 WORKDIR /app # Build tools for bcrypt native addon -RUN apk add --no-cache python3 make g++ && corepack enable +RUN apk add --no-cache python3 make g++ # Copy all manifests for dependency resolution. # In monorepo mode pnpm needs workspace config + all project manifests; @@ -25,6 +25,11 @@ RUN find /tmp/src -maxdepth 3 -name "package.json" -not -path "*/node_modules/*" && cp -f /tmp/src/.npmrc /app/ 2>/dev/null || true \ && rm -rf /tmp/src +# Provision the exact pnpm declared in package.json (single source of truth). +# No corepack: Node >= 25 no longer ships it. The +sha512 suffix is stripped; +# npm enforces registry integrity for the tarball itself. +RUN npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")" + # Install dependencies (--ignore-scripts prevents husky/prepare errors in Docker) # Rebuild bcrypt native addon separately RUN pnpm install --frozen-lockfile --ignore-scripts && pnpm rebuild bcrypt @@ -33,11 +38,15 @@ RUN pnpm install --frozen-lockfile --ignore-scripts && pnpm rebuild bcrypt FROM node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS builder ARG API_DIR=. WORKDIR /app -RUN corepack enable COPY --from=deps /app ./ COPY ${API_DIR}/ ./${API_DIR}/ +# Provision the exact pnpm declared in package.json (single source of truth). +# No corepack: Node >= 25 no longer ships it. The +sha512 suffix is stripped; +# npm enforces registry integrity for the tarball itself. +RUN npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")" + RUN cd ${API_DIR} && pnpm run build # Remove devDependencies after build diff --git a/FRAMEWORK-API.md b/FRAMEWORK-API.md index 047003c..6488fec 100644 --- a/FRAMEWORK-API.md +++ b/FRAMEWORK-API.md @@ -1,6 +1,6 @@ # @lenne.tech/nest-server — Framework API Reference -> Auto-generated from source code on 2026-07-16 (v11.29.0) +> Auto-generated from source code on 2026-07-17 (v11.29.1) > File: `FRAMEWORK-API.md` — compact, machine-readable API surface for Claude Code ## CoreModule.forRoot() diff --git a/migration-guides/11.29.0-to-11.29.1.md b/migration-guides/11.29.0-to-11.29.1.md new file mode 100644 index 0000000..07d77a6 --- /dev/null +++ b/migration-guides/11.29.0-to-11.29.1.md @@ -0,0 +1,311 @@ +# Migration Guide: 11.29.0 → 11.29.1 + +## Overview + +| Category | Details | +|----------|---------| +| **Breaking Changes** | None | +| **Bugfixes** | None — no runtime code changed. `src/core/` is byte-identical to 11.29.0. | +| **New Features** | None | +| **Maintenance** | **Toolchain only.** `package.json#packageManager` is restored as the *single* place the pnpm version is pinned. `corepack` is gone from the `Dockerfile` (Node >= 25 no longer ships it); every pnpm-running build stage now provisions the pinned pnpm itself. `pnpm/action-setup` reads the pin instead of carrying its own `version` input. A contract test (`tests/unit/pnpm-pin-contract.spec.ts`) guards the whole chain. | +| **Migration Effort** | **0 minutes for npm-mode and vendor-mode consumers** — nothing in your project changes. **~10 minutes if you adopt the pattern**, which you should do *before* moving any build to Node >= 25. | + +This release changes **how the framework repo builds itself**. It ships no API change, no +configuration change, and no behavioral change. Update with `pnpm add @lenne.tech/nest-server@11.29.1` +and you are done. + +The reason to read further: **the `Dockerfile` is not part of the npm package** (it is not in +`package.json#files`). Your project's own `Dockerfile` and CI came from the starter template, so you +do **not** inherit this fix automatically. If they still run `corepack enable`, they will break the +day you move to Node >= 25 — see [Adopting the pattern](#adopting-the-pattern-in-your-project-recommended). + +--- + +## Quick Migration (npm mode) + +No code changes required. + +```bash +# Update the package +pnpm add @lenne.tech/nest-server@11.29.1 + +# Verify +pnpm run build +pnpm test +``` + +--- + +## Why this release exists: corepack is going away + +`corepack` was the mechanism that read `package.json#packageManager` and materialized the right +package-manager binary. It shipped with Node for years, which is why the framework's `Dockerfile` +could simply say `corepack enable` and trust that pnpm appeared. + +**Node >= 25 no longer ships corepack.** A build stage that runs `corepack enable` on such an image +fails outright: + +``` +/bin/sh: corepack: not found +``` + +So the framework needed a way to provision an exact pnpm version that does not depend on corepack +existing — without giving up the single-source-of-truth pin. + +### What 11.29.0 did, and why it is reverted here + +11.29.0 removed the `packageManager` field and introduced `engines.pnpm: "^11.0.0"`. That left the +repo with **no exact pin at all**, so the version had to be restated wherever pnpm was needed — the +workflows grew a hardcoded `with: version: 11`, and Docker's `corepack enable` (with no +`packageManager` field to follow) would resolve to whatever it considered current. + +Two sources of truth, neither exact. 11.29.1 collapses them back into one: + +| | 11.29.0 | 11.29.1 | +|---|---|---| +| `package.json#packageManager` | *(removed)* | `pnpm@11.13.1+sha512.…` — **the** pin | +| `package.json#engines.pnpm` | `^11.0.0` | `^11.0.0` — unchanged, a soft major gate | +| `.github/workflows/*` | `with: version: 11` | *(no `version` input — reads the pin)* | +| `Dockerfile` | `corepack enable` | derive-line (below) | + +`engines.pnpm` stays, but understand what it is: a **soft major-range guard**, not a pin. It warns a +pnpm 10 user; it does not tell CI which version to install. Only `packageManager` does that. + +--- + +## What Changed + +### 1. `packageManager` is back — exact, with an integrity hash + +```jsonc +// package.json +{ + "packageManager": "pnpm@11.13.1+sha512.b2fc7683b8a6525414e7d13e1ba28caaddde96bf66ec540bfaeb7e702b81f3e0be4d1f295edf7f9fe0396740a8dce4509c582ddf79891f4543fea32d37645f25", + "engines": { + "node": ">= 22", + "pnpm": "^11.0.0" + } +} +``` + +**This field has no effect on your project.** `packageManager` is only ever read from the *root* +`package.json` of the project being built — never from a dependency's. You inherit nothing from the +framework's pin. + +Note that pnpm itself honours the field, with no corepack involved: run any `pnpm` command in a +directory whose `package.json` pins a different pnpm, and pnpm switches to it +(`managePackageManagerVersions`, on by default since pnpm 10). Corepack was never the only thing +reading this field — which is exactly why dropping it costs nothing. + +### 2. The Dockerfile provisions pnpm without corepack + +Every stage that runs `pnpm` now provisions the pinned version itself, from the pin: + +```dockerfile +# Provision the exact pnpm declared in package.json (single source of truth). +# No corepack: Node >= 25 no longer ships it. The +sha512 suffix is stripped; +# npm enforces registry integrity for the tarball itself. +RUN npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")" +``` + +The `.split('+')[0]` turns `pnpm@11.13.1+sha512.b2fc…` into `pnpm@11.13.1`, because `npm install -g` +takes a plain spec. The dropped hash is not a loss of integrity: npm verifies the tarball against the +registry's own integrity metadata on install. + +The line appears **once per pnpm-running stage** (`deps` and `builder`) — Docker stages do not inherit +each other's globally installed binaries, only what is explicitly `COPY --from=`'d. + +### 3. `pnpm/action-setup` no longer carries a `version` input + +```yaml +- name: Install pnpm + # No version input: the exact version is read from package.json's packageManager field. + uses: pnpm/action-setup@v6 +``` + +The action reads `packageManager` on its own. Specifying **both** is not redundant-but-harmless — the +action treats a mismatch as a hard error, so the two sources cannot silently drift. + +### 4. A contract test guards the chain + +`tests/unit/pnpm-pin-contract.spec.ts` (11 assertions, unit suite, no MongoDB) fails the build if +anyone re-introduces the drift this release removes: + +- the pin is exact (`x.y.z` + `sha512` hash — never a range), +- `engines.pnpm` tracks the pin's major, +- `devEngines.packageManager` never (re)appears — npm/npx abort with `EBADDEVENGINES` on it, +- the `Dockerfile` contains no `corepack` in any `RUN`, +- **every** pnpm-running stage runs the derive-line *before* its first `pnpm` command, and only after + a `COPY` has put `package.json` in place, +- no workflow passes a `version:` to `pnpm/action-setup` or hardcodes `npm install -g pnpm@…`. + +A twelfth test proves the chain end-to-end — it derives the spec exactly as the Dockerfile does, +installs it into a throwaway prefix, and asserts the provisioned binary reports the pinned version. +It needs network and ~10 MB, so it is gated to `CI` / `PIN_PROVISION_TEST=1` and stays out of your way +locally. + +--- + +## Breaking Changes + +**None.** No public API, config key, decorator, or exported symbol changed. + +--- + +## Adopting the pattern in your project (recommended) + +Do this **before** you move any build to Node >= 25 — not after it breaks. If your project was +generated from a recent `nest-server-starter` / `lt fullstack init`, check whether it is already done. + +### Step 1: Pin pnpm in your root `package.json` + +```bash +# Writes packageManager with the integrity hash for the version you actually use +pnpm self-update +``` + +Or set it by hand — the exact form matters (`pnpm@x.y.z+sha512.`): + +```jsonc +{ + "packageManager": "pnpm@11.13.1+sha512.b2fc7683…", + "engines": { "node": ">= 22", "pnpm": "^11.0.0" } +} +``` + +> **Monorepo:** this belongs in the **workspace root** `package.json`, not only in `projects/api/`. +> The Dockerfile's derive-line reads `/app/package.json`, which in monorepo mode +> (`--build-arg API_DIR=projects/api`) is the root manifest. See +> [Troubleshooting](#docker-build-fails-with-typeerror-cannot-read-properties-of-undefined-reading-split). + +### Step 2: Replace corepack in your Dockerfile + +**Before:** +```dockerfile +RUN apk add --no-cache python3 make g++ && corepack enable +``` + +**After:** +```dockerfile +RUN apk add --no-cache python3 make g++ + +# … COPY the manifests first — the derive-line reads package.json … + +# Provision the exact pnpm declared in package.json (single source of truth). +# No corepack: Node >= 25 no longer ships it. The +sha512 suffix is stripped; +# npm enforces registry integrity for the tarball itself. +RUN npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")" +``` + +Two ordering rules, both easy to get wrong: + +1. The derive-line must come **after** the `COPY` that puts `package.json` into the WORKDIR — it + reads that file. +2. It must be repeated in **each** stage that runs `pnpm`. A global install in `deps` does not reach + `builder`. + +### Step 3: Drop the hardcoded version in CI + +**GitHub Actions** — remove the `version` input: + +```yaml +- name: Install pnpm + uses: pnpm/action-setup@v6 # reads packageManager; no `with: version:` +``` + +**GitLab CI** (or any runner without the action) — use the same derive-line: + +```yaml +before_script: + # Provision the exact pnpm pinned in package.json (single source of truth). + - npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")" + - pnpm install --frozen-lockfile +``` + +### Step 4 (optional): Guard it with a test + +Copy `tests/unit/pnpm-pin-contract.spec.ts` from this repo and trim it to the files your project has. +The value is not the assertions — it is that the next person who "helpfully" re-adds +`with: version: 11` or a second `npm install -g pnpm@…` gets a red build instead of a silent drift +that surfaces months later as an unreproducible container. + +--- + +## Compatibility Notes + +| Pattern | Status | +|---------|--------| +| Any application code, decorator, service, or config using the framework | ✅ Unaffected — no runtime code changed | +| **npm-mode** consumers (`@lenne.tech/nest-server` as a dependency) | ✅ Unaffected — `pnpm add …@11.29.1` and done | +| **Vendor-mode** consumers (`src/core/` copied in) | ✅ Unaffected — `src/core/` is byte-identical to 11.29.0; a sync produces no delta | +| The framework's `packageManager` pin leaking into your project | ✅ Impossible — the field is read only from the root manifest, never from a dependency | +| Your project's `Dockerfile` still using `corepack enable` | ⚠️ Works on Node <= 24, **breaks on Node >= 25** — apply Step 2 | +| Your CI passing both `with: version:` **and** having a `packageManager` field | ⚠️ Hard error on version mismatch — apply Step 3 | +| Node version | ✅ Unchanged (`engines.node: ">= 22"`); the framework's own images stay on Node 24 LTS | +| pnpm 10 or older as your project's package manager | ⚠️ `engines.pnpm: "^11.0.0"` warns (`EBADENGINE`); a hard failure only with `engine-strict=true` | + +--- + +## Troubleshooting + +### `corepack: not found` in a Docker build or CI job + +You moved to a Node >= 25 image while your build still calls `corepack enable`. This is exactly the +failure this release prevents — apply [Step 2](#step-2-replace-corepack-in-your-dockerfile). Do not +"fix" it with `npm install -g corepack`: that reintroduces the indirection the derive-line removes. + +### Docker build fails with `TypeError: Cannot read properties of undefined (reading 'split')` + +The derive-line found a `package.json` **without** a `packageManager` field. Almost always a +monorepo: in monorepo mode the build context is the workspace root, so `/app/package.json` is the +**root** manifest — and the pin was only added to `projects/api/package.json`. + +Fix: add `packageManager` to the workspace **root** manifest (Step 1). Keeping the root and the API +package on the same pin is the point — one pnpm builds the whole workspace. + +### `ERR_PNPM_BAD_PM_VERSION`, or the action reports multiple pnpm versions + +Two sources disagree about the version. Either your workflow still passes `with: version:` alongside +a `packageManager` field (remove the input — [Step 3](#step-3-drop-the-hardcoded-version-in-ci)), or a +stray `npm install -g pnpm@` runs before the derive-line. + +### `EBADENGINE` / `ERR_PNPM_UNSUPPORTED_ENGINE` warning mentioning pnpm + +Unchanged from 11.29.0 — `engines.pnpm: "^11.0.0"` warns when installing with pnpm 10 or older. +Upgrade with `npm i -g pnpm@11`, or pin your project via `packageManager` and let pnpm switch itself. +The framework's *runtime* does not depend on pnpm; this concerns the install step only. + +### `EBADDEVENGINES` from npm or npx + +Something added a `devEngines.packageManager` block. npm and npx abort on it, and corepack rejects +ranges inside it. Use `packageManager` (exact pin) plus `engines.pnpm` (soft range) instead — the +contract test asserts `devEngines.packageManager` stays absent. + +--- + +## Affected Files + +No core module changed in this release, so there is no module documentation to revisit. For +completeness, the full change surface: + +| File | Change | +|------|--------| +| `package.json` | `packageManager` restored (exact pin + `sha512`); version → 11.29.1 | +| `Dockerfile` | `corepack enable` removed; derive-line added to the `deps` and `builder` stages | +| `.github/workflows/build.yml`, `publish.yml` | `with: version: 11` removed from `pnpm/action-setup` | +| `tests/unit/pnpm-pin-contract.spec.ts` | **New** — 11 structural assertions + 1 CI-gated provisioning proof | +| `spectaql.yml`, `FRAMEWORK-API.md` | Version bump only (kept in sync per the release process) | +| `.claude/rules/package-management.md` | Documents the corepack-free pin contract | + +--- + +## References + +- [Package Management Rules — fixed versions, overrides, the pnpm pin contract](../.claude/rules/package-management.md) +- [Versioning Strategy — release process, `package.json` / `spectaql.yml` version sync](../.claude/rules/versioning.md) +- Contract test: `tests/unit/pnpm-pin-contract.spec.ts` +- [Migration Guide 11.28.1 → 11.29.0](./11.28.1-to-11.29.0.md) — previous release (introduced the `engines.pnpm` gate this guide keeps) +- [Migration Guide 11.27.6 → 11.27.7](./11.27.6-to-11.27.7.md) — the pnpm 11 move, when corepack was still the mechanism +- [nest-server-starter](https://github.com/lenneTech/nest-server-starter) (reference implementation) + + diff --git a/package.json b/package.json index 70e25da..2488f20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lenne.tech/nest-server", - "version": "11.29.0", + "version": "11.29.1", "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).", "keywords": [ "node", @@ -79,6 +79,7 @@ "node": ">= 22", "pnpm": "^11.0.0" }, + "packageManager": "pnpm@11.13.1+sha512.b2fc7683b8a6525414e7d13e1ba28caaddde96bf66ec540bfaeb7e702b81f3e0be4d1f295edf7f9fe0396740a8dce4509c582ddf79891f4543fea32d37645f25", "dependencies": { "@apollo/server": "5.5.1", "@as-integrations/express5": "1.1.2", diff --git a/spectaql.yml b/spectaql.yml index 8a0e743..a49bce5 100644 --- a/spectaql.yml +++ b/spectaql.yml @@ -19,7 +19,7 @@ servers: info: title: lT Nest Server description: Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases). - version: 11.29.0 + version: 11.29.1 contact: name: lenne.Tech GmbH url: https://lenne.tech diff --git a/tests/unit/pnpm-pin-contract.spec.ts b/tests/unit/pnpm-pin-contract.spec.ts new file mode 100644 index 0000000..ce3f6f4 --- /dev/null +++ b/tests/unit/pnpm-pin-contract.spec.ts @@ -0,0 +1,148 @@ +/** + * Unit Tests: pnpm version pin contract (packageManager as single source of truth). + * + * Node >= 25 no longer ships corepack, so nothing may depend on `corepack enable` anymore. + * Instead, package.json's `packageManager` field is the ONLY place the pnpm version is pinned: + * + * - Dockerfiles provision it via the derive-line + * `npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")"`, + * - pnpm/action-setup reads the field automatically (its `version` input must stay absent, or the + * two sources drift and the action fails on a version mismatch), + * - `engines.pnpm` stays a soft major-range guard aligned with the pin. + * + * These tests assert the contract structurally. The final describe block proves the derive-chain + * actually works by provisioning the pinned pnpm into a throwaway prefix — it needs network and + * ~10MB, so it only runs in CI or when PIN_PROVISION_TEST is set. + */ +import { execSync } from 'node:child_process'; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const ROOT = join(__dirname, '..', '..'); + +const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); +const packageManager: string = pkg.packageManager; + +/** Exact version, no range, integrity hash present — e.g. `pnpm@11.13.1+sha512.`. */ +const PIN_PATTERN = /^pnpm@(\d+)\.\d+\.\d+\+sha512\.[A-Za-z0-9]+$/; + +/** The `pnpm@..` part, without the integrity suffix. */ +const pinnedSpec = packageManager?.split('+')[0]; +const pinnedVersion = pinnedSpec?.split('@')[1]; +const pinnedMajor = pinnedVersion?.split('.')[0]; + +/** The load-bearing fragment of the derive-line every Dockerfile must provision pnpm with. */ +const DERIVE_PATTERN = "packageManager.split('+')[0]"; + +describe('pnpm pin contract: package.json', () => { + it('pins an exact pnpm version with integrity hash in packageManager', () => { + expect(packageManager).toMatch(PIN_PATTERN); + }); + + it("keeps engines.pnpm as the pin's major range", () => { + expect(pkg.engines?.pnpm).toBe(`^${pinnedMajor}.0.0`); + }); + + // npm/npx abort with EBADDEVENGINES on devEngines.packageManager, and corepack rejects ranges + // in it — the field must never (re)appear. + it('does not declare devEngines.packageManager', () => { + expect(pkg.devEngines?.packageManager).toBeUndefined(); + }); +}); + +describe('pnpm pin contract: Dockerfile', () => { + const dockerfile = readFileSync(join(ROOT, 'Dockerfile'), 'utf8'); + + it('does not rely on corepack (Node >= 25 no longer ships it)', () => { + expect(dockerfile).not.toContain('corepack enable'); + // No RUN instruction may invoke corepack at all (comments explaining its absence are fine). + expect(dockerfile).not.toMatch(/^RUN[^\n]*corepack/m); + }); + + it('provisions pnpm from packageManager via the derive-line', () => { + expect(dockerfile).toContain(DERIVE_PATTERN); + }); + + it('runs the derive-line before the first pnpm command of every pnpm-running stage', () => { + // Split into build stages; a stage that runs pnpm must have provisioned it first — + // and AFTER package.json exists in the WORKDIR (an earlier COPY in the same stage, + // or `COPY --from=` of a directory that contains it). + const stages = dockerfile.split(/^FROM /m).slice(1); + const pnpmStages = stages.filter(stage => /^RUN[^\n]*\bpnpm\b/m.test(stage)); + expect(pnpmStages.length).toBeGreaterThan(0); + for (const stage of pnpmStages) { + const deriveIndex = stage.indexOf(DERIVE_PATTERN); + const firstPnpmRun = stage.search(/^RUN[^\n]*\bpnpm\b/m); + expect(deriveIndex).toBeGreaterThan(-1); + expect(deriveIndex).toBeLessThan(firstPnpmRun); + // package.json must already be in place when the derive-line reads it. + const copyBeforeDerive = /(^COPY |^RUN [^\n]*package\.json)/m.test(stage.slice(0, deriveIndex)); + expect(copyBeforeDerive).toBe(true); + } + }); +}); + +describe('pnpm pin contract: GitHub workflows', () => { + const workflowDir = join(ROOT, '.github', 'workflows'); + const workflows = readdirSync(workflowDir).filter(file => /\.ya?ml$/.test(file)); + + it('finds workflow files to check', () => { + expect(workflows.length).toBeGreaterThan(0); + }); + + for (const file of workflows) { + const content = readFileSync(join(workflowDir, file), 'utf8'); + + it(`${file}: pnpm/action-setup carries no version input (field is read from packageManager)`, () => { + const lines = content.split('\n'); + lines.forEach((line, index) => { + if (!line.includes('pnpm/action-setup')) { + return; + } + // Inspect the step's remaining lines (until the next step starts) for a bare `version:`. + for (let i = index + 1; i < lines.length && !/^\s*- /.test(lines[i]); i++) { + expect(lines[i]).not.toMatch(/^\s*version:/); + } + }); + }); + + it(`${file}: contains no hardcoded pnpm install or corepack usage`, () => { + expect(content).not.toMatch(/npm\s+(?:install|i)\s+-g\s+pnpm@\d/); + expect(content).not.toMatch(/corepack/i); + }); + } +}); + +// Functional proof of the whole chain: derive the spec exactly like the Dockerfile does, install +// it into a throwaway npm prefix, and check the provisioned binary reports the pinned version. +// Needs network + ~10MB, so it is gated to CI / explicit opt-in and must not slow local hooks. +describe.runIf(Boolean(process.env.CI || process.env.PIN_PROVISION_TEST))('pnpm pin contract: provisioning (CI / PIN_PROVISION_TEST only)', () => { + it( + 'derive-line yields the pinned spec and npm provisions exactly that pnpm version', + () => { + const derived = execSync('node -p "require(\'./package.json\').packageManager.split(\'+\')[0]"', { + cwd: ROOT, + encoding: 'utf8', + }).trim(); + expect(derived).toBe(pinnedSpec); + expect(derived).toBe(`pnpm@${pinnedVersion}`); + + const prefix = mkdtempSync(join(tmpdir(), 'pnpm-pin-contract-')); + try { + execSync(`npm install -g --prefix "${prefix}" "${derived}"`, { + cwd: ROOT, + encoding: 'utf8', + stdio: 'pipe', + timeout: 150_000, + }); + const provisioned = execSync(`"${join(prefix, 'bin', 'pnpm')}" --version`, { encoding: 'utf8' }).trim(); + expect(provisioned).toBe(pinnedVersion); + } finally { + rmSync(prefix, { force: true, recursive: true }); + } + }, + 180_000, + ); +});