| name | build-time-secret-injection |
| description | Use when introducing an AdMob production ID (`GADApplicationIdentifier` / `GADBannerUnitID`), ASC API `.p8` key, or any "ships in binary but must stay out of public-repo PR diffs" identifier into an Apple build. Codifies the xcconfig + Info.plist `$()` substitution + `Bundle.main` read pattern, paired with `secrets/.env` for CLI tooling; multi-app `CI_PRODUCT` dispatch + built-bundle smoke-test. Build-time injection mechanism, not the repo-hygiene baseline; for secret-leak prevention (gitleaks, lefthook, GitHub Secret Scanning) see apple-public-repo-security. |
Build-time Secret Injection (Apple-platform)
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
.p8 key, 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 (locked 2026-06-03)
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 = VALUE pairs
Project.swift declares per-target settings(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 before tuist generate runs
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
.env is KEY=VALUE shell-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 sudokuTarget = 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="${APPA_ADMOB_APP_ID:?missing APPA_ADMOB_APP_ID}"
BANNER_UNIT_ID="${APPA_ADMOB_BANNER_UNIT_ID:?missing APPA_ADMOB_BANNER_UNIT_ID}"
;;
AppB)
APP_ID="${APPB_ADMOB_APP_ID:?missing APPB_ADMOB_APP_ID}"
BANNER_UNIT_ID="${APPB_ADMOB_BANNER_UNIT_ID:?missing APPB_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:
let plist = try PropertyListSerialization.propertyList(from: sourceData, ...)
#expect((plist["GADBannerUnitID"] as? String)?.isEmpty == false)
guard
let bannerID = Bundle.main.object(forInfoDictionaryKey: "GADBannerUnitID") 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 a project-memory or secrets file by name; never paste the value inline.
-
Hardcoded production IDs in Live.swift with intent to "swap before release" without an enforcement mechanism. The interim fatalError("REPLACE_IN_v2.5.3:...") pattern is acceptable as a TRANSITIONAL guard paired with xcconfig migration (see Migration ยง3), but is forbidden as a long-term standalone solution. Once xcconfig is in place, replace with: Info.plist $() + runtime guard verifying Bundle.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 archive does NOT populate $(SUDOKU_ADMOB_APP_ID). Only positional xcodebuild VAR=value or -xcconfig override.xcconfig actually injects, OR a CI script writes the xcconfig file before build. Architect review ยงA documents this fatal assumption.
-
Bundle.main.object(forInfoDictionaryKey:) as! String โ force cast bypasses SwiftLint AND crashes hard if CI generation skipped + xcconfig missing. Use as? String + guard let ... else { preconditionFailure } with the unresolved-$() check.
-
Bundle.main from 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/ or Tuist/<Domain>.xcconfig committed by accident. Use an inner secrets/.gitignore deny-list (* / !*.example / !README.md) PLUS root .gitignore rules Tuist/*.xcconfig + !Tuist/*.xcconfig.example so neither slips through default-add operations.
-
Tuist tuist generate silently clobbering unmanaged xcconfigs. If Tuist/<Domain>.xcconfig exists but is NOT referenced in Project.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
.example file with sandbox/test default value
- Add inline comment in
.example describing purpose + where to find the real one (cite project memory file by name, NEVER the literal value)
- If Layer 1: add
$(KEY) substitution to Info.plist; add reading code via Bundle.main with guard (cover nil / empty / $(...) literal); add smoke test for key presence in source plist
- If Layer 1 CI path: extend
ci_post_clone.sh to 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
- Update memory
<domain>-credentials.md to record real values + reference this skill by name
Verification checklist (audit existing implementations)
Adjacent skills + memory
- 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: your ASC submission-ops workflow (who may push what) โ ASC API key handling more broadly
- Project memory file documenting the secret-scrubbing incident โ the incident that triggered this skill pattern
- Project memory files for each credential set โ real values held outside repo (cite by memory-file name, never paste inline)
AdMob env keys pattern
secrets/.env carries per-app production pairs (e.g. APPA_ADMOB_APP_ID /
APPA_ADMOB_BANNER_UNIT_ID and APPB_* twins). Two consumers render
Tuist/AdMob.xcconfig from them: XCC ci_post_clone.sh (from workflow
Secret env vars) and your TestFlight upload task (from secrets/.env).
Values live in secrets/.env (primary) + the XCC workflow config + the
project-memory files as recovery backup โ never in code, comments, or diffs.