fix(ios): stop Xcode project corruption with pre-existing app extensions#211
Merged
Conversation
When the app already contains another app-extension target (Share/Watch/ expo-live-activity), the widget config plugin corrupted project.pbxproj: pod install failed with "[Xcodeproj] Consistency issue: no parent for object" and Xcode reported "Cycle inside <target>". Root causes and fixes in the ios-widget/xcode plugin: - Reuse an existing embed-app-extensions copy phase (dstSubfolderSpec == 13, any display name) instead of always adding a second "Embed Foundation Extensions" phase (addBuildPhases/ensureBuildPhases). - Scope PBXBuildFile lookup to the target's own build phase so we never adopt a build file that belongs to another target's phase (ensureBuildFile). - Dedupe copy-files products by resolved fileRef and mint a fresh UUID when the product's build-file UUID is already used elsewhere (ensureCopyFilesPhaseProduct). - Construct the widget PBXGroup by hand so the xcode lib no longer side-effect-creates conflicting PBXBuildFile entries (addPbxGroup). - Resolve widget file references scoped to the widget's own PBXGroup, so an identically named Assets.xcassets no longer collides with another extension's catalog and shares a fileRef/PBXBuildFile (fixes the #32 "no parent for object 'Assets.xcassets'" family). Unified into one helper. - Delete orphan phase objects (and their _comment keys) when de-duplicating build-phase refs. Also extracts the pure applyXcodeChanges() core from configureXcodeProject and adds fixture-based integration tests (fresh project + project with a pre-existing extension) plus a pbxproj consistency validator proving the fix and idempotency. Fixes #87 Fixes #32
V3RON
added a commit
that referenced
this pull request
Jul 7, 2026
…ion passthrough (#213) > [!NOTE] > Stacked on #211 (`fix/xcode-pbxproj-corruption`) — review only the last five commits. Merge #211 first, then retarget this PR to `main`. ## What is this? This PR restructures how the plugin manages the widget's Xcode target and closes several long-standing behavioral gaps. It is aimed at maintainers: the corruption fixes in #211 made the pbxproj mutation code testable, and this follow-up removes the structural duplication that made those bugs likely in the first place. It also ships three user-visible corrections: widget signing now mirrors the main app per build configuration, the widget inherits the app's version and build number, and a missing `ios.bundleIdentifier` fails loudly instead of silently disabling the plugin. One commit per concern: 1. `refactor(ios)`: collapse the add/ensure duality into a single ensure pipeline 2. `refactor(ios)`: match build phases semantically instead of by comment 3. `fix(ios)`: sync widget code signing per build configuration 4. `fix(ios)`: thread app version/buildNumber into widget build settings 5. `feat(ios)!`: throw when `expo.ios.bundleIdentifier` is missing ## How does it work? - **Single ensure pipeline.** Every pbxproj object used to have parallel `add*` (create) and `ensure*` (reconcile) code paths that had to be kept behaviorally identical by hand. Now the only create-only step is `createTargetSkeleton` — a target cannot be "ensured" into existence — and everything else (configuration list, product file, group, build phases, target attributes, dependency) goes through one idempotent ensure chain regardless of whether the target already existed. The collapse surfaced a latent bug: the `xcode` lib's `buildPhaseObject` falls back to a project-wide comment search and was returning the *main app's* Sources phase for a fresh widget target; phase lookup is now scoped to the target's own `buildPhases` list. - **Semantic phase matching.** Phases are identified by membership in their pbxproj type section (and the embed phase purely by `dstSubfolderSpec == 13`), never by comment strings, which are decorative and stripped by other tools. Deduplication follows the same rule and leaves unrelated copy-files phases untouched. - **Per-configuration signing.** `getMainAppTargetSettings` now reads every build configuration of the main app instead of only the first one, and the widget's Debug/Release configurations each receive their name-matched signing settings (`CODE_SIGN_STYLE`, `DEVELOPMENT_TEAM`, `PROVISIONING_PROFILE_SPECIFIER`), with a first-configuration fallback for unmatched names. Previously the Debug signing was copied into Release, which breaks manual-signing release builds with distinct provisioning profiles. - **Version passthrough.** `expo.version` and `expo.ios.buildNumber` are threaded into the widget's `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` (previously hardcoded `"1.0"`/`"1"`, which App Store Connect flags because an appex version must match its host app). The hardcoded values remain as fallback when the app config doesn't provide them. - **Fail fast on missing bundle identifier.** Without `ios.bundleIdentifier` the plugin used to return the config untouched, so prebuild "succeeded" with no widget and nothing explaining why. It now throws with a message pointing at the exact app-config key to set. Technically breaking for configs that relied on the silent no-op, hence the `!`. All existing fixture/consistency/idempotency tests from #211 stay untouched and green after every commit; the branch adds a per-config-signing fixture and tests, version-passthrough tests, and bundle-identifier tests (38 tests total). ## Why is this useful? - One reconciliation path means create-vs-update drift — the root cause behind several of the historical corruption bugs — is no longer possible by construction. - Release builds with manual signing get the correct provisioning profile instead of Debug's. - App Store submissions no longer trip over a widget stuck at version `1.0 (1)`. - Misconfigured apps fail at prebuild with an actionable message instead of shipping without their widget. - Dead create-only exports and comment-based phase lookup are gone, shrinking the surface the next contributor has to understand.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What is this?
This PR fixes the Xcode project corruption that occurs when the Voltra config plugin runs against an app that already contains another app extension (a Share extension, a notification service,
expo-live-activity's widget, etc.). Affected users hitpod installfailures like[Xcodeproj] Consistency issue: no parent for objectorCycle inside <target>; building could produce unreliable results, and repeatedexpo prebuildruns made the project progressively worse.Fixes #87. Fixes #32.
How does it work?
The corruption came from how the
xcodenpm library resolves objects globally by bare path and comment, which breaks down as soon as two targets contain identically named files or phases. The plugin now scopes every pbxproj mutation to the widget's own objects:ensureWidgetFileReference), so a bare path likeAssets.xcassetscan no longer collide with another extension's asset catalog and steal itsPBXFileReference.addPbxGroup, which side-effect-createsPBXBuildFileobjects that collide with the ones managed by the build phases and end up parentless.ensureBuildFile, becauseaddBuildPhase(files, …)reuses build files globally by path — including ones already owned by another target's phase.dstSubfolderSpec == 13rather than by its comment string.The pbxproj mutation core is extracted into a pure
applyXcodeChanges(xcodeProject, props, widgetFiles)function and covered by fixture-based integration tests: a fresh Expo project fixture and a fixture with a pre-existing extension (the exact #87/#32 shape), a consistency checker mirroring the invariants CocoaPods' Xcodeproj enforces (every build file has exactly one parent phase, no orphan phases, exactly one embed phase, serialization round-trip), idempotency across three consecutive runs, and a snapshot.Why is this useful?
expo-live-activityconflict in [!] An error occurred while processing the post-install hook of the Podfile. [Xcodeproj] Consistency issue: no parent for objectAssets.xcassets:ResourcesBuildPhase,ResourcesBuildPhase#32.expo prebuild(without--clean) is now idempotent: re-running it reconciles the existing target instead of stacking duplicate phases and build files.