diff --git a/.claude/rules/ci.md b/.claude/rules/ci.md
index 2fbc095e0a..13ee3bc5d8 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 when the `plugins` detector is not false, on a PR and a push alike; a dispatch has no base, so it always compiles. 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 857a952303..f00e5705f9 100644
--- a/.github/workflows/macos-tests.yml
+++ b/.github/workflows/macos-tests.yml
@@ -50,7 +50,11 @@ jobs:
NSUnbufferedIO: "YES"
COMPILE_PLUGINS: ${{ 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)