eleven / messenger Public

ci: Xcode Cloud post-clone for the iOS app (XcodeGen + Package.resolved) (#671)

* ci: Xcode Cloud post-clone generates the project with XcodeGen (iOS)

The .xcodeproj is gitignored — generated from ios/project.yml by XcodeGen — so
Xcode Cloud finds no project after cloning. Add ci_scripts/ci_post_clone.sh to
brew-install XcodeGen and `xcodegen generate` in ios/ before the build resolves
deps and archives.

Also stamp Xcode Cloud's monotonic CI_BUILD_NUMBER into CURRENT_PROJECT_VERSION
before generating, so TestFlight never sees a duplicate build number (flows to
the app + both extensions via CFBundleVersion). Guarded on CI_BUILD_NUMBER, so
local generates are unaffected.

iOS only for now — the Mac scheme (Sparkle + a `go build` embed step) is a
deliberate follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: verbose diagnostics + hard failure in Xcode Cloud post-clone

Xcode Cloud reported the hook ran but the build still couldn't find
ios/LChat.xcodeproj. Instrument the script so the next run tells us which:
print pwd + all CI_* path/scheme env vars, list the repo root and ios/ before
and after generate, and fail loudly (non-zero) if project.yml is missing or the
.xcodeproj isn't produced — so it can't appear to "succeed" without a project.
Also fall back to deriving the repo root from the script's own dir if
CI_PRIMARY_REPOSITORY_PATH is unset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: move ci_scripts into ios/ so Xcode Cloud actually runs the hook

The post-clone hook never ran: Xcode Cloud only executes ci_scripts/ when it
sits next to the .xcodeproj/.xcworkspace, and our project lives in ios/. A
repo-root ci_scripts is silently ignored for a subdirectory project — so the
build skipped straight to `xcodebuild -resolvePackageDependencies` against an
ios/LChat.xcodeproj that was never generated, and failed with "does not exist".

Move ci_scripts/ -> ios/ci_scripts/ and derive the ios dir from
CI_PRIMARY_REPOSITORY_PATH (fallback: the script's parent dir, now ios/).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: resolve Swift packages in post-clone so Xcode Cloud finds Package.resolved

Xcode Cloud builds with -disableAutomaticPackageResolution and requires a
Package.resolved inside the project. Ours is generated fresh by XcodeGen (and
gitignored), so the file never existed and the build failed: "a resolved file
is required … dependencies were added: 'sparkle'".

After xcodegen generate, run `xcodebuild -resolvePackageDependencies` (which is
not passed the disable flag) to write project.xcworkspace/.../Package.resolved
before the locked-down build step. Reuse CI_DERIVED_DATA_PATH when exported to
avoid a duplicate package clone, and hard-fail if the file isn't produced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: drop -derivedDataPath from post-clone resolve (exited 74/EX_IOERR)

The CI_DERIVED_DATA_PATH branch was never exercised locally (the var is unset
there) and failed on the runner with EX_IOERR/74 — that dir isn't writable yet
during post-clone. The plain resolve writes Package.resolved into the project
workspace regardless of derived-data location, so just always use it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: ship a committed Package.resolved instead of resolving on the runner

Xcode Cloud disables automatic package resolution environment-wide, so it both
demands a Package.resolved inside the project AND refuses to let our post-clone
`xcodebuild -resolvePackageDependencies` create one (that exits 74 on the
runner, though it works locally where resolution is enabled).

Ship a known-good Package.resolved next to the hook (ios/ci_scripts/) and copy
it into the freshly-generated LChat.xcodeproj workspace during post-clone. Xcode
Cloud then finds it and never needs to resolve. Pins Sparkle 2.9.4 to match the
project.yml exactVersion; a comment documents regenerating it on Sparkle bumps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Dermot Daly c79bbe5c6ad5cd8efc453f48495c773a8eb74001 parent 03447e0
2 files changed, +122 −0
  • ios/ci_scripts/Package.resolved +14 −0
  • ios/ci_scripts/ci_post_clone.sh +108 −0
diff --git a/ios/ci_scripts/Package.resolved b/ios/ci_scripts/Package.resolved
new file mode 100644
index 0000000..079740e
--- /dev/null
+++ b/ios/ci_scripts/Package.resolved
@@ -0,0 +1,14 @@
+{
+ "pins" : [
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
+ "version" : "2.9.4"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/ios/ci_scripts/ci_post_clone.sh b/ios/ci_scripts/ci_post_clone.sh
new file mode 100755
index 0000000..9c5b190
--- /dev/null
+++ b/ios/ci_scripts/ci_post_clone.sh
@@ -0,0 +1,108 @@
+#!/bin/sh
+
+# 🤖 Xcode Cloud post-clone hook.
+#
+# The Xcode project is NOT committed to this repo — it is generated from
+# ios/project.yml by XcodeGen (see ios/.gitignore). Xcode Cloud clones the repo
+# and then expects to find ios/LChat.xcodeproj, so we regenerate it here, right
+# after the clone and before dependency resolution + the build.
+#
+# LOCATION MATTERS: Xcode Cloud only runs ci_scripts/ when it sits next to the
+# Xcode project/workspace. The project lives in ios/, so this folder must be
+# ios/ci_scripts/ — NOT the repo root. (A repo-root ci_scripts is silently
+# ignored for a subdirectory project, which looks like "the hook never ran".)
+#
+# Xcode Cloud runs this from the ci_scripts/ directory and exports
+# CI_PRIMARY_REPOSITORY_PATH pointing at the cloned checkout root. Homebrew is
+# preinstalled on the runners, so we install XcodeGen with it.
+#
+# Scope: iOS only for now (scheme "LChat"). The Mac scheme (LChatMac) pulls in
+# Sparkle and an "Embed eleven CLI" step that shells out to `go build`; when we
+# add Mac to Xcode Cloud this script will also need to install Go 1.25. Left out
+# deliberately until then.
+
+set -eu
+
+echo "════════════ ci_post_clone: start ════════════"
+echo "pwd = $(pwd)"
+echo "script = $0"
+echo "CI_PRIMARY_REPOSITORY_PATH = ${CI_PRIMARY_REPOSITORY_PATH:-<unset>}"
+echo "CI_WORKSPACE = ${CI_WORKSPACE:-<unset>}"
+echo "CI_BUILD_NUMBER = ${CI_BUILD_NUMBER:-<unset>}"
+
+# Don't let brew spend minutes auto-updating itself/formulae on every build.
+export HOMEBREW_NO_AUTO_UPDATE=1
+export HOMEBREW_NO_INSTALL_CLEANUP=1
+
+# The ios/ directory holds project.yml. Prefer the documented repo-root env var;
+# fall back to the parent of this script's dir (this script lives in ios/ci_scripts).
+if [ -n "${CI_PRIMARY_REPOSITORY_PATH:-}" ]; then
+ IOS_DIR="$CI_PRIMARY_REPOSITORY_PATH/ios"
+else
+ IOS_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+ echo "▸ CI_PRIMARY_REPOSITORY_PATH unset — derived IOS_DIR from script dir"
+fi
+echo "IOS_DIR = $IOS_DIR"
+
+if [ ! -f "$IOS_DIR/project.yml" ]; then
+ echo "✗ ERROR: $IOS_DIR/project.yml not found — repo layout is not what we expect" >&2
+ exit 1
+fi
+
+if ! command -v xcodegen >/dev/null 2>&1; then
+ echo "▸ installing xcodegen via Homebrew"
+ brew install xcodegen
+fi
+echo "xcodegen at $(command -v xcodegen) — $(xcodegen --version)"
+
+cd "$IOS_DIR"
+echo "──────── ios/ before generate ────────"
+ls -la
+
+# TestFlight rejects a re-uploaded build number, so stamp Xcode Cloud's
+# monotonic CI_BUILD_NUMBER into CURRENT_PROJECT_VERSION before generating.
+# CFBundleVersion is driven from that build setting (see project.yml), so this
+# flows through to the app and both extensions. Only touches project.yml when
+# running under Xcode Cloud (CI_BUILD_NUMBER set) — local generates are unchanged.
+if [ -n "${CI_BUILD_NUMBER:-}" ]; then
+ echo "▸ stamping build number $CI_BUILD_NUMBER into project.yml"
+ sed -i '' -E "s/^([[:space:]]*CURRENT_PROJECT_VERSION:[[:space:]]*).*/\1\"$CI_BUILD_NUMBER\"/" project.yml
+fi
+
+xcodegen generate
+
+echo "──────── ios/ after generate ────────"
+ls -la
+
+if [ ! -d "$IOS_DIR/LChat.xcodeproj" ]; then
+ echo "✗ ERROR: xcodegen ran but $IOS_DIR/LChat.xcodeproj does not exist" >&2
+ exit 1
+fi
+echo "──────── generated schemes ────────"
+ls -la "$IOS_DIR/LChat.xcodeproj/xcshareddata/xcschemes/" || true
+echo "✓ project present: $IOS_DIR/LChat.xcodeproj"
+
+# Xcode Cloud's build step runs with automatic package resolution DISABLED
+# (environment-wide), so it demands a Package.resolved already inside the
+# project and refuses to resolve one itself. Our .xcodeproj is generated fresh
+# (and gitignored), and we can't resolve on the runner either (same disabled
+# state — `xcodebuild -resolvePackageDependencies` exits 74 there). So we ship a
+# committed Package.resolved next to this script and stage it into the generated
+# workspace. Xcode Cloud then finds it and never needs to resolve.
+#
+# KEEP IN SYNC with the Sparkle pin in project.yml: when Sparkle's version
+# changes, regenerate locally and re-copy —
+# cd ios && xcodebuild -resolvePackageDependencies -project LChat.xcodeproj -scheme LChat
+# cp LChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved ci_scripts/Package.resolved
+SRC_RESOLVED="$IOS_DIR/ci_scripts/Package.resolved"
+SWIFTPM_DIR="$IOS_DIR/LChat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm"
+echo "──────── staging committed Package.resolved ────────"
+if [ ! -f "$SRC_RESOLVED" ]; then
+ echo "✗ ERROR: $SRC_RESOLVED missing — commit it (see comment above)" >&2
+ exit 1
+fi
+mkdir -p "$SWIFTPM_DIR"
+cp "$SRC_RESOLVED" "$SWIFTPM_DIR/Package.resolved"
+echo "✓ staged Package.resolved:"
+cat "$SWIFTPM_DIR/Package.resolved"
+echo "════════════ ci_post_clone: done ════════════"