Build time secret injection
Skill wei18/apple-dev-skills/apple-dev-skills/skills/build-time-secret-injection
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.From its SKILL.md
npx -y skills add wei18/apple-dev-skills --skill build-time-secret-injectionAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
11.3 KB, ~2.7k tokens by cl100k_base, as published. Nobody here has run it
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
.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 (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 = 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 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:
// ❌ WRONG — reads source plist, gets literal "$(GADBannerUnitID)" — passes falsely
let plist = try PropertyListSerialization.propertyList(from: sourceData, ...)
#expect((plist["GADBannerUnitID"] 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: "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.swiftwith intent to "swap before release" without an enforcement mechanism. The interimfatalError("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 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$(SUDOKU_ADMOB_APP_ID). Only positionalxcodebuild VAR=valueor-xcconfig override.xcconfigactually 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. 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 (cite project memory file by name, 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 - Update memory
<domain>-credentials.mdto record real values + reference this skill by name
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
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.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.