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..5f20003 --- /dev/null +++ b/Docs/os-support-policy.md @@ -0,0 +1,186 @@ +# 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. + +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. + +## 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..6868aa2 100755 --- a/Scripts/resolve-ios-simulator.sh +++ b/Scripts/resolve-ios-simulator.sh @@ -1,12 +1,23 @@ #!/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. +# +# 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 @@ -20,6 +31,14 @@ case "$device_family" in ;; 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 @@ -29,21 +48,69 @@ 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, 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): + 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 + and version(runtime)[0] <= sdk_major ] -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, + " 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) + 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)") +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" "$sdk_major") 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"