Hoe we onze CI-doorlooptijd hebben teruggebracht van 30 minuten naar 5 minuten
Trage CI is zoiets waar engineeringteams zich aan ergeren. Engineers wachten langer op feedback, contextswitching neemt toe en de verleiding om wijzigingen te bundelen groeit, wat het alleen maar erger maakt. We hebben een paar weken besteed aan het optimaliseren van onze GitHub Actions-workflows en hebben onze PR-checks teruggebracht van 30 minuten naar 5 minuten. Dat is een reductie van 83% in wachttijd, bereikt zonder in te leveren op testdekking of quality gates. Er was niet één magische oplossing. We hebben gekeken waar de tijd echt naartoe ging en drie principes toegepast: slimme job-afhankelijkheden, agressieve caching en het opsplitsen van tests. Dit is wat we hebben geleerd.

De basis: migreren naar Blacksmith
We hebben eerder al eens geprobeerd om naar Blacksmith te migreren, maar liepen toen tegen problemen aan. Met een main-branch die zo actief is als die van ons, wilden we geen gedoe met CI-instabiliteit. Deze keer verliep de migratie soepel (#26247): vervang buildjet-*vcpu-ubuntu-2204 runners door blacksmith-*vcpu-ubuntu-2404 in 28 workflowbestanden, wissel de cache- en setup-node-actions om en pas de vCPU-toewijzingen aan. Het enige echte probleem waar we tegenaan liepen was het corrupte-cacheprobleem met .next-bestanden (meer daarover in het caching-gedeelte).
Na de upgrade merkten we meteen een 2x snellere cache-restore. Omdat yarn install 1,2GB is, scheelt dat ongeveer 22 seconden op elke job die yarn install nodig heeft, en dat zijn de meeste. We gebruiken ook Blacksmith's container caching voor services die in veel jobs worden gebruikt (Postgres, Redis, Mailhog). De job Initialize containers is nu ruim 50% sneller, van 20 seconden naar 9 seconden.
jobs:
build:
name: Build Web App
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-build
Wat we moesten fixen
Blacksmith-runners tonen het cpu-aantal van de hostmachine in plaats van de toegewezen vCPU's (meestal 4). Playwright, behulpzaam als altijd, zou workers starten om dat aantal te evenaren, wat leidde tot resourceconflicten en flaky testfouten. We hoefden alleen de workers expliciet te begrenzen met --workers=4, zodat het overeenkwam met onze daadwerkelijke toewijzing.
We ontdekten ook dat hogere parallelisatie racecondities blootlegde in onze test-cleanuplogica. Integration tests die sequentieel prima werkten, botsten wanneer ze tegelijk draaiden, wat idempotencyKey-fouten opleverde. De oplossing vroeg om een andere manier van cleanup: boekingen opvragen via eventTypeId in plaats van individuele booking-ID's bij te houden, want daarmee misten we boekingen die indirect door de code onder test werden aangemaakt (#26269, #26278, #26283).
Zelfs onze screenshottests hadden aanpassingen nodig (#26247). Verschillende runners hebben subtiele verschillen in font rendering, dus voegden we deviceScaleFactor: 1 toe voor consistente rendering en verhoogden we onze diff-drempel licht van 0.05 naar 0.07 om variaties in antialiasing op te vangen zonder echte regressies te maskeren.
Sneller gaan legt vaak verborgen koppelingen in je systeem bloot. Die koppeling was er altijd al; je kon het alleen niet zien bij lagere snelheid. Juist het oplossen van deze problemen maakt duurzame snelheidsverbeteringen mogelijk.
Principe 1: Verkort het kritieke pad met slim dependency-ontwerp
Voor ons was een groot probleem dat lint dingen blokkeerde die het niet had moeten blokkeren. Als je E2E-tests 7-10 minuten duren, dan ze 2-3 minuten laten wachten op lint- en typechecks maakt de hele workflow alleen maar langer. Lint is lang klaar voordat E2E klaar is, dus waarom zou je ze niet parallel laten draaien?
Ontkoppel losse testsuites
Onze integration tests en hoofd-E2E-suite hebben de API v2-build niet nodig, dus die afhankelijkheid hebben we verwijderd (#26170). Omgekeerd hebben de API v2-E2E-tests de hoofd-webbuild niet nodig. Elke testsuite hangt nu alleen af van wat die echt nodig heeft, waardoor maximale parallelisatie mogelijk is.
Bundel preflight-jobs
We hadden aparte jobs voor detectie van bestandswijzigingen (changes) en E2E-labelcontrole (check-label). Elke job heeft opstartoverhead: een runner opstarten, code uitchecken, context herstellen. Door deze samen te voegen tot één prepare-job hebben we dubbele overhead geëlimineerd (#26101). We zijn nog verder gegaan en hebben onze stappen voor het installeren van dependencies ook naar de prepare-job verplaatst, waardoor we nog eens 20 seconden per workflow besparen door opnieuw een jobgrens weg te nemen (#26320).
Maak rapportage niet-blokkerend
E2E-testrapporten zijn waardevol, maar ze zouden niet op het kritieke pad moeten zitten. We hebben de jobs merge-reports, publish-report en cleanup-report naar een aparte workflow verplaatst die wordt getriggerd door workflow_run (#26157). Nu kunnen engineers mislukte jobs direct opnieuw draaien zonder te wachten tot de rapportgeneratie klaar is.
name: E2E Report
on:
workflow_run:
workflows: ["PR Update"]
types:
- completed
De regel is simpel: wees soepel in het eerder laten starten van E2E-jobs. Blokkeer langlopende jobs niet op kortlopende voorwaarden, tenzij er echt een afhankelijkheid is. De wall-clock time van je CI wordt bepaald door het kritieke pad, en elke onnodige afhankelijkheid verlengt dat pad.
Principe 2: Caching is koning (maar alleen als het hit)
Effectieve caching vereist keys die ongeldig worden wanneer dat moet, stabiel blijven wanneer dat moet, en een cache-opslag die gezond blijft op de lange termijn.
Maak gebruik van lookup-only
Onze grootste cachingverbetering kwam uit een simpele observatie: we downloadden ongeveer 1,2GB aan gecachte data, zelfs wanneer we niets hoefden te installeren. De cache bestond, de hit was succesvol, maar we betaalden nog steeds de downloadkosten voordat we ontdekten dat we de installatiestap konden overslaan.
De oplossing was om lookup-only-cachechecks te gebruiken (#26314). We hebben aparte stappen die vóór alle jobs draaien om te zorgen dat dependencies zijn geïnstalleerd, dus tegen de tijd dat losse jobs starten, weten we dat de cache bestaat. Voordat we proberen de volledige cache te herstellen, controleren we eerst of die bestaat zonder te downloaden. Als alle caches hitten, slaan we de hele restore- en installflow over. Geen downloads, geen installs, gewoon verdergaan met het echte werk. Alleen dit al scheelde veel tijd in elk cache-hitscenario, wat met een goed cache-keyontwerp de meeste runs zou moeten zijn.
- name: Check yarn cache (lookup-only)
if: ${{ inputs.skip-install-if-cache-hit == 'true' }}
uses: actions/cache/restore@v4
id: yarn-download-cache-check
with:
path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
lookup-only: true
We pasten dezelfde logica toe op de installatie van Playwright-browsers (#26060). Omdat we een aparte stap hebben die ervoor zorgt dat Playwright-browsers gecached zijn voordat testjobs draaien, kunnen we dezelfde lookup-only-aanpak gebruiken. Als de Playwright-cache hit, sla dan de installatie helemaal over. Nu controleren we eerst, en beslissen we daarna of we überhaupt gaan downloaden.
We hebben dit patroon ook uitgebreid naar onze databasecache (#26344). Met lookup-only-checks kunnen we het opnieuw opbouwen van de DB-cache overslaan wanneer die al bestaat. We hebben ook de cache-key vereenvoudigd door het PR-nummer en SHA te verwijderen, waardoor die veel beter herbruikbaar is tussen runs.
Cachecorrectheid
Cachecorrectheid is net zo belangrijk als cachesnelheid. We ontdekten dat ons **/node_modules/-globpatroon apps/web/.next/node_modules/ oppikte, wat buildoutput van Next.js is met symlinks en gegenereerde bestanden die tijdens builds veranderen. Dat leidde tot corrupte cache-archieven met tar-extractiefouten (#26268). De oplossing was een eenvoudig uitsluitingspatroon, maar het vinden ervan vereiste dat we precies begrepen wat er werd gecached en waarom.
We hebben ook automatische cache-opschoning geïmplementeerd (#26312). Wanneer een PR wordt samengevoegd of gesloten, verwijderen we nu de bijbehorende buildcaches. Zo blijft onze cache-opslag slank en voorkomen we ophoping van verouderde entries die nooit meer gebruikt zouden worden. We hebben ons cache-keyformaat in dat proces vereenvoudigd door onnodige segmenten zoals runner.os en node_version te verwijderen, die complexiteit toevoegden zonder de hit rates te verbeteren.
delete-cache-build-on-pr-close:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: blacksmith-2vcpu-ubuntu-2404
env:
CACHE_NAME: prod-build
steps:
- name: Delete cache-build cache
uses: useblacksmith/cache-delete@v1
with:
key: ${{ env.CACHE_NAME }}-${{ github.event.pull_request.head.ref }}
prefix: "true"
Turbo Remote Caching
We zijn geüpgraded naar Next.js 16 (#26093), waardoor onze builds van 6+ minuten naar ~1 minuut gingen (hulde aan het Next.js-team voor de enorme verbeteringen). Met Turbo Remote Caching ingeschakeld duren volgende builds nog maar 7 seconden om uit de cache te halen, vergeleken met ~1 minuut voor een volledige build.
We hebben Turbo Remote Caching ook ingeschakeld voor onze API v2 E2E-tests (#26331). Voorheen bouwde elke E2E-shard de platformpackages vanaf nul opnieuw op. Nu profiteert elke shard van Turbo's remote cache, en we hebben de TypeScript-compilatie van Jest geoptimaliseerd door isolatedModules in te schakelen en diagnostics in CI uit te zetten, wat de opstarttijd van tests aanzienlijk versnelt.
Principe 3: schaal tests op met sharding
Als je 1.100+ testcases hebt verspreid over 82 E2E-testbestanden, laat je prestaties liggen als je ze sequentieel draait. Test sharding, waarbij je je testsuite opsplitst over meerdere parallelle runners, is de meest directe manier om de wall-clock time van grote testsuites te verlagen. We hadden sharding al voor onze hoofd-E2E-suite, maar we hebben het verhoogd van 4 naar 8 shards (#26342), waardoor de totale tijd van de E2E-suite nog eens met een minuut daalde. Onze API v2 E2E-tests draaiden nog steeds als één job.
We hebben onze API v2 E2E-tests opgesplitst in 4 parallelle jobs met Jest's ingebouwde --shard-optie (#26183). Elke shard draait onafhankelijk met eigen Postgres- en Redis-services, en artifacts krijgen per shard unieke namen. Wat voorheen 10+ minuten kostte als één job, is nu aanzienlijk sneller klaar doordat het werk over vier runners wordt verdeeld.
- name: Run Tests
working-directory: apps/api/v2
run: |
yarn test:e2e:ci --shard=${{ matrix.shard }}/${{ strategy.job-total }}
EXIT_CODE=$?
echo "yarn test:e2e:ci --shard=${{ matrix.shard }}/${{ strategy.job-total }} command exit code: $EXIT_CODE"
exit $EXIT_CODE
Sharding op schaal legt ook infrastructuurproblemen bloot die je bij sequentiële uitvoering misschien niet ziet. Wanneer meerdere jobs tegelijk dezelfde databasecache proberen te vullen, krijg je racecondities. We hebben dit opgelost door een aparte setup-db-job te maken die vóór alle E2E- en integration-testjobs draait (#26171). Deze ene job vult de databasecache één keer, en alle downstream jobs herstellen daaruit. Geen races, geen dubbel werk.
We hebben ook een aparte workflow gemaakt voor API v2-unittests, los van de hoofdtestsuite (#26189). Daardoor kunnen ze parallel draaien met andere checks in plaats van te concurreren om resources in één monolithische testjob.
Het cumulatieve effect
Geen van deze wijzigingen afzonderlijk zou onze CI-tijd met 83% hebben verlaagd. De winst komt uit de combinatie.
Slimme afhankelijkheden zorgen ervoor dat jobs eerder kunnen starten. Effectieve caching betekent dat die jobs minder tijd kwijt zijn aan setup. Sharding zorgt ervoor dat de eigenlijke testuitvoering parallel gebeurt. Samen verkorten ze het kritieke pad van alle kanten.
De investering stapelt zich in de tijd op. Elke PR krijgt nu sneller feedback. Engineers blijven langer in flow. De verleiding om wijzigingen te bundelen neemt af, wat kleinere, beter reviewbare diffs oplevert. De codekwaliteit verbetert doordat de feedbackloop strakker is.
We blijven investeren in CI-prestaties omdat de opbrengst echt en meetbaar is. Het doel is niet snelheid om de snelheid zelf; het gaat erom engineers in staat te stellen sneller kwaliteitssoftware te shippen. Als CI snel is, werken snel bewegen en hoge standaarden behouden samen in plaats van tegen elkaar in.
Aangehaalde pull requests
Basis: migratie van Buildjet naar Blacksmith
- #26247 - GitHub-workflows migreren van Buildjet naar Blacksmith
Teststabiliteit
-
#26269 - Flaky e2e-tests fixen met geïsoleerde gebruikerssessies
-
#26278 - e2e-tests stabiliseren met scoped locators en deterministische schema's
-
#26283 - Testisolatie verbeteren voor managed scheduling link e2e-tests
Turbo Remote Caching
-
#26093 - Upgraden naar Next 16 (builds teruggebracht van 6+ minuten naar ~1 minuut)
-
#26331 - Turbo Remote Caching inschakelen (volgende builds duren 7s)
Job-afhankelijkheden en workflow-optimalisatie
-
#26101 - changes- en check-label-jobs samenvoegen in prepare
-
#26170 - Niet-API v2-tests loskoppelen van API v2-build
-
#26157 - E2E-rapportjobs niet-blokkerend maken door ze naar een aparte workflow te verplaatsen
-
#26320 - De deps-job naar de prepare-jobstap verplaatsen om ~20s per workflow te besparen
Maak gebruik van lookup-only
-
#26314 - lookup-only cachecheck gebruiken om downloads van de deps-job over te slaan
-
#26060 - Playwright-installatie overslaan bij cache-hit
-
#26344 - lookup-only gebruiken voor DB-cache en de cache-key vereenvoudigen
Cachecorrectheid
-
#26268 - .next/node_modules uitsluiten van yarn-cache om corruptie te voorkomen
-
#26312 - cache-build cache-entries verwijderen bij het sluiten van een PR
Testsharding en infrastructuur
