Skip to content

Commit efa4085

Browse files
chrfalchclaude
andcommitted
fix(spm): delete the embedded-flavor phase; hard-fail + converge on flavor 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>
1 parent 186b497 commit efa4085

7 files changed

Lines changed: 652 additions & 375 deletions

File tree

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

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -996,27 +996,37 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
996996
// Build-time flavor swap (invoked by the generated build phase). Kept early
997997
// and dependency-free — no version resolution, no network — because it runs
998998
// on EVERY Xcode build. Reads the Xcode build env for the target flavor +
999-
// products dir.
999+
// products dir. It ONLY repoints the app-local slot symlinks + corrects the
1000+
// link-step product copies — it NEVER writes the embedded `.app/Frameworks`
1001+
// copy (Xcode is that bundle's single writer).
10001002
if (action === 'swap-flavor') {
10011003
const {swapFlavorFrameworks} = require('./spm/swap-flavor');
10021004
try {
1003-
swapFlavorFrameworks({
1005+
const result = swapFlavorFrameworks({
10041006
appRoot,
10051007
configuration: process.env.CONFIGURATION,
10061008
builtProductsDir: process.env.BUILT_PRODUCTS_DIR,
10071009
platformName: process.env.PLATFORM_NAME,
10081010
isMacCatalyst: process.env.IS_MACCATALYST === 'YES',
1009-
// Embedded-copy correction (the appended "Fix SPM Embedded Flavor"
1010-
// phase runs after Xcode's implicit SPM Embed). Absent in the scheme
1011-
// pre-action, so the embedded fix is a no-op there.
1012-
targetBuildDir: process.env.TARGET_BUILD_DIR,
1013-
frameworksFolderPath: process.env.FRAMEWORKS_FOLDER_PATH,
1014-
codeSigningAllowed: process.env.CODE_SIGNING_ALLOWED,
1015-
expandedCodeSignIdentity: process.env.EXPANDED_CODE_SIGN_IDENTITY,
10161011
logger: {log},
10171012
});
1013+
// HARD-FAIL + CONVERGE. When an IN-TARGET build (BUILT_PRODUCTS_DIR set,
1014+
// i.e. hasProducts) STARTED on the wrong flavor and we just repointed it,
1015+
// this build must still fail: Xcode captured the stale embed source at
1016+
// graph-construction time, before this phase ran, and that cannot be fixed
1017+
// in-place (a second .app writer races Xcode's CodeSign). The pin is now
1018+
// correct, so the REBUILD is green. The generated build phase propagates
1019+
// this exit 1 to fail the build (at the END, after the trailing swap). No
1020+
// hard-fail in the pre-action (hasProducts false) — it stays auto-heal.
1021+
if (result.hasProducts && result.builtinsCorrected) {
1022+
const cfg = process.env.CONFIGURATION ?? '(unknown)';
1023+
logError(
1024+
`error: React Native SwiftPM frameworks were pinned to '${result.previousFlavor}' but this is a ${cfg} build. They have been switched to ${result.flavor} — build again (one-time after a configuration change). Tip: 'npx react-native spm update --flavor ${result.flavor}' switches ahead of time.`,
1025+
);
1026+
process.exitCode = 1;
1027+
}
10181028
} catch (e) {
1019-
// Non-fatal: a flavor-swap failure must not break the build.
1029+
// Non-fatal: a genuine flavor-swap failure must not break the build.
10201030
logError(`swap-flavor failed: ${e.message}`);
10211031
}
10221032
return;

packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js

Lines changed: 181 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
const {
1414
addPreActionToScheme,
15-
buildEmbeddedFixScript,
15+
buildSchemePreActionScript,
1616
buildSyncAutolinkingScript,
1717
generateXcscheme,
1818
} = require('../generate-spm-xcodeproj');
@@ -303,14 +303,20 @@ describe('buildSyncAutolinkingScript', () => {
303303
expect(script).not.toContain('[[');
304304
});
305305

306-
it('is deterministic — the build phase and scheme pre-action get the same single script', () => {
307-
// injectSpmPackages builds the phase with buildSyncAutolinkingScript(rnPath)
308-
// and the scheme pre-action with the same call; a pure, deterministic result
309-
// guarantees both embed byte-identical text.
306+
it('is deterministic (pure) — repeated calls are byte-identical', () => {
310307
expect(buildSyncAutolinkingScript(BAKED)).toBe(
311308
buildSyncAutolinkingScript(BAKED),
312309
);
313310
});
311+
312+
it('the in-target phase and the scheme pre-action are now DIFFERENT scripts', () => {
313+
// The phase carries the swap sandwich; the pre-action is sync-only. A
314+
// pre-action swap could win its race and mask a mismatch from the in-target
315+
// detector (a false green), so they intentionally diverge.
316+
expect(buildSyncAutolinkingScript(BAKED)).not.toBe(
317+
buildSchemePreActionScript(BAKED),
318+
);
319+
});
314320
});
315321

316322
// ---------------------------------------------------------------------------
@@ -412,38 +418,186 @@ describe('buildSyncAutolinkingScript watch-paths stale loop (behavioral)', () =>
412418
});
413419

414420
// ---------------------------------------------------------------------------
415-
// buildEmbeddedFixScript — the appended in-target phase that runs AFTER Xcode's
416-
// implicit SPM Embed to deterministically correct the embedded framework flavor.
421+
// The obsolete appended "Fix SPM Embedded Flavor" phase is gone entirely — RN
422+
// never writes the .app bundle (Xcode is its single writer).
417423
// ---------------------------------------------------------------------------
418-
describe('buildEmbeddedFixScript', () => {
419-
const BAKED = '../node_modules/react-native';
420-
const script = buildEmbeddedFixScript(BAKED);
424+
describe('embedded-fix phase removal', () => {
425+
it('no longer exports buildEmbeddedFixScript', () => {
426+
expect(require('../generate-spm-xcodeproj').buildEmbeddedFixScript).toBe(
427+
undefined,
428+
);
429+
});
421430

422-
it('shares the node/RN_DIR resolution preamble with the sync script', () => {
423-
expect(script).toContain('NODE_BINARY="${NODE_BINARY:-}"');
431+
it('no script references the embedded-fix phase', () => {
432+
const BAKED = '../node_modules/react-native';
433+
for (const s of [
434+
buildSyncAutolinkingScript(BAKED),
435+
buildSchemePreActionScript(BAKED),
436+
]) {
437+
expect(s).not.toContain('Fix SPM Embedded Flavor');
438+
expect(s).not.toContain('SPM embedded-flavor fix could not run');
439+
}
440+
});
441+
});
442+
443+
// ---------------------------------------------------------------------------
444+
// HARD-FAIL + CONVERGE — the in-target phase runs the swap sandwich and fails
445+
// the build ONCE at the end after correcting a mismatched start; the scheme
446+
// PRE-ACTION is sync-only (no swap → cannot mask a mismatch = false green).
447+
// ---------------------------------------------------------------------------
448+
describe('buildSchemePreActionScript (sync-only)', () => {
449+
const script = buildSchemePreActionScript('../node_modules/react-native');
450+
451+
it('does NOT dispatch or define a flavor swap', () => {
452+
expect(script).not.toContain('swap-flavor');
453+
expect(script).not.toContain('run_swap_flavor');
454+
expect(script).not.toContain('MISMATCH');
455+
});
456+
457+
it('still runs the sync dispatch (its auto-heal purpose)', () => {
424458
expect(script).toContain(
425-
"require('path').dirname(require.resolve('react-native/package.json'))",
459+
'"$NODE_BINARY" "$RN_DIR/scripts/setup-apple-spm.js" sync',
426460
);
427-
expect(script).toContain(`RN_DIR="${BAKED}"`);
461+
expect(script).toContain('if [ "$STALE" -eq 1 ]; then');
428462
});
429463

430-
it('dispatches swap-flavor directly (no npx) and is a single dispatch (no sync, no sandwich)', () => {
431-
expect(script).toContain(
432-
'"$NODE_BINARY" "$RN_DIR/scripts/setup-apple-spm.js" swap-flavor',
464+
it('parses under `sh -n`', () => {
465+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'preaction-'));
466+
const file = path.join(dir, 'pre.sh');
467+
fs.writeFileSync(file, script);
468+
try {
469+
expect(() => execFileSync('/bin/sh', ['-n', file])).not.toThrow();
470+
} finally {
471+
fs.rmSync(dir, {recursive: true, force: true});
472+
}
473+
});
474+
});
475+
476+
describe('buildSyncAutolinkingScript — swap sandwich + hard-fail', () => {
477+
const script = buildSyncAutolinkingScript('../node_modules/react-native');
478+
479+
it('runs the swap sandwich (def + leading + trailing = ≥3 run_swap_flavor uses)', () => {
480+
expect(
481+
(script.match(/run_swap_flavor/g) || []).length,
482+
).toBeGreaterThanOrEqual(3);
483+
});
484+
485+
it('the LEADING swap runs BEFORE the sync dispatch (state corrected up front)', () => {
486+
const leading = script.indexOf('run_swap_flavor\n');
487+
const sync = script.indexOf('setup-apple-spm.js" sync');
488+
expect(leading).toBeGreaterThan(-1);
489+
expect(leading).toBeLessThan(sync);
490+
});
491+
492+
it('captures the corrected-mismatch signal and defers the failure to the END', () => {
493+
expect(script).toContain('MISMATCH_PENDING');
494+
const gate = script.indexOf('if [ "$MISMATCH_PENDING" -eq 1 ]');
495+
expect(gate).toBeGreaterThan(-1);
496+
// The failure gate is AFTER the last swap (trailing) and after sync.
497+
expect(gate).toBeGreaterThan(script.lastIndexOf('run_swap_flavor'));
498+
expect(script.indexOf('setup-apple-spm.js" sync')).toBeLessThan(gate);
499+
expect(script.indexOf('exit 1', gate)).toBeGreaterThan(gate);
500+
});
501+
502+
it('parses under `sh -n`', () => {
503+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'phase-'));
504+
const file = path.join(dir, 'phase.sh');
505+
fs.writeFileSync(file, script);
506+
try {
507+
expect(() => execFileSync('/bin/sh', ['-n', file])).not.toThrow();
508+
} finally {
509+
fs.rmSync(dir, {recursive: true, force: true});
510+
}
511+
});
512+
});
513+
514+
// A real-shell behavioral check of HARD-FAIL + CONVERGE. The stub setup-apple-spm
515+
// is STATEFUL: swap-flavor reads a fake pin file, and only repoints + exits 1 on
516+
// a genuine pin-vs-CONFIGURATION mismatch; sync re-pins the fake pin to debug
517+
// (modelling generate-spm-package's default-debug links). This exercises the
518+
// leading-vs-trailing distinction the constant-exit-code stub could not.
519+
describe('sync script — shell behavior of HARD-FAIL + CONVERGE', () => {
520+
let dir, rnDir, srcroot, pinFile;
521+
522+
function setup(startFlavor) {
523+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-hardfail-'));
524+
srcroot = path.join(dir, 'app');
525+
fs.mkdirSync(srcroot, {recursive: true});
526+
fs.writeFileSync(
527+
path.join(srcroot, 'package.json'),
528+
JSON.stringify({name: 'x'}),
433529
);
434-
// Exactly one swap dispatch; NOT the sync-phase machinery.
435-
expect(script.match(/setup-apple-spm\.js" swap-flavor/g)).toHaveLength(1);
436-
expect(script).not.toContain('swap-flavor" sync');
437-
expect(script).not.toContain('run_swap_flavor');
438-
expect(script).not.toContain('"$STALE"');
530+
pinFile = path.join(dir, 'pin');
531+
fs.writeFileSync(pinFile, startFlavor); // the flavor the build STARTS on
532+
rnDir = path.join(dir, 'rn');
533+
fs.mkdirSync(path.join(rnDir, 'scripts'), {recursive: true});
534+
fs.writeFileSync(
535+
path.join(rnDir, 'scripts', 'setup-apple-spm.js'),
536+
[
537+
"const fs = require('fs');",
538+
'const pinFile = process.env.FAKE_PIN_FILE;',
539+
'const a = process.argv[2];',
540+
// sync re-pins to the add-time (debug) flavor, like linkOne does.
541+
"if (a === 'sync') { fs.writeFileSync(pinFile, 'debug'); process.exit(0); }",
542+
"if (a === 'swap-flavor') {",
543+
" const desired = process.env.CONFIGURATION === 'Release' ? 'release' : 'debug';",
544+
" const pin = fs.existsSync(pinFile) ? fs.readFileSync(pinFile, 'utf8').trim() : 'debug';",
545+
' if (pin !== desired) {',
546+
' fs.writeFileSync(pinFile, desired);',
547+
" console.log('error: corrected ' + pin + ' -> ' + desired);",
548+
' process.exit(1);',
549+
' }',
550+
' process.exit(0);',
551+
'}',
552+
'process.exit(0);',
553+
].join('\n'),
554+
);
555+
const scriptFile = path.join(dir, 'phase.sh');
556+
fs.writeFileSync(scriptFile, buildSyncAutolinkingScript(rnDir));
557+
return scriptFile;
558+
}
559+
560+
afterEach(() => fs.rmSync(dir, {recursive: true, force: true}));
561+
562+
function run(scriptFile, configuration) {
563+
try {
564+
execFileSync('/bin/sh', [scriptFile], {
565+
env: {
566+
...process.env,
567+
SRCROOT: srcroot,
568+
BUILT_PRODUCTS_DIR: path.join(dir, 'products'),
569+
NODE_BINARY: process.execPath,
570+
CONFIGURATION: configuration,
571+
FAKE_PIN_FILE: pinFile,
572+
},
573+
stdio: ['ignore', 'pipe', 'pipe'],
574+
encoding: 'utf8',
575+
});
576+
return {code: 0};
577+
} catch (e) {
578+
return {code: e.status, out: (e.stdout || '') + (e.stderr || '')};
579+
}
580+
}
581+
582+
it('SUCCEEDS (exit 0) when only the TRAILING swap repoints (sync re-pinned mid-build) — not a mismatched start', () => {
583+
// Start on release, build Release: the LEADING swap is matched (no
584+
// correction). The (stale) sync re-pins to debug, so the TRAILING swap
585+
// repoints back to release. Only the leading swap may flag a mismatch, so
586+
// this must NOT fail the build.
587+
const {code} = run(setup('release'), 'Release');
588+
expect(code).toBe(0);
439589
});
440590

441-
it('soft-fails (warns, no exit 1) so it never hard-breaks the build alone', () => {
442-
expect(script).toContain('warning: SPM embedded-flavor fix could not run');
443-
expect(script).not.toContain('exit 1');
591+
it('FAILS the build (exit 1) when the build STARTED on the wrong flavor (leading swap corrects)', () => {
592+
// Start on debug, build Release: the LEADING swap corrects debug->release and
593+
// flags it → fail once, converge, rebuild green.
594+
const {code, out} = run(setup('debug'), 'Release');
595+
expect(code).toBe(1);
596+
expect(out).toMatch(/error:/);
444597
});
445598

446-
it('is POSIX-sh clean (no bashisms)', () => {
447-
expect(script).not.toContain('[[');
599+
it('SUCCEEDS (exit 0) when the build started matched (Debug on debug)', () => {
600+
const {code} = run(setup('debug'), 'Debug');
601+
expect(code).toBe(0);
448602
});
449603
});

packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js

Lines changed: 42 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -194,18 +194,53 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => {
194194
expect(syncIdx).toBeLessThan(sourcesIdx);
195195
});
196196

197-
it('APPENDS the Fix SPM Embedded Flavor phase (last in buildPhases)', () => {
197+
it('does NOT append a Fix SPM Embedded Flavor phase (RN never writes the .app)', () => {
198198
const {text} = inject(PLAIN);
199-
expect(text).toContain('Fix SPM Embedded Flavor');
200-
// Its buildPhases-array member is the LAST entry (appended, not prepended).
199+
expect(text).not.toContain('Fix SPM Embedded Flavor');
201200
const bp = text.slice(text.indexOf('buildPhases = ('));
202201
const arr = bp.slice(0, bp.indexOf(');'));
203202
const comments = [...arr.matchAll(/\/\* ([^*]+) \*\//g)].map(m => m[1]);
204203
expect(comments[0]).toBe('Sync SPM Autolinking'); // prepended, first
205-
expect(comments[comments.length - 1]).toBe('Fix SPM Embedded Flavor');
206-
// Runs after Sources (post-embed).
207-
const fixIdx = text.indexOf('Fix SPM Embedded Flavor */,');
208-
expect(fixIdx).toBeGreaterThan(text.indexOf('Sources */,'));
204+
expect(comments).not.toContain('Fix SPM Embedded Flavor');
205+
});
206+
207+
it('MIGRATES away an existing Fix SPM Embedded Flavor phase on re-injection (object + membership)', () => {
208+
const {namespacedUUID} = require('../spm-pbxproj');
209+
const {text: first} = inject(PLAIN);
210+
const plan = planInjection(PLAIN, {});
211+
const legacyUuid = namespacedUUID(
212+
plan.rootUuid,
213+
'PBXShellScriptBuildPhase',
214+
'FixEmbeddedFlavor',
215+
);
216+
// Splice a legacy phase object + its buildPhases membership back in, as an
217+
// app injected by an older RN would have.
218+
const withObject = first.replace(
219+
'/* End PBXShellScriptBuildPhase section */',
220+
`\t\t${legacyUuid} /* Fix SPM Embedded Flavor */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\tname = "Fix SPM Embedded Flavor";\n\t\t\tshellScript = "echo legacy";\n\t\t};\n/* End PBXShellScriptBuildPhase section */`,
221+
);
222+
const withLegacy = withObject.replace(
223+
/(buildPhases = \([\s\S]*?)(\n\t\t\t\);)/,
224+
`$1\n\t\t\t\t${legacyUuid} /* Fix SPM Embedded Flavor */,$2`,
225+
);
226+
expect(withLegacy).toContain('Fix SPM Embedded Flavor');
227+
228+
const plan2 = planInjection(withLegacy, {});
229+
const {text: healed} = injectSpmIntoPbxproj(
230+
withLegacy,
231+
{
232+
rootUuid: plan2.rootUuid,
233+
targetUuid: plan2.target.uuid,
234+
configUuids: plan2.configUuids,
235+
frameworksPhaseUuid: plan2.frameworksPhaseUuid,
236+
sourcesPhaseUuid: plan2.sourcesPhaseUuid,
237+
},
238+
RN_PATH,
239+
null,
240+
);
241+
expect(healed).not.toContain('Fix SPM Embedded Flavor');
242+
expect(healed).not.toContain(legacyUuid);
243+
expect(isBalanced(healed)).toBe(true);
209244
});
210245
});
211246

@@ -385,31 +420,6 @@ describe('injectSpmIntoPbxproj — invariants', () => {
385420
expect(second).toBe(first);
386421
});
387422

388-
it('refreshes a stale Fix SPM Embedded Flavor shellScript on re-injection', () => {
389-
const first = inject(PLAIN).text;
390-
// Corrupt a substring unique to the embedded-fix phase script.
391-
const stale = first.replace(
392-
'SPM embedded-flavor fix could not run',
393-
'STALE_EMBEDDED_MARKER',
394-
);
395-
expect(stale).not.toBe(first);
396-
const plan = planInjection(stale, {});
397-
const second = injectSpmIntoPbxproj(
398-
stale,
399-
{
400-
rootUuid: plan.rootUuid,
401-
targetUuid: plan.target.uuid,
402-
configUuids: plan.configUuids,
403-
frameworksPhaseUuid: plan.frameworksPhaseUuid,
404-
},
405-
RN_PATH,
406-
null,
407-
).text;
408-
expect(second).not.toContain('STALE_EMBEDDED_MARKER');
409-
expect(second).toContain('SPM embedded-flavor fix could not run');
410-
expect(second).toBe(first);
411-
});
412-
413423
it('namespaces injected UUIDs by the host project root (collision-safe, stable)', () => {
414424
const {injectedUuids} = inject(PLAIN);
415425
// All injected UUIDs are valid 24-hex and none collide with the originals.

0 commit comments

Comments
 (0)