From 06b94dfb26342ea4d4a48f1edb666bb76170c7d6 Mon Sep 17 00:00:00 2001 From: Wesley Keetch Date: Tue, 1 Sep 2026 11:14:34 -0400 Subject: [PATCH 1/2] build: make iOS 26 the platform baseline Raise the deployment floor to iOS 26.0 / macOS 26.0, pin the test runtime to iOS 26.5 or newer, and make both contracts enforceable rather than assumed. Deployment floor: - project.yml declares iOS 26.0 and macOS 26.0, and drops the duplicate MACOSX_DEPLOYMENT_TARGET from settings.base so options.deploymentTarget is the single source of truth for the macOS floor. - Every availability gate is now unconditionally true, so the Liquid Glass paths in LiquidGlassGroup.swift and ViewModifiers.swift call the API directly and the macOS/iOS arms collapse into one. No #available or @available checks remain in the codebase. - 26.0 rather than a point release: nothing in the app needs a 26.x point API, so a higher floor would delete the same code and support fewer devices. SDK contract: - Scripts/verify-build-sdk.sh asserts the active toolchain's SDK major matches the branch contract (26), so a machine carrying an Xcode beta cannot silently produce a beta-SDK submission build. CI hard-fails; Scripts/verify-macos.sh warns, since developing on a beta is fine. Test runtime: - Scripts/resolve-ios-simulator.sh takes a minimum runtime (26.5 by default, CHEATSHEET_MIN_IOS_RUNTIME to override) and fails with a per-runtime breakdown rather than falling back below the floor. A green suite that ran on an unsupported runtime certifies nothing. - It matches device family on deviceTypeIdentifier instead of the display name; renamed simulators were being skipped entirely. Ranking now prefers stock-named devices so the pick is reproducible. Enforcement and CI: - Scripts/verify-project-config.sh asserts the deployment floors, so a regression fails CI instead of shipping. - CI moves to the macos-26 runner: a macOS 26 app cannot run on a macOS 15 host, and macos-26 also carries an SDK and simulator runtime satisfying both floors. Xcode selection prefers a versioned Xcode 26 bundle but defers to the SDK check as the real gate, since a bundle name cannot guarantee an SDK version. Docs/os-support-policy.md records the policy, the branching model for iOS 27 adoption, and the measured iOS 27 readiness result. Verified locally: 55 tests in 5 suites pass on both the iOS simulator and macOS, iOS Release builds clean, 0 warnings. The iOS 26 SDK itself is not installed on this machine, so CI remains the authority for the shipping SDK. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 26 ++- CONTRIBUTING.md | 22 ++- CheatSheetApp/Sources/LiquidGlassGroup.swift | 16 +- CheatSheetApp/Sources/ViewModifiers.swift | 54 +----- Docs/os-support-policy.md | 179 +++++++++++++++++++ README.md | 27 ++- Scripts/resolve-ios-simulator.sh | 78 ++++++-- Scripts/verify-build-sdk.sh | 74 ++++++++ Scripts/verify-macos.sh | 4 + Scripts/verify-project-config.sh | 20 +++ project.yml | 5 +- 11 files changed, 411 insertions(+), 94 deletions(-) create mode 100644 Docs/os-support-policy.md create mode 100755 Scripts/verify-build-sdk.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1170799..9d1f41b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: jobs: ios: name: iOS Unit Tests - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 30 steps: @@ -18,11 +18,18 @@ jobs: - name: Select Xcode 26 run: | + # Prefer an explicitly versioned Xcode 26 bundle, else keep the image + # default. A bundle name cannot guarantee an SDK version, so the real + # gate is the "Verify build SDK" step below. xcode_path=$(find /Applications -maxdepth 1 -type d -name 'Xcode_26*.app' -print | sort -V | tail -n 1) - test -n "$xcode_path" - sudo xcode-select -s "$xcode_path" + if [ -n "$xcode_path" ]; then + sudo xcode-select -s "$xcode_path" + fi xcodebuild -version + - name: Verify build SDK + run: Scripts/verify-build-sdk.sh + - name: Install XcodeGen run: | if ! command -v xcodegen >/dev/null 2>&1; then @@ -77,7 +84,7 @@ jobs: macos: name: macOS Unit Tests - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 30 steps: @@ -86,11 +93,18 @@ jobs: - name: Select Xcode 26 run: | + # Prefer an explicitly versioned Xcode 26 bundle, else keep the image + # default. A bundle name cannot guarantee an SDK version, so the real + # gate is the "Verify build SDK" step below. xcode_path=$(find /Applications -maxdepth 1 -type d -name 'Xcode_26*.app' -print | sort -V | tail -n 1) - test -n "$xcode_path" - sudo xcode-select -s "$xcode_path" + if [ -n "$xcode_path" ]; then + sudo xcode-select -s "$xcode_path" + fi xcodebuild -version + - name: Verify build SDK + run: Scripts/verify-build-sdk.sh + - name: Install XcodeGen run: | if ! command -v xcodegen >/dev/null 2>&1; then diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f70e9e..7fde22f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,10 @@ This project is meant to stay small, native, and easy to build. The best contrib ## Development Setup -1. Install Xcode 26 or later. +1. Install Xcode 26. An Xcode 27 beta is fine for development, but must + never be used for a submission build — see `Docs/os-support-policy.md`. + The app requires macOS 26 / iOS 26, so a Mac running macOS 26 or later is + needed to run the macOS app and its tests. 2. Install XcodeGen. 3. Generate the project. @@ -21,6 +24,15 @@ xcodegen generate Scripts/verify-macos.sh ``` +## Verifying the Build SDK + +Before archiving for the App Store, confirm the active toolchain ships the +SDK this branch targets. A beta SDK is rejected at upload. + +```sh +Scripts/verify-build-sdk.sh +``` + ## Running Tests ```sh @@ -40,6 +52,10 @@ xcodegen generate --spec project.yml xcodebuild test -project CheatSheet.xcodeproj -scheme CheatSheetiOS -destination "id=$(Scripts/resolve-ios-simulator.sh)" -derivedDataPath /tmp/CheatSheet-iOS-Test-DD CODE_SIGNING_ALLOWED=NO ``` +Tests run on iOS 26.5 or newer. `Scripts/resolve-ios-simulator.sh` fails rather +than selecting an older runtime; install a newer simulator via Xcode > Settings > +Components, or override the floor with `CHEATSHEET_MIN_IOS_RUNTIME`. + ## Widget Signing The widgets need a shared app group. Forks should use their own Apple Developer @@ -56,6 +72,9 @@ This project follows [Git Flow](https://nvie.com/posts/a-successful-git-branchin - `feature/` — new work, branched from `develop`. Merge back into `develop` via pull request when done. - `release/` — cut from `develop` to stabilize a release (version bumps, release notes, final QA). Merges into both `main` (tagged) and back into `develop`. - `hotfix/` — urgent fixes branched from `main`. Merges into both `main` (tagged) and `develop`. +- `feature/ios--readiness` — long-lived OS adoption branch off `develop`, carrying a different SDK + contract from the shipping line. Keep it thin and merge `develop` into it often. See + [`Docs/os-support-policy.md`](Docs/os-support-policy.md). Guidelines: @@ -63,6 +82,7 @@ Guidelines: - Keep branch names lowercase and hyphenated, e.g. `feature/widget-color-picker`, `hotfix/checklist-crash`. - Delete a branch after it merges. - Tag every merge into `main` with the release version (e.g. `v1.2.0`). +- Keep `main` and `develop` submittable at all times. Beta-SDK work belongs on an OS readiness branch. ## Code Style diff --git a/CheatSheetApp/Sources/LiquidGlassGroup.swift b/CheatSheetApp/Sources/LiquidGlassGroup.swift index 4bcde20..5766f7f 100644 --- a/CheatSheetApp/Sources/LiquidGlassGroup.swift +++ b/CheatSheetApp/Sources/LiquidGlassGroup.swift @@ -5,20 +5,8 @@ struct LiquidGlassGroup: View { @ViewBuilder var content: Content var body: some View { - #if os(macOS) - if #available(macOS 26.0, *) { - GlassEffectContainer(spacing: spacing) { - content - } - } else { - content - } - #elseif os(iOS) - if #available(iOS 26.0, *) { - GlassEffectContainer(spacing: spacing) { - content - } - } else { + #if os(macOS) || os(iOS) + GlassEffectContainer(spacing: spacing) { content } #else diff --git a/CheatSheetApp/Sources/ViewModifiers.swift b/CheatSheetApp/Sources/ViewModifiers.swift index 5b4631c..9dfc487 100644 --- a/CheatSheetApp/Sources/ViewModifiers.swift +++ b/CheatSheetApp/Sources/ViewModifiers.swift @@ -8,29 +8,11 @@ extension View { @ViewBuilder func glassCompatibleButtonStyle(prominent: Bool = false) -> some View { - #if os(macOS) - if #available(macOS 26.0, *) { - if prominent { - self.buttonStyle(.glassProminent) - } else { - self.buttonStyle(.glass) - } - } else if prominent { - self.buttonStyle(.borderedProminent) + #if os(macOS) || os(iOS) + if prominent { + self.buttonStyle(.glassProminent) } else { - self.buttonStyle(.bordered) - } - #elseif os(iOS) - if #available(iOS 26.0, *) { - if prominent { - self.buttonStyle(.glassProminent) - } else { - self.buttonStyle(.glass) - } - } else if prominent { - self.buttonStyle(.borderedProminent) - } else { - self.buttonStyle(.bordered) + self.buttonStyle(.glass) } #else if prominent { @@ -74,31 +56,11 @@ private struct LiquidGlassPanelModifier: ViewModifier { let interactive: Bool func body(content: Content) -> some View { - #if os(macOS) - if #available(macOS 26.0, *) { - if interactive { - content.glassEffect(.regular.tint(tint.opacity(glassTintOpacity)).interactive(), in: .rect(cornerRadius: cornerRadius)) - } else { - content.glassEffect(.regular.tint(tint.opacity(glassTintOpacity)), in: .rect(cornerRadius: cornerRadius)) - } + #if os(macOS) || os(iOS) + if interactive { + content.glassEffect(.regular.tint(tint.opacity(glassTintOpacity)).interactive(), in: .rect(cornerRadius: cornerRadius)) } else { - content.background( - AppTheme.glassFallbackFill(for: colorScheme), - in: RoundedRectangle(cornerRadius: cornerRadius) - ) - } - #elseif os(iOS) - if #available(iOS 26.0, *) { - if interactive { - content.glassEffect(.regular.tint(tint.opacity(glassTintOpacity)).interactive(), in: .rect(cornerRadius: cornerRadius)) - } else { - content.glassEffect(.regular.tint(tint.opacity(glassTintOpacity)), in: .rect(cornerRadius: cornerRadius)) - } - } else { - content.background( - AppTheme.glassFallbackFill(for: colorScheme), - in: RoundedRectangle(cornerRadius: cornerRadius) - ) + content.glassEffect(.regular.tint(tint.opacity(glassTintOpacity)), in: .rect(cornerRadius: cornerRadius)) } #else content.background( diff --git a/Docs/os-support-policy.md b/Docs/os-support-policy.md new file mode 100644 index 0000000..c79e67c --- /dev/null +++ b/Docs/os-support-policy.md @@ -0,0 +1,179 @@ +# OS Support Policy and iOS 27 Plan + +How CheatSheet decides which OS versions it runs on, which SDK it ships against, +and how a new major iOS release gets adopted without destabilising the version +that is on the App Store. + +## Current support matrix + +| Platform | Minimum (deployment target) | Built against (SDK) | Toolchain | Test runtime | +| ------------- | --------------------------- | ------------------- | --------- | ------------ | +| iOS / iPadOS | 26.0 | 26.x | Xcode 26 | iOS 26.5+ | +| macOS | 26.0 | 26.x | Xcode 26 | macOS 26+ | + +## Two versions that are easy to confuse + +These are independent knobs, and conflating them is the most common way a small +app either sheds users or gets rejected at upload. + +- **Deployment target** — the oldest OS that can install the app. Raising it + removes users. It is a product decision, never a housekeeping one. +- **SDK** — what the code is compiled against. Apple periodically raises the + minimum SDK it will accept for submission, and it never accepts a beta SDK. + Raising the SDK removes nobody. + +The floor may never exceed the SDK: `xcodebuild` refuses to build when the +deployment target is later than the SDK it is given. A floor *below* the SDK is +the normal, supported case. + +## Why the floor is iOS 26 / macOS 26 + +Set 2026-09-01. Every OS-conditional path in the app was a Liquid Glass gate — +`GlassEffectContainer`, `glassEffect(_:in:)`, and the `.glass` / +`.glassProminent` button styles, all introduced in 26.0. With the floor at 26.0 +those gates are unconditionally true, so they were removed: +`CheatSheetApp/Sources/LiquidGlassGroup.swift` and +`CheatSheetApp/Sources/ViewModifiers.swift` now call the Liquid Glass API +directly, and the two platform arms collapsed into one `#if os(macOS) || os(iOS)` +branch. There are no `#available` or `@available` checks left in the codebase. + +**26.0, not a point release.** Nothing in the app requires a 26.x point release, +so a 26.1–26.5 floor would delete exactly the same code while supporting fewer +devices. Pick the lowest floor that makes the code you actually want to delete +unreachable. + +Raise the floor again only when a specific feature the app needs demands it and +the gated fallback has become a maintenance burden — not merely because a newer +OS exists. The previous floor (iOS 18 / macOS 15) was retired because it had +stopped being a real configuration: every gate behind it was a fallback nobody +was shipping against, and no iOS 18 simulator remained to test it on. + +## Tests run on iOS 26.5 or newer + +`Scripts/resolve-ios-simulator.sh` picks the newest available simulator **at or +above a floor**, defaulting to 26.5 and overridable with +`CHEATSHEET_MIN_IOS_RUNTIME`. Below the floor it fails with a per-runtime +breakdown rather than substituting an older runtime — a green suite that ran +against an unsupported configuration certifies nothing. + +It matches device family on `deviceTypeIdentifier`, not the display name: a +simulator can be renamed to anything and still be an iPhone, and name matching +silently hid four real iPhone simulators on this machine. + +## Never submit from a beta toolchain + +A machine that carries an Xcode beta will silently build against the beta SDK, +and the rejection only surfaces at upload. `Scripts/verify-build-sdk.sh` asserts +the active toolchain matches the SDK major this branch ships against. + +```sh +Scripts/verify-build-sdk.sh # hard fail on mismatch — run before archiving +Scripts/verify-build-sdk.sh --warn # warn only — used by Scripts/verify-macos.sh +``` + +The expected major is a branch-level contract, defaulting to `26` and overridable: + +```sh +CHEATSHEET_EXPECTED_SDK_MAJOR=27 Scripts/verify-build-sdk.sh +``` + +Both CI jobs run the hard-fail form immediately after selecting Xcode, so the +pin is asserted rather than assumed. Local verification warns instead of failing, +because developing against a beta is fine — archiving against one is not. + +`Scripts/verify-project-config.sh` separately asserts the deployment floors in +`project.yml`, so a regression to an older target fails CI instead of shipping. + +## iOS 27 readiness: measured, not assumed + +Measured 2026-08-31 on Xcode 27.0 beta (build 27A5252f), iOS 27.0 SDK: + +- **The app compiles clean against the iOS 27 SDK.** `CheatSheetiOS` built + against `iPhoneSimulator27.0.sdk`: 91 Swift compile actions, **0 warnings, + 0 deprecations**. (Measured while the floor was still 18.0; the floor has + since risen to 26.0, which only removes code.) +- **Every Liquid Glass API the app uses survives iOS 27**, with no deprecation + or renaming: `GlassEffectContainer`, `glassEffect(_:in:)`, `Glass.tint(_:)`, + `Glass.interactive(_:)`, `.glass`, `.glassProminent`. +- `GlassButtonStyle.init(_ glass:)` is marked `@available(iOS 26.1, *)` — an + opt-in refinement the app does not currently need. + +**Caveat:** this is a beta SDK and can change before release. The finding means +there is no known iOS 27 migration debt today; it is not a guarantee. Re-run the +trial build against each new beta and against the GM. + +Reproduce the trial build: + +```sh +xcodegen generate --spec project.yml +DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer xcodebuild build \ + -project CheatSheet.xcodeproj -scheme CheatSheetiOS \ + -destination "id=$(Scripts/resolve-ios-simulator.sh)" \ + -derivedDataPath /tmp/CheatSheet-iOS27-DD CODE_SIGNING_ALLOWED=NO +``` + +## Branching + +The iOS 26 line stays shippable at all times; iOS 27 work happens beside it. +This slots into the existing Git Flow model (see `CONTRIBUTING.md`) rather than +replacing it. + +| Branch | Floor | SDK contract | Purpose | +| -------------------------- | ----- | ------------ | -------------------------------------------- | +| `main` | 26.0 | 26 | Released. Always submittable. | +| `develop` | 26.0 | 26 | Next release. Always submittable. | +| `release/*` | 26.0 | 26 | Stabilising a release cut from `develop`. | +| `hotfix/*` | 26.0 | 26 | Urgent fix off `main`. | +| `feature/ios-27-readiness` | 26.0 | 27 | Long-lived OS adoption branch off `develop`. | + +**Why a feature branch and not a permanent parallel branch.** A permanent +`ios-27` branch rots: it accumulates conflicts against every release, and the +merge back becomes a big-bang event exactly when time is shortest. Keeping it as +a long-lived but *thin* feature branch keeps the merge cheap. + +Rules for the readiness branch: + +- Keep it thin. Only the SDK contract, CI variant, and genuinely 27-gated + adoption belong on it. Ordinary features go to `develop` as usual. +- Merge `develop` into it on a regular cadence (weekly, or after every merge to + `develop`). Never let it drift. +- It may be red while a beta is broken. `develop` may never be red. + +Note that adopting the iOS 27 **SDK** does not imply an iOS 27 **floor**. Those +are separate decisions, governed by the rule above. + +## Phases + +**Phase 0 — baseline (done, 2026-08-31).** iOS 26 SDK contract made explicit and +enforced in CI. iOS 27 trial build proves zero migration debt. + +**Phase 1 — modern-only floor (done, 2026-09-01).** Floor raised to iOS 26.0 / +macOS 26.0, all availability gates removed, test-runtime floor set to iOS 26.5, +CI moved to the `macos-26` runner so the macOS job can host a macOS 26 app. + +**Phase 2 — beta season (now until iOS 27 GM).** Create +`feature/ios-27-readiness` off `develop` when there is something to put on it. +On that branch set `CHEATSHEET_EXPECTED_SDK_MAJOR=27` and point the CI Xcode +selection at `Xcode_27*.app`. Re-run the trial build against each beta. +`develop` and `main` do not move. + +**Phase 3 — iOS 27 released, Xcode 27 released.** Flip the SDK contract on +`develop`: `CHEATSHEET_EXPECTED_SDK_MAJOR` default `26` → `27`, CI Xcode +selection `26` → `27`, and update the support matrix above plus `README.md` and +`CONTRIBUTING.md`. Merge `feature/ios-27-readiness` into `develop`, cut a +`release/*`, run the full device sweep, tag into `main`. Leave the floor at 26.0 +unless a needed feature forces it higher. + +## Checklist for the day iOS 27 ships + +1. Install the released Xcode 27; confirm `Scripts/verify-build-sdk.sh` passes + with `CHEATSHEET_EXPECTED_SDK_MAJOR=27`. +2. Flip the SDK contract on `develop` (see Phase 3) and merge the readiness branch. +3. Regenerate the project and run the full suite: `Scripts/verify-macos.sh`, + iOS unit tests, iOS UI tests. +4. Verify the widgets on an iOS 27 device — App Group sharing and timeline + reloads are the parts most likely to regress across a major release. +5. Check Liquid Glass rendering on 27 against the 26 screenshots. +6. Confirm the app still installs on a simulator at the declared floor (iOS 26.0), + not only on the newest runtime. +7. Bump the build number, archive, and confirm the archive's SDK before upload. diff --git a/README.md b/README.md index e0b49b9..aed9b7e 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ CheatSheet includes WidgetKit extensions for macOS, iOS, and iPadOS. Widgets fol - Use adaptive navigation for compact iPhone layouts and split-view iPad and Mac layouts. - Store notes locally with SwiftData and app-group persistence; no account or network connection is required. - See recoverable storage failures in the app and retry loading without overwriting stored notes. -- Use native Liquid Glass controls on macOS and iOS/iPadOS 26 with system-material fallbacks on earlier supported releases. +- Use native Liquid Glass controls throughout on macOS and iOS/iPadOS 26. - Use semantic text styles, accessibility labels, and non-color selection indicators. ## Project Layout @@ -65,8 +65,9 @@ project.yml XcodeGen project definition ## Requirements -- macOS 15 or later, or iOS/iPadOS 18 or later -- Xcode 26 or later +- macOS 26 or later, or iOS/iPadOS 26 or later +- Xcode 26 for submission builds. An Xcode 27 beta is fine for development only + — see the [OS support policy](Docs/os-support-policy.md). - XcodeGen Install XcodeGen with Homebrew: @@ -83,6 +84,13 @@ Generate the project: xcodegen generate ``` +Confirm the active toolchain ships the SDK this branch targets. Required before +archiving for the App Store: + +```sh +Scripts/verify-build-sdk.sh +``` + Build from the command line: ```sh @@ -109,11 +117,14 @@ xcodebuild test -project CheatSheet.xcodeproj -scheme CheatSheetiOS -destination ``` `Scripts/resolve-ios-simulator.sh` prints the newest available iPhone simulator -UDID. Prefer it over a device name: a bare `name=iPhone 16` destination resolves -to `OS:latest`, which fails once the installed runtime is newer than that device -(iOS 26 ships the iPhone 17 family, not the iPhone 16). - -To check the iPad layout, resolve the newest installed iPad simulator: +UDID that meets the minimum test runtime (iOS 26.5; override with +`CHEATSHEET_MIN_IOS_RUNTIME`). Prefer it over a device name: a bare +`name=iPhone 17` destination resolves to `OS:latest`, which fails once the +installed runtime is newer than that device. It fails loudly rather than falling +back to a runtime below the floor, so the suite cannot pass against a +configuration the app does not support. + +To check the iPad layout, resolve the newest qualifying iPad simulator: ```sh xcodebuild build -project CheatSheet.xcodeproj -scheme CheatSheetiOS -destination "id=$(Scripts/resolve-ios-simulator.sh ipad)" -derivedDataPath /tmp/CheatSheet-iPad-DD CODE_SIGNING_ALLOWED=NO diff --git a/Scripts/resolve-ios-simulator.sh b/Scripts/resolve-ios-simulator.sh index e8210ea..f697c62 100755 --- a/Scripts/resolve-ios-simulator.sh +++ b/Scripts/resolve-ios-simulator.sh @@ -1,12 +1,17 @@ #!/bin/zsh -# Prints the UDID of the newest available iPhone or iPad simulator. +# Prints the UDID of the newest available iPhone or iPad simulator that meets a +# minimum iOS runtime. # Usage: Scripts/resolve-ios-simulator.sh [iphone|ipad] # # A bare `-destination 'platform=iOS Simulator,name=iPhone 16'` resolves to -# OS:latest. When the installed runtime is newer than that device (for example -# iOS 26, which ships the iPhone 17 family), xcodebuild matches nothing and the -# build fails. Resolving a concrete UDID keeps CI and local runs working across -# Xcode upgrades. +# OS:latest. When the installed runtime is newer than that device, xcodebuild +# matches nothing and the build fails. Resolving a concrete UDID keeps CI and +# local runs working across Xcode upgrades. +# +# The floor matters as much as the pick: without it, the newest *available* +# runtime may still be older than the app supports, and the whole suite runs +# green against a configuration nobody ships. Below the floor we fail loudly +# rather than substituting an older runtime. set -euo pipefail @@ -20,6 +25,8 @@ case "$device_family" in ;; esac +min_runtime="${CHEATSHEET_MIN_IOS_RUNTIME:-26.5}" + udid=$(xcrun simctl list devices available --json | python3 -c ' import json, re, sys @@ -29,21 +36,60 @@ def version(runtime): return (int(match.group(1)), int(match.group(2) or 0)) if match else (0, 0) -prefix = sys.argv[1] +def family(device, prefix): + # Match on deviceTypeIdentifier, not the display name: a simulator can be + # renamed to anything ("QA-Standard-17") and still be an iPhone. + identifier = device.get("deviceTypeIdentifier", "") + marker = "SimDeviceType." + if marker in identifier: + return identifier.split(marker, 1)[1].startswith(prefix) + return device["name"].startswith(prefix) + + +prefix, raw_floor = sys.argv[1], sys.argv[2] + +parts = raw_floor.split(".") +if not (1 <= len(parts) <= 2) or not all(p.isdigit() for p in parts): + sys.stderr.write( + "error: CHEATSHEET_MIN_IOS_RUNTIME must look like 26 or 26.5, got %r\n" % raw_floor + ) + raise SystemExit(2) +floor = (int(parts[0]), int(parts[1]) if len(parts) > 1 else 0) + devices = json.load(sys.stdin)["devices"] +runtimes = {r: e for r, e in devices.items() if "iOS-" in r} + +# Rank: newest runtime, then prefer a stock-named device over a renamed one, +# then by name so the pick is reproducible. Renamed simulators are typically +# scratch devices and are likelier to be stale or mid-teardown. candidates = [ - (version(runtime), device["udid"]) - for runtime, entries in devices.items() - if "iOS" in runtime + (version(runtime), device["name"].startswith(prefix), device["name"], device["udid"]) + for runtime, entries in runtimes.items() for device in entries - if device.get("isAvailable") and device["name"].startswith(prefix) + if device.get("isAvailable") and family(device, prefix) and version(runtime) >= floor ] -print(max(candidates)[1] if candidates else "") -' "$device_prefix") -if [[ -z "$udid" ]]; then - print -u2 "error: no available $device_prefix simulator found" - exit 1 -fi +if candidates: + print(max(candidates)[3]) + raise SystemExit(0) + +lines = [ + "error: no %s simulator available on iOS %s or newer" % (prefix, raw_floor), + " required: iOS >= %s (override with CHEATSHEET_MIN_IOS_RUNTIME)" % raw_floor, + " found, by runtime:", +] +if runtimes: + for runtime in sorted(runtimes, key=version, reverse=True): + matching = [d for d in runtimes[runtime] if d.get("isAvailable") and family(d, prefix)] + major, minor = version(runtime) + note = "" if (major, minor) >= floor else " (below floor)" + lines.append(" iOS %d.%d %d %s%s" % (major, minor, len(matching), prefix, note)) +else: + lines.append(" (no iOS runtimes installed)") +lines.append(" fix: install a runtime via Xcode > Settings > Components, then create a device:") +lines.append(" xcrun simctl create ") +sys.stderr.write("\n".join(lines) + "\n") +raise SystemExit(1) +' "$device_prefix" "$min_runtime") print "$udid" diff --git a/Scripts/verify-build-sdk.sh b/Scripts/verify-build-sdk.sh new file mode 100755 index 0000000..2761ed6 --- /dev/null +++ b/Scripts/verify-build-sdk.sh @@ -0,0 +1,74 @@ +#!/bin/zsh +# Asserts the active Xcode exposes the SDK major version this branch ships against. +# Usage: Scripts/verify-build-sdk.sh [--warn] +# +# App Store submissions must be built with a released SDK. A machine that also +# carries an Xcode beta will silently build against the beta SDK, and the +# rejection only surfaces at upload time. Run this before archiving. +# +# The expected major is a branch-level contract: the iOS 26 line pins 26, and an +# iOS 27 adoption branch overrides it with CHEATSHEET_EXPECTED_SDK_MAJOR=27. + +set -euo pipefail + +expected_major="${CHEATSHEET_EXPECTED_SDK_MAJOR:-26}" + +warn_only=0 +if [[ "${1:-}" == "--warn" ]]; then + warn_only=1 +elif [[ -n "${1:-}" ]]; then + print -u2 "error: unknown argument '${1}' (expected --warn or no argument)" + exit 2 +fi + +if ! [[ "$expected_major" == <-> ]]; then + print -u2 "error: CHEATSHEET_EXPECTED_SDK_MAJOR must be an integer, got '$expected_major'" + exit 2 +fi + +developer_dir=$(xcode-select -p) +# Take the first line without a pipe: `| head -n 1` can SIGPIPE xcodebuild, +# and pipefail would turn that race into an intermittent exit 141. +xcode_version=$(xcodebuild -version 2>/dev/null || true) +xcode_version="${xcode_version%%$'\n'*}" +: "${xcode_version:=unknown (xcodebuild unavailable)}" + +mismatches=() + +for sdk in macosx iphoneos iphonesimulator; do + if ! sdk_version=$(xcrun --sdk "$sdk" --show-sdk-version 2>/dev/null); then + mismatches+=("$sdk: SDK not installed in $developer_dir") + continue + fi + + sdk_major="${sdk_version%%.*}" + if [[ "$sdk_major" != "$expected_major" ]]; then + mismatches+=("$sdk: found $sdk_version, expected ${expected_major}.x") + fi +done + +if (( ${#mismatches} == 0 )); then + print "SDK check passed: ${xcode_version} exposes the expected ${expected_major}.x SDKs." + exit 0 +fi + +label="error" +(( warn_only )) && label="warning" + +print -u2 "${label}: active toolchain does not match the expected SDK major (${expected_major})." +print -u2 " Xcode: ${xcode_version}" +print -u2 " DEVELOPER_DIR: ${developer_dir}" +for mismatch in "${mismatches[@]}"; do + print -u2 " - ${mismatch}" +done + +if (( warn_only )); then + print -u2 "Continuing anyway (--warn). Do not archive for submission from this toolchain." + exit 0 +fi + +print -u2 "" +print -u2 "Select a matching Xcode before building for submission, for example:" +print -u2 " sudo xcode-select -s /Applications/Xcode.app" +print -u2 " DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer Scripts/verify-build-sdk.sh" +exit 1 diff --git a/Scripts/verify-macos.sh b/Scripts/verify-macos.sh index 28dcef4..dadcb47 100755 --- a/Scripts/verify-macos.sh +++ b/Scripts/verify-macos.sh @@ -13,6 +13,10 @@ if ! command -v xcodegen >/dev/null 2>&1; then exit 1 fi +# Local machines may carry an Xcode beta. Warn rather than fail: beta-SDK +# builds are fine for development, but must never be archived for submission. +"$repo_root/Scripts/verify-build-sdk.sh" --warn + print "Generating a local project outside File Provider..." xcodegen generate \ --spec "$repo_root/project.yml" \ diff --git a/Scripts/verify-project-config.sh b/Scripts/verify-project-config.sh index e1d5bd6..50a3459 100755 --- a/Scripts/verify-project-config.sh +++ b/Scripts/verify-project-config.sh @@ -33,6 +33,26 @@ abort "archive preActions must not mutate project.yml" if project.include?("preA hardened_runtime_count = project.scan(/ENABLE_HARDENED_RUNTIME:\s*YES/).length abort "expected Hardened Runtime on macOS app and widget Release settings" unless hardened_runtime_count >= 2 +# The shipping floor is a contract. Nothing else in the build asserts it: the +# SDK check only sees the toolchain, and the simulator resolver only sees +# runtimes, so a silent regression to an older deployment target would pass +# every other gate. +minimum_floor = Gem::Version.new("26.0") +{ "iOS" => /^\s*iOS:\s*"([0-9.]+)"/, "macOS" => /^\s*macOS:\s*"([0-9.]+)"/ }.each do |platform, pattern| + match = project.match(pattern) + abort "project.yml must declare a #{platform} deployment target" if match.nil? + actual = Gem::Version.new(match[1]) + if actual < minimum_floor + abort "#{platform} deployment target #{actual} is below the supported floor #{minimum_floor}" + end +end + +# options.deploymentTarget.macOS is the single source of truth. A second +# MACOSX_DEPLOYMENT_TARGET in settings.base silently overrides it. +if project.include?("MACOSX_DEPLOYMENT_TARGET") + abort "remove MACOSX_DEPLOYMENT_TARGET from settings.base; options.deploymentTarget.macOS owns the macOS floor" +end + ios_group = "group.com.wesleykeetch.wesleycheatsheet" mac_group = "HD39MR492X.com.wesleykeetch.wesleycheatsheet" diff --git a/project.yml b/project.yml index f849988..aad740c 100644 --- a/project.yml +++ b/project.yml @@ -2,13 +2,12 @@ name: CheatSheet options: bundleIdPrefix: com.wesleykeetch deploymentTarget: - macOS: "15.0" - iOS: "18.0" + macOS: "26.0" + iOS: "26.0" createIntermediateGroups: true settings: base: SWIFT_VERSION: "6.0" - MACOSX_DEPLOYMENT_TARGET: "15.0" GENERATE_INFOPLIST_FILE: YES ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor MARKETING_VERSION: "1.1" From ee8422091ded7e40235ecdb4813e10045ee0537f Mon Sep 17 00:00:00 2001 From: Wesley Keetch Date: Tue, 1 Sep 2026 16:38:13 -0400 Subject: [PATCH 2/2] fix: never resolve a simulator newer than the active SDK simctl keeps listing the iOS 27.0 beta runtime as available after switching back to the release Xcode, so "newest wins" sent shipping test runs onto the beta OS. Under Xcode 26.6 the resolver picked an iOS 27.0 iPhone rather than the 26.5 one. Exclude runtimes whose major exceeds the active simulator SDK. The toolchain now sets the ceiling, so the iOS 27 readiness branch needs no special case: selecting Xcode 27 raises it automatically. Major-level rather than exact, so a 26.6 runtime under a 26.5 SDK stays usable. Verified under Xcode 26.6 (SDK 26.5): the resolver returns the iOS 26.5 iPhone 17 Pro (9C915472), and the diagnostic marks 27.0 runtimes as newer than the active SDK. Co-Authored-By: Claude Opus 5 --- Docs/os-support-policy.md | 7 +++++++ Scripts/resolve-ios-simulator.sh | 29 +++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/Docs/os-support-policy.md b/Docs/os-support-policy.md index c79e67c..5f20003 100644 --- a/Docs/os-support-policy.md +++ b/Docs/os-support-policy.md @@ -56,6 +56,13 @@ above a floor**, defaulting to 26.5 and overridable with breakdown rather than substituting an older runtime — a green suite that ran against an unsupported configuration certifies nothing. +There is a ceiling as well as a floor: runtimes from a newer major than the +active simulator SDK are excluded. `simctl` keeps listing a beta OS runtime as +available after you switch back to the release Xcode, and "newest wins" would +otherwise send every shipping test run onto the beta OS. Because the ceiling +comes from the toolchain, an iOS 27 branch needs no special case — selecting +Xcode 27 raises it automatically. + It matches device family on `deviceTypeIdentifier`, not the display name: a simulator can be renamed to anything and still be an iPhone, and name matching silently hid four real iPhone simulators on this machine. diff --git a/Scripts/resolve-ios-simulator.sh b/Scripts/resolve-ios-simulator.sh index f697c62..6868aa2 100755 --- a/Scripts/resolve-ios-simulator.sh +++ b/Scripts/resolve-ios-simulator.sh @@ -12,6 +12,12 @@ # runtime may still be older than the app supports, and the whole suite runs # green against a configuration nobody ships. Below the floor we fail loudly # rather than substituting an older runtime. +# +# There is a ceiling too. simctl keeps listing a beta OS runtime as available +# after you switch back to the release Xcode, and "newest wins" would then send +# every shipping test run onto the beta OS. Runtimes from a newer major than the +# active SDK are excluded, so the toolchain sets the ceiling and an iOS 27 +# branch needs no special case. set -euo pipefail @@ -27,6 +33,12 @@ esac min_runtime="${CHEATSHEET_MIN_IOS_RUNTIME:-26.5}" +if ! sdk_version=$(xcrun --sdk iphonesimulator --show-sdk-version 2>/dev/null); then + print -u2 "error: no iOS simulator SDK in $(xcode-select -p)" + exit 2 +fi +sdk_major="${sdk_version%%.*}" + udid=$(xcrun simctl list devices available --json | python3 -c ' import json, re, sys @@ -46,7 +58,7 @@ def family(device, prefix): return device["name"].startswith(prefix) -prefix, raw_floor = sys.argv[1], sys.argv[2] +prefix, raw_floor, sdk_major = sys.argv[1], sys.argv[2], int(sys.argv[3]) parts = raw_floor.split(".") if not (1 <= len(parts) <= 2) or not all(p.isdigit() for p in parts): @@ -66,7 +78,10 @@ candidates = [ (version(runtime), device["name"].startswith(prefix), device["name"], device["udid"]) for runtime, entries in runtimes.items() for device in entries - if device.get("isAvailable") and family(device, prefix) and version(runtime) >= floor + if device.get("isAvailable") + and family(device, prefix) + and version(runtime) >= floor + and version(runtime)[0] <= sdk_major ] if candidates: @@ -76,13 +91,19 @@ if candidates: lines = [ "error: no %s simulator available on iOS %s or newer" % (prefix, raw_floor), " required: iOS >= %s (override with CHEATSHEET_MIN_IOS_RUNTIME)" % raw_floor, + " ceiling: iOS major <= %d (the active simulator SDK)" % sdk_major, " found, by runtime:", ] if runtimes: for runtime in sorted(runtimes, key=version, reverse=True): matching = [d for d in runtimes[runtime] if d.get("isAvailable") and family(d, prefix)] major, minor = version(runtime) - note = "" if (major, minor) >= floor else " (below floor)" + if major > sdk_major: + note = " (newer than the active SDK)" + elif (major, minor) < floor: + note = " (below floor)" + else: + note = "" lines.append(" iOS %d.%d %d %s%s" % (major, minor, len(matching), prefix, note)) else: lines.append(" (no iOS runtimes installed)") @@ -90,6 +111,6 @@ lines.append(" fix: install a runtime via Xcode > Settings > Components, then c lines.append(" xcrun simctl create ") sys.stderr.write("\n".join(lines) + "\n") raise SystemExit(1) -' "$device_prefix" "$min_runtime") +' "$device_prefix" "$min_runtime" "$sdk_major") print "$udid"