diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65da02c..8cce22b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,10 @@ jobs: name: Lint, typecheck, test, build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 cache: npm @@ -30,20 +32,43 @@ jobs: - run: npm run lint - run: npm run format:check - run: npm run typecheck - - run: npm run test + # V49 — the same suite, with a coverage floor (see vite.config.ts). + - run: npm run test:coverage - run: npm run build - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: dist path: dist retention-days: 7 + workflows: + name: Workflow audit (zizmor) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # V49 — a static audit of these workflows: template injection, unpinned + # or impostor action commits, credentials left on disk, over-broad + # permissions. Pinned by version and installed from PyPI rather than + # through a third-party action, so the auditor is not itself an + # unpinned dependency. The token lets the online audits check that each + # pinned SHA really belongs to the action's repository. + - name: Audit the workflows + env: + GH_TOKEN: ${{ github.token }} + run: | + pipx run --spec zizmor==1.30.1 zizmor --format=github .github/workflows + e2e: name: End-to-end (Playwright) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 cache: npm @@ -51,7 +76,7 @@ jobs: - run: npx playwright install --with-deps chromium - run: npm run build - run: npm run e2e - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: failure() with: name: playwright-report @@ -65,8 +90,10 @@ jobs: # Informational for now — scores on shared runners are noisy. continue-on-error: true steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 cache: npm @@ -95,12 +122,12 @@ jobs: # `quality`, `e2e` and `deploy` keep their scripts: they need esbuild to # build and workerd to deploy. - run: npm ci --ignore-scripts - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: dist path: dist - run: npx lhci autorun - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: lighthouse-report @@ -118,13 +145,15 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 cache: npm - run: npm ci - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: dist path: dist @@ -139,7 +168,7 @@ jobs: # the optional HF_TOKEN secret to lift the anonymous limit. - name: Cache the sharded language model id: llm - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: dist/llm key: llm-sharded-${{ hashFiles('scripts/prepare-llm.mjs') }} @@ -150,5 +179,10 @@ jobs: run: npm run llm:prepare -- dist/llm - name: Ensure the Pages project exists run: npx wrangler pages project create labml --production-branch=main || true + # V49 — the branch name reaches the shell through the environment, never + # through template expansion: `${{ }}` is pasted into the script before + # bash parses it, so a branch named `x";curl …;"` would have run as code. - name: Deploy - run: npx wrangler pages deploy dist --project-name=labml --branch="${{ github.event_name == 'push' && 'main' || github.head_ref }}" + env: + DEPLOY_BRANCH: ${{ github.event_name == 'push' && 'main' || github.head_ref }} + run: npx wrangler pages deploy dist --project-name=labml --branch="$DEPLOY_BRANCH" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 41f6b5c..ed37f09 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,8 +22,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v7 - - uses: github/codeql-action/init@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: javascript-typescript - - uses: github/codeql-action/analyze@v4 + - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 diff --git a/.github/workflows/llm-bench.yml b/.github/workflows/llm-bench.yml index a4b83c8..a4ec258 100644 --- a/.github/workflows/llm-bench.yml +++ b/.github/workflows/llm-bench.yml @@ -32,8 +32,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 cache: npm @@ -42,7 +44,7 @@ jobs: # reads them from disk and has no 25 MiB limit to work around. - name: Cache the pinned weights id: weights - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .llm-cache key: llm-cache-qwen3-0.6b-dq-q4f16-v1 @@ -58,7 +60,7 @@ jobs: env: LABML_LLM_OUT: bench-report.json run: npm run llm:bench:node - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: llm-bench-report path: bench-report.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a121f0..490b62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ entry per wave, newest first; the first six waves (V1–V6, the MVP) predate the are recorded in PLAN.md §B and §J. Each entry carries the opening sentence of its row and the reason the wave was built. +## V49 — CI that cannot be steered by a branch name + +The wave opened with zizmor 1.30.1 on the three workflows: **28 findings** (1 template injection, 21 actions pinned by a movable tag, 6 checkouts leaving the git token on the runner disk). + +_Why:_ Approved 09/10/2026 after V48. Continues V44: a security gate is worth its maintenance only when it distinguishes a real exposure, and every rule here is now enforced by a test or a job rather than by a reviewer remembering it. + ## V48 — A damaged share link ends on a refusal, never on a crash The plan for this row was a deeper validator: check `insights.importance`, numeric metrics, `classes` and `confusion`, the fields the audit had named. diff --git a/PLAN.md b/PLAN.md index 9067568..0420689 100644 --- a/PLAN.md +++ b/PLAN.md @@ -614,7 +614,7 @@ before features, and the one that produces wrong data comes first of all. | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **V47 — delivered** | **Workers that cannot answer the wrong question.** Six lifecycle defects in shipped code, each reproduced by a test that failed before its fix. **(1) A result filed under a target it was not trained for**: `setTarget` and `toggleColumn` reset the training state without cancelling the run, the worker kept posting `model-result`, and the auto-save recorded the new target beside the old scores. The store now refuses both changes while anything computed for the current question is running (training, tuning, learning curve, robust ranking, batch scoring), the target picker and the column buttons are disabled for the same span, and a fence (`isStaleResponse`) drops any training message outside a run and any run-derived answer when no run is done; the worker answers in order, so the training status is enough to tell a late answer from a current one, without a run id in the protocol. **(2) Cancel did not reach the parallel phase**: V37's helpers were only checked after every one of them had finished, so cancelling waited for the heaviest families; the phase now polls the flag every 100 ms and terminates the helpers mid-fit. **(3) A crashed ML worker** left `trainStatus` at `training` forever and kept the dead reference; `onerror` now terminates it, clears every busy flag, and the next request starts a fresh worker. **(4) The same crash in the chat** left « thinking » up and still offered a local model that died with its worker. **(5) DuckDB was never closed**: each visit to the SQL panel left a worker and its Wasm heap behind, a failed start leaked its worker too, and a failed Parquet export left its file in the heap; the panel now closes the engine on unmount, on a late start and on failure, and export names come from a counter rather than a millisecond clock. **(6) A webcam stream granted after the page was left** kept the camera lit; it is now stopped on arrival. **(7) Found by this wave's own CI**: touching the chat ran the interpretation bench, which failed on `size-mismatch:tokenizer_config.json`. `prepare-llm.mjs` called its revision « pinned » while it held `main`, and upstream commit `1e94c62` (07/10/2026) had grown that file from 9 762 to 10 416 bytes; the revision is now the commit the sizes were measured on (`d6beae7`), which also keeps the next deploy cache miss from failing the same way. **What this wave deliberately does not do**: add a run id to the worker protocol (the order guarantee makes it redundant today, and it is the first thing to add if requests ever become concurrent), cancel analyses the user cannot cancel today, or touch the deeper share-link validation, which is V48. 883 unit tests, 123 e2e. | Approved 09/10/2026 after the post-V46 audit. The defect in (1) does not crash and does not warn: it saves a run that looks valid and is not, which is the one kind of failure an honest lab must never produce. The others are the same class of bug, a worker outliving the state it was started for, and fixing them together keeps the rule in one place. | | **V48 — delivered** | **A damaged share link ends on a refusal, never on a crash.** The plan for this row was a deeper validator: check `insights.importance`, numeric metrics, `classes` and `confusion`, the fields the audit had named. The wave opened with a measurement instead, and the measurement changed the plan. Three real links were captured from the running app (titanic with uncertainty, threshold and segments; iris with k-NN tuning and groups; mpg as a regression) and every field of each was deleted, nulled, or retyped as a string, a number, an object or an array: **3 948 damaged links, 3 660 accepted by the decoder, 1 020 of those crashed the page, on 99 distinct paths**, from `insights.importance` to `artifacts.exploration.clusters[].traits[].clusterMean`. A validator for 99 paths would be the largest file in the share module and would drift with every analysis a run learns to carry. So the decoder keeps checking what every view reads first, and `SharedRunPage` wraps the run in a render guard: any failure while drawing it shows the same refusal page, with its own explanation (the link decoded, but part of the run does not have the shape LabML writes) and nothing from the run, so no half-read figure can pass for a real one. The three links are committed under `tests/share-links/`, and the measurement is now a test: every field shape is damaged six ways and the page must end on the run or on the refusal; it fails on the code before this wave and adds about 25 s to the unit suite, the price of covering the whole format rather than a sample. The five copies of the download helper become one, `src/lib/download.ts`, which revokes its object URL after the click has been handled instead of in the same tick, where some engines cancel the download silently. **What this wave deliberately does not do**: write a field-by-field schema for every artifact, show a partially readable run, or guard event handlers (a click on a damaged run is not reachable: the refusal replaces it first). 889 unit tests, 123 e2e. | Approved 09/10/2026 after V47. A link is input from a stranger, and the most common stranger is a chat client that cut it. The decoder already refused the shapes the V35 audit had found; the measurement showed it could not keep up with the format, and that the robust answer was at the point of failure rather than at the gate. | -| **V49 — pending** | **CI that cannot be steered by a branch name.** `ci.yml` interpolates `github.head_ref` straight into a `run:` line; pass it through `env:` instead. Pin third-party actions by commit SHA (Dependabot keeps them current), add a coverage report with a floor set from the measured baseline rather than a round number, and run zizmor on the workflows. | Continues V44. The injection needs write access to the repository today, which is why it is low severity, and why the fix should land before that stops being true. | +| **V49 — delivered** | **CI that cannot be steered by a branch name.** The wave opened with zizmor 1.30.1 on the three workflows: **28 findings** (1 template injection, 21 actions pinned by a movable tag, 6 checkouts leaving the git token on the runner disk). **(1) Injection**: the deploy step expanded `github.head_ref` inside its script, so a branch named `x";…;"` would have run as code; the value now reaches the shell through `env:`. Low severity today (only branches of this repository deploy) and fixed before that stops being true. **(2) Pins**: every action is pinned to a full commit SHA with its release in a trailing comment (`# v7.0.1`), which Dependabot keeps current; a tag can be moved by whoever controls the action's repository, a SHA cannot. **(3) Credentials**: every checkout sets `persist-credentials: false`; no job pushes, so nothing needed the token on disk. After the fixes the offline audit reports **0 findings**, and a new `Workflow audit (zizmor)` job runs it on every pull request, installed from PyPI at a pinned version rather than through a third-party action, with the token the online audits need to check that each pinned SHA really belongs to its repository. Three new tests in `supply-chain.test.ts` hold the same rules per workflow file (SHA plus release comment on every `uses:`, no persisted credentials, no pull-request-controlled `${{ }}` inside a `run:`); they fail on the workflows before this wave. **(4) Coverage**: `npm run test:coverage` (V8 provider) replaces `npm run test` in CI with a floor set from the measured baseline rounded down, not a round number: **64.56 % statements, 53.28 % branches, 54.46 % functions, 65.62 % lines** on 898 unit tests, floors 64 / 53 / 54 / 65. The figures cover unit tests only; the interface is mostly exercised by the e2e suite, which this does not measure. Cost: the quality job grows by about a minute. **What this wave deliberately does not do**: chase a coverage target, add e2e coverage instrumentation, or gate deploys on the audit job. 898 unit tests, 123 e2e. | Approved 09/10/2026 after V48. Continues V44: a security gate is worth its maintenance only when it distinguishes a real exposure, and every rule here is now enforced by a test or a job rather than by a reviewer remembering it. | | **V50 — pending** | **Metrics that agree with the reference on every edge.** `macroPrf` averages over every class the task knows, so a class absent from both truth and prediction in the test split adds a zero; scikit-learn averages over the labels present. `rmse` and `mae` return 0 on an empty set, which reads as a perfect score. A stratified split sends a single-row class entirely to test. Decide each case against the V42 generator (add the absent-class case to it), then align or document. | The same honesty rule as V35: a number must not flatter or punish by accident. Measure the difference on the demo datasets before changing anything. | | **V51 — pending** | **A store that fits on a screen.** `lab-store.ts` (about 1 000 lines, a 250-line `onmessage` chain) split into feature slices with a dispatch table; tests named after waves (`v35.test.ts` to `v40.test.ts`) renamed after the behaviour they pin; the duplicated missing-ratio and histogram code in `drift.ts` and `reference.ts` merged. No behaviour change, proven by the suite staying identical. | Only after V47 to V50: refactoring code whose defects are not yet pinned by tests moves the defects around instead of removing them. | | **V52 — pending** | **Major upgrades, one at a time, each measured.** Vitest 5, vite-plugin-pwa 2, TypeScript 7. Each in its own pull request with the before/after numbers that matter for it (suite time, build time, service-worker behaviour offline). | V44's rule: a major update keeps its migration cost visible. | diff --git a/README.md b/README.md index 0af7dc2..f6cb840 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ Wikimedia Commons), the portrait is NASA, public domain._ a doc page carries its whole article, in English until the app mounts); Lighthouse mobile ≈ 0.99 on `/ml` under real throttling. Heavy dependencies (Dexie, SheetJS, ONNX Runtime) load lazily. -- **Quality bar.** 889 unit tests and 123 Playwright end-to-end tests across five +- **Quality bar.** 898 unit tests and 123 Playwright end-to-end tests across five projects — desktop, a phone viewport in English and in French, dark mode, and Cloudflare Pages' own routing emulated by `wrangler pages dev` (a real 404, the security headers as served) — covering offline PWA, a fake webcam, a horizontal-overflow guard on diff --git a/docs/superpowers/plans/2026-10-09-v49-ci-hardening.md b/docs/superpowers/plans/2026-10-09-v49-ci-hardening.md new file mode 100644 index 0000000..bac4171 --- /dev/null +++ b/docs/superpowers/plans/2026-10-09-v49-ci-hardening.md @@ -0,0 +1,39 @@ +# V49: CI that cannot be steered by a branch name + +## Baseline + +zizmor 1.30.1, offline, on `.github/workflows`: + +| Finding | Count | Where | +| ------------------ | ----- | --------------------------------------- | +| template-injection | 1 | `ci.yml` deploy step, `github.head_ref` | +| unpinned-uses | 21 | every `uses:` in the three workflows | +| artipacked | 6 | every `actions/checkout` step | + +After this wave: 0 findings. + +## Changes + +1. The deploy branch reaches the shell through `env: DEPLOY_BRANCH`, never + through `${{ }}` inside `run:`. +2. Every action is pinned to a commit SHA with its release as a trailing + comment. Dependabot (`github-actions` ecosystem, already weekly) updates + both. +3. Every checkout sets `persist-credentials: false`. No job pushes. +4. New CI job `Workflow audit (zizmor)`: `pipx run zizmor==1.30.1` with + `GH_TOKEN`, so the online audits (impostor commits, ref confusion) run too. + They could not be run from the development container, whose proxy blocks + zizmor's API calls; the first CI run is their verification. +5. `npm run test:coverage` in CI, V8 provider, floors from the measured + baseline rounded down: 64 / 53 / 54 / 65 (statements, branches, functions, + lines). + +## Tests + +`src/lib/supply-chain.test.ts` gains three rules per workflow file. Run against +the workflows before this wave: 8 of 13 tests fail. After: 13 of 13 pass. + +## Not done + +A coverage target, e2e coverage instrumentation, and making deploys wait on the +audit job. diff --git a/package-lock.json b/package-lock.json index 56ab55d..acf0375 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "labml", - "version": "1.48.0", + "version": "1.49.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "labml", - "version": "1.48.0", + "version": "1.49.0", "license": "MIT", "dependencies": { "@duckdb/duckdb-wasm": "1.28.0", @@ -48,6 +48,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", + "@vitest/coverage-v8": "^4.1.11", "eslint": "^10.11.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", @@ -1716,6 +1717,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -6180,6 +6191,37 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", @@ -6578,6 +6620,25 @@ "node": ">=4" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -9551,6 +9612,13 @@ "node": "^22.13.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-parse-stringify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", @@ -10373,6 +10441,97 @@ "whatwg-fetch": "^3.4.1" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-report/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", @@ -11311,6 +11470,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", diff --git a/package.json b/package.json index d18fbea..9a100ba 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "labml", "private": true, - "version": "1.48.0", + "version": "1.49.0", "license": "MIT", "type": "module", "engines": { @@ -22,6 +22,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "e2e": "playwright test", "prepare": "husky" }, @@ -74,6 +75,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", + "@vitest/coverage-v8": "^4.1.11", "eslint": "^10.11.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", diff --git a/src/lib/supply-chain.test.ts b/src/lib/supply-chain.test.ts index 44458af..8d68edc 100644 --- a/src/lib/supply-chain.test.ts +++ b/src/lib/supply-chain.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { parse } from 'yaml'; @@ -94,14 +94,15 @@ describe('supply-chain automation', () => { }); expect(codeql.jobs.analyze.steps).toHaveLength(3); expect(codeql.jobs.analyze.steps[0]).toEqual({ - uses: expect.stringMatching(/^actions\/checkout@v\d+$/), + uses: expect.stringMatching(/^actions\/checkout@[0-9a-f]{40}$/), + with: { 'persist-credentials': false }, }); expect(codeql.jobs.analyze.steps.slice(1)).toEqual([ { - uses: 'github/codeql-action/init@v4', + uses: expect.stringMatching(/^github\/codeql-action\/init@[0-9a-f]{40}$/), with: { languages: 'javascript-typescript' }, }, - { uses: 'github/codeql-action/analyze@v4' }, + { uses: expect.stringMatching(/^github\/codeql-action\/analyze@[0-9a-f]{40}$/) }, ]); }); @@ -124,4 +125,44 @@ describe('supply-chain automation', () => { "github.actor != 'dependabot[bot]' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository)", ); }); + + // V49 — a tag can be moved by whoever controls the action's repository; a + // commit SHA cannot. Dependabot keeps the pins current and reads the + // trailing `# vX.Y.Z` comment to say which release a pin is. + const WORKFLOWS = readdirSync('.github/workflows') + .filter((name) => name.endsWith('.yml')) + .map((name) => `.github/workflows/${name}`); + + it.each(WORKFLOWS)('%s pins every action to a full commit SHA with its release', (path) => { + const uses = read(path) + .split('\n') + .filter((line) => /^\s*(- )?uses:/.test(line)); + expect(uses.length).toBeGreaterThan(0); + for (const line of uses) { + expect(line).toMatch(/uses: [\w.-]+\/[\w./-]+@[0-9a-f]{40} # v\d+\.\d+\.\d+$/); + } + }); + + it.each(WORKFLOWS)('%s never leaves the git token on the runner disk', (path) => { + const workflow = readYaml(path); + for (const job of Object.values(workflow.jobs) as { + steps: { uses?: string; with?: object }[]; + }[]) { + for (const step of job.steps.filter((s) => s.uses?.startsWith('actions/checkout@'))) { + expect(step.with).toMatchObject({ 'persist-credentials': false }); + } + } + }); + + it.each(WORKFLOWS)('%s expands no pull-request-controlled value inside a script', (path) => { + // `${{ }}` is pasted into the script before the shell parses it, so a + // branch name or a title there runs as code. Such values go through `env:`. + const workflow = readYaml(path); + const risky = /\$\{\{[^}]*github\.(head_ref|event\.(pull_request|issue|comment|review)\.)/; + for (const job of Object.values(workflow.jobs) as { steps: { run?: string }[] }[]) { + for (const step of job.steps) { + if (step.run) expect(step.run).not.toMatch(risky); + } + } + }); }); diff --git a/vite.config.ts b/vite.config.ts index ef6ba5d..99b2ff4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -571,5 +571,18 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.test.{ts,tsx}'], + // V49 — `npm run test:coverage` (what CI runs). The floor is the measured + // baseline rounded down, not a round number: it exists to catch a change + // that removes tests or ships a large untested module, and it should be + // raised whenever a wave moves the measurement up. + coverage: { + provider: 'v8', + include: ['src/**/*.{ts,tsx}'], + exclude: ['src/**/*.test.{ts,tsx}', 'src/**/*.d.ts', 'src/test/**'], + reporter: ['text-summary', 'json-summary'], + reportsDirectory: 'coverage', + // Measured 09/10/2026 on 898 tests: 64.56 / 53.28 / 54.46 / 65.62. + thresholds: { statements: 64, branches: 53, functions: 54, lines: 65 }, + }, }, });