A dbt project is code, and code changes should be tested before they reach production. The goal of this part: every pull request automatically builds and tests the models it changed, in its own temporary Snowflake schema, and the result shows up as a green tick or a red cross on the pull request. Only then is it merged and picked up by the Airflow DAG from part 8.
Code for this part: .github/workflows/dbt_ci.yml, macros/drop_ci_schemas.sql, ci/profiles.yml. Full project: tpch_analytics on GitHub.
The idea: slim CI
Building the whole project on every pull request is slow and costs credits. Slim CI builds only what a change can affect, using two dbt features:
--select state:modified+compares the pull request's project with production's and selects the models whose code changed, plus everything downstream of them (+).--defer --statelets the selected models read unchanged upstream models from production, so CI does not have to rebuild them.
Both need production's manifest.json, dbt's description of the project. The simplest way to get it, with no artifact storage, is to check out the main branch and run dbt parse, which writes the manifest without querying Snowflake.
What state:modified+ actually selects
I tested the selection on the project. Adding a column to the monthly revenue mart selects only that model:
bashdbt ls --select state:modified+ --state prod-state --resource-type modeltpch_analytics.marts.finance.agg_monthly_revenueAdding a column to stg_tpch__orders instead selects it and its five downstream models, 6 of the 13:
tpch_analytics.marts.finance.agg_monthly_revenue
tpch_analytics.marts.core.dim_customers
tpch_analytics.marts.core.fct_order_items
tpch_analytics.marts.core.fct_orders
tpch_analytics.intermediate.int_order_items__enriched
tpch_analytics.staging.tpch.stg_tpch__ordersWhy --defer matters
When I first ran that second selection in a fresh CI schema without --defer, it failed:
Done. PASS=5 WARN=0 ERROR=2 SKIP=14 NO-OP=0 REUSED=0 TOTAL=21The selected models referenced stg_tpch__customers and stg_tpch__line_items, which were not selected and so did not exist in the empty CI schema. With --defer --state prod-state, dbt points those references at the production tables instead:
Done. PASS=21 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=21These outputs come from the local DuckDB version of the project used to test the series; on Snowflake the same commands select and build the same models.
Step 1: ci and prod targets
Add two targets to ci/profiles.yml next to dev from part 4. Both use the key pair; the CI schema name comes from an environment variable set to the pull request number, so parallel pull requests never collide.
ci/profiles.yml (continued) ci: # GitHub Actions: one schema per pull request
type: snowflake
account: "{{ env_var('SNOWFLAKE_ACCOUNT') }}"
user: AIRFLOW_SVC
private_key_path: "{{ env_var('SNOWFLAKE_PRIVATE_KEY_PATH') }}"
private_key_passphrase: "{{ env_var('SNOWFLAKE_PRIVATE_KEY_PASSPHRASE') }}"
role: TRANSFORMER
warehouse: TRANSFORM_WH
database: ANALYTICS
schema: "{{ env_var('DBT_CI_SCHEMA', 'CI') }}"
threads: 8
prod: # used by CI to read the production manifest
type: snowflake
account: "{{ env_var('SNOWFLAKE_ACCOUNT') }}"
user: AIRFLOW_SVC
private_key_path: "{{ env_var('SNOWFLAKE_PRIVATE_KEY_PATH') }}"
private_key_passphrase: "{{ env_var('SNOWFLAKE_PRIVATE_KEY_PASSPHRASE') }}"
role: TRANSFORMER
warehouse: TRANSFORM_WH
database: ANALYTICS
schema: PROD
threads: 8For simplicity this reuses the AIRFLOW_SVC user. In a larger team, create a separate CI service user with only the TRANSFORMER role, so a leaked CI secret cannot load data.
Step 2: clean up after every run
Each run creates schemas such as CI_PR_42_STAGING and CI_PR_42_MARTS. This macro drops exactly the schemas the project built for the current run. It refuses to run on any target except ci, and only touches schemas starting with the CI schema name:
macros/drop_ci_schemas.sql{# Drop every schema this project built for the current CI run
(e.g. CI_PR_42_STAGING, CI_PR_42_MARTS, CI_PR_42_SNAPSHOTS). #}
{% macro drop_ci_schemas() %}
{% if target.name != 'ci' %}
{{ exceptions.raise_compiler_error("drop_ci_schemas only runs on the ci target") }}
{% endif %}
{% set schemas = [] %}
{% for node in graph.nodes.values() %}
{% if node.resource_type in ['model', 'snapshot', 'seed']
and node.schema | upper is not in schemas
and (node.schema | upper).startswith(target.schema | upper) %}
{% do schemas.append(node.schema | upper) %}
{% endif %}
{% endfor %}
{% for schema in schemas %}
{% do adapter.drop_schema(api.Relation.create(database=target.database, schema=schema)) %}
{{ log("Dropped schema " ~ schema, info=True) }}
{% endfor %}
{% endmacro %}Dropped schema CI_PR_7_INTERMEDIATE
Dropped schema CI_PR_7_MARTS
Dropped schema CI_PR_7_STAGING
Dropped schema CI_PR_7_SNAPSHOTSThe snapshot schema appears because part 7 configured the snapshot with schema='snapshots'. With the older target_schema setting, CI runs would have written test snapshots straight into the production snapshot table.
Step 3: the GitHub Actions workflow
In the companion repository the dbt project lives in a tpch_analytics/ subfolder, so its copy of this workflow sets defaults: run: working-directory: tpch_analytics and prefixes the paths with tpch_analytics/. The version below assumes the dbt project is at the root of its repository.
.github/workflows/dbt_ci.ymlname: dbt CI
on:
pull_request:
branches: [main]
paths:
- "models/**"
- "macros/**"
- "snapshots/**"
- "tests/**"
- "dbt_project.yml"
- "packages.yml"
concurrency:
group: dbt-ci-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
dbt-build-changed:
runs-on: ubuntu-latest
env:
SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
SNOWFLAKE_PRIVATE_KEY_PASSPHRASE: ${{ secrets.SNOWFLAKE_PRIVATE_KEY_PASSPHRASE }}
SNOWFLAKE_PRIVATE_KEY_PATH: ${{ github.workspace }}/rsa_key.p8
DBT_CI_SCHEMA: CI_PR_${{ github.event.pull_request.number }}
DBT_PROFILES_DIR: ${{ github.workspace }}/ci
steps:
- name: Check out the pull request
uses: actions/checkout@v4
- name: Check out main (the production version)
uses: actions/checkout@v4
with:
ref: main
path: main-branch
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dbt
run: pip install -r requirements.txt
- name: Write the Snowflake private key
run: echo "${{ secrets.SNOWFLAKE_PRIVATE_KEY }}" > rsa_key.p8
- name: Build the production manifest from main
working-directory: main-branch
run: |
dbt deps
dbt parse --target prod
cp -r target ../prod-state
- name: Build and test only what changed
run: |
dbt deps
dbt build --target ci --select state:modified+ --defer --state prod-state
- name: Drop the CI schemas
if: always()
run: dbt run-operation drop_ci_schemas --target ciPoints worth noticing
paths: the job runs only when dbt files change, not for README edits.concurrency: a new push to the same pull request cancels the previous run instead of queuing behind it.working-directory: main-branch: dbt is run from the main checkout, so its manifest describes production, whileDBT_PROFILES_DIRstill points at the pull request'sci/folder.if: always(): the clean-up runs even when the build fails.
Step 4: secrets and branch protection
In the repository settings, add three secrets: SNOWFLAKE_ACCOUNT, SNOWFLAKE_PRIVATE_KEY (the full contents of rsa_key.p8) and SNOWFLAKE_PRIVATE_KEY_PASSPHRASE. Then add a branch protection rule on main requiring the dbt-build-changed check to pass before merging. Without that rule, CI is only advice.
Step 5: deploying to Airflow
After a merge, Airflow needs the new code and a fresh manifest (part 8 reads target/manifest.json). How you ship code depends on your Airflow setup (a git-sync sidecar, a new container image, or a file sync to a managed service), but the deployment step always ends with:
bashcd /opt/airflow/dbt/tpch_analytics
/opt/airflow/dbt_venv/bin/dbt deps
/opt/airflow/dbt_venv/bin/dbt parse --target prodThe next scheduled run then builds production with the merged changes. For changes to incremental models, remember part 7: if existing rows must be recalculated, trigger a one-off --full-refresh for that model rather than waiting for the daily run.
Next
Code is now tested before it ships and runs daily without anyone touching it. The final part covers living with it: what the pipeline costs in Snowflake, how to find slow models, how to spot failures from dbt's own run results, and a runbook for the mornings when something breaks.
The series
- The stack and what we will build
- Setting up Snowflake for dbt
- Loading raw data into Snowflake
- Your first dbt project on Snowflake
- Modeling with dbt: staging to marts
- Testing and documenting with dbt
- Incremental models and snapshots
- Orchestrating dbt with Airflow
- CI/CD for dbt with GitHub Actions (this post)
- Running the pipeline in production
All parts: Data Pipeline Series. Every file shown in this series is in the companion project tpch_analytics on GitHub; clone it to follow along, or run it locally on DuckDB without a Snowflake account.
Comments
Post a Comment