Automating with CI: GitHub Actions for dbt
A screenshot of passing tests isn't CI. Elin wires up GitHub Actions to run dbt build on every pull request, so a broken model can never merge silently.
Part 4 of 5— GitOps with Snowflake & dbt
- Why DevOps for Data?
- Environment Setup: dbt + Snowflake Basics
- Version Control: Monorepo & CODEOWNERS
- Automating with CI: GitHub Actions for dbt
- Full GitOps Flow: Deploy, Promote, Rollback
Two weeks after CODEOWNERS went live, Elin caught herself doing something she almost didn’t notice: she opened a pull
request, ran dbt test on her own laptop, screenshotted the terminal output, and pasted it into the PR description as
proof it passed.
Mikael saw it and laughed. “You’re doing CI. By hand. With a screenshot.”
Elin, Johan, Mikael, and Nordicvinter Retail are fictional — the story continues from Parts 1–3. The lessons are real.
He was right — and it’s exactly the failure mode CI exists to remove. A screenshot can be stale, faked, or run against the wrong branch entirely. What Nordicvinter needed was for a machine to run those tests automatically, in a clean environment, every time — not to trust that a human remembered to, and remembered correctly. That’s the last gap left open in Part 3: the process still leaned on memory exactly where it hurt most.
What CI Actually Does
Continuous Integration means: every time code changes, an automated process runs checks against it — before a human
even has to look. For a dbt project, that means running dbt build (which compiles, runs, and tests your models)
automatically whenever a Pull Request is opened or updated.
The payoff is simple but significant: a broken model can’t merge silently. If a test fails, the PR is blocked — visibly, immediately, before it ever touches production.
Your First GitHub Actions Workflow
GitHub Actions is GitHub’s built-in automation system. Workflows are defined in YAML files living in
.github/workflows/. Here’s a first, minimal one for a dbt project:
name: dbt CI
on:
pull_request:
branches: [main]
jobs:
dbt-build:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dbt
run: pip install dbt-snowflake
- name: Run dbt build
run: dbt build --target ci
env:
DBT_SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
DBT_SNOWFLAKE_USER: ${{ secrets.SNOWFLAKE_USER }}
DBT_SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_PASSWORD }}
DBT_SNOWFLAKE_ROLE: ${{ secrets.SNOWFLAKE_ROLE }}
DBT_SNOWFLAKE_WAREHOUSE: ${{ secrets.SNOWFLAKE_WAREHOUSE }}
DBT_SNOWFLAKE_DATABASE: ${{ secrets.SNOWFLAKE_DATABASE }}
Reading this like someone who’s never seen a YAML pipeline before:
on: pull_request— this workflow triggers whenever a PR is opened or updated againstmain. Nobody has to remember to kick it off.jobs→steps— a sequential list of things to do, top to bottom, on a fresh virtual machine GitHub spins up just for this run.uses:vsrun:—usespulls in a pre-built, reusable action (checking out code, setting up Python);runexecutes a raw shell command.dbt build— this is dbt’s combined command: it compiles models, runs them, and runs tests, all in one pass. For CI, this is generally preferable to runningdbt runanddbt testas two separate steps.
One missing piece would bite you on the very first run: in Part 2,
profiles.yml deliberately lived in ~/.dbt/, outside the repo — but that fresh virtual machine has no home-directory
profile. The fix is a second profiles.yml, committed at the root of the project, that’s safe to share because it
contains no secrets — only env_var lookups that resolve from the workflow’s environment at runtime:
# profiles.yml — in the repo, and safe there: every value comes from the environment
nordicvinter_project:
target: ci
outputs:
ci:
type: snowflake
account: "{{ env_var('DBT_SNOWFLAKE_ACCOUNT') }}"
user: "{{ env_var('DBT_SNOWFLAKE_USER') }}"
password: "{{ env_var('DBT_SNOWFLAKE_PASSWORD') }}"
role: "{{ env_var('DBT_SNOWFLAKE_ROLE') }}"
database: "{{ env_var('DBT_SNOWFLAKE_DATABASE') }}"
warehouse: "{{ env_var('DBT_SNOWFLAKE_WAREHOUSE') }}"
schema: ci_build
threads: 4
dbt checks the current working directory for profiles.yml before falling back to ~/.dbt/, so the CI machine finds
this one automatically — and your laptop keeps using your personal profile from Part 2.
Secrets: Never Put Credentials in the YAML
Notice nothing in either file is an actual password — everything sensitive comes from ${{ secrets.SOMETHING }}. These
are configured in the repository’s Settings → Secrets and variables → Actions, stored encrypted, and only exposed to
workflow runs — never visible in logs, never visible in the YAML itself.
This is the same principle as profiles.yml living outside the project and .env being gitignored from Parts 2 and 3 —
credentials never travel with the code, in any form, at any stage. If you’ve worked with GitHub Actions OIDC patterns
for cloud providers before, the same instinct applies here: prefer short-lived, scoped credentials over long-lived
static secrets wherever your setup allows it, and rotate anything static on a regular schedule.
Making the Safety Net Tangible
Here’s the moment that makes CI click for beginners: watching it catch something real.
The project already has tests — the not_null and unique checks on sales_orders declared in
Part 2’s schema.yml. They’ve been green all along, and with the
workflow above they now run on every PR. But Part 2 also left a landmine armed: order 1009’s split payment quietly
double-counts July 10th in org_revenue — the view reads 8,730 for a day that earned 4,630 — and no test asserts
anything about that view.
Time to write the one that does. Alongside YAML column checks, dbt supports singular tests: a .sql file in the
project’s tests/ folder expressing a rule — if the query returns any rows, the test fails. Create
tests/assert_org_revenue_matches_orders.sql:
-- The revenue view must account for every order exactly once, so its
-- grand total has to equal the total of the orders it's built from.
-- Any row returned = test failure.
with revenue as (
select sum(daily_revenue) as total from {{ ref('org_revenue') }}
),
orders as (
select sum(order_total) as total from {{ ref('sales_orders') }}
)
select revenue.total as revenue_total, orders.total as orders_total
from revenue, orders
where revenue.total != orders.total
Open a PR with just this new file. Watch GitHub Actions run automatically, watch dbt build fail — revenue_total
24,715 against orders_total 20,615, the fan-out made visible — and watch the PR get a red ✗ with the merge button
disabled.
The fix belongs in the same PR, and it’s satisfyingly small. Johan’s left join kept unpaid orders in the revenue —
right idea — but once revenue is summed from the orders themselves, the payments join contributes nothing except the
duplication. Delete it:
-- models/accounting_dm/org_revenue.sql
select
o.order_date,
sum(o.order_total) as daily_revenue
from {{ ref('sales_orders') }} o
group by o.order_date
Push, watch CI turn green, merge. Nobody caught this by reading the SQL carefully — it had been sitting in production since Part 1. A one-file test surfaced it, and from now on that rule is checked on every Pull Request, forever. The safety net caught it structurally — which is the entire point.
A Note on Scoping CI (Preview for Part 5)
As a monorepo grows — recall the trade-offs from
Part 3 — running dbt build against every model
on every PR gets slow and expensive. A common refinement, worth knowing exists even if we don’t build it fully today,
is scoping CI to only what changed:
dbt build --select state:modified+
This tells dbt to build only models that changed, plus everything downstream of them — comparing against a stored
“state” of the last known-good run. A PR that only touches /models/finance/ doesn’t sit around waiting on unrelated
sales model tests. We’ll pick this back up when we talk about full deployment in Part 5.
What’s Next
When the red ✗ lit up Elin’s pull request, it wasn’t hypothetical: her new revenue test had caught order 1009’s split payment, and July 10th had been reading almost double for weeks — on the leadership dashboard, in production, under everyone’s noses. She felt something she hadn’t expected: relief. Nobody had caught the bug. The machine had. But late that afternoon, after the fix merged with a green check beside it, she watched Mikael open a terminal and run dbt against production by hand, the way he always had. The tests were automated now. The deploy still walked on human legs.
CI now catches broken models before they merge. But catching problems isn’t the same as the full loop — nothing yet actually deploys an approved change to production, promotes it through environments, or tells you what to do if something still slips through. In Part 5, we close that loop: merge-to-main triggering an automatic deploy, promoting dev → staging → prod, and what rollback actually looks like when it’s needed.