Skip to content

iOS rules

  • Never hand-edit .pbxproj or .xcodeproj/ contents. Create .swift/resource files only. How a new file joins the target depends on how the project file is produced, so check in this order:

    1. project.yml at the component root — the project is generated by XcodeGen. The .pbxproj is a build artifact that happens to be committed. Don’t hand-edit it, and don’t add the file through Xcode — the next xcodegen generate overwrites both. If sources: declares a directory (the usual case), a new file inside it needs no project.yml change at all: run xcodegen generate and commit the regenerated .pbxproj in the same change as the file that caused it. A new target does need a project.yml edit first.
    2. PBXFileSystemSynchronizedRootGroup in the .pbxproj — Xcode 16 synchronized groups. Files under a synced folder are auto-included in the target; don’t add membership manually and don’t edit the .pbxproj.
    3. Neither — ask the user to add the file to the target in Xcode rather than editing the project file.

    Check the generator first. The two aren’t mutually exclusive, and the synchronized-groups test answers the wrong question for a generated project: an XcodeGen project reports no synchronized groups, so testing for those alone routes you to step 3 — and adding a file through Xcode is the one action guaranteed not to survive xcodegen generate.

  • Never modify .xcworkspace contents.

  • Never add Swift Package Manager dependencies without explicit user permission. To bump existing deps (no new deps added), use flowdeck project packages update — it re-resolves Package.resolved to the latest versions allowed by the existing upToNextMajorVersion constraints without touching the .pbxproj; build + test afterward.

  • Never change the deployment target without explicit user request.

  • Never modify .entitlements files without explicit user request.

  • Never use NavigationView — always NavigationStack.

  • Never use ObservableObject — always @Observable.

  • Never use @StateObject — always @State with @Observable objects.

  • Never use @Published@Observable properties publish automatically.

  • ITSAppUsesNonExemptEncryption must be set in Info.plist (or as INFOPLIST_KEY_ITSAppUsesNonExemptEncryption build setting). NO for apps using only standard HTTPS; YES for apps with custom encryption. A missing or wrong value causes export compliance failures on every TestFlight upload.
  • Terms of Use (EULA). Configure a custom EULA in App Store Connect → License Agreement (if you set none, Apple’s standard EULA applies automatically). Separately, App Review requires a functional Terms of Use link in the App Store description — link your custom EULA, or Apple’s standard EULA: https://www.apple.com/legal/internet-services/itunes/dev/stdeula/. A missing/broken EULA link is a common rejection.
  • Privacy Policy link. Required for every app: set the Privacy Policy URL in App Store Connect → App Information, and make the policy reachable inside the app. Include the link in the App Store description too. A non-functional or missing privacy policy link is a frequent rejection.
  • Subscription / IAP apps (Guideline 3.1.2): the paywall/purchase screen itself must clearly show price, duration, auto-renewal terms, and how to cancel — not just the description — and both the privacy policy and terms of use (EULA) links must be clickable on that screen (in the binary, not only in metadata). See Premium / subscription gating.
  • No price in the app name or icon (Guideline 2.3.7). “Free”, “Lite (Free)”, a price, or a FREE badge burned into the icon artwork all get rejected — in the App Store name, the on-device name, and the icon image itself. The description is exempt. On a free/paid pair this bites the free product: set both CFBundleDisplayName and CFBundleName (the fallback iOS shows in Settings, which otherwise defaults to $(PRODUCT_NAME)), and check the 1024pt icon for baked-in badge text.
  • A version train closes permanently once its version reaches READY_FOR_SALE. Uploading another build against that same marketing version fails with error 90186 (“Invalid Pre-Release Train”), no matter the build number. A shipped app needs a version bump to accept a new build. In a repo shipping several apps, this is why one release trigger must never fan out to every product: the already-shipped one can only fail.

Shipping more than one app from one repository

Section titled “Shipping more than one app from one repository”

A repository that ships a paid and a free variant declares each as a [[product]] in .lacquer.toml:

[[product]]
name = "MyApp"
scheme = "MyApp"
bundle_id = "com.example.myapp"
asc_app_id = "1234567890"
[[product]]
name = "MyApp Lite"
scheme = "MyApp Lite"
bundle_id = "com.example.myapp.lite"
asc_app_id = "0987654321"

The release workflow becomes a matrix with one leg per product: separate archive, IPA, TestFlight upload and GitHub Release asset for each.

CI does the same. Build (Release) and Test get one leg per product, so the free variant is compiled and its own test bundle is run. Four optional fields drive the test leg, each defaulting to the historical single-app value:

[[product]]
name = "MyApp Lite"
scheme = "MyApp Lite"
bundle_id = "com.example.myapp.lite"
asc_app_id = "0987654321"
tag_prefix = "myapplite"
test_target = "MyApp LiteTests" # defaults to "<name>Tests"
ui_test_target = "" # blank = this variant has no UI tests
extra_test_targets = [] # local package suites to run as well
app_target = "MyApp.app" # coverage target; defaults to "<name>.app"

app_target is declared rather than derived because a scheme and its built product genuinely differ — one app in this fleet builds A Bible Verse Daily.app from a scheme named A Bible Verse Each Day Free. A derived value would select no coverage row, and jq selecting nothing reports 0.0%, not an error. ui_test_target is conditional in the shell rather than always passed: an empty -only-testing: selector matches nothing and still exits 0.

extra_test_targets exists because -only-testing: is a whitelist. A local Swift package’s test target that no selector names is run by nothing — the app’s selector excludes it, xcodebuild exits 0, and a maintained suite (and any coverage rule over the code it covers) is enforced by no one. The observed workaround was copying an assertion into the app’s test target via @testable import purely so something would execute it.

extra_test_targets = ["CoreKitTests", "Feature KitTests"]

Per-product, for the same reason test_target is: a package linked into one scheme and not the other must not be selected on the leg that cannot run it, where it would match nothing and pass. The selectors are built as a shell array, so a target name containing a space stays one argument rather than word-splitting into two selectors that each match nothing.

Declaring any turns on a Verify Test Selectors Matched step, which reads the result bundle back and fails the job for any selector that produced no test bundle — the only thing standing between an extra selector and a green run over a suite that did not execute. It fails closed: an unreadable bundle, a missing tool or a changed schema is red, not a pass. Opting in hardens test_target and ui_test_target too, since the step checks every selector the job passed. The pre-commit Swift Tests hook runs the same extras, so the fast local loop does not cover less than CI.

Each leg’s simulator and uploaded test results are scoped by a slug derived from the product name. Two legs sharing one simulator name means the second leg’s stale-simulator cleanup deletes the simulator the first is mid-test on, which reports as “the test runner crashed before establishing connection” and reads like an app bug.

Declare nothing and you get exactly one product, synthesised from [project] — not a special case in the workflow, a one-entry matrix down the same code path. A single-app project’s CI workflow is rendered byte-for-byte as it was before products existed.

fail-fast: false because the products are separate App Store submissions with separate review outcomes: one failing validation must not cancel the other’s upload. max-parallel: 1 because both legs sign on the same runner and share its certificate directory.

Two things bite specifically on a paid/free pair, both learned the hard way: Guideline 2.3.7 rejects a price reference in the name or icon of the free product, and error 90186 means a release trigger that fans out to a product which has already shipped that version can only fail — see App Store requirements above.

Two separate buckets — never mix them.

App-runtime keys → Secrets.xcconfig (compiled into the app)

Section titled “App-runtime keys → Secrets.xcconfig (compiled into the app)”

Service keys the app needs at runtime (RevenueCat, Aptabase, …) live in a gitignored Secrets.xcconfig, never in source or the committed project.yml. The lacquer syncs a Secrets.xcconfig.example template into the component dir. See the ios-secrets-setup skill for wiring a new key through project.yml into Info.plist and reading it at runtime. Secrets.xcconfig values are build-time — they’re baked into the binary, so treat them as obfuscated, not secret. A truly sensitive secret belongs on a server, never in the app.

CI / server secrets → GitHub Actions (never in the app)

Section titled “CI / server secrets → GitHub Actions (never in the app)”

The release and quality workflows — and any server-side job that calls a vendor REST API — read these from repo/org GitHub Actions secrets, never from an xcconfig.

Organization secrets only exist for organizations. gh secret set --org 404s against a personal account, so check which {{GITHUB_ORG}} is before choosing:

Terminal window
gh api /orgs/{{GITHUB_ORG}} >/dev/null 2>&1 \
&& gh secret set <NAME> --org {{GITHUB_ORG}} \
|| gh secret set <NAME> -R {{GITHUB_ORG}}/<repo> # personal account: per repo

This is not a nitpick. Every repo in this fleet was missing CLAUDE_CODE_OAUTH_TOKEN for months because the instruction here was unconditionally org-level and the account owning them is personal — so the command silently could not have worked, and four workflows failed on every run. When a secret is per-repo, fan it out deliberately rather than one at a time:

Terminal window
for r in $(gh repo list <owner> --limit 200 --json name --jq '.[].name'); do
gh secret set <NAME> -R <owner>/"$r" < secret.txt
done
Secret Used by Source
ASC_KEY_ID release App Store Connect → Users and Access → Integrations → API key
ASC_ISSUER_ID release same page (issuer ID)
ASC_KEY_CONTENT release the .p8 private key contents
APPLE_TEAM_ID release Apple Developer membership
KEYCHAIN_PASSWORD release (signing) the dedicated runner’s login-keychain password — set this as an org-level secret so every repo’s release can unlock the system keychain (release never creates its own, and its final always() step restores the keychain’s prior settings and re-locks it, so neither the unlocked window nor the timeout change outlives the run)
CLAUDE_CODE_OAUTH_TOKEN claude, quality-review, dependency-audit claude setup-token
SENTRY_AUTH_TOKEN release (dSYM upload) Sentry → Settings → Auth Tokens, scoped to project:releases
SENTRY_ORG release (dSYM upload) the Sentry org slug
SENTRY_PROJECT release (dSYM upload) the Sentry project slug — differs per repo, so this one is never org-level
REVENUECAT_REST_API_KEY server/REST API calls RevenueCat → API keys → secret key (sk_…) — full account access
APP_STORE_CONNECT_FEEDBACK_KEY_IDENTIFIER testflight-feedback a separate, least-privilege ASC API key id (read-only)
APP_STORE_CONNECT_FEEDBACK_ISSUER_ID testflight-feedback issuer id for that key
APP_STORE_CONNECT_FEEDBACK_PRIVATE_KEY testflight-feedback that key’s .p8 contents

The TestFlight-feedback job uses its own App Store Connect key, distinct from the release/signing key (ASC_*) — it only needs read access to beta feedback, and it runs on a GitHub-hosted runner, so it must never carry the signing key.

Several of these are opt-in, and skip rather than fail when unset, so a project that hasn’t provisioned a vendor isn’t permanently red:

  • TestFlight feedback. Without all three APP_STORE_CONNECT_FEEDBACK_* secrets the daily run skips with a ::notice:: and stays green; a manual workflow_dispatch fails loudly instead, because someone deliberately asked for feedback and a green check with zero results is indistinguishable from “no new feedback”.
  • Sentry dSYM upload. All three SENTRY_* secrets must be present or the step skips — a project with no Sentry gets a clean release, not a red one.
  • The three Claude-powered workflows (ios-claude.yml, ios-quality-review.yml, ios-dependency-audit.yml) hard-fail inside the action when CLAUDE_CODE_OAUTH_TOKEN is empty, so each checks for it first and behaves according to who is waiting. Unattended runs (quality-review, dependency-audit) skip with a ::warning:: and stay green, because a permanently red scheduled run trains you to stop reading the Actions tab. The interactive one (ios-claude.yml) fails and comments on the thread saying why — someone typed @claude and is waiting.

GITHUB_TOKEN is provided automatically by Actions — do not set it.

The release job borrows your login keychain, so it must give it back. It unlocks the login keychain to sign, and sets an auto-lock timeout to keep it open across a 45-minute job. That timeout is a change to a keychain the job doesn’t own, and it used to be permanent: a runner Mac that is also somebody’s personal machine was left with lock-on-sleep timeout=3600s — macOS defaults to neither — so it locked hourly and on every sleep, days later, with nothing in any run saying why. The final always() step now captures the prior settings and restores them. If your runner is dedicated hardware nobody logs into, none of this is visible; if it’s also a machine you use, it’s worth knowing CI reaches your login keychain at all.

Every synced workflow already sets the correct runner per job — when editing an existing job, keep whatever runs-on it already has; don’t re-derive it. The rule below matters only when authoring a brand-new job:

Xcode-touching work (build/test/lint/archive/sign/release) uses runs-on: [self-hosted, macOS, ARM64, dedicated] — never a GitHub-hosted macOS runner (macos-latest) or a stray self-hosted label like mac-mini. A pure script/REST-call job with no Xcode dependency (merge gates, a TestFlight-feedback fetch, a deploy) uses ubuntu-latest instead — don’t tie up the Mac for work that doesn’t need it.

See the macos-ci-recipes skill for the reasoning and copy-in recipes when the new job is a macOS-only or hybrid iOS+macOS workflow.

In an interactive session, reach for flowdeck first — build, run, test, simulator, device, logs, UI automation. It resolves schemes and destinations, keeps DerivedData where you point it, and returns structured output, so it beats hand-assembling an xcodebuild line every time.

Terminal window
flowdeck simulator list # find an available simulator UDID (names are ambiguous across OS versions)
flowdeck build -w {{XCODEPROJ}} -s <YourScheme> -S <udid> -d {{COMPONENT_PREFIX}}DerivedData
flowdeck test -w {{XCODEPROJ}} -s <YourScheme> -S <udid> -d {{COMPONENT_PREFIX}}DerivedData
flowdeck project packages update # bump SPM deps within constraints (no .pbxproj edit)

Prefer a UDID over a simulator name — names duplicate across OS versions and resolve ambiguously.

Raw xcodebuild/xcrun is correct in non-interactive contexts, and this profile ships it that way. .pre-commit-config.yaml runs xcodebuild test with an explicit -scheme/-destination; ios-ci.yml and ios-release.yml run xcodebuild for build, test and archive, xcodebuild -showBuildSettings in the Baseline job, and xcrun simctl for the whole simulator lifecycle. Those pin their destination and toolchain deliberately, and the steps around them parse their output — so don’t “fix” a hook or a workflow to call flowdeck instead. That changes what CI actually verifies; it isn’t a style cleanup.

  • Pass a unique derived-data path per worktree (-d {{COMPONENT_PREFIX}}DerivedData-<feature>) so parallel worktrees don’t collide on one DerivedData dir (collisions surface as SIGKILL test crashes).
  • Delete that derived-data dir before running format/lint — otherwise it lints compiled dependency sources and reports phantom file_length/format violations. (The .swiftformat/.swiftlint.yml excludes cover DerivedData*; keep your path matching that glob.)
  • Ignore SourceKit diagnostics in a fresh worktree (No such module 'X', Cannot find type) — the worktree has no built index, so they’re false positives. The authoritative signal is flowdeck build / flowdeck test’s printed output — not its exit code (see above).

The synced .claude/settings.json installs hooks that: block edits to .pbxproj/.xcworkspace/.xib/.storyboard/.entitlements (PreToolUse), run SwiftFormat + SwiftLint on every .swift write (PostToolUse), and — on SessionStart — auto-approve the Xcode MCP permission dialog via allow_mcp.js (requires macOS Accessibility permission for your terminal). That auto-approve is a deliberate convenience; remove the SessionStart hook if you’d rather approve the Xcode MCP dialog manually.

Tests must never run longer than 5 minutes (300 seconds). If tests exceed 5 minutes, they’re hung. Kill the process immediately and investigate. When invoking builds/tests via a Bash tool, set a 300000 ms timeout.

View (SwiftUI) → ViewModel (@Observable, @MainActor) → Service → Repository → DataSource

Key patterns:

  • All ViewModels: @Observable + @MainActor.
  • All service/repository protocols: Sendable.
  • Stateless services: final class; stateful services: actor.
  • Async operations: async/await and AsyncStream.
  • Constructor injection for dependencies.

Project structure:

{{COMPONENT_PREFIX}}<YourApp>/
├── App/ # App entry point, dependency container
├── Features/ # Feature modules (one folder per feature)
├── Core/ # Services, Repositories, Models, Networking
├── Shared/ # Components, Extensions, Utilities
└── Resources/

Layer rule: ViewModels must not depend directly on Repository protocols. Inject Service protocols instead.

If this project imports SwiftData, lacquer sync suggests the dpearson2699/swift-ios-skills@swiftdata skill (see internal/skillsuggest) — it covers CloudKit-compatible schema constraints. Install it with skills add if it wasn’t suggested.

Swift Testing is the standard for all new test files. Use @Test, @Suite, and #expect. XCTest is legacy — only modify existing XCTest files when touched for other reasons. Never create new XCTest files.

import Testing
@testable import <YourApp>
@Suite("Feature Tests", .serialized)
@MainActor
struct FeatureTests {
private var mockService = MockService()
private var sut: FeatureViewModel {
FeatureViewModel(service: mockService)
}
@Test func testBehavior() async {
// arrange, act, assert
}
}

During RED/GREEN, run targeted tests only (-only-testing:<YourApp>Tests/SomeSuite/someTest) — never a full self-run. The full suite runs at pre-commit and again in CI (fresh checkout). Treat SwiftLint warnings as errors — fix the code, never suppress (see core rule 7).

Test support: waitUntil (no Task.sleep in tests)

Section titled “Test support: waitUntil (no Task.sleep in tests)”

The no_task_sleep_in_tests lint rule bans arbitrary Task.sleep delays in tests — they cause flaky failures. See the swift-testing-wait-until skill for the polling helper to add to your test target instead.

Every CI gate and where it runs before push. See core “Local checks match CI” — a new CI job adds a row here, and a hook never runs weaker than its CI twin, except a job that is itself a build or test run (see the note below the table).

CI job / step Local
Lint → SwiftLint --strict pre-commit swiftlint (--strict, staged files)
Lint → SwiftFormat --lint pre-commit swiftformat (writes; a changed file fails the commit)
missing_docs pre-commit swiftlint-docs (--strict, staged files)
Test CI-only — see below
Baseline lacquer audit (exit 4) — CI-only, it reads the pbxproj
Build (Release) CI-only: a full Release archive is not a commit-time cost
No lacquer drift lacquer audit (exit 3) — run it locally any time

No local xcodebuild test/docbuild hook, deliberately. This fleet’s self-hosted Mac runner is frequently the very same physical machine you commit from. On separate hardware, a local build/test hook buys you an earlier signal before a slower CI run; here it buys nothing but a second, identical xcodebuild invocation on the one shared box you’re also trying not to tie up. Test is CI-only for that reason — not an oversight, and not a case of “a hook never runs weaker than its CI twin,” since there is no weaker local version, only none. Everything else in the table above is static analysis (lint/format) with no build cost, so it stays local as usual.

The --strict flags are the load-bearing part. line_length, file_length, type_body_length and function_body_length are all warning severity in .swiftlint.yml, so without --strict they print and pass locally and then fail the PR. A hook carrying || true, or missing --strict, is the single most common way this fleet produces a “worked on my machine” failure — and it is now a drift violation, not just a bad idea.

The editor hook counts too. The PostToolUse hooks in .claude/settings.json run on every Swift write, and they were the worst offender of all: swiftlint lint --path "$FP" --quiet 2>/dev/null || true. --path has not been a valid SwiftLint option for some time — the command errored on every single invocation, and 2>/dev/null || true swallowed the error, so the hook linted nothing, ever, and looked healthy doing it. It now passes the path positionally, names the project config explicitly, runs --strict, and on a violation exits 2 so the diagnostic reaches the agent that just wrote the file. The formatter hook likewise names --config explicitly, and neither hook suppresses stderr any more.

DocC is a requirement — see core documentation rules for the standard and the relaxation mechanism. This is the Swift half.

Every declaration above private carries a /// doc comment, and the DocC archive builds with zero warnings. Both are checked by the local pre-commit hook — there is no CI-side publishing step; this is a local, pre-commit-enforced requirement only.

Use - Parameter / - Returns / - Throws for anything a caller must know, and double-backtick symbol links rather than plain-text type names — a link is checked by the build, prose isn’t. Run the checks locally:

Terminal window
swiftlint --strict --config .swiftlint-docs.yml . # every declaration documented
scripts/build-docs.sh docs-site # docs build clean

.swiftlint-docs.yml is a separate config from .swiftlint.yml on purpose: the documentation baseline is the one rule set a project may relax, and mixing it in would either hand every style rule an escape hatch or leave this one without the one the standard promises.

Three settings in scripts/build-docs.sh decide whether this checks anything:

  • DOCC_MINIMUM_ACCESS_LEVEL=internal — DocC extracts public and above by default, and an app target’s code is internal by default. Without it an app builds an archive containing essentially nothing, succeeds, and reports as a pass.
  • OTHER_DOCC_FLAGS=--warnings-as-errorsDOCC_FLAGS is also a real build setting name, and it’s silently ignored. The same broken symbol link exits 0 with DOCC_FLAGS and 65 with OTHER_DOCC_FLAGS; picking the obvious-looking name turns the gate off without turning the job red.
  • DOCC_TRANSFORM_FOR_STATIC_HOSTING=YES plus DOCC_HOSTING_BASE_PATH, which is baked into every asset URL and must match where the site is served from.

A .docc catalog adds landing pages, articles, and tutorials beyond the symbol reference; a <Target>.md root page is the highest-value addition, because it’s what a reader lands on.

Apply these whenever touching widgets, animations, networking, or background work — see the ios-performance-battery-patterns skill for the concrete patterns (Timeline entry limits, animation cleanup, Low Power Mode guards, constrained-network config, observer/task cleanup under @Observable).

Swift 6 concurrency & default actor isolation

Section titled “Swift 6 concurrency & default actor isolation”

Swift 6 language mode is the baseline, in every build configuration — not just the app target. SWIFT_VERSION = 6 and SWIFT_TREAT_WARNINGS_AS_ERRORS = YES are asserted by the lacquer and checked two ways: lacquer audit reads the pbxproj statically across every configuration, and the CI Baseline job reads the effective settings via xcodebuild -showBuildSettings. Below Swift 6, data-race diagnostics are warnings rather than errors, so violations accrue invisibly until the migration has to happen as one large risky change. A target left behind (tests, widget, watch app) reports as a coverage ratio like 4/12, not as a pass. Genuine exceptions go in [baseline.relax] with a reason and an expiry.

If the app target sets SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor (approachable concurrency), classes without an explicit isolation annotation — including services — are implicitly @MainActor.

  • await urlSession.data(for:) still does its network I/O off the main thread; the suspension yields. Only the synchronous work around it (e.g. JSON decoding) runs on the main actor — fine at small payload sizes.
  • If a method does heavy synchronous work (large decode, image processing, crypto), mark that method (or the type) nonisolated / @concurrent deliberately so it runs off-main.
  • Never reach for @unchecked Sendable or nonisolated(unsafe) to silence a diagnostic. Fix the root cause: make the type a value type, isolate it to an actor, or make stored state immutable.
  • Mini-player / bottom accessory: the shipping API is .tabViewBottomAccessory { ... } — not .tabViewAccessory.
  • Tab-bar morphing search: declare the search tab with Tab(role: .search) and use .searchable(text:prompt:) with automatic placement. SearchFieldPlacement.tabBar does not exist in the iOS 26 SDK.
  • Naming: name your tab enum AppTab (or similar) — a type named Tab shadows SwiftUI’s Tab builder struct and breaks the TabView content.

Validate every user-provided URL before it reaches AVPlayer, URLSession, or a WKWebView — see the url-validation-security skill for the positive-allowlist validator and where to apply it.

Accessibility & design-token contrast (WCAG 1.4.11)

Section titled “Accessibility & design-token contrast (WCAG 1.4.11)”

Audit non-text contrast, not just text. Ship two distinct boundary tokens and use them for their intended roles:

  • controlBorder — ~white @ 30% opacity, ≥ 3:1 against its background — for the boundary of an interactive control (button outline, text-field border, selected chip).
  • a decorative hairline — ~white @ 8% — for dividers and separators that carry no meaning.

Other rules:

  • Use a saturated controlAccent for controls that sit against a white system thumb (e.g. Toggle). A near-white accent fails ~3:1 against the white thumb and reads as “off” to low-vision users.
  • Selection states must be non-color-redundant: show a checkmark / icon, not just a colored ring or tint, so the state survives color-blindness and grayscale.

Premium / subscription gating (if monetized)

Section titled “Premium / subscription gating (if monetized)”

If this project imports StoreKit, lacquer sync suggests the dpearson2699/swift-ios-skills@storekit skill (see internal/skillsuggest) — it covers paywall/entitlement architecture. Install it with skills add if it wasn’t suggested.