# Salesforce — Automation: 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

```yaml
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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.
