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.ymltargets:/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 thecheck-target/evidence-producercomposition 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-baselineset). Replacement:mode: comparewith the identical value passed asold-library(orabi-baseline, unchanged) and the samenew-library. This is the ordinary two-sidedmode: compareshape the rest of this page already describes. - An audit-only scan (no baseline, or
audit: true). Replacement:mode: comparewith bothold-libraryandabi-baselineomitted. Omission is the trigger — there is no separate audit flag. This runs a first-classcompare --no-baselineagainstnew-libraryalone, 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 inv0.6.0, alongsidemode: scan's retirement. Pinv0.6.0or its commit SHA to run this example as written;v0.5.0used legacymode: scanwith 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
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.
Recommended flow: a multi-library release with one shared facts pack¶
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:
- Matrix over libraries — one matrix row per library, not per platform.
- Inline embedding at dump time — each
matrix row's
dumpstep pointsbuild-infoat the same shared facts pack;-H/headerscopes 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. - 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/scandon'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.