diff --git a/.github/CI-AUDIT.md b/.github/CI-AUDIT.md index 62b7bd7d9..f80ccf149 100644 --- a/.github/CI-AUDIT.md +++ b/.github/CI-AUDIT.md @@ -28,7 +28,7 @@ This document summarizes the **current inventory** and **stabilization measures* |-------|--------|-------| | Node (recommended) | [`.nvmrc`](../.nvmrc) | **22** | | Node (CI matrix) | `ci.yml` `quality` | **22** and **24** (explicit, no `node` = "current") | -| pnpm | `package.json` `packageManager` | **11.5.2** | +| pnpm | `package.json` `packageManager` | **11.22.0** (security floor **11.11.0**) | | Lint | Biome | `pnpm run lint` | | i18n | `scripts/check-i18n-keys.mjs` | `pnpm run i18n:check` | | Unit | Vitest + V8 | CI: `pnpm exec vitest run --coverage`; local: targeted `pnpm exec vitest run ` | diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index bc8cb4166..e1c2d7c62 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -16,13 +16,10 @@ inputs: runs: using: composite steps: - # QNBS-v3: corepack enable ensures pnpm is available even if the runner has a different default. - # Node 24+ changed corepack behavior; explicit enable prevents "pnpm: command not found" errors. - - name: Enable corepack - shell: bash - run: corepack enable - - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + # QNBS-v3: install the patched exact pnpm before setup-node reads the repository lockfile for caching. + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + with: + version: 11.22.0 # QNBS-v3: When node-version is set (matrix), use it; otherwise read .nvmrc so # non-matrix jobs stay pinned to the project's declared Node version. @@ -33,6 +30,12 @@ runs: node-version-file: ${{ inputs.node-version == '' && inputs.node-version-file || '' }} cache: pnpm + - name: Assert toolchain + run: | + export npm_config_user_agent="pnpm/$(pnpm --version)" + node scripts/check-pnpm-toolchain.mjs + shell: bash + - name: Install dependencies run: pnpm install --frozen-lockfile shell: bash diff --git a/.github/workflows/deploy-cloudflare-pages.yml.disabled b/.github/workflows/deploy-cloudflare-pages.yml.disabled index dece11dde..1cce823fe 100644 --- a/.github/workflows/deploy-cloudflare-pages.yml.disabled +++ b/.github/workflows/deploy-cloudflare-pages.yml.disabled @@ -45,15 +45,20 @@ jobs: # QNBS-v3: SHA-pinned to prevent supply-chain attacks via mutable tags - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 with: - version: 10 + version: 11.22.0 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: pnpm + - name: Assert toolchain + run: | + export npm_config_user_agent="pnpm/$(pnpm --version)" + node scripts/check-pnpm-toolchain.mjs + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/AGENTS.md b/AGENTS.md index 7c04debbd..39f51993f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ The app supports a multi-provider AI stack (Gemini, OpenAI, Claude, Grok, OpenRo | Layer | Technology | |-------|------------| -| Runtime | Node.js `>=22.0.0` (`.nvmrc` → `22`), pnpm `>=11.0.0` (`packageManager: pnpm@11.5.2`) | +| Runtime | Node.js `>=22.0.0` (`.nvmrc` → `22`), pnpm `11.22.0` (`packageManager: pnpm@11.22.0`) | | Framework | React `^19.2.7`, TypeScript 7 via `@typescript/native-preview` (tsgo, strict) — no pinned classic `typescript` package | | Build tool | Vite `^8.0.16` (`vite.config.ts`) | | Type checker | `tsgo` (TypeScript Go port) via `tsconfig.tsgo.json` with 4 checkers (`pnpm run typecheck`) | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7dbf66c63..ab55f31cf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ This project follows the [Contributor Covenant Code of Conduct](.github/CODE_OF_ 1. Install **Node.js 22+** LTS from [nodejs.org](https://nodejs.org/) (includes Corepack) or use **nvm-windows** and install `22` from [`.nvmrc`](.nvmrc). 2. Open **PowerShell or CMD as Administrator** once and run: `corepack enable` -3. In the repo folder: `corepack prepare pnpm@11.5.2 --activate` (version matches `packageManager` in [`package.json`](package.json); adjust if that field changes). +3. In the repo folder: `corepack prepare pnpm@11.22.0 --activate` (version matches `packageManager` in [`package.json`](package.json); adjust if that field changes). 4. Confirm: `pnpm -v` — then `pnpm install` and use `pnpm run …` for all scripts (hooks expect `pnpm` on `PATH`). If `corepack` is not recognized, reinstall Node or enable the “Tools for Native Modules” / standard installation so `corepack.cmd` is on `PATH`. diff --git a/README.md b/README.md index 0362c3aef..d0016fea4 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2924 keys - 6908+ tests / 567 files + 6910+ tests / 568 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2924 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (6908+ tests / 567 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (6910+ tests / 568 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -549,7 +549,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (6908+ tests, 567 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (6910+ tests, 568 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -711,7 +711,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):** -- **6908+ unit tests** across **567 test files** — CI is authoritative for pass/fail +- **6910+ unit tests** across **568 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) - i18n: **2924 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/docs/CI.md b/docs/CI.md index 6448f417e..eaaee91b9 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -78,7 +78,7 @@ non-blocking under the exit criteria documented below. The coverage ratchet rema `.github/actions/setup/action.yml` centralises pnpm + Node.js bootstrap into one reusable step used by every job: ``` -pnpm/action-setup → actions/setup-node (cache: pnpm) → pnpm install --frozen-lockfile +pnpm/action-setup (explicit patched 11.22.0) → actions/setup-node (cache: pnpm) → toolchain check → pnpm install --frozen-lockfile ``` Each job that uses the composite must call `actions/checkout@v6` first (local composite actions are resolved from the workspace, so the repo must be checked out before `uses: ./.github/actions/setup` can be used). The `quality` job additionally passes `node-version: ${{ matrix.node-version }}` to cover the LTS matrix. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index c8c74ec04..30b07d9d3 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -60,7 +60,7 @@ gh run view --log-failed 5. **Do not use** `npx wrangler deploy` (Workers) nor `wrangler pages deploy` in the deploy step — redundant and often fails on API token scope in the build container. 6. If the UI forces a deploy command, use: `pnpm run deploy:cloudflare` — it **exits 0** on Cloudflare (`CF_PAGES=1`) without calling Wrangler. 7. Remove **`CLOUDFLARE_API_TOKEN`** from Pages **build** environment variables unless you have a dedicated manual deploy workflow; it is not needed for Git-based Pages. -8. **Environment variables (build):** `NODE_VERSION=22`, `PNPM_VERSION=10` (or Corepack). +8. **Environment variables (build):** `NODE_VERSION=22`, `PNPM_VERSION=11.22.0` (or Corepack using the exact `packageManager` pin). 9. **Root:** repository root; **Package manager:** pnpm. > **Status:** Optional GitHub workflow [`.github/workflows/deploy-cloudflare-pages.yml`](../.github/workflows/deploy-cloudflare-pages.yml) is **paused** (`if: false`). Prefer dashboard-only Pages deploy. diff --git a/package.json b/package.json index 9d3801324..e271365aa 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,10 @@ "tauri" ], "type": "module", - "packageManager": "pnpm@11.5.2", + "packageManager": "pnpm@11.22.0", "engines": { "node": ">=22.0.0", - "pnpm": "11.5.2" + "pnpm": "11.22.0" }, "scripts": { "hooks:install": "simple-git-hooks", diff --git a/scripts/check-pnpm-toolchain.mjs b/scripts/check-pnpm-toolchain.mjs index 82710cf3a..adf0fc3b1 100644 --- a/scripts/check-pnpm-toolchain.mjs +++ b/scripts/check-pnpm-toolchain.mjs @@ -1,4 +1,5 @@ import process from 'node:process'; +import { isVersionAtLeast } from './pnpm-version-policy.mjs'; const projectPackage = JSON.parse( await (await import('node:fs/promises')).readFile('package.json', 'utf8'), @@ -7,12 +8,25 @@ const expectedPackageManager = projectPackage.packageManager; const expectedPnpmVersion = expectedPackageManager?.startsWith('pnpm@') ? expectedPackageManager.slice('pnpm@'.length) : null; +const minimumSecurePnpmVersion = '11.11.0'; if (!expectedPnpmVersion) { console.error('[toolchain] package.json must pin packageManager to pnpm@.'); process.exit(1); } +if (projectPackage.engines?.pnpm !== expectedPnpmVersion) { + console.error('[toolchain] engines.pnpm must exactly match packageManager.'); + process.exit(1); +} + +if (!isVersionAtLeast(expectedPnpmVersion, minimumSecurePnpmVersion)) { + console.error( + `[toolchain] pnpm ${expectedPnpmVersion} is below the security floor ${minimumSecurePnpmVersion}.`, + ); + process.exit(1); +} + const minimumNodeMajor = Number.parseInt( projectPackage.engines?.node?.match(/\d+/)?.[0] ?? '0', 10, @@ -27,10 +41,6 @@ if (actualNodeMajor < minimumNodeMajor) { const isDirectHook = process.argv.includes('--hook'); const userAgentVersion = process.env.npm_config_user_agent?.match(/(?:^|\s)pnpm\/(\S+)/)?.[1]; -if (userAgentVersion && userAgentVersion !== expectedPnpmVersion) { - console.error(`[toolchain] pnpm ${expectedPnpmVersion} is required; found ${userAgentVersion}.`); - process.exit(1); -} if (!userAgentVersion) { if (isDirectHook) { @@ -43,6 +53,9 @@ if (!userAgentVersion) { ); process.exit(1); } +} else if (userAgentVersion !== expectedPnpmVersion) { + console.error(`[toolchain] pnpm ${expectedPnpmVersion} is required; found ${userAgentVersion}.`); + process.exit(1); } else { console.log( `[toolchain] Node ${process.versions.node} and pnpm ${userAgentVersion} match the project pin.`, diff --git a/scripts/pnpm-version-policy.d.mts b/scripts/pnpm-version-policy.d.mts new file mode 100644 index 000000000..37bda39bb --- /dev/null +++ b/scripts/pnpm-version-policy.d.mts @@ -0,0 +1,2 @@ +export function parseVersion(version: string): number[] | null; +export function isVersionAtLeast(version: string, minimum: string): boolean; diff --git a/scripts/pnpm-version-policy.mjs b/scripts/pnpm-version-policy.mjs new file mode 100644 index 000000000..54f0dc9b0 --- /dev/null +++ b/scripts/pnpm-version-policy.mjs @@ -0,0 +1,19 @@ +export function parseVersion(version) { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); + return match ? match.slice(1).map(Number) : null; +} + +export function isVersionAtLeast(version, minimum) { + const actualParts = parseVersion(version); + const minimumParts = parseVersion(minimum); + if (!actualParts || !minimumParts) return false; + + for (let index = 0; index < 3; index += 1) { + const actualPart = actualParts[index]; + const minimumPart = minimumParts[index]; + if (actualPart > minimumPart) return true; + if (actualPart < minimumPart) return false; + } + + return true; +} diff --git a/tests/unit/pnpmToolchainPolicy.test.ts b/tests/unit/pnpmToolchainPolicy.test.ts new file mode 100644 index 000000000..9cab0d0c7 --- /dev/null +++ b/tests/unit/pnpmToolchainPolicy.test.ts @@ -0,0 +1,22 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { isVersionAtLeast } from '../../scripts/pnpm-version-policy.mjs'; + +// QNBS-v3: Compare semantic version components left-to-right so a lower major cannot pass on a higher minor. +describe('pnpm version policy', () => { + it.each([ + ['11.11.0', '11.11.0', true], + ['11.12.0', '11.11.0', true], + ['12.0.0', '11.11.0', true], + ['11.10.9', '11.11.0', false], + ['10.99.0', '11.0.0', false], + ['11.22.0', '11.11.0', true], + ])('%s >= %s is %s', (version, minimum, expected) => { + expect(isVersionAtLeast(version, minimum)).toBe(expected); + }); + + it('rejects malformed versions instead of treating them as secure', () => { + expect(isVersionAtLeast('11.22', '11.11.0')).toBe(false); + expect(isVersionAtLeast('11.22.0-beta.1', '11.11.0')).toBe(false); + }); +}); diff --git a/tests/unit/workflowPolicy.test.ts b/tests/unit/workflowPolicy.test.ts index e9c5d6623..01bdb2010 100644 --- a/tests/unit/workflowPolicy.test.ts +++ b/tests/unit/workflowPolicy.test.ts @@ -14,16 +14,57 @@ import { const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url)); const workflowPath = fileURLToPath(new URL('../../.github/workflows/ci.yml', import.meta.url)); +const setupActionPath = fileURLToPath( + new URL('../../.github/actions/setup/action.yml', import.meta.url), +); +const cloudflareWorkflowPath = fileURLToPath( + new URL('../../.github/workflows/deploy-cloudflare-pages.yml.disabled', import.meta.url), +); const scheduledSecurityWorkflowPath = fileURLToPath( new URL('../../.github/workflows/security-scheduled.yml', import.meta.url), ); const tauriManifestPath = fileURLToPath(new URL('../../src-tauri/Cargo.toml', import.meta.url)); const workflowSource = readFileSync(workflowPath, 'utf8'); +const setupActionSource = readFileSync(setupActionPath, 'utf8'); +const cloudflareWorkflowSource = readFileSync(cloudflareWorkflowPath, 'utf8'); const scheduledSecurityWorkflowSource = readFileSync(scheduledSecurityWorkflowPath, 'utf8'); +const packageJson = JSON.parse( + readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8'), +) as { + packageManager: string; +}; const tauriManifestSource = readFileSync(tauriManifestPath, 'utf8'); // QNBS-v3: Keep CI path and deployment authority policy executable against the real workflow files. describe('CI workflow policy', () => { + // QNBS-v3: the first package-manager binary must be patched and explicit before repository caching/install. + it('bootstraps the exact secure pnpm before setup-node cache or install', () => { + const expectedVersion = packageJson.packageManager.replace('pnpm@', ''); + const actionIndex = setupActionSource.indexOf('pnpm/action-setup@'); + const nodeIndex = setupActionSource.indexOf('actions/setup-node@'); + expect(setupActionSource).toContain( + 'pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86', + ); + expect(setupActionSource).toContain(`version: ${expectedVersion}`); + expect(actionIndex).toBeGreaterThanOrEqual(0); + expect(actionIndex).toBeLessThan(nodeIndex); + expect(setupActionSource).not.toContain('corepack enable'); + const setupToolchainIndex = setupActionSource.indexOf( + 'export npm_config_user_agent="pnpm/$(pnpm --version)"', + ); + const setupInstallIndex = setupActionSource.indexOf('pnpm install --frozen-lockfile'); + expect(setupToolchainIndex).toBeGreaterThanOrEqual(0); + expect(setupToolchainIndex).toBeLessThan(setupInstallIndex); + + const cloudflareToolchainIndex = cloudflareWorkflowSource.indexOf( + 'export npm_config_user_agent="pnpm/$(pnpm --version)"', + ); + const cloudflareInstallIndex = cloudflareWorkflowSource.indexOf( + 'pnpm install --frozen-lockfile', + ); + expect(cloudflareToolchainIndex).toBeGreaterThanOrEqual(0); + expect(cloudflareToolchainIndex).toBeLessThan(cloudflareInstallIndex); + }); // QNBS-v3: preserve first-attempt Vitest failures as visible CI evidence instead of masking flakes with retries. it('runs Vitest once so first-attempt failures cannot be hidden by retry', () => { const vitestRun = workflowSource.match(/run:\s*pnpm exec vitest run[^\n]*/)?.[0];