Skip to content

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's targets:/profiles: block, the check-project.yml matrix) 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 exit 1 — 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; aggregate combines those (a policy-blocked COMPATIBLE still fails, a demoted BREAKING can pass) rather than recomputing a gate from the verdict. The exit code is 0 pass / 1 coverage 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. a scan budget overflow) / 2 source break / 4 ABI break (see abicheck aggregate for the full contract). A missing required build is a coverage failure at 1 — never promoted to a fake ABI-break exit 4; a contract-coverage gap (evidence incomplete for a target that did report) is a separate 1 for a different reason, and the JSON output's own contract_coverage block 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): an api_break-severity finding blocks under fail-on-api-break alone, and a breaking-severity one under fail-on-breaking alone — both map to a fixed exit code (2 / 4) regardless of any config. A risk-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/risk share the potential_breaking category and block only when both that category is configured error and fail-on-api-break; a breaking-severity finding needs both abi_breaking: error and fail-on-breaking. The matching fail-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: new posts 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: always comments every run, including a clean No ABI changes result; never disables it.
  • pr-comment-detail: full lists every change with source locations and expands all sections; summary reduces 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:

      - uses: abicheck/abicheck@v0.6.0
        with:
          old-library: build/libfoo.so
          new-library: build/libfoo.so
          install-deps: false
          extra-args: '--used-by build/myapp'