A Green Build Is Not Proof That Your pbxproj Edit Was Correct
When an agent edits project.pbxproj, the build stays green while resources and script phases quietly fall out. Four structural checks and an audit script that finishes in 0.1 seconds on a 1,200-file project, drawn from running iOS apps in production.
One of my Android wallpaper apps once shipped a release where the build succeeded, the rollout succeeded, and a subset of devices showed a blank white background. Nothing in the code was broken. Play Store density splitting had dropped the images from one density bucket.
The build result had never said anything about whether the resources actually reached the device.
The same shape of gap exists on iOS, inside project.pbxproj — and it widens as soon as you let an agent edit that file. I run six apps as an indie developer and keep Antigravity agents running through most of my day, but pbxproj is the one file I treat differently. Here is why, and what I put in place instead.
A green build only proves that compilation succeeded
xcodebuild returns success when sources compile, linking resolves, and a product is produced. Almost every pbxproj editing accident lives outside that set.
Edit
Build result
What actually happens
An image or JSON file drops out of the target's resources
Success
A lookup returns nil at runtime and one screen renders empty
A Run Script phase loses its declared output paths
Success
It reruns on every incremental build, or gets skipped on CI
An SPM package is added but never linked to the target
Success (while unused)
It fails somewhere unrelated the day someone imports it
A localization file belongs to no target at all
Success
That language shows raw key names on screen
Every one of them has the same shape: the reference exists, but it never reaches a build phase. The compiler does not look at this, and it has no reason to.
In the sense of a configuration change that is silently ignored, this is the same root problem I wrote about in How silently ignored config keys slip through CI, and how to close the gap. Wherever a layer accepts an invalid instruction without complaining, that layer becomes a blind spot in your verification.
Why pbxproj diffs slip past review
Build phases in pbxproj are lists of 24-character hexadecimal UUIDs. I took a 1,200-file project, removed the single line for one image, and looked at the diff.
@@ -2508,7 +2508,6 @@ 3B000000000000000000003F /* img63.png in Resources */, 3B0000000000000000000040 /* img64.png in Resources */, 3B0000000000000000000041 /* img65.png in Resources */,- 3B0000000000000000000042 /* img66.png in Resources */, 3B0000000000000000000043 /* img67.png in Resources */, 3B0000000000000000000044 /* img68.png in Resources */,
One changed line. It looks exactly like its neighbors, and the diff gives you nothing to decide whether the removal was intentional. If an agent had touched thirty files in one pass and this line sat somewhere in the middle, I would not trust myself to catch it.
Git shows you added and removed lines. What matters in a pbxproj is where a reference ends up. The granularity a human reviews at and the granularity the file is structured at simply do not line up.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Draw your own line between the pbxproj edits an agent may make and the ones Xcode should generate, based on how recoverable each one is
✦Catch missing resources at commit time instead of hearing about them after release, when only some devices show a blank screen
✦Take home a structural audit that finishes in 0.1 seconds on a 1,200-file pbxproj, so your verification no longer depends on a green build
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
So instead of reading the diff, read the parse result. Four checks cover the cases above.
Check
What it inspects
Accident it catches
A
PBXFileReference entries no PBXBuildFile points at
File added to the project but not to any target
B
Resource-typed references that never reach the Resources phase
Build file exists but was dropped from the phase
C
Run Script phases with empty inputPaths / outputPaths
Reruns every build, or gets skipped incrementally
D
XCRemoteSwiftPackageReference with no product dependency
Package added but never linked to a target
Check C comes from wiring the Firebase Crashlytics dSYM upload into a build phase. Scripts like that succeed by not running. You find out days later, when crash reports arrive unsymbolicated.
Check D came out of moving the Firebase Apple SDK from CocoaPods to Swift Package Manager. The package reference and the target link live in different sections of the file, and a project with only one of them still opens and still builds. Reorganizing the AdMob adapters the same way produced the same shape of half-applied dependency, and a half-applied dependency stays invisible until the day something imports it.
Writing the audit script
Xcode's UI gives you no single place to confirm all four. The pbxproj format is a line-oriented plist dialect, but for this narrow purpose regular expressions read it well enough.
#!/usr/bin/env python3"""Structural audit for project.pbxproj: editing accidents a build cannot reveal.Usage: python3 pbxaudit.py <path/to/project.pbxproj>Exit codes: 0 = clean / 1 = findings / 2 = unparseable"""import reimport sys# Multi-line objects (build phases, package references, and so on)OBJ_BLOCK = re.compile( r'^\t\t([0-9A-F]{24})\s*(?:/\*(.*?)\*/)?\s*=\s*\{(.*?)^\t\t\};', re.M | re.S,)# Single-line objects (PBXBuildFile / PBXFileReference are written this way)OBJ_LINE = re.compile( r'^\t\t([0-9A-F]{24})\s*(?:/\*(.*?)\*/)?\s*=\s*\{(.*)\};\s*$', re.M,)KV = re.compile(r'(\w+)\s*=\s*([^;]+);')# Headers and products legitimately belong to no target; skip them in check ASKIP_UNOWNED = ('.h', '.hpp', '.pch', '.app', '.xcodeproj', '.framework')RESOURCE_EXT = ('.png', '.jpg', '.json', '.xcassets', '.storyboard', '.xib', '.strings', '.plist', '.mp3', '.m4a', '.html')def parse(text): """Return UUID -> {isa, comment, attrs, raw body}.""" objects = {} for uid, comment, body in (list(OBJ_BLOCK.findall(text)) + list(OBJ_LINE.findall(text))): attrs = {} for k, v in KV.findall(body): attrs[k] = v.strip() objects[uid] = { 'isa': attrs.get('isa', ''), 'comment': (comment or '').strip(), 'attrs': attrs, 'body': body, } return objectsdef uuids_in(body, key): """Pull UUIDs out of files = ( A /* x */, B /* y */, );""" m = re.search(key + r'\s*=\s*\((.*?)\);', body, re.S) if not m: return [] return re.findall(r'([0-9A-F]{24})', m.group(1))def paths_in(body, key): m = re.search(key + r'\s*=\s*\((.*?)\);', body, re.S) if not m: return [] return [x for x in re.findall(r'"?([^",\s][^",]*)"?\s*,', m.group(1))]
That is the reading half. The checks themselves are just cross-references over what we collected.
def audit(path): text = open(path, encoding='utf-8', errors='replace').read() objects = parse(text) if not objects: print('parse failed: no objects found', file=sys.stderr) return 2 by_isa = {} for uid, o in objects.items(): by_isa.setdefault(o['isa'], []).append(uid) # Collect every fileRef a PBXBuildFile points at referenced = set() buildfile_of = {} for uid in by_isa.get('PBXBuildFile', []): ref = objects[uid]['attrs'].get('fileRef', '').split('/')[0].strip() if ref: referenced.add(ref) buildfile_of.setdefault(ref, []).append(uid) # What each build phase actually holds phase_files = {} for isa in ('PBXResourcesBuildPhase', 'PBXSourcesBuildPhase', 'PBXFrameworksBuildPhase'): s = set() for uid in by_isa.get(isa, []): s.update(uuids_in(objects[uid]['body'], 'files')) phase_files[isa] = s findings = [] # Check A: file references that belong to no target for uid in by_isa.get('PBXFileReference', []): name = (objects[uid]['comment'] or objects[uid]['attrs'].get('path', '')).strip('"') if uid in referenced: continue etype = objects[uid]['attrs'].get('explicitFileType', '') if 'wrapper.application' in etype or name.endswith(SKIP_UNOWNED): continue findings.append(('A', 'not in any target', name)) # Check B: resource files that never reach the Resources phase for uid in by_isa.get('PBXFileReference', []): name = (objects[uid]['comment'] or objects[uid]['attrs'].get('path', '')).strip('"') if not name.lower().endswith(RESOURCE_EXT): continue bfs = buildfile_of.get(uid, []) if not bfs: continue # already reported by check A if not any(b in phase_files['PBXResourcesBuildPhase'] for b in bfs): findings.append(('B', 'outside Resources phase', name)) # Check C: Run Script phases with no declared inputs or outputs for uid in by_isa.get('PBXShellScriptBuildPhase', []): o = objects[uid] name = (o['attrs'].get('name', '') or o['comment']).strip('"') ins = paths_in(o['body'], 'inputPaths') outs = paths_in(o['body'], 'outputPaths') if not outs and o['attrs'].get('alwaysOutOfDate', '') != '1': findings.append(('C', 'Run Script without output paths', name or uid)) elif not ins and outs: findings.append(('C', 'Run Script without input paths', name or uid)) # Check D: packages added but not linked to a target linked = set() for uid in by_isa.get('XCSwiftPackageProductDependency', []): pkg = objects[uid]['attrs'].get('package', '').split('/')[0].strip() if pkg: linked.add(pkg) for uid in by_isa.get('XCRemoteSwiftPackageReference', []): if uid not in linked: findings.append(('D', 'package not linked to a target', objects[uid]['comment'] or uid)) for code, label, name in findings: print(f'[{code}] {label}: {name}') print(f'findings={len(findings)}') return 1 if findings else 0if __name__ == '__main__': if len(sys.argv) != 2: print('usage: pbxaudit.py <project.pbxproj>', file=sys.stderr) sys.exit(2) sys.exit(audit(sys.argv[1]))
The alwaysOutOfDate lookup matters. That flag, added in Xcode 14, marks a script as deliberately excluded from dependency analysis, so a missing output path there is intended rather than an accident.
What I measured on my machine
Running it against a pbxproj seeded with all four accidents produced exactly four findings.
$ python3 pbxaudit.py sample.pbxproj[A] not in any target: Localizable.strings[B] outside Resources phase: categories_v7.json[C] Run Script without output paths: Upload dSYM to Crashlytics[D] package not linked to a target: XCRemoteSwiftPackageReference "GoogleMobileAds"findings=4$ echo $?1
Fixing all four in place brought it back to findings=0 and exit code 0, with no leftover noise. That second half matters more than the first. A check that always reports one warning is a check nobody reads after the first week.
Scale behaved well too. On a 404 KB, 3,716-line pbxproj holding 600 sources and 600 images, five consecutive runs each took 0.10 seconds. Twelve hundred files is close to what my wallpaper apps actually carry, and at that cost you can afford to run it on every commit.
Then the single removed line from earlier — the one that was indistinguishable in the diff:
What disappears at the granularity of a diff stands up as one finding at the granularity of structure. That contrast is what convinced me to keep this around.
What to delegate, and what to let Xcode generate
With the check in place, the delegation question becomes answerable. My line is simple: can I reliably get back to the previous state if this goes wrong?
Edit
Owner
Reason
Adding or removing files and resources
Xcode
UUID allocation plus three coordinated insertions is not worth doing by hand
Adding or bumping SPM packages
Xcode
Reference, product dependency, and resolved file move together
Bulk build-setting changes
Agent, via xcconfig
Kept in .xcconfig, the diff is readable text
Editing the body of a Run Script
Agent
Shell code reviews fine; adding the phase itself stays with Xcode
Restructuring targets
By hand
Hardest to undo, and rare enough not to automate
The point is not to keep agents away from the project. It is to move the entry point for edits out of pbxproj and into .xcconfig. Once build settings live in xcconfig files, what the agent touches is ordinary text with a diff you can read, and pbxproj is left holding only the structural parts that Xcode generates faster anyway.
A check you have to remember to run is not a check. This one fires only on commits that touch a pbxproj.
#!/bin/sh# .git/hooks/pre-commitset -echanged=$(git diff --cached --name-only --diff-filter=ACM | grep 'project\.pbxproj$' || true)[ -z "$changed" ] && exit 0for f in $changed; do # Audit the staged content, not the working tree tmp=$(mktemp) git show ":$f" > "$tmp" if ! python3 tools/pbxaudit.py "$tmp"; then echo "Structural audit reported findings for: $f" echo "If the change is intentional, add a line to tools/pbxaudit-allow.txt." rm -f "$tmp" exit 1 fi rm -f "$tmp"done
git show ":$f" is the part worth copying. Reading the working tree instead would audit content that differs from what actually gets committed whenever you stage selectively with git add -p.
When an Antigravity agent works unattended, this hook becomes the last gate before anything lands. Agents read commit output, so printing the specific finding gives them enough to correct themselves on the next attempt. It pairs well with confirming which files an agent can even see, which I covered in Files Git tracks but your agent cannot read, and where they drop out.
Keeping false positives out
Every project has files deliberately kept out of a target: READMEs, design source assets, JSON used only by tests. Flagging those on every run is how a check earns its way into being ignored.
I keep the exclusions in a repository text file rather than in the script.
# Insert after findings are collected in audit()def load_allow(path='tools/pbxaudit-allow.txt'): try: lines = open(path, encoding='utf-8').read().splitlines() except FileNotFoundError: return set() return {l.strip() for l in lines if l.strip() and not l.startswith('#')}allow = load_allow()findings = [f for f in findings if f[2] not in allow]
A text file lets you write the reason next to the entry, which is what makes it readable six months later. If that list keeps growing, the check itself is out of step with how the project works, and the right move is to fix the check.
Three things I learned putting it into daily use
None of these are about the checks themselves. They are about where the script sits and how its failures are treated.
Do not let an unparseable day pass silently
The script returns exit code 2 when it finds no objects at all. The hook above treats that as a failure because it tests with !, but adding || true to a CI one-liner erases the distinction instantly. From the day Xcode changes the format, the check would quietly stop working. I keep "clean" and "could not read" on separate exit codes and fail the CI job on 2 as well.
Make it fit how agents retry
When pre-commit rejects a commit, an Antigravity agent will rebuild it. If all you print is a check code and a filename, the agent has nothing to act on and repeats the same mistake. Printing which file dropped out of which phase gives it enough to fix itself on the next attempt. In unattended runs, the specificity of your error message is your recovery time.
Put it earlier than production CI
I tried running it only in CI. The finding then arrives minutes after the build starts, which slows down isolating the cause. I recommend the local hook as the primary gate with CI as the backstop — hooks can be disabled per machine, so the CI copy has to stay.
One thing to try next
Run it once against a project you already ship. Three steps:
Drop pbxaudit.py into tools/ and run it against the project.pbxproj you are shipping today
Read the findings by category A through D, and move only the deliberate ones into tools/pbxaudit-allow.txt with a reason
Once the remaining findings reach zero, enable the pre-commit hook
A first run on an existing codebase usually surfaces somewhere between a handful and a dozen findings, and whether any of them is news to you is the honest test of whether this belongs in your pipeline.
For a long time I treated pbxproj as territory Xcode owns and nobody else should enter. Running agents daily changed that view: leaving it untouched is not the same as knowing it is correct, and the difference has to be checked by something that reads structure. I am still adding checks, and I would be glad if this saves someone the same detour.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.