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/3] 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 d3a3c0e622c94a63c34378f4ef03e67ed66b3341 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Oct 2026 03:38:29 +0700 Subject: [PATCH 2/3] ci: run Release Build on the self-hosted Mac nightly and on release/** --- .claude/rules/ci.md | 14 +++++---- .claude/skills/release/SKILL.md | 3 +- .github/workflows/release-build.yml | 44 +++++++++++++++++++++++++---- RELEASING.md | 4 +-- scripts/release/test_version.py | 11 ++++---- scripts/release/version.py | 13 +++++---- 6 files changed, 66 insertions(+), 23 deletions(-) diff --git a/.claude/rules/ci.md b/.claude/rules/ci.md index 1d4b97d0ed..271cf09c5e 100644 --- a/.claude/rules/ci.md +++ b/.claude/rules/ci.md @@ -32,7 +32,9 @@ 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`. +- Every Xcode job in the test and release workflows runs on the `xcode-27` runner label, a GitHub preview image, except Release Build. actionlint learns the label from `.github/actionlint.yaml`. +- Release Build runs on `mac-mini-m4pro`, a repo-level self-hosted runner with the labels `self-hosted`, `macOS`, `ARM64` and `tablepro-release`: the maintainer's M4 Pro (12 cores, 24 GB, Xcode 27.1 at `/Applications/Xcode.app`), run by launchd as the maintainer's user. +- No other workflow names `tablepro-release`. Release Build never takes a PR, and fork PR workflows need approval for every outside contributor. - 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`. @@ -61,8 +63,10 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ ## Release Build (`release-build.yml`) -- Push to main or `release/**`, and dispatch; never a PR. Never cancelled. Not a required check. -- `Changes (Release)` outputs `run` (detector `release`). `Release Build` runs on `xcode-27` with a 150-minute limit, with VERSION 0.0.0-dev and BUILD 9999999, so build.sh passes the version settings a tag passes. +- Nightly at 19:00 UTC on main, push to `release/**`, and dispatch; never a PR. Never cancelled. Not a required check. +- `Changes (Release)` outputs `run`. A dispatch always builds. A nightly runs the `release` detector against the head of the last nightly that ended green, read with `gh api`, so a quiet day builds nothing. A `release/**` push diffs `github.event.before`, and builds when the push created the branch. +- `Release Build` runs on the self-hosted Mac; a dispatch with `runner: github` runs it on `xcode-27`. It has a 150-minute limit and VERSION 0.0.0-dev and BUILD 9999999, so build.sh passes the version settings a tag passes. +- While the Mac is offline a run waits for it, and GitHub cancels a job still queued after 24 hours. The tag gate refuses a commit with any red run, so re-run that run once the Mac is back; a `runner: github` dispatch on the same commit does not replace it. - Setup as macOS Tests up to the SPM restore, which it never saves; then Go, `build-dameng.sh both` and `build-hana.sh both`. - `scripts/release/build.sh app --unsigned` archives the `TablePro` scheme; `build.sh plugins --unsigned` builds `RegistryPlugins` and copies the publish set, the targets `scripts/release/registry.py targets` reads from `.github/plugin-registry.json`, to `build/release/plugins`. Both build universal. They run `lipo -verify_arch` once per arch (arm64, x86_64), since Xcode 27's `lipo` takes one arch per call, then `verify-plugin-loads.sh`. - `scripts/release/package.sh app --unsigned` writes the release assets: the DMG, the `.tar.xz` (extracted and compared with the app) and the dSYMs zip; `package.sh plugins` writes one zip per plugin and the plugin dSYMs zip. @@ -128,7 +132,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ ## What runs where - A pull request: both changes jobs; macOS Tests and iOS and Package Tests unless their detector says false (Oracle only when its detector is not false); SwiftLint; Repo Hygiene; PR title. `AllPlugins` only when the `plugins` detector is not false. -- A push to main or `release/**`: the same minus PR title, plus Release Build; never cancelled, `AllPlugins` always, the SPM cache saved on a miss. Every code push runs both plans in full; a docs-only or changelog-only push skips the suites through the detector. +- A push to main or `release/**`: the same minus PR title; never cancelled, `AllPlugins` always, the SPM cache saved on a miss. Every code push runs both plans in full; a docs-only or changelog-only push skips the suites through the detector. A `release/**` push also runs Release Build; main gets it nightly. - A `v*` tag runs only `release.yml`, and a `linux-v*` tag only `release-linux.yml`, which calls `linux-packages.yml`. The test workflows never run on a tag. - `workflow_dispatch` exists on every workflow except `pr-title.yml` and `release.yml`. - `build-linux.yml` builds and tests the Linux app (`linux/docs/testing.md`, CI). Its `Changes (Linux)` job skips the rest when nothing under `linux/` or the scripts and workflows it reads changed, and its `Linux` job gates on all of them. @@ -139,7 +143,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ - `release.yml` restores that cache and never saves it, since a save in tag scope is unreadable from other refs. - `release-build.yml` restores it and leaves the save to macOS Tests. - There is no other Apple cache: no libs or DerivedData cache, and none in the iOS or plugin workflows. The Linux jobs cache Cargo builds through `Swatinem/rust-cache` and the Flatpak build. -- CI fits the GitHub Free and Pro plans: no paid larger runner, no self-hosted Mac. At most 5 macOS jobs run at once, and main runs queue behind PR runs for those slots. +- CI fits the GitHub Free and Pro plans: no paid larger runner. At most 5 hosted macOS jobs run at once, and main runs queue behind PR runs for those slots. Release Build takes none of them unless a dispatch picks `runner: github`. ## Writing workflow and lint code diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index a387f3135f..b24e35452a 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -27,7 +27,8 @@ Before any decision, gather: - the live lines: `git ls-remote --heads origin 'release/*'`; - open regressions: `gh issue list --label regression --state open`; - open Dependabot alerts: `gh api --paginate 'repos/TableProApp/TablePro/dependabot/alerts?state=open&per_page=100' -q '.[].security_advisory.summary'`; -- main's push runs: `gh run list --branch main --event push --limit 10`, with macOS Tests, iOS and Package Tests and Release Build green on the commit you would cut; +- main's push runs: `gh run list --branch main --event push --limit 10`, with macOS Tests and iOS and Package Tests green on the commit you would cut; +- main's Release Builds: `gh run list --workflow release-build.yml --branch main --status success --limit 5`. The commit you would cut needs one, nightly or dispatched (`gh workflow run release-build.yml --ref main`); - whether `main` has the last stable's `## [X.Y.Z]` heading. If not, the forward-port is missing and the next tag will refuse to build: do that first (section 9). ## 3. Decide what ships diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 5305f51ea0..c9b5cf0450 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -1,12 +1,21 @@ name: Release Build # The Release configuration of the app and every registry plugin, universal and unsigned, packaged -# the way a tag packages it, so a packaging failure shows on the commit instead of on the tag. +# the way a tag packages it, so a packaging failure shows before the tag: nightly on main, on every +# release branch push, and on demand. on: push: - branches: [main, 'release/**'] + branches: ['release/**'] + schedule: + - cron: '0 19 * * *' workflow_dispatch: + inputs: + runner: + description: Where the build runs. github is the fallback while the self-hosted Mac is offline. + type: choice + options: [self-hosted, github] + default: self-hosted permissions: contents: read @@ -16,6 +25,9 @@ jobs: name: Changes (Release) runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + actions: read + contents: read outputs: run: ${{ steps.decide.outputs.run }} steps: @@ -23,13 +35,35 @@ jobs: with: fetch-depth: 0 + # A nightly diffs against the last nightly that ended green, built or skipped: main up to + # that commit is covered. A push that creates a release branch builds the cut commit, which + # the merge-base rule would skip. - name: Decide whether the Release build runs id: decide env: - BASE: ${{ github.event.before }} + EVENT: ${{ github.event_name }} + CREATED: ${{ github.event.created }} + BEFORE: ${{ github.event.before }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - run=$(scripts/ci/detect-changed-paths.sh --base "$BASE" release) + case "$EVENT" in + workflow_dispatch) + run=true + ;; + schedule) + base=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/release-build.yml/runs?event=schedule&status=success&per_page=1" \ + --jq '.workflow_runs[0].head_sha // ""') + run=$(scripts/ci/detect-changed-paths.sh --base "$base" release) + ;; + *) + if [ "$CREATED" = true ]; then + run=true + else + run=$(scripts/ci/detect-changed-paths.sh --base "$BEFORE" release) + fi + ;; + esac echo "run=$run" >> "$GITHUB_OUTPUT" # Reads "not false" so a failed changes job builds instead of passing. @@ -37,7 +71,7 @@ jobs: name: Release Build needs: changes if: ${{ !cancelled() && needs.changes.outputs.run != 'false' }} - runs-on: xcode-27 + runs-on: ${{ inputs.runner == 'github' && 'xcode-27' || fromJSON('["self-hosted", "macOS", "ARM64", "tablepro-release"]') }} timeout-minutes: 150 # The tag's version settings run here first; 9999999 is the largest build at MAJOR 0 (RELEASING.md, Versions). env: diff --git a/RELEASING.md b/RELEASING.md index 5c75b09107..2e2690c654 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -36,7 +36,7 @@ This runbook covers the macOS app, its registry plugins and the static libraries ## Cutting a beta -beta.1 cuts the line at the newest `main` commit whose push runs are all green; beta.N tags the line's head once the picks are on it. +beta.1 cuts the line at the newest `main` commit a green nightly or dispatched Release Build ran on, once its push runs are green too. The push that creates the branch builds it again, and the tag's `verify` waits for that build. beta.N tags the line's head once the picks are on it. ```bash git fetch origin @@ -84,7 +84,7 @@ python3 scripts/release/notes.py docs X.Y.Z --ref vX.Y.Z --mdx ../tablepro-docs/ A `v*` tag runs `release.yml` and nothing else. A run is never cancelled, and every job reads the version, build and channel from `verify`. -- `verify`: `version.py check` refuses a tag that breaks the grammar or the bounds; that `origin/release/` does not contain; whose previous tag on the line is not its ancestor; a stable X.Y.0 with no published beta, or a beta after its X.Y.0; a `CHANGELOG.md` missing the stable's heading, or a published stable of this or the previous line below it; a stable whose tag still holds fragments in `changelog.d/`. Then it needs macOS Tests, iOS and Package Tests and Release Build green on the tag commit or the nearest first-parent commit that ran them, with no change to the files they read after it, waiting on a pending run. Last, it writes the notes. +- `verify`: `version.py check` refuses a tag that breaks the grammar or the bounds; that `origin/release/` does not contain; whose previous tag on the line is not its ancestor; a stable X.Y.0 with no published beta, or a beta after its X.Y.0; a `CHANGELOG.md` missing the stable's heading, or a published stable of this or the previous line below it; a stable whose tag still holds fragments in `changelog.d/`. Then it needs macOS Tests, iOS and Package Tests and Release Build green on the tag commit or the nearest first-parent commit that ran them, with no change to the files they read after it, waiting on a pending run. A push, nightly or dispatched run counts; a PR run builds a merge commit, so it does not. Last, it writes the notes. - `app` (`signing`): a GA Xcode for a stable (`setup-xcode` `require-release`), the CloudKit Production schema check, `build.sh app` (archive, export with `scripts/release/ExportOptions.plist`, universal and plugin load checks), `notarize.sh` on the app, `package.sh app`, `notarize.sh` on the DMG. - `plugins` (`signing`): the Dameng and HANA bridges, `build.sh plugins` (each target `registry.py targets` prints, universal, signed with Developer ID, the hardened runtime and a timestamp), one notary submission for all, `package.sh plugins` (the zips, described as the manifest will read them). - `github-release`: `actions/attest` attests `dist/*`; `gh release create` drafts, uploads and publishes; `gh release verify` and `verify-asset` check the published release. diff --git a/scripts/release/test_version.py b/scripts/release/test_version.py index aa2521bb79..010c4996a6 100644 --- a/scripts/release/test_version.py +++ b/scripts/release/test_version.py @@ -81,7 +81,7 @@ def __init__(self, releases=(), runs=None, jobs=None): def releases(self): return self._releases - def push_runs(self, workflow, sha): + def commit_runs(self, workflow, sha): self.run_queries.append((workflow, sha)) snapshots = self._runs.get((workflow, sha), [[]]) return snapshots.pop(0) if len(snapshots) > 1 else snapshots[0] @@ -390,7 +390,8 @@ def run(command, **_): path = command[command.index("--jq") - 1] outputs = { "/releases": "v0.82.0\tfalse\nplugin-oracle-v1.0.0\ttrue\n", - "/runs?": "11\tcompleted\tsuccess\n12\tin_progress\t\n", + "/runs?": ("11\tcompleted\tsuccess\tpush\n12\tin_progress\t\tschedule\n" + "13\tcompleted\tsuccess\tworkflow_dispatch\n14\tcompleted\tfailure\tpull_request\n"), "/jobs?": "success\n\n", } output = next(text for key, text in outputs.items() if key in path) @@ -398,11 +399,11 @@ def run(command, **_): github = version.GitHub("Owner/Repo", run=run) self.assertEqual(github.releases(), [("v0.82.0", False), ("plugin-oracle-v1.0.0", True)]) - self.assertEqual(github.push_runs("macos-tests.yml", "abc"), - [("11", "completed", "success"), ("12", "in_progress", "")]) + self.assertEqual(github.commit_runs("macos-tests.yml", "abc"), + [("11", "completed", "success"), ("12", "in_progress", ""), ("13", "completed", "success")]) self.assertEqual(github.job_conclusions("11", "macOS Tests"), ["success", ""]) self.assertIn("--paginate", calls[0]) - self.assertIn("repos/Owner/Repo/actions/workflows/macos-tests.yml/runs?head_sha=abc&event=push&per_page=100", + self.assertIn("repos/Owner/Repo/actions/workflows/macos-tests.yml/runs?head_sha=abc&per_page=100", calls[1]) def test_a_gh_failure_is_raised(self): diff --git a/scripts/release/version.py b/scripts/release/version.py index dbffba5563..369cf69cdf 100755 --- a/scripts/release/version.py +++ b/scripts/release/version.py @@ -27,6 +27,8 @@ DETECTOR = "scripts/ci/detect-changed-paths.sh" FRAGMENTS = "changelog.d/" POLL_SECONDS = 60 +# A pull_request run builds a merge commit, not its head_sha; these events build the commit itself. +COMMIT_EVENTS = ("push", "schedule", "workflow_dispatch") PENDING_STATUSES = {"queued", "in_progress", "waiting", "requested", "pending"} @@ -151,12 +153,13 @@ def releases(self) -> List[Tuple[str, bool]]: ".[] | [.tag_name, .draft] | @tsv", paginate=True) return [(tag, draft == "true") for tag, draft in (line.split("\t") for line in lines if line)] - def push_runs(self, workflow: str, sha: str) -> List[Tuple[str, str, str]]: + def commit_runs(self, workflow: str, sha: str) -> List[Tuple[str, str, str]]: lines = self._api(f"repos/{self.repository}/actions/workflows/{workflow}/runs" - f"?head_sha={sha}&event=push&per_page=100", - ".workflow_runs[] | [.id, .status, .conclusion // \"\"] | @tsv") + f"?head_sha={sha}&per_page=100", + ".workflow_runs[] | [.id, .status, .conclusion // \"\", .event] | @tsv") return [(run_id, status, conclusion) - for run_id, status, conclusion in (line.split("\t") for line in lines if line)] + for run_id, status, conclusion, event in (line.split("\t") for line in lines if line) + if event in COMMIT_EVENTS] def job_conclusions(self, run_id: str, job: str) -> List[str]: return self._api(f"repos/{self.repository}/actions/runs/{run_id}/jobs?filter=latest&per_page=100", @@ -253,7 +256,7 @@ def fragment_errors(paths: List[str], version: Version) -> List[str]: def is_green(github: GitHub, workflow: str, job: str, sha: str, sleep: Callable[[float], None], log: Callable[[str], None]) -> bool: while True: - runs = github.push_runs(workflow, sha) + runs = github.commit_runs(workflow, sha) pending = [run_id for run_id, status, _ in runs if status in PENDING_STATUSES] if not pending: break From 6d239d2656c372f91e3fcab07557ac182fe15d22 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Oct 2026 04:49:33 +0700 Subject: [PATCH 3/3] 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"