Salesforce logoAutomation

The Build Goes Red Before the Org Goes Wrong: A Salesforce Drift Gate in CI

Schema governance that lives in a wiki dies in a wiki. The only governance that survives contact with a delivery team is the kind wired into the pipeline — a check that runs on every change, fails loudly when meaning breaks, and costs nothing to keep passing. This article wires that check up for Salesforce with CoreModels: a CI job that audits a fresh describe export against the governed model, a one-line pass/fail contract, a status badge, a rolling evidence trail, and the two mechanisms that cover what CI cannot see.

The Build Goes Red Before the Org Goes Wrong: A Salesforce Drift Gate in CI

Schema governance that lives in a wiki dies in a wiki. The only governance that survives contact with a delivery team is the kind wired into the pipeline — a check that runs on every change, fails loudly when meaning breaks, and costs nothing to keep passing. This article wires that check up for Salesforce with CoreModels: a CI job that audits a fresh describe export against the governed model, a one-line pass/fail contract, a status badge, a rolling evidence trail, and the two mechanisms that cover what CI cannot see.

The contract: one number

CI calls the machine-to-machine surface, which accepts CoreModels user API keys:

POST https://coremodels.example.com/v1/{PROJECT_ID}/integrations/salesforce/audit

Three properties make this endpoint CI-friendly. It is read-only and runs at Viewer role, so the key in your CI secrets cannot change governed meaning even if it leaks. Its response is wrapped in an envelope — success at the top level, everything else under data. And it carries one unambiguous gate signal: data.errorCount > 0 means the change violates governed meaning — fail the build.

The severity split does the triage for you. Real meaning violations — a governed field removed or retyped, a governed value set narrowed — surface as errors. The Salesforce hygiene rules (picklist-unrestricted, field-no-help, polymorphic-reference) are warnings and infos: visible in every report, blocking nothing unless you decide to gate harder.

Producing the artifact in CI

The audit itself needs no Salesforce connection — it sees only the JSON you send. What CI must arrange is that the describe export reflects the proposed change: in a metadata repository, that means your existing validation step (deploying the PR's metadata to a scratch or sandbox org) runs first, and the describes are exported from that org. The export uses the same credential-free recipe as everywhere else; only your CI's own Salesforce auth is involved, never ours.

The GitHub Actions job

name: salesforce-drift-gate
on:
  pull_request:
    paths:
      - "force-app/**"

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

      # Your existing steps go here: authenticate the sf CLI and deploy the
      # PR's metadata to the scratch/sandbox org aliased "ci-org".

      - name: Export sObject describes
        env:
          GOVERNED_OBJECTS: Account Contact Opportunity Invoice__c
        run: |
          for OBJ in $GOVERNED_OBJECTS; do
            sf api request rest "/services/data/v61.0/sobjects/$OBJ/describe" -o ci-org
          done | jq -s '.' > describe.json

      - name: CoreModels schema audit
        env:
          COREMODELS_API_URL: ${{ vars.COREMODELS_API_URL }}
          COREMODELS_API_KEY: ${{ secrets.COREMODELS_API_KEY }}
          COREMODELS_PROJECT_ID: ${{ vars.COREMODELS_PROJECT_ID }}
        run: |
          set -euo pipefail
          jq -n --rawfile describe describe.json \
            '{artifacts: {describe: $describe}, recordHistory: true}' > audit-request.json
          curl -sS -o audit-response.json -X POST \
            "$COREMODELS_API_URL/v1/$COREMODELS_PROJECT_ID/integrations/salesforce/audit" \
            -H "Authorization: Bearer $COREMODELS_API_KEY" \
            -H "Content-Type: application/json" \
            --data-binary @audit-request.json
          test "$(jq -r '.success' audit-response.json)" = "true"
          jq -r '.data.markdown' audit-response.json >> "$GITHUB_STEP_SUMMARY"
          test "$(jq -r '.data.errorCount' audit-response.json)" -eq 0

Walk the last step's logic: build the request with jq so the describe JSON is escaped correctly as a string value; POST it with the API key; confirm the call itself succeeded; append the data.markdown report to the job summary so reviewers read findings without leaving the PR; then apply the gate. If errors exist, the final test exits nonzero and the build goes red — with the exact objects, fields, and codes already sitting in the summary.

Want a stricter gate — for example in the weeks before a release freeze? Add one line:

test "$(jq -r '.data.warningCount' audit-response.json)" -eq 0

Now an unrestricted picklist blocks merges too, instead of merely being reported.

The badge

Notice recordHistory: true in the request. That is what turns each CI run into a recorded checkpoint — and recorded checkpoints are what the badge renders. The badge lives on the same API-key surface:

curl -sS -H "Authorization: Bearer $COREMODELS_API_KEY" \
  "$COREMODELS_API_URL/v1/$COREMODELS_PROJECT_ID/integrations/salesforce/badge" \
  -o salesforce-audit.svg

It returns image/svg+xml reflecting the latest recorded run: green when clean, yellow when the last run had warnings only, red when it had errors, gray when nothing has been recorded yet. Because the route needs the key in a header, the practical README pattern is fetch-and-publish: pull the SVG in a scheduled or post-merge job and commit it (or push it to wherever your README images live). The badge then answers the team's standing question — "is the org still conformant?" — without anyone opening a report.

The evidence trail

Every run recorded from CI lands in the project's rolling audit history alongside runs recorded interactively and by re-audits, each tagged with its trigger. The history endpoint lives on the interactive surface (login token rather than API key), which suits its audience — humans and dashboards, not the gate itself:

curl -sS -H "Authorization: Bearer $TOKEN" \
  "https://coremodels.example.com/graph/integrations/salesforce/history/$PROJECT_ID" |
  jq -r '.projects[].runs[] | [.at, .trigger, .errorCount, .warningCount, .infoCount] | @tsv'

That one-liner prints a drift timeline: when each check ran, what triggered it, and how the counts moved. Runs are kept newest-first and capped, and each carries the artifact fingerprint — so you can tell "the org changed" apart from "the same export was audited twice."

The direction CI cannot see

Your CI gate catches the org moving away from the governed model. But drift has a second direction: the governed model moves — a steward tightens a taxonomy, renames an element, adds a constraint — while the org stands still. No PR fires in your metadata repo, so no gate runs.

That is what re-audit is for. At import time, CoreModels stores the parsed org snapshot; the re-audit verb replays the audit engine over that stored snapshot against the current governed model — no fresh export, no Salesforce connection, and the run is always recorded in the history:

curl -sS -X POST \
  "https://coremodels.example.com/graph/integrations/salesforce/reaudit/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

Make it a habit at the end of any governance session: change the model, re-audit, and the trail immediately shows whether the last-known org still conforms. One honest caveat: re-audit depends on the stored snapshot. Very large orgs can exceed the snapshot storage cap (about 1.5 MB encoded); import then reports snapshotStored: false with a lossiness record, fresh-artifact audits keep working, and re-audit is unavailable until a smaller extract is imported.

The heartbeat

The final piece removes the human from the schedule. For deployments under your control, a scheduled re-audit worker exists behind the Integrations:ScheduledReaudit configuration — off by default, deliberately. Switched on, it periodically re-audits opted-in projects against their stored snapshots and records each run into the same history. Governed-model drift then surfaces within a heartbeat interval even during quiet weeks, and the badge stays honest when no PRs are flowing.

The loop, assembled

Estate-side drift is caught by the CI gate on every pull request. Model-side drift is caught by re-audit — manually after governance sessions, automatically via the heartbeat. Every check lands in one rolling history, and the badge compresses the latest verdict into a color. None of these pieces writes governed content: the entire loop runs on Viewer-role, read-only calls, which is exactly what you want from automation with credentials scattered across CI systems.

The Salesforce quickstart in the CoreModels docs (quickstarts/salesforce) contains this gate, the extraction recipe, and every route it touches, on one page.