Build time secret injection
Skill wei18/apple-dev-skills/apple-dev-skills/skills/build-time-secret-injection
Reusable Claude Code skills for AI-agent-driven Swift / Apple-platform development — composable via git submodule; aggregates other specialist skill repos
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.
What its author says it does
Copied from the file, not written here
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.
SKILL.md
11.3 KB, 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.