From 30a6ea69bf0195ec8b294395984331a726928725 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Oct 2026 03:31:52 +0700 Subject: [PATCH 1/2] ci: select Xcode by version through DEVELOPER_DIR --- .claude/rules/ci.md | 6 ++- .github/actions/setup-xcode/action.yml | 58 ++++++++++++++++---------- .github/workflows/ios-tests.yml | 2 +- 3 files changed, 40 insertions(+), 26 deletions(-) diff --git a/.claude/rules/ci.md b/.claude/rules/ci.md index 9b2f3c0190..1d4b97d0ed 100644 --- a/.claude/rules/ci.md +++ b/.claude/rules/ci.md @@ -32,8 +32,10 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ ## Runners and toolchain -- Every Xcode job in the test and release workflows runs on the `xcode-27` runner label, a GitHub preview image. actionlint learns the label from `.github/actionlint.yaml`. A stable tag builds only on a GA Xcode: `setup-xcode` with `require-release` fails on a beta or Release Candidate. -- The Xcode version is written once, in `.github/actions/setup-xcode/action.yml`, which runs `xcode-select --switch` and prints `xcodebuild -version`. A workflow never selects Xcode another way. +- Every Xcode job in the test and release workflows runs on the `xcode-27` runner label, a GitHub preview image. actionlint learns the label from `.github/actionlint.yaml`. +- The Xcode version and Apple's released build of it are written once, in `.github/actions/setup-xcode/action.yml`. It takes the one non-symlink `/Applications/Xcode*.app` whose `version.plist` has that version, fails on none or several, exports `DEVELOPER_DIR` to the job's later steps and prints `xcodebuild -version`. +- A workflow never selects Xcode another way, and never runs an Xcode tool under `sudo`, which drops `DEVELOPER_DIR`. +- A stable tag builds only on Apple's released Xcode: `setup-xcode` with `require-release` needs the pinned build and `licenseType` `GM` in the bundle's `LicenseInfo.plist`. The folder name is no signal: the image names the released 27A9275 `Xcode_27.1_Release_Candidate.app`. - XcodeGen comes from `.github/actions/setup-xcodegen`, pinned by version and SHA-256. - `scripts/release/feed.py` runs Sparkle's `generate_appcast` and `sign_update` from `SPARKLE_BIN`, which `.github/actions/setup-sparkle` sets. The action reads the version and revision from the app's `Package.resolved` (`project.yml` pins Sparkle with `exactVersion`) and checks the archive against the checksum in Sparkle's `Package.swift` at that revision. - The compilation cache is off on CI (`COMPILATION_CACHE_ENABLE_CACHING=NO`); `Configs/Base.xcconfig` turns it on locally. diff --git a/.github/actions/setup-xcode/action.yml b/.github/actions/setup-xcode/action.yml index 803891e043..ab886fc395 100644 --- a/.github/actions/setup-xcode/action.yml +++ b/.github/actions/setup-xcode/action.yml @@ -3,39 +3,51 @@ description: Selects the one Xcode every TablePro build and test job uses. inputs: require-release: - description: true fails the job unless the selected Xcode is a GA release, as a stable build requires. + description: true fails the job unless the selected Xcode is Apple's released build, as a stable build requires. required: false default: "false" -# The only place the version is written. runs-on names the image (xcode-27); this picks the build -# inside it, the one developers install locally. The image has no Xcode 26. +# The only place the version is written. The bundle name differs by machine +# (Xcode_27.1_Release_Candidate.app on xcode-27, Xcode.app on a Mac), so it is found by its version. runs: using: composite steps: - shell: bash env: - XCODE: /Applications/Xcode_27.1.app + XCODE_VERSION: "27.1" + # Apple's released 27.1 (developer.apple.com/news/releases). The 27.1 beta, 27A9269, also says GM. + XCODE_RELEASE_BUILD: 27A9275 REQUIRE_RELEASE: ${{ inputs.require-release }} run: | set -euo pipefail - sudo xcode-select --switch "$XCODE" + matches=() + installed="" + for app in /Applications/Xcode*.app; do + if [ -L "$app" ] || [ ! -d "$app" ]; then + continue + fi + version="$(plutil -extract CFBundleShortVersionString raw -o - "$app/Contents/version.plist" 2> /dev/null || true)" + installed="$installed $app ($version)" + if [ "$version" = "$XCODE_VERSION" ]; then + matches+=("$app") + fi + done + if [ "${#matches[@]}" -ne 1 ]; then + echo "::error::Expected one Xcode $XCODE_VERSION in /Applications, found ${#matches[@]}:${installed:- no Xcode}" + exit 1 + fi + + app="${matches[0]}" + export DEVELOPER_DIR="$app/Contents/Developer" + echo "DEVELOPER_DIR=$DEVELOPER_DIR" >> "$GITHUB_ENV" xcodebuild -version - [ "$REQUIRE_RELEASE" = true ] || exit 0 - # The image links Xcode_.app to whichever build it carries, a beta or RC included. - app="$(cd "$XCODE" && pwd -P)" - case "$(basename "$app")" in - *_beta* | *_Release_Candidate*) - echo "::error::$XCODE is $app, not a GA Xcode; a stable release builds only on a GA Xcode" - exit 1 - ;; - esac - license="$app/Contents/Resources/LicenseInfo.plist" - type="$(plutil -extract licenseType raw -o - "$license" 2> /dev/null || true)" - case "$(tr '[:upper:]' '[:lower:]' <<< "$type")" in - *beta*) - echo "::error::$app has the license type '$type', not a GA Xcode; a stable release builds only on a GA Xcode" - exit 1 - ;; - esac - echo "$app is a GA Xcode" + build="$(plutil -extract ProductBuildVersion raw -o - "$app/Contents/version.plist")" + license="$(plutil -extract licenseType raw -o - "$app/Contents/Resources/LicenseInfo.plist" 2> /dev/null || true)" + echo "$app: build $build, licenseType ${license:-missing}" + [ "$REQUIRE_RELEASE" = true ] || exit 0 + if [ "$build" != "$XCODE_RELEASE_BUILD" ] || [ "$license" != GM ]; then + echo "::error::$app is $build with licenseType '$license', not Apple's released Xcode $XCODE_VERSION ($XCODE_RELEASE_BUILD, GM); a stable release builds only on it" + exit 1 + fi + echo "$app is Apple's released Xcode $XCODE_VERSION" diff --git a/.github/workflows/ios-tests.yml b/.github/workflows/ios-tests.yml index 6ccbf1f1ef..f21a9e8a25 100644 --- a/.github/workflows/ios-tests.yml +++ b/.github/workflows/ios-tests.yml @@ -66,7 +66,7 @@ jobs: - name: Make sure the simulator exists run: | if ! xcrun simctl list devices available | grep -F "iPhone 18 Pro (" > /dev/null; then - sudo xcodebuild -downloadPlatform iOS + xcodebuild -downloadPlatform iOS fi # One simulator, not clones: on a 3-core runner a second booted simulator killed runs with From 6d239d2656c372f91e3fcab07557ac182fe15d22 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Oct 2026 04:49:33 +0700 Subject: [PATCH 2/2] test: give the Unit test host the Xcode that runs its tests --- .claude/rules/ci.md | 2 +- .claude/rules/tests.md | 3 ++- TestPlans/Unit.xctestplan | 4 ++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.claude/rules/ci.md b/.claude/rules/ci.md index 1d4b97d0ed..4d52e9881d 100644 --- a/.claude/rules/ci.md +++ b/.claude/rules/ci.md @@ -34,7 +34,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ - Every Xcode job in the test and release workflows runs on the `xcode-27` runner label, a GitHub preview image. actionlint learns the label from `.github/actionlint.yaml`. - The Xcode version and Apple's released build of it are written once, in `.github/actions/setup-xcode/action.yml`. It takes the one non-symlink `/Applications/Xcode*.app` whose `version.plist` has that version, fails on none or several, exports `DEVELOPER_DIR` to the job's later steps and prints `xcodebuild -version`. -- A workflow never selects Xcode another way, and never runs an Xcode tool under `sudo`, which drops `DEVELOPER_DIR`. +- A workflow never selects Xcode another way, and never runs an Xcode tool under `sudo`, which drops `DEVELOPER_DIR`. A test host gets it from the Unit plan (`.claude/rules/tests.md`, Plans). - A stable tag builds only on Apple's released Xcode: `setup-xcode` with `require-release` needs the pinned build and `licenseType` `GM` in the bundle's `LicenseInfo.plist`. The folder name is no signal: the image names the released 27A9275 `Xcode_27.1_Release_Candidate.app`. - XcodeGen comes from `.github/actions/setup-xcodegen`, pinned by version and SHA-256. - `scripts/release/feed.py` runs Sparkle's `generate_appcast` and `sign_update` from `SPARKLE_BIN`, which `.github/actions/setup-sparkle` sets. The action reads the version and revision from the app's `Package.resolved` (`project.yml` pins Sparkle with `exactVersion`) and checks the archive against the checksum in Sparkle's `Package.swift` at that revision. diff --git a/.claude/rules/tests.md b/.claude/rules/tests.md index f7c199d638..ba597cf703 100644 --- a/.claude/rules/tests.md +++ b/.claude/rules/tests.md @@ -39,7 +39,8 @@ UI flows: `.claude/rules/ui-tests.md`. Running tests and reading verdicts: `.cla - `TestPlans/Unit.xctestplan` is the `TablePro` scheme's default plan. `TestPlans/UI.xctestplan` holds `TableProUITests` alone. - Unit holds `TableProTests`, the hostless plugin bundles, the Editor and Grammars package targets and the TableProMacCore package targets. - TableProCore and TableProOracle test targets are in no plan and run under `swift test`. Some depend on the `TableProPluginKit` package, and a plan target that does makes the app and plugins compile against that module instead of the resilient framework. A package joins the Unit plan only when nothing it builds depends on that package; the top of `Packages/TableProMacCore/Package.swift` says so. -- Unit plan settings: language `en`, region `US`, timeouts on with a 60 s default and a 120 s maximum per case, no code coverage, the sandbox variable (Isolation below). UI plan settings are in `.claude/rules/ui-tests.md`. +- Unit plan settings: language `en`, region `US`, timeouts on with a 60 s default and a 120 s maximum per case, no code coverage, the sandbox variable (Isolation below) and `DEVELOPER_DIR` set to `$(DEVELOPER_DIR)`. UI plan settings are in `.claude/rules/ui-tests.md`. +- The host gets no shell environment, so without that entry a `/usr/bin` shim a test runs (`python3`) resolves through the Mac's selected Xcode, not the one running the tests. On `xcode-27` that is 27.0, and the shim asked to install the Command Line Tools instead. - The Unit plan never retries. A red case is a bug, or a flake to fix. - The Unit plan runs serially. Its Xcode targets carry `"parallelizable": false`; the package targets carry no key, so their suites run in parallel in-process. A new Xcode target entry stays serial too. - Why serial: in-process parallelism starts every `TableProTests` suite at once and starves the main actor, so time limits kill cases and then the host. Parallel clones never split the Swift Testing suites of a hosted target: they all run in one clone. The plugin bundles in parallel save about 1% of the Unit step. diff --git a/TestPlans/Unit.xctestplan b/TestPlans/Unit.xctestplan index 318818287c..7c7c2527d9 100644 --- a/TestPlans/Unit.xctestplan +++ b/TestPlans/Unit.xctestplan @@ -12,6 +12,10 @@ "codeCoverage" : false, "defaultTestExecutionTimeAllowance" : 60, "environmentVariableEntries" : [ + { + "key" : "DEVELOPER_DIR", + "value" : "$(DEVELOPER_DIR)" + }, { "key" : "TABLEPRO_UI_TEST_SANDBOX", "value" : "$(TARGET_TEMP_DIR)\/UnitTestStorage"