Skip to main content

08 - CI/CD for dbt: GitHub Actions, Slim CI with State and Automatic Testing on Pull Request

dbt 16 min CI/CD for dbt: GitHub Actions, Slim CI with State and Automatic Testing on Pull Request How to automate dbt testing and deployment with GitHub...

What do you think about this article?

At a Glance

dbt 16 min CI/CD for dbt: GitHub Actions, Slim CI with State and Automatic Testing on Pull Request How to automate dbt testing and deployment with GitHub...

dbt 16 min

CI/CD for dbt: GitHub Actions, Slim CI with State and Automatic Testing on Pull Request

How to automate dbt testing and deployment with GitHub Actions: Slim CI with --state and --defer to test only modified models (saving up to 90% of CI time), separate schema for test environments and main branch protection.

dbt CI/CD dbt github actions slim CI dbt dbt state defer dbt

Why CI/CD is Fundamental to dbt Projects

A dbt design without CI is a design that breaks in production silently. Without automatic PR testing, every merge is a leap of faith: Does the model compile? Do the tests pass? Are the downstream models still consistent? CI solves this problem automatically running checks on every pull request, before the code reaches main.

The problem with naïve CI is the cost: if you execute dbt build about everything the project at each PR, on a warehouse with 200+ models you can spend 20-30 minutes of computing for every single change. Slim CI solves this: just run the templates modified and their downstream, reducing execution to 1-10% of the total cost.

Concetti Fondamentali: State, Defer e Manifest

Prima di costruire il workflow, è necessario capire tre concetti che rendono possibile lo Slim CI:

manifest.json: Il DAG Serializzato

Ad ogni dbt run o dbt compile, dbt genera un file target/manifest.json. Questo file contiene la rappresentazione completa dello stato del progetto: tutti i modelli, le loro dipendenze, gli hash del codice SQL compilato. È la base per lo Slim CI.

-- Genera il manifest senza eseguire modelli
dbt compile

-- Il manifest viene generato anche da:
dbt run
dbt build
dbt docs generate

-- Struttura semplificata del manifest.json:
-- {
--   "metadata": { "dbt_version": "1.8.x", "generated_at": "..." },
--   "nodes": {
--     "model.my_project.fct_orders": {
--       "unique_id": "model.my_project.fct_orders",
--       "depends_on": { "nodes": ["model.my_project.stg_orders", "..."] },
--       "compiled_code": "SELECT ...",
--       "checksum": { "name": "sha256", "checksum": "abc123..." }
--     }
--   },
--   "sources": { ... },
--   "exposures": { ... }
-- }

--state: Confrontare con il Manifest di Produzione

-- --state indica a dbt dove trovare il manifest di riferimento (produzione)
-- dbt confronta l'hash del codice corrente con quello nel manifest di produzione

-- Seleziona solo i modelli il cui SQL è cambiato rispetto a produzione:
dbt build --select state:modified

-- Seleziona i modelli modificati E tutti i loro downstream (il più comune in CI):
dbt build --select state:modified+

-- Modelli modificati + 2 livelli downstream:
dbt build --select state:modified+2

-- Tipi di modifiche rilevate da state:modified:
-- * SQL del modello cambiato
-- * Config del modello cambiata (materializzazione, tags, ecc.)
-- * Schema.yml del modello cambiato (descrizioni, test)
-- * Macro utilizzata dal modello cambiata

--defer: Usare i Modelli di Produzione per gli Upstream

-- Senza --defer: per testare fct_orders modificata, dbt dovrebbe prima ricreare
-- stg_orders, stg_customers, int_orders, ecc. nel schema CI. Costoso.

-- Con --defer: dbt usa le tabelle di PRODUZIONE per gli upstream non modificati,
-- e crea solo i modelli che hai effettivamente cambiato nello schema CI.

dbt build \
  --select state:modified+ \
  --defer \
  --state ./prod-artifacts \
  --target ci

-- Con --defer:
-- * stg_orders non modificata → dbt usa analytics_prod.staging.stg_orders
-- * fct_orders modificata → dbt ricrea solo fct_orders nello schema ci_pr123
-- Risparmio: 95%+ del compute nella maggior parte dei PR

Setup Completo: GitHub Actions per dbt CI

Step 1: Configurare il profiles.yml per l'Ambiente CI

# profiles.yml
my_project:
  target: dev
  outputs:
    dev:
      type: snowflake
      account: "{{ env_var('SNOWFLAKE_ACCOUNT') }}"
      user: "{{ env_var('SNOWFLAKE_USER') }}"
      password: "{{ env_var('SNOWFLAKE_PASSWORD') }}"
      database: analytics
      warehouse: compute_wh_xs
      schema: "dbt_dev_{{ env_var('DBT_DEVELOPER', 'unknown') }}"
      threads: 4

    # Schema CI unico per ogni PR (evita conflitti tra PR parallele)
    ci:
      type: snowflake
      account: "{{ env_var('SNOWFLAKE_ACCOUNT') }}"
      user: "{{ env_var('SNOWFLAKE_CI_USER') }}"
      password: "{{ env_var('SNOWFLAKE_CI_PASSWORD') }}"
      database: analytics_ci
      warehouse: compute_wh_xs
      schema: "dbt_ci_pr{{ env_var('PR_NUMBER', 'unknown') }}"
      threads: 8

    prod:
      type: snowflake
      account: "{{ env_var('SNOWFLAKE_ACCOUNT') }}"
      user: "{{ env_var('SNOWFLAKE_PROD_USER') }}"
      password: "{{ env_var('SNOWFLAKE_PROD_PASSWORD') }}"
      database: analytics
      warehouse: compute_wh_m
      schema: prod
      threads: 16

Step 2: Salvare il Manifest di Produzione su S3

# .github/workflows/dbt-prod.yml
# Workflow di produzione: salva il manifest dopo ogni deploy su main

name: dbt Production Deploy

on:
  push:
    branches: [main]

jobs:
  dbt_prod:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Python 3.11
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Cache pip
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}

      - name: Install dbt
        run: pip install -r requirements.txt

      - name: dbt deps
        run: dbt deps
        env:
          DBT_PROFILES_DIR: .

      - name: dbt build (produzione completa)
        run: dbt build --target prod
        env:
          DBT_PROFILES_DIR: .
          SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
          SNOWFLAKE_PROD_USER: ${{ secrets.SNOWFLAKE_PROD_USER }}
          SNOWFLAKE_PROD_PASSWORD: ${{ secrets.SNOWFLAKE_PROD_PASSWORD }}

      # Salva il manifest in S3 per lo Slim CI delle PR successive
      - name: Upload manifest to S3
        run: |
          aws s3 cp target/manifest.json \
            s3://my-dbt-artifacts/prod/manifest.json
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_DEFAULT_REGION: eu-west-1

Step 3: Il Workflow Slim CI per Pull Request

# .github/workflows/dbt-ci.yml
# Workflow CI: eseguito su ogni PR verso main

name: dbt Slim CI

on:
  pull_request:
    branches: [main]
    paths:
      - 'models/**'
      - 'macros/**'
      - 'tests/**'
      - 'snapshots/**'
      - 'seeds/**'
      - 'packages.yml'
      - 'dbt_project.yml'

env:
  PR_NUMBER: ${{ github.event.pull_request.number }}

jobs:
  slim_ci:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Checkout PR branch
        uses: actions/checkout@v4

      - name: Setup Python 3.11
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Cache pip packages
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}

      - name: Install dbt
        run: pip install -r requirements.txt

      - name: dbt deps
        run: dbt deps
        env:
          DBT_PROFILES_DIR: .

      # Scarica il manifest di produzione per il confronto state
      - name: Download production manifest from S3
        run: |
          mkdir -p prod-artifacts
          aws s3 cp s3://my-dbt-artifacts/prod/manifest.json \
            prod-artifacts/manifest.json
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_DEFAULT_REGION: eu-west-1

      # Slim CI: build + test solo sui modelli modificati e downstream
      - name: dbt Slim CI Build
        id: dbt_build
        run: |
          dbt build \
            --select state:modified+ \
            --defer \
            --state prod-artifacts \
            --target ci \
            --no-partial-parse
        env:
          DBT_PROFILES_DIR: .
          PR_NUMBER: ${{ github.event.pull_request.number }}
          SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
          SNOWFLAKE_CI_USER: ${{ secrets.SNOWFLAKE_CI_USER }}
          SNOWFLAKE_CI_PASSWORD: ${{ secrets.SNOWFLAKE_CI_PASSWORD }}

      # Commento sul PR con il risultato
      - name: Comment PR with results
        uses: actions/github-script@v7
        if: always()
        with:
          script: |
            const status = '${{ steps.dbt_build.outcome }}' === 'success'
              ? '✅ dbt Slim CI passed'
              : '❌ dbt Slim CI failed';
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `${status}\n\nTarget schema: \`dbt_ci_pr${{ github.event.pull_request.number }}\``
            });

      # Cleanup: elimina lo schema CI dopo il test
      - name: Cleanup CI schema
        if: always()
        run: |
          dbt run-operation drop_schema \
            --args '{"schema_name": "dbt_ci_pr${{ github.event.pull_request.number }}"}'
            --target ci
        env:
          DBT_PROFILES_DIR: .
          SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
          SNOWFLAKE_CI_USER: ${{ secrets.SNOWFLAKE_CI_USER }}
          SNOWFLAKE_CI_PASSWORD: ${{ secrets.SNOWFLAKE_CI_PASSWORD }}

Macro per il Cleanup dello Schema CI

-- macros/admin/drop_schema.sql
-- Usata dal workflow CI per pulizia post-test

{% macro drop_schema(schema_name) %}
    {% set drop_query %}
        DROP SCHEMA IF EXISTS {{ target.database }}.{{ schema_name }} CASCADE
    {% endset %}
    {% do run_query(drop_query) %}
    {{ log("Dropped schema: " ~ schema_name, info=True) }}
{% endmacro %}

Protezione del Branch Main

Il workflow CI ha valore solo se il branch main è protetto e il merge è bloccato finché il CI non passa. Configurazione in GitHub:

# Configurazione Branch Protection (Settings > Branches > main)
# Da fare una volta sola via GitHub UI o API:

# Required status checks:
#   - dbt Slim CI / slim_ci

# Require pull request reviews: 1 reviewer
# Dismiss stale reviews: true
# Require status checks to pass before merging: true
# Require branches to be up to date: true
# Include administrators: true (raccomandato)

# Equivalente via GitHub API:
# gh api repos/OWNER/REPO/branches/main/protection \
#   --method PUT \
#   --field required_status_checks='{"strict":true,"contexts":["dbt Slim CI / slim_ci"]}' \
#   --field required_pull_request_reviews='{"required_approving_review_count":1}'

Pattern Avanzati: Test Selettivi e Notifiche

Eseguire Solo i Test Critici in CI (Ottimizzazione Costo)

# In schema.yml: tagga i test critici
models:
  - name: fct_orders
    columns:
      - name: order_id
        tests:
          - unique:
              config:
                tags: ['critical', 'ci']     # questi test girano sempre in CI
          - not_null:
              config:
                tags: ['critical', 'ci']
      - name: customer_id
        tests:
          - relationships:
              to: ref('dim_customers')
              field: customer_id
              config:
                tags: ['ci']
          - dbt_expectations.expect_column_mean_to_be_between:
              min_value: 1
              max_value: 999999
              config:
                tags: ['slow', 'full-refresh-only']  # NON in CI normale
# Nel workflow CI, esegui solo i test taggati 'ci':
- name: dbt Slim CI Build (solo test critici)
  run: |
    dbt build \
      --select state:modified+ \
      --defer \
      --state prod-artifacts \
      --target ci \
      --exclude tag:slow tag:full-refresh-only

Workflow Nightly: Full Build + Test Lenti

# .github/workflows/dbt-nightly.yml
# Ogni notte esegue il full build con TUTTI i test (inclusi quelli lenti)

name: dbt Nightly Full Build

on:
  schedule:
    - cron: '0 2 * * *'   # 02:00 UTC ogni notte

jobs:
  nightly:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install dbt
        run: pip install -r requirements.txt
      - name: dbt full build + all tests
        run: dbt build --target prod --full-refresh --select tag:daily
        # Include i test lenti esclusi dal CI
      - name: Slack notification on failure
        if: failure()
        uses: rtCamp/action-slack-notify@v2
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_DATA_ALERTS }}
          SLACK_MESSAGE: 'dbt Nightly Build FAILED - check GitHub Actions'
          SLACK_COLOR: danger

Gestione degli Ambienti: Dev, Staging, Prod

# Pattern consigliato per la gestione multi-ambiente
# dbt_project.yml

# Ogni ambiente usa uno schema diverso grazie a generate_schema_name macro
dispatch:
  - macro_namespace: dbt
    search_order: ['my_project', 'dbt']
-- macros/get_custom_schema.sql
-- Override del comportamento default per gestire ambienti multipli

{% macro generate_schema_name(custom_schema_name, node) -%}
    {%- set default_schema = target.schema -%}

    {%- if target.name == 'prod' -%}
        -- In produzione: usa il custom_schema se specificato, altrimenti default
        {%- if custom_schema_name is none -%}
            {{ default_schema }}
        {%- else -%}
            {{ custom_schema_name | trim }}
        {%- endif -%}

    {%- elif target.name == 'ci' -%}
        -- In CI: prefissa tutto con il numero PR per isolamento
        {%- if custom_schema_name is none -%}
            {{ default_schema }}
        {%- else -%}
            {{ default_schema }}_{{ custom_schema_name | trim }}
        {%- endif -%}

    {%- else -%}
        -- In dev: prefissa con il nome del developer
        {%- if custom_schema_name is none -%}
            {{ default_schema }}
        {%- else -%}
            {{ default_schema }}_{{ custom_schema_name | trim }}
        {%- endif -%}
    {%- endif -%}

{%- endmacro %}

requirements.txt per il Progetto dbt

dbt-core==1.8.7
dbt-snowflake==1.8.3      # adatta al tuo warehouse
dbt-utils==1.3.0
dbt-expectations==0.10.4

Pinna sempre le versioni esatte in requirements.txt. Una versione major di dbt Core può introdurre breaking change nel comportamento degli adapter. Aggiorna consapevolmente, non automaticamente.

Monitorare il Costo del CI: Metriche Importanti

Uno degli obiettivi del Slim CI è il risparmio economico. È utile tracciare le metriche del CI nel tempo per quantificare il valore e identificare modelli costosi da ottimizzare:

# .github/workflows/dbt-ci.yml - aggiunta metriche di costo
# Aggiungi questo step al workflow CI per loggare le metriche

      - name: Log CI metrics
        if: always()
        run: |
          echo "PR_NUMBER=${{ github.event.pull_request.number }}"
          echo "MODELS_TESTED=$(cat target/run_results.json | python3 -c "
          import json, sys
          results = json.load(sys.stdin)
          print(len([r for r in results['results'] if r['unique_id'].startswith('model.')]))")"
          echo "TESTS_RUN=$(cat target/run_results.json | python3 -c "
          import json, sys
          results = json.load(sys.stdin)
          print(len([r for r in results['results'] if r['unique_id'].startswith('test.')]))")"
          echo "ELAPSED_SECONDS=$(cat target/run_results.json | python3 -c "
          import json, sys
          results = json.load(sys.stdin)
          print(results['elapsed_time'])")"
-- Query per analizzare il risparmio Slim CI nel tempo
-- (richiede che tu loggi le metriche da qualche parte, es. BigQuery o Snowflake)

SELECT
    pr_number,
    run_date,
    models_in_pr,
    total_project_models,
    ROUND(100.0 * models_in_pr / total_project_models, 1) AS pct_models_tested,
    elapsed_seconds,
    estimated_full_run_seconds,
    ROUND(100.0 * (1 - elapsed_seconds::float / estimated_full_run_seconds), 1) AS time_saved_pct
FROM ci_metrics
ORDER BY run_date DESC
LIMIT 30

-- Risparmio tipico Slim CI su progetto reale (200 modelli, 5 modificati per PR):
-- Full run: 25 minuti | Slim CI: 2-3 minuti → 88-92% di risparmio tempo/costo

Conclusioni e Prossimi Passi

Slim CI è una delle ottimizzazioni più impattanti che puoi fare su un progetto dbt maturo. Riduce il costo del CI da “devo aspettare 30 minuti per ogni PR” a “il check è pronto in 2-3 minuti”, rendendo il workflow molto più fluido per il team.

Gli ingredienti chiave: manifest.json di produzione salvato in S3, --state per il confronto, --defer per evitare di ricreare modelli upstream, schema CI isolati per PR, e branch protection su main.

I prossimi due moduli approfondiscono temi avanzati: il modulo 09 esplora le strategie incrementali avanzate (microbatch, CDC, gestione late-arriving data), e il modulo 10 chiude la serie con dbt in produzione a livello enterprise (governance, Semantic Layer, orchestrazione Airflow).

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects