From ea7374673cdd9c8b9acabc8598fa0eb138a3290f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Oct 2026 03:30:58 +0700 Subject: [PATCH] ci(plugins): check PluginKit ABI with Xcode's ABI checker --- .claude/rules/ci.md | 2 +- .claude/rules/plugin-system.md | 15 +- .../fix-issue/scripts/lib/platform-apple.sh | 9 +- .github/workflows/macos-tests.yml | 15 ++ .github/workflows/repo-hygiene.yml | 3 - .../Plugins/PluginKitABIResilienceTests.swift | 13 -- scripts/check-pluginkit-abi.sh | 177 ++++++++------- scripts/ci/check-pluginkit-bump-cadence.py | 123 ---------- .../ci/test_check_pluginkit_bump_cadence.py | 211 ------------------ 9 files changed, 119 insertions(+), 449 deletions(-) delete mode 100644 scripts/ci/check-pluginkit-bump-cadence.py delete mode 100644 scripts/ci/test_check_pluginkit_bump_cadence.py diff --git a/.claude/rules/ci.md b/.claude/rules/ci.md index 6a4724fb97..5b5fa5141f 100644 --- a/.claude/rules/ci.md +++ b/.claude/rules/ci.md @@ -45,6 +45,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ - Setup, in order: checkout, setup-xcode, prepare-build (setup-xcodegen, `scripts/download-libs.sh`, `generate-project.sh macos`, SPM restore), resolve, SPM save. - One `build-for-testing` with no `-testPlan` builds both plans and writes one `.xctestrun` per plan, with signing, the index store and the compilation cache off. - `COMPILE_PLUGINS` is true on every push and dispatch, and on a PR when the `plugins` detector is not false. It adds Go, the Dameng and HANA bridge builds, `xcodebuild build -scheme AllPlugins`, then `scripts/ci/verify-plugin-loads.sh`. +- With `COMPILE_PLUGINS`, `scripts/check-pluginkit-abi.sh` then checks PluginKit against the newest `v*` tag, `--frozen` on a PR into `release/*`; for that tag the job checks out with `fetch-depth: 0` and `filter: blob:none`. - `test-without-building -testPlan Unit`, then `-testPlan UI`. The UI plan runs after a Unit failure too, so one round trip reports both. - No shards, and no parallel, iteration or retry flags: the plans decide. - `scripts/ci/summarize-xcresult.sh` always runs. Evidence uploads only on failure (`.claude/skills/fix-issue/references/verification-apple.md`, Reading CI). @@ -105,7 +106,6 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ - `check-lib-sourcing.py`, `check-plugin-manifest.py`, `check-plugin-provides.py`, `check-deployment-target.sh`, `check-ios-shared-isolation.py`; - `localization.py plugins` and `plugins --target ios`; - `check-string-catalogs.py`, `check-license-inventory.py`, `check-entitlements.py`, `check-test-plans.py`; - - `check-pluginkit-bump-cadence.py`; - `scripts/audit-refactor-health.sh --check`, `check-hub-ratchet.py`; - the Go checks on the HANA helper, `govulncheck` among them, when the `hana` detector is true. New advisories between changes arrive as Dependabot alerts. - `audit-refactor-health.sh --check` fails a file defining a production `DatabaseType` outside `Packages/TableProCore/Sources/TableProCoreTypes/DatabaseType.swift`, unless `.github/duplicate-contract-baseline.txt` lists its path. Moving a listed file moves its baseline path in the same commit. Never add an entry to let a new copy pass. diff --git a/.claude/rules/plugin-system.md b/.claude/rules/plugin-system.md index 4db3fb7ba3..ea3c0602ab 100644 --- a/.claude/rules/plugin-system.md +++ b/.claude/rules/plugin-system.md @@ -24,20 +24,13 @@ TableProPluginKit is built with Library Evolution, so an already-built plugin ke - Add a requirement only with a default implementation, then bridge it in `PluginDriverAdapter`. - Additive: a new requirement with a default, a new field on a non-`@frozen` struct, a new case on a non-`@frozen` enum, a reordering. - Breaking: a changed, removed or renamed requirement or signature, a requirement without a default, a new case on a `@frozen` enum, a change to a frozen type's layout. -- Every ABI change bumps `TABLEPRO_PLUGINKIT_VERSION` in `Configs/PluginKit.xcconfig`, at most one past the newest `vX.Y.Z` tag: later changes reuse the pending number. `scripts/ci/check-pluginkit-bump-cadence.py` checks it in Repo Hygiene. A `release/*` line never bumps it. +- Every ABI change sets `TABLEPRO_PLUGINKIT_VERSION` in `Configs/PluginKit.xcconfig` to one past the kit of the newest `v*` tag reachable from HEAD; later changes before the next tag reuse it. A `release/*` line never bumps it. - 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. -- Check with `verify.sh abi ` (`scripts/check-pluginkit-abi.sh`). It builds PluginKit at HEAD and at the base with the same toolchain and diffs the public `.swiftinterface`. A base with no interface passes; a diff prints the additive and breaking rules and exits 1. -- It refuses uncommitted changes, so run it after the commit and before the push. No workflow runs it. -- The diff is textual, so a `-` line can be a declaration that moved. Only a removed line with no matching added line, or a requirement added without a default, is breaking. This lists the removed lines that do not come back: - - ```bash - L= - comm -23 <(grep -E '^-[^-]' "$L" | sed 's/^-[[:space:]]*//' | sort -u) \ - <(grep -E '^\+[^+]' "$L" | sed 's/^+[[:space:]]*//' | sort -u) - ``` - +- `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. +- 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/scripts/lib/platform-apple.sh b/.claude/skills/fix-issue/scripts/lib/platform-apple.sh index 20c9b1320a..eb0e696ca1 100644 --- a/.claude/skills/fix-issue/scripts/lib/platform-apple.sh +++ b/.claude/skills/fix-issue/scripts/lib/platform-apple.sh @@ -9,7 +9,7 @@ # verify.sh [options] uitest [Test...] # TestPlans/UI.xctestplan, all of it when no Test is named # verify.sh [options] package [regex] # swift test in Packages/ # verify.sh [options] ios [Suite...] # TableProMobileTests on a simulator -# verify.sh [options] abi +# verify.sh [options] abi [base] # PluginKit ABI checker; default base: the newest v* tag # verify.sh [options] lint [path...] # verify.sh [options] docs [docs-checkout] # TableProApp/docs checks against this tree (default ../tablepro-docs) # verify.sh [options] l10n # plugin and package strings in the mac and ios catalogs @@ -497,12 +497,13 @@ platform_step() { ;; abi) - [ $# -ge 1 ] || usage - STEP_DETAIL="merge-base $1" + [ $# -le 1 ] || usage + STEP_DETAIL="base ${1:-newest v* tag}" setup_toolchain require_tool xcodebuild + require_tool xcodegen log="$(new_log abi)" - run_logged "$log" "$REPO_ROOT/scripts/check-pluginkit-abi.sh" "$1" + run_logged "$log" "$REPO_ROOT/scripts/check-pluginkit-abi.sh" "$@" code=$? if [ $code -eq 0 ]; then STATUS=PASS diff --git a/.github/workflows/macos-tests.yml b/.github/workflows/macos-tests.yml index 605609279b..29ec8013ca 100644 --- a/.github/workflows/macos-tests.yml +++ b/.github/workflows/macos-tests.yml @@ -50,7 +50,11 @@ jobs: NSUnbufferedIO: "YES" COMPILE_PLUGINS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.plugins != 'false' }} steps: + # The PluginKit ABI step builds the newest v* tag, so it needs the tags and their history. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + filter: blob:none - uses: ./.github/actions/setup-xcode @@ -113,6 +117,17 @@ jobs: | xcbeautify --renderer github-actions scripts/ci/verify-plugin-loads.sh DerivedData/Build/Products/Debug + - name: Check the PluginKit ABI + if: ${{ !cancelled() && env.COMPILE_PLUGINS == 'true' }} + env: + BASE_REF: ${{ github.base_ref }} + run: | + if [[ "$BASE_REF" == release/* ]]; then + scripts/check-pluginkit-abi.sh --frozen + else + scripts/check-pluginkit-abi.sh + fi + - name: Run the Unit test plan if: ${{ !cancelled() && steps.build.outcome == 'success' }} run: | diff --git a/.github/workflows/repo-hygiene.yml b/.github/workflows/repo-hygiene.yml index 4595744d01..984b5df867 100644 --- a/.github/workflows/repo-hygiene.yml +++ b/.github/workflows/repo-hygiene.yml @@ -120,9 +120,6 @@ jobs: - name: Check every test target is in its test plan run: python3 scripts/ci/check-test-plans.py - - name: Check currentPluginKitVersion moved at most once since the last release - run: python3 scripts/ci/check-pluginkit-bump-cadence.py --remote origin - - name: Check the shared-contract drift gates run: scripts/audit-refactor-health.sh --check diff --git a/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift b/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift index 852c877eee..08adb94cc6 100644 --- a/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift +++ b/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift @@ -1,16 +1,3 @@ -// -// PluginKitABIResilienceTests.swift -// TableProTests -// -// Guards the additive-safety promise of the resilient PluginKit ABI: a driver -// that implements only the required surface and omits every defaulted -// requirement must still satisfy the protocol, and each default must return its -// documented value. If a requirement is ever added without a default, this file -// (and FakeMSSQLPluginDriver) stops compiling, flagging a breaking change that -// needs a currentPluginKitVersion bump. Cross-binary load compatibility itself -// is checked by scripts/check-pluginkit-abi.sh, run manually before merging. -// - import Foundation @testable import TablePro import TableProPluginKit diff --git a/scripts/check-pluginkit-abi.sh b/scripts/check-pluginkit-abi.sh index cfd4cc50ad..70c472110d 100755 --- a/scripts/check-pluginkit-abi.sh +++ b/scripts/check-pluginkit-abi.sh @@ -1,100 +1,111 @@ #!/usr/bin/env bash set -euo pipefail -# PluginKit ABI gate (toolchain-independent). -# -# Builds TableProPluginKit at the current tree AND at a base ref with the SAME toolchain, then -# diffs their public Swift interfaces. Comparing two builds from one compiler means Swift version -# drift between a dev machine and CI cannot produce a false diff, so there is no committed baseline -# to keep in sync. A reported diff is a real ABI change; the rules printed at the end say what to -# raise in Configs/PluginKit.xcconfig. -# -# Both sides are generated from their project.yml first, so this regenerates -# TablePro.xcodeproj in the working tree as well as in the base checkout. -# -# Usage: scripts/check-pluginkit-abi.sh [base-ref] (default: origin/main) +# Usage: scripts/check-pluginkit-abi.sh [--frozen] [base] +# Rules: .claude/rules/plugin-system.md, ABI. -PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -BASE_REF="${1:-origin/main}" - -RESULT="" - -# Build TableProPluginKit in project dir $1, writing its normalized public interface to $2. -# Sets RESULT to ok | none (no interface emitted, i.e. Library Evolution off) | failed. -# The .swiftinterface lives under an arch-named subdir (arm64-apple-macos on Apple Silicon, -# x86_64-apple-macos on Intel), so locate it by glob instead of hardcoding the host arch. -build_interface() { - local dir="$1" out="$2" sym interface - sym="$(mktemp -d)" - if ! (cd "$dir" && xcodegen generate --quiet --spec project.yml) >"$sym/generate.log" 2>&1; then - RESULT="failed" - tail -20 "$sym/generate.log" - return - fi - if ! xcodebuild -project "$dir/TablePro.xcodeproj" -target TableProPluginKit -configuration Debug \ - -skipPackagePluginValidation build SYMROOT="$sym" >"$sym/build.log" 2>&1; then - RESULT="failed" - tail -20 "$sym/build.log" - return - fi - interface="$(find "$sym/Debug/TableProPluginKit.framework" -name '*.swiftinterface' 2>/dev/null | head -1)" - if [ -n "$interface" ]; then - grep -v '^// swift-' "$interface" > "$out" - RESULT="ok" - else - RESULT="none" - fi -} +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +frozen=0 +if [ "${1:-}" = "--frozen" ]; then + frozen=1 + shift +fi +if [ $# -gt 1 ]; then + echo "usage: scripts/check-pluginkit-abi.sh [--frozen] [base]" >&2 + exit 2 +fi +base="${1:-$(git -C "$root" describe --tags --abbrev=0 --match 'v[0-9]*')}" -if ! git -C "$PROJECT_DIR" diff --quiet HEAD; then - echo "::error::Working tree has uncommitted changes; commit or stash before running the ABI gate." +fail() { + echo "::error::$1" exit 1 -fi +} -base_sha="$(git -C "$PROJECT_DIR" rev-parse --verify "$BASE_REF" 2>/dev/null)" || { - echo "::error::cannot resolve base ref '$BASE_REF'"; exit 1 +kits() { + awk -F' *= *' -v origin="$1" ' + $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 + } + 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" +read -r head_kit head_minimum <<< "$head_kits" + work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT +cleanup() { + git -C "$root" worktree remove --force "$work/src" > /dev/null 2>&1 || true + rm -rf "$work" +} +trap cleanup EXIT -echo "Building current TableProPluginKit..." -build_interface "$PROJECT_DIR" "$work/head.txt" -[ "$RESULT" = "failed" ] && { echo "::error::current TableProPluginKit build failed"; exit 1; } -head_result="$RESULT" +# Without -error-on-abi-breakage, swift-api-digester reports a breakage as a warning. +build_kit() { + local project="$1" side="$2" + shift 2 + xcodebuild build -project "$project/TablePro.xcodeproj" -scheme TableProPluginKit -configuration Debug \ + -destination platform=macOS -derivedDataPath "$work/$side/DerivedData" \ + -clonedSourcePackagesDirPath "$work/packages" -skipPackagePluginValidation \ + CODE_SIGNING_ALLOWED=NO OTHER_SWIFT_ABI_CHECKER_FLAGS=-error-on-abi-breakage \ + RUN_SWIFT_ABI_GENERATION_TOOL=YES SWIFT_ABI_GENERATION_TOOL_OUTPUT_DIR="$work/$side/ABI" \ + "$@" > "$work/$side.log" 2>&1 +} -echo "Building base ($BASE_REF -> ${base_sha:0:8})..." -git clone --quiet --local --no-hardlinks "$PROJECT_DIR" "$work/base" -git -C "$work/base" checkout --quiet "$base_sha" -build_interface "$work/base" "$work/base.txt" -[ "$RESULT" = "failed" ] && { echo "::error::base TableProPluginKit build failed at $BASE_REF"; exit 1; } -base_result="$RESULT" +echo "Building TableProPluginKit at $base" +git -C "$root" worktree add --quiet --detach "$work/src" "$base" +(cd "$work/src" && xcodegen generate --quiet --spec project.yml) +build_kit "$work/src" base || { tail -30 "$work/base.log"; fail "TableProPluginKit does not build at $base"; } -if [ "$base_result" = "none" ]; then - echo "Base has no resilient interface (Library Evolution not enabled there). Bootstrap, nothing to compare. Pass." - exit 0 -fi +echo "Building TableProPluginKit at HEAD" +build_kit "$root" head || { tail -30 "$work/head.log"; fail "TableProPluginKit does not build at HEAD"; } +for side in base head; do + ls "$work/$side/ABI"/*.json > /dev/null 2>&1 || fail "The $side build wrote no ABI baseline." +done -if [ "$head_result" = "none" ]; then - echo "::error::current build produced no .swiftinterface but the base did. Was BUILD_LIBRARY_FOR_DISTRIBUTION turned off?" - exit 1 +echo "Checking the ABI against $base" +change=unchanged +if ! build_kit "$root" head RUN_SWIFT_ABI_CHECKER_TOOL=YES SWIFT_ABI_CHECKER_BASELINE_DIR="$work/base"; then + breakages="$(grep ': error: ABI breakage: ' "$work/head.log" | sort -u)" \ + || { tail -30 "$work/head.log"; fail "The ABI checker failed without reporting a breakage."; } + echo "$breakages" + change=breaking +else + compared=0 + diff -rq "$work/base/ABI" "$work/head/ABI" > /dev/null || compared=$? + case "$compared" in + 0) ;; + 1) change=changed ;; + *) fail "Could not compare the ABI baselines." ;; + esac fi -if diff -u "$work/base.txt" "$work/head.txt"; then - echo "PluginKit ABI unchanged vs $BASE_REF." - exit 0 +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 - -cat <<'EOF' - -TableProPluginKit public ABI changed vs base (diff above). Decide additive vs breaking: - Additive: a new requirement with a default, a reordering, or a field on a non-frozen transfer - struct. Raise TABLEPRO_PLUGINKIT_VERSION in Configs/PluginKit.xcconfig. Shipped plugins - keep loading; the bump makes an older app refuse a plugin rebuilt against this API - rather than failing its load. - Breaking: a changed or removed requirement, a requirement without a default, a case on a @frozen - enum, or a frozen type's layout. Raise TABLEPRO_PLUGINKIT_VERSION and - TABLEPRO_PLUGINKIT_MINIMUM_VERSION. - Registry plugins ship with the next app release, built against the new kit. -EOF -exit 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 + ;; +esac +echo "PluginKit ABI against $base: $change; kit $head_kit, minimum $head_minimum." diff --git a/scripts/ci/check-pluginkit-bump-cadence.py b/scripts/ci/check-pluginkit-bump-cadence.py deleted file mode 100644 index c05a33d55c..0000000000 --- a/scripts/ci/check-pluginkit-bump-cadence.py +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env python3 -"""Fail when TABLEPRO_PLUGINKIT_VERSION has moved more than one past the newest release tag. - -Only the value at release time ever ships, so every PluginKit change in one release cycle reuses -the number the first one took. The registry keeps binaries for the three newest kit versions, and -each extra bump pushes one more shipped release out of that window: v0.74.0 shipped kit 30, two -bumps before v0.75.0 took main to 32, and the next registry publish at kit 33 kept 31 to 33, which -left v0.74.0 with nothing to install. - -The newest release is the highest vX.Y.Z tag by version number. With --remote the tag list comes -from that remote and the one tag is fetched when it is missing, so a shallow CI checkout with no tags -works. The fetch is at depth 1 only in a checkout that is already shallow: in a full clone it would -cut the history every worktree shares down to that one commit. A tag cut before -Configs/PluginKit.xcconfig existed declares the kit in PluginManager.swift. -test_check_pluginkit_bump_cadence.py pins what it must and must not report. -""" - -from __future__ import annotations - -import argparse -import re -import subprocess -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -KIT_CONFIG = "Configs/PluginKit.xcconfig" -KIT_SETTING = re.compile(r"^TABLEPRO_PLUGINKIT_VERSION\s*=\s*(\d+)\s*$", re.MULTILINE) -LEGACY_PLUGIN_MANAGER = "TablePro/Core/Plugins/PluginManager.swift" -LEGACY_DECLARATION = re.compile(r"\bstatic\s+let\s+currentPluginKitVersion\s*=\s*(\d+)\b") -RELEASE_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") - - -def git(repo: Path, *args: str) -> str: - result = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True) - if result.returncode != 0: - raise SystemExit(f"ERROR: git {' '.join(args)} failed: {result.stderr.strip()}") - return result.stdout - - -def kit_version(source: str, origin: str, pattern: re.Pattern[str] = KIT_SETTING) -> int: - match = pattern.search(source) - if match is None: - raise SystemExit(f"ERROR: no PluginKit version in {origin}") - return int(match.group(1)) - - -def released_kit_version(repo: Path, tag: str) -> int: - for path, pattern in ((KIT_CONFIG, KIT_SETTING), (LEGACY_PLUGIN_MANAGER, LEGACY_DECLARATION)): - if has_object(repo, f"{tag}:{path}"): - return kit_version(git(repo, "show", f"{tag}:{path}"), f"{tag}:{path}", pattern) - raise SystemExit(f"ERROR: {tag} has neither {KIT_CONFIG} nor {LEGACY_PLUGIN_MANAGER}") - - -def newest_release_tag(names: list[str]) -> str | None: - releases = [ - (tuple(int(part) for part in match.groups()), name) - for name in names - if (match := RELEASE_TAG.match(name)) - ] - return max(releases)[1] if releases else None - - -def local_tag_names(repo: Path) -> list[str]: - return git(repo, "tag", "--list", "v*").split() - - -def remote_tag_names(repo: Path, remote: str) -> list[str]: - output = git(repo, "ls-remote", "--tags", "--refs", remote, "refs/tags/v*") - return [line.split("refs/tags/", 1)[1] for line in output.splitlines() if "refs/tags/" in line] - - -def has_object(repo: Path, ref: str) -> bool: - result = subprocess.run( - ["git", "-C", str(repo), "rev-parse", "--quiet", "--verify", ref], - capture_output=True, - ) - return result.returncode == 0 - - -def fetch_tag(repo: Path, remote: str, tag: str) -> None: - if has_object(repo, f"refs/tags/{tag}^{{commit}}"): - return - shallow = git(repo, "rev-parse", "--is-shallow-repository").strip() == "true" - depth = ["--depth=1"] if shallow else [] - git(repo, "fetch", "--quiet", "--no-tags", *depth, remote, f"+refs/tags/{tag}:refs/tags/{tag}") - - -def cadence_violation(tag: str, released: int, current: int) -> str | None: - if current <= released + 1: - return None - return ( - f"TABLEPRO_PLUGINKIT_VERSION is {current}, but {tag} shipped {released}. It moves at most once " - f"per release cycle, so every PluginKit change until the next release reuses {released + 1}." - ) - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument("--repo", type=Path, default=ROOT) - parser.add_argument("--remote", help="read the release tags from this remote and fetch the newest") - args = parser.parse_args(argv) - - names = remote_tag_names(args.repo, args.remote) if args.remote else local_tag_names(args.repo) - tag = newest_release_tag(names) - if tag is None: - raise SystemExit("ERROR: no vX.Y.Z release tag to compare TABLEPRO_PLUGINKIT_VERSION against") - if args.remote: - fetch_tag(args.repo, args.remote, tag) - - released = released_kit_version(args.repo, tag) - current = kit_version((args.repo / KIT_CONFIG).read_text(encoding="utf-8"), KIT_CONFIG) - - violation = cadence_violation(tag, released, current) - if violation: - print(violation, file=sys.stderr) - return 1 - print(f"TABLEPRO_PLUGINKIT_VERSION {current}; {tag} shipped {released}.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/ci/test_check_pluginkit_bump_cadence.py b/scripts/ci/test_check_pluginkit_bump_cadence.py deleted file mode 100644 index add50f8b36..0000000000 --- a/scripts/ci/test_check_pluginkit_bump_cadence.py +++ /dev/null @@ -1,211 +0,0 @@ -#!/usr/bin/env python3 -"""Fixture tests for check-pluginkit-bump-cadence.py, each run against a throwaway git repository.""" - -from __future__ import annotations - -import os -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - -SCRIPT = Path(__file__).with_name("check-pluginkit-bump-cadence.py") -KIT_CONFIG = Path("Configs/PluginKit.xcconfig") -LEGACY_PLUGIN_MANAGER = Path("TablePro/Core/Plugins/PluginManager.swift") - -GIT_ENV = { - **os.environ, - "GIT_CONFIG_GLOBAL": os.devnull, - "GIT_CONFIG_NOSYSTEM": "1", - "GIT_AUTHOR_NAME": "Fixture", - "GIT_AUTHOR_EMAIL": "fixture@example.com", - "GIT_COMMITTER_NAME": "Fixture", - "GIT_COMMITTER_EMAIL": "fixture@example.com", -} - - -def kit_config_source(kit: int) -> str: - return f"TABLEPRO_PLUGINKIT_VERSION = {kit}\nTABLEPRO_PLUGINKIT_MINIMUM_VERSION = 19\n" - - -def legacy_plugin_manager_source(kit: int) -> str: - return ( - "final class PluginManager {\n" - f" nonisolated static let currentPluginKitVersion = {kit}\n" - " nonisolated static let minimumCompatiblePluginKitVersion = 19\n" - "}\n" - ) - - -class FixtureRepository: - def __init__(self, path: Path) -> None: - self.path = path - - def git(self, *args: str) -> str: - return subprocess.run( - ["git", "-C", str(self.path), *args], - env=GIT_ENV, - check=True, - capture_output=True, - text=True, - ).stdout - - def write(self, relative: Path, text: str) -> None: - target = self.path / relative - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(text, encoding="utf-8") - - def write_kit(self, kit: int) -> None: - self.write(KIT_CONFIG, kit_config_source(kit)) - - def commit_kit(self, kit: int, tag: str | None = None, legacy: bool = False) -> None: - if legacy: - self.write(LEGACY_PLUGIN_MANAGER, legacy_plugin_manager_source(kit)) - self.git("add", str(LEGACY_PLUGIN_MANAGER)) - else: - self.write_kit(kit) - self.git("add", str(KIT_CONFIG)) - self.git("commit", "--quiet", "--allow-empty", "-m", f"kit {kit}") - if tag: - self.git("tag", tag) - - def run_check(self, *args: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [sys.executable, str(SCRIPT), "--repo", str(self.path), *args], - env=GIT_ENV, - capture_output=True, - text=True, - ) - - -class BumpCadenceTests(unittest.TestCase): - def setUp(self) -> None: - self.scratch = tempfile.TemporaryDirectory() - self.addCleanup(self.scratch.cleanup) - self.repo = self.make_repository("repo") - - def make_repository(self, name: str) -> FixtureRepository: - path = Path(self.scratch.name) / name - path.mkdir() - repo = FixtureRepository(path) - repo.git("init", "--quiet", "--initial-branch=main") - return repo - - def assertPasses(self, result: subprocess.CompletedProcess[str]) -> None: - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - - def assertFails(self, result: subprocess.CompletedProcess[str], *fragments: str) -> None: - self.assertEqual(result.returncode, 1, result.stdout + result.stderr) - for fragment in fragments: - self.assertIn(fragment, result.stderr) - - def test_the_first_bump_after_a_release_passes(self) -> None: - self.repo.commit_kit(30, tag="v0.74.0") - self.repo.commit_kit(31) - self.assertPasses(self.repo.run_check()) - - def test_no_bump_since_the_release_passes(self) -> None: - self.repo.commit_kit(30, tag="v0.74.0") - self.repo.commit_kit(30) - self.assertPasses(self.repo.run_check()) - - def test_a_second_bump_in_one_release_cycle_fails(self) -> None: - self.repo.commit_kit(30, tag="v0.74.0") - self.repo.commit_kit(31) - self.repo.commit_kit(32) - self.assertFails(self.repo.run_check(), "32", "v0.74.0", "30", "31") - - def test_an_uncommitted_second_bump_fails(self) -> None: - self.repo.commit_kit(30, tag="v0.74.0") - self.repo.write_kit(32) - self.assertFails(self.repo.run_check(), "32") - - def test_the_newest_release_is_chosen_by_version_number(self) -> None: - self.repo.commit_kit(20, tag="v0.9.0") - self.repo.commit_kit(10, tag="plugin-oracle-v9.0.0") - self.repo.commit_kit(30, tag="v0.10.0") - self.repo.commit_kit(31) - self.assertPasses(self.repo.run_check()) - - self.repo.commit_kit(20, tag="v0.9.1") - self.repo.commit_kit(31) - self.assertPasses(self.repo.run_check()) - - def test_a_repository_without_a_release_tag_fails(self) -> None: - self.repo.commit_kit(30) - self.assertFails(self.repo.run_check(), "release tag") - - def test_a_missing_declaration_fails(self) -> None: - self.repo.commit_kit(30, tag="v0.74.0") - self.repo.write(KIT_CONFIG, "TABLEPRO_PLUGINKIT_MINIMUM_VERSION = 19\n") - self.assertFails(self.repo.run_check(), str(KIT_CONFIG)) - - def test_a_release_before_the_xcconfig_is_read_from_swift(self) -> None: - self.repo.commit_kit(39, tag="v0.81.0", legacy=True) - self.repo.commit_kit(40) - self.assertPasses(self.repo.run_check()) - - self.repo.write_kit(41) - self.assertFails(self.repo.run_check(), "41", "v0.81.0", "39") - - def test_the_xcconfig_wins_over_a_leftover_swift_literal(self) -> None: - self.repo.commit_kit(30, legacy=True) - self.repo.commit_kit(39, tag="v0.82.0") - self.repo.write_kit(40) - self.assertPasses(self.repo.run_check()) - - def test_a_release_with_no_kit_fails(self) -> None: - self.repo.git("commit", "--quiet", "--allow-empty", "-m", "empty") - self.repo.git("tag", "v0.74.0") - self.repo.write_kit(30) - self.assertFails(self.repo.run_check(), "v0.74.0", str(KIT_CONFIG)) - - def test_a_shallow_clone_reads_the_release_from_its_remote(self) -> None: - self.repo.commit_kit(25, tag="v0.73.0") - self.repo.commit_kit(30, tag="v0.74.0") - self.repo.commit_kit(30) - clone_path = Path(self.scratch.name) / "clone" - subprocess.run( - ["git", "clone", "--quiet", "--depth=1", "--no-tags", self.repo.path.as_uri(), str(clone_path)], - env=GIT_ENV, - check=True, - capture_output=True, - ) - clone = FixtureRepository(clone_path) - self.assertEqual(clone.git("tag", "--list").strip(), "") - - clone.write_kit(31) - self.assertPasses(clone.run_check("--remote", "origin")) - - clone.write_kit(32) - self.assertFails(clone.run_check("--remote", "origin"), "v0.74.0") - - def test_a_full_clone_stays_complete(self) -> None: - self.repo.commit_kit(25, tag="v0.73.0") - self.repo.commit_kit(30, tag="v0.74.0") - self.repo.commit_kit(30) - self.repo.commit_kit(31) - for name, extra in (("full-clone", ["--no-tags"]), ("full-clone-with-tags", [])): - with self.subTest(clone=name): - clone_path = Path(self.scratch.name) / name - subprocess.run( - ["git", "clone", "--quiet", *extra, self.repo.path.as_uri(), str(clone_path)], - env=GIT_ENV, - check=True, - capture_output=True, - ) - clone = FixtureRepository(clone_path) - commits = clone.git("rev-list", "--count", "HEAD").strip() - - self.assertPasses(clone.run_check("--remote", "origin")) - self.assertEqual(clone.git("rev-parse", "--is-shallow-repository").strip(), "false") - self.assertEqual(clone.git("rev-list", "--count", "HEAD").strip(), commits) - self.assertEqual( - clone.git("rev-parse", "v0.74.0^{commit}"), - self.repo.git("rev-parse", "v0.74.0^{commit}"), - ) - - -if __name__ == "__main__": - unittest.main(verbosity=2)