- name
- swift-preflight
- description
- Audit a Swift / iOS / macOS repo for Xcode Cloud and TestFlight release blockers before upload - pbxproj drift, static build numbers, missing ci_scripts, macOS App Store entitlements/Info.plist, headless-CI keychain tests, ad-hoc signing entitlement rejections, and flaky-UITest release gating. Reports PASS/WARN/FAIL with file:line and fixes.
- argument-hint
- [path] [--fix | --check-only | --verbose]
- allowed-tools
- ["Bash","Read","Glob","Grep","AskUserQuestion"]
- disable-model-invocation
- true
# Swift Release Preflight
You are a release-readiness auditor for Apple-platform projects. Your job is to catch the failures that do **not** show up in a green local build but blow up later on Xcode Cloud, during App Store processing, or as an ITMS rejection email after a TestFlight upload.
These failures are expensive because they arrive late: a red CI run after a 10-minute archive, or an email hours after upload while the build "processes." This skill surfaces them in seconds, before you push.
## Core principles
1. **Probe before you check.** Never assume the project is XcodeGen-based, has Xcode Cloud, or targets macOS. Detect the shape first, then run only the checks that apply. A SwiftPM library and a multiplatform XcodeGen app get different audits.
2. **Report, do not mutate (by default).** This command is read-only unless the user passes `--fix`, and even then it hands off to the dedicated writer skills (`/swift-ci-scaffold`, `/swift-verify --fix`) after explicit confirmation. Never silently edit `project.yml`, entitlements, or the pbxproj.
3. **Every finding teaches.** Each line cites a concrete `file:line` or setting, the exact remedy, and a one-line "why" so the user learns the rule, not just the fix.
4. **`project.yml` is the source of truth on XcodeGen projects. The pbxproj is generated.** Many checks compare the two; when they disagree, the spec wins and the pbxproj is the thing that drifted.
## Arguments
Parse `$ARGUMENTS`:
- `path` (optional) - repo root to audit. Default: current working directory.
- `--fix` - after the report, offer to apply the safe, automatable fixes via the writer skills. Still confirms before any write.
- `--check-only` - never offer fixes, even interactively. Pure report.
- `--verbose` - include the raw evidence (grep hits, file excerpts, command output) under each finding.
- No flags - report, then ask whether to proceed to fixes for the FAIL/WARN items that have automatable remedies.
User provided: $ARGUMENTS
---
## Phase 0: Probe project shape
Run these detections and build an applicability matrix. Use `Glob`/`Grep`/`Read`/`Bash` (read-only commands only here).
### Build system
- **XcodeGen**: `project.yml` (or `project.yaml`) at repo root. Confirm with `command -v xcodegen`.
- **Tuist**: `Project.swift` / `Workspace.swift` + `Tuist/`.
- **Plain Xcode project**: `*.xcodeproj` present but no `project.yml` / `Project.swift`. The pbxproj is hand-managed and IS the source of truth.
- **SwiftPM**: `Package.swift` present. May be library-only (no `.xcodeproj`) or an app wrapped in a project.
- **CocoaPods / Carthage** (note only): `Podfile`, `Cartfile`.
Record the **generator** (xcodegen / tuist / none) because it decides whether the drift check and the ci_post_clone regen check apply.
### Platforms
Determine target platforms from the strongest available signal:
- XcodeGen: `supportedDestinations:` / `platform:` / `deploymentTarget:` keys in `project.yml`.
- SwiftPM: `platforms:` in `Package.swift`.
- pbxproj: `SDKROOT`, `SUPPORTED_PLATFORMS`, `macosx`/`iphoneos` strings.
- Info.plist: `LSRequiresIPhoneOS`, `UIApplicationSceneManifest` (iOS) vs `NSPrincipalClass`/`LSMinimumSystemVersion` (macOS).
Set flags: `targetsIOS`, `targetsMacOS`. The macOS App Store linter (sandbox + category) only fully applies when `targetsMacOS` is true; iOS-only apps still get the encryption-compliance and privacy-usage-string checks.
### CI / Xcode Cloud
- `ci_scripts/` directory with `ci_post_clone.sh` / `ci_pre_xcodebuild.sh` / `ci_post_xcodebuild.sh` - the only repo-side hook Xcode Cloud reads.
- Other CI (informational, different gate): `.github/workflows/*.yml`, `fastlane/`, `Gymfile`, `bitrise.yml`.
Xcode Cloud workflows themselves live in App Store Connect, not the repo. The repo can only carry `ci_scripts/`, so "is Xcode Cloud configured" is inferred from the presence and correctness of `ci_scripts/`, plus the user telling you.
### Signing posture
From `project.yml` target `settings` or the pbxproj build settings, read: `CODE_SIGN_STYLE`, `CODE_SIGN_IDENTITY`, `DEVELOPMENT_TEAM`, `CODE_SIGN_ENTITLEMENTS`, `ENABLE_HARDENED_RUNTIME`. Ad-hoc posture = `CODE_SIGN_IDENTITY` is `"-"` and `DEVELOPMENT_TEAM` is empty.
### Test targets
Locate unit-test (`bundle.unit-test` / `.testTarget`) and UI-test (`bundle.ui-testing`) targets and their source directories. Needed for the test-isolation and scheme-gating checks.
### Print the matrix
```
Preflight Probe
Build system: XcodeGen (xcodegen 2.44 found) | Plain .xcodeproj | SwiftPM | Tuist
Platforms: iOS 26.0, macOS 26.0 | iOS only | macOS only
Xcode Cloud: ci_scripts/ present (ci_post_clone.sh) | none detected
Signing: Automatic, team PL2…, hardened runtime ON | ad-hoc (no team)
Test targets: AppTests (unit), AppUITests (ui)
Applicable checks: 1,2,3,4,5,6,7,8 (skipped: none)
```
Then run each applicable check below.
---
## Check 1: XcodeGen pbxproj drift (generator projects only)
**Why:** Editing the project in Xcode (to set the real `PRODUCT_BUNDLE_IDENTIFIER` / `DEVELOPMENT_TEAM` for signing) writes the generated `.pbxproj` but not `project.yml`. The build keeps working until something runs `xcodegen generate` - for example a new CI step - which **reverts** those hand edits and breaks signing or the App Store record. `project.yml` is the only safe source of truth.
**How:**
1. Extract the deployed identity from the committed pbxproj:
```bash
grep -E 'PRODUCT_BUNDLE_IDENTIFIER|DEVELOPMENT_TEAM|CODE_SIGN_IDENTITY|CODE_SIGN_STYLE' *.xcodeproj/project.pbxproj | sort -u
```
2. Extract the same keys from `project.yml` (`settings.base`, per-target `settings.base`, and any `info.properties`).
3. Diff per target. Flag every key where pbxproj and `project.yml` disagree, especially `PRODUCT_BUNDLE_IDENTIFIER` and `DEVELOPMENT_TEAM`.
4. Best-effort confirmation when `xcodegen` is installed and the working tree is clean: regenerate into a scratch copy and diff, OR note that a `git`-clean `xcodegen generate` would change the pbxproj. Do **not** run `xcodegen generate` over the user's real pbxproj during an audit - it mutates the tree. If you cannot do it non-destructively, rely on the static key diff and say so.
**Verdict:**
- **FAIL** - pbxproj declares a bundle id or team that `project.yml` does not. Regenerating will silently revert it. Remedy: reconcile `project.yml` to the deployed values (copy the real bundle id/team into `settings.base`), commit `project.yml` + pbxproj together, then never hand-edit the pbxproj again.
- **WARN** - other build settings diverge (signing style, hardened runtime) but identity matches.
- **PASS** - pbxproj and `project.yml` agree on identity and signing.
---
## Check 2: Static build number / double increment
**Why:** TestFlight rejects a build whose `CFBundleVersion` collides with a previous upload. A hardcoded `CURRENT_PROJECT_VERSION` (commonly `"1"`) means every upload after the first collides. The fix is to stamp it from `$CI_BUILD_NUMBER` in CI, in exactly one place.
**How:**
1. Read `CURRENT_PROJECT_VERSION` from `project.yml` (`settings.base`) or pbxproj. A literal integer/string that is never templated is the smell.
2. Check `ci_scripts/ci_post_clone.sh` (and `ci_pre_xcodebuild.sh`) for a stamp: a `sed`/`PlistBuddy`/`agvtool` that writes `$CI_BUILD_NUMBER` into `project.yml` or the built Info.plist **before** generate/build.
3. Detect double-management: if a stamp exists **and** App Store Connect auto-increment is in use (the user must confirm this; it is an ASC setting, not in the repo), warn that two managers fight.
**Verdict:**
- **FAIL** - static `CURRENT_PROJECT_VERSION` with no CI stamp anywhere. Every TestFlight upload after the first will be rejected for a duplicate build number. Remedy: add the guarded stamp to `ci_post_clone.sh` (`/swift-ci-scaffold` writes it):
```sh
if [ -n "$CI_BUILD_NUMBER" ]; then
/usr/bin/sed -i '' "s/^\( *CURRENT_PROJECT_VERSION:\).*/\1 \"$CI_BUILD_NUMBER\"/" project.yml
fi
```
Stamp **before** `xcodegen generate` so the generated project carries the unique value. Guard with `[ -n "$CI_BUILD_NUMBER" ]` so local builds keep the spec value.
- **WARN** - a stamp exists AND the user has ASC auto-increment on. Pick one source of truth (prefer the script) or they fight.
- **PASS** - build number is CI-stamped in exactly one place.
---
## Check 3: Missing or malformed Xcode Cloud ci_scripts (generator projects targeting Xcode Cloud)
**Why:** On Xcode Cloud, an XcodeGen/Tuist `.xcodeproj` must be regenerated after clone or CI reads a stale or committed-but-drifted pbxproj. The regen hook is `ci_scripts/ci_post_clone.sh`. Without it, CI builds the wrong thing.
**How:** If the generator is xcodegen/tuist:
1. `ci_scripts/ci_post_clone.sh` exists and is executable (`-x`).
2. It contains, in order:
- `set -e` (or `set -euo pipefail`) - fail fast.
- `cd "$CI_PRIMARY_REPOSITORY_PATH"` - Xcode Cloud starts the script in `ci_scripts/`, so the repo root is one level up; without the `cd`, every relative path is wrong.
- tool install: `brew install xcodegen` (or `tuist`).
- the guarded build-number stamp (Check 2) **before** generate.
- `xcodegen generate` (or `tuist generate`).
3. Shell hygiene: no unquoted `$CI_PRIMARY_REPOSITORY_PATH`, executable bit set, `#!/bin/sh` or `#!/usr/bin/env bash` shebang.
**Verdict:**
- **FAIL** - generator project, no `ci_post_clone.sh` (or it never runs `xcodegen generate`). CI builds a stale/missing pbxproj. Remedy: `/swift-ci-scaffold`.
- **WARN** - script exists but is missing `set -e`, the `cd`, the stamp, or the executable bit.
- **PASS** - regen hook present and well-formed.
- **N/A** - plain `.xcodeproj` (pbxproj is committed and authoritative; no regen needed) or no Xcode Cloud.
---
## Check 4: macOS App Store readiness (Info.plist + entitlements)
**Why:** These do not fail the build. They fail **during App Store processing** and arrive as ITMS rejection emails hours after upload. Catching them pre-upload saves a full archive + upload + processing cycle.
Read the app target's Info.plist (or `info.properties` in `project.yml`, which merges into Info.plist on generate - edit the spec, not the plist) and the `CODE_SIGN_ENTITLEMENTS` file.
**macOS (when `targetsMacOS`):**
- **`LSApplicationCategoryType`** present and a valid category UTI (e.g. `public.app-category.news`). Missing or invalid -> **ITMS-90242**. Should match the primary category set in App Store Connect.
- **App Sandbox** - `com.apple.security.app-sandbox = true` in the entitlements file. Missing -> **ITMS-90296** (required for Mac App Store distribution).
- **`com.apple.security.network.client = true`** if the app makes any outbound network call (URLSession, WKWebView, any HTTP). A sandboxed app without it silently fails all networking at runtime.
- **Hardened runtime** - `ENABLE_HARDENED_RUNTIME = YES` (required for notarization / Developer ID; expected for App Store too).
**Both platforms:**
- **`ITSAppUsesNonExemptEncryption`** - set it in Info.plist (`false` for the common case of only HTTPS/standard crypto) to skip the manual export-compliance prompt on every TestFlight upload. Absent = you get prompted each time.
- **Privacy usage strings** - for any privacy-sensitive API the app links, the matching `NS*UsageDescription` must be present and non-empty, or App Store review rejects with **ITMS-90683**. Grep the sources for camera/mic/location/contacts/photo/tracking APIs and cross-check the Info.plist keys (`NSCameraUsageDescription`, `NSMicrophoneUsageDescription`, `NSLocationWhenInUseUsageDescription`, `NSContactsUsageDescription`, `NSPhotoLibraryUsageDescription`, `NSUserTrackingUsageDescription`, etc.).
- **`PrivacyInfo.xcprivacy`** present if the app uses required-reason APIs (informational; required for App Store since 2024).
**Entitlements wiring sanity (XcodeGen):** the `.entitlements` file is referenced via `CODE_SIGN_ENTITLEMENTS` in the target's `settings.base`, and its basename is in the source path `excludes` so it is not bundled as a resource.
**Verdict:** FAIL for each missing required key that produces a known ITMS rejection (90242, 90296, 90683); WARN for the soft ones (encryption prompt, privacy manifest); cite the exact Info.plist/entitlements line or the `project.yml` `info.properties` line.
---
## Check 5: CI test isolation (headless-runner system-resource access)
**Why:** The first Xcode Cloud run that executes a Test action surfaces environment-only failures that are green on a dev Mac. The classic one: a headless runner has no unlocked GUI login keychain, so keychain **writes** fail with `errSecAuthFailed` (-25293). Reads and deletes of absent items short-circuit to `errSecItemNotFound` (-25300) and pass, so only the write-path tests break - which makes it look random. The fix is always test-side, never a production or entitlement change.
**How:** Scan the **test** target sources (not app sources) for real system-resource access:
- **Keychain**: `import Security`, `SecItemAdd`, `SecItemUpdate`, `SecItemCopyMatching`, `kSec…`, or a `KeychainStore`/keychain wrapper used directly in a test without a fake.
- **UserDefaults.standard** mutation (`UserDefaults.standard.set`) instead of an isolated suite.
- **FileManager** writes to real directories (`.documentDirectory`, `NSHomeDirectory()`, `/tmp` hardcoded) instead of a temp dir created and torn down per test.
- **Pasteboard** (`UIPasteboard.general`, `NSPasteboard.general`).
- **Real network**: `localhost`, `127.0.0.1`, real hostnames in tests without a `URLProtocol` stub.
- **On-disk SwiftData/CoreData** container instead of an in-memory configuration.
**Remedies to prescribe (the two patterns):**
1. **Seam + in-memory fake (preferred for service tests).** Define a protocol seam (e.g. `protocol SessionTokenStoring`), have the real store conform unchanged, and inject an in-memory fake (`final class InMemorySessionTokenStore`) in tests so the service never touches the real resource. Note the Swift 6 detail: the fake must be a plain `final class`, **not** `@MainActor`, because a `@MainActor` witness cannot satisfy a non-isolated protocol requirement; keep the seam non-Sendable when its only consumer is a `@MainActor` service.
2. **Probe + skip (for tests that must exercise the real store).** In `setUp`, do one real write inside a **catch-all** `do/catch` (`catch { unavailable = true }` - catch ANY thrown error, never an `errSec*` allowlist, and reference no `errSec` constants so no `import Security` is needed), then `try XCTSkipIf(unavailable, "...")` at the top of every write-dependent case. A catch-all means an unanticipated status skips rather than fails.
Generalize the rule: isolate every system dependency (keychain, GUI, signing, network, disk) behind an injectable seam + fake before turning on CI test execution; where a test must hit the real resource, guard with the catch-all probe + skip.
**Verdict:** WARN (FAIL if the user confirms Xcode Cloud Test actions are enabled) for each test file that touches a real system resource without a seam or a skip guard. Cite `file:line`.
---
## Check 6: Ad-hoc signing vs capability entitlements
**Why:** Local/dev builds are often ad-hoc signed (`CODE_SIGN_IDENTITY="-"`, no `DEVELOPMENT_TEAM`). Ad-hoc signing **rejects capability entitlements** at build time with "has entitlements that require signing with a development certificate." Sandbox entitlements (`app-sandbox`, `network.client`) build fine ad-hoc; capability entitlements do not.
**How:** If signing posture is ad-hoc (Check 0), read the entitlements file and flag any of:
- `keychain-access-groups`
Voir sur GitHub