Pin Xcode 26 (objectVersion 60) and add pbxproj normalizer + CI guard (#4836)

* Add deterministic normalizer for cmux.xcodeproj/project.pbxproj

scripts/normalize-pbxproj.py sorts the high-churn sections (PBXBuildFile,
PBXFileReference, and the files = (...) arrays inside Sources / Resources
/ Frameworks / CopyFiles build phases) into a deterministic order keyed
on the entry comment plus UUID. The Xcode build does not care about the
order of these flat dictionary sections; sorting them just kills the
nondeterministic diff noise Xcode generates on every UI touch.

Does not touch UUIDs, comments, or PBXGroup children = (...) arrays
(navigator order is intentional). Idempotent: a second run produces zero
diff.

Standalone in this commit so the diff is just the script. The next
commit applies the script and bumps objectVersion in one shot, so
the resulting churn is contained and never repeated.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* Pin objectVersion = 60 and normalize pbxproj

Bumps objectVersion from 56 to 60 (the format Xcode 16+ and Xcode 26
write by default) and runs scripts/normalize-pbxproj.py once to
establish the deterministic baseline. After this commit, future
diffs to project.pbxproj show only real changes, not Xcode's
nondeterministic section reordering.

One-time large diff. No semantic changes to targets, sources, build
phases, or settings: pure sort + version pin.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* Add tracked pre-commit hook that normalizes pbxproj

scripts/git-hooks/pre-commit calls scripts/normalize-pbxproj.py on
cmux.xcodeproj/project.pbxproj when it is staged and re-stages the
result. scripts/install-git-hooks.sh points the clone at this directory
via `git config core.hooksPath scripts/git-hooks`, and scripts/setup.sh
auto-runs it so devs get the hook without a separate manual step.

After this, Xcode's nondeterministic reordering of build-file and
file-reference sections is canceled out at commit time. The CI guard
in the next commit enforces the rule for anyone who bypasses the hook
with --no-verify or who never ran setup.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* Add CI guard for objectVersion pin and pbxproj normalization

scripts/check-pbxproj.sh asserts cmux.xcodeproj/project.pbxproj has
objectVersion = 60 (Xcode 26 default) and that the file is normalized
per scripts/normalize-pbxproj.py. Wired as a step in the
workflow-guard-tests job so every PR is gated.

This catches anyone who bypasses the pre-commit hook with --no-verify
or who never ran scripts/setup.sh. The error message points at the
exact fix path.

To bump the pin (e.g., when the team adopts a newer Xcode major), edit
EXPECTED_OBJECT_VERSION in this script and the matching line in
CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* Add .xcode-version and document Xcode 26 pin in CLAUDE.md

.xcode-version records the major (26.0) for tooling that reads it
(xcodes CLI, some CI helpers).

CLAUDE.md gains an Xcode toolchain section explaining the pin, the
normalizer + pre-commit hook + CI guard mechanics, and the procedure
for bumping the pin in the future.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* Read .xcode-version as the source of truth in check-pbxproj.sh

scripts/check-pbxproj.sh now reads .xcode-version and maps the Xcode
major to the expected objectVersion via a one-entry case statement.
Bumping the team's Xcode pin becomes a one-file edit (.xcode-version),
with a script update only required when Apple actually changes
objectVersion in a new Xcode major.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* Address CodeRabbit findings on check-pbxproj.sh and pre-commit hook

scripts/check-pbxproj.sh now passes "$PBXPROJ" explicitly to
normalize-pbxproj.py instead of letting it default to a path relative
to the current working directory, so the guard works regardless of
where CI invokes it.

scripts/git-hooks/pre-commit refuses to run when the working-tree
pbxproj has unstaged changes. Previously the hook would normalize
the working-tree file and `git add` the result, which silently staged
any unstaged hunks the user had deliberately left out of the commit.
The hook now exits non-zero with a clear message telling the user to
either stage the whole file or stash the unstaged hunks first.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* Address Greptile findings: misleading comment + bump-step docs

scripts/normalize-pbxproj.py: the comment said "preserve empty lines
exactly where they are" but the implementation collapses blanks to a
trailing group. Reworded the comment to match the actual behavior.

CLAUDE.md: the bump procedure now mentions opening cmux.xcodeproj in
the new Xcode so objectVersion gets rewritten automatically. Without
that step a developer following the docs alone would update only the
pin file and the script case, and the CI guard would fail on their
next commit.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Abdulaziz Albahar
2026-05-27 08:50:56 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 2f66b97567
commit 88352eb7b7
9 changed files with 1669 additions and 1428 deletions
+3
View File
@@ -54,6 +54,9 @@ jobs:
- name: Validate pbxproj test-wiring lint
run: ./tests/test_ci_pbxproj_test_wiring.sh
- name: Validate pbxproj objectVersion pin and normalization
run: ./scripts/check-pbxproj.sh
# Paused: stale-base merge races (two PRs each fitting the budget can
# overshoot when merged back-to-back without rebasing). CodeRabbit and
# Greptile already flag large-file growth on PRs. Re-enable by uncommenting
+1
View File
@@ -0,0 +1 @@
26.0
+9 -1
View File
@@ -2,12 +2,20 @@
## Initial setup
Run the setup script to initialize submodules and build GhosttyKit:
Run the setup script to initialize submodules, build GhosttyKit, and install the pbxproj normalization pre-commit hook:
```bash
./scripts/setup.sh
```
## Xcode toolchain
The team is pinned to Xcode 26.x. `.xcode-version` records the major; `cmux.xcodeproj/project.pbxproj` carries `objectVersion = 60`, which is what Xcode 26 writes by default. (objectVersion 77 is reserved for projects that adopt synchronized folder groups, which cmux does not use yet. Bumping to a different value requires a deliberate team decision.)
`scripts/setup.sh` installs a tracked pre-commit hook (`scripts/git-hooks/pre-commit`) that runs `scripts/normalize-pbxproj.py` on any staged `cmux.xcodeproj/project.pbxproj`, sorting the high-churn sections so Xcode's nondeterministic reordering never reaches a commit. The hook is idempotent. CI runs `scripts/check-pbxproj.sh` to enforce both the `objectVersion` pin and normalization, so anyone who skips the hook (or never ran setup) gets a clear failure on their PR.
`.xcode-version` is the single source of truth. To bump the pin: edit `.xcode-version`, open `cmux.xcodeproj` in the new Xcode (which rewrites `objectVersion` automatically when it touches the file), and add a case for the new Xcode major in `scripts/check-pbxproj.sh` mapping it to the `objectVersion` that major writes.
## Local dev
After making code changes, always run the reload script with a tag to build the Debug app:
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# CI guard for cmux.xcodeproj/project.pbxproj.
# Fails when:
# - objectVersion drifts from the pinned value (Xcode major leak)
# - the file is not normalized (someone bypassed the pre-commit hook)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
PBXPROJ="$REPO_ROOT/cmux.xcodeproj/project.pbxproj"
XCODE_VERSION_FILE="$REPO_ROOT/.xcode-version"
# Source of truth for the team's Xcode pin: .xcode-version at the repo root.
# When the team bumps to a new Xcode major, edit that one file and update
# the case below if Apple bumped objectVersion in the new major.
XCODE_VERSION="$(tr -d '[:space:]' < "$XCODE_VERSION_FILE")"
XCODE_MAJOR="${XCODE_VERSION%%.*}"
case "$XCODE_MAJOR" in
26) EXPECTED_OBJECT_VERSION=60 ;;
*) echo "::error::Unknown Xcode major '$XCODE_MAJOR' in .xcode-version ($XCODE_VERSION). Add a case in scripts/check-pbxproj.sh." >&2; exit 1 ;;
esac
actual="$(grep -E '^[[:space:]]*objectVersion = [0-9]+;' "$PBXPROJ" | head -1 | grep -oE '[0-9]+')"
if [[ "$actual" != "$EXPECTED_OBJECT_VERSION" ]]; then
echo "::error file=cmux.xcodeproj/project.pbxproj,line=6::objectVersion is $actual, expected $EXPECTED_OBJECT_VERSION for Xcode $XCODE_VERSION." >&2
echo "The team is pinned to Xcode $XCODE_VERSION (see .xcode-version)." >&2
echo "If you intended to bump the pin, edit .xcode-version and add a case in scripts/check-pbxproj.sh." >&2
exit 1
fi
python3 "$SCRIPT_DIR/normalize-pbxproj.py" --check "$PBXPROJ"
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# cmux pre-commit hook
# Installed by ./scripts/setup.sh via `git config core.hooksPath scripts/git-hooks`.
# Normalizes cmux.xcodeproj/project.pbxproj when staged so Xcode's
# nondeterministic section reordering never reaches a commit.
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
PBXPROJ="cmux.xcodeproj/project.pbxproj"
# Only run when the pbxproj is part of this commit.
if ! git diff --cached --name-only --diff-filter=ACMRT | grep -Fxq "$PBXPROJ"; then
exit 0
fi
cd "$REPO_ROOT"
# Refuse to normalize when the working-tree pbxproj has unstaged changes.
# Otherwise the `git add` at the end would silently stage hunks the
# user deliberately left out of this commit.
if ! git diff --quiet -- "$PBXPROJ"; then
echo "pre-commit: $PBXPROJ has unstaged changes; cannot safely normalize." >&2
echo "Either stage the whole file (git add $PBXPROJ) or stash the unstaged hunks before committing." >&2
exit 1
fi
python3 scripts/normalize-pbxproj.py "$PBXPROJ"
# If the normalizer rewrote the file, restage so the commit captures
# the normalized form. (Working tree had no unstaged changes per the
# guard above, so anything that changed is purely from normalization.)
if ! git diff --quiet -- "$PBXPROJ"; then
git add "$PBXPROJ"
fi
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Point this clone's git at scripts/git-hooks/ for tracked, reviewed hooks.
# Idempotent: re-running just rewrites the same config line.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$REPO_ROOT"
git config core.hooksPath scripts/git-hooks
chmod +x scripts/git-hooks/*
echo "==> Git hooks installed (core.hooksPath = scripts/git-hooks)."
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""
Deterministically sort the high-churn sections of cmux.xcodeproj/project.pbxproj.
What we sort:
- Every entry inside PBXBuildFile and PBXFileReference (Xcode picks
arbitrary order; entries are referenced by UUID so order is irrelevant
to the build).
- The files = ( ... ) arrays inside PBXSourcesBuildPhase,
PBXResourcesBuildPhase, PBXFrameworksBuildPhase, and
PBXCopyFilesBuildPhase (Xcode reorders these on UI touches; the
compiler does not care about order).
What we leave alone:
- PBXGroup children = ( ... ) arrays. Order controls the project
navigator's visible order; sorting would reorder folders in the UI.
- All UUIDs and all comment text. We only reorder lines, never
rewrite identifiers.
- The objectVersion field, build settings, and every other section.
Idempotent: running twice produces zero diff.
Designed for the OpenStep-pbxproj flavor that Xcode writes by default.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
DEFAULT_PATH = Path("cmux.xcodeproj/project.pbxproj")
ENTRY_COMMENT_RE = re.compile(r"/\*\s*(?P<label>.+?)\s*\*/")
# Sections we sort flat. Every entry is a single line of the form
# <UUID> /* <label> */ = { ... };
FLAT_SECTIONS = (
"PBXBuildFile",
"PBXFileReference",
)
# Build phase sections whose `files = (...)` arrays we sort. Each line
# inside the array looks like
# <UUID> /* <label> in <phase> */,
BUILD_PHASE_SECTIONS = (
"PBXSourcesBuildPhase",
"PBXResourcesBuildPhase",
"PBXFrameworksBuildPhase",
"PBXCopyFilesBuildPhase",
)
def entry_sort_key(line: str) -> tuple[str, str]:
"""Sort lines by their /* comment */ label, then UUID as tie-breaker.
Falls back to the raw line when no comment is present so we never
drop or scramble unexpected lines.
"""
comment = ENTRY_COMMENT_RE.search(line)
label = comment.group("label").lower() if comment else line.strip().lower()
uuid = line.lstrip().split(" ", 1)[0]
return (label, uuid)
def sort_flat_section(lines: list[str], section: str) -> list[str]:
begin = f"/* Begin {section} section */"
end = f"/* End {section} section */"
try:
start = next(i for i, l in enumerate(lines) if l.strip() == begin)
stop = next(i for i, l in enumerate(lines) if l.strip() == end)
except StopIteration:
return lines
body = lines[start + 1 : stop]
# Separate content lines from blank lines; blanks are collapsed to a
# trailing group so they don't interleave with the sorted entries.
entries = [l for l in body if l.strip()]
blanks = [l for l in body if not l.strip()]
entries.sort(key=entry_sort_key)
new_body = entries + blanks
return lines[: start + 1] + new_body + lines[stop:]
def sort_build_phase_files(lines: list[str], section: str) -> list[str]:
begin = f"/* Begin {section} section */"
end = f"/* End {section} section */"
try:
start = next(i for i, l in enumerate(lines) if l.strip() == begin)
stop = next(i for i, l in enumerate(lines) if l.strip() == end)
except StopIteration:
return lines
out = lines[: start + 1]
body = lines[start + 1 : stop]
i = 0
while i < len(body):
line = body[i]
out.append(line)
if line.strip() == "files = (":
j = i + 1
inner = []
while j < len(body) and body[j].strip() != ");":
inner.append(body[j])
j += 1
inner.sort(key=entry_sort_key)
out.extend(inner)
i = j
continue
i += 1
return out + lines[stop:]
def normalize(text: str) -> str:
lines = text.splitlines(keepends=True)
for section in FLAT_SECTIONS:
lines = sort_flat_section(lines, section)
for section in BUILD_PHASE_SECTIONS:
lines = sort_build_phase_files(lines, section)
return "".join(lines)
def main(argv: list[str]) -> int:
check_only = "--check" in argv
positional = [a for a in argv[1:] if not a.startswith("--")]
path = Path(positional[0]) if positional else DEFAULT_PATH
if not path.exists():
print(f"error: not found: {path}", file=sys.stderr)
return 2
original = path.read_text()
normalized = normalize(original)
if check_only:
if original != normalized:
print(
f"error: {path} is not normalized. Run scripts/normalize-pbxproj.py to fix.",
file=sys.stderr,
)
return 1
return 0
if original != normalized:
path.write_text(normalized)
print(f"normalized: {path}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+2
View File
@@ -18,6 +18,8 @@ fi
"$SCRIPT_DIR/ensure-ghosttykit.sh"
"$SCRIPT_DIR/install-git-hooks.sh"
echo "==> Setup complete!"
echo ""
echo "You can now build and run the app:"