From fb8723ebb589044a99835354d186235ce6fa6c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 23:39:12 +0200 Subject: [PATCH 1/3] ci: run the integration suite as two shards The suite outgrew its runner. The 25-minute guard was sized for 128 files taking about 13 minutes; the sharing release took the suite to 180 files and past the cap on CI runners, so every run died at the timeout with every test to that point green, reported as cancelled. The guard's own rule applies: split, don't raise. The files run as two vitest shards, each with its own Postgres testcontainer. A gate job carries the exact name branch protection requires and fails when any shard failed or was cancelled, so a half-green matrix cannot satisfy it. vitest is invoked directly because pnpm passes the -- separator through to the script literally: the flag never applied through the script form, and both "shards" ran the full suite. Proven by paired local runs: the script form ran 180 files twice, the direct form splits 90/90 with 927 plus 689 tests, all green. --- .github/workflows/integration.yml | 46 +++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 79b2c50bc..ceb9557f5 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -10,18 +10,21 @@ permissions: contents: read jobs: - integration: + # The suite outgrew a single runner: 128 files took ~13 minutes when the + # 25-minute guard was written, and the v1.37 sharing work took it to 173 + # files and past 25 minutes of wall clock, so every run died at the timeout + # with every test to that point green. The guard's own rule applies — split, + # don't raise — so the files run as two vitest shards. Each shard boots its + # own Postgres testcontainer; the timeout stays a hang detector, sized so a + # healthy half-suite clears it on a slow runner. + shard: runs-on: ubuntu-latest - # This guard exists to kill a hung testcontainer or a deadlocked query, not - # to hold the suite to a performance budget. At 15 it had stopped telling - # those apart: the suite itself takes around 13 minutes of job time (128 - # files against a real Postgres), so two consecutive runs of the SAME - # commit range came in at 12m50s and then over 15m, and the second was - # killed mid-run with every test to that point green. A cap that a healthy - # run clears only on a fast runner reports runner weather, not health. - # 25 leaves real headroom and still catches a hang inside one CI cycle. - # If the suite ever approaches this number, split it rather than raise it. timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + shard: [1, 2] + name: integration shard ${{ matrix.shard }}/2 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -36,7 +39,12 @@ jobs: - run: pnpm db:generate - - run: pnpm test:integration + # vitest is invoked directly: pnpm passes the `--` separator through to + # the script literally, so `pnpm test:integration -- --shard=…` hands + # vitest a stray positional argument and the shard flag never applies — + # both "shards" then run the full suite. Proven by a paired local run + # before this workflow shipped. + - run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/2 env: # The integration suite boots a Postgres testcontainer and points # Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`. @@ -50,3 +58,19 @@ jobs: API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" SESSION_SECRET: "integration-tests" + + # Branch protection requires a check named exactly "integration". 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. + integration: + 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 4bbf4c6d1050a8bb0d9c752b2b661735fecd0ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 00:38:51 +0200 Subject: [PATCH 2/3] ci: three integration shards, not two Two shards cleared in about fourteen minutes on a quiet day, but with six pull requests running side by side the larger shard hit the same 25-minute guard the split was meant to escape. Three shards keep the biggest slice around sixty files, which clears the guard with real headroom on the slowest runner observed. The gate job is unchanged. --- .github/workflows/integration.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index ceb9557f5..a4add73d3 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -10,21 +10,21 @@ permissions: contents: read jobs: - # The suite outgrew a single runner: 128 files took ~13 minutes when the - # 25-minute guard was written, and the v1.37 sharing work took it to 173 - # files and past 25 minutes of wall clock, so every run died at the timeout - # with every test to that point green. The guard's own rule applies — split, - # don't raise — so the files run as two vitest shards. Each shard boots its - # own Postgres testcontainer; the timeout stays a hang detector, sized so a - # healthy half-suite clears it on a slow runner. + # The suite outgrew a single runner, and then a half-suite outgrew a slow + # one: two shards cleared in ~14 minutes on a quiet day, but with six pull + # requests running side by side the larger shard hit the same 25-minute + # guard the split was meant to escape. Three shards keep the biggest slice + # around 60 files, which clears the guard with real headroom even on the + # slowest runner observed. Each shard boots its own Postgres testcontainer; + # the timeout stays a hang detector, not a performance budget. shard: runs-on: ubuntu-latest timeout-minutes: 25 strategy: fail-fast: false matrix: - shard: [1, 2] - name: integration shard ${{ matrix.shard }}/2 + shard: [1, 2, 3] + name: integration shard ${{ matrix.shard }}/3 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -44,7 +44,7 @@ jobs: # vitest a stray positional argument and the shard flag never applies — # both "shards" then run the full suite. Proven by a paired local run # before this workflow shipped. - - run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/2 + - run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/3 env: # The integration suite boots a Postgres testcontainer and points # Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`. From 0849d59ecf445b4b1fed4d6e1b747919cdf601f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:02:08 +0200 Subject: [PATCH 3/3] test(cycle): pin the fixture clock to the suite's clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cycle suites compute their relative day keys with local Date arithmetic under TZ=UTC while the routes resolve today in the user's zone, falling back to Europe/Berlin when the fixture sets none. For two hours every evening the two clocks disagree about the date and every day-of-cycle assertion is off by one — the suite was red from 22:00 UTC to midnight and green the rest of the day. The fixtures now carry an explicit UTC timezone so the test's clock and the route's clock are the same clock at any hour. Reproduced red at 23:07 UTC, green with the pin at 23:19 UTC. --- tests/integration/cycle-flow-opens-cycle.test.ts | 8 ++++++++ tests/integration/cycle-routes.test.ts | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/tests/integration/cycle-flow-opens-cycle.test.ts b/tests/integration/cycle-flow-opens-cycle.test.ts index 5e81fda56..7a9fe7d84 100644 --- a/tests/integration/cycle-flow-opens-cycle.test.ts +++ b/tests/integration/cycle-flow-opens-cycle.test.ts @@ -70,6 +70,14 @@ beforeEach(async () => { username: "cycle-flow", email: "cycle-flow@example.test", gender: "FEMALE", + // The suite runs under TZ=UTC and computes its relative day keys with + // local Date arithmetic, i.e. in UTC. The route resolves "today" in the + // USER's zone and falls back to Europe/Berlin when none is set - one + // hour past 22:00 UTC the two clocks disagree about the date and every + // day-of-cycle assertion is off by one. Pinning the fixture zone to UTC + // makes the test's clock and the route's clock the same clock at any + // hour of the day. + timezone: "UTC", }, }); await loginAs(USER_ID); diff --git a/tests/integration/cycle-routes.test.ts b/tests/integration/cycle-routes.test.ts index 8dc05e92b..d75c8822f 100644 --- a/tests/integration/cycle-routes.test.ts +++ b/tests/integration/cycle-routes.test.ts @@ -69,6 +69,14 @@ beforeEach(async () => { username: "cycle-female", email: "cycle-female@example.test", gender: "FEMALE", + // The suite runs under TZ=UTC and computes its relative day keys with + // local Date arithmetic, i.e. in UTC. The route resolves "today" in the + // USER's zone and falls back to Europe/Berlin when none is set - one + // hour past 22:00 UTC the two clocks disagree about the date and every + // day-of-cycle assertion is off by one. Pinning the fixture zone to UTC + // makes the test's clock and the route's clock the same clock at any + // hour of the day. + timezone: "UTC", }, }); await prisma.user.create({ @@ -77,6 +85,7 @@ beforeEach(async () => { username: "cycle-male", email: "cycle-male@example.test", gender: "MALE", + timezone: "UTC", }, }); });