Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/actions/setup-node-pnpm/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Setup Node and pnpm
description: >
pnpm + Node 22 with the pnpm store cached, then `pnpm install --frozen-lockfile`.
Extracted when the single serial `frontend-run` job became five parallel jobs
(fe-static / fe-test / fe-coverage / fe-servers / fe-build): the same four
steps were about to be copy-pasted five times, and a version bump applied to
four of the five copies is a drift bug that reports green.

runs:
using: composite
steps:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
with:
version: 10

- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm

- run: pnpm install --frozen-lockfile
shell: bash
173 changes: 139 additions & 34 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,27 +41,31 @@ concurrency:
cancel-in-progress: true

jobs:
# Frontend checks: lint, tests, build — platform-independent (jsdom + vite)
frontend-run:

# ── Frontend verification, split into parallel groups ─────────────────────
#
# This was ONE job running `pnpm check:all` end to end: 25 lint gates, then
# 35k tests with coverage, then three builds — strictly serial, ~22 min, and
# the critical path of every PR. The groups below are the same 31 steps
# (`pnpm check:all` still composes exactly them, and check-scripts-parity
# asserts it), so the critical path becomes the MAX of the groups rather than
# their SUM.
#
# Tests shard because they dominate. Each shard writes a blob report; the
# merge job reconstitutes them and applies the coverage thresholds to the
# COMBINED result — verified to reproduce the unsharded numbers
# (94.28/90.62/93.77/95.13 vs 94.27/90.60/93.74/95.13). Sharding without that
# merge would silently drop the coverage gate, which is worse than being slow.
fe-static:
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
# Fetch enough history for the new-deps slopsquatting gate to diff
# against origin/main.
# Full history: the slopsquatting gate and the baseline ratchet both
# diff against the merge base.
fetch-depth: 0

- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
with:
version: 10

- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm

- run: pnpm install --frozen-lockfile
- uses: ./.github/actions/setup-node-pnpm

- name: Security audit
# pnpm 10 audit broken: npm retired legacy endpoint (410), bulk API lands in pnpm 11
Expand All @@ -78,19 +82,104 @@ jobs:
- name: Baseline ratchet (merge-base, all committed baselines)
# Every gate compares the tree against its baseline in the SAME commit,
# so a PR can raise its own floor — edit the offending file and its
# baseline together and every check reports green (audit 20260729 C3;
# generalized from file-size alone to all 12 baselines by the 20260803
# review, D2). This re-reads each baseline at the merge base, where the
# PR cannot have written it, and rejects any loosening.
#
# PR-only, and NOT part of `pnpm check:all`: the comparison needs a base
# ref, which a local checkout cannot guarantee. The checkout above uses
# fetch-depth: 0, so `git merge-base origin/<base> HEAD` resolves here;
# if it ever cannot, the script exits 1 rather than skipping.
if: github.event_name == 'pull_request'
# baseline together and every check reports green (audit 20260729 C3).
# This re-reads each baseline at the merge base, where the PR cannot
# have written it, and rejects any loosening.
run: node scripts/check-baseline-ratchet.mjs "origin/${{ github.base_ref }}"

- run: pnpm check:all
- run: pnpm check:static

fe-test:
runs-on: ubuntu-latest
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/setup-node-pnpm

# Blob reports carry this shard's coverage; fe-coverage merges them and
# is the ONLY place thresholds are applied.
#
# The zeroed thresholds are REQUIRED, not tidiness. A shard runs a quarter
# of the suite, so it measures ~49% line coverage and would fail the 94.7%
# global floor every time — all four shards did exactly that on the first
# run of this workflow. Coverage is a property of the WHOLE suite, so the
# only place it can be judged is after the merge. (Enforced by
# scripts/check-scripts-parity.test.mjs, so a future edit cannot quietly
# restore per-shard gating and make the matrix permanently red.)
#
# `--reporter=default` alongside `blob` is not cosmetic: the blob reporter
# REPLACES the console reporter, so a failing shard printed nothing but
# "Process completed with exit code 1" — no test name, no assertion, no
# way to tell a real failure from a flake without re-running locally.
# (Enforced below by check-scripts-parity.)
#
# Deliberately ONE long line rather than a folded `>-` block. `ci.yml` is
# on the BYTE_IDENTICAL identity ratchet in
# `ghaWorkflow/save/__tests__/corpusRoundtrip.test.ts`, and a folded
# scalar does not survive that CST round trip byte-identically — it
# dropped the file off the list. A literal `|` block is NOT the
# alternative: `>-` folds to a single command, while `|` keeps the
# newlines, so under `bash -e` every flag line would run as its own
# command.
- name: Tests (shard ${{ matrix.shard }}/4)
run: pnpm vitest run --coverage --shard=${{ matrix.shard }}/4 --reporter=blob --reporter=default --outputFile.blob=.vitest-reports/blob-${{ matrix.shard }}.json --coverage.thresholds.lines=0 --coverage.thresholds.functions=0 --coverage.thresholds.statements=0 --coverage.thresholds.branches=0

# `include-hidden-files` is REQUIRED: vitest writes blobs to
# `.vitest-reports/`, a dot-directory, and upload-artifact@v4 treats
# everything under one as hidden and skips it by default. The blob was
# written and then silently not uploaded.
#
# `if-no-files-found: error` is the other half. The default is `warn`, so
# that skip passed the shard and only surfaced two jobs later as
# `ENOENT: scandir '.vitest-reports'` in fe-coverage — an error message
# pointing at the consumer instead of the producer. Fail where the file
# should have been made.
- uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shard }}
path: .vitest-reports/*
include-hidden-files: true
if-no-files-found: error
retention-days: 1

fe-coverage:
needs: [fe-test]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/setup-node-pnpm

- uses: actions/download-artifact@v4
with:
path: .vitest-reports
pattern: blob-report-*
merge-multiple: true

# Thresholds live in vitest.config.ts and apply to the MERGED report, so
# this is the real coverage gate — the shards deliberately do not gate.
- name: Merge shard reports and enforce coverage thresholds
run: pnpm vitest --merge-reports=.vitest-reports --coverage

fe-servers:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/setup-node-pnpm
- run: pnpm check:servers

fe-build:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/setup-node-pnpm
- run: pnpm check:build

# Real-WebKit tier. `pnpm check:all` is jsdom-only, and the difference is not
# cosmetic: real WebKit drains a microtask BETWEEN capture listeners and jsdom
Expand Down Expand Up @@ -122,21 +211,37 @@ jobs:

- run: pnpm exec vitest run --config vitest.browser.config.ts

# Gate job: required by branch protection (exact name "frontend")
# Gate job: required by branch protection (exact name "frontend").
#
# Fails unless EVERY group succeeded. `if: always()` means this runs even when
# a dependency failed or was skipped, so the check must treat anything other
# than "success" as a failure — a skipped group is not a passed group, and
# `needs` alone would let one silently vanish from the gate.
frontend:
if: always()
needs: [frontend-run, webkit]
needs: [fe-static, fe-test, fe-coverage, fe-servers, fe-build, webkit]
runs-on: ubuntu-latest
steps:
- name: Check result
env:
RESULT: ${{ needs.frontend-run.result }}
STATIC: ${{ needs.fe-static.result }}
TESTS: ${{ needs.fe-test.result }}
COVERAGE: ${{ needs.fe-coverage.result }}
SERVERS: ${{ needs.fe-servers.result }}
BUILD: ${{ needs.fe-build.result }}
WEBKIT: ${{ needs.webkit.result }}
run: |
if [ "$RESULT" != "success" ]; then
echo "frontend-run: $RESULT"
exit 1
fi
failed=0
for pair in "fe-static:$STATIC" "fe-test:$TESTS" "fe-coverage:$COVERAGE" \
"fe-servers:$SERVERS" "fe-build:$BUILD" "webkit:$WEBKIT"; do
name="${pair%%:*}"
result="${pair#*:}"
if [ "$result" != "success" ]; then
echo "$name: $result"
failed=1
fi
done
[ "$failed" -eq 0 ]
if [ "$WEBKIT" != "success" ]; then
echo "webkit: $WEBKIT"
exit 1
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@
"mutation:ts": "stryker run",
"test:sidecar": "pnpm --dir server/mcp lint && pnpm --dir server/mcp build && pnpm --dir server/mcp test:coverage",
"test:content-server": "pnpm --dir server/content build && pnpm --dir server/content smoke && pnpm --dir server/content test:coverage",
"check:all": "pnpm lint && pnpm lint:console && pnpm lint:selection-styles && pnpm lint:design-tokens && pnpm lint:emdash && pnpm lint:deps && pnpm lint:hooks-purity && pnpm lint:extension-budget && pnpm lint:store-coupling && pnpm lint:mock-boundaries && pnpm lint:command-errors && pnpm lint:mutants-config && pnpm lint:deleted-names && pnpm lint:mcp-contracts && pnpm lint:tauri-versions && pnpm lint:i18n && pnpm lint:themes && pnpm lint:keybinding-manifest && pnpm lint:merge-drops && pnpm lint:file-size && pnpm lint:barrels && pnpm lint:shell-slots && pnpm lint:bespoke-buttons && pnpm knip && pnpm lint:knip-baseline && pnpm test:coverage && pnpm test:sidecar && pnpm test:content-server && pnpm build && pnpm lint:eager && pnpm size",
"check:static": "pnpm lint && pnpm lint:console && pnpm lint:selection-styles && pnpm lint:design-tokens && pnpm lint:emdash && pnpm lint:deps && pnpm lint:hooks-purity && pnpm lint:extension-budget && pnpm lint:store-coupling && pnpm lint:mock-boundaries && pnpm lint:command-errors && pnpm lint:mutants-config && pnpm lint:deleted-names && pnpm lint:mcp-contracts && pnpm lint:tauri-versions && pnpm lint:i18n && pnpm lint:themes && pnpm lint:keybinding-manifest && pnpm lint:merge-drops && pnpm lint:file-size && pnpm lint:barrels && pnpm lint:shell-slots && pnpm lint:bespoke-buttons && pnpm knip && pnpm lint:knip-baseline",
"check:servers": "pnpm test:sidecar && pnpm test:content-server",
"check:build": "pnpm build && pnpm lint:eager && pnpm size",
"check:all": "pnpm check:static && pnpm test:coverage && pnpm check:servers && pnpm check:build",
"lint:extension-budget": "node scripts/check-extension-budget.mjs",
"lint:knip-baseline": "node scripts/check-knip-baseline.mjs",
"lint:store-coupling": "node scripts/check-plugin-store-coupling.mjs",
Expand Down
6 changes: 5 additions & 1 deletion scripts/check-command-error-ratchet.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { invokedScripts } from "./lib/packageScripts.mjs";
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const SCRIPT = path.join(REPO, "scripts", "check-command-error-ratchet.mjs");

Expand Down Expand Up @@ -274,7 +275,10 @@ describe("wiring — real package.json", () => {
it("exposes lint:command-errors and chains it into check:all", () => {
const pkg = JSON.parse(readFileSync(path.join(REPO, "package.json"), "utf8"));
expect(pkg.scripts["lint:command-errors"]).toContain("check-command-error-ratchet.mjs");
expect(pkg.scripts["check:all"]).toContain("lint:command-errors");
// Transitive: check:all composes check:static/servers/build, so a
// literal substring check would break on regrouping (see
// scripts/lib/packageScripts.mjs).
expect(invokedScripts(pkg.scripts, "check:all")).toContain("lint:command-errors");
});
});

Expand Down
6 changes: 5 additions & 1 deletion scripts/check-mock-boundaries.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { invokedScripts } from "./lib/packageScripts.mjs";
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const SCRIPT = path.join(REPO, "scripts", "check-mock-boundaries.mjs");

Expand Down Expand Up @@ -316,7 +317,10 @@ describe("wiring (case 13) — real package.json and real baseline", () => {
it("exposes lint:mock-boundaries and chains it into check:all", () => {
const pkg = JSON.parse(readFileSync(path.join(REPO, "package.json"), "utf8"));
expect(pkg.scripts["lint:mock-boundaries"]).toBe("node scripts/check-mock-boundaries.mjs");
expect(pkg.scripts["check:all"]).toContain("lint:mock-boundaries");
// Transitive: check:all composes check:static/servers/build, so a
// literal substring check would break on regrouping (see
// scripts/lib/packageScripts.mjs).
expect(invokedScripts(pkg.scripts, "check:all")).toContain("lint:mock-boundaries");
});

it("ships a real identity baseline, registered in the WI-16 merge-base ratchet", async () => {
Expand Down
6 changes: 5 additions & 1 deletion scripts/check-mutants-config-path.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { invokedScripts } from "./lib/packageScripts.mjs";
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const SCRIPT = path.join(REPO, "scripts", "check-mutants-config-path.mjs");

Expand Down Expand Up @@ -104,7 +105,10 @@ describe("wiring — real package.json and the real tree", () => {
expect(pkg.scripts["lint:mutants-config"]).toBe(
"node scripts/check-mutants-config-path.mjs",
);
expect(pkg.scripts["check:all"]).toContain("lint:mutants-config");
// Transitive: check:all composes check:static/servers/build, so a
// literal substring check would break on regrouping (see
// scripts/lib/packageScripts.mjs).
expect(invokedScripts(pkg.scripts, "check:all")).toContain("lint:mutants-config");
});

it("passes on the real repository (config relocated, legacy file gone)", () => {
Expand Down
6 changes: 5 additions & 1 deletion scripts/check-plugin-store-coupling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { fileURLToPath } from "node:url";
// @ts-expect-error — plain .mjs module without type declarations
import { findCouplingViolations } from "./check-plugin-store-coupling.mjs";

import { invokedScripts } from "./lib/packageScripts.mjs";
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const SCRIPT = path.join(REPO, "scripts", "check-plugin-store-coupling.mjs");

Expand Down Expand Up @@ -396,7 +397,10 @@ describe("wiring — the real package.json and the real baseline", () => {
it("exposes lint:store-coupling and chains it into check:all", () => {
const pkg = JSON.parse(readFileSync(path.join(REPO, "package.json"), "utf8"));
expect(pkg.scripts["lint:store-coupling"]).toBe("node scripts/check-plugin-store-coupling.mjs");
expect(pkg.scripts["check:all"]).toContain("lint:store-coupling");
// Transitive: check:all composes check:static/servers/build, so a
// literal substring check would break on regrouping (see
// scripts/lib/packageScripts.mjs).
expect(invokedScripts(pkg.scripts, "check:all")).toContain("lint:store-coupling");
});

it("keeps the @/stores channel at ZERO — the win the other three now protect", () => {
Expand Down
Loading
Loading