Skip to content

feat(iOS) swiftpm support in iOS#57332

Open
chrfalch wants to merge 65 commits into
mainfrom
chrfalch/swift-package-manager
Open

feat(iOS) swiftpm support in iOS#57332
chrfalch wants to merge 65 commits into
mainfrom
chrfalch/swift-package-manager

Conversation

@chrfalch

@chrfalch chrfalch commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

⚠️ This is a stacked PR — review/merge the base PRs first

Builds on, and should land after (bottom-up):

  1. refactor(ios): remove clang VFS overlay, resolve headers via new ReactNativeHeaders framework #57285 — Remove the Clang VFS overlay / modularize React headers (base of the stack). <React/…> via the framework module map; lowercase namespaces (react/, yoga/, jsi/, …) via the ReactNativeHeaders module map.
  2. refactor(iOS): include resources like bundles into the precompiled XCFrameworks #57305 — Prebuilt artifact resources + ReactNativeHeaders.xcframework. Ships the prebuilt core artifacts (incl. ReactNativeHeaders in the tarball + embedded React.framework resources) and the CocoaPods React-Core-prebuilt facades.
  3. feat(iOS): serve third-party deps from the prebuilt ReactNativeDependencies pod via dependency-only facades #57440 — Self-serving prebuilt ReactNativeDependencies + dependency-only facades. The deps pod becomes the single header authority for the third-party namespaces (RCT-Folly/glog/boost/…); community s.dependency "RCT-Folly" resolves to local facades instead of trunk source pods.
  4. feat(iOS): ReactNativeDependenciesHeaders sidecar + pure-RN ReactNativeHeaders, published to Maven #57442 — ReactNativeDependenciesHeaders sidecar + pure-RN ReactNativeHeaders (immediate base). The deps prebuild emits a headers-only LIBRARY-type sidecar (the binary deps xcframework is framework-type — invisible to SwiftPM binaryTargets), ReactNativeHeaders drops the third-party namespaces, and both headers artifacts publish standalone to Maven.

The whole stack is rebased onto current main (2026-07-06).

This PR consumes the final five-artifact set: React, ReactNativeHeaders (pure-RN), ReactNativeDependencies, ReactNativeDependenciesHeaders, hermes-engine.

Summary

Adds npx react-native spm and the package-generation tooling that turns an app into a SwiftPM-integrated RN app using the base stack's prebuilt XCFrameworks. CocoaPods stays supported — additive, opt-in, no Ruby toolchain. Integration is injected into the existing .xcodeproj in place (nothing generated/renamed/replaced), recorded in .spm-injected.json so it reverses exactly.

What this PR adds

  • The spm CLI (add/update/deinit/scaffold + hidden sync/codegen/download), zero-arg auto-resolution, --deintegrate for CocoaPods→SwiftPM.
  • SwiftPM package generation (scripts/spm/): autolinking→Package.swift, Codegen→React-GeneratedCode, core XCFramework binary targets, artifact download/cache, surgical pbxproj inject/remove.
  • ReactNativeDependenciesHeaders wired as the 5th binaryTarget: the headers-only companions (ReactNativeHeaders from the core tarball, the deps sidecar from the deps tarball) are staged automatically out of their parent tarballs — no --headers-tarball priming; REQUIRED_ARTIFACTS covers all five.
  • Community-library scaffolding from podspecs (incl. root-level-source podspecs via generated include// shims, sibling wiring for transitive spm.dependencies, self-ingestion guards).
  • In-place Xcode integration (XCLocalSwiftPackageReference + Sync build phase + scheme pre-action + auto-sync; ${PODS_ROOT}-anchored REACT_NATIVE_PATH replaced on --deintegrate with exact deinit rollback).
  • rn-tester + helloworld SwiftPM consumption + test library; npm-packaging hygiene; a small RNCoreFacades.podspec_dir fix.

Documentation

Changelog:

[IOS] [ADDED] - npx react-native spm command + SwiftPM package-generation tooling (opt-in; CocoaPods stays supported)

Test Plan

350 scripts/spm unit tests (incl. byte-identical add→deinit round-trip); manual E2E against the five-artifact set: fresh app via cli initspm add --deintegrate→build (artifact resources verified in the app), rn-tester in-place migration (RNTesterPods.xcodeproj, test libraries + spmModules), helloworld in-place migration — all BUILD SUCCEEDED. Verified npx react-native spm <cmd> from the command line in each journey.

Scope & limitations

iOS, prebuilt-only (no build-from-source yet); full spm.xcframework/spm.source library metadata not yet (app-local spm.modules + scaffolding are); Expo not yet.

Follow-ups

Remote-mode ReactNative Package.swift must vend the ReactNativeDependenciesHeaders product (spm-distribution repo); library self-containment for repo-portable manifests; build-from-source.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 25, 2026
Comment thread packages/react-native/scripts/spm/download-spm-artifacts.js Fixed
Comment thread packages/react-native/scripts/spm/download-spm-artifacts.js Fixed
Comment thread packages/react-native/scripts/spm/download-spm-artifacts.js Fixed
Comment thread packages/react-native/scripts/spm/spm-pbxproj.js Fixed
@chrfalch chrfalch force-pushed the chrfalch/swift-package-manager branch from 64fcaab to 68bbbba Compare June 25, 2026 12:28
Comment thread packages/react-native/scripts/spm/spm-pbxproj.js Fixed
Comment thread packages/react-native/scripts/spm/download-spm-artifacts.js Fixed
@chrfalch chrfalch force-pushed the chrfalch/swift-package-manager branch 2 times, most recently from 8177e87 to c47b761 Compare July 6, 2026 15:24
chrfalch and others added 11 commits July 6, 2026 18:35
The minimal machinery to build the packaged header structures:

- headers-spec.js: the executable layout contract (rules R1-R8) — which
  namespaces are hoisted into the React framework, which carry module maps,
  and how collisions are rejected.
- headers-inventory.js: scans the source tree and classifies every shipped
  header (language surface + modularizability bucket) — the input to the spec.
  computeInventory() feeds the build in-memory; the CLI writes a JSON manifest.
- headers-compose.js: emits the layout — writes the <React/...> headers +
  umbrella + module map into each React.framework slice (detected by the
  framework's presence), and assembles the headers-only
  ReactNativeHeaders.xcframework (every other namespace + deps + Hermes).
  Called by xcframework.js during compose.

This is the alternative header source that lets consumers resolve React Native
headers without a clang VFS overlay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Emit the headers-spec layout unconditionally and delete the VFS overlay across
JS, CI publish, and Ruby. Consumers resolve headers the way the SwiftPM branch
does: <React/...> from the vendored React.framework, every other namespace from
ReactNativeHeaders. No root Headers/ on the xcframework, no VFS.

JS:
- xcframework.js: always emit the React.framework spec layout and build
  ReactNativeHeaders.xcframework (was gated behind RN_ZERO_I_LAYOUT=1). Remove
  the legacy header path entirely — the podspec->root-Headers enumeration,
  createModuleMapFile, and copyHeaderFilesToSlices — so the published
  React.xcframework is a standard framework (Info.plist + per-slice
  React.framework/{Headers,Modules}), no root Headers/ or Modules/. Ship
  ReactNativeHeaders.xcframework inside the reactnative-core tarball (sibling of
  React.xcframework) so the prebuilt pod can vend both; keep the standalone
  ReactNativeHeaders.xcframework.tar.gz for the SPM path. Drop the
  React-VFS-template.yaml emit and the ./vfs import.
- vfs.js: deleted (its only consumer was xcframework.js).
- types.js: drop the now-unused VFSEntry/VFSOverlay/HeaderMapping types.
- replace-rncore-version.js: drop the React-VFS.yaml preservation rationale.

Ruby/CocoaPods:
- React-Core-prebuilt.podspec: vend React.xcframework (its per-slice
  React.framework + module map serves <React/...> and @import React via
  FRAMEWORK_SEARCH_PATHS); flatten ReactNativeHeaders' headers into a top-level
  Headers/ in prepare_command and expose them via the pod header search path.
  Drop the VFS-era root header_mappings_dir/module_map.
- rncore.rb: remove the -ivfsoverlay injection and process_vfs_overlay;
  add_rncore_dependency and configure_aggregate_xcconfig now add a
  HEADER_SEARCH_PATHS to React-Core-prebuilt/Headers for podspec, aggregate, and
  third-party targets.
- react_native_pods.rb: drop the process_vfs_overlay post-install call.

Docs: replace the "VFS Overlay System" section with the headers-spec layout;
drop the obsolete "Known Issues" (pre-headers-spec) section.

Verified end-to-end: prebuild compose produces a VFS-free, root-Headers-free
React.xcframework; rn-tester pod install + xcodebuild (prebuilt path) BUILD
SUCCEEDED with zero -ivfsoverlay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
React.framework is a clang module; when an SPM consumer precompiles it, a
modular React/ header that #imports <react/...> hit
-Wnon-modular-include-in-framework-module because the lowercase react/
namespace (served from ReactNativeHeaders, per R1's Linux/Windows-safe layout)
was deliberately kept out of any module.

Give react/ a module where it already lives instead of relocating it (relocation
would require case-folding react.framework -> React.framework, which only works
on case-insensitive filesystems):

- headers-spec.js: drop the react/ namespace-module exemption so its
  objc-modular-candidates get a module; emit that module as
  ReactNativeHeaders_react (a module literally named 'react' would alias the
  React framework module on a case-insensitive filesystem). Module names are
  internal; <react/...> still resolves by header path and is now modular.
- headers-inventory.js: classify C++ default member initializers in aggregates
  (e.g. struct { NSString *family = nil; } in RCTFontProperties.h) as ObjC++ so
  these are not misclassified objc-modular-candidate and pulled into a plain
  ObjC module they cannot compile in.

The unguarded ObjC/C react/ headers (e.g. JSRuntimeFactoryCAPI.h) now resolve
modularly; the C++ react/renderer/* includes are #ifdef __cplusplus-guarded and
skipped during the ObjC module emit, so they need no module.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In prebuilt mode the React core pods' code + headers live entirely in
React.xcframework / React-Core-prebuilt. Re-installing their SOURCE podspecs
made them ship duplicate headers that shadow the prebuilt artifact and break
the React framework's clang explicit-module precompile
(-Wnon-modular-include-in-framework-module) under Xcode 26.

Install those core pods as dependency-only FACADES instead: generated podspecs
with no sources/headers, installed via :path (so nothing is fetched), each
depending on React-Core-prebuilt. Version, subspecs, default_subspec and
resources (e.g. the privacy manifest) are DERIVED from the real podspec so the
facade stays graph- and resource-equivalent to the source pod.

With the shadowing gone the React module precompiles cleanly with
SWIFT_ENABLE_EXPLICIT_MODULES on, so the Xcode-26 workaround (#53457) is
removed. The prebuilt header search path + ReactNativeHeaders module-map
activation are consolidated into a single post-install injection site
(configure_aggregate_xcconfig); add_rncore_dependency now only declares the
React-Core-prebuilt dependency.

rn-tester's NativeComponentExample uses the canonical <React/...> include for
RCTFabricComponentsPlugins.h (resolved from the framework) so it builds against
the facaded React-RCTFabric in prebuilt mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`yarn format-check` (prettier) was failing CI on PR #57285. Run prettier on the
ios-prebuild headers scripts (headers-compose.js, headers-inventory.js),
replace-rncore-version.js, and __docs__/README.md so format-check passes. No
logic changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tarball

The prebuilt React core now ships two xcframeworks — React.xcframework and the
headers-only ReactNativeHeaders.xcframework. React-Core-prebuilt's prepare_command
flattens the latter's Headers (including module.modulemap) into the pod. The
compose step only tar'd React.xcframework, so consumers got no
React-Core-prebuilt/Headers/module.modulemap and failed the build with
"module map file ... not found". Tar both xcframeworks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…le (R9)

The public-umbrella model (which replaced the VFS overlay) excludes `+Private`
and objc-blocked headers from React.framework's module, so privileged framework
consumers (e.g. Expo) that `#import <React/RCTBridge+Private.h>`,
`<React/RCTMountingManager.h>`, etc. fail to compile under explicit modules even
though the headers still ship in React.framework/Headers.

Add R9: a curated allowlist appended to the React module map —
`RCTBridge+Private.h` as a real `header` (objc-modular-candidate, reaches no
C++) and the six Fabric headers as `textual header` (objc-blocked; a real
member would re-trip -Wnon-modular-include, and their <react/...> C++ includes
resolve at the consumer's use site). Backwards-compatible: existing
`#import <React/...>` (and Swift `import React`) sites are unchanged. Fails
closed if an allowlisted header is removed/renamed or drifts bucket.

Note: RCTUIKit.h / RCTRootContentView.h are absent from source entirely and
need restoration, not exposure — out of scope here.
The flattened ReactNativeHeaders layout ships the individual
React_RCTAppDelegate/*.h headers but no per-namespace umbrella. Consumers like
Expo probe `<React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h>` via
__has_include (RCTAppDelegateUmbrella.h); with the umbrella gone the probe
fails and RCTReactNativeFactory / RCTRootViewFactory are never declared,
breaking the Expo pod's clang module.

Add R10: emit a per-namespace umbrella (content DERIVED from namespaceModules
so it can't drift — e.g. RCTArchConfiguratorProtocol.h, gone from this branch,
is correctly omitted) and add it to that namespace's module so the import stays
modular under explicit modules. Targeted via UMBRELLA_NAMESPACES (currently just
React_RCTAppDelegate, the only umbrella Expo imports); fails closed if a listed
namespace loses its modular headers.
…ic facade

Community Fabric modules quote-import "RCTFabricComponentsPlugins.h" (~47x:
slider, maps, pager-view, keyboard-controller, ...). In source, React-RCTFabric
vended it at header_dir "React", so it landed in dependents' CocoaPods header
maps and the bare quoted name resolved. In prebuilt mode React-RCTFabric is a
dependency-only facade that ships no headers — the only copy is baked angle-only
into React.framework (it's objc-blocked, excluded from the framework module
map), so quoted imports fail to resolve.

Re-vend JUST that one header from the facade (FACADE_REEXPOSED_HEADERS), copied
as a self-contained snapshot at header_dir "React", restoring dependents' header
maps exactly as the source pod did. Header-only (no compiled sources, no
duplicate symbols — the impl stays in React.framework). Re-exposing a single
header does not put <react/...>/<yoga/...> on -I, so it does not reintroduce the
non-modular-include shadowing the modular layout eliminates. Fails closed if the
glob matches nothing.
…the headers compose

buildReactNativeHeadersXcframework copied each declared deps namespace
(folly/glog/boost/fmt/double-conversion/fast_float) from the staged
ReactNativeDependencies headers, but only console.warn'd on a missing one and
kept going — silently shipping a ReactNativeHeaders.xcframework without
third-party header resolution (consumers then fail on <folly/...> etc.). The
summary log also printed the INTENDED namespace list, masking the gap.

Throw instead: a missing declared deps namespace means deps weren't staged
(third-party/ReactNativeDependencies.xcframework/Headers — from a full prebuild
or the cache slot), so refuse to emit an incomplete artifact.
…est imports

Two CI fixes for the prebuild-ios-core workflow:

- prebuild-ios-core.yml: the compose-xcframework job downloaded the
  build slices and React headers but never staged
  third-party/ReactNativeDependencies.xcframework. buildReactNativeHeadersXcframework
  folds the third-party deps namespaces (folly/glog/boost/...) into
  ReactNativeHeaders.xcframework, so it fail-closed with "deps namespace
  'folly' missing ... refusing to ship an incomplete
  ReactNativeHeaders.xcframework". Add the Download + Extract
  ReactNativeDependencies steps (mirroring build-slices) so the deps
  headers are present before composing.

- headers-spec-test.js: reorder requires so the `../headers-spec` import
  sorts before `fs`, fixing the @react-native/monorepo/sort-imports
  warning that failed `eslint --max-warnings 0` in test_js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
chrfalch and others added 23 commits July 9, 2026 09:58
mixed is a deprecated utility type (use unknown), and Buffer.from
needs the string+encoding overload since JSON.stringify on unknown
types as string | void.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sidecar

Same macOS-only clonefile flag as headers-compose; keeps the prebuild
scripts runnable under Linux CI jest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The scripts/spm toolchain that generates a SwiftPM Package.swift + xcodeproj
for React Native and community libraries, on top of the headers-spec header
layout (React.framework + ReactNativeHeaders, no VFS). Consumes
headers-compose.ensureHeadersLayout to compose the header artifacts; ships the
compose runtime + scripts/spm via the npm files allowlist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SPM-mode consumption for the example apps: rn-tester and helloworld each get a
Package.swift + *-SPM xcodeproj that consume React via SwiftPM product deps
(zero header search paths). Adds react-native-test-library (apple + common) and
its rn-tester example, plus the react-native.config.js spmModules wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…scripts

generate-spm-package previously composed the headers-spec layout on the consumer
side when the prebuilt artifacts lacked ReactNativeHeaders.xcframework. That
pulled the ios-prebuild build scripts (headers-compose and its deps) into the
published npm package, which in turn required the `plist` dependency at consumer
runtime.

The prebuilt core artifact ships React.xcframework AND ReactNativeHeaders.xcframework
together, so a consumer never needs to compose the layout. Remove the
consumer-side compose: fail with a clear error if ReactNativeHeaders is absent,
and stop listing scripts/ios-prebuild/* in the package files allowlist — those
are full-repo prebuild scripts, not consumer runtime code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…m-scratch generator

Replace the ambiguous `init`/`--from-scratch` (with its silent
in-place→from-scratch fall-through that could rename a user's project to
.legacy) with four clear verbs:

  add      inject SPM into the existing .xcodeproj, in place (idempotent;
           fail-loud on a CocoaPods-integrated pbxproj). --deintegrate runs
           `pod deintegrate` + strips RN from the Podfile + removes the
           leftover empty Pods group.
  update   re-run the pipeline + refresh the injection.
  deinit   surgical inverse of add (reverse exactly what .spm-injected.json
           records → byte-identical revert; no prompt).
  scaffold unchanged.
  sync/codegen/download stay but are hidden from primary help.

Zero-arg `npx react-native spm` auto-resolves: injected→update; a fresh
CocoaPods project (safe-gate: stock Podfile + git-clean pbxproj/Podfile)→
add --deintegrate; else strict add.

- Delete the from-scratch xcodeproj generator + legacy-migration/Podfile-patch
  machinery and the now-dead whole-file-generation helpers in spm-pbxproj.js.
- Add surgical pbxproj removal primitives (removeObjectByUuid,
  removeArrayMembersByUuid, removeField, removeArrayStringValues,
  removeEmptyPodsGroup); injection now records its exact edits in
  .spm-injected.json so deinit can reverse them.
- Merge flags: --skip/force-download → --download <auto|skip|force>;
  --local-xcframework + --artifacts-dir → --artifacts <path>. Remove
  --skip-xcodeproj/--force-xcodeproj/--bundle-identifier/--entry-file and the
  --platform-name CLI shim.
- generate-spm-package now throws on artifact-slot failures (incl. missing
  ReactNativeHeaders) instead of a silent exitCode+return.
- Update CLI registration, __doc__/spm-scripts.md, and the spm test suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The newer toolchain on the rebased base flags deprecated Flow utility
types and a few lint nits in the SPM scripts + test library:

- Flow: `$ReadOnly`/`$ReadOnlyArray`/`$ReadOnlySet` -> `Readonly*`,
  `$Shape` -> `Partial`, `mixed` -> `unknown`, `+` variance sigil ->
  `readonly`; narrow the nullable remote-package config before reading
  `.url`/`.version` (the `plan.isRemote` branch didn't refine it).
- ESLint: sort requires in expand-spm-dependencies.js and the two
  react-native-test-library entrypoints; drop unnecessary `\"` escapes
  in generate-spm-autolinking-test.js.
- Prettier formatting on spm-pbxproj.js.

No behavior change; 349 scripts/spm tests still green.

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

The `*-SPM.xcodeproj` projects (+ their app-local Package.swift) added to
the example apps were untested demo scaffolding — no CI job builds them.
In helloworld they also broke `test_ios_helloworld`: two `.xcodeproj` in
`ios/` made CocoaPods unable to auto-select the project ("Could not
automatically select an Xcode project") during `pod install`.

Remove Helloworld-SPM.xcodeproj and RNTester-SPM.xcodeproj plus their
Package.swift, and revert the SPM-artifact lines from the .gitignore
files. The rn-tester TestLibrary example and SPM tooling are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ReactNativeHeaders is pure-RN after the deps-headers split: the
third-party namespaces (folly/glog/boost/fmt/double-conversion/
fast_float/SocketRocket) ship in the ReactNativeDependenciesHeaders
sidecar the deps prebuild emits (the binary deps xcframework is
framework-type — invisible to SwiftPM binaryTargets).

- download-spm-artifacts: headers-only companions are staged out of
  their parent tarball (ReactNativeHeaders from the ReactCore tarball,
  the sidecar from the deps tarball) on fresh extracts, with fast-path
  backfill — generalizing the hermes-headers pattern. This retires the
  --headers-tarball priming workaround for ReactNativeHeaders; the
  override remains for testing, joined by --deps-headers-tarball /
  RN_DEPS_HEADERS_TARBALL_PATH. Both companions register in
  artifacts.json fail-closed (pre-companion tarballs die with
  actionable advice). REQUIRED_ARTIFACTS now lists all five artifacts,
  fixing the inconsistency where generate-spm-package hard-required
  ReactNativeHeaders but cache validation never checked it.
- generate-spm-package needs no code change: it renders one product +
  binaryTarget per artifacts.json entry.
- Product consumers add the ReactNativeDependenciesHeaders product
  wherever deps namespaces must resolve: reactProductDeps()
  (autolinking), the codegen Package.swift template, and scaffolded
  community-library manifests (SCAFFOLDER_VERSION 17 -> 18 so existing
  scaffolds regenerate).

Verified: fresh-app E2E (verdaccio -> cli init -> spm add -> build),
rn-tester and helloworld in-place migrations, 350 spm jest tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Migrating rn-tester (RNTesterPods.xcodeproj, in-place) and helloworld to
SwiftPM surfaced five latent bugs:

- scaffold: podspecs with root-level sources (s.source_files =
  "*.{h,m,mm}") got no publicHeadersPath, so SPM rejected the target
  over the nonexistent default include/. The scaffolder now mirrors
  root headers into a generated include/<SwiftName>/ shim dir
  (publicHeadersPath: "include"), giving dependents the CocoaPods
  header-map spelling `#import <SwiftName/Header.h>`.
- scaffold: root podspec globs matched the scaffolder's own output on
  re-runs — Package.swift's `.swift` extension tripped the
  mixed-language gate into DELETING the manifest it had just written
  (runs alternated between scaffolding and self-destructing). Scaffold
  artifacts (Package.swift, the prefix header, include/ shims) are now
  excluded from source expansion.
- autolinking: the one-time legacy-layout cleanup unconditionally
  removed `<dep>/include/`, nuking the scaffolder's shim dirs on every
  sync. It now only removes include/ together with a marker-bearing
  legacy in-place Package.swift.
- scaffold: transitive spm.dependencies entries carry no podspecPath,
  so podspec-name sibling deps (s.dependency "TestLibraryCommon")
  could not be wired ("Unknown dependency"). The sibling map now
  discovers podspecs at those deps' roots.
- add --deintegrate: a ${PODS_ROOT}-anchored REACT_NATIVE_PATH (the
  CocoaPods template default) dangles once CocoaPods is deintegrated,
  breaking the "Bundle React Native code and images" phase. The
  injection now replaces it with the SPM-computed path, recording the
  original in the marker so deinit restores it exactly.

Also declares TestLibraryApple's real dependency on TestLibraryCommon
in its podspec (CocoaPods resolved the undeclared edge leniently
through the shared Public headers dir; SPM needs it for sibling
package wiring).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- download-spm-artifacts.js: the tar/cp invocations interpolated
  URL/env-derived paths into shell strings (js/shell-command-injection,
  incl. one new critical on the companion backfill path). All four now
  use execFileSync argv form — no shell involved.
- spm-pbxproj.js: generateUUID used md5 (js/weak-cryptographic-algorithm).
  Not a security use — the hash only derives deterministic pbxproj
  UUIDs — but sha256 (truncated to the same 24 hex chars) is just as
  deterministic and keeps the scanner green. Affects only not-yet-
  released injections; the marker records actual UUIDs so deinit of
  md5-era injections is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Overhaul the SwiftPM tooling docs to match current behavior:

- Mark the feature Preview (header + intro) — commands/layout/distribution
  may change; CocoaPods stays the default.
- Fresh clones & CI: document that Xcode resolves the package graph before
  any build phase, so a clean checkout needs one `npx react-native spm` run
  (or a `postinstall` hook) before xcodebuild — the auto-sync phase can't
  bootstrap it. Correct the Quick Start lines that implied otherwise, and add
  a "Resolve Package Graph" row + the matching Troubleshooting entry.
- Drop stale VFS overlay references (VFS was removed; headers now come from
  the React / ReactNativeHeaders / ReactNativeDependenciesHeaders products) —
  rewrite Header Resolution, and fix the sync-spm-autolinking.js comment.
- Directory Layout: list all five artifacts.
- Migration: spell out exactly what `--deintegrate` removes vs preserves and
  how to keep non-RN pods (re-`pod install`, open the .xcworkspace, never
  re-add `use_react_native!`).
- New Brownfield section (project must live inside the JS tree; use
  `--xcodeproj`/`--productName`; native-root-with-RN-subfolder unsupported).
- New "Community packages without a Package.swift" section — scaffold +
  patch-package, and recommend filing an issue/PR to ship the manifest
  upstream.
- Use "SwiftPM" in prose throughout (identifiers, the `spm` command, and the
  literal "Sync SPM Autolinking" phase name kept verbatim); move the
  Pipeline/Directory/Header/Auto-Sync deep-dives into a Reference section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…re dep

rn-tester's test libraries (TestLibraryApple / TestLibraryCommon) failed to
build under SwiftPM with `RCTDeprecation/RCTDeprecation.h file not found`: the
scaffolded Package.swift carried no React products because the scaffolder's
`coreReactNative` detection missed them.

Both are plain ObjC modules that wire React core ONLY through the
`install_modules_dependencies(s)` podspec helper and ship no `codegenConfig`.
The scaffolder's two detection paths both fall through for that shape:

  1. Explicit `s.dependency "React-Core"` — but the helper is stripped before
     `pod ipc spec` (and its `min_supported_versions` call makes pod-ipc fail
     outright, falling back to the regex parser, which can't expand the helper
     either), so React-Core never surfaces in the parsed dependencies.
  2. package.json `codegenConfig` — only marks Fabric / New-Arch libraries,
     not plain ObjC modules.

Record whether the podspec source calls `install_modules_dependencies` (visible
even though the helper is stripped for pod-ipc) and treat its presence as an
authoritative React-core signal alongside codegenConfig — injecting that
family is the helper's entire job.

- read-podspec.js: set `usesInstallModulesDependencies` from the podspec source
- scaffold-package-swift.js: OR it into the coreReactNative fallback
- spm-types.js: add the field to PodspecModel

Verified: TestLibraryApple/Common now scaffold with the full React product set
(ReactNative, ReactNativeHeaders, ReactNativeDependenciesHeaders,
ReactAppHeaders); rn-tester BUILD SUCCEEDED. New focused unit test covers the
plain-ObjC-module-with-install_modules_dependencies shape.

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

Add an extension seam so a framework with its own module system (Expo is the
first consumer) can contribute to the SwiftPM autolinking graph that
`npx react-native spm` generates — the analog of the Podfile /
`use_expo_modules!` / `react_native_post_install` hooks CocoaPods gave Expo.

A one-shot post-process of the generated Package.swift is clobbered on the next
sync (the Xcode auto-sync build phase re-runs autolinking on every dependency
change), so plugins are invoked from generate-spm-autolinking's `main()` — the
single function both `add`/`update` and the build-time `sync` call — and run on
every regeneration.

Discovery is transitive and zero-config: any autolinked dep opts in from its
own react-native.config.js (`spm.autolinkingPlugin`), mirroring how CocoaPods
pulls in `use_expo_modules!`. An app can exclude a plugin via `spm.denyPlugins`
(escape hatch, not a required allowlist). Fail-closed and named: a plugin that
can't load, doesn't export a function, throws, or returns a malformed
contribution aborts the run identifying the framework — a silent drop (green
build, missing native code) is worse than a loud stop.

A plugin returns DATA (packageDependencies / productDependencies /
generatedSources); RN owns the merge, so regeneration stays deterministic and
idempotent, and package/product deps are deduped by name. The plugin context
exposes `react` — RN's own React package ref + product set (local xor remote,
incl. ReactAppHeaders from the per-app React-GeneratedCode package), derived
from one source of truth so a plugin depends on exactly RN's React surface
without re-deriving paths/identity that move as RN repackages.

Also threads `--flavor` (debug/release) through setup + sync into the context
so plugins can pick the matching prebuilt slice.

Contract is Preview / unstable while Expo validates it; to be ratified via RFC.
`generatedSources` is captured but its codegen registration + provider ordering
is intentionally left for the first real plugin to drive to a stable shape.

- autolinking-plugins.js: discoverPlugins + invokePlugins (new)
- generate-spm-autolinking.js: discovery/invoke in main(), reactDescriptor,
  plugin-host-dep target exemption, merge into the aggregator manifest
- spm-types.js: PluginContext / ReactDescriptor / PluginResult / …; flavor arg
- setup-apple-spm.js, sync-spm-autolinking.js: thread --flavor
- __doc__/spm-autolinking-plugins.md (new) + spm-scripts.md pointer
- tests: autolinking-plugins-test.js (new) + reactDescriptor / contribution
  cases in generate-spm-autolinking-test.js

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drive generate-spm-autolinking's main() over a minimal app fixture whose only
autolinked iOS dep is a plugin host (expo: native sources, no Package.swift) to
pin down the exemption added with the plugin system: a dep that declares an
`spm.autolinkingPlugin` owns its native contribution and must NOT also be
source-built through the community-lib path.

A regression pair, so the guard is meaningful rather than incidental:

- With the plugin declared: main() does not throw, the plugin's ExpoModulesCore
  package + product contribution is merged into the aggregator, and expo is not
  source-built (no `packages/<Name>` wrapper ref, no `__rnAutolinkedLibs` guard).
- Negative control: the SAME fixture with the plugin declaration removed throws
  MissingManifestError — proving the exemption is load-bearing.

This exercises the full main() path (dep walk, plugin discovery/invoke, manifest
write) rather than a pure helper, which is why it was missing; the surrounding
pieces (discoverPlugins / invokePlugins / the manifest merge) were already
covered.

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

Group A of the SwiftPM review (code + unit-testable; SPM Release build fixes
land separately). Majors:

- generate-spm-xcodeproj.js: the generated Sync build phase ran `spm sync` then
  `RC=$?` under `set -euo pipefail`, so a non-zero exit aborted the phase and the
  RC branch was unreachable (every transient sync failure broke the build). Now
  `spm sync || RC=$?` captures the code so the warn-and-exit-0 branch works.
- spm-utils.js: buildPerAppHeaderTree built into `outDir + '.tmp'`, which sits
  INSIDE build/generated/ios — a folded root — so foldDir folded the half-built
  farm into a spurious `ReactAppHeaders.tmp/` namespace duplicating every codegen
  header. Build into build/.react-app-headers.tmp (outside every folded root),
  then rename into place. Test asserts `.tmp/` is absent.
- read-podspec.js: pod-ipc JSON extraction anchored on the first `{`/last `}`
  anywhere, so a pre-JSON diagnostic containing braces (`Building {module}`) shifted
  the slice and dropped SILENTLY to the weaker regex parser. Anchor on the
  line-boundary `{`…`}` pod ipc actually emits, and log when the authoritative
  parse is abandoned.

Minors / CodeQL / ergonomics:
- spm-pbxproj.js: array build-setting dedup was substring-based (`-ObjC` vs
  `-ObjCFoo`); now exact-token as the docstring promised.
- expand-spm-dependencies.js: swiftName collision detection is case-insensitive
  (`worklets` vs `Worklets` collide on case-insensitive macOS filesystems).
- generate-spm-autolinking.js: the mixed-language friendly diagnostic now also
  guards user spm.modules / synth wrappers, not just the community-dep path; and
  the `.podspec` scanners skip a crashed run's `.spm-scaffold-*` leftover.
- download-spm-artifacts.js: registry `version` is validated against a safe charset
  before it flows into Maven URLs / tar filenames (clear error instead of a 404;
  satisfies the CodeQL command-line data-flow). Added `--deps-tarball` /
  RN_DEPS_TARBALL_PATH (symmetric with --core-tarball) — a local deps tarball
  auto-stages its headers sidecar via the existing companion path.
- generate-spm-package.js: guard against a stale/wrong React.xcframework layout
  (missing Headers/React-umbrella.h or a nested Headers/React_Core/) — fail loudly
  instead of dying deep in the consumer's Clang module build.
- scaffold-package-swift.js: correct the `exclude:` comment (none is emitted).

CodeQL weak-crypto (spm-pbxproj generateUUID) is already sha256 — those alerts are
stale pre-fix and clear on rescan.

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

Makes SwiftPM Release builds link + bundle (Debug already worked). Root-caused
via ios-build-matrix.sh; unblocks RN-Tester + community-Fabric SPM Release.

- (A) NDEBUG propagation. The Release React.framework strips
  DebugStringConvertible's vtable (and shifts the ShadowNode layout that
  inherits it) under NDEBUG, but the autolinked Fabric synth targets compiled
  with zero RN defines → undefined-symbol / layout-mismatch at link. Emit
  `.define("DEBUG", .when(.debug))` + `.define("NDEBUG", .when(.release))` on
  every autolinked C++ target: scaffolded community libs
  (scaffold-package-swift.js, SCAFFOLDER_VERSION 18→19 so existing scaffolds
  regenerate), synth wrappers, and inline spm.module targets
  (generate-spm-autolinking.js). NDEBUG-only is deliberate: Debug is green with
  no defines, so the always-on RN defines are provably unneeded — NDEBUG is the
  sole Debug↔Release divergence. Mirrors packages/react-native/Package.swift.
- (B) hermesc resolution. Under SPM there is no hermes-engine pod, so
  react-native-xcode.sh's `$PODS_ROOT/hermes-engine/.../hermesc` fallback
  resolves to a non-existent path and Release Hermes bundling fails for every
  SPM app. Inject HERMES_CLI_PATH (=$(REACT_NATIVE_PATH)/../hermes-compiler/
  hermesc/osx-bin/hermesc) via mergeReactBuildSettings; react-native-xcode.sh
  honors a pre-set value before its pod fallback, and an existing user value is
  left untouched. Reversed byte-for-byte on deinit (covered by the round-trip test).
- (Major #3) `--artifacts <local.xcframework>`: artifacts.json now also records
  ReactNativeHeaders + ReactNativeDependenciesHeaders (they ship beside their
  binaries in the tarballs). They are required by validateArtifactsCache /
  generate-spm-package; omitting them made the local slot report incomplete and
  forced a full Maven download, defeating "use it directly".

Tests: NDEBUG defines asserted in the scaffolded + synth manifests;
HERMES_CLI_PATH asserted in both build configs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the prior Release-support commit — two gaps the ios-build-matrix
Release run exposed that the prior commit missed.

- (A, completion) The codegen ReactCodegen / ReactAppDependencyProvider targets
  were NOT getting NDEBUG. That is where the app's codegen'd Fabric C++ lives
  (RNTMyNativeViewProps), so rn-tester still failed to link with an undefined
  "vtable for DebugStringConvertible". Add the DEBUG/NDEBUG config defines to
  scripts/codegen/templates/Package.swift.spm-template (the SPM codegen manifest
  installed by spm sync). Verified: rn-tester SPM Release now BUILD SUCCEEDED.
- (B, fix) HERMES_CLI_PATH must be ABSOLUTE, not `$(REACT_NATIVE_PATH)/../…`.
  node_modules/react-native is commonly a SYMLINK (monorepo default + many real
  apps); a `..` after it resolves — kernel-side — to the symlink TARGET's parent
  (packages/), so the relative path pointed at a non-existent
  packages/hermes-compiler and Release JS bundling failed with "No such file".
  resolveHermesCliPathSetting now returns the require.resolve'd absolute hermesc
  path (regenerated per `spm add`; matches how the hermes-engine pod sets it).
  Verified: HelloWorld SPM Release now BUILD SUCCEEDED.

Matrix: helloworld + rn-tester SPM Release both PASS (newapp pending a Verdaccio
re-publish of these scripts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RN ships flavored prebuilt binaries — debug React/hermesvm/ReactNativeDependencies
carry the dev experience (dev menu, assertions, RN_DEBUG_STRING_CONVERTIBLE),
release strips them — so a Debug build must embed debug binaries and a
Release/archive the release ones. SwiftPM binaryTargets can't branch on the build
configuration, so mirror the CocoaPods React-Core-prebuilt swap
(replace-rncore-version.js):

- `spm add` now downloads BOTH flavors up front (best-effort) so the swap never
  needs the network mid-build.
- New `swap-flavor` action + swap-flavor.js: reads the Xcode build env
  ($CONFIGURATION / $BUILT_PRODUCTS_DIR / $PLATFORM_NAME), slice-matches the
  target flavor's React / ReactNativeDependencies / hermesvm frameworks from the
  cache slot, and rsyncs them over the SwiftPM-copied frameworks in
  BUILT_PRODUCTS_DIR before Link. Idempotent, best-effort (never fatal). Resolves
  the cache slot via a single-level readlink (not realpath) so a locally-built
  React.xcframework symlinked into the slot doesn't send resolution out of the
  slot and under-swap.
- The generated build phase invokes it on every build (no-op in the scheme
  pre-action, where BUILT_PRODUCTS_DIR isn't populated yet).

Verified on the sim BOTH directions (hash-compared against the slot binaries):
add-debug→Release build ships release; add-release→Debug build ships debug; all
three frameworks swapped. Unit tests cover platform→slice mapping (incl. Mac
Catalyst) and the swap at both the top-level and PackageFrameworks layouts.

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

The core prebuild left <hermes/...> to be folded consumer-side (ensureHeadersLayout),
so a SwiftPM consumer of a published ReactNativeHeaders hit "'hermes/hermes.h'
file not found". Pass the hermes-ios tarball's staged destroot/include
(.build/artifacts/hermes/destroot/include) as the 5th arg to
buildReactNativeHeadersXcframework so the PUBLISHED ReactNativeHeaders carries
the hermes/ namespace. Null when unstaged (then <hermes/...> stays
consumer-composed, as before) — no behavior change for source builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the missing depsTarball field to DownloadArgs, replace the
deprecated mixed utility type with unknown, and make debug-log
interpolation explicit (String(boolean), nullish fallback for ?string).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lint no-useless-escape)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
plutil is macOS-only; the destructured execFileSync requires a hoisted
module mock. Portable plist-parse stand-in keeps the suite hermetic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@chrfalch chrfalch force-pushed the chrfalch/swift-package-manager branch from 92fc1bb to ea1d681 Compare July 9, 2026 08:10
chrfalch and others added 6 commits July 9, 2026 11:08
…d sync

The generated Sync SPM Autolinking phase dispatched through
'npx react-native', which requires @react-native-community/cli — absent
in Expo apps — so the Debug/Release flavor swap silently no-oped and the
build linked the add-time flavor (Debug + release React => RCT_DEV=0 =>
'No script URL provided' at launch).

- Resolve NODE_BINARY from the app-local .xcode.env(.local) (also covers
  Xcode GUI builds with no node on PATH), fallback to PATH.
- Resolve the react-native dir at build time via require.resolve — the
  generation-time baked path goes stale in pnpm stores — with the baked
  path as fallback.
- Dispatch scripts/setup-apple-spm.js directly for both swap-flavor and
  sync (npx fallback retained for sync).
- When the swap cannot run AND the pinned add-time flavor provably
  mismatches $CONFIGURATION, fail the build with an actionable error:
  instead of a swallowed warning.

Reported by Expo SwiftPM integration testing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erence on add

The community template has carried a navigator-only JavaScriptCore.framework
PBXFileReference since RN 0.60 (added in 0.58 when JSI required explicitly
linking system JSC; the CocoaPods migration removed the build-phase link but
left the file reference dangling). It links nothing and is meaningless under
Hermes, but confuses users of converted projects.

`spm add` now removes it as one-time conversion hygiene, alongside the
existing empty-Pods-group cleanup. Safety-gated: a reference that any
PBXBuildFile still links (an app deliberately using JSC) is left untouched,
as is the old tvOS DEVELOPER_DIR variant. `update` stays a minimal re-sync
and never touches it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tifacts contract

Four fixes from Expo's SwiftPM integration retest, plus the new plugin
artifact-swap contract:

1. Sync failure-atomicity: install the SPM codegen template immediately
   after codegen, before autolinking-plugin execution — a plugin throw can
   no longer leave codegen's mis-rooted default Package.swift breaking
   every subsequent Resolve Package Graph.

2. `spm update` now refreshes the injected script text: new setScalarField
   rewrites the sync phase's shellScript and the scheme pre-action's
   scriptText in place (byte-identical when unchanged; index-splicing on
   the scheme to survive $-sequences in the script).

3. Embed-flavor fix, three layers (mechanism proven by instrumented
   builds: Xcode captures binaryTarget embed sources at build-graph-
   construction time, and scheme pre-actions race graph construction):
   - swap sandwich in the shared phase/pre-action script; the symlink
     repoint half of swap-flavor no longer requires BUILT_PRODUCTS_DIR,
     so the pre-action can act before the graph is built;
   - pin-preserving sync (flavor derived from the current slot pin
     instead of hardcoded debug), so sync can't clobber a repoint;
   - deterministic appended `Fix SPM Embedded Flavor` phase: runs after
     Xcode's implicit SPM embed, rsyncs correct-flavor slices over
     .app/Frameworks and re-codesigns (CocoaPods-style, ad-hoc fallback).
   Verified E2E on a scratch app: forced-stale, debug-pinned, clean
   Release builds embed release deterministically (nm getDebugProps = 0,
   signatures verify), and the reverse Debug flip embeds debug.

4. flavoredArtifacts plugin contract: plugins may declare precompiled
   flavor pairs ({name, link, flavors:{debug,release}}); merged with
   warn-and-drop validation, recorded to
   .spm-plugin-flavored-artifacts.json ([] clears removals), and consumed
   by swap-flavor's unified loop — RN's own frameworks are normalized
   into the same shape, so plugin artifacts (e.g. Expo's precompiled
   modules) inherit the identical swap mechanics, including the embed
   fix. RN only repoints plugin-owned links, never creates/deletes them.

Docs: flavoredArtifacts contract + note that context.appRoot is the
Xcode project dir, not the package root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Autolinking plugins can return generatedSources (e.g. Expo's
ExpoModulesProvider.swift), previously recorded to a sidecar but consumed
by nothing. An @objc class compiled into the static AutolinkedAggregate
never reaches the ObjC classlist, so NSClassFromString discovery fails —
name-discovered classes must compile into the app target itself.

The injector now reads .spm-plugin-generated-sources.json at add/update
and wires each source into the injected app target: PBXFileReference +
PBXBuildFile + Sources-phase entry, parented under a single namespaced
"SPM Generated Sources" group. Paths are stored SRCROOT-relative when
under the app root, absolute otherwise. All UUIDs are deterministic
(namespaced by normalized path) so re-injection is byte-identical.

The .spm-injected.json marker gains a generatedSources section
(path -> [uuids]); `spm update` reconciles it against the current
manifest, retiring entries whose sources disappeared (and the group when
the last one goes). `spm deinit` reverts everything via the existing
recorded-UUID machinery. A target with no Sources phase logs loudly and
skips the wiring without failing the injection.

The generated-sources sidecar is now always written ([] when empty, like
the flavored-artifacts sidecar) so removing a plugin entirely reconciles
instead of leaving a stale manifest.

Verified E2E on a scratch app with a real autolinking plugin: the
generated @objc class reaches the app binary's ObjC classlist, and
unregistering the plugin retires all pbxproj entries on the next update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…plate warning

Three staleness/robustness hardening items:

1. Watch-path staleness (closes three gaps): the sync phase's
   .spm-sync-watch-paths file now carries mixed dirs AND files. Each
   autolinked npm dep's checked-in root Package.swift and .react-native/
   dir are watched (dep root threaded from the autolinking model), so
   editing a library manifest finally trips the in-build re-sync (file
   edits never bump parent-dir mtimes, so the old dirs-only watch missed
   them). Plugins gain a watchPaths contract (absolute dirs/files,
   validated warn-and-drop like flavoredArtifacts) so plugin packages
   contribute staleness inputs. A watched path that VANISHES now forces
   a re-sync so the autolinker surfaces the real config error instead of
   dangling-symlink noise. The generated phase loop distinguishes
   -d (find -newer probe) / -f ([ -nt ]) / vanished (stale). Behavioral
   sh tests drive the real generated loop under /bin/sh.

2. Artifact version pinning: an explicit `spm add/update --version <v>`
   is recorded as artifactsVersionOverride in .spm-injected.json
   (preserved on later runs without --version; removed by deinit), and
   the in-build sync prefers it over the package.json-derived version —
   previously a version-mismatched setup had every in-build sync heal
   against the wrong artifact slot. findInjectedXcodeproj moved to
   generate-spm-xcodeproj.js as the single marker-lookup authority.

3. installSpmCodegenTemplate warns loudly when the SPM codegen template
   file is missing (previously a silent no-op that left codegen's
   mis-rooted default Package.swift breaking every subsequent Resolve
   Package Graph).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lavor flips

The appended `Fix SPM Embedded Flavor` phase raced Xcode's standalone
CodeSign of the same frameworks (no dependency edge exists: declaring the
.app paths as phase outputs collides with the implicit embed's outputs —
"Multiple commands produce" — and declaring the SOURCE paths cycles
through the package targets). Instrumented builds further proved the
implicit embed is unordered with respect to script phases entirely, and
that scheme pre-actions race graph capture non-deterministically: no
in-build mechanism can affect what the current build embeds. Only the
state at build start counts.

New semantics — hard-fail + converge:
- The appended phase is gone (and is actively removed from already-
  injected projects on `spm add`/`update`). React Native writes nothing
  into the .app; Xcode's own embed copies and signs the pinned flavor.
- A build that STARTS with the wrong pinned flavor corrects the symlinks
  and BUILT_PRODUCTS copies, completes the sync, then deliberately fails:
  "frameworks were pinned to 'debug' but this is a Release build — they
  have been switched; build again." The rebuild is green with the correct
  flavor linked AND embedded. Exactly one explanatory red build per
  configuration flip; a stale-flavor binary can never ship silently.
- Only the LEADING swap may flag the failure (it alone observes the
  start state); a trailing correction converges silently, so mid-build
  re-syncs and fresh-clone auto-heals never produce a false red.
- The scheme pre-action is now sync-only: a pre-action swap that won its
  race would mask a mismatched start into a false green.
- `npx react-native spm update --flavor <x>` remains the zero-red-build
  way to switch ahead of time.

E2E-verified on a real app with code signing enabled, both directions:
matched builds green; flips red with the message and a converged pin;
rebuilds green with the correct embedded flavor (nm-verified) and valid
signatures; stale re-syncs on a matched pin stay green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. p: Expo Partner: Expo Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants