Soluzioni

Impresa

Cal.ai

Sviluppatore

Risorse

Prezzo

Da

Keith Williams

1 gen 2026

Come abbiamo ridotto il nostro tempo di CI da 30 minuti a 5 minuti

Come abbiamo ridotto il nostro tempo di CI da 30 minuti a 5 minuti

Come abbiamo ridotto il nostro tempo di CI da 30 minuti a 5 minuti

Un CI lento è una di quelle cose che fa innervosire i team di ingegneria. Gli ingegneri aspettano più a lungo per un feedback, il cambio di contesto aumenta e la tentazione di accorpare le modifiche cresce, il che non fa altro che peggiorare le cose. Abbiamo trascorso alcune settimane ad ottimizzare i nostri flussi di lavoro di GitHub Actions e abbiamo ridotto i nostri controlli PR da 30 minuti a 5 minuti. Questa è una riduzione dell'83% del tempo di attesa, ottenuta senza sacrificare alcuna copertura di test o gate di qualità.

Non c'era una soluzione magica. Abbiamo esaminato dove andava realmente il tempo e applicato tre principi: dipendenze dei lavori intelligenti, caching aggressivo e sharding dei test. Ecco cosa abbiamo imparato.

La Fondazione: Migrare a Blacksmith

In realtà avevamo già provato a migrare a Blacksmith in passato, ma abbiamo incontrato problemi. Con un main branch attivo come il nostro, non volevamo affrontare l'instabilità del CI. Questa volta, la migrazione è stata semplice (#26247): sostituire i runner buildjet-*vcpu-ubuntu-2204 con blacksmith-*vcpu-ubuntu-2404 in 28 file di workflow, sostituire le azioni cache e setup-node, e regolare le allocazioni di vCPU. L'unico vero problema che abbiamo riscontrato è stato il problema della cache corrotta con i file .next (maggiore dettaglio nella sezione caching).

Una volta aggiornati, abbiamo notato un immediato miglioramento della velocità di 2 volte per il recupero della cache. Con yarn install che pesa 1.2GB, questo ci fa risparmiare circa 22 secondi su ogni lavoro che necessita di yarn install, che è la maggior parte di essi. Stiamo anche sfruttando il caching dei contenitori di Blacksmith per i servizi utilizzati in molti lavori (Postgres, Redis, Mailhog). Il lavoro Initialize containers è ora sceso oltre il 50%, da 20 secondi a 9 secondi.

jobs:
  build:
    name: Build Web App
    runs-on: blacksmith-4vcpu-ubuntu-2404
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/yarn-install
      - uses

jobs:
  build:
    name: Build Web App
    runs-on: blacksmith-4vcpu-ubuntu-2404
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/yarn-install
      - uses

jobs:
  build:
    name: Build Web App
    runs-on: blacksmith-4vcpu-ubuntu-2404
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/yarn-install
      - uses

Cosa abbiamo dovuto sistemare

I runner di Blacksmith espongono il numero di CPU della macchina host piuttosto che le vCPUs allocate (tipicamente 4). Playwright, essendo utile, avrebbe generato lavoratori per corrispondere, causando contesa di risorse e fallimenti di test instabili. Dovevamo semplicemente limitare esplicitamente i lavoratori con --workers=4 per corrispondere alla nostra reale allocazione.

Abbiamo anche scoperto che un'elevata parallelismo esponeva condizioni di gara nella nostra logica di pulizia dei test. I test di integrazione che funzionavano bene sequenzialmente collidivano quando eseguiti in parallelo, producendo errori idempotencyKey. La soluzione ha richiesto di ripensare a come i test si puliscono dopo se stessi: interrogando le prenotazioni per eventTypeId piuttosto che tracciando ID di prenotazione individuali, che perdevano prenotazioni create indirettamente dal codice in fase di test (#26269, #26278, #26283).

Anche i nostri test di screenshot avevano bisogno di aggiustamenti (#26247). Diversi runner hanno sottili differenze di rendering dei caratteri, quindi abbiamo aggiunto deviceScaleFactor: 1 per un rendering coerente e abbiamo leggermente aumentato la nostra soglia di differenza da 0.05 a 0.07 per tenere conto delle variazioni di antialiasing senza mascherare vere regressioni.

Muoversi più velocemente espone spesso collegamenti nascosti nel tuo sistema. Il collegamento è sempre stato lì; non potevi semplicemente vederlo a velocità inferiori. Risolvere questi problemi è ciò che consente miglioramenti sostenibili della velocità.

Principio 1: Accorciare il Percorso Critico con il Design delle Dipendenze

Per noi, un grosso problema era che il lint stava bloccando cose che non avrebbe dovuto bloccare. Quando i tuoi test E2E richiedono 7-10 minuti, bloccarli su 2-3 minuti di controlli di lint e tipo rende l'intero workflow più lungo. Il lint finirà molto prima che E2E lo faccia, quindi perché non farli eseguire in parallelo?

Decouplare suite di test non correlate

I nostri test di integrazione e la suite principale E2E non necessitano della build API v2, quindi abbiamo rimosso quella dipendenza (#26170). Al contrario, i test API v2 E2E non necessitano della build web principale. Ogni suite di test ora dipende solo da ciò di cui ha realmente bisogno, consentendo il massimo parallelismo.

Consolidare i lavori di preflight

Avevamo lavori separati per la rilevazione delle modifiche ai file (changes) e il controllo delle etichette E2E (check-label). Ogni lavoro ha un overhead di avvio: accendere un runner, controllare il codice, ripristinare il contesto. Combinando questi in un unico lavoro prepare, abbiamo eliminato l'overhead ridondante (#26101). Siamo andati oltre e abbiamo spostato i nostri passi di installazione delle dipendenze nel lavoro prepare anch'esso, risparmiando altri 20 secondi per workflow eliminando un'altro confine di lavoro (#26320).

Rendere i report non bloccanti

I report dei test E2E sono preziosi, ma non dovrebbero rimanere sul percorso critico. Abbiamo spostato i lavori merge-reports, publish-report e cleanup-report in un workflow separato attivato da workflow_run (#26157). Ora gli ingegneri possono ripetere immediatamente i lavori falliti senza aspettare che la generazione del report sia completata.

name: E2E Report

on:
  workflow_run:
    workflows: ["PR Update"]
    types

name: E2E Report

on:
  workflow_run:
    workflows: ["PR Update"]
    types

name: E2E Report

on:
  workflow_run:
    workflows: ["PR Update"]
    types

La regola è semplice: essere permissivi riguardo all'inizio dei lavori E2E il prima possibile. Non bloccare lavori a lunga durata su prerequisiti a breve durata a meno che non ci sia una vera dipendenza. Il tempo muro del tuo CI è determinato dal percorso critico, e ogni dipendenza non necessaria estende quel percorso.

Principio 2: Il Caching è Re (Ma Solo Se Funziona)

Un caching efficace richiede chiavi che si invalidano quando dovrebbero, rimangono stabili quando dovrebbero, e un archivio cache che rimanga sano nel tempo.

Fare uso di lookup-only

Il nostro miglioramento del caching più drammatico è venuto da una semplice osservazione: stavamo scaricando circa 1.2GB di dati cache anche quando non dovevamo installare nulla. La cache esisteva, il colpo era riuscito, ma stavamo comunque pagando il costo del download prima di scoprire che potevamo saltare il passo di installazione.

La soluzione è stata utilizzare controlli della cache lookup-only (#26314). Abbiamo passaggi dedicati che vengono eseguiti prima di tutti i lavori per garantire che le dipendenze siano installate, quindi nel momento in cui i lavori individuali vengono eseguiti, sappiamo che la cache esiste. Prima di tentare di ripristinare l'intera cache, controlliamo prima se esiste senza scaricare. Se tutte le cache colpiscono, saltiamo l'intero flusso di ripristino e installazione. Niente download, niente installazioni, passiamo direttamente al lavoro effettivo. Questo da solo ha salvato tempo significativo in ogni scenario di colpo della cache, che, con una corretta progettazione della chiave della cache, dovrebbe essere la maggior parte delle esecuzioni.

- 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
- 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
- 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

Abbiamo applicato la stessa logica all'installazione del browser Playwright (#26060). Poiché abbiamo un passaggio dedicato che garantisce che i browser Playwright siano cacheati prima che i lavori di test vengano eseguiti, possiamo utilizzare lo stesso approccio lookup-only. Se la cache di Playwright colpisce, salta completamente l'installazione. Ora controlliamo prima, poi decidiamo se scaricare o meno.

Abbiamo esteso questo modello anche alla nostra cache del database (#26344). Utilizzando controlli lookup-only, possiamo saltare la ricostruzione della cache del DB quando esiste già. Abbiamo anche semplificato la chiave della cache rimuovendo il numero PR e lo SHA, rendendola molto più riutilizzabile tra le esecuzioni.

Correttezza della cache

La correttezza della cache è importante quanto la velocità della cache. Abbiamo scoperto che il nostro modello glob **/node_modules/ stava catturando apps/web/.next/node_modules/, che è l'output di build di Next.js contenente symlink e file generati che cambiano durante le build. Questo ha portato a archivi cache corrotti con errori di estrazione tar (#26268). La soluzione è stata un semplice modello di esclusione, ma trovarlo ha richiesto di capire esattamente cosa stava venendo cache e perché.

Abbiamo anche implementato la pulizia automatica della cache (#26312). Quando un PR viene fuso o chiuso, ora eliminiamo le cache di build associate. Questo mantiene il nostro archivio cache snello e previene l'accumulo di voci obsolete che non saranno mai più utilizzate. Abbiamo semplificato il nostro formato della chiave della cache nel processo, rimuovendo segmenti non necessari come runner.os e node_version che aggiungevano complessità senza migliorare i tassi di colpo.

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"
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"
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

Ci siamo aggiornati a Next.js 16 (#26093), che ha ridotto le nostre build da oltre 6 minuti a circa 1 minuto (ringraziamenti al team di Next.js per i grandi miglioramenti). Con il Turbo Remote Caching abilitato, le build successive richiedono solo 7 secondi per essere recuperate dalla cache rispetto al minuto circa per una build completa.

Abbiamo anche abilitato il Turbo Remote Caching per i nostri test E2E API v2 (#26331). In precedenza, ogni shard E2E ricostruiva i pacchetti della piattaforma da zero. Ora ogni shard beneficia della cache remota di Turbo, e abbiamo ottimizzato la compilazione di TypeScript di Jest abilitando isolatedModules e disabilitando i diagnostici nel CI, il che velocizza significativamente il tempo di avvio dei test.

Principio 3: Scalare i Test con lo Sharding

Quando hai oltre 1.100 casi di test distribuiti su 82 file di test E2E, eseguirli sequenzialmente è una prestazione non sfruttata. Lo sharding dei test, che divide la tua suite di test su più runner in parallelo, è il modo più diretto per ridurre il tempo del muro per suite di test grandi. Avevamo già lo sharding in atto per la nostra suite principale E2E, ma l'abbiamo aumentato da 4 a 8 shard (#26342), riducendo il tempo totale della suite E2E di un altro minuto. I nostri test E2E API v2 stavano ancora girando come un singolo lavoro.

Abbiamo suddiviso i nostri test E2E API v2 in 4 lavori paralleli utilizzando l'opzione --shard incorporata di Jest (#26183). Ogni shard viene eseguito in modo indipendente con i propri servizi Postgres e Redis, e gli artefatti sono nominati in modo univoco per shard. Ciò che in precedenza richiedeva oltre 10 minuti come lavoro singolo ora si completa significativamente più velocemente con il lavoro distribuito su quattro runner.

- 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
- 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
- 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

Lo sharding su larga scala evidenzia anche problemi di infrastruttura che potresti non notare con l'esecuzione sequenziale. Quando più lavori cercano di popolare la stessa cache del database simultaneamente, si ottengono condizioni di gara. Abbiamo risolto questo creando un lavoro dedicato setup-db che viene eseguito prima di tutti i lavori di test E2E e di integrazione (#26171). Questo singolo lavoro popola la cache del database una volta, e tutti i lavori a valle ripristinano da essa. Niente gare, nessun lavoro duplicato.

Abbiamo anche creato un workflow dedicato per i test unitari API v2, separandoli dalla suite principale di test (#26189). Questo consente loro di essere eseguiti in parallelo ad altri controlli piuttosto che competere per le risorse in un lavoro di test monolitico.

Il Effetto Composto

Nessuna di queste modifiche in isolamento avrebbe ridotto il nostro tempo di CI del 83%. I guadagni arrivano dalla loro combinazione.

Dipendenze intelligenti permettono ai lavori di iniziare prima. Un caching efficace significa che quei lavori spendono meno tempo nella configurazione. Lo sharding significa che l'esecuzione effettiva dei test avviene in parallelo. Insieme, comprimono il percorso critico da ogni direzione.

L'investimento si accumula nel tempo. Ogni PR ora ottiene feedback più veloce. Gli ingegneri rimangono in flusso più a lungo. La tentazione di raggruppare le modifiche diminuisce, il che significa differenze più piccole e più revisionabili. La qualità del codice migliora perché il ciclo di feedback è più stretto.

Continueremo a investire nelle prestazioni del CI perché i ritorni sono reali e misurabili. L'obiettivo non è la velocità per il suo stesso bene; è consentire agli ingegneri di spedire software di qualità più velocemente. Quando il CI è veloce, muoversi rapidamente e mantenere elevati standard funzionano insieme piuttosto che contro di loro.

Pull Request Riferite

Fondazione: Migrazione da Buildjet a Blacksmith

  • #26247 - Migrare i workflow di GitHub da Buildjet a Blacksmith

Stabilità del Test

  • #26269 - Risolvere i test e2e instabili con sessioni utente isolate

  • #26278 - Stabilizzare i test e2e con localizzatori a portata limitata e orari deterministici

  • #26283 - Migliorare l'isolamento dei test per i test e2e di tipo evento gestito

Turbo Remote Caching

  • #26093 - Aggiornare a Next 16 (le build ridotte da 6+ minuti a ~1 minuto)

  • #26331 - Abilitare il Turbo Remote Caching (le build successive richiedono 7s)

Dipendenze di Lavoro e Ottimizzazione del Workflow

  • #26101 - Consolidare i lavori di cambiamento e controllo etichetta in prepare

  • #26170 - Decouplare i test non API v2 dalla build API v2

  • #26157 - Rendere i lavori di report E2E non bloccanti spostandoli in un workflow separato

  • #26320 - Spostare il lavoro delle dipendenze nel passo del lavoro prepare per risparmiare ~20s per workflow

Fare uso di lookup-only

  • #26314 - Utilizzare il controllo della cache lookup-only per saltare i download delle dipendenze

  • #26060 - Saltare l'installazione di Playwright con colpo della cache

  • #26344 - Utilizzare lookup-only per la cache del DB e semplificare la chiave della cache

Correttezza della cache

  • #26268 - Escludere .next/node_modules dalla cache yarn per prevenire corruzione

  • #26312 - Eliminare le voci di cache-build alla chiusura del PR

Sharding dei Test e Infrastruttura

  • #26342 - Aumentare gli shard E2E principali da 4 a 8 (risparmia 1 minuto)

  • #26183 - Shardare i test E2E API v2 in 4 lavori paralleli

  • #26171 - Aggiungere lavoro dedicato setup-db per eliminare le condizioni di gara della cache

  • #26189 - Creare un nuovo workflow per test unitari API v2

Inizia subito gratuitamente con Cal.com!

Sperimenta una programmazione e produttività senza interruzioni senza spese nascoste. Iscriviti in pochi secondi e inizia a semplificare la tua programmazione oggi, senza bisogno di carta di credito!