From 73921c35f3b69f8701e9857571d0b077a1d67a77 Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Mon, 7 Sep 2026 00:28:19 +0500 Subject: [PATCH 1/2] Run SPM-generated shell scripts under bash, not /bin/sh --- .../__tests__/generate-spm-xcodeproj-test.js | 25 ++++++ .../__tests__/inject-spm-xcodeproj-test.js | 29 +++++++ .../scripts/spm/generate-spm-xcodeproj.js | 80 ++++++++++++++----- 3 files changed, 112 insertions(+), 22 deletions(-) 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 47c1326a2cf..1f44bcbbabd 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,31 @@ 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, + ); + }); }); 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 e9786be3705..3d4093c9334 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 0e86cff7fa5..9369fecac4a 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. + const contentCloseIdx = xmlWithScript.indexOf('>', stIdx); + const shellToInvokeMarker = 'shellToInvoke = "'; + const stiIdx = xmlWithScript.indexOf(shellToInvokeMarker, valueStart); + if (stiIdx >= 0 && stiIdx < contentCloseIdx) { + const stiValueStart = stiIdx + shellToInvokeMarker.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 +1942,8 @@ function addPreActionToScheme( ` ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">\n` + ` \n` + + ` scriptText = "${escapeXmlAttribute(syncScript)}"\n` + + ` shellToInvoke = "/bin/bash">\n` + ` \n` + ` ${cleanRef}\n` + ` \n` + From ed15f831b6cd609dc34b980a6857db2ed6d71003 Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Tue, 8 Sep 2026 15:49:36 +0500 Subject: [PATCH 2/2] fix shellToInvoke refresh missing reordered or unspaced attributes --- .../__tests__/generate-spm-xcodeproj-test.js | 25 +++++++++++++++++++ .../scripts/spm/generate-spm-xcodeproj.js | 17 +++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) 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 1f44bcbbabd..ec6a44d4f8a 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 @@ -176,6 +176,31 @@ describe('scheme pre-action', () => { 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/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 9369fecac4a..e0a43692fc8 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -1898,12 +1898,19 @@ function addPreActionToScheme( // attribute names (default /bin/sh), independent of a // PBXShellScriptBuildPhase's own shellPath (see shellScriptPhase) — pin // it to bash too. `>` can't appear unescaped inside either attribute - // value, so it reliably closes the ActionContent open tag. + // 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 shellToInvokeMarker = 'shellToInvoke = "'; - const stiIdx = xmlWithScript.indexOf(shellToInvokeMarker, valueStart); - if (stiIdx >= 0 && stiIdx < contentCloseIdx) { - const stiValueStart = stiIdx + shellToInvokeMarker.length; + 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) +