# XcodeGen for a one person Mac app: project.yml, tests and a reproducible build

> Install XcodeGen, write a project.yml for a Mac app with tests and an embedded framework, and generate Info.plist and entitlements. A real 122 line spec.

Canonical: https://www.notchstar.com/blog/xcodegen-for-a-one-person-mac-app/
Last updated: 2026-11-04

*What a 122 line spec replaces, which settings turned out to matter, and the scripts that regenerate the project every time*

Published 2026-11-04.

## 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](https://github.com/yonaskolb/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):

```sh
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:

```sh
xcodegen dump --spec project.yml --type summary
```

Its output for this repository on 4 November 2026:

```text
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.

```yaml
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.

```yaml
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.

```yaml
    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`:

```sh
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.

```yaml
        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.

```yaml
    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:

```yaml
        # 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.

```sh
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:

```sh
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.

```sh
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.

```sh
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 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:

```yaml
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

| 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](https://www.notchstar.com/), with a 7 day free trial.

## More posts

- [A shadow test that sampled an icon: testing flat design with pixels in Swift](https://www.notchstar.com/blog/swift-pixel-test-shadow-sampled-an-icon/)
- [A universal DMG in a shell script: lipo, hdiutil, notarytool and stapling](https://www.notchstar.com/blog/universal-dmg-shell-script-lipo-hdiutil-notarytool/)
