diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js index 47c1326a2cf6..ec6a44d4f8a2 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -151,6 +151,56 @@ describe('scheme pre-action', () => { updated, ); }); + + // Xcode always runs a scheme pre-action's scriptText under the shell named + // by shellToInvoke (default /bin/sh), independent of a + // PBXShellScriptBuildPhase's own shellPath — the sync script needs bash + // (`set -o pipefail`), so pin it here too. + it('pins shellToInvoke to bash on a freshly generated scheme', () => { + const result = generateXcscheme('MyApp', 'TARGET_UUID', 'MyApp', 'SCRIPT'); + expect(result).toContain('shellToInvoke = "/bin/bash"'); + }); + + it('adds shellToInvoke to a scheme injected before this attribute existed', () => { + // Simulates a scheme written by an older RN version — no shellToInvoke. + const legacy = generateXcscheme( + 'MyApp', + 'TARGET_UUID', + 'MyApp', + 'SCRIPT', + ).replace('\n shellToInvoke = "/bin/bash">', '>'); + expect(legacy).not.toContain('shellToInvoke'); + const updated = addPreActionToScheme(legacy, 'TARGET_UUID', 'SCRIPT'); + expect(updated).toContain('shellToInvoke = "/bin/bash"'); + expect(addPreActionToScheme(updated, 'TARGET_UUID', 'SCRIPT')).toBe( + updated, + ); + }); + + it('refreshes shellToInvoke when it appears before scriptText', () => { + const reordered = generateXcscheme( + 'MyApp', + 'TARGET_UUID', + 'MyApp', + 'SCRIPT', + ).replace( + 'scriptText = "SCRIPT"\n shellToInvoke = "/bin/bash">', + 'shellToInvoke = "/bin/bash"\n scriptText = "SCRIPT">', + ); + const updated = addPreActionToScheme(reordered, 'TARGET_UUID', 'SCRIPT'); + expect(updated.match(/shellToInvoke/g)).toHaveLength(1); + }); + + it('refreshes shellToInvoke with no spaces around the equals sign', () => { + const unspaced = generateXcscheme( + 'MyApp', + 'TARGET_UUID', + 'MyApp', + 'SCRIPT', + ).replace('shellToInvoke = "/bin/bash"', 'shellToInvoke="/bin/bash"'); + const updated = addPreActionToScheme(unspaced, 'TARGET_UUID', 'SCRIPT'); + expect(updated.match(/shellToInvoke/g)).toHaveLength(1); + }); }); describe('sync scripts', () => { diff --git a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js index e9786be37058..3d4093c9334c 100644 --- a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js @@ -297,6 +297,35 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { expect(syncIdx).toBeLessThan(sourcesIdx); }); + it('runs every injected shell-script build phase under bash, not /bin/sh', () => { + // Their bodies start with `set -euo pipefail`, which a non-bash /bin/sh + // (e.g. dash) rejects at runtime. + const {text} = inject(PLAIN); + const phaseCount = text.match(/isa = PBXShellScriptBuildPhase;/g)?.length; + const bashCount = text.match(/shellPath = \/bin\/bash;/g)?.length; + expect(phaseCount).toBeGreaterThan(0); + expect(bashCount).toBe(phaseCount); + }); + + it('upgrades an already-injected phase from /bin/sh to bash on re-run', () => { + // A project injected before this fix recorded `shellPath = /bin/sh;` on + // the Sync SPM Autolinking phase. Re-running inject (e.g. `spm update`) + // must refresh it in place, not just apply bash to newly-created phases. + const {text: firstText} = inject(PLAIN); + const downgraded = firstText.replace( + /shellPath = \/bin\/bash;/g, + 'shellPath = /bin/sh;', + ); + const {text: secondText} = inject(downgraded); + expect(secondText).not.toContain('shellPath = /bin/sh;'); + const phaseCount = secondText.match( + /isa = PBXShellScriptBuildPhase;/g, + )?.length; + expect(secondText.match(/shellPath = \/bin\/bash;/g)?.length).toBe( + phaseCount, + ); + }); + it.each([ [ '"$(inherited)"', diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 0e86cff7fa53..e0a43692fc8b 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -319,7 +319,11 @@ function shellScriptPhase( outputFileListPaths: empty, outputPaths: pathList(options.outputPaths), runOnlyForDeploymentPostprocessing: '0', - shellPath: '/bin/sh', + // React Native's own bodies here start with `set -euo pipefail`, which + // /bin/sh rejects on a host where it isn't bash (e.g. dash's `set -o + // pipefail: Illegal option`). Run every phase under bash, including + // plugin-contributed ones, so a plugin's body can rely on it too. + shellPath: '/bin/bash', shellScript: quoteIfNeeded(script), }, }; @@ -899,7 +903,8 @@ function generateXcscheme( ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction"> + scriptText = "${escapedSync}" + shellToInvoke = "/bin/bash"> ` can't appear unescaped inside either attribute + // value, so it reliably closes the ActionContent open tag. Search the + // whole open tag (not just after scriptText) and allow any spacing + // around `=`, since attribute order and formatting aren't guaranteed. + const contentOpenIdx = xmlWithScript.lastIndexOf( + '', stIdx); + const openTag = xmlWithScript.slice(contentOpenIdx, contentCloseIdx); + const stiMatch = openTag.match(/shellToInvoke\s*=\s*"/); + if (stiMatch != null) { + const stiValueStart = + contentOpenIdx + stiMatch.index + stiMatch[0].length; + const stiValueEnd = xmlWithScript.indexOf('"', stiValueStart); + xmlWithScript = + xmlWithScript.slice(0, stiValueStart) + + '/bin/bash' + + xmlWithScript.slice(stiValueEnd); + } else { + xmlWithScript = + xmlWithScript.slice(0, contentCloseIdx) + + '\n shellToInvoke = "/bin/bash"' + + xmlWithScript.slice(contentCloseIdx); + } + return xmlWithScript; } const refMatch = xml.match( new RegExp( @@ -1907,7 +1949,8 @@ function addPreActionToScheme( ` ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">\n` + ` \n` + + ` scriptText = "${escapeXmlAttribute(syncScript)}"\n` + + ` shellToInvoke = "/bin/bash">\n` + ` \n` + ` ${cleanRef}\n` + ` \n` +