From e52dbb5fb4ac7b6ab8221ba4f1e8d5cac9947b9b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Oct 2026 07:15:15 +0700 Subject: [PATCH] ci: fix the self-hosted Release Build's Xcode, XcodeGen and fallback --- .claude/rules/ci.md | 14 ++++++------- .github/actions/setup-xcode/action.yml | 15 ++++++++------ .github/actions/setup-xcodegen/action.yml | 24 ++++++++++++++--------- .github/workflows/release-build.yml | 3 ++- RELEASING.md | 1 + 5 files changed, 34 insertions(+), 23 deletions(-) diff --git a/.claude/rules/ci.md b/.claude/rules/ci.md index d1e31cd255..cab25e4d91 100644 --- a/.claude/rules/ci.md +++ b/.claude/rules/ci.md @@ -33,12 +33,12 @@ 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, 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. +- 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, the Xcode build `setup-xcode` pins, 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`. +- The Xcode version, the build every job uses and Apple's released build 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 and build, fails on none or several, exports `DEVELOPER_DIR` to the job's later steps and prints `xcodebuild -version`. The version alone is no pin: the 27.1 beta, 27A9269, is also 27.1. - 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. +- A stable tag builds only on Apple's released Xcode: `setup-xcode` with `require-release` needs Apple's released 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. It installs into the runner's tool cache without `sudo` and goes first on `PATH`, ahead of a Homebrew `xcodegen`. - `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. @@ -67,8 +67,8 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ - 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. +- `Release Build` runs on the self-hosted Mac, or on `xcode-27` when a dispatch picks `runner: github` or, for a push or nightly, the repository variable `RELEASE_BUILD_RUNNER` is `github`. 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, and a `runner: github` dispatch on the same commit does not replace it: `RELEASING.md`, When a run fails. - 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. @@ -144,7 +144,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. 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`. +- 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 or `RELEASE_BUILD_RUNNER` picks `github`. ## Writing workflow and lint code diff --git a/.github/actions/setup-xcode/action.yml b/.github/actions/setup-xcode/action.yml index ab886fc395..55c83eaa60 100644 --- a/.github/actions/setup-xcode/action.yml +++ b/.github/actions/setup-xcode/action.yml @@ -7,15 +7,17 @@ inputs: required: false default: "false" -# 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. +# The only place the version and build are 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.plist. runs: using: composite steps: - shell: bash env: XCODE_VERSION: "27.1" - # Apple's released 27.1 (developer.apple.com/news/releases). The 27.1 beta, 27A9269, also says GM. + # The 27.1 beta, 27A9269, has the same version, so every job also checks the build. + XCODE_BUILD: 27A9275 + # Apple's released 27.1 (developer.apple.com/news/releases). The 27.1 beta also says GM. XCODE_RELEASE_BUILD: 27A9275 REQUIRE_RELEASE: ${{ inputs.require-release }} run: | @@ -27,13 +29,14 @@ runs: 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 + build="$(plutil -extract ProductBuildVersion raw -o - "$app/Contents/version.plist" 2> /dev/null || true)" + installed="$installed $app ($version $build)" + if [ "$version" = "$XCODE_VERSION" ] && [ "$build" = "$XCODE_BUILD" ]; then matches+=("$app") fi done if [ "${#matches[@]}" -ne 1 ]; then - echo "::error::Expected one Xcode $XCODE_VERSION in /Applications, found ${#matches[@]}:${installed:- no Xcode}" + echo "::error::Expected one Xcode $XCODE_VERSION ($XCODE_BUILD) in /Applications, found ${#matches[@]}:${installed:- no Xcode}" exit 1 fi diff --git a/.github/actions/setup-xcodegen/action.yml b/.github/actions/setup-xcodegen/action.yml index b803ce9134..2181960469 100644 --- a/.github/actions/setup-xcodegen/action.yml +++ b/.github/actions/setup-xcodegen/action.yml @@ -10,18 +10,24 @@ runs: # generated pbxproj output differs between XcodeGen versions. install.sh copies # both bin/xcodegen and share/xcodegen/SettingPresets; without the presets, # generation silently drops build settings and still exits 0. + # It goes to the runner's tool cache, not /usr/local: the self-hosted Mac has no + # passwordless sudo, and the xcodegen already on its PATH is Homebrew's, unverified. env: XCODEGEN_VERSION: "2.46.0" XCODEGEN_SHA256: "4d9e34b62172d645eed6457cac13fc222569974098ef4ee9c3368bedf0196806" run: | set -euo pipefail - if command -v xcodegen > /dev/null && [ "$(xcodegen --version | awk '{print $2}')" = "$XCODEGEN_VERSION" ]; then - echo "XcodeGen $XCODEGEN_VERSION already installed" - exit 0 + prefix="${RUNNER_TOOL_CACHE:?}/xcodegen/$XCODEGEN_VERSION" + if [ "$("$prefix/bin/xcodegen" --version 2> /dev/null | awk '{print $2}')" = "$XCODEGEN_VERSION" ]; then + echo "XcodeGen $XCODEGEN_VERSION already in $prefix" + else + curl -fsSLo "$RUNNER_TEMP/xcodegen.zip" \ + "https://github.com/yonaskolb/XcodeGen/releases/download/${XCODEGEN_VERSION}/xcodegen.zip" + echo "${XCODEGEN_SHA256} $RUNNER_TEMP/xcodegen.zip" | shasum -a 256 -c - + rm -rf "$RUNNER_TEMP/xcodegen-dist" "$prefix" + unzip -q "$RUNNER_TEMP/xcodegen.zip" -d "$RUNNER_TEMP/xcodegen-dist" + mkdir -p "$prefix" + "$RUNNER_TEMP/xcodegen-dist/xcodegen/install.sh" "$prefix" fi - curl -fsSLo "$RUNNER_TEMP/xcodegen.zip" \ - "https://github.com/yonaskolb/XcodeGen/releases/download/${XCODEGEN_VERSION}/xcodegen.zip" - echo "${XCODEGEN_SHA256} $RUNNER_TEMP/xcodegen.zip" | shasum -a 256 -c - - unzip -q "$RUNNER_TEMP/xcodegen.zip" -d "$RUNNER_TEMP/xcodegen-dist" - sudo "$RUNNER_TEMP/xcodegen-dist/xcodegen/install.sh" /usr/local - xcodegen --version + echo "$prefix/bin" >> "$GITHUB_PATH" + "$prefix/bin/xcodegen" --version diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index c9b5cf0450..d2a215da22 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -71,7 +71,8 @@ jobs: name: Release Build needs: changes if: ${{ !cancelled() && needs.changes.outputs.run != 'false' }} - runs-on: ${{ inputs.runner == 'github' && 'xcode-27' || fromJSON('["self-hosted", "macOS", "ARM64", "tablepro-release"]') }} + # A push or nightly has no input, so it reads the RELEASE_BUILD_RUNNER repository variable; unset is the Mac. + runs-on: ${{ (inputs.runner || vars.RELEASE_BUILD_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 aef9ca877b..342328ea77 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -110,6 +110,7 @@ A `v*` tag runs `release.yml` and nothing else. A run is never cancelled, and ev - After it publishes: **Re-run failed jobs**. Every later step skips what is done: a published release, a feed item with the same version and build, or a manifest release with the same build and sha256. - A stable that never published: the next release PR runs `git revert ` before `notes.py assemble`, and the leftover draft goes (`gh release delete vX.Y.Z`). - No GA Xcode on the runner image fails a stable in `app`. The fix is a PR pointing `.github/actions/setup-xcode` at a GA Xcode on an image that has one. Betas still ship. +- Release Build runs on the maintainer's Mac, and a tag's `verify` waits for it. While the Mac is offline, `gh variable set RELEASE_BUILD_RUNNER --body github` sends pushes and nightlies to `xcode-27`; a run already waiting needs a cancel and a re-run. `gh variable delete RELEASE_BUILD_RUNNER` once the Mac is back. ## Withdrawing a release