Skip to content

SPM: let autolinking plugins declare build-time script phases - #57757

Open
chrfalch wants to merge 1 commit into
mainfrom
chrfalch/spm-plugin-script-phases
Open

SPM: let autolinking plugins declare build-time script phases#57757
chrfalch wants to merge 1 commit into
mainfrom
chrfalch/spm-plugin-script-phases

Conversation

@chrfalch

Copy link
Copy Markdown
Collaborator

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 writes EXConstants.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:

scriptPhases: [{
  id: 'expo-constants.app-config',   // stable: ledger key + deterministic UUID seed
  name: 'Generate Expo app.config',  // Xcode's display name
  script: '…',
  position: 'end',                   // 'end' (default) | 'beforeCompile'
  inputPaths: ['$(SRCROOT)/../app.json'],
  outputPaths: ['$(TARGET_BUILD_DIR)/…/EXConstants.bundle/app.config'],
  alwaysOutOfDate: true,
}]

The 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 .spm-injected.json by id, so update reconciles and deinit reverts.

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:

5. Resources
6. Bundle React Native code and images
7. [Expo Dev Launcher] Strip Local Network Keys for Release
8. Generate Expo app.config          ← last

BUILD SUCCEEDED, and EXConstants.bundle/app.config is written with sdkVersion: 56.0.0 — precisely the value whose absence caused the original bug. Red baseline confirmed first: before the declaration, the same app built with 0 script phase(s), an empty sidecar, and no EXConstants.bundle at all. They also independently confirmed deinit leaves zero residue, add is idempotent (same sha1 twice), and no phase duplicates.

Their side is expo/expo#47647.

Design decisions worth a reviewer's attention

  • end appends at the true end of buildPhases, 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 is Sources, Frameworks, Resources, Bundle React Native code and images.
  • beforeCompile anchors 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.
  • Validation is fatal, matching flavoredFrameworks rather than the lenient watchPaths. 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.
  • id is the ledger key and the UUID seed. The charset allows a scoped npm name (@expo/log-box) but excludes :, which separates the plugin:<id> seed. __proto__/constructor/prototype are rejected because plainObject['__proto__'] = v vanishes through JSON.stringify, which would record a phase that deinit could never remove.
  • A plugin-supplied name reaches 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 phase deinit reported removing but didn't. 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 fixed in passing

add → update → deinit did not restore project.pbxproj byte-for-byte, even with zero script phases: the second run's marker forgot what the first had created, leaving an empty packageReferences / packageProductDependencies and the generated .xcscheme behind. The created-record now carries forward and scheme.created is 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 survives deinit), 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 scriptPhases

Test Plan:

yarn jest packages/react-native/scripts853 tests, all green. The SwiftPM suite specifically went from 462 → 637 tests.

Written red first throughout. Coverage includes:

  • one declared phase → exactly one PBXShellScriptBuildPhase, correct name/shellScript/serialized paths; alwaysOutOfDate emitted as unquoted 1 only when set
  • end lands last; beforeCompile lands after the sync phase and before Sources; declared order preserved for multiple phases of each position and for a mix; a changed position is re-seated on the next sync
  • add / update-in-place / remove keyed on id; unchanged re-sync byte-identical; deinit byte-identical with no orphan object or section
  • a 17-row hostile-name matrix ({ } ( ) , ; = */ /* * /, unbalanced quote, tab, unicode, 300 chars, a name that normalizes to empty) × {balanced after add, byte-identical re-inject, clean deinit}
  • scripts containing quotes, backslashes, newlines and $(VAR) round-trip through emission, refresh and deinit
  • contract validation: 16 malformed-entry cases, duplicate id across plugins, reserved ids, scoped ids accepted, : rejected
  • add → update → deinit byte-identity with zero phases and with two

Not covered by unit tests, deliberately: that end runs after the JS bundle phase. The plain-app.pbxproj fixture 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 → add is 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 committed project.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.js and spm-pbxproj.js, which #57744 and #57756 also touch — including the same marker-write block. All three are cut independently from main; whichever lands first, I'll rebase the others. Happy to restack in whatever order is easiest to review.

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>
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jul 29, 2026
@facebook-github-tools facebook-github-tools Bot added p: Expo Partner: Expo Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. labels Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. p: Expo Partner: Expo Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant