From 99da7c9be196bd55621db7099d28427b5ef1a05b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 22 May 2026 09:49:27 -0700 Subject: [PATCH] ci(perf): cache Playwright + uv venvs; tighten lint-only scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent CI-cost wins from the e2e-strategy audit: 1. **Cache Playwright browsers** in all 5 jobs that install them (examples-chat-e2e, cockpit-e2e × 24 matrix entries, website-e2e, demo-deploy/website-conditional, production-smoke). Key on the lockfile hash so any @playwright/test bump invalidates the cache. Expected savings: ~30-60s per runner × 28+ runners on a libs/chat PR ≈ ~15-30 CI-minutes. 2. **Cache per-cap python `.venv`** at all 3 uv-sync sites (examples-chat-smoke, examples-chat-e2e, cockpit-e2e × 24). Key on the per-cap uv.lock hash. `uv sync` becomes a no-op when the cache hits. Cache key includes `matrix.cap.python` so the 24 caps don't fight over a single cache entry. 3. **Tighten lint-only file scope**. `eslint.config.mjs` was in GLOBAL_CI_FILES, forcing the full e2e fleet to fire on any lint-config tweak. Moved to a new LINT_ONLY_FILES set that flips only the scopes that actually run `nx lint` (library, cockpit, website, examples_chat) — not the *_e2e/_smoke/_deploy/_secret scopes. Added 2 unit tests covering the new behavior; all 15 ci-scope tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 51 +++++++++++++++++++++++++++++++++++++++ scripts/ci-scope.mjs | 15 ++++++++++++ scripts/ci-scope.spec.mjs | 30 +++++++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4e3407e..35162dfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -235,6 +235,11 @@ jobs: with: python-version: '3.12' - run: npm ci + - name: Cache examples-chat python venv + uses: actions/cache@v4 + with: + path: examples/chat/python/.venv + key: uv-venv-${{ runner.os }}-py3.12-${{ hashFiles('examples/chat/python/uv.lock') }} - working-directory: examples/chat/python run: uv sync - run: npx nx run examples-chat-python:smoke --skip-nx-cache @@ -260,8 +265,20 @@ jobs: with: python-version: '3.12' - run: npm ci + - name: Cache examples-chat python venv + uses: actions/cache@v4 + with: + path: examples/chat/python/.venv + key: uv-venv-${{ runner.os }}-py3.12-${{ hashFiles('examples/chat/python/uv.lock') }} - working-directory: examples/chat/python run: uv sync + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: | + playwright-${{ runner.os }}- - run: npx playwright install --with-deps chromium - run: npx nx e2e examples-chat-angular --skip-nx-cache -- --shard=${{ matrix.shard }}/4 - name: Upload Playwright trace on failure @@ -333,9 +350,21 @@ jobs: with: python-version: '3.12' - run: npm ci + - name: Cache cap python venv + uses: actions/cache@v4 + with: + path: ${{ matrix.cap.python }}/.venv + key: uv-venv-${{ runner.os }}-py3.12-${{ matrix.cap.python }}-${{ hashFiles(format('{0}/uv.lock', matrix.cap.python)) }} - name: uv sync per-cap python working-directory: ${{ matrix.cap.python }} run: uv sync + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: | + playwright-${{ runner.os }}- - run: npx playwright install --with-deps chromium - name: nx e2e ${{ matrix.cap.angular }} run: npx nx e2e "${{ matrix.cap.angular }}" --skip-nx-cache @@ -374,6 +403,13 @@ jobs: node-version: 22 cache: npm - run: npm ci + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: | + playwright-${{ runner.os }}- - run: npx playwright install --with-deps chromium - run: npx nx e2e website --skip-nx-cache @@ -598,6 +634,14 @@ jobs: echo "website=$website_changed" >> "$GITHUB_OUTPUT" echo "cockpit=$cockpit_changed" >> "$GITHUB_OUTPUT" + - name: Cache Playwright browsers + if: steps.affected.outputs.website == 'true' + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: | + playwright-${{ runner.os }}- - name: Install Playwright browsers if: steps.affected.outputs.website == 'true' run: npx playwright install --with-deps chromium @@ -786,6 +830,13 @@ jobs: run: npx tsx scripts/verify-shared-deployment.ts env: LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: | + playwright-${{ runner.os }}- - run: npx playwright install --with-deps chromium - name: Run production smoke tests run: npx playwright test apps/cockpit/e2e/production-smoke.spec.ts --reporter=list diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs index 5dab8d1e..65339be3 100644 --- a/scripts/ci-scope.mjs +++ b/scripts/ci-scope.mjs @@ -18,9 +18,21 @@ const GLOBAL_CI_FILES = new Set([ 'nx.json', 'tsconfig.json', 'tsconfig.base.json', +]); + +/** Files whose changes only affect linting (not builds, not e2e tests). + * These flip the scopes that actually run `nx lint` (library, cockpit, + * website, examples_chat) but do NOT trigger e2e/smoke/deploy jobs. + * Avoids the ~50 CI-minute spike from a one-line lint-config tweak + * re-running the 24-cap cockpit-e2e matrix. */ +const LINT_ONLY_FILES = new Set([ 'eslint.config.mjs', ]); +/** Subset of SCOPE_KEYS that own jobs running `nx lint`. Flipped true + * when a LINT_ONLY_FILES entry changes. */ +const LINT_SCOPE_KEYS = ['library', 'cockpit', 'website', 'examples_chat']; + export function emptyScope() { return Object.fromEntries(SCOPE_KEYS.map((k) => [k, false])); } @@ -58,6 +70,9 @@ export function classifyFromAffected(changedFiles, affectedProjects) { if (SCOPE_KEYS.includes(key)) scope[key] = true; } } + if (changedFiles.some((f) => LINT_ONLY_FILES.has(f))) { + for (const key of LINT_SCOPE_KEYS) scope[key] = true; + } return scope; } diff --git a/scripts/ci-scope.spec.mjs b/scripts/ci-scope.spec.mjs index a1a2200a..47ece4b3 100644 --- a/scripts/ci-scope.spec.mjs +++ b/scripts/ci-scope.spec.mjs @@ -36,6 +36,36 @@ describe('classifyFromAffected — short-circuit', () => { }); }); +describe('classifyFromAffected — lint-only files', () => { + it('eslint.config.mjs flips only lint-running scopes, NOT e2e/smoke/deploy', () => { + const scope = classifyFromAffected(['eslint.config.mjs'], []); + // Lint-running scopes: true + assert.equal(scope.library, true); + assert.equal(scope.cockpit, true); + assert.equal(scope.website, true); + assert.equal(scope.examples_chat, true); + // E2e / smoke / deploy / secret / posthog scopes: false + assert.equal(scope.website_e2e, false); + assert.equal(scope.cockpit_e2e, false); + assert.equal(scope.cockpit_smoke, false); + assert.equal(scope.cockpit_examples, false); + assert.equal(scope.cockpit_secret, false); + assert.equal(scope.cockpit_deploy_smoke, false); + assert.equal(scope.posthog, false); + }); + + it('eslint.config.mjs alongside an affected project still ORs in the project scopes', () => { + const scope = classifyFromAffected( + ['eslint.config.mjs', 'cockpit/chat/messages/python/src/graph.py'], + [{ name: 'cockpit-chat-messages-python', tags: ['scope:cockpit-e2e', 'scope:cockpit-examples', 'scope:cockpit-smoke'] }], + ); + assert.equal(scope.library, true); + assert.equal(scope.cockpit_e2e, true); + assert.equal(scope.cockpit_examples, true); + assert.equal(scope.cockpit_smoke, true); + }); +}); + describe('classifyFromAffected — publishable lib broadcast', () => { it('publishable lib triggers library + website + website_e2e + cockpit_* + examples_chat', () => { const scope = classifyFromAffected(['libs/chat/src/foo.ts'], [