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
- 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.
- 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 editproject.yml, entitlements, or the pbxproj. - Every finding teaches. Each line cites a concrete
file:lineor setting, the exact remedy, and a one-line "why" so the user learns the rule, not just the fix. project.ymlis 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(orproject.yaml) at repo root. Confirm withcommand -v xcodegen. - Tuist:
Project.swift/Workspace.swift+Tuist/. - Plain Xcode project:
*.xcodeprojpresent but noproject.yml/Project.swift. The pbxproj is hand-managed and IS the source of truth. - SwiftPM:
Package.swiftpresent. 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 inproject.yml. - SwiftPM:
platforms:inPackage.swift. - pbxproj:
SDKROOT,SUPPORTED_PLATFORMS,macosx/iphoneosstrings. - Info.plist:
LSRequiresIPhoneOS,UIApplicationSceneManifest(iOS) vsNSPrincipalClass/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 withci_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:
- Extract the deployed identity from the committed pbxproj:
grep -E 'PRODUCT_BUNDLE_IDENTIFIER|DEVELOPMENT_TEAM|CODE_SIGN_IDENTITY|CODE_SIGN_STYLE' *.xcodeproj/project.pbxproj | sort -u - Extract the same keys from
project.yml(settings.base, per-targetsettings.base, and anyinfo.properties). - Diff per target. Flag every key where pbxproj and
project.ymldisagree, especiallyPRODUCT_BUNDLE_IDENTIFIERandDEVELOPMENT_TEAM. - Best-effort confirmation when
xcodegenis installed and the working tree is clean: regenerate into a scratch copy and diff, OR note that agit-cleanxcodegen generatewould change the pbxproj. Do not runxcodegen generateover 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.ymldoes not. Regenerating will silently revert it. Remedy: reconcileproject.ymlto the deployed values (copy the real bundle id/team intosettings.base), commitproject.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.ymlagree 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:
- Read
CURRENT_PROJECT_VERSIONfromproject.yml(settings.base) or pbxproj. A literal integer/string that is never templated is the smell. - Check
ci_scripts/ci_post_clone.sh(andci_pre_xcodebuild.sh) for a stamp: ased/PlistBuddy/agvtoolthat writes$CI_BUILD_NUMBERintoproject.ymlor the built Info.plist before generate/build. - 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_VERSIONwith no CI stamp anywhere. Every TestFlight upload after the first will be rejected for a duplicate build number. Remedy: add the guarded stamp toci_post_clone.sh(/swift-ci-scaffoldwrites it):
Stamp beforeif [ -n "$CI_BUILD_NUMBER" ]; then /usr/bin/sed -i '' "s/^\( *CURRENT_PROJECT_VERSION:\).*/\1 \"$CI_BUILD_NUMBER\"/" project.yml fixcodegen generateso 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:
ci_scripts/ci_post_clone.shexists and is executable (-x).- It contains, in order:
set -e(orset -euo pipefail) - fail fast.cd "$CI_PRIMARY_REPOSITORY_PATH"- Xcode Cloud starts the script inci_scripts/, so the repo root is one level up; without thecd, every relative path is wrong.- tool install:
brew install xcodegen(ortuist). - the guarded build-number stamp (Check 2) before generate.
xcodegen generate(ortuist generate).
- Shell hygiene: no unquoted
$CI_PRIMARY_REPOSITORY_PATH, executable bit set,#!/bin/shor#!/usr/bin/env bashshebang.
Verdict:
- FAIL - generator project, no
ci_post_clone.sh(or it never runsxcodegen generate). CI builds a stale/missing pbxproj. Remedy:/swift-ci-scaffold. - WARN - script exists but is missing
set -e, thecd, 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):
LSApplicationCategoryTypepresent 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 = truein the entitlements file. Missing -> ITMS-90296 (required for Mac App Store distribution). com.apple.security.network.client = trueif 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 (falsefor 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*UsageDescriptionmust 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.xcprivacypresent 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 aKeychainStore/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(),/tmphardcoded) 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 aURLProtocolstub. - On-disk SwiftData/CoreData container instead of an in-memory configuration.
Remedies to prescribe (the two patterns):
- 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 plainfinal class, not@MainActor, because a@MainActorwitness cannot satisfy a non-isolated protocol requirement; keep the seam non-Sendable when its only consumer is a@MainActorservice. - Probe + skip (for tests that must exercise the real store). In
setUp, do one real write inside a catch-alldo/catch(catch { unavailable = true }- catch ANY thrown error, never anerrSec*allowlist, and reference noerrSecconstants so noimport Securityis needed), thentry 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-groupscom.apple.security.application-groups(app groups)aps-environment(push)- any
com.apple.developer.*key
Verdict:
- FAIL - ad-hoc posture + a capability entitlement present. The build will fail for anyone without the team configured. Remedy: defer those entitlements until a real
DEVELOPMENT_TEAMis set, or make the team a prerequisite; code paths that would use the entitlement should detecterrSecMissingEntitlement(-34018) and fall back. Sandbox entitlements are fine to keep. - PASS - ad-hoc with only sandbox entitlements, or a real team is configured.
Also surface the own-item keychain footgun: if the entitlements add keychain-access-groups solely to make the app's own keychain items work under App Sandbox, that is unnecessary and harmful. A Release build signed with a real team auto-gets its default application-identifier access group, which is all that own-item SecItem access needs (leave kSecAttrAccessGroup nil). The first declared group becomes the default write group and can orphan existing items. Recommend the codesign verification step on the built app: codesign -d --entitlements :- /path/to/exported.app and confirm com.apple.application-identifier + com.apple.security.app-sandbox are present.
Check 7: Scheme sharing + UITest release gating
Why: Xcode Cloud only sees shared schemes (xcshareddata/xcschemes/). And if the Test action runs a scheme whose test target set includes slow/flaky UITests, a flaky UITest can block a TestFlight release. The pattern is a dedicated unit-test-only CI scheme for the Test action, with Archive on the full scheme.
How:
- List shared schemes:
ls *.xcodeproj/xcshareddata/xcschemes/*.xcscheme(and.xcworkspace). XcodeGen generatesschemes:fromproject.ymlas shared. - For each scheme, read its
testaction's target list. A scheme whose Test action includes abundle.ui-testingtarget is a release-gating-UITest risk. - Check whether a unit-test-only scheme exists (Test action = unit targets only).
Verdict:
- WARN - no shared unit-test-only CI scheme; the only test scheme includes UITests. Recommend adding one (XcodeGen
schemes:entry;/swift-ci-scaffoldproposes it). Remind that the scheme is only visible to Xcode Cloud after the commit that defines it is pushed, and that Test actions should point at it ("Required to Pass") while Archive stays on the full scheme. - WARN - a scheme referenced by CI is not shared (won't appear in Xcode Cloud).
- PASS - a shared unit-test-only scheme exists.
Check 8: Local gate health (build/lint/test reproducibility)
Why: Catch the "works in my editor" trap. SourceKit live diagnostics are stale for newly added files until the project is regenerated; xcodebuild and swiftlint --strict are authoritative.
How (read-only inventory, do not run the full build here):
- Presence of
.swiftformat,.swiftlint.yml, and a verify script (scripts/verify.shor similar). - Whether
swiftformat/swiftlint/xcodegenare installed (command -v). - Whether
.githooks/exists andcore.hooksPathpoints at it.
Verdict: WARN for a missing local gate (no verify script, no format/lint config). Point the user at /swift-verify to run the actual gate, and at /swift-ci-scaffold to install an opt-in .githooks/pre-push.
Note for the user: do not trust SourceKit "cannot find type / no such module" errors on brand-new .swift files - regenerate (xcodegen generate) and trust xcodebuild / swiftlint --strict instead.
Report format
Print a single categorized report. Group by severity, most severe first. Every line: file:line (or setting), the remedy, and a one-line why.
Swift Release Preflight — <repo name>
Probe: XcodeGen · iOS 26 + macOS 26 · Xcode Cloud (ci_scripts present) · Automatic signing (team set)
FAIL (blocks a clean release)
[2] project.yml:18 CURRENT_PROJECT_VERSION is static "1" with no CI stamp.
→ Add the guarded $CI_BUILD_NUMBER sed to ci_post_clone.sh before generate.
Why: TestFlight rejects duplicate build numbers; every upload after the first collides.
[4] (no macOS entitlements file) App Sandbox not enabled for the Mac App Store build.
→ Add com.apple.security.app-sandbox = true to the entitlements.
Why: ITMS-90296, rejected during processing after upload, not at build time.
WARN (will bite later / soft rejection)
[5] AppTests/AuthServiceTests.swift:31 Test exercises the real KeychainStore (SecItemAdd) with no seam.
→ Inject a SessionTokenStoring fake; keep real-store cases behind a catch-all probe + XCTSkipIf.
Why: headless Xcode Cloud has no unlocked login keychain; writes fail errSecAuthFailed (-25293).
[4] Info.plist ITSAppUsesNonExemptEncryption not set.
→ Set it to false (HTTPS-only case) to skip the export-compliance prompt each upload.
PASS
[1] project.yml ↔ pbxproj identity match (bundle id + team).
[3] ci_scripts/ci_post_clone.sh well-formed (set -e, cd CI_PRIMARY_REPOSITORY_PATH, generate).
[6] Ad-hoc posture not in use; entitlements are sandbox-only.
[7] Shared unit-test-only CI scheme present.
N/A
(none — all checks applicable)
Summary: 2 FAIL · 2 WARN · 4 PASS
Next: /swift-ci-scaffold to fix [2][3], then re-run /swift-preflight.
Fix handoff
--check-only: stop after the report.- Default or
--fix: if any FAIL/WARN has an automatable remedy, ask (oneAskUserQuestion) whether to:- run
/swift-ci-scaffoldfor ci_scripts / build-number / CI-scheme fixes, - run
/swift-verify --fixfor format/lint, - or hand the test-isolation and entitlements edits back as a guided manual list (these touch app/test code and are not blindly automatable). Never write files from this skill directly; delegate to the writers, which show diffs and confirm.
- run
Reference constant catalog
Keychain OSStatus: errSecSuccess 0 · errSecItemNotFound -25300 · errSecAuthFailed -25293 · errSecInteractionNotAllowed -25308 · errSecMissingEntitlement -34018.
App Store / ITMS: ITMS-90242 missing/invalid LSApplicationCategoryType · ITMS-90296 App Sandbox not enabled · ITMS-90683 missing privacy usage description · export-compliance prompt = missing ITSAppUsesNonExemptEncryption.
Xcode Cloud env: CI_BUILD_NUMBER · CI_PRIMARY_REPOSITORY_PATH · CI_XCODE_PROJECT · hooks ci_post_clone.sh / ci_pre_xcodebuild.sh / ci_post_xcodebuild.sh. Workflows are defined in App Store Connect, not the repo.