feat(swiftpm): let autolinking plugins declare build-time script phases - #57757
Closed
chrfalch wants to merge 1 commit into
Closed
feat(swiftpm): let autolinking plugins declare build-time script phases#57757chrfalch wants to merge 1 commit into
chrfalch wants to merge 1 commit into
Conversation
cipolleschi
approved these changes
Jul 31, 2026
cipolleschi
left a comment
Contributor
There was a problem hiding this comment.
I think this is ok for now, but I believe we should investigate Swift plugins that can run before building, instead of injecting script phases inside the pbxproject directly.
|
@cipolleschi has imported this pull request. If you are a Meta employee, you can view this in D114318236. |
Collaborator
Author
Problem with plugin is that they're running in a sandbox with very limited permissions. They can write data in the build directory, everything else is read-only. |
SwiftPM has no equivalent of CocoaPods' `script_phase`, so a framework that
needs to generate content at build time had no way to get one under SwiftPM.
The first casualty is expo-constants: nothing writes
`EXConstants.bundle/app.config`, which surfaces at runtime as "Unable to find
the embedded app config".
Add a 6th field to the SwiftPM autolinking plugin contract:
scriptPhases: [{id, name, script,
position?: 'beforeCompile' | 'end', // default 'end'
inputPaths?, outputPaths?, alwaysOutOfDate?}]
A plugin returns data; RN validates it, records it to a
`.spm-plugin-script-phases.json` sidecar (written even when empty, so removing
a plugin clears stale entries), and `spm add`/`update` emits one
PBXShellScriptBuildPhase per entry, tracked in the `.spm-injected.json` marker
by `id` so update reconciles and deinit reverts.
Design notes:
- `end` appends at the true end of `buildPhases`, which is after the JS bundle
phase — where expo-constants needs to write, since it targets
$TARGET_BUILD_DIR. Anchoring relative to the Frameworks phase would land it
before the bundle phase.
- `beforeCompile` anchors after RN's own "Sync SPM Autolinking" phase, which
must stay first because it regenerates autolinking; the anchor chains forward
so declared order survives. Position and relative order are re-derived on
every sync, so a phase moved by hand in Xcode returns to its declared slot.
- Validation is fatal, matching flavoredFrameworks rather than watchPaths: a
silently dropped phase means the generated content is never written and the
app fails at runtime with no build-time signal — the very bug being fixed.
- `id` is the ledger key and the deterministic UUID seed. Its charset allows a
scoped npm name (@expo/log-box) but excludes `:`, which separates the
`plugin:<id>` seed. `__proto__`/`constructor`/`prototype` are rejected because
a JSON ledger key of `__proto__` vanishes through JSON.stringify.
- A plugin-supplied `name` reaches pbxproj comments, which are scanned by
single-line regexes, so what lands in a comment is normalized (a name
containing `*/`, `{` or `,` otherwise produced an unopenable project and a
phase deinit could not remove). The full name still goes verbatim into the
`name` field Xcode displays. The same normalization now covers generated-source
filenames, which had the identical hole.
Also fixes, in passing, that add -> update -> deinit did not restore
project.pbxproj byte-for-byte even with no script phases: the second run's
marker forgot what the first had created, so an empty packageReferences /
packageProductDependencies field and the generated scheme were left behind. The
created-record now carries forward and scheme.created is sticky, and a created
array field is removed only when it is empty after RN's own members come out —
so a package a user added to it survives deinit.
Verified end to end by the Expo team on a real Expo app: the phase lands last,
after "Bundle React Native code and images", the build succeeds, and
app.config is written with the sdkVersion whose absence caused the original bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chrfalch
force-pushed
the
chrfalch/spm-plugin-script-phases
branch
from
August 1, 2026 07:21
d59ebbd to
63ff07b
Compare
meta-codesync Bot
pushed a commit
that referenced
this pull request
Aug 10, 2026
Summary:
**`spm add --version <ver>` pinned a value that nothing ever read back.**
The marker write has been there all along, and so has the reader — `readArtifactsVersionOverride()` in `spm/generate-spm-xcodeproj.js`, exported and unit-tested. It just had **zero production callers**. Every reference to it was a test.
`determineVersion` — the only resolver — went straight from the flag to `package.json`:
```js
let version = args.version; // --version
if (version == null) {
version = pkgJson.version; // react-native/package.json; marker never consulted
}
```
That value picks which artifact slots the project gets wired to. So:
1. `spm add --version 0.88.0-nightly-…` → wires the nightly's slots, pins the label ✅
2. `spm update` (no flag) → silently resolves `package.json`'s version instead, re-pointing the project at different slots, while the marker still claims the nightly ❌
`--version` was effectively single-use. In this monorepo `package.json` is `1000.0.0`, which has no published artifacts, so a flagless run after a pinned `add` fails outright — which is why the standing advice has been to pass `--version` on *every* invocation. That advice was working around this bug.
**Fix:** insert the pin between the two existing sources.
```
--version → pinned override → react-native/package.json
```
15 lines of logic. `spm download` and the scaffold path pick it up for free, since both consume the same resolved value. Because this is persistent state, one line is logged when the pin is the source, so a stale pin is diagnosable instead of silent; there is still no way to clear it short of `deinit`.
**Three comments were actively wrong** and are corrected here: the reader's doc block, the marker-field comment, and `findInjectedXcodeproj`'s comment all asserted that the build-time sync calls this via `readArtifactsVersionOverride`. It does not and never did — the `sync` action returns before artifacts are resolved at all. Those comments are what made the gap invisible; they misled me while investigating.
## Changelog:
[Internal] [Fixed] - SwiftPM: `spm --version` now sticks, so a later run without the flag keeps using the pinned artifact version
Pull Request resolved: #57762
Test Plan:
`yarn jest packages/react-native/scripts` → **31 suites, 684 tests**, all green.
New tests, written red first — the load-bearing one failed with `Expected: "0.80.0" / Received: "1000.0.0"`, i.e. exactly the reported bug:
- `--version` given → wins, even with a different value pinned
- no flag, pin present → the pinned version is used
- no flag, no pin → `package.json`'s version (unchanged behaviour)
- no flag, corrupt or absent marker → `package.json`'s version, no throw
- the log line fires only when the pin is the source
The three fallback cases passed *before* the fix too, which is the point — they pin today's behaviour so this change can't regress it.
## Note on landing order
Touches `setup-apple-spm.js` and `spm/generate-spm-xcodeproj.js`, which #57744, #57756 and #57757 also touch. All are cut independently from `main`; whichever lands first, I'll rebase the rest. #57756 is the closest relative — it does the same wiring for `--config-command`, which had the identical write-only-pin shape.
Reviewed By: fabriziocucci
Differential Revision: D114318107
Pulled By: cipolleschi
fbshipit-source-id: 157da5b2eaaef9adee924eaa1832b7a38b008f4c
|
@cipolleschi merged this pull request in 6ff47ef. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary:
SwiftPM has no equivalent of CocoaPods'
script_phase, so a framework that generates content at build time can't get one. The first casualty is expo-constants: nothing writesEXConstants.bundle/app.config, which shows up at runtime as "Unable to find the embedded app config".This adds a 6th field to the SwiftPM autolinking plugin contract:
The plugin returns data; RN validates it, records it to a
.spm-plugin-script-phases.jsonsidecar (written even when empty, so removing a plugin clears stale entries), andspm add/updateemits onePBXShellScriptBuildPhaseper entry — tracked in.spm-injected.jsonbyid, soupdatereconciles anddeinitreverts.Verified end to end by the Expo team
On a real Expo app, against a local cut of this branch. A
position: 'end'phase lands last, after the JS bundle phase:BUILD SUCCEEDED, andEXConstants.bundle/app.configis written withsdkVersion: 56.0.0— precisely the value whose absence caused the original bug. Red baseline confirmed first: before the declaration, the same app built with0 script phase(s), an empty sidecar, and noEXConstants.bundleat all. They also independently confirmeddeinitleaves zero residue,addis idempotent (same sha1 twice), and no phase duplicates.Their side is expo/expo#47647.
Design decisions worth a reviewer's attention
endappends at the true end ofbuildPhases, which is after the JS bundle phase — where expo-constants must write, since it targets$TARGET_BUILD_DIR. Anchoring relative to the Frameworks phase (the obvious-looking choice) lands it before the bundle phase, because real template order isSources, Frameworks, Resources, Bundle React Native code and images.beforeCompileanchors after RN's own "Sync SPM Autolinking" phase, which must stay first since it regenerates autolinking — a plugin phase ahead of it would run against stale generated content. The anchor chains forward so declared order survives. Position and relative order are re-derived every sync, so a phase dragged by hand in Xcode returns to its declared slot.flavoredFrameworksrather than the lenientwatchPaths. A silently dropped phase means the content is never written and the app fails at runtime with no build-time signal — which is the bug being fixed.idis the ledger key and the UUID seed. The charset allows a scoped npm name (@expo/log-box) but excludes:, which separates theplugin:<id>seed.__proto__/constructor/prototypeare rejected becauseplainObject['__proto__'] = vvanishes throughJSON.stringify, which would record a phase thatdeinitcould never remove.namereaches pbxproj comments, and those are scanned by single-line regexes. What lands in a comment is therefore normalized: a name containing*/,{or,otherwise produced a brace-unbalanced project Xcode couldn't open, or an orphan phasedeinitreported removing but didn't. The full name still goes verbatim into thenamefield Xcode displays. The same normalization now covers generated-source filenames, which had the identical hole.Also fixed in passing
add → update → deinitdid not restoreproject.pbxprojbyte-for-byte, even with zero script phases: the second run's marker forgot what the first had created, leaving an emptypackageReferences/packageProductDependenciesand the generated.xcschemebehind. The created-record now carries forward andscheme.createdis sticky. Two guards came with that, both tested: a created array field is removed only when it is empty after RN's own members come out (so a package a user added to it survivesdeinit), and the scheme is deleted only if it is still RN's own (so a scheme the user has taken over is left alone).Changelog:
[Internal] [Added] - SwiftPM: autolinking plugins can declare build-time script phases via
scriptPhasesTest Plan:
yarn jest packages/react-native/scripts→ 853 tests, all green. The SwiftPM suite specifically went from 462 → 637 tests.Written red first throughout. Coverage includes:
PBXShellScriptBuildPhase, correctname/shellScript/serialized paths;alwaysOutOfDateemitted as unquoted1only when setendlands last;beforeCompilelands after the sync phase and before Sources; declared order preserved for multiple phases of each position and for a mix; a changedpositionis re-seated on the next syncid; unchanged re-sync byte-identical;deinitbyte-identical with no orphan object or sectionnamematrix ({ } ( ) , ; = */ /* * /, unbalanced quote, tab, unicode, 300 chars, a name that normalizes to empty) × {balanced after add, byte-identical re-inject, clean deinit}$(VAR)round-trip through emission, refresh and deinitidacross plugins, reserved ids, scoped ids accepted,:rejectedadd → update → deinitbyte-identity with zero phases and with twoNot covered by unit tests, deliberately: that
endruns after the JS bundle phase. Theplain-app.pbxprojfixture has no bundle phase, so it is unassertable here — this is disclosed in a comment at the test rather than papered over, and is exactly what the Expo verification above establishes.Known limitation, not addressed here: against a project Xcode has previously saved,
add → deinit → addis structurally identical (same UUIDs, same reference counts) but not byte-identical — Xcode writes multi-line dicts in sorted order, the injector writes single-line dicts in insertion order, which shows up as formatting churn in a committedproject.pbxproj. Pre-existing for every object the injector emits, not specific to script phases, and filed separately.Note on landing order
This touches
generate-spm-xcodeproj.jsandspm-pbxproj.js, which #57744 and #57756 also touch — including the same marker-write block. All three are cut independently frommain; whichever lands first, I'll rebase the others. Happy to restack in whatever order is easiest to review.