Skip to content

GitHub Action: Source Scans & Build Evidence

The main GitHub Action page covers installation, inputs, outputs, and the everyday compare recipes. This page is the source-intelligence companion: running mode: compare with build/source evidence from CI, pinning the depth dial, single-release audits, cost estimation, cross-check gating, and the three ways to feed L3/L4/L5 build/source evidence into a baseline. For what the evidence layers are, see Evidence & Detectability; for the underlying CLI flags, see Evidence Depth.

See also. If this check is one of several a project-wide .abicheck.yml targets:/profiles: block declares (not a standalone root-Action step), see S7: Source Scan via Compile-DB Replay and S8/S9: Source Facts From the Build Itself for the check-target/evidence-producer composition this page's inputs map onto.

Source-aware comparisons (build & source evidence)

mode: compare (the default) is the only entry point for source intelligence against a real baseline — mode: scan is retired outright (see below). It always runs the compiler-free pattern pre-scan and every intra-version cross-source check (CROSS_SOURCE_EVOLUTION_CHECKS), and takes depth/since/changed-path/sources/build-info inputs — running the pinned evidence level (L3 build context / L4 source-ABI replay / L5 source graph) and comparing against old-library. It emits a single coverage-annotated report saying, per layer, what ran versus what was skipped.

Omitting both old-library and abi-baseline runs a single-release audit with no baseline at all instead (see Single-release audit below); .abicheck.yml's policy.overrides.<CHANGE_KIND>: error (passed via build-config) is the replacement for legacy crosscheck's promotion syntax (see Gate CI on a specific cross-source check below). new-library-set (the multi-library audit mode) and risk-rules (the risk-driven auto depth escalation) are retired outright with no replacement input — see the depth table below for risk-rules' replacement, and compare each library individually for new-library-set until package component inventories land. build-target is retired too, on every mode: mode: scan's own retirement of it went first, and mode: dump's build-target input (the only other mode that ever forwarded it) was retired outright next, once that removal resolved the routing hazard that had deferred it — put the root target(s) in .abicheck.yml's build.targets instead (Bazel only so far) and pass the config via mode: dump's build-config: input. budget (a wall-clock guard, BUDGET_OVERFLOW rather than overrun) now applies to mode: compare's two-sided shape.

mode: scan is retired — use mode: compare

0.6 retired scan as a second analysis product. The CLI command was removed outright first (no deprecation window, D8); its Action-input-lifecycle amendment then closed the last gap: mode: scan itself is now retired on the composite Action too. Setting mode: scan on a step fails it immediately (before Python setup or any toolchain install) with an ::error:: naming the replacement for your own shape.

mode: scan never collapsed to one spelling, and it does not migrate to one either:

  • A baseline scan (against/abi-baseline set). Replacement: mode: compare with the identical value passed as old-library (or abi-baseline, unchanged) and the same new-library. This is the ordinary two-sided mode: compare shape the rest of this page already describes.
  • An audit-only scan (no baseline, or audit: true). Replacement: mode: compare with both old-library and abi-baseline omitted. Omission is the trigger — there is no separate audit flag. This runs a first-class compare --no-baseline against new-library alone, reporting no old/new compatibility verdict at all — see Single-release audit below.

The audit-gate migration trap: add severity-preset or lose your gate

Legacy mode: scan with no baseline gated a CI job on a BREAKING/API_BREAK-classified finding by default, unconditionally, no flag needed. mode: compare's own audit-only shape reproduces the identical gating partition at its own orthogonal exit code, 3 (published as the AUDIT_GATE verdict output) — but the axis is opt-in, activated only by severity-preset (any value except info-only).

If your audit-only mode: scan step relied on the default gating (i.e. you did not already pass severity-preset: info-only to opt out), you must add severity-preset: default (or strict) when you migrate it to mode: compare. Without it, the migrated step always exits 0/passes regardless of what the audit finds — a silent loss of the gate, not a loud one. A step that already set severity-preset: info-only needs no change; that value keeps the same meaning.

Live-verified against the G20 corpus: case148_xcheck_header_build_ mismatch/case149_xcheck_odr_variant (API_BREAK-classified findings) exit 3 under compare --no-baseline --severity-preset default, the same as legacy scan's own exit 2 on the identical fixtures; case143_audit_accidental_export (RISK-classified) stays exit 0 either way.

New to what these layers see? The concept-track level-by-level walk-through shows, on one running example, the concrete data each level (L0→L5) produces and where each goes blind — the "why" behind the inputs below.

The common case needs four inputs — the built binary, its public headers, the source tree, and a baseline (old-library) to compare against:

permissions:
  contents: read
jobs:
  abi-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # needed for `since: origin/...` change focusing

      - name: Build
        run: cmake -B build -S . && cmake --build build

      - name: Source-aware comparison
        uses: abicheck/abicheck@v0.6.0
        with:
          old-library: abi-baseline.json   # committed, or use abi-baseline: latest-release
          new-library: build/libfoo.so
          new-header: include/
          sources: .
          depth: source   # pin the source-ABI replay -- nothing escalates on its own (see below)
          since: origin/${{ github.base_ref }}   # focus on changed files
          fail-on-api-break: true       # gate on source/API breaks too

clang is installed automatically (for L4/L5). On a pull_request run, since: origin/${{ github.base_ref }} focuses the (expensive) source replay on the files the PR touched — pair it with fetch-depth: 0 in checkout so the base ref is available.

Pin the depth

depth is the single evidence-depth dial. Pin it — compare never escalates on its own (the risk-driven auto selection legacy scan once had is retired outright, along with scan itself). Omitting depth is not risk-based selection either way: compare infers source/build from whichever of sources/build-info is supplied, and bottoms out at headers when neither is given.

A pinned depth is a contract. compare --depth build|source enforces a floor and reports it through a dedicated axis: an operand this run extracts live that cannot reach the pinned rung records evidence_contract_error and exits 7 (policy/depth_evidence_contract.py; the full per-command account is in docs/use/evidence-depth.md).

The one carve-out is a side that is already a serialized snapshot (old-library: abi-baseline.json): that operand was not extracted by this run at all, so there is no "reached a shallower rung than requested" failure to report for it, and such a pair still exits 0/2/4 on its own contents. So sources:/build-info: stay load-bearing in exactly that case — a stored-snapshot operand pinned to depth: build/source is not checked against the pin, while a live one is.

      - uses: abicheck/abicheck@v0.6.0
        with:
          old-library: abi-baseline.json
          new-library: build/libfoo.so
          new-header: include/
          sources: .
          depth: source         # source-ABI replay of changed TUs (deterministic)
          since: origin/main    # scope the L4 replay to the PR's changed TUs
Want… Set
Cheap build-flag drift only (L3) depth: build
Source semantics on changed TUs (+ L5 graph) depth: source + since:
Full source-ABI replay of the whole library depth: source with no since:/changed-path (an unseeded depth: source already analyses the whole current target)
Risk-driven depth selection (auto) Retired, along with mode: scan itself. An omitted depth infers from sources/build-info as the table above describes; nothing is risk-scored any more — pin depth: build/source for the rung the risk score used to escalate to.
A budget: wall-clock guard (BUDGET_OVERFLOW rather than overrun) mode: compare's two-sided shape only — the audit-only shape (old-library/abi-baseline both omitted) rejects budget: upfront, since compare --no-baseline's wall-clock guard isn't wired to that path

The old scan-mode/source-method inputs and the full depth are gone

Earlier releases exposed scan-mode (pr/pr-deep/baseline/audit) and source-method (s0…s6) Action inputs, plus a fifth depth: full rung. As of the pre-1.0 CLI reset all three are removed outright, not deprecated — the CLI's --depth no longer accepts full/--mode/ --source-method/--max at all (a plain usage error). Use depth (omitting old-library/abi-baseline for an audit-only run); full collapsed into source, since the two only ever differed in replay scope, and an unseeded depth: source already resolves to the whole target. The mapping from the old axes is in the Removed scan axes appendix.

Single-release audit (no baseline)

Run the intra-version hygiene checks against one build — no old version needed. Useful as a standing lint on the default branch. Omit both old-library and abi-baseline on a mode: compare step — this is the replacement for legacy mode: scan with no baseline (see Scenario S5):

compare's audit-only shape (this whole recipe) ships in v0.6.0, alongside mode: scan's retirement. Pin v0.6.0 or its commit SHA to run this example as written; v0.5.0 used legacy mode: scan with no baseline (see Migrating from mode: scan for the migration details).

      - uses: abicheck/abicheck@v0.6.0
        with:
          mode: compare
          new-library: build/libfoo.so
          new-header: include/
          sources: .
          severity-preset: default
          # No `old-library`/`abi-baseline` on this step -- omitting both is
          # what selects the audit-only shape. `severity-preset` is what
          # keeps this step gating on a BREAKING/API_BREAK-classified
          # finding the way legacy `mode: scan`'s own audit mode always
          # did by default -- see the warning above.

On the command line, the same audit runs over a whole release: abicheck compare --no-baseline build/lib/ (or a .deb/.rpm/.whl/ .tar.* package, or a stored ProjectSnapshot package) audits every library it ships, each exactly as compare --no-baseline <library> would, and writes one audit_set report. --select-required libfoo.so declares a member that must be present (scope.on_incomplete: block makes its absence fail the run); a run that audits no member at all exits 1. See Exit codes § compare --no-baseline DIR. The Action's own new-library-set input does not route here yet.

Estimate cost before committing to a depth

dry-run: 'true' prints the resolved depth/scope — without comparing anything. A resolvable preview exits 0, but an invalid input combination or an unsatisfiable requested depth/evidence contract still exits nonzero (a live-candidate request pinning depth: build/source via extra-args with no sources/build-info given previews the same blocker the real run would hit, at exit 1) — a dry run validates what it can see, it does not turn every outcome into success. Applies to mode: compare equally for both the two-sided and audit-only shapes. Handy when sizing a job for a large repo:

      - uses: abicheck/abicheck@v0.6.0
        with:
          old-library: abi-baseline.json
          new-library: build/libfoo.so
          new-header: include/
          sources: .
          depth: source
          dry-run: 'true'

Gate CI on a specific cross-source check

Cross-source findings are advisory by default. Promoting one to error makes a finding for it exit 2 (the API_BREAK tier); add fail-on-api-break: true so that exit turns the step red. Legacy scan --crosscheck's KEY=error promotion syntax is retired along with mode: scan itself — every cross-source check already reaches compare as an ordinary finding, so its replacement is .abicheck.yml's policy.overrides.<CHANGE_KIND>: error, passed as build-config:

      - uses: abicheck/abicheck@v0.6.0
        with:
          mode: compare
          old-library: abi-baseline.json
          new-library: build/libfoo.so
          new-header: include/
          sources: .
          build-config: .abicheck.yml   # policy.overrides.private_header_leak: error, etc.
          fail-on-api-break: true   # gate on the exit-2 (API_BREAK) tier
# .abicheck.yml
policy:
  overrides:
    private_header_leak: error
    odr_type_variant: error

fail-on-api-break gates the whole API_BREAK tier (baseline/source breaks and promoted cross-checks alike); the Action can't tell from the exit code which one fired, so leave it false if you only want binary ABI breaks (exit 4) to gate.

Passing sources into a baseline (build/source evidence)

There are three ways to feed L3/L4/L5 evidence into the comparison. Pick by where your build produces facts.

A. Inline at dump time (simplest)

dump with sources/build-info embeds the build/source facts inline in the snapshot, so any later compare (including one run from this Action on two such snapshots) carries the L3/L4/L5 findings — no out-of-band directories:

      - name: Dump baseline with build + source evidence
        uses: abicheck/abicheck@v0.6.0
        with:
          mode: dump
          new-library: build/libfoo.so
          header: include/
          sources: .
          depth: source                 # whole-library L3+L4+L5 for a baseline (unseeded `source` already analyses the whole target)
          output-file: abi-baseline.json

Compare two such snapshots later with the default compare mode — the embedded evidence diffs automatically.

B. Independently-produced dumps or a build-emitted facts pack

The collect/merge commands that used to combine a binary-side dump with a separately-produced source-side dump (or an abicheck-cc-emitted abicheck_inputs/ Flow-2 pack) were removed from the public CLI in the pre-1.0 CLI reset with no replacement command, and the Action's mode: merge dispatch went with them. Section A (inline embedding) above is the only Action-supported flow today.

For a build that genuinely produces the binary and source sides on separate runners (or emits a Flow-2 pack), compare's own out-of-band --old-build-info/--new-build-info flags accept a pack directory per side — including auto-detecting an abicheck_inputs/ pack — but the Action does not currently expose per-side build-info inputs for mode: compare. Run that step directly with the CLI (pip install abicheck) instead of through this Action, or embed inline at dump time as in Section A. See Build Info & Sources for the underlying CLI-level flows.

This is the canonical multi-DSO recipe — Action Reference and More Recipes link here rather than restating it, so update this section (not a copy) when the recipe changes.

This is the concrete, Action-supported answer to a specific, recurring shape of project: several libraries built from one source tree, one facts pack collected once for the whole build (via source replay, the abicheck-cc wrapper, or the Clang plugin), and no single ".so that represents the release" to hand scan/dump — which, per Mode/input compatibility, only accept one artifact each; there is no scan/dump equivalent of compare's directory/package fan-out.

Scope caveat: every library in the recipe below points at the same shared abicheck_inputs/ pack, with no per-target projection check that the pack's facts actually belong to that specific library rather than another one built from the same tree. That's fine for what this recipe supports today — a build-wide source audit, and a per-target header-depth check (-H/header scopes each matrix row's L2 declared surface correctly, which is independent of the shared pack). It is not enough to claim per-target source-depth coverage: nothing here proves library A's embedded L3/L4/L5 facts didn't actually come from library B's translation units. Recording that distinction (a build-output.json evidence.projection: "declared" vs. "inferred" tag) needs the per-target projection validator tracked as G30 plan item P1.1, not yet implemented — until then, treat this recipe's depth: source dumps as build-wide source evidence applied uniformly, not as independently-proven per-library source coverage.

The fix is not a new Action feature — it's composing three recipes this page and More Recipes already document individually, which is easy to miss without seeing them chained together:

  1. Matrix over libraries — one matrix row per library, not per platform.
  2. Inline embedding at dump time — each matrix row's dump step points build-info at the same shared facts pack; -H/header scopes the L2 declared surface to that row's own public headers, and the embedded L3/L4/L5 facts are matched against it — the pack is collected once per build, not once per library.
  3. Post-matrix ABI gate — aggregates the per-library verdicts into one exit code, since there is no single combined verdict from a fan-out this page's dump/scan don't do natively.

The abicheck_inputs/ pack itself is produced by whichever producer fits your build; the collect-facts Action wires that up (phase: prepare before the build, phase: verify after) instead of a hand-rolled build script.

This recipe specifically needs a pack it can upload-artifact from the build job and download-artifact into separate dump-baselines matrix jobs, so pin producer to wrapper or clang-plugin rather than auto: for a CMake/Bazel/compile-DB project, auto resolves to replay, whose phase: prepare returns mode: inline with an empty pack-path and never creates an abicheck_inputs/ directory at all — there is nothing to upload, and every matrix row's build-info: abicheck_inputs/ would point at a directory that doesn't exist. (Replay's inline mode is for the single-job case in Section A, where dump runs right after the build with the checked-out sources: tree still on disk — not for reuse across separate jobs.)

# Release workflow — build once, produce a per-library baseline set from the
# one shared facts pack (matches "Recipe A" in Baseline Management, but with a
# manifest row per library instead of a single baseline file).
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # collect-facts pinned to a commit SHA, not a tag: see "Pin both uses:
      # lines" in producing-source-facts.md -- a version tag old enough to
      # predate this sub-action's own introduction can't resolve it at all.
      - uses: abicheck/abicheck/actions/collect-facts@<same-sha-as-below>
        id: facts
        with: { phase: prepare, producer: wrapper, public-roots: "include" }
      - name: Build
        # phase: prepare only *exports* the ABICHECK_CC_* env vars
        # abicheck-cc reads (see its own ::notice::) -- nothing invokes
        # abicheck-cc for you, so front every compile with it explicitly via
        # CMake's compiler-launcher hooks. Swap this line for
        # `-DCMAKE_CXX_FLAGS="$ABICHECK_PLUGIN_FLAGS"` if you pin
        # `producer: clang-plugin` instead.
        run: |
          cmake -DCMAKE_CXX_COMPILER_LAUNCHER=abicheck-cc \
                -DCMAKE_C_COMPILER_LAUNCHER=abicheck-cc -S . -B build
          cmake --build build
      - uses: abicheck/abicheck/actions/collect-facts@<same-sha-as-below>
        id: facts-verify
        with: { phase: verify, producer: ${{ steps.facts.outputs.producer }} }
      - uses: actions/upload-artifact@v4
        with:
          name: release-build
          path: |
            build/lib*.so
            include/
            abicheck_inputs/

  dump-baselines:
    needs: build
    strategy:
      matrix:
        lib:
          - { name: libfoo, so: build/libfoo.so, header: include/foo.h }
          - { name: libbar, so: build/libbar.so, header: include/bar.h }
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with: { name: release-build }
      # Same SHA as the build job's collect-facts calls above -- this step
      # consumes the abicheck_inputs/ pack that produced, and a version tag
      # here could disagree with collect-facts' pinned SHA on the pack
      # schema/fact-set recipe, risking a mismatch or missing source facts
      # the same "pin both uses: lines" rule in producing-source-facts.md
      # exists to prevent (Codex review).
      - uses: abicheck/abicheck@<same-sha-as-above>
        with:
          mode: dump
          new-library: ${{ matrix.lib.so }}
          header: ${{ matrix.lib.header }}
          build-info: abicheck_inputs/       # the one shared pack, every row
          depth: source
          new-version: ${{ github.ref_name }}
          output-file: ${{ matrix.lib.name }}.abicheck.json
      - uses: actions/upload-artifact@v4
        with:
          name: baseline-${{ matrix.lib.name }}
          path: ${{ matrix.lib.name }}.abicheck.json

  publish-baselines:
    needs: dump-baselines
    runs-on: ubuntu-latest
    permissions: { contents: write }
    steps:
      - uses: actions/download-artifact@v4
        with: { pattern: baseline-*, merge-multiple: true, path: baselines/ }
      # -R is required here: gh normally infers the repo from a local git
      # checkout, but this job only downloads artifacts, never checks out
      # the repo, so gh has no repository context to infer from.
      - run: gh release upload ${{ github.ref_name }} baselines/*.abicheck.json --clobber -R ${{ github.repository }}
        env: { GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} }
# PR workflow — same matrix, dumping the candidate build instead of publishing,
# then comparing two JSON snapshots per library (no headers/build-info needed
# at compare time — both sides already have their facts embedded).
jobs:
  build:
    # Identical to the release workflow's `build` job above, just without
    # its `publish-baselines` job at the end -- repeated in full here (not
    # abbreviated) so this block is copy-pasteable on its own.
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: abicheck/abicheck/actions/collect-facts@<same-sha-as-below>
        id: facts
        with: { phase: prepare, producer: wrapper, public-roots: "include" }
      - name: Build
        run: |
          cmake -DCMAKE_CXX_COMPILER_LAUNCHER=abicheck-cc \
                -DCMAKE_C_COMPILER_LAUNCHER=abicheck-cc -S . -B build
          cmake --build build
      - uses: abicheck/abicheck/actions/collect-facts@<same-sha-as-below>
        id: facts-verify
        with: { phase: verify, producer: ${{ steps.facts.outputs.producer }} }
      - uses: actions/upload-artifact@v4
        with:
          name: release-build
          path: |
            build/lib*.so
            include/
            abicheck_inputs/

  scan-candidates:
    needs: build
    strategy:
      matrix:
        lib:
          - { name: libfoo, so: build/libfoo.so, header: include/foo.h }
          - { name: libbar, so: build/libbar.so, header: include/bar.h }
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with: { name: release-build }
      - name: Dump candidate with build/source evidence
        # Same SHA as the build job's collect-facts calls above (Codex
        # review) -- see the note on the release workflow's equivalent step.
        uses: abicheck/abicheck@<same-sha-as-above>
        with:
          mode: dump
          new-library: ${{ matrix.lib.so }}
          header: ${{ matrix.lib.header }}
          build-info: abicheck_inputs/
          depth: source
          output-file: candidate.json
      - name: Download this library's baseline
        # -R is required: this job never checks out the repo either, so gh
        # has no local repository context to infer from (same reason as the
        # release-workflow's gh release upload above).
        run: gh release download --pattern '${{ matrix.lib.name }}.abicheck.json' -D baselines/ -R ${{ github.repository }}
        env: { GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} }
      - name: Compare two snapshots (source/API + binary evidence together)
        uses: abicheck/abicheck@<same-sha-as-above>
        with:
          old-library: baselines/${{ matrix.lib.name }}.abicheck.json
          new-library: candidate.json
          format: json
          output-file: report-${{ matrix.lib.name }}.json
          fail-on-breaking: false   # let the post-matrix gate job decide
          fail-on-api-break: false
      - name: Upload this library's report
        uses: actions/upload-artifact@v4
        with:
          name: report-${{ matrix.lib.name }}
          path: report-${{ matrix.lib.name }}.json

  abi-gate:
    needs: scan-candidates
    # same aggregation job as "Post-matrix ABI gate (unified verdict)" --
    # downloads with `pattern: report-*`, `merge-multiple: true`

Layering onto an existing binary-ABI tool (the common reason to reach for this pattern at all): keep that tool's job exactly as-is for the binary ABI gate, and add the above as a second, independent job for the source/API surface — don't try to make one job do both. Start the second job advisory (fail-on-breaking: false, fail-on-api-break: false, report only) while you build confidence in the new source/API signal on real history; flip on fail-on-api-break: true once it's been quiet for a burn-in period. This mirrors Choose Your Workflow's guidance to not make one step prove more than its evidence actually supports.

This pattern produces one baseline file per library, which is a per-library instance of the release-contract baseline — apply that page's release-vs-accepted-main split and refresh discipline to each file the same way you would to a single-library baseline.