11name : Set up latest stable Xcode
2- description : Select the newest non-beta Xcode installed on the macOS runner.
2+ description : >-
3+ Select the newest stable Xcode installed on the macOS runner. Excludes
4+ betas and release candidates (matches the "latest-stable" semantics of
5+ maxim-lobanov/setup-xcode).
36
47outputs :
58 version :
@@ -23,26 +26,53 @@ runs:
2326 run : |
2427 set -euo pipefail
2528
26- # Mirrors the selection strategy from maxim-lobanov/setup-xcode
27- # (scan installed Xcode apps, ignore beta releases, choose newest
28- # stable), but keeps it local to avoid external-action allowlist issues.
29+ # Implements the "latest-stable" selection strategy from
30+ # maxim-lobanov/setup-xcode (scan installed Xcode apps, exclude
31+ # both betas and release candidates, choose the newest stable),
32+ # kept local to avoid external-action allowlist issues.
33+ #
34+ # Note: LicenseInfo.plist's licenseType is not a reliable signal
35+ # for release candidates (Apple commonly sets it to "GM" or
36+ # "Release" on RCs, not "Beta"). GitHub-hosted runners do, however,
37+ # name pre-release bundles consistently, e.g.
38+ # /Applications/Xcode_<v>_beta_<n>.app
39+ # /Applications/Xcode_<v>_Release_Candidate.app
40+ # so we filter on the .app bundle name in addition to the license
41+ # type. Without this, an RC's SDK can be selected and its absolute
42+ # path baked into cached LLVM CMake imported targets, only to vanish
43+ # from the runner image once Apple ships the GA build.
2944 selection_file=$(mktemp)
3045 python3 > "$selection_file" <<'PY'
3146 from pathlib import Path
3247 import plistlib
3348 import re
3449 import sys
3550
51+ # The "rc" token must be delimited by non-letters on both sides so
52+ # it matches "_RC.", "_RC1.", "-rc.", etc. but not substrings of
53+ # real words. \b alone is insufficient because "_" is a word char
54+ # in Python regex, so "_RC" has no boundary between "_" and "R".
55+ PRERELEASE_NAME = re.compile(
56+ r"(beta"
57+ r"|release[\s_.\-]?candidate"
58+ r"|(?<![A-Za-z])rc\d*(?![A-Za-z]))",
59+ re.IGNORECASE)
60+
3661 def semver_key(version):
3762 return tuple(int(part) for part in re.findall(r"\d+", version))
3863
3964 versions = []
40- for app in Path("/Applications").iterdir():
65+ for app in sorted( Path("/Applications").iterdir() ):
4166 if not app.is_dir() or app.is_symlink():
4267 continue
4368 if not re.match(r"^Xcode.*\.app$", app.name):
4469 continue
4570
71+ if PRERELEASE_NAME.search(app.name):
72+ print(f"Skipping pre-release Xcode (bundle name): {app.name}",
73+ file=sys.stderr)
74+ continue
75+
4676 version_plist = app / "Contents" / "version.plist"
4777 license_plist = app / "Contents" / "Resources" / "LicenseInfo.plist"
4878 if not version_plist.exists() or not license_plist.exists():
5686 version = str(version_info.get("CFBundleShortVersionString", ""))
5787 build = str(version_info.get("ProductBuildVersion", ""))
5888 license_type = str(license_info.get("licenseType", "")).lower()
59- if not version or not license_type or "beta" in license_type:
89+ if not version or not license_type:
90+ continue
91+ if "beta" in license_type:
92+ print(f"Skipping pre-release Xcode (license type): {app.name}",
93+ file=sys.stderr)
6094 continue
6195
6296 versions.append((semver_key(version), version, build, str(app)))
0 commit comments