XcodeGen for a one person Mac app: project.yml, tests and a reproducible build
What a 122 line spec replaces, which settings turned out to matter, and the scripts that regenerate the project every time
TL;DR
- NotchStar’s Xcode project is not in git. A 122 line
project.ymlis, andxcodegen generatewritesNotchStar.xcodeprojfrom it. TheInfo.plistand the entitlements file are also generated from that one file, and all three are listed in.gitignore. - The spec holds four targets and two schemes, and it was edited in 14 commits between 6 October and 31 October 2026. Every build script regenerates first, so a stale project cannot be built by accident.
- The settings that matter are few: a version number read by the release script, a license environment that differs between Debug and Release, and an embedded framework that is deliberately not linked.
The problem: a project file nobody can read
An .xcodeproj is a package whose real content is project.pbxproj, a property list of object ids that reference each other. On the Mac used for this post, the generated one is 1,554 lines long and has 161 lines that mention PBXBuildFile (counted on 4 November 2026). Two things go wrong with such a file in a repository.
It changes in places nobody meant to change. Adding one Swift file edits several sections of it. Two branches that each add a file conflict inside a list of hexadecimal ids, and the conflict can only be resolved by someone who understands the format.
It also hides intent. Why is a framework embedded but not linked? Where does the camera usage string come from? Who set the deployment target to 14.0? In the project file those are values in a long list, with no place for a comment.
A one person project does not have the merge problem much. It has the intent problem, and a second one: the build needs to be repeatable on a Mac that has nothing but the repository, a fresh clone, or a machine rebuilt after a failure.
XcodeGen is a command line tool that reads a YAML spec and writes the Xcode project. NotchStar uses it. This post walks through the real project.yml and the scripts around it.
Install XcodeGen
On the Mac used here, XcodeGen is installed with Homebrew and reports its version like this (4 November 2026):
brew install xcodegen
xcodegen --version
# Version: 2.46.0
That is the entire install. Nothing is added to the project, and no part of the app depends on XcodeGen when it runs. It is a build tool, like a code generator.
Approach: one spec, three outputs
Running xcodegen generate in the folder with project.yml writes:
| Output | Where | In git |
|---|---|---|
| The Xcode project | NotchStar.xcodeproj | no, *.xcodeproj is in .gitignore |
| The Info.plist | Sources/NotchStar/Info.plist | no, listed in .gitignore |
| The entitlements | Sources/NotchStar/NotchStar.entitlements | no, listed in .gitignore |
git ls-files | grep -c xcodeproj prints 0 here. The source of truth is project.yml and the folders it points at. The tool also has a dry command that prints the structure without writing anything:
xcodegen dump --spec project.yml --type summary
Its output for this repository on 4 November 2026:
Name: NotchStar
Targets:
NotchStar: macOS application
MediaRemoteAdapter: macOS framework
NotchStarTests: macOS unitTestBundle
NotchStarUITests: macOS uiTestBundle
Schemes:
NotchStar
NotchStarUI
The shape of the spec
The top of the file sets the project wide values. The deployment target is a single line, the Swift language version is a single line, and the version numbers sit in one place.
name: NotchStar
options:
bundleIdPrefix: com.notchstar
developmentLanguage: en
deploymentTarget:
macOS: "14.0"
settings:
base:
SWIFT_VERSION: "6.0"
MARKETING_VERSION: "1.0.5"
CURRENT_PROJECT_VERSION: "5"
Sources are folders, not file lists. This is the biggest change from a hand edited project.
targets:
NotchStar:
type: application
platform: macOS
sources:
- Sources/NotchStar
- path: Vendor/MediaRemoteAdapter/bin/mediaremote-adapter.pl
buildPhase: resources
A new Swift file placed under Sources/NotchStar/ is in the app after the next xcodegen generate. There is no step where a file is added to a target. The repository has 83 Swift files under Sources/NotchStar, 38 under Tests/NotchStarTests and 5 under Tests/NotchStarUITests (git ls-files counts, 4 November 2026), and none of them is mentioned by name in the spec. The Perl script that the Now Playing feature needs is one explicit entry, because it is not Swift and must be copied into the app’s resources.
Info.plist and entitlements as data
The plist content sits under info: and the entitlements under entitlements:, next to the target they belong to.
info:
path: Sources/NotchStar/Info.plist
properties:
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
LSUIElement: true
NSCameraUsageDescription: NotchStar shows a live mirror preview from your camera. Nothing is recorded.
entitlements:
path: Sources/NotchStar/NotchStar.entitlements
properties:
com.apple.security.personal-information.calendars: true
com.apple.security.automation.apple-events: true
com.apple.security.device.camera: true
The version in the plist is a build setting reference, so a release changes MARKETING_VERSION in exactly one place. The release script reads that same line to name the DMG, with a one line awk:
VERSION=$(awk '/MARKETING_VERSION/ {gsub(/"/,"",$2); print $2; exit}' project.yml)
Run against the file on 4 November 2026 it prints 1.0.5. The version in the app, in the plist and in the file name of the download cannot disagree, because there is one number.
The entitlements file has a second job. The release script passes it to codesign by path when it signs the finished app, so the permissions in the signed binary are the ones in the spec. The generated file on this Mac holds three keys, for calendars, Apple events and the camera, and the signed app’s entitlements include the camera key (codesign -d --entitlements - finds it, 4 November 2026). If the spec and the signing step each kept their own list, they would drift. This way they cannot.
Two build configurations that must not mix
NotchStar sells through a payment provider that has a test mode and a live mode. A Debug build must never talk to the live one, and a Release build must never talk to the test one. The spec holds that as a build setting with a Debug override.
NOTCHSTAR_LICENSE_ENV: live
configs:
Debug:
NOTCHSTAR_LICENSE_ENV: test
The plist reads it back with NotchStarLicenseEnvironment: $(NOTCHSTAR_LICENSE_ENV). The default is live and only Debug overrides it.
A framework that is bundled and not linked
Now Playing relies on an open source adapter that Apple’s own /usr/bin/perl loads by symbol name. The app embeds the framework but never calls into it from Swift, so the spec says so.
dependencies:
# Bundled but deliberately NOT linked: it is loaded by /usr/bin/perl, not by us.
- target: MediaRemoteAdapter
link: false
embed: true
In the Xcode project the same intent is a pair of checkboxes with no place for a reason. In YAML it has a comment next to it. The built app confirms it: otool -L on the Release binary shows no reference to the framework (0 matching lines), and NotchStar.app/Contents/Frameworks/ contains MediaRemoteAdapter.framework. The framework target, in turn, keeps its symbols visible:
# perl calls into the framework by symbol name, so symbols must stay visible.
GCC_SYMBOLS_PRIVATE_EXTERN: NO
OTHER_CFLAGS: "-fvisibility=default"
nm -gU on that framework printed 167 lines of exported symbols, including _MRMediaRemoteGetNowPlayingInfo (4 November 2026). The comment says what would break with the opposite setting. The failure itself was not reproduced for this post, so it is stated as the reason in the file and not as an observed fault.
The wrong turn: the entitlements that vanished at signing
Generating the project is half of a reproducible build. The other half is signing, and the one real failure in this area came from the entitlements. The release script once signed with codesign --force --deep. That command signed the nested framework again and the app in one pass and dropped the app’s entitlements. With an ad hoc signature that goes unnoticed. With the hardened runtime on, a missing camera, calendar or Apple events entitlement would make those features fail silently. The fix, in scripts/release.sh, signs the framework first and then the app, and passes the generated entitlements file by path for the app.
codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID" "$APP/Contents/Frameworks/MediaRemoteAdapter.framework"
codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" --sign "$DEVELOPER_ID" "$APP"
The script then checks the result instead of trusting it:
codesign -d --entitlements - "$APP" 2>/dev/null | grep -q "device.camera" || { echo "!! entitlements are missing from the signed app"; exit 1; }
The tie to XcodeGen is the path in $ENTITLEMENTS. The release script has to produce that file before it signs. It does, in the first step of the build.
echo "==> Building NotchStar $VERSION (Release, universal)"
xcodegen generate -q
The same line opens the test runner, scripts/test-all.sh, which calls xcodegen generate >/dev/null before xcodebuild test. Neither script trusts an existing project on disk. On this Mac the project file was last written at 08:04 on 4 November 2026 (ls -l), and it is never edited by hand.
From a clean clone
The repository’s own instructions for a new machine reduce to a short list, taken from AGENTS.md and scripts/test-all.sh. The tool comes first, then the project, then the tests.
brew install xcodegen
xcodegen generate
xcodebuild test -scheme NotchStar -destination 'platform=macOS'
Nothing else has to be restored by hand. The plist, the entitlements and the project all come out of the second command, and the version number comes from the spec. The test runner script adds the UI tests and the website tests, and it starts with the same xcodegen generate. A pre push hook runs that script, once enabled with git config core.hooksPath scripts/git-hooks, so a push from a machine whose generated files are stale generates them again before anything is tested.
This post does not cover the signing failure in full. See the post on codesign --deep and entitlements for that.
Numbers, with the method
All figures were taken from the repository on 4 November 2026. No build was run for this post.
| Fact | How it was counted | Value |
|---|---|---|
| Lines in the spec | wc -l project.yml | 122 |
| Lines in the generated project file | wc -l NotchStar.xcodeproj/project.pbxproj | 1,554 |
| Build file entries in it | grep -c PBXBuildFile | 161 |
| Project files tracked in git | git ls-files | grep -c xcodeproj | 0 |
| Swift files in the app | git ls-files Sources/NotchStar | grep -c '\.swift$' | 83 |
| Swift files in unit tests | same, under Tests/NotchStarTests | 38 |
| Swift files in UI tests | same, under Tests/NotchStarUITests | 5 |
| Commits that touched the spec | git log --oneline -- project.yml | wc -l | 14, from 6 October to 31 October 2026 |
| XcodeGen version | xcodegen --version | 2.46.0 |
The generation time was not measured. Running xcodegen generate rewrites the project and plist files in the working tree, and these checks were read only, so no number is given.
The 14 commits are the evidence that the file is not static. The first one is the project scaffold, on 6 October. The later ones add tools, the bundled framework, the UI test target, the license environment, the localization settings and the version bumps for each release. In a hand edited project those would be 14 changes to a file nobody reviews.
What is still bad
- The project file is generated, so Xcode edits to it are lost. A setting changed in the Xcode UI works until the next
xcodegen generateand then disappears. The rule has to be remembered: changeproject.yml. - Plist and entitlements files are ignored by git, so a fresh clone has neither until the generator runs. Opening a clone in Xcode without generating first gives a project that does not exist.
- Version bumps edit two lines by hand,
MARKETING_VERSIONandCURRENT_PROJECT_VERSION. Nothing checks that the second goes up with the first. - XcodeGen is one more tool the build needs. A machine without it cannot produce the project from a clone.
Try it yourself
- Install the tool. Run
brew install xcodegen, thenxcodegen --version. - Start from a folder. Put your Swift files in
Sources/MyAppand your tests inTests/MyAppTests. - Write a minimal
project.yml. A name, a deployment target and one target withtype: application,platform: macOSandsources: [Sources/MyApp]. - Generate. Run
xcodegen generateand openMyApp.xcodeproj. - Add the plist as data. Move your usage descriptions under
info: properties:and entitlements underentitlements: properties:. Delete the hand written files. - Add a test target. Use
type: bundle.unit-testwithdependencies: [{target: MyApp}], and a scheme that lists it undertest: targets. - Ignore the outputs. Add
*.xcodeprojand the two generated file paths to.gitignore. - Make every script generate first. Put
xcodegen generate -qbeforexcodebuildin each one. - Check the spec without writing. Run
xcodegen dump --spec project.yml --type summaryto see targets and schemes.
A starting file for step 3:
name: MyApp
options:
bundleIdPrefix: com.example
deploymentTarget:
macOS: "14.0"
targets:
MyApp:
type: application
platform: macOS
sources: [Sources/MyApp]
Cheat sheet
- Spec in git, project not in git
- Sources as folders, not file lists
- Plist and entitlements as
properties:under their target - One
MARKETING_VERSION, read by every script that needs it - Debug and Release differences as build settings with an override
- Comments next to any setting that looks wrong but is deliberate
-
xcodegen generate -qat the top of every build and test script - Generated plist paths in
.gitignore - A check after signing that the entitlements are still there
| Command | What it does |
|---|---|
brew install xcodegen | Installs the tool |
xcodegen generate | Writes the project and the plist files |
xcodegen generate -q | The same, with no output, for scripts |
xcodegen dump --spec project.yml --type summary | Prints targets and schemes, writes nothing |
xcodegen --help | Lists --spec, --project, --only-plists and the rest |
Closing
XcodeGen builds the project that ships NotchStar, a Mac app that puts ten tools in the notch. If you want to try it, it is at notchstar.com, with a 7 day free trial.
NotchStar is a native Mac notch app, $4 once, with a 7 day free trial. Try it, or read the ten tools.