From 8494557a3a0265c6ed1f2a91558cf158750c0fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 04:22:13 +0200 Subject: [PATCH 1/2] ci: run the browser suite as two shards The single job passed at about eighteen minutes against a twenty-minute cap, and the settings geometry measurements pushed it over, so the run died at the timeout with everything to that point green. Same remedy as the integration suite, split rather than raise: two Playwright shards, each with its own database and production build, and a gate job carrying the exact name branch protection requires that fails when any shard failed or was cancelled. --- .github/workflows/e2e.yml | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e2f0601c3..1eab3326e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -15,9 +15,19 @@ concurrency: cancel-in-progress: true jobs: - e2e: + # The suite outgrew the runner the same way the integration suite did: the + # single job passed at ~18 minutes against a 20-minute cap, and the settings + # geometry measurements pushed it over. Same remedy, split rather than raise: + # two Playwright shards, each with its own database and production build, and + # a gate job below that carries the exact name branch protection requires. + shard: runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + shard: [1, 2] + name: e2e shard ${{ matrix.shard }}/2 services: postgres: image: postgres:16-alpine @@ -87,7 +97,7 @@ jobs: # chart-runtime boundary (a duplicated ~92 KB gz recharts chunk). - run: pnpm bundle-check - - run: pnpm exec playwright test + - run: pnpm exec playwright test --shard=${{ matrix.shard }}/2 env: E2E_BASE_URL: http://localhost:3000 @@ -99,3 +109,19 @@ jobs: path: playwright-report/ retention-days: 7 if-no-files-found: ignore + + # Branch protection requires a check named exactly "e2e". This job carries + # that name and answers for both shards: it fails when any shard failed or + # was cancelled, so a half-green matrix cannot satisfy the gate. + e2e: + runs-on: ubuntu-latest + needs: shard + if: always() + timeout-minutes: 5 + steps: + - name: Every shard finished green + run: | + if [ "${{ needs.shard.result }}" != "success" ]; then + echo "shard result: ${{ needs.shard.result }}" + exit 1 + fi From ea4d284cc9f697c19f43f38be92ef757c9ff59f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 04:45:04 +0200 Subject: [PATCH 2/2] ci: carry the report per shard and point the guard at the running job The determinism guard reads the job that runs the browser, which is now the shard matrix rather than the gate that only carries the required check name. The report artifact is named per shard so the two uploads cannot clobber each other. --- .github/workflows/e2e.yml | 2 +- scripts/__tests__/ci-determinism.test.ts | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1eab3326e..6bc757ed4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -105,7 +105,7 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: playwright-report + name: playwright-report-shard-${{ matrix.shard }} path: playwright-report/ retention-days: 7 if-no-files-found: ignore diff --git a/scripts/__tests__/ci-determinism.test.ts b/scripts/__tests__/ci-determinism.test.ts index 1a8cfac66..34bac3b29 100644 --- a/scripts/__tests__/ci-determinism.test.ts +++ b/scripts/__tests__/ci-determinism.test.ts @@ -77,14 +77,19 @@ describe("Playwright CI determinism", () => { const workflow = parse( readRepoFile(".github/workflows/e2e.yml"), ) as Workflow; - const upload = workflow.jobs.e2e?.steps?.find((step) => + // The suite runs as a shard matrix since the single job outgrew its + // timeout; the running job is `shard`, while `e2e` is the gate that + // carries the required check name and runs no browser. The report + // therefore lives on the shard job, named per shard so the two + // artifacts cannot clobber each other. + const upload = workflow.jobs.shard?.steps?.find((step) => step.uses?.startsWith("actions/upload-artifact@"), ); expect(upload).toBeDefined(); expect(upload?.if).toBe("always()"); expect(upload?.with).toMatchObject({ - name: "playwright-report", + name: "playwright-report-shard-${{ matrix.shard }}", path: "playwright-report/", "retention-days": 7, "if-no-files-found": "ignore", @@ -95,7 +100,7 @@ describe("Playwright CI determinism", () => { const workflow = parse( readRepoFile(".github/workflows/e2e.yml"), ) as Workflow; - const env = workflow.jobs.e2e?.env; + const env = workflow.jobs.shard?.env; expect(env?.API_TOKEN_HMAC_KEY).toMatch(/^[0-9a-f]{64}$/); expect(env?.SESSION_COOKIE_SECURE).toBe("false");