Airbyte logoAutomation

A Drift Gate for Airbyte: CI, Badges, and the Heartbeat Between Runs

A dbt gate has an obvious trigger: someone opens a pull request, the project compiles, and the compiled artifact is the thing you audit. Airbyte has no build step. A catalog is *discovered*, not compiled, and the schema it describes belongs to a system nobody in the repository controls. So the first question for an Airbyte drift gate is not "which endpoint do I call" — it is "where does `catalog.json` come from, and when".

A Drift Gate for Airbyte: CI, Badges, and the Heartbeat Between Runs

A dbt gate has an obvious trigger: someone opens a pull request, the project compiles, and the compiled artifact is the thing you audit. Airbyte has no build step. A catalog is discovered, not compiled, and the schema it describes belongs to a system nobody in the repository controls. So the first question for an Airbyte drift gate is not "which endpoint do I call" — it is "where does catalog.json come from, and when".

There are two good answers, and they gate different risks.

Version-control the catalog. Export the connection's configured catalog and commit it. A change to the catalog becomes a reviewable diff, and the gate runs on pull requests that touch it. This catches deliberate changes — enabling a stream, changing a sync mode, adding a field — before they merge.

Re-discover on a schedule. Run source discovery against a staging or read-only source in a scheduled job and audit the fresh result. This catches the changes nobody proposed: the upstream team that renamed a column last night.

Most teams want both. The pull-request gate protects intent; the scheduled job protects against surprise. Both call the same endpoint.

The endpoint under the gate

POST https://coremodels.example.com/v1/{PROJECT_ID}/integrations/airbyte/audit
Authorization: Bearer $TOKEN
Content-Type: application/json

{ "artifacts": { "catalog": "<catalog.json content>" }, "recordHistory": true }

This is the machine-to-machine surface, so $TOKEN is a revocable user API key rather than an interactive login — CI never needs a human session. Viewer access to the governing project is sufficient, because the audit never writes. The response is wrapped in the standard envelope, so every field lives under data:

{
  "success": true,
  "error": null,
  "data": {
    "vendor": "airbyte",
    "projectName": "airbyte-connection",
    "errorCount": 0,
    "warningCount": 1,
    "infoCount": 2,
    "codes": { "stream-no-primary-key": 1, "no-cursor-field": 1, "untyped-fields": 1 },
    "driftedObjects": [],
    "fingerprint": "9f2c41ab7d0e5b83",
    "findings": [],
    "markdown": "# airbyte Schema Audit — airbyte-connection\n…",
    "historyRecorded": true,
    "lossiness": []
  }
}

Two integers do the work: data.errorCount is the gate, data.warningCount is the optional tightening. Everything else — codes, driftedObjects, findings, markdown — exists so a failed build can explain itself without anyone opening a browser.

The workflow

name: CoreModels Airbyte Schema Audit

on:
  pull_request:
    paths:
      - "airbyte/catalog.json"
  schedule:
    - cron: "17 6 * * 1-5"
  workflow_dispatch:

jobs:
  audit:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    env:
      COREMODELS_API_URL: ${{ vars.COREMODELS_API_URL }}
      COREMODELS_API_KEY: ${{ secrets.COREMODELS_API_KEY }}
      COREMODELS_PROJECT_ID: ${{ vars.COREMODELS_PROJECT_ID }}
      FAIL_ON: error
    steps:
      - uses: actions/checkout@v4

      - name: Audit the catalog against governed meaning
        run: |
          set -euo pipefail

          test -s airbyte/catalog.json
          jq -e '.streams | length > 0' airbyte/catalog.json > /dev/null

          jq -n --rawfile catalog airbyte/catalog.json \
            '{artifacts: {catalog: $catalog}, recordHistory: true}' > audit-request.json

          status=$(curl -sS -o audit-response.json -w "%{http_code}" -X POST \
            "$COREMODELS_API_URL/v1/$COREMODELS_PROJECT_ID/integrations/airbyte/audit" \
            -H "Authorization: Bearer $COREMODELS_API_KEY" \
            -H "Content-Type: application/json" \
            --data-binary @audit-request.json)

          if [ "$status" != "200" ]; then
            echo "::error::CoreModels audit call failed with HTTP $status"
            head -c 2000 audit-response.json || true
            exit 1
          fi

          if [ "$(jq -r '.success' audit-response.json)" != "true" ]; then
            echo "::error::Audit rejected: $(jq -r '.error.message // "unknown"' audit-response.json)"
            exit 1
          fi

          jq -r '.data.markdown' audit-response.json >> "$GITHUB_STEP_SUMMARY"

          jq -r '.data.findings[] | "\(.severity)|\(.code)|\(.subject)|\(.message)"' audit-response.json |
          while IFS='|' read -r severity code subject message; do
            case "$severity" in
              Error)   echo "::error title=$code::$subject — $message" ;;
              Warning) echo "::warning title=$code::$subject — $message" ;;
            esac
          done

          errors=$(jq -r '.data.errorCount' audit-response.json)
          warnings=$(jq -r '.data.warningCount' audit-response.json)
          drifted=$(jq -r '(.data.driftedObjects // []) | join(", ")' audit-response.json)
          echo "errors=$errors warnings=$warnings drifted=[$drifted]"

          test "$errors" -eq 0
          if [ "$FAIL_ON" = "warning" ]; then test "$warnings" -eq 0; fi

      - name: Keep the response for debugging
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coremodels-airbyte-audit
          path: audit-response.json

Three repository settings make it run: the variables COREMODELS_API_URL and COREMODELS_PROJECT_ID, and the secret COREMODELS_API_KEY.

One honest note on tooling. We ship a composite GitHub Action for this audit, and it is dbt-shaped: it reads a manifest path and posts the file under the artifact key manifest. Airbyte's connector requires the artifact key catalog, so that body comes back rejected with The 'catalog' artifact ((Configured)AirbyteCatalog JSON) is required. The curl and jq step above is the Airbyte equivalent, and it does the same four things: build the body, post it, publish the report, gate on the count.

What errorCount actually counts for Airbyte

A gate people cannot predict is a gate people disable. Here is the full picture of which findings can fail an Airbyte build.

Error severity comes only from the Drift section — the comparison between the estate and what the graph already governs:

CodeFires when
dataset-removeda governed stream no longer exists in the catalog
field-removeda governed property no longer exists on its stream
field-type-drifta property's native type changed since the last import
enum-narrowedaccepted values were removed while still governed as taxonomy terms

Coverage findings never fail a build: dataset-unmapped is a Warning (a stream not yet under governance), field-unmapped is Info (a property added since the last import). Conformance for Airbyte is Warning and Info only — stream-no-primary-key (Warning), no-cursor-field (Info), untyped-fields (Info). enum-widened and enum-constraint-removed are Warnings.

The practical consequence: a green build means nothing governed was removed, retyped or narrowed. A brand-new stream appearing in the catalog will not fail the gate — it surfaces as a coverage warning telling you there is something new to govern.

If you want a specific hygiene rule to block a merge, gate on the code histogram rather than on severity:

# Fail when a stream with no primary key is proposed, even though the rule is a Warning.
jq -e '(.data.codes["stream-no-primary-key"] // 0) == 0' audit-response.json

The same shape expresses the opposite policy — accepting a known finding while a fix is in flight:

# Fail on errors, except a single field-type-drift that already has a ticket.
jq -e '.data.errorCount - (.data.codes["field-type-drift"] // 0) == 0' audit-response.json

Recording runs, and reading the trail

recordHistory: true appends one compact record — timestamp, trigger, counts, code histogram, fingerprint — to the project's rolling audit trail. From the v1 surface the trigger is ci; interactive audits record as audit, re-audits as reaudit, the server-side heartbeat as scheduled.

curl -sS "https://coremodels.example.com/graph/integrations/airbyte/history/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  | jq -r '.projects[] | .projectName as $p
           | .runs[] | "\($p)\t\(.at)\t\(.trigger)\t\(.errorCount)E \(.warningCount)W\t\(.fingerprint)"'
airbyte-connection	2026-08-04T09:31:07.4410000+00:00	ci	0E 1W	9f2c41ab7d0e5b83
airbyte-connection	2026-08-03T05:00:11.9020000+00:00	scheduled	0E 1W	9f2c41ab7d0e5b83
airbyte-connection	2026-08-01T14:22:48.1770000+00:00	audit	1E 1W	4b7e0d19c6aa2f30

The trail is per vendor and estate, newest first, capped at the fifty most recent runs. The fingerprint column is the one to triage with: identical fingerprints mean the same catalog content was audited, so changed counts across two same-fingerprint runs mean the governed model moved, not the estate. Note that history and reaudit are on the interactive surface only — the API-key surface carries audit and badge.

The badge

GET https://coremodels.example.com/v1/{PROJECT_ID}/integrations/airbyte/badge
Authorization: Bearer $TOKEN

The response is image/svg+xml: a shields-style badge labeled airbyte audit, rendered from the latest recorded run — green when clean, yellow when only warnings, red with the error count, gray before any run is recorded. Because the route is authenticated, embedding it somewhere whose fetcher cannot send a header will not work; the reliable pattern is to pull it in the job and publish it where your documentation lives:

      - name: Refresh the audit badge
        if: always()
        run: |
          curl -sS -o docs/airbyte-audit.svg \
            "$COREMODELS_API_URL/v1/$COREMODELS_PROJECT_ID/integrations/airbyte/badge" \
            -H "Authorization: Bearer $COREMODELS_API_KEY"

The other direction: re-audit

The CI gate answers "does this catalog still conform to the governed model?" There is a mirror question no pull request will ever ask: the governed model just changed — does the last-known catalog still conform to it?

curl -sS -X POST \
  "https://coremodels.example.com/graph/integrations/airbyte/reaudit/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}' \
  | jq '{errorCount, warningCount, codes}'

Re-audit runs the identical audit engine over the estate snapshot stored at import time against the current governed model. No artifacts, no Airbyte call, no credentials. Its run is always recorded, with trigger reaudit. This is the call to make right after someone tightens a taxonomy or renames a governed element: it says immediately whether the change just invalidated a live ingestion path.

Two caveats. Re-audit needs a stored snapshot — import persists one, and reports snapshotStored: false with a lossiness record when the encoded snapshot exceeds the storage cap. And it is on the interactive surface, so a scheduled job calling it needs an interactive credential rather than an API key.

The heartbeat

Which is why the server can do it instead. A recurring re-audit worker re-audits opted-in projects against their stored snapshots and appends each run to the same history with trigger scheduled. It is configuration-gated and off by default: an enable flag, an optional cron expression, and an explicit list of project ids. Projects opt in, nothing runs silently, and the worker skips any connector that does not declare the audit capability.

The result is one drift trail fed by three independent sources: pull requests (ci), people asking questions (audit, reaudit), and the heartbeat (scheduled). When someone asks "when did this start?", the answer is a row with a timestamp instead of a recollection.

For the catalog extraction commands that feed all of this, see the Airbyte quickstart in the CoreModels integration docs.