Skip to content

Commit ed3229a

Browse files
chrfalchmeta-codesync[bot]
authored andcommitted
fix(swiftpm): persist --config-command so the in-build sync keeps using it (#57756)
Summary: **`spm add --config-command` worked once, then broke every build.** An app that replaces `react-native-community/cli` autolinking — an Expo app, for instance — has to override the autolinking config command, which `--config-command` (and `RCT_SPM_AUTOLINKING_CONFIG_COMMAND`) exists to do. But the flag was never stored anywhere. So: 1. `npx react-native spm add --config-command '[...]'` → succeeds, writes a valid project ✅ 2. Build in Xcode → the "Sync SPM Autolinking" phase re-derives `autolinking.json`, doesn't know about the flag, falls back to the default command, and fails ❌ ``` PhaseScriptExecution failed with a nonzero exit code → Sync SPM Autolinking → 'npx --no-install react-native-community/cli config' exited with status 1 ``` The only real workaround was exporting `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` into Xcode's environment — i.e. committing it to `.xcode.env` — which shouldn't be necessary when you already passed a flag. Reported by the Expo team while testing SwiftPM. **Fix:** pin the command into the `.spm-injected.json` marker at `add`/`update` time, and read it back on later runs — the same set-or-preserve pin the neighbouring `artifactsVersionOverride` already uses. Resolution order, unchanged at the front and only extended at the back: ``` --config-command → RCT_SPM_AUTOLINKING_CONFIG_COMMAND → pinned value → default ``` **Both input routes persist.** The help text advertises the env var as an equivalent way to supply the command, so pinning only the flag would have left half the documented interface broken in exactly the same way — export the env var, run `spm add`, and the build phase (which does not inherit your shell) still fails. The pin therefore stores the *resolved* command from either route. Two subtleties worth a reviewer's eye: - `generateAutolinkingConfig` resolves the env var *internally* when no explicit command is passed, so handing it the pin would silently outrank a developer's env override. The read path therefore **withholds** the pin while the env var is set, letting the existing precedence do its job. The four order cases are tested, including that a whitespace-only env var falls through to the pin rather than stranding it. - A pinned value is re-validated through the same `parseConfigCommandJson` the flag goes through, so a hand-edited or corrupt marker degrades to the env/default command instead of injecting a bogus argv into a build. Because this is now persistent state, `add`/`update` logs one line when the command comes from the pin, naming `.spm-injected.json` — a stale pin should be diagnosable from build output rather than invisible. There is no "clear" verb short of `deinit`, same as the version pin; that is noted in the marker comment. 122 added lines across three source files. No new mechanism, no changes to the sync scripts, and nothing baked into the generated build phase. **Noticed while here, filed separately, deliberately not fixed:** `readArtifactsVersionOverride` — the version pin this is modelled on — has **no production caller**. Only its write half is wired, and two comments claim the build-time sync reads it. Those comments are corrected here (they misled me while writing this); wiring the version pin up is its own change. ### This isn't blocking anyone Expo's SwiftPM verification is **not blocked** on this, so it needn't be rushed. The env-var half of the override already works at build time: adding ```sh export RCT_SPM_AUTOLINKING_CONFIG_COMMAND='["node","…/expo-modules-autolinking.js","react-native-config","--json","--platform","ios"]' ``` to the app's `.xcode.env` (or `.xcode.env.local`) gets the command into the sync phase, because the generated phase sources both files before dispatching. That is what unblocks Expo today, and it is exactly the "commit an env var to `.xcode.env`" step this PR removes the need for. One caveat that argues for fixing it properly rather than documenting the workaround: the phase sources `.xcode.env` **only when `NODE_BINARY` is unset** (`nodeAndRnDirPreamble`). An app that sets `NODE_BINARY` as an Xcode build setting — a documented RN practice — never sources those files, so the workaround silently does nothing there and the build fails with no hint as to why. Persisting the flag doesn't depend on any of that plumbing. ## Changelog: [Internal] [Fixed] - SwiftPM: persist `spm --config-command` so the in-build autolinking sync keeps using it Pull Request resolved: #57756 Test Plan: `yarn jest packages/react-native/scripts` → **31 suites, 703 tests** (25 new). Each new test written red first, covering: - the command round-trips through `.spm-injected.json` (marker content asserted) - `add` → `update` **without** the flag keeps the pin; a later flag overwrites it - `add` with **only the env var** set pins the env-derived command, and a later run with neither flag nor env resolves it back - all four resolution-order cases, including that **the env var beats the pin**, and that a whitespace-only env var pins nothing and falls through - an invalid non-blank env var still fails loud rather than pinning garbage - a corrupt or hand-edited pin (bare string, `[]`, non-string member, empty-string member, object) degrades to the default rather than throwing - `deinit` drops it with the marker Not covered: no test drives a real `sync` end to end, since that needs artifacts, codegen and a real pbxproj. The two halves are tested separately against the same marker field — the injector writes `configCommand`, and the resolver reads it. The original failure was reported from a real Expo app build; confirmation that this fixes that build is still pending on the Expo side. Reviewed By: fabriziocucci Differential Revision: D114317929 Pulled By: cipolleschi fbshipit-source-id: a0fe47de3da55b25e320b000a2b0b1b44b88af9a
1 parent fc365f6 commit ed3229a

6 files changed

Lines changed: 479 additions & 21 deletions

File tree

packages/react-native/scripts/setup-apple-spm.js

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,18 @@ const {
8585
const {
8686
generateAutolinkingConfig,
8787
parseConfigCommandJson,
88+
readEnvConfigCommand,
89+
resolveEnvConfigCommand,
8890
} = require('./spm/generate-spm-autolinking-config');
8991
const {main: generatePackage} = require('./spm/generate-spm-package');
9092
const {findSourcePath} = require('./spm/generate-spm-package');
9193
const {
94+
SPM_INJECTED_MARKER,
9295
cleanupDanglingJavaScriptCoreRef,
9396
cleanupLeftoverPodsGroup,
9497
findInjectedXcodeproj,
9598
injectSpmIntoExistingXcodeproj,
99+
readPinnedConfigCommand,
96100
removeSpmInjection,
97101
} = require('./spm/generate-spm-xcodeproj');
98102
const {scaffoldAll} = require('./spm/scaffold-package-swift');
@@ -192,7 +196,7 @@ function parseArgs(argv /*: Array<string> */) /*: SetupArgs */ {
192196
.option('config-command', {
193197
type: 'string',
194198
describe:
195-
'[advanced] JSON array of the argv used to generate autolinking.json, overriding the default @react-native-community/cli config command. Also settable via RCT_SPM_AUTOLINKING_CONFIG_COMMAND. Example: \'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]\'',
199+
'[advanced] JSON array of the argv used to generate autolinking.json, overriding the default @react-native-community/cli config command. Also settable via RCT_SPM_AUTOLINKING_CONFIG_COMMAND. Either way `add`/`update` remembers the value in .spm-injected.json, so later runs and Xcode builds reuse it. Example: \'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]\'',
196200
})
197201
.usage(
198202
'Usage: $0 [action] [options]\n\nSets up Swift Package Manager support in a React Native app.',
@@ -844,6 +848,7 @@ async function setupXcodeproj(
844848
// (injectSpmIntoExistingXcodeproj preserves it — see
845849
// generate-spm-xcodeproj.js).
846850
artifactsVersionOverride: args.version ?? null,
851+
configCommand: resolveConfigCommandToPin(args),
847852
});
848853
if (result.status !== 'injected') {
849854
logError(`SPM injection failed: ${result.reason}`);
@@ -903,6 +908,45 @@ function logNextSteps(
903908
log('To remove SPM later: `npx react-native spm deinit`');
904909
}
905910

911+
// The autolinking config command for this run: an explicit `--config-command`
912+
// first, then the value a previous `add`/`update` pinned into the injection
913+
// marker. undefined means "no explicit command", which is what makes
914+
// generateAutolinkingConfig fall back to RCT_SPM_AUTOLINKING_CONFIG_COMMAND and
915+
// then to the built-in default — so the pin has to be WITHHELD while the env
916+
// var is set, or a stale pin would outrank a developer's env override.
917+
function resolveExplicitConfigCommand(
918+
args /*: SetupArgs */,
919+
appRoot /*: string */,
920+
) /*: Array<string> | void */ {
921+
if (args.configCommand != null) {
922+
return args.configCommand;
923+
}
924+
if (readEnvConfigCommand() != null) {
925+
return undefined;
926+
}
927+
const pinned = readPinnedConfigCommand(appRoot);
928+
if (pinned == null) {
929+
return undefined;
930+
}
931+
log(
932+
`Autolinking config command (pinned in ${SPM_INJECTED_MARKER}): ` +
933+
pinned.join(' '),
934+
);
935+
return pinned;
936+
}
937+
938+
// The command to record in the injection marker. The env var is resolved here
939+
// too, because the Xcode build phase inherits neither the flag nor the shell
940+
// that set it — an env-only override that went unpinned would leave the build
941+
// re-deriving autolinking.json with the default command. null pins nothing and
942+
// preserves any earlier pin. An invalid env value throws, as the flag does,
943+
// though `add` has already failed closed on it by this point.
944+
function resolveConfigCommandToPin(
945+
args /*: SetupArgs */,
946+
) /*: ?Array<string> */ {
947+
return args.configCommand ?? resolveEnvConfigCommand();
948+
}
949+
906950
// Generate autolinking.json, failing closed on a config-command error.
907951
//
908952
// generateAutolinkingConfig throws ONLY when the config command itself fails —
@@ -937,7 +981,9 @@ function generateAutolinkingConfigOrFailClosed(
937981
'RCT_SPM_AUTOLINKING_CONFIG_COMMAND (or pass --config-command) to a ' +
938982
'JSON argv array whose command prints the React Native CLI config, ' +
939983
'e.g. \'["npx","expo-modules-autolinking","react-native-config",' +
940-
'"--json","--platform","ios"]\'.',
984+
'"--json","--platform","ios"]\'. An earlier `add`/`update` may also ' +
985+
`have pinned a command in ${SPM_INJECTED_MARKER}; re-run with ` +
986+
'--config-command to replace a stale one.',
941987
);
942988
process.exitCode = 2;
943989
return null;
@@ -1035,7 +1081,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
10351081
log('Generating autolinking.json (CLI config)...');
10361082
autolinkingConfigResult = generateAutolinkingConfigOrFailClosed({
10371083
projectRoot,
1038-
configCommand: args.configCommand ?? undefined,
1084+
configCommand: resolveExplicitConfigCommand(args, appRoot),
10391085
});
10401086
if (autolinkingConfigResult == null) {
10411087
// Fail closed: the config command errored and the helper already set
@@ -1214,6 +1260,8 @@ module.exports = {
12141260
generateAutolinkingConfigOrFailClosed,
12151261
parseArgs,
12161262
resolveAction,
1263+
resolveConfigCommandToPin,
1264+
resolveExplicitConfigCommand,
12171265
shouldAutoDeintegrate,
12181266
ensureBothArtifactFlavors,
12191267
};

packages/react-native/scripts/spm/__doc__/spm-scripts.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,40 @@ accepts kebab-case equivalents (e.g. `--skip-codegen`).
137137
| `--artifacts <path>` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots |
138138
| `--download <auto\|skip\|force>` | [advanced] Artifact download policy (default: auto) |
139139
| `--skipCodegen` | [advanced] Skip the codegen step |
140+
| `--configCommand <json>` | [advanced] JSON array of the argv used to generate `autolinking.json`, overriding the default `@react-native-community/cli config` command. Also settable via the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` env var. Either way the value is remembered, so you pass it once. Example: `'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]'` |
141+
142+
### The autolinking config command is remembered
143+
144+
An app that replaces `@react-native-community/cli` autolinking (an Expo app,
145+
for example) has to tell `spm` how to produce `autolinking.json`. Pass the
146+
command once, on `add` or `update`:
147+
148+
```bash
149+
npx react-native spm add --configCommand '["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]'
150+
```
151+
152+
Every action that needs `autolinking.json``add`, `update`, `scaffold`, and
153+
the build-time `sync` — resolves the command in this order:
154+
155+
1. `--configCommand`
156+
2. `RCT_SPM_AUTOLINKING_CONFIG_COMMAND`
157+
3. the `configCommand` pinned in `MyApp.xcodeproj/.spm-injected.json` by an
158+
earlier `add`/`update`
159+
4. the default `@react-native-community/cli config`
160+
161+
`add`/`update` pin whichever of the first two routes supplied the command,
162+
validated as an argv array; a later run that passes neither keeps the existing
163+
pin, and passing `--configCommand` again replaces it. The pin exists because
164+
the **Sync SPM Autolinking** build phase inherits neither your flag nor the
165+
shell that exported the env var — without it, a successful `add` is followed by
166+
failing builds, because the phase re-derives `autolinking.json` with the
167+
default command. A pin never shadows the env var, so an override in your shell
168+
still takes effect, and a pin that no longer parses is ignored in favor of the
169+
default.
170+
171+
`deinit` deletes `.spm-injected.json`, and the pin with it. A later `add`
172+
therefore falls back to the default command unless you pass `--configCommand`
173+
(or export the env var) again.
140174

141175
### Debug/Release flavor is automatic
142176

@@ -161,7 +195,7 @@ package graph, or require a second build.
161195
| Path | Commit? | Why |
162196
|------|---------|-----|
163197
| `MyApp.xcodeproj/` | Yes | Your project, with SwiftPM injected in place. Holds your signing, capabilities, Build Phases — `add` only adds SwiftPM refs/settings, additively. |
164-
| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. |
198+
| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. Also pins settings later runs and Xcode builds must reuse, such as the [autolinking config command](#the-autolinking-config-command-is-remembered). |
165199
| `build/generated/` | No | Codegen/autolinking output; regenerated |
166200
| `build/xcframeworks/` | No | Symlinks to the machine-local artifact cache |
167201
| `Package.resolved` | No | SwiftPM resolution file; machine-specific |
@@ -335,6 +369,7 @@ across apps; refresh it with `react-native spm update --download force`.
335369
| "not contained in target" | Re-run setup (regenerates file-level symlinks) |
336370
| Codegen fails | Use `--skipCodegen` to iterate on other parts |
337371
| "SPM sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present |
372+
| "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `--configCommand` (or with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). |
338373
| Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` |
339374
| Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode |
340375
| Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` |

packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const {
1414
SPM_INJECTED_MARKER,
1515
injectSpmIntoExistingXcodeproj,
1616
readArtifactsVersionOverride,
17+
readPinnedConfigCommand,
1718
removeSpmInjection,
1819
} = require('../generate-spm-xcodeproj');
1920
const fs = require('node:fs');
@@ -393,3 +394,148 @@ describe('readArtifactsVersionOverride', () => {
393394
expect(readArtifactsVersionOverride(appRoot)).toBeNull();
394395
});
395396
});
397+
398+
// ---------------------------------------------------------------------------
399+
// configCommand — the marker field persisting an explicit `spm add/update
400+
// --config-command '<json argv>'`. Without the pin, the build-time `sync`
401+
// re-derived autolinking.json with the default @react-native-community/cli
402+
// command and failed the "Sync SPM Autolinking" build phase in apps (e.g. Expo
403+
// apps) that replace it.
404+
// ---------------------------------------------------------------------------
405+
describe('configCommand marker field', () => {
406+
const EXPO_COMMAND = [
407+
'npx',
408+
'expo-modules-autolinking',
409+
'react-native-config',
410+
'--json',
411+
'--platform',
412+
'ios',
413+
];
414+
415+
it('records an explicit config command into the marker', () => {
416+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
417+
injectSpmIntoExistingXcodeproj({
418+
appRoot,
419+
reactNativeRoot: rnRoot,
420+
xcodeprojPath,
421+
configCommand: EXPO_COMMAND,
422+
});
423+
expect(readMarker(xcodeprojPath).configCommand).toEqual(EXPO_COMMAND);
424+
expect(readPinnedConfigCommand(appRoot)).toEqual(EXPO_COMMAND);
425+
});
426+
427+
it('defaults to null when --config-command has never been given', () => {
428+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
429+
injectSpmIntoExistingXcodeproj({
430+
appRoot,
431+
reactNativeRoot: rnRoot,
432+
xcodeprojPath,
433+
});
434+
expect(readMarker(xcodeprojPath).configCommand).toBeNull();
435+
expect(readPinnedConfigCommand(appRoot)).toBeNull();
436+
});
437+
438+
it('preserves the pin on a later run without --config-command', () => {
439+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
440+
injectSpmIntoExistingXcodeproj({
441+
appRoot,
442+
reactNativeRoot: rnRoot,
443+
xcodeprojPath,
444+
configCommand: EXPO_COMMAND,
445+
});
446+
injectSpmIntoExistingXcodeproj({
447+
appRoot,
448+
reactNativeRoot: rnRoot,
449+
xcodeprojPath,
450+
});
451+
expect(readMarker(xcodeprojPath).configCommand).toEqual(EXPO_COMMAND);
452+
expect(readPinnedConfigCommand(appRoot)).toEqual(EXPO_COMMAND);
453+
});
454+
455+
it('a later explicit --config-command overwrites the previous pin', () => {
456+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
457+
injectSpmIntoExistingXcodeproj({
458+
appRoot,
459+
reactNativeRoot: rnRoot,
460+
xcodeprojPath,
461+
configCommand: EXPO_COMMAND,
462+
});
463+
injectSpmIntoExistingXcodeproj({
464+
appRoot,
465+
reactNativeRoot: rnRoot,
466+
xcodeprojPath,
467+
configCommand: ['my-cli', 'config'],
468+
});
469+
expect(readMarker(xcodeprojPath).configCommand).toEqual([
470+
'my-cli',
471+
'config',
472+
]);
473+
expect(readPinnedConfigCommand(appRoot)).toEqual(['my-cli', 'config']);
474+
});
475+
476+
it('deinit drops the pin along with the whole marker', () => {
477+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
478+
injectSpmIntoExistingXcodeproj({
479+
appRoot,
480+
reactNativeRoot: rnRoot,
481+
xcodeprojPath,
482+
configCommand: EXPO_COMMAND,
483+
});
484+
removeSpmInjection({appRoot, xcodeprojPath});
485+
expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe(
486+
false,
487+
);
488+
expect(readPinnedConfigCommand(appRoot)).toBeNull();
489+
});
490+
});
491+
492+
// ---------------------------------------------------------------------------
493+
// readPinnedConfigCommand — pure fs read, used by setup-apple-spm.js (including
494+
// the build-time `sync`) to reuse the config command an earlier `add`/`update`
495+
// pinned. A hand-edited or corrupt marker must degrade to the env/default path
496+
// instead of injecting a bogus argv or throwing mid-build.
497+
// ---------------------------------------------------------------------------
498+
describe('readPinnedConfigCommand', () => {
499+
it('returns null when no xcodeproj has been injected yet', () => {
500+
const {appRoot} = scaffoldApp();
501+
expect(readPinnedConfigCommand(appRoot)).toBeNull();
502+
});
503+
504+
it('returns null (never throws) on a malformed marker', () => {
505+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
506+
injectSpmIntoExistingXcodeproj({
507+
appRoot,
508+
reactNativeRoot: rnRoot,
509+
xcodeprojPath,
510+
configCommand: ['my-cli', 'config'],
511+
});
512+
fs.writeFileSync(
513+
path.join(xcodeprojPath, SPM_INJECTED_MARKER),
514+
'{ not valid json',
515+
'utf8',
516+
);
517+
expect(() => readPinnedConfigCommand(appRoot)).not.toThrow();
518+
expect(readPinnedConfigCommand(appRoot)).toBeNull();
519+
});
520+
521+
it.each([
522+
['a bare string', '"npx expo-modules-autolinking"'],
523+
['an empty array', '[]'],
524+
['a non-string member', '["npx", 7]'],
525+
['an empty-string member', '["npx", ""]'],
526+
['an object', '{"command": "npx"}'],
527+
])('returns null for a pinned value that is %s', (_label, pinned) => {
528+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
529+
injectSpmIntoExistingXcodeproj({
530+
appRoot,
531+
reactNativeRoot: rnRoot,
532+
xcodeprojPath,
533+
configCommand: ['my-cli', 'config'],
534+
});
535+
const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER);
536+
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
537+
marker.configCommand = JSON.parse(pinned);
538+
fs.writeFileSync(markerPath, JSON.stringify(marker), 'utf8');
539+
expect(readPinnedConfigCommand(appRoot)).toBeNull();
540+
});
541+
});

0 commit comments

Comments
 (0)