GitHub Action: More Recipes¶
A grab-bag of abicheck/abicheck workflow recipes beyond the basics in
GitHub Action: caching, SARIF, cross-compilation,
multi-library/multi-platform matrices, dependency/appcompat checks, PR
comments, and package-comparison modes.
Split out of GitHub Action, which covers quick start, inputs/outputs, and the three core usage examples.
See also. If your project has more than one library, profile, or baseline channel, Which Scenario Am I? picks the right primitive for your project's whole lifecycle (multiple targets,
.abicheck.yml'stargets:/profiles:block, thecheck-project.ymlmatrix) rather than a single recipe — several of this page's recipes below (cross-compilation, multi-platform matrices, dependency/appcompat checks) now also have a dedicated scenario walkthrough there.
Use GitHub Actions cache for baseline¶
- name: Restore cached baseline
uses: actions/cache@v4
with:
path: abi-baseline.json
key: abi-baseline-${{ github.event.repository.default_branch }}-${{ github.sha }}
restore-keys: |
abi-baseline-${{ github.event.repository.default_branch }}-
- name: Check ABI
uses: abicheck/abicheck@v0.6.0
with:
old-library: abi-baseline.json
new-library: build/libfoo.so
new-header: include/foo.h
SARIF with GitHub Code Scanning¶
Upload results to the Security tab so ABI breaks appear as code scanning alerts.
Note
Requires security-events: write permission. On PRs, GitHub only shows
new alerts introduced by the PR — existing alerts stay on the default
branch and don't clutter the review.
Pin every action in this job to a commit SHA
Any uses: step running inside a job that carries security-events: write
(or any other elevated permission) executes with that permission's token.
A mutable tag (@v4, @v0.6.0) can be repointed — accidentally or
maliciously — to different code after you've reviewed it once; a full
commit SHA cannot. Pin every action here, not just abicheck/abicheck,
and keep the release tag in a trailing comment so the pin stays
human-auditable. See Versioning.
jobs:
abi-check:
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- run: mkdir build && cd build && cmake .. && make
- uses: abicheck/abicheck@<commit-sha> # pin to the SHA your chosen release tag resolves to
with:
old-library: abi-baseline.json
new-library: build/libfoo.so
new-header: include/foo.h
format: sarif
upload-sarif: true
Cross-compilation check (dump mode)¶
Cross-compilation flags (gcc-prefix, sysroot, gcc-options) are only supported
in dump mode. Use mode: dump to generate a baseline from a cross-compiled binary,
then compare with a separate step.
# Step 1: dump ABI snapshot from cross-compiled binary
- uses: abicheck/abicheck@v0.6.0
with:
mode: dump
new-library: build-arm64/libfoo.so
header: include/foo.h
gcc-prefix: aarch64-linux-gnu-
sysroot: /usr/aarch64-linux-gnu
lang: c
output-file: baseline-arm64.json
Matrix: multiple libraries¶
strategy:
matrix:
lib:
- { name: libfoo, so: build/libfoo.so, header: include/foo.h }
- { name: libbar, so: build/libbar.so, header: include/bar.h }
steps:
- uses: abicheck/abicheck@v0.6.0
with:
old-library: baselines/${{ matrix.lib.name }}.json
new-library: ${{ matrix.lib.so }}
new-header: ${{ matrix.lib.header }}
If the release also carries build-emitted source facts from one shared
abicheck_inputs/ pack, see Source Scans → Recommended flow: a
multi-library release with one shared facts
pack
for the full walkthrough — it chains this recipe with inline build-info
embedding and the post-matrix ABI gate
below.
Matrix: multiple platforms (native scan per OS)¶
Use native runners to get the best platform-specific signal (Linux/ELF, macOS/Mach-O, Windows/PE):
jobs:
abi-scan:
strategy:
matrix:
include:
- os: ubuntu-latest
ext: so
- os: macos-latest
ext: dylib
- os: windows-latest
ext: dll
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
# Build your platform artifact here (example command only)
- name: Build
run: |
echo "build on ${{ matrix.os }}"
- name: ABI compare (native)
uses: abicheck/abicheck@v0.6.0
with:
old-library: baselines/${{ runner.os }}/abi-old.json
new-library: build/${{ runner.os }}/libfoo.${{ matrix.ext }}
new-header: include/foo.h
format: json
output-file: abi-report-${{ runner.os }}.json
- name: Upload platform ABI report
uses: actions/upload-artifact@v4
with:
name: abi-report-${{ runner.os }}
path: abi-report-${{ runner.os }}.json
Post-matrix ABI gate (fan-out builds, fan-in verdict)¶
Each platform builds and compares on its own matrix leg (fan-out) and uploads
a JSON report; a gate job downloads them all and folds them into one verdict
(fan-in) with abicheck aggregate:
A single committed abi-targets.json is the source of truth: a plan job
reads it into the build matrix, and the gate job reads the same file to
reconcile coverage — so the two cannot drift. The manifest carries the
per-target build metadata (os/ext) alongside id/required; aggregate
reads only id/required and ignores the rest.
{
"aggregate_manifest_version": "1.0",
"targets": [
{"id": "linux-x86_64", "required": true, "os": "ubuntu-latest", "ext": "so"},
{"id": "macos-arm64", "required": true, "os": "macos-latest", "ext": "dylib"},
{"id": "windows-x86_64", "required": true, "os": "windows-latest", "ext": "dll"}
]
}
The optional aggregate_manifest_version lets aggregate reject a manifest
written for a newer major version it cannot interpret; omit it and the manifest
is treated as the current major.
jobs:
abi-plan: # read the manifest → matrix, exactly once
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: plan
run: |
echo "matrix={\"include\":$(jq -c '.targets' abi-targets.json)}" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4 # hand the SAME file to the gate job
with:
name: abi-target-manifest
path: abi-targets.json
abi-scan:
needs: abi-plan
strategy:
fail-fast: false # don't cancel other legs when one fails
matrix: ${{ fromJSON(needs.abi-plan.outputs.matrix) }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Build
run: cmake -B build && cmake --build build
- name: ABI compare (native)
uses: abicheck/abicheck@v0.6.0
with:
old-library: baselines/${{ matrix.id }}/abi-old.json
new-library: build/libfoo.${{ matrix.ext }}
new-header: include/foo.h
format: json
output-file: abi-report-${{ matrix.id }}.json
fail-on-breaking: false # let the gate job decide
- name: Upload platform ABI report
if: ${{ always() }} # upload even if the build/compare failed
uses: actions/upload-artifact@v4
with:
name: abi-report-${{ matrix.id }}
path: abi-report-${{ matrix.id }}.json
if-no-files-found: ignore
abi-gate:
needs: [abi-plan, abi-scan]
if: ${{ always() }} # run the gate even if a matrix leg failed
runs-on: ubuntu-latest
steps:
- name: Download the target manifest
uses: actions/download-artifact@v4
with:
name: abi-target-manifest # the exact abi-targets.json the plan used
- name: Download all ABI reports
uses: actions/download-artifact@v4
with:
pattern: abi-report-*
merge-multiple: true
path: abi-reports/
- name: Aggregate verdicts and gate
run: |
pip install abicheck --quiet
abicheck aggregate abi-reports/ --manifest abi-targets.json
The gate downloads the manifest as an artifact (not actions/checkout), so it
gates against the exact target set the matrix was planned from. The same flag
takes a project plan run-plan directly (--manifest run-plan.json) — the
document's own schema says which shape it is — and --discovered-only opts
out of the coverage gate entirely. One of the two is
required — a bare
aggregate abi-reports/ is a usage error, because with no declared target set
the gate cannot tell a missing required target from an absent one. aggregate
then guarantees the properties the old hand-written gate loop silently violated:
- A required target with no report is unavailable (unknown), never counted
as compatible. If the Windows leg fails before uploading
abi-report-windows-x86_64.json, the gate reports Windows as unavailable and fails at exit1— it does not pass green as "all platforms compatible" when a required platform was never analyzed. - Gate, coverage, compatibility, and contract coverage stay orthogonal. Each report carries its own
severity gate decision;
aggregatecombines those (a policy-blockedCOMPATIBLEstill fails, a demotedBREAKINGcan pass) rather than recomputing a gate from the verdict. The exit code is0pass /1coverage gap, an addition/quality-only block, a target's own contract evidence being incomplete under--contract, or another non-verdict per-report failure (e.g. ascanbudget overflow) /2source break /4ABI break (seeabicheck aggregatefor the full contract). A missing required build is a coverage failure at1— never promoted to a fake ABI-break exit4; a contract-coverage gap (evidence incomplete for a target that did report) is a separate1for a different reason, and the JSON output's owncontract_coverageblock says which targets caused it.
Tip
Set fail-on-breaking: false in each matrix job and let the gate decide.
Use fail-fast: false on the matrix and if: ${{ always() }} on the
upload step and the gate job so one failed leg neither cancels the others
nor skips the fan-in. For a manifest, this means bumping
"aggregate_manifest_version" to at least "2.0" (the gate block
ships only at that major — an older-versioned manifest carrying gate
is rejected as malformed rather than silently honored) and setting
"gate": {"missing_required": "warn"} (or run-plan-projected manifest,
via .abicheck.yml's aggregate: gate: {missing_required: warn} block —
project plan sources this policy from project config, not a CLI flag)
if you want a missing
required target to be reported but not fail the gate on that account
alone (contract-coverage evidence and other analyzed targets' own gate
decisions remain independent axes that can still produce a failing exit
code); mark a target "required": false in the manifest if its absence
should never fail coverage.
Sample output when the Windows leg failed to produce a report:
ABI aggregate gate: Failed (coverage: partial)
Analyzed 2 of 3 required targets
linux-x86_64: COMPATIBLE
macos-arm64: COMPATIBLE
windows-x86_64: ⚠ unavailable — no report was produced for this expected target
Compatibility:
No ABI regressions in the analyzed targets.
Coverage:
Incomplete — required target(s) unknown: windows-x86_64.
Gate:
Failed — exit 1; required coverage incomplete.
Add -o json=... for a versioned, machine-readable result — the four axes
are kept separate under gate (passed/exit_code/blocking_targets),
coverage (status/counts/missing_required_targets), compatibility
(verdict/analyzed_targets), and contract_coverage
(exit_contribution/incomplete_targets; always present, with zero/empty
values when --contract is not enabled), plus a per-targets
breakdown and an unexpected_targets list — to post elsewhere.
Skip system dependency installation¶
If castxml + compiler are already available (custom image, pre-provisioned VM,
or conda-forge environment), set install-deps: false:
- uses: abicheck/abicheck@v0.6.0
with:
old-library: old.json
new-library: new.json
install-deps: false
Example (conda-forge pre-step):
- name: Install abicheck from conda-forge
run: |
conda install -y -c conda-forge abicheck
- uses: abicheck/abicheck@v0.6.0
with:
old-library: old.json
new-library: new.json
install-deps: false
When comparing two JSON snapshots, no header-analysis toolchain is needed.
Full-stack dependency check on container image update¶
Validate that updating a base image doesn't break your application's dependency
stack. This runs deps-compare to compare the binary's full transitive
dependency tree across old and new container root filesystems:
jobs:
deps-compare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Extract old rootfs
run: |
mkdir -p /tmp/old-root
docker export $(docker create old-image:latest) | tar -xf - -C /tmp/old-root
- name: Extract new rootfs
run: |
mkdir -p /tmp/new-root
docker export $(docker create new-image:latest) | tar -xf - -C /tmp/new-root
- name: Full-stack ABI check
uses: abicheck/abicheck@v0.6.0
with:
mode: deps-compare
new-library: usr/bin/myapp
old-root: /tmp/old-root
new-root: /tmp/new-root
format: json
output-file: stack-report.json
Exit codes for deps-compare: 0 = PASS, 1 = WARN (ABI risk), 4 = FAIL (load failure or ABI break).
Dependency tree audit¶
Show the resolved dependency tree and symbol binding status for a binary. Useful for auditing which libraries a binary actually loads and detecting missing dependencies before deployment:
- name: Audit dependencies
uses: abicheck/abicheck@v0.6.0
with:
mode: deps-tree
new-library: build/myapp
sysroot: /path/to/target/rootfs
Include dependency info in compare¶
Add follow-deps: true to include the transitive dependency graph and symbol
binding information alongside the regular ABI diff:
- name: Compare with dependency context
uses: abicheck/abicheck@v0.6.0
with:
old-library: baseline.json
new-library: build/libfoo.so
new-header: include/foo.h
follow-deps: true
Inline PR annotations¶
Set annotate: true to get ABI breaking changes as inline comments on the PR diff.
See GitHub PR Annotations for full details.
- uses: abicheck/abicheck@v0.6.0
with:
old-library: baseline.json
new-library: build/libfoo.so
new-header: include/foo.h
annotate: true
Sticky PR comment¶
On pull_request runs the action posts a single, self-updating comment that
groups every finding into Breaking, Needs review, and Informational
findings (plus its own ➕ Public API additions table) sections and shows
the scanned head SHA. It is a content channel only — it never changes the
check's red/green state, which is still driven by fail-on-breaking /
fail-on-api-break / severity-*. The headline names the actual reason
instead of a generic verdict wherever the bucket's members agree on one: a
single-severity Needs-review bucket reads e.g. ⚠️ Source API changed; binary
ABI unchanged (source-level only) or ⚠️ Compatibility risk — review
recommended (a risk finding), falling back to the generic ⚠️ Review
recommended only when the bucket mixes both; real ABI breaks turn the check
red and post a ❌ ABI BREAKING comment.
A fourth, separate 🛑 Analysis incomplete section — degraded or missing
comparison evidence (e.g. the baseline was scanned with debug info or build
context the candidate lacks) — never mixes into those three buckets, and
never drives a ⚠️ Review recommended headline: that finding isn't a claim
about the API/ABI at all, so the comment says "Source analysis incomplete" or
"Analysis coverage reduced" instead, so a reviewer can tell "this PR's
comparison had a coverage gap" apart from "this PR made a risky API change."
Whether that headline reads as blocking (🛑) or advisory (⚠️) mirrors the
Action's own gate exactly, and follows whichever exit-code scheme actually
produced the report:
- Legacy scheme (no
--severity-*flags): anapi_break-severity finding blocks underfail-on-api-breakalone, and abreaking-severity one underfail-on-breakingalone — both map to a fixed exit code (2 / 4) regardless of any config. Arisk-severity finding (e.g.layer_coverage_asymmetric's default) never blocks under this scheme: its fixed legacy exit code is 0. - Severity-aware scheme (
--severity-*active):api_break/riskshare thepotential_breakingcategory and block only when both that category is configurederrorandfail-on-api-break; abreaking-severity finding needs bothabi_breaking: errorandfail-on-breaking. The matchingfail-on-*flag alone is not enough —compare's exit-code-2/4 tiers require the category actually gated, under either scheme.
Two exceptions are unconditional, with no fail-on-* gate at all: a
compatible-severity finding (e.g. dwarf_info_missing) whose resolved
severity-config category (addition or quality_issues) is set to error
— compare's own exit-code-1 SEVERITY_ERROR tier — and a
--contract run's own coverage-failure ledger
(contract_coverage_failures): its contract_coverage_exit_contribution
folds into the real exit code regardless of any other axis, including a
--used-by/--required-symbol scoped verdict — a scoped-compatible run
whose contract coverage also failed still renders the blocking headline,
not "✅ Compatible (scoped)".
A directory/package (release) operand carries the same contract-coverage
ledger, coarsened to which librar(y/ies) contributed rather than
per-provider detail (the release JSON has no aggregated
contract_coverage_failures array, only each library's own
contract_coverage_exit_contribution int) — a release whose only problem
is incomplete contract coverage still renders the blocking "🛑 Source
analysis incomplete" headline rather than silently posting no comment (or
"No ABI changes") because every ordinary compatibility bucket was empty.
For any non-pull_request trigger (or pr-comment: false), where the
Action never builds a JSON report for a release-style operand, abicheck
itself also announces this to the job's stderr log so the fact isn't
silently invisible there either — the ordinary release Markdown/step
summary doesn't carry it (only -o json=...'s own
contract_coverage_exit_contribution field does).
A breaking/review finding's row also carries, when the report provides them:
the demangled C++ signature as the primary Symbol value (with the raw
mangled linker symbol kept alongside as evidence, in full detail — see
linker: ...), a normalized source location with CI-runner-specific
checkout-path noise stripped, and an Impact: line drawn from the
finding's own impact field (a free-form consequence note, not a guaranteed
remediation step).
permissions:
contents: read
pull-requests: write # required for the comment
jobs:
abi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: abicheck/abicheck@v0.6.0
with:
old-library: baseline.json
new-library: build/libfoo.so
new-header: include/foo.h
# all optional — these are the defaults:
pr-comment: true
pr-comment-mode: update # one sticky comment, edited each run
pr-comment-on: changes # skip the comment when nothing changed
pr-comment-detail: standard # per-symbol tables for breaking/review
Behavior knobs:
pr-comment-mode: newposts a fresh comment per run instead of editing the previous one (use when you want a per-commit history in the thread).pr-comment-on: alwayscomments every run, including a clean No ABI changes result;neverdisables it.pr-comment-detail: fulllists every change with source locations and expands all sections;summaryreduces the comment to the verdict and counts.
The same four inputs work for mode: compare's audit-only shape
(old-library/abi-baseline both omitted — the replacement for
legacy mode: scan with no baseline): the comment renders the audit's own
AUDIT_GATE/AUDIT_CLEAN/AUDIT_RISK verdict and candidate-side findings,
with no second compare run and no OLD side to render at all:
- uses: abicheck/abicheck@v0.6.0
with:
new-library: build/libfoo.so
new-header: include/foo.h
severity-preset: default # keeps AUDIT_GATE gating -- see github-action.md's migration note
pr-comment: true
pr-comment-on: always # also comment a clean audit
On large diffs the standard view stays readable by rolling related changes up
to their enclosing API — overloads, template instantiations and members of the
same type/namespace collapse into one row showing the family and a member count
(distinct symbols keep their own row; full keeps every change separate). The
body is always kept under GitHub's 65,536-character comment limit: if it would
overflow, the detail level is automatically reduced (and, as a last resort, the
body is truncated), with a link back to the full report uploaded as the
workflow-run artifact so nothing is lost.
Informational findings/Public API additions mirror whatever the
checker already classified as compatible — so public-header surface scoping
(on by default) and policy profiles (e.g. sdk_vendor demoting a
removal) flow through automatically; the comment never re-classifies
anything.
The comment also tracks the gate: with fail-on-api-break: true (which turns
the check red on source/API breaks), those findings are filed under Breaking
in the comment to match, rather than Needs review.
Conditional failure¶
Allow API breaks but block binary ABI breaks:
- uses: abicheck/abicheck@v0.6.0
with:
old-library: baseline.json
new-library: build/libfoo.so
new-header: include/foo.h
fail-on-breaking: true
fail-on-api-break: false
Detect unintentional API expansion¶
Block PRs that accidentally add new public symbols or types:
- uses: abicheck/abicheck@v0.6.0
with:
old-library: baseline.json
new-library: build/libfoo.so
new-header: include/foo.h
fail-on-breaking: true
severity-preset: strict # exit code 1 if any new public API appears
severity-preset: strict raises the addition category to error. A
per-category override is not an Action input — put it in .abicheck.yml's
severity: block (see Severity).
Under that preset:
- Exit code 1 → new public symbol/type added (verdict: SEVERITY_ERROR)
- Exit code 0 → no additions, no breaks (verdict: COMPATIBLE)
- Exit code 4 → binary ABI break (verdict: BREAKING)
This is useful when your library has a stable frozen API and any expansion must be a deliberate, reviewed decision rather than an accidental side effect.
Compare RPM packages¶
old-library/new-library may be directories or packages instead of a
single library each — compare (the default mode) detects this and fans
out to a per-library comparison automatically, no separate mode needed.
Supported formats: RPM, Deb, tar (.tar.gz, .tar.xz, .tar.bz2, .tgz),
conda (.conda, .tar.bz2), wheel (.whl), and plain directories.
- name: Compare RPM packages
uses: abicheck/abicheck@v0.6.0
with:
old-library: libfoo-1.0-1.el9.x86_64.rpm
new-library: libfoo-1.1-1.el9.x86_64.rpm
Compare packages with debug info¶
Provide separate debug info packages for full type-level analysis via build-id resolution:
- name: Compare with debug info
uses: abicheck/abicheck@v0.6.0
with:
old-library: libfoo-1.0.rpm
new-library: libfoo-1.1.rpm
debug-info1: libfoo-debuginfo-1.0.rpm
debug-info2: libfoo-debuginfo-1.1.rpm
Compare Deb packages with development headers¶
- name: Compare Deb packages
uses: abicheck/abicheck@v0.6.0
with:
old-library: libfoo1_1.0-1_amd64.deb
new-library: libfoo1_1.1-1_amd64.deb
devel-pkg1: libfoo-dev_1.0-1_amd64.deb
devel-pkg2: libfoo-dev_1.1-1_amd64.deb
Compare tar archives (DSOs only)¶
- name: Compare SDK tarballs
uses: abicheck/abicheck@v0.6.0
with:
old-library: sdk-2.0.tar.gz
new-library: sdk-2.1.tar.gz
dso-only: true
Compare conda packages¶
- name: Compare conda packages
uses: abicheck/abicheck@v0.6.0
with:
old-library: pkg-v1.conda
new-library: pkg-v2.conda
Application compatibility check¶
There is no separate appcompat mode (it was folded into compare --used-by). Check whether your application binary is affected by a library
update by scoping a normal compare to it via extra-args (see the note in
GitHub Action: Application-scoped comparison
about the dedicated used-by input available in v0.6.0):
- uses: abicheck/abicheck@v0.6.0
with:
old-library: libfoo.so.1
new-library: build/libfoo.so.2
header: include/foo.h
extra-args: '--used-by build/myapp'
Quick symbol availability check (weak mode)¶
Verify a library provides all symbols an application needs by comparing it against itself (no real ABI change) — the app-scoped verdict reports COMPATIBLE only if every symbol it uses resolves: