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

By Arin Prajapati 10 min read
White headline on black reading XcodeGen for a one person Mac app, with the kicker Xcode and XcodeGen and the NotchStar logo

TL;DR

  • NotchStar’s Xcode project is not in git. A 122 line project.yml is, and xcodegen generate writes NotchStar.xcodeproj from it. The Info.plist and 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:

OutputWhereIn git
The Xcode projectNotchStar.xcodeprojno, *.xcodeproj is in .gitignore
The Info.plistSources/NotchStar/Info.plistno, listed in .gitignore
The entitlementsSources/NotchStar/NotchStar.entitlementsno, 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.

FactHow it was countedValue
Lines in the specwc -l project.yml122
Lines in the generated project filewc -l NotchStar.xcodeproj/project.pbxproj1,554
Build file entries in itgrep -c PBXBuildFile161
Project files tracked in gitgit ls-files | grep -c xcodeproj0
Swift files in the appgit ls-files Sources/NotchStar | grep -c '\.swift$'83
Swift files in unit testssame, under Tests/NotchStarTests38
Swift files in UI testssame, under Tests/NotchStarUITests5
Commits that touched the specgit log --oneline -- project.yml | wc -l14, from 6 October to 31 October 2026
XcodeGen versionxcodegen --version2.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 generate and then disappears. The rule has to be remembered: change project.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_VERSION and CURRENT_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

  1. Install the tool. Run brew install xcodegen, then xcodegen --version.
  2. Start from a folder. Put your Swift files in Sources/MyApp and your tests in Tests/MyAppTests.
  3. Write a minimal project.yml. A name, a deployment target and one target with type: application, platform: macOS and sources: [Sources/MyApp].
  4. Generate. Run xcodegen generate and open MyApp.xcodeproj.
  5. Add the plist as data. Move your usage descriptions under info: properties: and entitlements under entitlements: properties:. Delete the hand written files.
  6. Add a test target. Use type: bundle.unit-test with dependencies: [{target: MyApp}], and a scheme that lists it under test: targets.
  7. Ignore the outputs. Add *.xcodeproj and the two generated file paths to .gitignore.
  8. Make every script generate first. Put xcodegen generate -q before xcodebuild in each one.
  9. Check the spec without writing. Run xcodegen dump --spec project.yml --type summary to 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 -q at the top of every build and test script
  • Generated plist paths in .gitignore
  • A check after signing that the entitlements are still there
CommandWhat it does
brew install xcodegenInstalls the tool
xcodegen generateWrites the project and the plist files
xcodegen generate -qThe same, with no output, for scripts
xcodegen dump --spec project.yml --type summaryPrints targets and schemes, writes nothing
xcodegen --helpLists --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.