Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 248 additions & 0 deletions .github/workflows/abi_compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright Contributors to the OpenColorIO Project.
#
# GitHub Actions workflow file
# https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions
#
# ABI compatibility gate for patch releases.
#
# Patch releases are cut from the RB-MAJOR.MINOR release branches (e.g. RB-2.6),
# and a patch release must not break the ABI promised by the library's SONAME.
# OpenColorIO sets SOVERSION = MAJOR.MINOR (see the top-level CMakeLists.txt), so
# every binary linked against libOpenColorIO.so.<MAJOR.MINOR> -- i.e. built
# against X.Y.0 -- is expected to keep working with any later X.Y.Z without
# recompilation.
#
# This workflow enforces that contract *before* a patch ships: it runs on every
# pull request targeting an RB-* branch, builds the shared library from the
# post-merge state of the PR and from the series anchor (X.Y.0), and compares
# the two ABIs with libabigail's `abidiff`. If the change would break the
# series ABI, the check fails and the PR is blocked.
#
# -----------------------------------------------------------------------------
# Why libabigail (`abidiff`)?
# -----------------------------------------------------------------------------
# Several tools can compare C/C++ ABIs. The options that were considered:
#
# * libabigail (abidiff / abidw) -- CHOSEN.
# Actively maintained by Sourceware/Red Hat and used as the ABI gate by
# the Linux kernel, systemd, and most major distros. Reads DWARF debug
# info straight from the built .so (no source-level reparsing), has a
# deep model of C++ (vtables, templates, member layout, mangling), gives
# scriptable bitmask exit codes that make CI gating trivial, supports
# `--headers-dir` to restrict the diff to the public API, and supports
# suppression files for the rare intentional exception. Installable in
# the ASWF ci-ocio container via dnf.
#
# * abi-compliance-checker (+ abi-dumper) -- the historical ABI Laboratory
# tool. Produces nice HTML reports, but is Perl-based, more loosely
# maintained, needs an extra dumper/vtable-dumper toolchain, and its
# header-driven mode is brittle with modern C++. Worse fit for an
# unattended CI gate; its exit handling is also less granular.
#
# * Symbol-list diffing (nm -D / readelf --dyn-syms, or `abidw` symbol-only)
# -- trivial to run, but only catches added/removed *symbols*. It is
# blind to the changes that most often break consumers silently: struct
# layout, vtable order, enum/parameter/return-type changes. Too coarse to
# trust as the sole gate.
#
# * Off-the-shelf "ABI check" marketplace Actions -- thin wrappers around one
# of the above, generally unmaintained and unpinned. Calling the tool
# directly is more transparent and easier to pin/audit.
#
# libabigail gives the strongest correctness guarantee for the least
# maintenance, which is why it is used here.
# -----------------------------------------------------------------------------

name: ABI Compatibility

on:
# Only run on pull requests that target a release branch. Patch releases are
# the only releases that must preserve the ABI, and they are always prepared
# on RB-MAJOR.MINOR branches.
pull_request:
branches:
- 'RB-*'

# Manual trigger, e.g. to dry-run the check against a release branch before
# opening the PR.
workflow_dispatch:
inputs:
base_branch:
description: 'Release branch the change targets (e.g. RB-2.6)'
required: true
type: string
ref:
description: 'Ref to check as the proposed change (optional; defaults to the launched ref)'
required: false
type: string

# A PR can be pushed to repeatedly; this double build is expensive, so cancel
# superseded runs.
concurrency:
group: abi-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
abi:
name: 'ABI compatibility check'
runs-on: ubuntu-latest
# Build inside the same ASWF image used by the CI workflow so the toolchain
# and dependencies match what releases are actually built with. Pinned to a
# stable VFX CY; bump this together with the CI matrix.
container:
image: aswf/ci-ocio:2025

steps:
- name: Install libabigail
run: |
dnf install -y libabigail \
|| { dnf install -y epel-release && dnf install -y libabigail; }
abidiff --version

- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Full history + tags are needed to resolve the anchor tag and to add
# a worktree for it. For a pull_request, github.ref is the merge ref
# (refs/pull/N/merge), i.e. the post-merge state we want to validate.
fetch-depth: 0
fetch-tags: true
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }}

- name: Determine ABI baseline
id: versions
shell: bash
# The event-specific values (which branch the change targets, and a
# description of the change) are resolved here; the baseline lookup
# itself lives in a reusable, locally testable script.
run: |
share/abi/determine_abi_baseline.sh \
"${{ github.event_name == 'workflow_dispatch' && inputs.base_branch || github.base_ref }}" \
"${{ github.event_name == 'workflow_dispatch' && format('{0} -> {1}', inputs.ref || github.ref_name, inputs.base_branch) || format('PR #{0} -> {1}', github.event.pull_request.number, github.base_ref) }}"

- name: Write build helper
if: steps.versions.outputs.skip != 'true'
shell: bash
run: |
set -euo pipefail
cat > "${RUNNER_TEMP}/abi_build.sh" <<'EOF'
#!/usr/bin/env bash
# Build only the shared library so abidiff can read the DWARF ABI.
# RelWithDebInfo is deliberate: it emits -g AND is the only optimized
# build type OCIO leaves unstripped (share/cmake/macros/StripUtils.cmake
# strips Release/MinSizeRel, which would erase the debug info abidiff
# needs). Everything not needed for the .so is turned off to keep the
# double build as fast as possible.
set -euo pipefail
SRC="$1"
OUT="$2"
cmake -S "${SRC}" -B "${OUT}/build" \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DCMAKE_INSTALL_PREFIX="${OUT}/install" \
-DBUILD_SHARED_LIBS=ON \
-DOCIO_USE_SOVERSION=ON \
-DOCIO_BUILD_PYTHON=OFF \
-DOCIO_BUILD_APPS=OFF \
-DOCIO_BUILD_TESTS=OFF \
-DOCIO_BUILD_GPU_TESTS=OFF \
-DOCIO_BUILD_OPENFX=OFF \
-DOCIO_BUILD_DOCS=OFF \
-DOCIO_INSTALL_EXT_PACKAGES=MISSING \
-DOCIO_WARNING_AS_ERROR=OFF
cmake --build "${OUT}/build" --target install -- -j"$(nproc)"
EOF
chmod +x "${RUNNER_TEMP}/abi_build.sh"

- name: Build baseline (${{ steps.versions.outputs.baseline }})
if: steps.versions.outputs.skip != 'true'
shell: bash
run: |
set -euo pipefail
git worktree add --detach "${RUNNER_TEMP}/baseline-src" "${{ steps.versions.outputs.baseline }}"
"${RUNNER_TEMP}/abi_build.sh" "${RUNNER_TEMP}/baseline-src" "${RUNNER_TEMP}/old"

- name: Build proposed change
if: steps.versions.outputs.skip != 'true'
shell: bash
run: |
set -euo pipefail
"${RUNNER_TEMP}/abi_build.sh" "${GITHUB_WORKSPACE}" "${RUNNER_TEMP}/new"

- name: Compare ABI (abidiff)
if: steps.versions.outputs.skip != 'true'
shell: bash
run: |
set -euo pipefail

OLD_LIB=$(find "${RUNNER_TEMP}/old/install" -type f -name 'libOpenColorIO.so.*' | head -1)
NEW_LIB=$(find "${RUNNER_TEMP}/new/install" -type f -name 'libOpenColorIO.so.*' | head -1)
if [ -z "${OLD_LIB}" ] || [ -z "${NEW_LIB}" ]; then
echo "::error::Could not locate the built shared libraries (old='${OLD_LIB}' new='${NEW_LIB}')."
exit 1
fi
echo "Baseline library: ${OLD_LIB}"
echo "Proposed library: ${NEW_LIB}"

ARGS=(
# Restrict the comparison to types reachable from the public headers.
--headers-dir1 "${RUNNER_TEMP}/old/install/include"
--headers-dir2 "${RUNNER_TEMP}/new/install/include"
# Added symbols are forward-compatible for existing consumers, so do
# not count them as a breaking change (they would warrant a minor
# bump, but they do not break binaries linked against the baseline).
--no-added-syms
# Fail loudly instead of silently degrading to a weaker, symbol-only
# comparison if a build somehow lacks debug info.
--fail-no-debug-info
)
SUPPR="${GITHUB_WORKSPACE}/share/abi/ocio.abignore"
if [ -f "${SUPPR}" ]; then
ARGS+=(--suppressions "${SUPPR}")
fi

set +e
abidiff "${ARGS[@]}" "${OLD_LIB}" "${NEW_LIB}" | tee "${RUNNER_TEMP}/abidiff.txt"
STATUS=${PIPESTATUS[0]}
set -e

# abidiff exit code is a bitmask:
# 1 = ABIDIFF_ERROR (tool error)
# 2 = ABIDIFF_USAGE_ERROR
# 4 = ABIDIFF_ABI_CHANGE
# 8 = ABIDIFF_ABI_INCOMPATIBLE_CHANGE
{
echo "## ABI compatibility check"
echo ""
echo "- Baseline: \`${{ steps.versions.outputs.baseline }}\`"
echo "- Change: ${{ steps.versions.outputs.current_desc }}"
echo ""
echo '```'
cat "${RUNNER_TEMP}/abidiff.txt"
echo '```'
} >> "${GITHUB_STEP_SUMMARY}"

if (( STATUS & 2 )); then
echo "::error::abidiff usage error (exit ${STATUS})."
exit 1
fi
if (( STATUS & 1 )); then
echo "::error::abidiff failed to run (exit ${STATUS}); a build may be missing debug info."
exit 1
fi
if (( STATUS & (4 | 8) )); then
echo "::error::This change breaks the ABI of the ${{ steps.versions.outputs.baseline }} series. A patch release must preserve the ABI. If the change is intentional and verified safe, add a justified suppression to share/abi/ocio.abignore."
exit 1
fi
echo "ABI is compatible with the baseline. ✅"

- name: Upload abidiff report
if: steps.versions.outputs.skip != 'true' && always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: abidiff-report
path: ${{ runner.temp }}/abidiff.txt
if-no-files-found: ignore
77 changes: 77 additions & 0 deletions share/abi/determine_abi_baseline.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: BSD-3-Clause
# Copyright Contributors to the OpenColorIO Project.
#
# Resolve the ABI baseline for the ABI compatibility check
# (.github/workflows/abi_compatibility.yml).
#
# Given the release branch a change targets (RB-MAJOR.MINOR), this prints the
# tag to use as the ABI baseline: the series anchor vMAJOR.MINOR.0 (the ABI
# first published under the MAJOR.MINOR SONAME), falling back to the lowest
# existing patch tag in the series if the anchor tag is missing.
#
# Usage:
# determine_abi_baseline.sh <base-ref> [change-description]
#
# <base-ref> The targeted branch, e.g. "RB-2.6".
# <change-description> Human-readable description of the proposed change, used
# only for reporting, e.g. "PR #123 -> RB-2.6".
# Defaults to <base-ref>.
#
# Results are written as key=value lines to the file named by $GITHUB_OUTPUT
# when set (GitHub Actions step outputs), otherwise to stdout for local use:
# skip=true|false
# baseline=<tag> (only when skip=false)
# current_desc=<text> (only when skip=false)

set -euo pipefail

BASE_REF="${1:?usage: determine_abi_baseline.sh <base-ref> [change-description]}"
CURRENT_DESC="${2:-${BASE_REF}}"

# Emit a key=value step output (or print it when run outside GitHub Actions).
emit() {
if [ -n "${GITHUB_OUTPUT:-}" ]; then
echo "$1" >> "${GITHUB_OUTPUT}"
else
echo "$1"
fi
}

# The CI container checks out the workspace as a different user than the one git
# expects; mark everything safe so git/worktree commands work. Harmless locally.
git config --global --add safe.directory '*'

echo "Target release branch: ${BASE_REF}"

# The release branch name encodes the series: RB-MAJOR.MINOR.
if [[ ! "${BASE_REF}" =~ ^RB-([0-9]+)\.([0-9]+)$ ]]; then
echo "::notice::Base branch '${BASE_REF}' is not an RB-MAJOR.MINOR release branch; skipping ABI check."
emit "skip=true"
exit 0
fi
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"

# Anchor on X.Y.0, the ABI first published under the MAJOR.MINOR SONAME.
# Comparing the proposed change directly against the series anchor enforces
# compatibility with every consumer linked against any release in the series.
# Fall back to the lowest existing patch tag if X.Y.0 is missing for some reason.
ANCHOR="v${MAJOR}.${MINOR}.0"
if git rev-parse -q --verify "refs/tags/${ANCHOR}" >/dev/null; then
BASELINE="${ANCHOR}"
else
# `|| true`: an empty match (or head closing the pipe early under pipefail)
# is an expected outcome handled by the emptiness check below, not an error.
BASELINE=$(git tag --list "v${MAJOR}.${MINOR}.*" | grep -E "^v${MAJOR}\.${MINOR}\.[0-9]+$" | sort -V | head -1 || true)
fi
if [ -z "${BASELINE}" ]; then
echo "::notice::The ${MAJOR}.${MINOR} series has no published release tag yet, so there is no ABI to preserve. Skipping."
emit "skip=true"
exit 0
fi

echo "Comparing '${CURRENT_DESC}' against baseline ${BASELINE}"
emit "skip=false"
emit "baseline=${BASELINE}"
emit "current_desc=${CURRENT_DESC}"
38 changes: 38 additions & 0 deletions share/abi/ocio.abignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright Contributors to the OpenColorIO Project.
#
# libabigail suppression specification for the OpenColorIO ABI compatibility
# check (see .github/workflows/abi_compatibility.yml).
#
# Reference:
# https://sourceware.org/libabigail/manual/libabigail-concepts.html#suppression-specifications
#
# The ABI check is run with `--headers-dir`, so abidiff already restricts the
# comparison to the types that are reachable from the installed public headers
# (include/OpenColorIO/*.h). Internal symbols and statically linked third-party
# dependencies (expat, yaml-cpp, pystring, Imath, minizip-ng, zlib, ...) are
# therefore ignored automatically and do NOT need entries here.
#
# Add a suppression below ONLY to allow-list an *intentional* ABI change that
# you have verified is safe for consumers in the current major.minor series
# (this should be very rare in a patch release). Every entry must:
# * carry a comment justifying why the change is ABI-safe, and
# * reference the PR / issue that introduced it.
# Entries become obsolete once the next minor/major release re-baselines the
# ABI, so prune them at that point.
#
# ----------------------------------------------------------------------------
# Example (kept commented out on purpose):
#
# Allow a new data member appended at the end of an opaque/impl-detail struct,
# which does not change the layout seen by existing consumers:
#
# [suppress_type]
# name = OpenColorIO_v2_6::SomeType::Impl
# has_data_member_inserted_at = end
#
# Allow a newly added member function (additions are backward compatible):
#
# [suppress_function]
# name_regexp = ^OpenColorIO_v2_6::.*::newlyAddedMethod
# ----------------------------------------------------------------------------
Loading