chore: add project-level xcodegen agent skill

This commit is contained in:
Qiu Yuzhou
2026-09-19 23:30:00 +08:00
parent 5ec308301a
commit 720794de2d
3 changed files with 589 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
---
name: xcodegen
description: Generate and manage Xcode projects with XcodeGen — write project.yml as the source of truth and produce .xcodeproj from it instead of hand-editing pbxproj files. Use this skill whenever creating or modifying an Xcode project's structure (targets, schemes, build settings, Info.plist, entitlements, dependencies like SPM packages or frameworks), when the user mentions xcodegen / project.yml / regenerating or fixing a .xcodeproj, or when asked to add files or targets to an Xcode project — even if they don't name XcodeGen explicitly. Also applies to CI project generation and migrating a repo to a generated-project workflow.
---
# XcodeGen
XcodeGen generates `.xcodeproj` files from a declarative `project.yml` spec.
**Core mental model: `project.yml` is the source of truth; `.xcodeproj` is a disposable build artifact.** Never hand-edit the `.xcodeproj` and never tell the user to add files through Xcode's UI — every structural change (new target, new source directory, new dependency) goes through `project.yml` + regeneration. That is the entire point of the tool: no pbxproj merge conflicts, and the project structure becomes reviewable YAML in git.
## The workflow
1. Check the tool exists: `which xcodegen` (install with `brew install xcodegen`). If the spec uses newer features, note them in `options.minimumXcodeGenVersion`.
2. Write or edit `project.yml` at the repo/project root.
3. Generate: `xcodegen generate` (add `--use-cache` in scripts/CI so unchanged specs skip regeneration).
4. Verify the result — a generated project that doesn't build is not done:
- `xcodebuild -list -project <Name>.xcodeproj` — schemes and configurations are as expected.
- `xcodebuild -project <Name>.xcodeproj -scheme <Scheme> build` — the real gate.
- `xcodegen dump --spec project.yml` prints the fully-resolved spec; use it to debug `include:` merges or surprising settings presets.
## Minimal working example
macOS app + unit tests + scheme — the shape most projects need; adapt platform, paths, and deployment target:
```yaml
name: MyApp
options:
bundleIdPrefix: com.example # targets without PRODUCT_BUNDLE_IDENTIFIER get com.example.<TargetName>
deploymentTarget:
macOS: "13.0"
createIntermediateGroups: true
targets:
MyApp:
type: application
platform: macOS
sources:
- path: MyApp # a directory: everything under it is picked up
info:
path: MyApp/Info.plist # generated and written to disk; CFBundle* keys auto-filled
properties:
LSMinimumSystemVersion: "13.0"
scheme:
testTargets:
- MyAppTests # scheme "MyApp" builds app + runs tests
MyAppTests:
type: bundle.unit-test
platform: macOS
sources:
- path: MyAppTests
dependencies:
- target: MyApp # TEST_HOST is set automatically for unit tests
```
```bash
xcodegen generate
xcodebuild -project MyApp.xcodeproj -scheme MyApp build
```
## Rules that prevent most mistakes
- **Regenerate after adding/removing source files.** Source globs are resolved when `xcodegen generate` runs, not live by Xcode — a new `.swift` file on disk is invisible to the project until then. "My file isn't in the target" almost always means: run `xcodegen generate`. (Xcode auto-reloads a regenerated project that's already open.)
- **Never mix settings styles.** In a `settings:` block, if you use `base:`, `configs:`, or `groups:`, a sibling plain map (e.g. `MARKETING_VERSION: 1.0` at the same level) is *silently ignored*. Pick one style per block; the plain-map style only works alone.
- **Quote deployment targets**: `deploymentTarget: "14.0"`, not `14.0` (YAML parses the latter as a float and trailing zeros vanish).
- **Prefer `info:` / `entitlements:` blocks** over committing static plists: XcodeGen writes the files and sets `INFOPLIST_FILE` / `CODE_SIGN_ENTITLEMENTS` for you, and fills `CFBundleIdentifier`, `CFBundleExecutable`, `CFBundleShortVersionString` etc. automatically. Anything you put in `properties:` wins. Info.plist files found in sources are never added to build phases.
- **Mark generated/optional paths** with `optional: true` on a source, or generation fails when the directory doesn't exist yet.
- **Test targets**: declare the app as a `dependencies: - target:` of the test target — that alone wires up `TEST_HOST` (unit tests) or `TEST_TARGET_NAME` (UI tests, type `bundle.ui-testing`).
- **Extensions embed themselves**: a dependency of an `application` target defaults to `embed: true`, so `- target: MyExtension` is usually all an app needs — no copy-files phase to write.
- **Extension bundle IDs must live under the app's bundle ID.** macOS/iOS validate that an embedded extension's bundle ID starts with the parent app's — `com.example.MyProxy.Tunnel`, not `com.example.Tunnel`. Since `bundleIdPrefix` auto-derivation gives every target `prefix.TargetName`, extensions fail a *build-validation* step (not compilation) with "Embedded binary's bundle identifier is not prefixed with the parent app's bundle identifier". Always set `PRODUCT_BUNDLE_IDENTIFIER` explicitly on extension targets (see recipes.md #2).
- **`.gitignore` the generated project** (`*.xcodeproj`) so no one edits it by accident. If the repo currently tracks one, flag the migration step (remove from index, regenerate locally) rather than doing it silently.
- **`xcodegen generate` is idempotent.** When in doubt, re-run it; there is no state to corrupt.
## Choosing between the reference files
- `references/project-spec.md` — syntax cheat sheet: every section of `project.yml` with exact key names. Read when you need a field you don't remember precisely (source exclude patterns, dependency options, scheme fields, SPM version constraints).
- `references/recipes.md` — complete copy-pasteable `project.yml` examples: app + unit/UI tests, app extension (e.g. network extension) with entitlements, SPM remote/local packages, per-config xcconfig, vendored pre-built binaries/frameworks, build-script phases, splitting a spec with `include:`. Read before writing any non-trivial spec — reuse a recipe rather than assembling from memory.
@@ -0,0 +1,261 @@
# project.yml Syntax Cheat Sheet
Condensed from the official ProjectSpec docs (XcodeGen 2.4x). When this file and the official docs disagree, trust the docs: https://github.com/yonaskolb/XcodeGen/blob/master/Docs/ProjectSpec.md
## Top level
```yaml
name: MyProject # required
include: [base.yml] # merge other specs (see bottom of this file)
options: {...} # project-wide behavior (below)
attributes: {...} # raw PBXProject attributes (rarely needed)
configs: {...} # build configurations (default: Debug/Release)
configFiles: {Debug: path.xcconfig}
settingGroups: {name: {settings}} # reusable settings, referenced via settings.groups
settings: {...} # project-level build settings
targets: {TargetName: {...}} # the targets
fileGroups: [dir-or-file] # shown in project navigator, not in any target
schemes: {SchemeName: {...}}
schemeTemplates: {...}
targetTemplates: {...}
packages: {PackageName: {...}} # Swift packages
projectReferences: {Name: {path: Other.xcodeproj}}
```
## Options (commonly used)
```yaml
options:
bundleIdPrefix: com.example # auto bundle IDs: prefix + target name
deploymentTarget: # project-wide defaults
macOS: "13.0"
iOS: "16.0"
createIntermediateGroups: true # groups for intermediate path components
minimumXcodeGenVersion: "2.38.0"
developmentLanguage: zh-Hans # default "en"
transitivelyLinkDependencies: false # link deps-of-deps (default false)
generateEmptyDirectories: false
groupSortPosition: bottom # top | bottom | none
groupOrdering: # custom group order in navigator
- order: [Sources, Resources, Tests]
preGenCommand: make resources # bash, skipped when cache skips generation
postGenCommand: pod install
defaultConfig: Debug
defaultSourceDirectoryType: group # group | folder | syncedFolder (xcode16_0+)
```
Environment variable expansion works in any string: `${SOME_VAR}` (disable with `xcodegen generate -n`).
## Configs
Each config maps to a build type; anything other than `debug`/`release` gets no default settings:
```yaml
configs:
Debug: debug
Beta: release
AppStore: release
```
## Settings
Two forms — **never mix them in one block** (plain keys are silently dropped if `base`/`configs`/`groups` is present):
```yaml
settings:
SWIFT_VERSION: "5.9" # form 1: simple map
settings: # form 2: structured
base:
MARKETING_VERSION: "1.0"
configs: # key matched case-insensitively/partially to config names
debug:
CODE_SIGN_IDENTITY: Apple Development
release:
CODE_SIGN_IDENTITY: Apple Distribution
groups: [shared-settings] # references to settingGroups
```
## Target
```yaml
targets:
MyTarget:
type: application # required, see list below
platform: macOS # required: macOS | iOS | tvOS | watchOS | visionOS | auto
supportedDestinations: [macOS, macCatalyst] # alternative to platform (Xcode 14+)
deploymentTarget: "13.0" # or per-platform map for multi-platform targets
sources: [...]
dependencies: [...]
settings: {...}
configFiles: {Debug: path.xcconfig}
info: {...} # generates Info.plist
entitlements: {...} # generates .entitlements
templates: [Name] # from targetTemplates; ${target_name} substitution
templateAttributes: {attr: value}
preBuildScripts: [...] # also postCompileScripts, postBuildScripts
buildRules: [...]
buildToolPlugins: [{plugin: P, package: PKG}]
scheme: {...} # per-target convenience scheme
attributes: {...}
transitivelyLinkDependencies: true
requiresObjCLinking: true # default true only for library.static
```
Product types (attribute to `type:`): `application`, `app-extension`, `extensionkit-extension`, `framework`, `framework.static`, `library.static`, `library.dynamic`, `bundle`, `bundle.unit-test`, `bundle.ui-testing`, `tool`, `xpc-service`, `system-extension`, `watchkit2-extension`, `application.messages`, `application.watchapp2`, and friends.
Settings set automatically for you (don't set by hand): `INFOPLIST_FILE` (if an Info.plist is found in sources or defined via `info:`), `TEST_HOST` (unit tests depending on an app), `TEST_TARGET_NAME` (UI tests depending on an app), `FRAMEWORK_SEARCH_PATHS` (Carthage), `OTHER_LDFLAGS` (ObjC linking).
Multi-platform: `platform: [iOS, tvOS]` generates one target per platform named `Name_iOS`, `Name_tvOS`; `${platform}` interpolates inside the target spec.
## Sources
A source is a path string or an object. Directories are expanded at **generation time** — new files require regeneration.
```yaml
sources:
- Sources # whole directory
- path: Sources
excludes: # bash-4 globstar patterns, relative to `path`
- "**/*.md"
- "ios/*.[mh]"
- "*-Private.h"
includes: ["**/*.swift"] # whitelist; excludes win on conflict
compilerFlags: ["-Werror"]
createIntermediateGroups: true
optional: true # don't fail if the path doesn't exist
headerVisibility: public # public | private | project
type: group # group (default for dirs) | file | folder | syncedFolder
buildPhase: sources # sources | resources | headers | none, or copyFiles: {destination: …, subpath: …}
- path: Vendor/Binaries
buildPhase: none # show in navigator only
- path: MyFile.swift # single file
buildPhase:
copyFiles:
destination: wrapper # absolutePath | productsDirectory | wrapper | executables |
subpath: Contents/Tools # resources | javaResources | frameworks | sharedFrameworks |
# sharedSupport | plugins
```
## Dependencies
```yaml
dependencies:
- target: OtherTarget # links; embeds if this is an application (embed default true)
- target: OtherProject/OtherTarget # via projectReferences
- package: SomePackage # name from top-level packages
product: LibraryName # only if product ≠ package name
products: [A, B] # multiple products
- framework: Vendor/Foo.xcframework
embed: true # default true for apps
codeSign: true
weak: false
- sdk: libc++.tbd # or Contacts.framework, libz.dylib
- carthage: Alamofire # linkType: static|dynamic, findFrameworks: bool
- bundle: Prebuilt.bundle # adds to Copy Resources
```
## Info.plist and entitlements (`info:` / `entitlements:`)
Files are written to disk on every generation. `info` auto-fills CFBundleIdentifier, CFBundleInfoDictionaryVersion, CFBundleExecutable, CFBundleName, CFBundleDevelopmentRegion, CFBundleShortVersionString, CFBundleVersion, CFBundlePackageType where appropriate; `entitlements` requires all properties explicitly.
```yaml
info:
path: App/Info.plist
properties:
LSMinimumSystemVersion: "13.0"
entitlements:
path: App/App.entitlements
properties:
com.apple.security.application-groups: [group.com.example.app]
```
## Build scripts
```yaml
preBuildScripts: # also postCompileScripts, postBuildScripts
- name: SwiftLint
script: swiftlint # or path: scripts/lint.sh
inputFiles: ["$(SRCROOT)/file1"]
outputFileLists: ["$(SRCROOT)/outputs.xcfilelist"]
showEnvVars: false
basedOnDependencyAnalysis: true # skip when inputs/outputs unchanged
runOnlyWhenInstalling: false
```
## Schemes
Per-target convenience (preferred when one scheme ≈ one target):
```yaml
targets:
App:
scheme:
testTargets: [AppTests]
configVariants: [Staging, Production] # generates one scheme per variant
gatherCoverageData: true
commandLineArguments: {"-MyArg": true}
environmentVariables: {KEY: VALUE}
```
Top-level (full control; `build.targets` is required):
```yaml
schemes:
MyApp:
build:
targets:
MyApp: all # all | none | [run, test, profile, analyze, archive]
MyAppTests: [test]
run:
config: Debug
commandLineArguments: {"-InMemory": true}
environmentVariables:
- variable: API_ENV
value: staging
isEnabled: true
test:
gatherCoverageData: true
targets:
- AppTests
- name: UITests
parallelizable: true
randomExecutionOrder: true
skippedTests: [UITests/testFlaky()]
archive:
config: Release
customArchiveName: MyApp
```
## Swift packages
```yaml
packages:
Yams:
url: https://github.com/jpsim/Yams
from: 2.0.0 # or majorVersion / minorVersion / exactVersion / version,
# minVersion+maxVersion, branch, revision
Alamofire:
github: Alamofire/Alamofire # shorthand for github URLs
exactVersion: 5.8.0
LocalLib:
path: Packages/LocalLib # directory containing Package.swift
group: LocalPackages # navigator group, default "Packages"
```
Link into targets with `dependencies: - package: Yams`.
## Splitting the spec (`include`)
Included files merge additively (dicts merge, arrays append); suffix a key with `:REPLACE` to override instead of append. Paths inside included files are relative to that file unless `relativePaths: false`.
```yaml
include:
- base.yml
- path: extra.yml
relativePaths: false
enable: ${INCLUDE_EXTRA}
targets:
App: # target defined in base.yml
sources:REPLACE: [NewSources]
```
@@ -0,0 +1,252 @@
# project.yml Recipes
Complete, copy-pasteable specs. Combine the parts you need; trim what you don't. Each recipe is self-contained except where noted.
## 1. iOS app + unit tests + UI tests
```yaml
name: MyApp
options:
bundleIdPrefix: com.example
deploymentTarget:
iOS: "16.0"
targets:
MyApp:
type: application
platform: iOS
sources: [MyApp]
info:
path: MyApp/Info.plist
properties:
UILaunchScreen: {} # modern no-storyboard launch
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
scheme:
testTargets: [MyAppTests, MyAppUITests]
MyAppTests:
type: bundle.unit-test
platform: iOS
sources: [MyAppTests]
dependencies:
- target: MyApp # TEST_HOST set automatically
MyAppUITests:
type: bundle.ui-testing
platform: iOS
sources: [MyAppUITests]
dependencies:
- target: MyApp # TEST_TARGET_NAME set automatically
```
## 2. macOS app with an embedded extension (network extension, share extension, …)
Extensions need three things: the right `type`, an `NSExtension` (or `NSSystemExtension`) dict in Info.plist properties, and usually entitlements. The app embeds them automatically — no copy phase needed.
```yaml
name: MyProxy
options:
bundleIdPrefix: com.example
deploymentTarget:
macOS: "13.0"
targets:
MyProxy:
type: application
platform: macOS
sources:
- path: MyProxy
info:
path: MyProxy/Info.plist
properties:
LSMinimumSystemVersion: "13.0"
entitlements:
path: MyProxy/MyProxy.entitlements
properties:
com.apple.security.app-sandbox: true
com.apple.security.network.client: true
com.apple.security.network.server: true
dependencies:
- target: Tunnel # embedded into Contents/PlugIns automatically
scheme:
testTargets: [MyProxyTests]
Tunnel:
type: app-extension # Network Extension packet-tunnel providers use this type;
# use system-extension for DriverKit/transparent-proxy style
platform: macOS
sources: [Tunnel]
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.example.MyProxy.Tunnel # must be prefixed by the app's
# bundle ID; bundleIdPrefix auto-derivation (com.example.Tunnel)
# fails at build validation, not compile
info:
path: Tunnel/Info.plist
properties:
CFBundleDisplayName: Tunnel
NSExtension:
NSExtensionPointIdentifier: com.apple.networkextension.packet-tunnel
entitlements:
path: Tunnel/Tunnel.entitlements
properties:
com.apple.security.app-sandbox: true
com.apple.security.network.client: true
MyProxyTests:
type: bundle.unit-test
platform: macOS
sources: [MyProxyTests]
dependencies:
- target: MyProxy
```
Note: Network Extension entitlements (`com.apple.developer.networking.networkextension`) require a provisioning profile from Apple; generation and local builds succeed without it, distribution signing does not. Don't "fix" a signing failure by inventing entitlement values.
## 3. SPM dependencies (remote and local)
```yaml
name: MyApp
options:
bundleIdPrefix: com.example
packages:
SnapshotTesting:
url: https://github.com/pointfreeco/swift-snapshot-testing
from: 1.17.0
Alamofire:
github: Alamofire/Alamofire # shorthand
exactVersion: 5.8.0
FeatureKit:
path: Packages/FeatureKit # local package (dir with Package.swift)
targets:
MyApp:
type: application
platform: iOS
sources: [MyApp]
dependencies:
- package: Alamofire
- package: FeatureKit
product: FeatureKitDomain # only when product ≠ package name
MyAppTests:
type: bundle.unit-test
platform: iOS
sources: [MyAppTests]
dependencies:
- target: MyApp
- package: SnapshotTesting
products: [SnapshotTesting] # or use `product:` for one
```
## 4. Custom configs + xcconfig files
```yaml
name: MyApp
options:
bundleIdPrefix: com.example
configs:
Debug: debug
Beta: release # maps to release-type defaults
AppStore: release
configFiles: # project level
Debug: Configs/project-debug.xcconfig
targets:
MyApp:
type: application
platform: iOS
sources: [MyApp]
configFiles: # target level overrides
Debug: Configs/app-debug.xcconfig
Beta: Configs/app-beta.xcconfig
AppStore: Configs/app-appstore.xcconfig
settings:
base:
XCConfigOnlyWhenNotInFile: value # xcconfig wins for keys it defines
schemes:
MyApp-Beta:
build:
targets: {MyApp: all}
run:
config: Beta
archive:
config: AppStore
```
## 5. Vendored pre-built binaries and frameworks
Pre-built CLI tools and xcframeworks checked into the repo (not built from source):
```yaml
targets:
MyApp:
type: application
platform: macOS
sources:
- path: MyApp
- path: Vendor/Tools/ss-local # a single pre-built executable
buildPhase:
copyFiles:
destination: wrapper
subpath: Contents/Tools # ends up at MyApp.app/Contents/Tools/ss-local
- path: Vendor/Tools/privoxy
buildPhase:
copyFiles:
destination: wrapper
subpath: Contents/Tools
dependencies:
- framework: Vendor/Frameworks/SwiftZip.xcframework
```
Keep vendored binaries out of any target's plain `sources:` glob (or exclude them), otherwise XcodeGen guesses a build phase from the extension. Executables without extension are left alone, but being explicit with `buildPhase: copyFiles` documents intent.
## 6. Build script phases with proper inputs/outputs
Scripts without declared inputs/outputs re-run every build and break incremental builds — declare them:
```yaml
targets:
MyApp:
type: application
platform: iOS
sources: [MyApp]
preBuildScripts:
- name: Generate Protos
script: |
./scripts/generate-protos.sh
inputFiles:
- $(SRCROOT)/Protos/messages.proto
outputFiles:
- $(DERIVED_FILE_DIR)/Messages.pb.swift
basedOnDependencyAnalysis: true
postBuildScripts:
- name: SwiftLint
script: swiftlint
showEnvVars: false
- name: Crash Reporter Upload
script: ./scripts/upload-dsyms.sh
runOnlyWhenInstalling: true
```
## 7. Splitting a big spec across files
`include` merges additively (dicts merge, arrays append); add `:REPLACE` to overwrite a list instead of appending to it.
```yaml
# project.yml
name: MyApp
include:
- project/targets.yml
- project/schemes.yml
options:
bundleIdPrefix: com.example
```
```yaml
# project/targets.yml
targets:
MyApp:
type: application
platform: iOS
sources: [MyApp]
MyAppTests:
type: bundle.unit-test
platform: iOS
sources: [MyAppTests]
dependencies:
- target: MyApp
```
Use `xcodegen dump --spec project.yml` to inspect the merged result when an override doesn't behave.