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.5.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.5.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.5.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.5.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.5.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.5.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. For a quick inline set instead of a manifest, --expect linux-x86_64,macos-arm64,windows-x86_64 with optional --optional <ids> is equivalent; --discovered-only opts out of the coverage gate entirely. One of the three 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 (ADR-042, extended by ADR-049 Phase 7). 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-evaluation, 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. Pass --on-missing-required warn to aggregate if you want a missing required target to be reported but not fail the gate (the per-target gate decisions alone then decide the exit code); mark a target "required": false in the manifest (or --optional) 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 --format 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-evaluation 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.5.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.5.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.5.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.5.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.5.0
        with:
          old-library: baseline.json
          new-library: build/libfoo.so
          new-header: include/foo.h
          follow-deps: true

Inline PR annotations

Add --annotate to get ABI breaking changes as inline comments on the PR diff. See GitHub PR Annotations for full details.

      - uses: abicheck/abicheck@v0.5.0
        with:
          old-library: baseline.json
          new-library: build/libfoo.so
          new-header: include/foo.h
          extra-args: --annotate

Sticky PR comment

On pull_request runs the action posts a single, self-updating comment that groups every finding into Breaking, Needs review, and Safe 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-*. This means review-needed items (source breaks, risk, additions) surface as a green check with a ⚠️ Review recommended comment, while real ABI breaks turn the check red and post a comment.

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.5.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.

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.

"Safe" mirrors whatever the checker already classified as compatible — so public-header surface scoping (--scope-public-headers) 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.5.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.5.0
        with:
          old-library: baseline.json
          new-library: build/libfoo.so
          new-header: include/foo.h
          fail-on-breaking: true
          severity-addition: error   # exit code 1 if any new public API appears

When severity-addition: error: - 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.5.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.5.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.5.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.5.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.5.0
        with:
          old-library: pkg-v1.conda
          new-library: pkg-v2.conda

Application compatibility check

There is no separate appcompat mode (ADR-043 folded it 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 newer, post-v0.5.0 dedicated used-by input):

      - uses: abicheck/abicheck@v0.5.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.5.0
        with:
          old-library: build/libfoo.so
          new-library: build/libfoo.so
          install-deps: false
          extra-args: '--used-by build/myapp'