From c88362c414327df8ade5323ae0f5febf6cff64ab Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Oct 2026 06:54:32 +0700 Subject: [PATCH] ci(plugins): check the PluginKit minimum and test the ABI verdict --- .claude/rules/plugin-system.md | 4 +- .../fix-issue/references/platform-apple.md | 2 +- scripts/check-pluginkit-abi.sh | 39 ++-- scripts/ci/test_check_pluginkit_abi.py | 192 ++++++++++++++++++ 4 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 scripts/ci/test_check_pluginkit_abi.py diff --git a/.claude/rules/plugin-system.md b/.claude/rules/plugin-system.md index ea3c0602ab..ea0d5c7242 100644 --- a/.claude/rules/plugin-system.md +++ b/.claude/rules/plugin-system.md @@ -28,8 +28,8 @@ TableProPluginKit is built with Library Evolution, so an already-built plugin ke - The build stamps that value into `TableProPluginKitVersion` of the app and of every plugin. A plugin's `Info.plist` holds `TABLEPRO_PLUGINKIT_VERSION` and is preprocessed, because every shipped app reads the key with `as? Int`; the source file therefore fails `plutil -lint`. - After an additive change, built plugins keep loading, and the bump makes an older app refuse a plugin rebuilt against the new API instead of failing its load. - A breaking change also raises `TABLEPRO_PLUGINKIT_MINIMUM_VERSION`. Every tag ships every registry plugin built against its own kit, so the release that carries the change carries plugins in its range. -- `scripts/check-pluginkit-abi.sh [--frozen] [base]` (`verify.sh abi [base]`) builds PluginKit at the base, by default the newest `v*` tag reachable from HEAD, and at HEAD with Xcode's ABI checker (`RUN_SWIFT_ABI_CHECKER_TOOL`). macOS Tests runs it whenever it compiles the plugins, with `--frozen` on a PR into `release/*`. -- A checker error is a breaking change: the kit and the minimum must both be one past the base's kit, so the host refuses every plugin built before it. A baseline that changed with no error needs the kit one past the base's; an identical one needs the base's kit. `--frozen` fails on any change. +- `scripts/check-pluginkit-abi.sh [--frozen] [base]` (`verify.sh abi [base]`) builds PluginKit at the base, by default the newest `v*` tag reachable from HEAD, and at HEAD with Xcode's ABI checker (`RUN_SWIFT_ABI_CHECKER_TOOL`). When CI runs it: `.claude/rules/ci.md`, macOS Tests. +- A checker error is a breaking change: the kit and the minimum must both be one past the base's kit, so the host refuses every plugin built before it. A baseline that changed with no error needs the kit one past the base's and the base's minimum; an identical one needs the base's kit and minimum. `--frozen` fails on any ABI change. - The checker reports breakage only; an additive change shows as a changed baseline. Its errors are in the log `verify.sh abi` prints. - Mark a public enum `@frozen` only when its case set is closed. `PluginCapability` and the transfer structs stay non-frozen so they can grow. - PluginKit code with no callers stays: removing it is an ABI change. diff --git a/.claude/skills/fix-issue/references/platform-apple.md b/.claude/skills/fix-issue/references/platform-apple.md index 40ab83906b..d384e9155f 100644 --- a/.claude/skills/fix-issue/references/platform-apple.md +++ b/.claude/skills/fix-issue/references/platform-apple.md @@ -38,7 +38,7 @@ The macOS app and the iOS app, with everything they build from. Every profile ha | `package` | ` [regex]` | `Packages/` changed and you want the whole package; `test ` already routes a package suite | | `ios` | ` [Suite...]`, at least one | `TableProMobile/` or a file it compiles changed | | `plugins` | none | anything under `Plugins/` changed. Builds `AllPlugins`, building the HANA helper first when it is missing and Go is installed | -| `abi` | `` | PluginKit changed: after the commit, before the push (`.claude/rules/plugin-system.md`, ABI) | +| `abi` | `[base]`, default the newest `v*` tag | PluginKit changed (`.claude/rules/plugin-system.md`, ABI) | | `lint` | AGENTS | every changed Swift file | | `l10n` | none | strings, or plugin or package messages, changed. Passes only when every plugin and package string is in both the macOS and iOS catalogs, and prints the `--add` command for each target that failed | | `docs` | `[docs-checkout]`, default `tablepro-docs` beside the main checkout | AGENTS Every change 4 applies, or a menu, shortcut, driver list or docs.tablepro.app link changed | diff --git a/scripts/check-pluginkit-abi.sh b/scripts/check-pluginkit-abi.sh index 70c472110d..41602eeef7 100755 --- a/scripts/check-pluginkit-abi.sh +++ b/scripts/check-pluginkit-abi.sh @@ -22,21 +22,20 @@ fail() { } kits() { - awk -F' *= *' -v origin="$1" ' + awk -F' *= *' ' $1 == "TABLEPRO_PLUGINKIT_VERSION" { kit = $2 } $1 == "TABLEPRO_PLUGINKIT_MINIMUM_VERSION" { minimum = $2 } END { - if (kit !~ /^[0-9]+$/ || minimum !~ /^[0-9]+$/) { - print "::error::no PluginKit versions in " origin - exit 1 - } + if (kit !~ /^[0-9]+$/ || minimum !~ /^[0-9]+$/) exit 1 print kit, minimum }' } -base_kits="$(git -C "$root" show "$base:Configs/PluginKit.xcconfig" | kits "$base")" -head_kits="$(kits HEAD < "$root/Configs/PluginKit.xcconfig")" -read -r base_kit _ <<< "$base_kits" +config="Configs/PluginKit.xcconfig" +settings="TABLEPRO_PLUGINKIT_VERSION and TABLEPRO_PLUGINKIT_MINIMUM_VERSION" +base_kits="$(git -C "$root" show "$base:$config" | kits)" || fail "$config at $base does not set $settings to integers." +head_kits="$(kits < "$root/$config")" || fail "$config does not set $settings to integers." +read -r base_kit base_minimum <<< "$base_kits" read -r head_kit head_minimum <<< "$head_kits" work="$(mktemp -d)" @@ -86,26 +85,16 @@ else esac fi -next=$((base_kit + 1)) -config="Configs/PluginKit.xcconfig" if [ "$frozen" -eq 1 ] && [ "$change" != unchanged ]; then fail "PluginKit's ABI differs from $base, and a release line never changes it." fi +next=$((base_kit + 1)) case "$change" in - breaking) - if [ "$head_kit" -ne "$next" ] || [ "$head_minimum" -ne "$next" ]; then - fail "Breaking change since $base: set TABLEPRO_PLUGINKIT_VERSION and TABLEPRO_PLUGINKIT_MINIMUM_VERSION to $next in $config." - fi - ;; - changed) - if [ "$head_kit" -ne "$next" ]; then - fail "PluginKit's ABI changed since $base: set TABLEPRO_PLUGINKIT_VERSION to $next in $config." - fi - ;; - unchanged) - if [ "$head_kit" -ne "$base_kit" ]; then - fail "PluginKit's ABI matches $base: TABLEPRO_PLUGINKIT_VERSION stays $base_kit in $config." - fi - ;; + breaking) want_kit=$next want_minimum=$next ;; + changed) want_kit=$next want_minimum=$base_minimum ;; + unchanged) want_kit=$base_kit want_minimum=$base_minimum ;; esac +if [ "$head_kit" -ne "$want_kit" ] || [ "$head_minimum" -ne "$want_minimum" ]; then + fail "PluginKit's ABI against $base: $change. Set TABLEPRO_PLUGINKIT_VERSION to $want_kit and TABLEPRO_PLUGINKIT_MINIMUM_VERSION to $want_minimum in $config." +fi echo "PluginKit ABI against $base: $change; kit $head_kit, minimum $head_minimum." diff --git a/scripts/ci/test_check_pluginkit_abi.py b/scripts/ci/test_check_pluginkit_abi.py new file mode 100644 index 0000000000..dc48b52600 --- /dev/null +++ b/scripts/ci/test_check_pluginkit_abi.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 + +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent.parent / "check-pluginkit-abi.sh" +API = ["query", "fetchRows"] + +# Stands in for Xcode: a build writes the tree's api.txt as its ABI baseline, and the checker +# reports each baseline line missing from api.txt as a breakage. +XCODEBUILD = """#!/usr/bin/env bash +set -euo pipefail +project="" out="" baseline="" previous="" +for arg in "$@"; do + [ "$previous" = -project ] && project="${arg%/TablePro.xcodeproj}" + case "$arg" in + SWIFT_ABI_GENERATION_TOOL_OUTPUT_DIR=*) out="${arg#*=}" ;; + SWIFT_ABI_CHECKER_BASELINE_DIR=*) baseline="${arg#*=}" ;; + esac + previous="$arg" +done +if [ -e "$project/build-fails" ]; then + echo "$project/Kit.swift:1:1: error: cannot find type 'Row' in scope" + exit 65 +fi +mkdir -p "$out" +cp "$project/api.txt" "$out/arm64-apple-macos.json" +[ -n "$baseline" ] || exit 0 +removed="$(grep -vxFf "$project/api.txt" "$baseline/ABI/arm64-apple-macos.json" || true)" +[ -n "$removed" ] || exit 0 +while IFS= read -r symbol; do + echo "$project/Kit.swift:1:1: error: ABI breakage: func $symbol has been removed" +done <<< "$removed" +exit 65 +""" + + +class CheckPluginKitABITests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + scratch = Path(self.temp.name) + stubs = scratch / "bin" + stubs.mkdir() + (stubs / "xcodebuild").write_text(XCODEBUILD, encoding="utf-8") + (stubs / "xcodegen").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + for stub in stubs.iterdir(): + stub.chmod(0o755) + self.env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + self.env["PATH"] = f"{stubs}{os.pathsep}{self.env['PATH']}" + self.repo = scratch / "repo" + (self.repo / "scripts").mkdir(parents=True) + shutil.copy(SCRIPT, self.repo / "scripts/check-pluginkit-abi.sh") + self.git("-c", "init.defaultBranch=main", "init", "-q") + self.commit(API, 39, 19, tag="v1.0.0") + + def tearDown(self): + self.temp.cleanup() + + def git(self, *args): + result = subprocess.run( + ["git", "-c", "user.name=test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", + "-c", "tag.gpgsign=false", *args], + cwd=self.repo, env=self.env, check=True, capture_output=True, text=True, stdin=subprocess.DEVNULL, + ) + return result.stdout.strip() + + def commit(self, api, kit, minimum, tag=None, config=None): + (self.repo / "api.txt").write_text("".join(f"{symbol}\n" for symbol in api), encoding="utf-8") + if config is None: + config = f"TABLEPRO_PLUGINKIT_VERSION = {kit}\nTABLEPRO_PLUGINKIT_MINIMUM_VERSION = {minimum}\n" + (self.repo / "Configs").mkdir(exist_ok=True) + (self.repo / "Configs/PluginKit.xcconfig").write_text( + "// When to raise each: .claude/rules/plugin-system.md, ABI.\n" + config, encoding="utf-8") + self.git("add", "-A") + self.git("commit", "-q", "--allow-empty", "-m", "change") + if tag: + self.git("tag", tag) + + def check(self, *args): + result = subprocess.run( + ["bash", "scripts/check-pluginkit-abi.sh", *args], + cwd=self.repo, env=self.env, capture_output=True, text=True, stdin=subprocess.DEVNULL, + ) + return result.returncode, result.stdout + result.stderr + + def assertPasses(self, expected, *args): + code, out = self.check(*args) + self.assertEqual(code, 0, out) + self.assertIn(expected, out) + + def assertFails(self, expected, *args): + code, out = self.check(*args) + self.assertEqual(code, 1, out) + errors = [line for line in out.splitlines() if line.startswith("::error::")] + self.assertEqual(len(errors), 1, out) + self.assertIn(expected, errors[0]) + + def test_same_api_and_versions_pass(self): + self.commit(API, 39, 19) + self.assertPasses("against v1.0.0: unchanged; kit 39, minimum 19.") + + def test_same_api_with_a_kit_bump_fails(self): + self.commit(API, 40, 19) + self.assertFails("against v1.0.0: unchanged. Set TABLEPRO_PLUGINKIT_VERSION to 39 and " + "TABLEPRO_PLUGINKIT_MINIMUM_VERSION to 19 in Configs/PluginKit.xcconfig.") + + def test_same_api_with_a_lowered_minimum_fails(self): + self.commit(API, 39, 18) + self.assertFails("against v1.0.0: unchanged. Set TABLEPRO_PLUGINKIT_VERSION to 39 and " + "TABLEPRO_PLUGINKIT_MINIMUM_VERSION to 19") + + def test_reverted_break_that_keeps_its_minimum_fails(self): + self.commit(["query"], 40, 40) + self.commit(API, 39, 40) + self.assertFails("against v1.0.0: unchanged. Set TABLEPRO_PLUGINKIT_VERSION to 39 and " + "TABLEPRO_PLUGINKIT_MINIMUM_VERSION to 19") + + def test_added_api_without_a_kit_bump_fails(self): + self.commit(API + ["prepare"], 39, 19) + self.assertFails("against v1.0.0: changed. Set TABLEPRO_PLUGINKIT_VERSION to 40 and " + "TABLEPRO_PLUGINKIT_MINIMUM_VERSION to 19") + + def test_added_api_with_a_kit_bump_passes(self): + self.commit(API + ["prepare"], 40, 19) + self.assertPasses("against v1.0.0: changed; kit 40, minimum 19.") + + def test_added_api_with_a_raised_minimum_fails(self): + self.commit(API + ["prepare"], 40, 40) + self.assertFails("against v1.0.0: changed. Set TABLEPRO_PLUGINKIT_VERSION to 40 and " + "TABLEPRO_PLUGINKIT_MINIMUM_VERSION to 19") + + def test_removed_api_with_only_a_kit_bump_fails(self): + self.commit(["query"], 40, 19) + self.assertFails("against v1.0.0: breaking. Set TABLEPRO_PLUGINKIT_VERSION to 40 and " + "TABLEPRO_PLUGINKIT_MINIMUM_VERSION to 40") + self.assertIn("error: ABI breakage: func fetchRows has been removed", self.check()[1]) + + def test_removed_api_with_kit_and_minimum_bumps_passes(self): + self.commit(["query"], 40, 40) + self.assertPasses("against v1.0.0: breaking; kit 40, minimum 40.") + + def test_frozen_passes_with_no_change(self): + self.commit(API, 39, 19) + self.assertPasses("against v1.0.0: unchanged; kit 39, minimum 19.", "--frozen") + + def test_frozen_fails_on_added_api_even_with_a_kit_bump(self): + self.commit(API + ["prepare"], 40, 19) + self.assertFails("PluginKit's ABI differs from v1.0.0, and a release line never changes it.", "--frozen") + + def test_frozen_fails_on_a_minimum_change(self): + self.commit(API, 39, 18) + self.assertFails("against v1.0.0: unchanged.", "--frozen") + + def test_default_base_is_the_nearest_v_tag_reachable_from_head(self): + self.commit(API + ["prepare"], 40, 19, tag="v1.1.0") + self.git("checkout", "-q", "-b", "side", "v1.0.0") + self.commit(API, 39, 19, tag="v9.0.0") + self.git("checkout", "-q", "main") + self.commit(API + ["prepare", "explain"], 41, 19, tag="libs-1") + self.assertPasses("against v1.1.0: changed; kit 41, minimum 19.") + + def test_an_explicit_base_replaces_the_tag(self): + self.commit(API + ["prepare"], 40, 19, tag="v1.1.0") + self.assertPasses("against v1.1.0: unchanged; kit 40, minimum 19.") + self.assertPasses("against v1.0.0: changed; kit 40, minimum 19.", "v1.0.0") + + def test_versions_that_are_not_integers_fail_with_a_message(self): + self.commit(API, 40, 19, config="TABLEPRO_PLUGINKIT_VERSION = 40 // bumped\n" + "TABLEPRO_PLUGINKIT_MINIMUM_VERSION = 19\n") + self.assertFails("Configs/PluginKit.xcconfig does not set TABLEPRO_PLUGINKIT_VERSION and " + "TABLEPRO_PLUGINKIT_MINIMUM_VERSION to integers.") + + def test_a_missing_base_version_fails_with_a_message(self): + self.commit(API, 39, 19, config="TABLEPRO_PLUGINKIT_VERSION = 39\n", tag="v1.1.0") + self.commit(API, 39, 19) + self.assertFails("Configs/PluginKit.xcconfig at v1.1.0 does not set") + + def test_a_head_that_does_not_build_fails(self): + (self.repo / "build-fails").touch() + self.commit(API, 39, 19) + self.assertFails("TableProPluginKit does not build at HEAD") + + def test_two_bases_are_a_usage_error(self): + self.assertEqual(self.check("v1.0.0", "v1.0.0")[0], 2) + + +if __name__ == "__main__": + unittest.main()