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:
co-authored by
Claude Opus 4.7
parent
2f66b97567
commit
88352eb7b7
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
26.0
|
||||
@@ -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:
|
||||
|
||||
+1427
-1427
File diff suppressed because it is too large
Load Diff
Executable
+31
@@ -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"
|
||||
Executable
+34
@@ -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
|
||||
Executable
+12
@@ -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)."
|
||||
Executable
+150
@@ -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))
|
||||
@@ -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:"
|
||||
|
||||
Reference in New Issue
Block a user