Skip to content

Commit a920e5d

Browse files
chrfalchmeta-codesync[bot]
authored andcommitted
Fail SwiftPM sync when plugin frameworks and the Xcode project disagree (#58827)
Summary: An autolinking plugin, such as Expo, can provide precompiled frameworks. The build-time `sync` reads the list of these frameworks from the plugin. `npx react-native spm add` and `spm update` write a second list: the frameworks that the Xcode project links. When the two lists differ, the build fails at the link step with "no such file … plugins/<pkg>.xcframework" or "no such module". That error does not say what is wrong or what to run. This happens, for example, when a module is precompiled after the last `spm update`, or when a teammate commits a project that links a module this machine does not provide. This PR makes `sync` compare the two lists before the build continues. For each framework that differs, it prints an Xcode `error:` line that names the framework and what to do, and it exits with code 2. Existing projects already stop the build on exit code 2. How it works: - `spm add` and `spm update` record the linked frameworks in `.spm-injected.json`, next to the project. The check reads them there. For a marker from an older version, the check reads the embed phase of `project.pbxproj`, as before. - `.spm-injected.json` is now a sync input. A pull that changes what the project links starts a sync on the next build. `project.pbxproj` is not a sync input, because Xcode rewrites it on many IDE edits. - On a mismatch, `sync` saves the error lines in `build/generated/autolinking/.spm-plugin-mismatch` and refreshes its stamp. Later builds print the saved errors and stop without running the full sync again. When any sync input changes, the next build runs the sync and the check again. When the check passes, `sync` deletes the file. - The team must precompile the same set of frameworks on every machine, because the committed project is the contract. So the messages say: - Project links a framework that no plugin provides: precompile it, then run `npx react-native spm sync`. Running `spm update` instead removes it from the committed project for the whole team. - A plugin provides a framework that the project does not link: run `npx react-native spm update` to add it for the whole team. - The help text of `npx react-native spm` now lists `sync`, because the error tells users to run it. - `sync` still never writes `project.pbxproj`. Only `spm add` and `spm update` change what the project links. - React Native's own frameworks are not compared. - The check uses Xcode's `PROJECT_FILE_PATH` to find the project being built. This PR is independent of #58828. They can land in either order. Both change the generated sync check, so the second one to land needs a rebase. ## Changelog: [IOS] [ADDED] - SwiftPM: the build stops with a clear error when precompiled plugin frameworks and the Xcode project disagree, and the error says what to run. Pull Request resolved: #58827 Test Plan: ``` yarn jest --no-cache -i packages/react-native/scripts/spm/__tests__/ ``` Result: 22 suites and 1046 tests pass. The new tests failed before the change. One exception: the test that a newer `project.pbxproj` alone does not start a sync passed at once, because it guards against a regression. - `plugin-framework-mismatch-test.js` uses a real injected project. It runs each case twice: with the list recorded in the marker, and with an older marker that has no list. It covers a framework that is missing in either list, a renamed framework, matching lists, a name with a space, a project that is not injected or has no embed phase, a missing manifest, and two injected projects. It also checks that running `spm add` or `spm update` twice gives the same `project.pbxproj`, and the warning when an older marker and a missing manifest make the check skip one direction. - `sync-spm-autolinking-test.js` checks that a mismatch saves the errors and refreshes the stamp, that a passing check deletes the saved errors, the recovery after a precompile, and the exit codes of the script's direct entry point. - `generate-spm-xcodeproj-test.js` runs the generated sync check under `/bin/bash`. It checks that a later build prints the saved errors without a sync, that a changed sync input runs the sync again, that a newer `.spm-injected.json` starts a sync, and that a newer `project.pbxproj` alone does not. Not tested: - The exit-code-2 mapping in `setup-apple-spm.js`. `main()` has no test seam. The change is one `instanceof` check. - A full Xcode build with a precompiled plugin. The order (the sync phase runs before the link step) comes from the code and from `spm-scripts.md`. Reviewed By: cortinico Differential Revision: D125208952 Pulled By: cipolleschi fbshipit-source-id: c7727e0e77932c5260237183bbd14ae7195819e3
1 parent 7624c9c commit a920e5d

10 files changed

Lines changed: 1035 additions & 65 deletions

File tree

‎packages/community-cli-plugin/src/commands/spm.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ const spmCommand: Command = {
2525
name: 'spm [action]',
2626
description:
2727
'Set up or maintain Swift Package Manager support for the iOS/macOS app. ' +
28-
'Actions: add, update, deinit, scaffold. With no action: add (or update ' +
29-
'if SPM is already set up).',
28+
'Actions: add, update, deinit, scaffold, sync. With no action: add (or ' +
29+
'update if SPM is already set up).',
3030
options: [
3131
{
3232
name: '--version <string>',

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@
3434
* .spm-injected.json) and drop the marker.
3535
* scaffold Generate Package.swift for community deps that
3636
* lack SPM support.
37-
* sync / codegen / download Advanced/internal: `sync` is invoked by the
38-
* generated Xcode build phase; `codegen` and
39-
* `download` run a single pipeline step.
37+
* sync Re-run the build-time autolinking sync and its
38+
* checks. The generated Xcode build phase runs
39+
* it; run it by hand after precompiling a module.
40+
* codegen / download Advanced/internal: run a single pipeline step.
4041
*
4142
* Zero-arg `npx react-native spm` auto-detects: a freshly-scaffolded CocoaPods
4243
* project (clean tree, stock Podfile) → `add --deintegrate`; an injected
@@ -93,6 +94,7 @@ const {
9394
const {main: generatePackage} = require('./spm/generate-spm-package');
9495
const {findSourcePath} = require('./spm/generate-spm-package');
9596
const {
97+
PluginFrameworkMismatchError,
9698
SPM_INJECTED_MARKER,
9799
cleanupDanglingJavaScriptCoreRef,
98100
cleanupLeftoverPodsGroup,
@@ -1200,8 +1202,11 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
12001202
iosDeploymentTarget,
12011203
]);
12021204
} catch (e) {
1203-
if (e instanceof MissingManifestError) {
1204-
// The per-dep `error:` lines were already printed by the autolinker.
1205+
if (
1206+
e instanceof MissingManifestError ||
1207+
e instanceof PluginFrameworkMismatchError
1208+
) {
1209+
// The `error:` lines were already printed.
12051210
// Exit 2 (distinct from generic failure) so the Xcode build phase can
12061211
// turn this into a hard build error while staying lenient on transient
12071212
// sync failures.

‎packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,9 @@ The declarations are recorded to
131131
immutable app-local slots as React Native, and added to Xcode's exact linker and
132132
embed settings. They are not emitted as SwiftPM product dependencies. Adding or
133133
removing one requires `spm update`; the build-time `spm sync` intentionally does
134-
not mutate runtime framework settings.
134+
not mutate runtime framework settings. When the frameworks that plugins provide
135+
and the frameworks that the project links disagree, sync fails the build. An
136+
`error:` line names each framework and the command to run.
135137

136138
### `watchPaths` — plugin staleness inputs
137139

‎packages/react-native/scripts/spm/__docs__/spm-scripts.md‎

Lines changed: 41 additions & 23 deletions
Large diffs are not rendered by default.

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

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const {
2121
readScriptPhasesManifest,
2222
} = require('../generate-spm-xcodeproj');
2323
const {DOMParser} = require('@xmldom/xmldom');
24-
const {execFileSync} = require('node:child_process');
24+
const {execFileSync, spawnSync} = require('node:child_process');
2525
const fs = require('node:fs');
2626
const os = require('node:os');
2727
const path = require('node:path');
@@ -369,6 +369,181 @@ describe('sync scripts', () => {
369369
}
370370
});
371371

372+
describe('stale inputs and a recorded plugin framework mismatch', () => {
373+
const MISMATCH =
374+
'error: The Xcode project links ExpoCore.framework, but no autolinking plugin provides it on this machine.';
375+
376+
let root;
377+
let srcRoot;
378+
let xcodeprojPath;
379+
let generatedDir;
380+
let fakeNode;
381+
let rnDir;
382+
let syncLog;
383+
384+
// Does to the generated dir what a real sync does: on a mismatch it prints
385+
// the errors, records them, refreshes the stamp, and exits 2.
386+
const FAKE_NODE = `#!/bin/bash
387+
if [ "$1" = "--print" ]; then
388+
echo "$FAKE_RN_DIR"
389+
exit 0
390+
fi
391+
echo sync >> "$FAKE_SYNC_LOG"
392+
DIR="$SRCROOT/build/generated/autolinking"
393+
date > "$DIR/.spm-sync-stamp"
394+
if [ -n "$FAKE_MISMATCH" ]; then
395+
echo "$FAKE_MISMATCH" >&2
396+
echo "$FAKE_MISMATCH" > "$DIR/.spm-plugin-mismatch"
397+
exit 2
398+
fi
399+
rm -f "$DIR/.spm-plugin-mismatch"
400+
`;
401+
402+
const setTime = (file, secondsFromNow) => {
403+
const time = Date.now() / 1000 + secondsFromNow;
404+
fs.utimesSync(file, time, time);
405+
};
406+
407+
beforeEach(() => {
408+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-mismatch-'));
409+
srcRoot = path.join(root, 'app');
410+
xcodeprojPath = path.join(srcRoot, 'MyApp.xcodeproj');
411+
generatedDir = path.join(srcRoot, 'build', 'generated', 'autolinking');
412+
rnDir = path.join(root, 'react-native');
413+
fs.mkdirSync(xcodeprojPath, {recursive: true});
414+
fs.mkdirSync(generatedDir, {recursive: true});
415+
fs.mkdirSync(path.join(rnDir, 'scripts'), {recursive: true});
416+
fs.writeFileSync(path.join(rnDir, 'scripts', 'setup-apple-spm.js'), '');
417+
fs.writeFileSync(path.join(srcRoot, 'package.json'), '{}\n');
418+
fs.writeFileSync(path.join(xcodeprojPath, '.spm-injected.json'), '{}\n');
419+
fs.writeFileSync(
420+
path.join(generatedDir, '.spm-plugin-flavored-frameworks.json'),
421+
'[]\n',
422+
);
423+
for (const file of [
424+
path.join(srcRoot, 'package.json'),
425+
path.join(xcodeprojPath, '.spm-injected.json'),
426+
path.join(generatedDir, '.spm-plugin-flavored-frameworks.json'),
427+
]) {
428+
setTime(file, -100);
429+
}
430+
fakeNode = path.join(root, 'node');
431+
fs.writeFileSync(fakeNode, FAKE_NODE, {mode: 0o755});
432+
syncLog = path.join(root, 'syncs.log');
433+
});
434+
435+
afterEach(() => {
436+
fs.rmSync(root, {recursive: true, force: true});
437+
});
438+
439+
function build({mismatch}) {
440+
fs.writeFileSync(syncLog, '');
441+
const result = spawnSync('/bin/bash', ['-c', script], {
442+
encoding: 'utf8',
443+
env: {
444+
PATH: process.env.PATH,
445+
SRCROOT: srcRoot,
446+
PROJECT_FILE_PATH: xcodeprojPath,
447+
NODE_BINARY: fakeNode,
448+
FAKE_RN_DIR: rnDir,
449+
FAKE_SYNC_LOG: syncLog,
450+
FAKE_MISMATCH: mismatch ? MISMATCH : '',
451+
},
452+
});
453+
const output = result.stdout + result.stderr;
454+
return {
455+
status: result.status,
456+
errors: output.split(MISMATCH).length - 1,
457+
syncs: fs.readFileSync(syncLog, 'utf8').split('sync').length - 1,
458+
};
459+
}
460+
461+
function writeStamp(secondsFromNow) {
462+
const stamp = path.join(generatedDir, '.spm-sync-stamp');
463+
fs.writeFileSync(stamp, 'earlier sync\n');
464+
setTime(stamp, secondsFromNow);
465+
}
466+
467+
it('syncs when the marker is newer than the stamp', () => {
468+
writeStamp(-50);
469+
setTime(path.join(xcodeprojPath, '.spm-injected.json'), -10);
470+
expect(build({mismatch: false})).toEqual({
471+
status: 0,
472+
errors: 0,
473+
syncs: 1,
474+
});
475+
});
476+
477+
it('does not sync when only project.pbxproj is newer than the stamp', () => {
478+
writeStamp(-50);
479+
const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
480+
fs.writeFileSync(pbxprojPath, '// !$*UTF8*$!\n');
481+
setTime(pbxprojPath, -10);
482+
expect(build({mismatch: false})).toEqual({
483+
status: 0,
484+
errors: 0,
485+
syncs: 0,
486+
});
487+
});
488+
489+
it('passes the next build after the recovery sync clears the mismatch', () => {
490+
build({mismatch: true});
491+
// `npx react-native spm sync`, run after precompiling the framework.
492+
spawnSync(fakeNode, ['setup-apple-spm.js', 'sync'], {
493+
env: {SRCROOT: srcRoot, FAKE_SYNC_LOG: syncLog, FAKE_MISMATCH: ''},
494+
});
495+
expect(build({mismatch: false})).toEqual({
496+
status: 0,
497+
errors: 0,
498+
syncs: 0,
499+
});
500+
});
501+
502+
it('fails the first build and prints the errors once', () => {
503+
expect(build({mismatch: true})).toEqual({
504+
status: 1,
505+
errors: 1,
506+
syncs: 1,
507+
});
508+
});
509+
510+
it('fails later builds with the recorded errors without re-running sync', () => {
511+
build({mismatch: true});
512+
expect(build({mismatch: true})).toEqual({
513+
status: 1,
514+
errors: 1,
515+
syncs: 0,
516+
});
517+
});
518+
519+
it.each([
520+
[
521+
'the marker changes',
522+
() => path.join(xcodeprojPath, '.spm-injected.json'),
523+
],
524+
[
525+
'spm update rewrites the plugin manifest',
526+
() => path.join(generatedDir, '.spm-plugin-flavored-frameworks.json'),
527+
],
528+
['a dependency input changes', () => path.join(srcRoot, 'package.json')],
529+
])('re-runs sync when %s', (_, changedFile) => {
530+
build({mismatch: true});
531+
setTime(path.join(generatedDir, '.spm-sync-stamp'), -50);
532+
setTime(path.join(generatedDir, '.spm-plugin-mismatch'), -50);
533+
setTime(changedFile(), -10);
534+
expect(build({mismatch: false})).toEqual({
535+
status: 0,
536+
errors: 0,
537+
syncs: 1,
538+
});
539+
expect(build({mismatch: false})).toEqual({
540+
status: 0,
541+
errors: 0,
542+
syncs: 0,
543+
});
544+
});
545+
});
546+
372547
it('is deterministic, shared with the pre-action, and valid POSIX shell', () => {
373548
expect(buildSyncAutolinkingScript(baked)).toBe(script);
374549
expect(buildSchemePreActionScript(baked)).toBe(script);

0 commit comments

Comments
 (0)