Din CI-pipeline har ljugit for dig i 13 dagar

Erik Treviño avatar
Erik Treviño
Cover for Din CI-pipeline har ljugit for dig i 13 dagar

Var CI-pipeline misslyckades i 13 dagar i strack.

Ingen markerade.

Over 20 misslyckanden i foljd. Roda builds varje enda dag. Auto-deploy-pipelinen var dod. Varje E2E-korning fick timeout efter 30 minuter, brande CI-krediter och producerade en rod badge som ingen tittade pa.

I 13 dagar mergade teamet kod utan nagon E2E-validering. Varje PR som shippades under de tva veckorna gick till produktion utan den testsvit som skulle fanga regressioner. Sakerhetsnat hade ett hal, och teamet gick pa lina utan att titta ner.

Dag 0: PR:en som krossade allt

En frontend-refaktorering landade. Nodvandigt arbete — teamet konsoliderade URL-endpointdefinitioner over hela applikationen. Routenycklar som var duplicerade i 17 filer centraliserades till en enda konfigurationsmodul. Ren refaktorering. Valavsedd PR.

Alla kodgranskningar godkandes. Alla enhetstester gick igenom. Alla 4 godkannare signerade. Den mest erfarna granskaren pa PR:en flaggade till och med URL-mappningsomradet som en risk — och granskade det noggrant.

Luckan slank igenom anda.

E2E-testinfrastrukturen underholl sin egen URL-mappning. En separat konfigurationsfil som mappade routenamn till faktiska URL:er. Nar frontend refaktorerade sina routenycklar uppdaterade ingen testinfrastrukturens mappning. Sa varje E2E-test som navigerade till en route — vilket ar varje E2E-test — forsokte na en URL som inte langre existerade.

// test-config/routes.ts — THE BROKEN FILE
// These route keys matched the frontend's OLD naming convention.
// The frontend PR renamed them. This file was not updated.
export const ROUTES = {
  dashboard: '/app/dashboard',
  userProfile: '/app/user/profile',
  settings: '/app/settings/general',
  // ... 14 more routes
  reports: '/app/reports/overview',     // OLD: was renamed to 'reporting'
  analytics: '/app/analytics/main',     // OLD: was renamed to 'insights'
} as const;

// Every test used these routes:
test('user can view analytics', async ({ page }) => {
  await page.goto(ROUTES.analytics);  // Navigates to a URL that no longer exists
  // Test times out waiting for a page that will never load
});

Dag 1-12: Det tysta misslyckandet

Har ar vad som hande under de nasta 12 dagarna: ingenting.

E2E-pipelinen korde vid varje merge till main. Den misslyckades. Den skickade en notifikation. Notifikationen gick till en kanal som teamet hade mutat manader tidigare for att den var for brusig. Workflowkorningen visade rott i GitHub Actions-fliken, som ingen kollade for att PR-checkarna (enhetstester, linting) alla gick igenom.

Varje dag gjorde pipelinen foljande:

  1. Hamtade koden
  2. Installerade beroenden
  3. Startade webblasarna
  4. Forsokte navigera till routes som inte langre existerade
  5. Vantade 30 sekunder pa att varje sida skulle ladda
  6. Fick timeout
  7. Rapporterade misslyckande
  8. Upprepade for varje test i sviten

30 minuter CI-tid, branda. Varje enda dag. I nastan tva veckor.

Dag 13: Upptackten

Jag hittade det pa samma satt som de flesta tysta misslyckanden hittas — av en slump. Jag undersokte ett annat problem och oppnade GitHub Actions-fliken. Vaggen av rott var omojlig att missa nar man valde att titta. Over 20 misslyckanden i rad pa main-branchens E2E-pipeline.

Utredningen borjade med en enkel fraga: nar borjade det har?

# Find the first failing run on main
gh run list --workflow=e2e.yml --branch=main --limit=30 --json conclusion,createdAt \
  | jq '.[] | select(.conclusion == "failure") | .createdAt' | tail -1

# Result: 13 days ago

Sedan: vad andrades for 13 dagar sedan?

# Find the merge commit from 13 days ago
git log --oneline --since="13 days ago" --until="12 days ago" --merges

# Cross-reference with the first failure timestamp
git log --oneline --all --after="2026-03-15T00:00:00" --before="2026-03-16T00:00:00"

# Found it: the URL consolidation PR, merged on March 15

Sedan: vad gick exakt sonder?

# Compare the test config routes against the frontend route definitions
# The frontend had renamed keys — the test config still used the old names
git diff HEAD~50..HEAD -- frontend/src/config/routes.ts
git diff HEAD~50..HEAD -- tests/config/routes.ts  # This file had ZERO changes

Diffen berattade hela historien omedelbart. Frontend-routefilen visade 17 omdopta nycklar over ett fonster pa 50 commits. Test-routefilen visade noll andringar under samma period. Mappningen hade glidit isar.

Fixen

Fixen var 10 rader. Uppdatera testroutekonfigurationen sa att den matchade de nya frontend-routenycklarna.

// test-config/routes.ts — FIXED
export const ROUTES = {
  dashboard: '/app/dashboard',
  userProfile: '/app/user/profile',
  settings: '/app/settings/general',
  // Updated to match the frontend refactor
  reporting: '/app/reporting/overview',   // Was 'reports'
  insights: '/app/insights/main',         // Was 'analytics'
  // ...
} as const;

Pipelinen gick fran 30-minuters timeouts till 4-minuters grona korningar. Omedelbart.

Fixen tog 10 minuter. Att hitta den tog 30 minuter av git-arkeologi. Att problemet existerade i 13 dagar ar det som haller mig vaken pa natterna.

URL-mappningskontraktet

Den riktiga fixen ar inte 10-radersuppdateringen av routes. Den riktiga fixen ar att sakerstalla att den har kategorin av misslyckanden aldrig kan ga oupptackt igen.

URL-mappningar mellan din frontend-routing och din testinfrastruktur ar ett forstklassigt kontrakt. De behover sin egen validering — inte bara “jag hoppas att nagon kommer ihag att uppdatera testkonfigurationen.”

Sa har ser ett kontrakt mellan frontend-routes och testinfrastruktur ut i kod:

// tests/contracts/route-sync.spec.ts
// This test validates that the test route config matches the frontend route config.
// It runs in CI on every PR and fails if the mappings drift.

import { ROUTES as TEST_ROUTES } from '../config/routes';
import { ROUTES as APP_ROUTES } from '../../frontend/src/config/routes';

test('test routes must match application routes', () => {
  const testRouteKeys = Object.keys(TEST_ROUTES).sort();
  const appRouteKeys = Object.keys(APP_ROUTES).sort();

  // Every app route should have a corresponding test route
  const missingInTests = appRouteKeys.filter(key => !testRouteKeys.includes(key));
  const staleInTests = testRouteKeys.filter(key => !appRouteKeys.includes(key));

  expect(missingInTests).toEqual([]);
  expect(staleInTests).toEqual([]);
});

test('test route URLs must match application route URLs', () => {
  for (const [key, url] of Object.entries(TEST_ROUTES)) {
    expect(APP_ROUTES[key]).toBeDefined();
    expect(APP_ROUTES[key]).toBe(url);
  }
});

Det har testet kor pa under en sekund. Det kraver ingen webblasare. Det fangar exakt det misslyckande som gick oupptackt i 13 dagar. Om frontend-PR:en hade inkluderat det har kontraktstestet i kodbasen hade PR:en sjalv misslyckats i CI — for att andra routenycklar i frontend utan att uppdatera testkonfigurationen hade fangats som ett kontraktsbrott.

Checklistan for overvakning av pipelinehalsa

13-dagarsmisslyckandet var mojligt pa grund av flera luckor i pipelinens observerbarhet. Har ar vad jag implementerade efterat:

1. Varningar vid konsekutiva misslyckanden

# .github/workflows/pipeline-health.yml
# Runs daily and alerts if the main branch E2E pipeline has failed
# more than 3 consecutive times
name: Pipeline Health Check
on:
  schedule:
    - cron: '0 9 * * 1-5'  # Every weekday at 9 AM

jobs:
  check-health:
    runs-on: ubuntu-latest
    steps:
      - name: Check E2E pipeline status
        run: |
          RECENT=$(gh run list --workflow=e2e.yml --branch=main --limit=5 \
            --json conclusion -q '.[].conclusion')
          FAILURES=$(echo "$RECENT" | grep -c "failure" || true)
          if [ "$FAILURES" -ge 3 ]; then
            echo "🚨 E2E pipeline has failed $FAILURES of the last 5 runs"
            # Send alert to team channel
            exit 1
          fi
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

2. Dashboard for pipelinens framgangsfrekvens

Folj den rullande framgangsfrekvensen over 7 dagar. Om den sjunker under 80 % ar nagot strukturellt fel — det ar inte bara ett flakigt test.

3. Maximal acceptabel misslyckandeduration

Bestam som team: vad ar det maximala antalet konsekutiva dagar som E2E-pipelinen far misslyckas innan det blir en blockerande prioritet? For mitt team ar svaret nu 2 dagar. Om pipelinen ar rod i 2 konsekutiva dagar eskaleras det.

4. Disciplin i notifikationskanaler

Teamet hade mutat CI-notifikationskanalen for att den var for brusig. Det ar ett symptom pa ett annat problem — pipelinen skickade varningar for flakiga tester tillsammans med genuina misslyckanden, och signalen forsvann i bruset. Losningen: separata kanaler for “pipelinemisslyckande” (harda misslyckanden som kraver atgard) och “testinstabilitet” (flakiga tester som sparas separat).

5. Kontraktstester for delad konfiguration

Varje konfigurationsdel som delas mellan applikationskod och testinfrastruktur behover ett kontraktstest. Routes, feature flags, miljovariabler, API-endpoint-URL:er — om testsviten ar beroende av att den forblir synkroniserad med applikationen, validera synkroniseringen i CI.

Varfor tysta misslyckanden ar farligast

Hogljudda misslyckanden blir fixade. Ett test som misslyckas pa varje PR blockerar merge. En deploy som kraschar aterrutas. En build som smaller till undersoks omedelbart.

Tysta misslyckanden urholkar fortroendet gradvis. Pipelinen ar rod, men PR:er fortsatter att mergas (for att E2E-sviten kor pa main, inte som en PR-check). Dashboarden visar misslyckanden, men teamet har slutat titta pa den. Auto-deploy ar trasigt, men manuella deploys fungerar fortfarande, sa ingen ar riktigt blockerad.

Vid dag 5 har teamet anpassat sig till det trasiga tillstandet. Vid dag 10 ar det trasiga tillstandet normaltillstandet. Vid dag 13 uppmarksammar nagon det och reaktionen ar “ah, det har varit trasigt ett tag” istallet for “det har ar en nodsituation.”

Det har ar det farligaste fellagret inom testautomatisering. Inte testet som flakar ibland — det ar synligt och irriterande. Pipelinen som misslyckas tyst i veckor, tranar teamet att ignorera den, tills sakerhetsnatet den erbjuder ar rent teoretiskt.

Den djupare lardomen

Om din CI-pipeline kan misslyckas i tva veckor utan att nagon agerar har du inte CI. Du har ett cron-jobb som skickar mejl till /dev/null.

Kontinuerlig integration betyder kontinuerlig. Inte “kontinuerlig om inte E2E-sviten ar trasig, i sa fall kor vi bara enhetstesterna och hoppas pa det basta.” Hela poangen med CI ar att fanga misslyckanden tidigt. En pipeline som misslyckas tyst i 13 dagar fangar ingenting. Den spenderar berakningstid pa att producera roda badges som ingen laser.

Det svaraste med att fixa det har var inte 10-radersuppdateringen av routes. Det var inte kontraktstestet. Det var inte overvakningsworkflowen. Det var att marka det. Allt annat tog en eftermiddag. Att marka det tog 13 dagar — och hade tagit langre om jag inte hade tittat pa Actions-fliken av en helt annan anledning.

Bygg system som marker at dig. Kontrakt som misslyckas snabbt. Varningar som nar ratt personer. Pipelinen ska aldrig kunna ljuga for dig i 13 dagar.

For pipelinen var inte flakig. Den var trasig. Och det svaraste var inte att fixa den — det var att marka det.