Build-time Secret Injection (Apple-platform)
Tuist assumption: the Project.swift snippets below assume the app's .xcodeproj is generated by Tuist from a root-level Project.swift (Tuist's own convention keeps Tuist/ for Package.swift and shared helpers, not for Project.swift itself). A hand-maintained .xcodeproj needs no Project.swift step — see Non-Tuist projects below for the equivalent (an xcconfig referenced directly from the target's Build Settings → Configurations, instead of via Project.swift).
When to invoke
Any task that introduces or wires values which are:
- Technically app-public once the app ships (embedded in
Info.plist, visible in shipped binary, observable in network traffic), AND - Pre-launch sensitive (committed to public repo before ship = ad-fraud reconnaissance window, convention violation among collaborators, or fingerprinting of unreleased product)
Examples:
- AdMob App ID + Banner / Interstitial / Rewarded Unit IDs
- ASC API
.p8key, key-id, issuer ID, ASC numeric app-id - Any third-party SDK app key (Firebase, RevenueCat, etc.) where the convention is "hold until ship"
Do NOT invoke for:
- True per-deploy secrets (signing certs, CloudKit production API keys, push notification keys) — those have stricter patterns (see
apple-public-repo-security) - Values genuinely public from day 1 (bundle IDs, CKContainer IDs, IAP product IDs, marketing URLs)
The pattern
Two storage layers, one mechanism per layer
Layer 1 — Build-time secrets (consumed by Xcode build process)
Tuist/
├── <Domain>.xcconfig # gitignored, real values
├── <Domain>.xcconfig.example # committed, sandbox values + structure
├── Signing.xcconfig # existing precedent (gitignored)
└── Signing.xcconfig.example # existing precedent (committed)
- xcconfig holds
KEY = VALUEpairs Project.swiftdeclares per-targetsettings(configurations: [.debug(name:, xcconfig:), .release(name:, xcconfig:)])pointing at the file- Info.plist uses
$(KEY)substitution to embed values at compile time - App code reads via
Bundle.main.object(forInfoDictionaryKey: "...")— guarded against nil / empty / unresolved$()token - CI side (
ci_scripts/ci_post_clone.sh): reads XCC env vars (stored as Secrets in ASC → Xcode Cloud → Workflow → Environment Variables) and generates the xcconfig file beforetuist generateruns
Layer 2 — CLI tooling secrets (consumed by swift run <CLI> etc.)
secrets/
├── .env # gitignored, real values
├── .env.example # committed, structure + docstring
├── <Domain>AuthKey_*.p8 # gitignored binary cert
└── .gitignore # deny-by-default: */!*.example/!README.md
.envisKEY=VALUEshell-style- Dev pattern:
source secrets/.env && swift run <CLI> --flag-using-$KEY ... - CLI itself does NOT need code changes to read env automatically
Project.swift wiring (Tuist)
let appTarget = Target.target(
// ...
settings: .settings(
base: ["SWIFT_VERSION": "6"],
configurations: [
.debug(name: "Debug", xcconfig: "Tuist/Config-Debug.xcconfig"),
.release(name: "Release", xcconfig: "Tuist/Config-Release.xcconfig"),
]
)
)
Wrap multiple xcconfigs via a Config-{Debug,Release}.xcconfig that #include? both Signing + AdMob (Tuist's xcconfig: arg takes a single path).
Multi-app dispatch in ci_post_clone.sh
When one repo ships multiple app schemes (e.g. AppA + AppB), XCC sets $CI_PRODUCT and $CI_XCODE_SCHEME per workflow. Case-switch on the scheme to pick the right env-var prefix:
case "${CI_XCODE_SCHEME:-${CI_PRODUCT:-}}" in
AppA)
APP_ID="${APP_A_ADMOB_APP_ID:?missing APP_A_ADMOB_APP_ID}"
BANNER_UNIT_ID="${APP_A_ADMOB_BANNER_UNIT_ID:?missing APP_A_ADMOB_BANNER_UNIT_ID}"
;;
AppB)
APP_ID="${APP_B_ADMOB_APP_ID:?missing APP_B_ADMOB_APP_ID}"
BANNER_UNIT_ID="${APP_B_ADMOB_BANNER_UNIT_ID:?missing APP_B_ADMOB_BANNER_UNIT_ID}"
;;
*)
echo "Unknown CI_XCODE_SCHEME: ${CI_XCODE_SCHEME:-}" >&2
exit 1
;;
esac
cat > Tuist/AdMob.xcconfig <<EOF
ADMOB_APP_ID = ${APP_ID}
ADMOB_BANNER_UNIT_ID = ${BANNER_UNIT_ID}
EOF
Run before tuist generate so the per-target xcconfig reference resolves.
Non-Tuist projects
If the project uses a hand-edited .xcodeproj, the equivalent storage is Config/*.xcconfig referenced via target → Build Settings → Base Configuration. Pattern is otherwise unchanged. Tuist regen / clobbering concerns don't apply; manual sync remains your responsibility.
Smoke test scope (CRITICAL)
The substitution-resolution check must run against the built bundle's Info.plist, not the source-tree Info.plist:
// ❌ WRONG — reads source plist, gets literal "$(ADMOB_BANNER_UNIT_ID)" — passes falsely
// (ADMOB_BANNER_UNIT_ID is this project's own xcconfig key name, not one Google defines —
// see the multi-app xcconfig rendering above.)
let plist = try PropertyListSerialization.propertyList(from: sourceData, ...)
#expect((plist["ADMOB_BANNER_UNIT_ID"] as? String)?.isEmpty == false) // passes for "$(...)" string
// ✅ RIGHT — combine source-plist key-presence test + runtime guard in code
// Source test catches "someone deleted the key"; runtime guard catches "substitution failed"
guard
let bannerID = Bundle.main.object(forInfoDictionaryKey: "ADMOB_BANNER_UNIT_ID") as? String,
!bannerID.isEmpty,
!bannerID.hasPrefix("$(")
else { preconditionFailure("...") }
A future PR should add a build-phase script that asserts no $() literals survived substitution into the built .app/Info.plist. Until then, the runtime guard is the catch.
Anti-patterns to refuse
Production IDs in code comments, docstrings, PR descriptions, commit messages, or
Info.plist <!-- -->blocks. Even when the value field uses a sandbox stand-in, the surrounding prose leaks production via git history. Including the literal ID anywhere in tracked text — even prefixed by TODO / FIXME / "will-replace" — IS the leak. Reference the out-of-repo vault entry or the gitignored secrets file by name; never paste the value inline.Hardcoded production IDs in
Live.swiftwith intent to "swap before release" without an enforcement mechanism. The interimfatalError("REPLACE_BEFORE_RELEASE: ...")pattern is acceptable as a TRANSITIONAL guard paired with xcconfig migration, but is forbidden as a long-term standalone solution. Once xcconfig is in place, replace with: Info.plist$()+ runtime guard verifyingBundle.main.object(forInfoDictionaryKey:)returns non-empty AND non-$(...).Conflating GitHub Secrets with XCC env vars. Apple's XCC does not read GH Secrets — they're separate storage. If CI builds on XCC, secrets must live in XCC's Environment Variables UI, not GH.
Most common mistake: ❗ Shell env vars do NOT feed xcconfig
$(VAR)interpolation. xcconfig variable resolution reads from the build settings table, not process env.source admob.env && xcodebuild archivedoes NOT populate$(ADMOB_APP_ID). Only positionalxcodebuild VAR=valueor-xcconfig override.xcconfigactually injects, OR a CI script writes the xcconfig file before build.Bundle.main.object(forInfoDictionaryKey:) as! String— force cast bypasses SwiftLint AND crashes hard if CI generation skipped + xcconfig missing. Useas? String+guard let ... else { preconditionFailure }with the unresolved-$()check.Bundle.mainfrom inside a SwiftPM package is fine for app-target composition root reads but flaky for #Preview / test host / unit-test contexts. Wrap reads in a smoke test that asserts the key exists in source plist; runtime guard compensates for missing-substitution case.secrets/orTuist/<Domain>.xcconfigcommitted by accident. Use an innersecrets/.gitignoredeny-list (* / !*.example / !README.md) PLUS root.gitignorerulesTuist/*.xcconfig+!Tuist/*.xcconfig.exampleso neither slips through default-add operations.Tuist
tuist generatesilently clobbering unmanaged xcconfigs. IfTuist/<Domain>.xcconfigexists but is NOT referenced inProject.swift's.settings(configurations:), Tuist regen drops it from the project. Verify Project.swift wiring before assuming xcconfig is active.
Checklist when adding a new secret value
- Decide layer:
- Consumed by Xcode build / Info.plist / Bundle.main read → Layer 1 xcconfig
- Consumed by
swift run/ CLI scripts / shell → Layer 2.env
- Add KEY to appropriate
.examplefile with sandbox/test default value - Add inline comment in
.exampledescribing purpose + where to find the real one (name the out-of-repo vault entry — password manager / team vault — NEVER the literal value) - If Layer 1: add
$(KEY)substitution toInfo.plist; add reading code viaBundle.mainwith guard (cover nil / empty /$(...)literal); add smoke test for key presence in source plist - If Layer 1 CI path: extend
ci_post_clone.shto write the new KEY from XCC env var with${VAR:?missing message}fail-fast; if multi-app, branch on$CI_XCODE_SCHEME - Run
grep -r "<real-prod-value>" .(excluding gitignored dirs) — must return zero hits - Record the real values in the out-of-repo secret store (password manager / team vault) and note which entry holds them — never in a tracked file
Verification checklist (audit existing implementations)
- Root
.gitignorehasTuist/*.xcconfig+!Tuist/*.xcconfig.example -
secrets/.gitignoreinner deny-list present (* / !*.example / !README.md) -
Project.swiftper-target.settings(configurations:)references the xcconfig -
Info.plistuses$(KEY)substitution for each secret - App code reads via
Bundle.main.object(forInfoDictionaryKey:)with guard (NOTas!) - Runtime guard rejects
nil, empty, and$(...)literal - Smoke test reads source plist for key-presence assertion
-
ci_post_clone.shwrites xcconfig BEFOREtuist generate - Multi-app:
caseon$CI_XCODE_SCHEMEselects per-app env vars - XCC Workflow Environment Variables UI lists each KEY (per scheme if multi-app), marked Secret
-
grep -r "<real-prod-value>" .returns zero hits across all tracked files
Related skills
- REQUIRED background:
apple-public-repo-security— broader secret-leak prevention (gitleaks, lefthook, GitHub Secret Scanning) - SIBLING:
monetization-sdk-integration— invoke together when wiring AdMob; this skill is the secret-handling layer - SIBLING:
asc-api-automation— ASC API key handling (the.p8) once the key leaves the build and drives the REST API