From 02069809697aa330c9f9e75bfed61794fbc771db Mon Sep 17 00:00:00 2001 From: Matt James Date: Mon, 13 Jul 2026 11:09:26 +0100 Subject: [PATCH 1/4] Bump version to 1.7.0 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 203e2c1..04de10b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,7 +19,7 @@ if(WASM) set(CMAKE_TOOLCHAIN_FILE "${CMAKE_SOURCE_DIR}/wasm/emsdk/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake") endif() -project(mgclient VERSION 1.6.0) +project(mgclient VERSION 1.7.0) # Minor version increase can also mean ABI incompatibility with previous # versions. IMPORTANT: Take care of the SO version manually. set(mgclient_SOVERSION 2) From 72a2abc2e0aeb9603d0b3b3cfdf0ecc27fd4a692 Mon Sep 17 00:00:00 2001 From: Matt James Date: Mon, 13 Jul 2026 11:37:04 +0100 Subject: [PATCH 2/4] bump SO version --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 04de10b..aea65f1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,7 +22,7 @@ endif() project(mgclient VERSION 1.7.0) # Minor version increase can also mean ABI incompatibility with previous # versions. IMPORTANT: Take care of the SO version manually. -set(mgclient_SOVERSION 2) +set(mgclient_SOVERSION 3) set(WASM OFF CACHE BOOL "Compile mgclient for wasm") add_definitions(-DMGCLIENT_VERSION="${mgclient_VERSION}") From 11426e75ede070642b35de988a038a54978e159b Mon Sep 17 00:00:00 2001 From: Matt James Date: Tue, 14 Jul 2026 06:33:01 +0100 Subject: [PATCH 3/4] undo bump SO version --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index aea65f1..04de10b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,7 +22,7 @@ endif() project(mgclient VERSION 1.7.0) # Minor version increase can also mean ABI incompatibility with previous # versions. IMPORTANT: Take care of the SO version manually. -set(mgclient_SOVERSION 3) +set(mgclient_SOVERSION 2) set(WASM OFF CACHE BOOL "Compile mgclient for wasm") add_definitions(-DMGCLIENT_VERSION="${mgclient_VERSION}") From e4203f1c87718dca6d432a1e5f4ddf41ac81f7f6 Mon Sep 17 00:00:00 2001 From: Gareth Lloyd Date: Tue, 14 Jul 2026 13:10:15 +0100 Subject: [PATCH 4/4] build(ci): add ABI/versioning consistency gate Add a check that the shared library's ABI stays consistent with its version numbers, so an incompatible change cannot ship under an unchanged SOVERSION. The check compares the built library against an in-tree baseline of the last released ABI (captured with libabigail's abidw) using abidiff, classifying the result as an incompatible change, compatible additions, or no change. It then enforces the versioning policy: an incompatible change requires both SOVERSION and project VERSION to increase; compatible additions require a VERSION increase; no ABI change requires nothing. Because a minor version bump may carry an ABI break, SOVERSION rather than a semver major is the compatibility guard. The policy is a pure core, so the whole matrix is unit-tested with plain data without building anything; the shell around it gathers facts and renders. Classification reads no version data from CMakeLists.txt: SOVERSION comes from the built library's ELF SONAME and the version from its own mg_client_version(). An update subcommand refreshes the baseline, and optionally sets the CMake variables, on an intended ABI change or release. The check and the policy tests run as a new job on push and pull_request. --- .github/workflows/ci.yml | 22 + .gitignore | 4 + tool/abicheck.py | 348 +++++ tool/mgclient.abi | 2798 +++++++++++++++++++++++++++++++++++++ tool/mgclient.abi.version | 1 + tool/test_abicheck.py | 82 ++ 6 files changed, 3255 insertions(+) create mode 100755 tool/abicheck.py create mode 100644 tool/mgclient.abi create mode 100644 tool/mgclient.abi.version create mode 100644 tool/test_abicheck.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45adeef..02929aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,28 @@ jobs: run: | ./tool/format.sh + abi_check: + if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + steps: + - name: Set-up repository + uses: actions/checkout@v4 + - name: Install environment + # build-essential, cmake, binutils (readelf) and python3 are preinstalled + # on the ubuntu-latest runner; only abigail-tools and pytest are missing. + # libssl-dev is kept explicit because the build does + # find_package(OpenSSL REQUIRED). python3-pytest avoids a pip install on + # the runner's externally-managed Python. + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends abigail-tools libssl-dev python3-pytest + - name: Test the policy core + run: | + python3 -m pytest tool/ -q + - name: Check ABI vs versioning + run: | + python3 ./tool/abicheck.py + build_and_test_apple: if: ${{ github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && inputs.apple) }} strategy: diff --git a/.gitignore b/.gitignore index bbb3b4e..0321cc0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ html/ # emsdk folder pulled from wasm/install_deps.sh wasm/emsdk + +# Python bytecode cache (tool/*.py) +__pycache__/ +*.pyc diff --git a/tool/abicheck.py b/tool/abicheck.py new file mode 100755 index 0000000..25dd451 --- /dev/null +++ b/tool/abicheck.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Check the shared-library ABI against an in-tree baseline and verify that +SOVERSION / project VERSION are consistent with it. + +The baseline is a snapshot of the last *released* ABI: + + * tool/mgclient.abi `abidw` XML; embeds the released SONAME + (e.g. soname='libmgclient.so.2'). + * tool/mgclient.abi.version the released project version (one line). + +Ordinary PRs must NOT regenerate the baseline: it is the fixed reference that +lets us detect an incompatible change shipped under an unchanged SOVERSION. +Refresh it only when intentionally changing the ABI / cutting a release, with +`tool/abicheck.py update ...`. + +In `check` mode nothing is read from CMakeLists.txt: the *built* library is the +source of truth. SOVERSION comes from its ELF SONAME and the project version +from its own mg_client_version(). CMakeLists.txt is only *written*, by `update`. + +Policy (a minor bump may carry an ABI break, so SOVERSION, not a semver major, +is the compatibility guard): + + * ABI incompatible vs baseline (symbol removed or signature changed) + -> SOVERSION MUST be higher than the baseline's, AND + project VERSION MUST be higher than the baseline's. [hard fail] + * ABI compatible additions vs baseline (only new symbols) + -> project VERSION MUST be higher than the baseline's. [hard fail] + SOVERSION should be unchanged (a bump is unnecessary -> [warning]). + * No ABI change vs baseline + -> nothing required. + +The check splits into a pure policy core (`evaluate`) and an I/O shell (fact +gathering + rendering), so the policy is testable without building anything; +see tool/test_abicheck.py. + +Usage: + tool/abicheck.py # check working tree vs committed baseline + tool/abicheck.py update # refresh baseline from current tree + tool/abicheck.py update --version 2.0.0 --soversion 3 + # set CMake vars, rebuild, refresh baseline + +Exit: 0 = ok, 1 = policy violation, 2 = tooling/build error. +""" +from __future__ import annotations + +import argparse +import ctypes +import os +import re +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from enum import Enum, IntFlag +from pathlib import Path +from typing import NoReturn + +ROOT = Path(__file__).resolve().parent.parent +CMAKELISTS = ROOT / "CMakeLists.txt" +BASELINE = ROOT / "tool" / "mgclient.abi" +BASELINE_VER = ROOT / "tool" / "mgclient.abi.version" + +EXIT_OK = 0 +EXIT_POLICY = 1 +EXIT_TOOLING = 2 + + +class Abidiff(IntFlag): + """abidiff exit-code bits (libabigail).""" + + ERROR = 1 + USAGE = 2 + CHANGE = 4 + INCOMPATIBLE = 8 + + +class AbiChange(Enum): + NONE = "no ABI change" + COMPATIBLE = "compatible additions" + INCOMPATIBLE = "incompatible change" + + +class Severity(Enum): + FAIL = "FAIL" + WARN = "WARN" + + +class Code(Enum): + SOVERSION_NOT_BUMPED = "soversion-not-bumped" + VERSION_NOT_BUMPED = "version-not-bumped" + UNNECESSARY_SOVERSION_BUMP = "unnecessary-soversion-bump" + + +@dataclass(frozen=True) +class Finding: + severity: Severity + code: Code + message: str + + +@dataclass(frozen=True) +class Versions: + version: str # project version, "X.Y.Z" + soversion: int + + +# --- pure policy core (no I/O; see tool/test_abicheck.py) ------------------- # +def version_tuple(v: str) -> tuple[int, ...]: + if not re.fullmatch(r"\d+(\.\d+)*", v): + raise ValueError(f"not a numeric version: {v!r}") + return tuple(int(x) for x in v.split(".")) + + +def version_gt(a: str, b: str) -> bool: + return version_tuple(a) > version_tuple(b) + + +def evaluate(change: AbiChange, base: Versions, built: Versions) -> list[Finding]: + """Apply the versioning policy to a classified change. Returns the findings + (empty = consistent). Pure: the result is a function of its arguments.""" + out: list[Finding] = [] + if change is AbiChange.INCOMPATIBLE: + if not built.soversion > base.soversion: + out.append(Finding( + Severity.FAIL, Code.SOVERSION_NOT_BUMPED, + f"ABI is incompatible with the last release but SOVERSION did " + f"not increase ({base.soversion} -> {built.soversion}). Bump it, " + f"then `tool/abicheck.py update`.")) + if not version_gt(built.version, base.version): + out.append(Finding( + Severity.FAIL, Code.VERSION_NOT_BUMPED, + f"ABI is incompatible with the last release but project VERSION " + f"did not increase ({base.version} -> {built.version}). Bump it.")) + elif change is AbiChange.COMPATIBLE: + if not version_gt(built.version, base.version): + out.append(Finding( + Severity.FAIL, Code.VERSION_NOT_BUMPED, + f"New public symbols were added since the last release but project " + f"VERSION did not increase ({base.version} -> {built.version}). " + f"Bump it (at least a minor).")) + if built.soversion != base.soversion: + out.append(Finding( + Severity.WARN, Code.UNNECESSARY_SOVERSION_BUMP, + f"SOVERSION was bumped ({base.soversion} -> {built.soversion}) but " + f"the change is backward-compatible; a bump is unnecessary.")) + else: # AbiChange.NONE + if built.soversion != base.soversion: + out.append(Finding( + Severity.WARN, Code.UNNECESSARY_SOVERSION_BUMP, + f"SOVERSION was bumped ({base.soversion} -> {built.soversion}) but " + f"no ABI change was detected.")) + return out + + +def has_failure(findings: list[Finding]) -> bool: + return any(f.severity is Severity.FAIL for f in findings) + + +# --- I/O shell ------------------------------------------------------------- # +def die(msg: str, code: int = EXIT_TOOLING) -> NoReturn: + print(f"error: {msg}", file=sys.stderr) + sys.exit(code) + + +def run(cmd: list[str]) -> subprocess.CompletedProcess: + # LC_ALL=C keeps parsed tool output locale-stable. + return subprocess.run(cmd, capture_output=True, text=True, + env={**os.environ, "LC_ALL": "C"}) + + +def require(*tools: str) -> None: + for tool in tools: + if shutil.which(tool) is None: + die(f"{tool} not found (install abigail-tools / binutils)") + + +def build_lib(build_dir: Path) -> Path: + """Configure + build libmgclient.so (RelWithDebInfo, so DWARF is present).""" + jobs = str(os.cpu_count() or 4) + for cmd in ( + ["cmake", "-B", str(build_dir), "-S", str(ROOT), + "-DCMAKE_BUILD_TYPE=RelWithDebInfo", "-DBUILD_TESTING=OFF"], + ["cmake", "--build", str(build_dir), "--target", "mgclient-shared", + "--", "-j", jobs], + ): + res = run(cmd) + if res.returncode != 0: + sys.stderr.write(res.stdout + res.stderr) + die("build failed") + libs = [p for p in build_dir.rglob("libmgclient.so.*") + if re.fullmatch(r"libmgclient\.so\.\d+", p.name)] + if len(libs) != 1: + die(f"expected exactly one libmgclient.so., found {len(libs)}") + return libs[0] + + +def elf_soname(lib: Path) -> str: + """SONAME string from the ELF dynamic section, e.g. 'libmgclient.so.2'.""" + m = re.search(r"Library soname: \[(.+?)\]", run(["readelf", "-d", str(lib)]).stdout) + if not m: + die(f"no SONAME in {lib}") + return m.group(1) + + +def soversion_of(soname: str) -> int: + m = re.search(r"\.so\.(\d+)$", soname) + if not m: + die(f"cannot parse SOVERSION from soname {soname!r}") + return int(m.group(1)) + + +def lib_version(lib: Path) -> str: + """Project version compiled into the library, read by calling its own + mg_client_version(). No fallback: a load failure is a hard error rather + than a guess.""" + try: + dll = ctypes.CDLL(str(lib)) + except OSError as e: + die(f"cannot load {lib} to read its version: {e}") + dll.mg_client_version.restype = ctypes.c_char_p + v = dll.mg_client_version() + if not v: + die(f"mg_client_version() returned nothing for {lib}") + return v.decode() + + +def baseline_soname() -> str: + m = re.search(r"soname='(libmgclient\.so\.\d+)'", BASELINE.read_text()) + if not m: + die(f"no soname in baseline {BASELINE}") + return m.group(1) + + +def abidiff_rc(baseline: Path, lib: Path, *extra: str) -> tuple[int, str]: + res = run(["abidiff", *extra, str(baseline), str(lib)]) + if res.returncode & (Abidiff.ERROR | Abidiff.USAGE): + sys.stderr.write(res.stdout + res.stderr) + die(f"abidiff failed to run (exit {res.returncode})") + return res.returncode, res.stdout + + +def classify(baseline: Path, lib: Path) -> tuple[AbiChange, str]: + """Classify the ABI change vs the baseline from abidiff exit codes. + + abidiff's INCOMPATIBLE exit bit under-reports: a changed parameter type is + reported textually but leaves the bit clear, so trusting the bit would pass + a real break. Instead, a second run with --no-added-syms drops additions + from the exit code, so anything that still registers is a removal or a + changed signature, i.e. incompatible. Returns the change and the + human-readable diff (for the log).""" + rc, report = abidiff_rc(baseline, lib) + if rc == 0: + return AbiChange.NONE, report + strict_rc, _ = abidiff_rc(baseline, lib, "--no-added-syms") + if strict_rc == 0: + return AbiChange.COMPATIBLE, report + return AbiChange.INCOMPATIBLE, report + + +def read_versions_or_die(label: str, version: str, soversion: int) -> Versions: + try: + version_tuple(version) + except ValueError as e: + die(f"{label} version: {e}") + return Versions(version, soversion) + + +def cmd_check(_args: argparse.Namespace) -> None: + require("abidiff", "readelf") + if not (BASELINE.exists() and BASELINE_VER.exists()): + die("no ABI baseline found. Create it with: tool/abicheck.py update") + + base = read_versions_or_die( + "baseline", BASELINE_VER.read_text().strip(), soversion_of(baseline_soname())) + print(f">> baseline version/soversion: {base.version} / {base.soversion}") + + with tempfile.TemporaryDirectory() as bd: + lib = build_lib(Path(bd)) + built = read_versions_or_die( + "built", lib_version(lib), soversion_of(elf_soname(lib))) + print(f">> built version/soversion: {built.version} / {built.soversion}") + print(">> abidiff (baseline -> built library)") + change, report = classify(BASELINE, lib) + + print(report, end="") + print(f">> classification: {change.value}") + + findings = evaluate(change, base, built) + for f in findings: + print(f"{f.severity.value}: {f.message}") + + if has_failure(findings): + print(">> RESULT: versioning is INCONSISTENT with the ABI change.") + sys.exit(EXIT_POLICY) + print(">> RESULT: versioning is consistent with the ABI baseline.") + + +def set_cmake_var(pattern: str, replacement: str, label: str) -> None: + new, n = re.subn(pattern, replacement, CMAKELISTS.read_text(), count=1) + if n != 1: + die(f"could not update {label} in {CMAKELISTS}") + CMAKELISTS.write_text(new) + print(f">> set {label}") + + +def cmd_update(args: argparse.Namespace) -> None: + require("abidw", "readelf") + if args.version: + if not re.fullmatch(r"\d+\.\d+\.\d+", args.version): + die("--version must be X.Y.Z") + set_cmake_var(r"project\(mgclient VERSION [0-9]+\.[0-9]+\.[0-9]+", + f"project(mgclient VERSION {args.version}", + f"project VERSION = {args.version}") + if args.soversion is not None: + set_cmake_var(r"set\(mgclient_SOVERSION [0-9]+\)", + f"set(mgclient_SOVERSION {args.soversion})", + f"mgclient_SOVERSION = {args.soversion}") + + with tempfile.TemporaryDirectory() as bd: + lib = build_lib(Path(bd)) + res = run(["abidw", "--no-corpus-path", "--no-show-locs", + "--out-file", str(BASELINE), str(lib)]) + if res.returncode != 0: + sys.stderr.write(res.stdout + res.stderr) + die("abidw failed") + BASELINE_VER.write_text(lib_version(lib) + "\n") + + so = soversion_of(baseline_soname()) + ver = BASELINE_VER.read_text().strip() + print(f">> baseline refreshed: version {ver}, soname libmgclient.so.{so}") + print(f">> commit {BASELINE.relative_to(ROOT)}, " + f"{BASELINE_VER.relative_to(ROOT)} and CMakeLists.txt together.") + + +def main() -> None: + p = argparse.ArgumentParser(description="mgclient ABI/version consistency gate") + sub = p.add_subparsers(dest="cmd") + sub.add_parser("check", help="check working tree vs committed baseline (default)") + up = sub.add_parser("update", help="rebuild and refresh the baseline (+ optionally set CMake vars)") + up.add_argument("--version", help="set project VERSION (X.Y.Z) in CMakeLists.txt") + up.add_argument("--soversion", type=int, help="set mgclient_SOVERSION in CMakeLists.txt") + args = p.parse_args() + (cmd_update if args.cmd == "update" else cmd_check)(args) + + +if __name__ == "__main__": + main() diff --git a/tool/mgclient.abi b/tool/mgclient.abi new file mode 100644 index 0000000..c5279ad --- /dev/null +++ b/tool/mgclient.abi @@ -0,0 +1,2798 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tool/mgclient.abi.version b/tool/mgclient.abi.version new file mode 100644 index 0000000..bd8bf88 --- /dev/null +++ b/tool/mgclient.abi.version @@ -0,0 +1 @@ +1.7.0 diff --git a/tool/test_abicheck.py b/tool/test_abicheck.py new file mode 100644 index 0000000..ebf891a --- /dev/null +++ b/tool/test_abicheck.py @@ -0,0 +1,82 @@ +"""Unit tests for the pure policy core of abicheck.py. + +These exercise `evaluate()` and the version helpers directly, with no build and +no abidiff, so the whole policy matrix runs instantly. The I/O shell (build, +classify, ELF/ctypes reads) is covered by running `tool/abicheck.py` in CI. +""" +import pytest + +from abicheck import ( + AbiChange, + Code, + Severity, + Versions, + evaluate, + has_failure, + version_gt, + version_tuple, +) + + +def codes(findings): + return {f.code for f in findings} + + +# --- evaluate(): the full policy matrix ------------------------------------ # +@pytest.mark.parametrize( + "change, base, built, expected", + [ + # incompatible: both SOVERSION and VERSION must increase + (AbiChange.INCOMPATIBLE, Versions("1.7.0", 2), Versions("2.0.0", 3), set()), + (AbiChange.INCOMPATIBLE, Versions("1.7.0", 2), Versions("2.0.0", 2), + {Code.SOVERSION_NOT_BUMPED}), + (AbiChange.INCOMPATIBLE, Versions("1.7.0", 2), Versions("1.7.0", 3), + {Code.VERSION_NOT_BUMPED}), + (AbiChange.INCOMPATIBLE, Versions("1.7.0", 2), Versions("1.7.0", 2), + {Code.SOVERSION_NOT_BUMPED, Code.VERSION_NOT_BUMPED}), + # compatible additions: VERSION must increase, SOVERSION should not + (AbiChange.COMPATIBLE, Versions("1.6.0", 2), Versions("1.7.0", 2), set()), + (AbiChange.COMPATIBLE, Versions("1.6.0", 2), Versions("1.6.0", 2), + {Code.VERSION_NOT_BUMPED}), + (AbiChange.COMPATIBLE, Versions("1.6.0", 2), Versions("1.7.0", 3), + {Code.UNNECESSARY_SOVERSION_BUMP}), + # no change: nothing required, but a stray SOVERSION bump warns + (AbiChange.NONE, Versions("1.7.0", 2), Versions("1.7.0", 2), set()), + (AbiChange.NONE, Versions("1.7.0", 2), Versions("1.8.0", 3), + {Code.UNNECESSARY_SOVERSION_BUMP}), + ], +) +def test_evaluate_matrix(change, base, built, expected): + assert codes(evaluate(change, base, built)) == expected + + +def test_unnecessary_soversion_bump_is_warning_not_failure(): + findings = evaluate(AbiChange.COMPATIBLE, Versions("1.6.0", 2), Versions("1.7.0", 3)) + assert not has_failure(findings) + assert all(f.severity is Severity.WARN for f in findings) + + +def test_soversion_not_bumped_is_failure(): + findings = evaluate(AbiChange.INCOMPATIBLE, Versions("1.7.0", 2), Versions("2.0.0", 2)) + assert has_failure(findings) + + +# --- version helpers ------------------------------------------------------- # +@pytest.mark.parametrize( + "a, b, expected", + [ + ("1.7.0", "1.6.0", True), + ("1.6.0", "1.7.0", False), + ("1.7.0", "1.7.0", False), + ("1.10.0", "1.9.0", True), # numeric, not lexical + ("2.0.0", "1.99.99", True), + ], +) +def test_version_gt(a, b, expected): + assert version_gt(a, b) is expected + + +@pytest.mark.parametrize("bad", ["1.7.0-rc1", "v1.7.0", "1.7.x", ""]) +def test_version_tuple_rejects_non_numeric(bad): + with pytest.raises(ValueError): + version_tuple(bad)