95% snabbare CI med 15 rader bash

Erik Treviño avatar
Erik Treviño
Cover for 95% snabbare CI med 15 rader bash

Jag skar ner vår CI-valideringstid med 95%.

Inte med caching. Inte med sharding. Inte med ett fancy tredjepartsverktyg som lägger till ytterligare ett beroende i din pipeline.

Med 15 rader bash. Noll beroenden. Noll risk i leveranskedjan.

Den PR som levererade denna ändring var mindre än de flesta commit-meddelanden. Och den sparar teamet 7-8 timmar per månad.

Problemet

Varje PR körde hela CI-pipelinen. Backend-lintning, backend-enhetstester, frontend-enhetstester, E2E-tester — hela karusellen. En utvecklare ändrar en enda E2E-testfil, pushar branchen och väntar 8 minuter och 18 sekunder på att backend-lintningen ska berätta att deras Python-kod är okej.

Python-koden de inte rörde.

Det här är standardbeteendet för de flesta CI-uppsättningar. En enda workflow-fil, en enda trigger, kör allting vid varje push. Det är enkelt. Det är säkert. Det bränner också utvecklartid på valideringar som omöjligen kan misslyckas för de ändringar som görs.

Lösningen: 15 rader bash

Idén är enkel: använd git diff --name-only för att avgöra vilka filer som ändrats, och hoppa sedan villkorligt över jobb som är irrelevanta för dessa ändringar.

#!/bin/bash
# detect-changes.sh — Determine which CI jobs to run based on changed files
# Called by GitHub Actions workflow to set output variables

CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)

RUN_BACKEND=false
RUN_FRONTEND=false
RUN_E2E=false

for file in $CHANGED_FILES; do
  case "$file" in
    backend/*|api/*|*.py)        RUN_BACKEND=true ;;
    frontend/*|src/*|*.ts|*.tsx) RUN_FRONTEND=true ;;
    tests/e2e/*|playwright/*)    RUN_E2E=true ;;
    .github/*|docker-compose*|Makefile)
      # Infrastructure changes: run everything as a safety net
      RUN_BACKEND=true; RUN_FRONTEND=true; RUN_E2E=true ;;
  esac
done

echo "run-backend=$RUN_BACKEND" >> "$GITHUB_OUTPUT"
echo "run-frontend=$RUN_FRONTEND" >> "$GITHUB_OUTPUT"
echo "run-e2e=$RUN_E2E" >> "$GITHUB_OUTPUT"

Det är allt. Skriptet läser diffen, klassificerar varje ändrad fil efter katalog och sätter output-flaggor som nedströms jobb kontrollerar innan de körs.

GitHub Actions-workflowet: Före och efter

Före: Kör allting alltid

# .github/workflows/ci.yml — BEFORE
name: CI
on: [push, pull_request]

jobs:
  backend-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install ruff
      - run: ruff check backend/

  backend-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: pytest backend/tests/

  frontend-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  e2e-test:
    runs-on: ubuntu-latest
    needs: [backend-test, frontend-test]
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

Varje jobb körs på varje PR. En stavningsfix i en E2E-testfil triggar backend-lintning, backend-tester, frontend-tester och E2E-tester. Total väggtid: 8 minuter 18 sekunder.

Efter: Kör det som är relevant

# .github/workflows/ci.yml — AFTER
name: CI
on: [push, pull_request]

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      run-backend: ${{ steps.changes.outputs.run-backend }}
      run-frontend: ${{ steps.changes.outputs.run-frontend }}
      run-e2e: ${{ steps.changes.outputs.run-e2e }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for accurate git diff
      - id: changes
        run: bash .github/scripts/detect-changes.sh

  backend-lint:
    needs: detect-changes
    if: needs.detect-changes.outputs.run-backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install ruff
      - run: ruff check backend/

  backend-test:
    needs: detect-changes
    if: needs.detect-changes.outputs.run-backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: pytest backend/tests/

  frontend-test:
    needs: detect-changes
    if: needs.detect-changes.outputs.run-frontend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  e2e-test:
    needs: detect-changes
    if: needs.detect-changes.outputs.run-e2e == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

Det enda tillägget är detect-changes-jobbet (körs på ~5 sekunder) och if:-villkoren på varje nedströms jobb. Allt annat är identiskt.

Resultaten

PR-typFöreEfterBesparing
Enbart E2E-ändringar8 min 18 sek39 sek95%
Enbart frontend-ändringar6 min 13 sek1 min 32 sek75%
Enbart backend-ändringar5 min 45 sek2 min 10 sek62%
Fullstack-ändringar8 min 18 sek8 min 18 sek0% (kör allting)
CI/infra-ändringar8 min 18 sek8 min 18 sek0% (skyddsnät)

Under en typisk vecka — ungefär 40% enbart E2E-PR:ar, 30% enbart frontend, 20% enbart backend, 10% fullstack — ger detta uppskattningsvis 7-8 timmar per månad i sparad väntetid för utvecklare.

Skyddsnätet är viktigt: alla ändringar i .github/-workflows, Docker-konfigurationer eller Makefile triggar hela pipelinen. Man hoppar aldrig över validering av infrastrukturen som kör din validering.

Varför inte använda en GitHub Action för detta?

Det är här historien blir intressant. Jag granskade tre populära GitHub Actions för sökvägsbaserad CI-filtrering innan jag skrev bash-skriptet. Det jag hittade övertygade mig om att noll beroenden var rätt val.

Action 1: Ringer hem till ett kommersiellt API

En populär action för sökvägsfiltrering gör ett API-anrop till en kommersiell tjänst vid varje CI-körning. Läs det igen: din CI-pipeline ringer hem till en tredje part varje gång den körs. Actionen fungerar korrekt när tjänsten är uppe och din prenumeration är aktiv. När den inte är det — och jag hittade GitHub Issues från användare som rapporterade detta — kan actionen få din build att hårdfejla. Din CI-pipeline har nu ett tillgänglighetsberoende på en SaaS-produkt du inte kontrollerar.

Action 2: Mål för supply chain-attack (CVE-2025-30066)

I mars 2025 komprometterades den populära tj-actions/changed-files GitHub Action — som användes av över 23 000 repositoryn — i en supply chain-attack. Angriparen modifierade befintliga versiontaggar så att de pekade på skadlig kod som dumpade CI-runnerns minne och exponerade workflow-hemligheter inklusive API-nycklar, tokens och autentiseringsuppgifter. CISA utfärdade ett formellt meddelande (CVE-2025-30066). Attacken spårades senare till en komprometterad personal access token som tillhörde ett botkonto med skrivbehörighet till repositoryt.

Det här är inte en teoretisk risk. Det hände. Med en action som gör exakt det som mitt 15-raders bash-skript gör.

Action 3: Fungerar bra, men onödig

Den tredje actionen jag utvärderade är välunderhållen, open source och har inga kända säkerhetsproblem. Den fungerar korrekt. Den lägger också till ett beroende som behöver versionspinnas, övervakas för uppdateringar och granskas periodiskt. För 15 rader bash som gör samma sak är beroendet inte motiverat.

Säkerhetsargumentet för noll beroenden

Varje GitHub Action du lägger till i ditt workflow är kod som du exekverar med tillgång till dina repository-hemligheter, din källkod och din CI-miljö. Varje action är ett förtroendebeslut. tj-actions/changed-files-incidenten visade att även populära, brett använda actions kan komprometteras.

Bash-skriptets ansats har en annorlunda säkerhetsprofil:

  • Inga nätverksanrop. Skriptet läser git diff-output. Det hämtar ingenting, ringer inte hem till någonting och beror inte på någonting utanför repositoryt.
  • Ingen tredjepartskodexekvering. Skriptet är incheckat i ditt repository och granskas i samma PR-process som din applikationskod.
  • Inga versionspinningsspel. Det finns ingen @v4-tagg att oroa sig för. Ingen attackvektor i leveranskedjan. Koden är din.
  • Full granskningsbarhet. Vem som helst i teamet kan läsa 15 rader bash och förstå exakt vad det gör.

Ibland är det bästa beroendet inget beroende alls.

Specialfall och skyddsnät

Några saker jag hanterar som en naiv implementation skulle missa:

Nya filer utan tydlig kategori

# If a file doesn't match any known pattern, run everything.
# Better to over-run than under-run.
*)
  RUN_BACKEND=true; RUN_FRONTEND=true; RUN_E2E=true ;;

Standardfallet triggar en fullständig körning. En okänd filtyp ska aldrig tyst hoppa över validering.

Borttagna filer

git diff --name-only inkluderar borttagna filer. En borttagen backend-fil behöver fortfarande att backend-testsviten körs — borttagningen kan bryta en import.

Merge-commits

fetch-depth: 0 i checkout-steget är avgörande. Utan fullständig git-historik ger git diff mot basgrenen felaktiga resultat. Jag har sett CI-uppsättningar med fetch-depth: 1 (standardvärdet) som missar ändringar eftersom den ytliga klonen inte innehåller merge-basen.

Vad du kan göra på måndag morgon

  1. Mät din nuvarande baslinje. Vad är den genomsnittliga CI-tiden för din vanligaste PR-typ? Om du inte vet, kolla de senaste 20 workflow-körningarna.

  2. Kategorisera dina CI-jobb efter katalog. Vilka jobb motsvarar vilka delar av kodbasen? De flesta pipelines har en naturlig mappning.

  3. Skriv bash-skriptet. Börja med versionen ovan och anpassa katalogmönstren efter din repostruktur.

  4. Lägg till detect-changes-jobbet. Koppla in det i ditt workflow med if:-villkor.

  5. Lägg till skyddsnätet. Alla ändringar i CI-konfiguration, Docker-filer eller infrastrukturskript ska trigga en fullständig körning. Alltid.

  6. Mät resultatet. Jämför samma PR-typer före och efter. Siffrorna talar för sig själva bättre än något argument.

Den bredare lärdomen

CI-pipelinen är den mest försummade infrastrukturen i de flesta teknikorganisationer. Team lägger veckor på att optimera databasfrågor som sparar 200ms per anrop, och ser sedan varje utvecklare sitta igenom 8 minuter av irrelevant CI-validering 5 gånger om dagen utan att ifrågasätta det.

Lösningen här är inte smart. Den är inte ny. Det är 15 rader bash som ställer en enkel fråga: påverkade ändringarna i denna PR faktiskt den kod som det här jobbet validerar?

Om svaret är nej, hoppa över det. Det är inte att ta genvägar. Det är att respektera ditt teams tid.

15 rader. Noll beroenden. 95% snabbare. Ibland är den enklaste lösningen den som ingen provar eftersom den känns för enkel för att fungera.