A CI Drift Gate for dbt: Fail the PR, Feed the Badge, Keep the Trail
A pull request retypes `order_total` from a numeric to a string, and every test in the dbt project still passes — because the tests never encoded what the column *means*, only what it does. A nastier variant: a PR *deletes* the `accepted_values` test on `order_status`, and CI goes green precisely because the assertion is gone. Branch-vs-main tooling can't flag either one as a violation, because both branches are internally consistent; the thing being violated lives outside the repo. That is the difference in comparison target this gate exists for: it checks the PR against the *governed model* — the agreed meaning — so the retype surfaces as `field-type-drift` and the deleted test as `enum-constraint-removed`, each failing the build with a comment explaining exactly which governed agreement it breaks. This article builds that gate with CoreModels: the audit endpoint CI calls, a portable curl+jq version, the ready-made GitHub Action, the README badge, the rolling history behind it, the re-audit that catches drift in the other direction, and the scheduled heartbeat that keeps the whole loop honest between pull requests.
A CI Drift Gate for dbt: Fail the PR, Feed the Badge, Keep the Trail
A pull request retypes order_total from a numeric to a string, and every test in the dbt project still passes — because the tests never encoded what the column means, only what it does. A nastier variant: a PR deletes the accepted_values test on order_status, and CI goes green precisely because the assertion is gone. Branch-vs-main tooling can't flag either one as a violation, because both branches are internally consistent; the thing being violated lives outside the repo. That is the difference in comparison target this gate exists for: it checks the PR against the governed model — the agreed meaning — so the retype surfaces as field-type-drift and the deleted test as enum-constraint-removed, each failing the build with a comment explaining exactly which governed agreement it breaks. This article builds that gate with CoreModels: the audit endpoint CI calls, a portable curl+jq version, the ready-made GitHub Action, the README badge, the rolling history behind it, the re-audit that catches drift in the other direction, and the scheduled heartbeat that keeps the whole loop honest between pull requests.
The endpoint and the one number that matters
CI calls the machine-to-machine surface, which accepts CoreModels user API keys:
POST https://coremodels.example.com/v1/{projectId}/integrations/dbt/audit
Authorization: Bearer $TOKEN
Content-Type: application/json
{ "artifacts": { "manifest": "<manifest.json>" }, "recordHistory": true }
The route is read-only and needs only Viewer access — a CI key never needs write permission, because the audit never writes. Responses on this surface are wrapped in the standard API envelope, so the report lives under data. The contract is one line: data.errorCount > 0 means the PR violates governed meaning — fail the build. Error findings are drift against the governed model (a removed field, a retyped column, a narrowed enum, a flipped contract) or hard conformance violations; warnings and infos are advisory unless you choose to gate on them too.
recordHistory: true appends the run to the project's rolling audit trail with trigger ci — that is what powers the badge and the history view. It is opt-in: leave it false and the audit is a pure query.
The portable version: curl + jq
Any CI system that can run a shell can gate on this. Compile the manifest, build the body, post, check the count:
# 1. Produce the manifest for the PR's code — no warehouse connection needed
dbt deps
dbt parse
# 2. Build the request body without passing the manifest through argv
jq -n --rawfile manifest target/manifest.json \
'{artifacts: {manifest: $manifest}, recordHistory: true}' > audit-request.json
# 3. Call the audit
http_status=$(curl -sS -o audit-response.json -w "%{http_code}" \
-X POST "https://coremodels.example.com/v1/$PROJECT_ID/integrations/dbt/audit" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @audit-request.json)
[ "$http_status" = "200" ] || { echo "audit call failed: HTTP $http_status"; exit 1; }
# 4. Gate on errorCount; print the human report on failure
error_count=$(jq -r '.data.errorCount' audit-response.json)
if [ "$error_count" -gt 0 ]; then
jq -r '.data.markdown' audit-response.json
echo "This change violates governed meaning ($error_count error findings)."
exit 1
fi
dbt parse is the cheapest way to produce a manifest in CI — no adapter connection, no profiles secrets beyond what dbt itself needs to parse. The data.markdown field is a complete, formatted report; data.findings is the same content machine-readable, and data.driftedObjects lists the dbt-native identities of everything that moved.
The GitHub Action
A ready-made composite action ships with CoreModels — vendor it into your repo as .github/actions/coremodels-audit. The workflow:
# .github/workflows/coremodels-audit.yml
name: CoreModels Schema Audit
on:
pull_request:
paths: ["models/**", "seeds/**", "snapshots/**", "dbt_project.yml", "**/schema.yml", "**/*.sql"]
permissions:
contents: read
pull-requests: write
jobs:
schema-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- name: Compile dbt manifest
run: |
pip install dbt-core dbt-duckdb # swap dbt-duckdb for your adapter
dbt deps
dbt parse
- name: CoreModels Schema Audit
uses: ./.github/actions/coremodels-audit
with:
api-url: ${{ vars.COREMODELS_API_URL }}
api-key: ${{ secrets.COREMODELS_API_KEY }}
project-id: ${{ vars.COREMODELS_PROJECT_ID }}
manifest-path: target/manifest.json
fail-on: error # 'warning' to gate harder
record-history: "true" # feeds the history trail + badge
Setup is one secret and two variables: the repo secret COREMODELS_API_KEY (a CoreModels user API key — Viewer access suffices), and repo variables COREMODELS_API_URL and COREMODELS_PROJECT_ID (the 32-character hex id of the governing project).
Beyond the gate itself, the action does the PR hygiene you'd otherwise script by hand: it writes the markdown report into the job summary, posts it as a sticky PR comment (updated in place on each push rather than stacking, keyed by an HTML marker), emits one ::error or ::warning annotation per error- and warning-severity finding so failures land on the Checks tab with their finding code as the title, and exposes outputs — error-count, warning-count, drifted-objects, report-path, report-json-path — for downstream steps. An optional catalog-path input adds catalog.json for warehouse-real types, and fail-on: warning tightens the gate to fail on warnings as well.
The badge
The badge lives on the same API-key surface:
GET https://coremodels.example.com/v1/{projectId}/integrations/dbt/badge
Authorization: Bearer $TOKEN
It returns a self-contained shields-style SVG (image/svg+xml) rendered from the latest recorded audit run: green clean, yellow warnings only, red errors, gray no recorded runs yet. Because it reflects the most recent recorded run, a badge is only as fresh as your recordHistory discipline — which is exactly why the action records by default.
The rolling history
Every recorded run — CI audits, interactive audits that opted in, and re-audits — lands in a rolling per-project trail, readable on the interactive surface with a normal login token:
GET https://coremodels.example.com/graph/integrations/dbt/history/{projectId}
Authorization: Bearer $TOKEN
Each run record carries a timestamp, its trigger (ci, audit, reaudit, or scheduled), the three severity counts, per-code finding counts, and the artifact fingerprint. That last field is quietly useful: two runs with the same fingerprint audited byte-identical artifacts, so any difference in findings came from the governed model changing, not the estate. Note the surface split — reaudit and history live on the interactive surface only; the v1 API-key surface carries audit and badge.
Re-audit: drift's other direction
The CI gate catches the estate moving away from the governed model. The opposite also happens: someone changes governed meaning in CoreModels on Tuesday, and the dbt project — untouched since Monday — is now out of conformance with nobody the wiser until the next PR. The reaudit verb covers that window:
POST https://coremodels.example.com/graph/integrations/dbt/reaudit/{projectId}
Authorization: Bearer $TOKEN
Content-Type: application/json
{}
No artifacts needed: it runs the same audit engine over the estate snapshot stored at import time against the current governed model, and it always records its run in the history. One prerequisite: the snapshot must have been stored. On very large estates (encoded snapshot above roughly 1.5 MB) import reports snapshotStored: false with a lossiness record — fresh-artifact CI audits keep working, but reaudit has nothing stored to run against.
The heartbeat
PRs are event-driven; governance changes are not. CoreModels includes a scheduled server-side re-audit — a config-gated background worker (off by default) that periodically re-audits opted-in projects against their stored snapshots and records the runs into the history. With the heartbeat on, the badge and the trail stay current even in weeks where nobody opens a pull request: a governed-model change that breaks conformance shows up as a red badge on schedule, not whenever the next PR happens to land.
The loop, assembled
Pull request → dbt parse → v1 audit → gate on errorCount, sticky comment, annotations, history record. Merge quiet weeks are covered by the heartbeat re-audit; the badge in the README summarizes the latest recorded state; and the history endpoint gives you drift over time with every run attributable to its trigger. None of it holds a warehouse credential, and only the import that set the estate up in the first place ever needed write access.
Full setup notes, including a demo of a failing and passing run, ship with CoreModels alongside the dbt quickstart.