Skip to content

Commit 634f490

Browse files
antonisclaude
andcommitted
fix(core): Fix sourcemap upload when withSentry is used programmatically
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5bf96de commit 634f490

3 files changed

Lines changed: 305 additions & 6 deletions

File tree

packages/core/plugin/src/withSentry.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,29 @@ interface PluginProps {
2222
experimental_android?: SentryAndroidGradlePluginOptions;
2323
}
2424

25+
/**
26+
* Store build-time properties in config._internal so they're discoverable
27+
* by the sourcemap upload script via `expo config --json`, even when
28+
* withSentry is used programmatically in app.config.ts.
29+
*
30+
* We use _internal instead of extra because _internal is stripped from the
31+
* public config (app manifest) and not shipped in the production app, while
32+
* extra would leak org/project metadata into the app binary.
33+
*/
34+
function storeBuildPropertiesInConfig(config: ExpoConfig, props: PluginProps | void): void {
35+
if (props?.organization || props?.project) {
36+
// ExpoConfig types don't include _internal, but it's a standard Expo field
37+
// used by config plugins infrastructure (e.g. pluginHistory).
38+
const configWithInternal = config as ExpoConfig & { _internal?: Record<string, unknown> };
39+
configWithInternal._internal = configWithInternal._internal || {};
40+
configWithInternal._internal.sentryBuildProperties = {
41+
organization: props?.organization,
42+
project: props?.project,
43+
url: props?.url || 'https://sentry.io/',
44+
};
45+
}
46+
}
47+
2548
const withSentryPlugin: ConfigPlugin<PluginProps | void> = (config, props) => {
2649
const sentryProperties = getSentryProperties(props);
2750

@@ -30,6 +53,8 @@ const withSentryPlugin: ConfigPlugin<PluginProps | void> = (config, props) => {
3053
delete props.authToken;
3154
}
3255

56+
storeBuildPropertiesInConfig(config, props);
57+
3358
let cfg = config;
3459
const pluginOptions = props?.options ? { ...props.options } : {};
3560
// oxlint-disable-next-line typescript-eslint(no-unsafe-member-access)

packages/core/scripts/expo-upload-sourcemaps.js

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,67 @@ function getSentryPluginPropertiesFromExpoConfig() {
3434
return pluginName === '@sentry/react-native/expo';
3535
});
3636

37-
if (!sentryPlugin) {
38-
return null;
37+
if (sentryPlugin) {
38+
const [, pluginConfig] = sentryPlugin;
39+
return pluginConfig;
40+
}
41+
42+
// When withSentry is used programmatically in app.config.ts, the plugin
43+
// doesn't appear in the plugins array. Check config._internal where the
44+
// plugin stashes build-time properties as a fallback.
45+
if (config._internal?.sentryBuildProperties) {
46+
return config._internal.sentryBuildProperties;
3947
}
40-
const [, pluginConfig] = sentryPlugin;
41-
return pluginConfig;
48+
49+
return null;
4250
} catch (error) {
4351
console.error('Error fetching expo config:', error);
4452
return null;
4553
}
4654
}
4755

56+
function getSentryPropertiesFromFile() {
57+
const candidates = [
58+
path.join(projectRoot, 'android', 'sentry.properties'),
59+
path.join(projectRoot, 'ios', 'sentry.properties'),
60+
];
61+
for (const candidate of candidates) {
62+
if (!fs.existsSync(candidate)) {
63+
continue;
64+
}
65+
try {
66+
const content = fs.readFileSync(candidate, 'utf8');
67+
const props = {};
68+
for (const line of content.split('\n')) {
69+
const trimmed = line.trim();
70+
if (!trimmed || trimmed.startsWith('#')) {
71+
continue;
72+
}
73+
const eqIndex = trimmed.indexOf('=');
74+
if (eqIndex === -1) {
75+
continue;
76+
}
77+
const key = trimmed.substring(0, eqIndex).trim();
78+
const value = trimmed.substring(eqIndex + 1).trim();
79+
if (key === 'defaults.org') {
80+
props.organization = value;
81+
} else if (key === 'defaults.project') {
82+
props.project = value;
83+
} else if (key === 'defaults.url') {
84+
props.url = value;
85+
}
86+
}
87+
if (props.organization || props.project) {
88+
console.log(`Found sentry properties in ${candidate}`);
89+
return props;
90+
}
91+
} catch (_e) {
92+
// continue to next candidate
93+
}
94+
}
95+
return null;
96+
}
97+
4898
function readAndPrintJSONFile(filePath) {
4999
if (!fs.existsSync(filePath)) {
50100
throw new Error(`The file "${filePath}" does not exist.`);
@@ -144,9 +194,16 @@ const sentryCliBin = getEnvVar(SENTRY_CLI_EXECUTABLE) || require.resolve('@sentr
144194

145195
if (!sentryOrg || !sentryProject || !sentryUrl) {
146196
console.log('🐕 Fetching from expo config...');
147-
const pluginConfig = getSentryPluginPropertiesFromExpoConfig();
197+
let pluginConfig = getSentryPluginPropertiesFromExpoConfig();
198+
if (!pluginConfig) {
199+
console.log('Could not fetch from expo config, trying sentry.properties files...');
200+
pluginConfig = getSentryPropertiesFromFile();
201+
}
148202
if (!pluginConfig) {
149-
console.error("Could not fetch '@sentry/react-native' plugin properties from expo config.");
203+
console.error(
204+
"Could not resolve Sentry configuration. Set SENTRY_ORG, SENTRY_PROJECT, and SENTRY_URL environment variables, " +
205+
"or ensure '@sentry/react-native/expo' is in your plugins array in app.json/app.config.ts."
206+
);
150207
process.exit(1);
151208
}
152209

packages/core/test/scripts/expo-upload-sourcemaps.test.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,223 @@ process.exit(exitCode);
294294
});
295295
});
296296

297+
describe('sentry.properties fallback', () => {
298+
let mockNpxScript: string;
299+
let mockBinDir: string;
300+
301+
beforeEach(() => {
302+
// Create a mock npx that makes `expo config --json` fail fast
303+
// so the script falls through to the sentry.properties fallback
304+
mockBinDir = path.join(tempDir, 'mock-bin');
305+
fs.mkdirSync(mockBinDir, { recursive: true });
306+
mockNpxScript = path.join(mockBinDir, 'npx');
307+
fs.writeFileSync(mockNpxScript, '#!/usr/bin/env node\nprocess.exit(1);\n');
308+
fs.chmodSync(mockNpxScript, '755');
309+
});
310+
311+
const createSentryProperties = (dir: string, content: string) => {
312+
fs.mkdirSync(dir, { recursive: true });
313+
fs.writeFileSync(path.join(dir, 'sentry.properties'), content);
314+
};
315+
316+
const runScriptWithCwd = (
317+
cwd: string,
318+
env: Record<string, string | undefined> = {},
319+
): { stdout: string; stderr: string; exitCode: number } => {
320+
const defaultEnv = {
321+
SENTRY_AUTH_TOKEN: 'test-token',
322+
SENTRY_CLI_EXECUTABLE: mockSentryCliScript,
323+
// Put mock npx first in PATH so expo config fails fast
324+
PATH: `${mockBinDir}:${process.env.PATH}`,
325+
};
326+
327+
const result = spawnSync(process.execPath, [EXPO_UPLOAD_SCRIPT, outputDir], {
328+
cwd,
329+
env: { ...process.env, ...defaultEnv, ...env },
330+
encoding: 'utf8',
331+
timeout: 10000,
332+
});
333+
334+
return {
335+
stdout: result.stdout || '',
336+
stderr: result.stderr || '',
337+
exitCode: result.status || 0,
338+
};
339+
};
340+
341+
it('reads config from android/sentry.properties when expo config is not available', () => {
342+
createAssets(['bundle.js', 'bundle.js.map']);
343+
createSentryProperties(
344+
path.join(tempDir, 'android'),
345+
'defaults.url=https://sentry.io/\ndefaults.org=props-org\ndefaults.project=props-project\n',
346+
);
347+
348+
const result = runScriptWithCwd(tempDir, {
349+
SENTRY_ORG: undefined,
350+
SENTRY_PROJECT: undefined,
351+
SENTRY_URL: undefined,
352+
MOCK_CLI_EXIT_CODE: '0',
353+
});
354+
355+
expect(result.exitCode).toBe(0);
356+
expect(result.stdout).toContain('Found sentry properties in');
357+
expect(result.stdout).toContain('android');
358+
});
359+
360+
it('reads config from ios/sentry.properties when android is not available', () => {
361+
createAssets(['bundle.js', 'bundle.js.map']);
362+
createSentryProperties(
363+
path.join(tempDir, 'ios'),
364+
'defaults.url=https://sentry.io/\ndefaults.org=ios-org\ndefaults.project=ios-project\n',
365+
);
366+
367+
const result = runScriptWithCwd(tempDir, {
368+
SENTRY_ORG: undefined,
369+
SENTRY_PROJECT: undefined,
370+
SENTRY_URL: undefined,
371+
MOCK_CLI_EXIT_CODE: '0',
372+
});
373+
374+
expect(result.exitCode).toBe(0);
375+
expect(result.stdout).toContain('Found sentry properties in');
376+
expect(result.stdout).toContain('ios');
377+
});
378+
379+
it('skips comment lines and empty lines in sentry.properties', () => {
380+
createAssets(['bundle.js', 'bundle.js.map']);
381+
createSentryProperties(
382+
path.join(tempDir, 'android'),
383+
'# This is a comment\ndefaults.url=https://sentry.io/\n\ndefaults.org=comment-org\n# Another comment\ndefaults.project=comment-project\n',
384+
);
385+
386+
const result = runScriptWithCwd(tempDir, {
387+
SENTRY_ORG: undefined,
388+
SENTRY_PROJECT: undefined,
389+
SENTRY_URL: undefined,
390+
MOCK_CLI_EXIT_CODE: '0',
391+
});
392+
393+
expect(result.exitCode).toBe(0);
394+
expect(result.stdout).toContain('Found sentry properties in');
395+
});
396+
397+
it('fails with helpful message when no config source is available', () => {
398+
createAssets(['bundle.js', 'bundle.js.map']);
399+
400+
const result = runScriptWithCwd(tempDir, {
401+
SENTRY_ORG: undefined,
402+
SENTRY_PROJECT: undefined,
403+
SENTRY_URL: undefined,
404+
});
405+
406+
expect(result.exitCode).toBe(1);
407+
const output = result.stdout + result.stderr;
408+
expect(output).toContain('SENTRY_ORG');
409+
expect(output).toContain('SENTRY_PROJECT');
410+
});
411+
});
412+
413+
describe('config._internal.sentryBuildProperties fallback (withSentry programmatic usage)', () => {
414+
const runScriptWithMockExpoConfig = (
415+
expoConfig: Record<string, unknown>,
416+
env: Record<string, string | undefined> = {},
417+
): { stdout: string; stderr: string; exitCode: number } => {
418+
// Create a mock npx that outputs the given expo config as JSON
419+
const mockBinDir = path.join(tempDir, 'mock-bin-expo');
420+
fs.mkdirSync(mockBinDir, { recursive: true });
421+
const mockNpxScript = path.join(mockBinDir, 'npx');
422+
// The mock npx script outputs the config JSON when called with 'expo config --json'
423+
fs.writeFileSync(
424+
mockNpxScript,
425+
`#!/usr/bin/env node
426+
const args = process.argv.slice(2);
427+
if (args.includes('expo') && args.includes('config') && args.includes('--json')) {
428+
process.stdout.write(${JSON.stringify(JSON.stringify(expoConfig))});
429+
process.exit(0);
430+
}
431+
process.exit(1);
432+
`,
433+
);
434+
fs.chmodSync(mockNpxScript, '755');
435+
436+
const defaultEnv = {
437+
SENTRY_AUTH_TOKEN: 'test-token',
438+
SENTRY_CLI_EXECUTABLE: mockSentryCliScript,
439+
PATH: `${mockBinDir}:${process.env.PATH}`,
440+
};
441+
442+
const result = spawnSync(process.execPath, [EXPO_UPLOAD_SCRIPT, outputDir], {
443+
cwd: tempDir,
444+
env: { ...process.env, ...defaultEnv, ...env },
445+
encoding: 'utf8',
446+
timeout: 10000,
447+
});
448+
449+
return {
450+
stdout: result.stdout || '',
451+
stderr: result.stderr || '',
452+
exitCode: result.status || 0,
453+
};
454+
};
455+
456+
it('reads config from _internal.sentryBuildProperties when plugin is not in plugins array', () => {
457+
createAssets(['bundle.js', 'bundle.js.map']);
458+
459+
const result = runScriptWithMockExpoConfig(
460+
{
461+
plugins: [['some-other-plugin', {}]],
462+
_internal: {
463+
sentryBuildProperties: {
464+
organization: 'internal-org',
465+
project: 'internal-project',
466+
url: 'https://sentry.io/',
467+
},
468+
},
469+
},
470+
{
471+
SENTRY_ORG: undefined,
472+
SENTRY_PROJECT: undefined,
473+
SENTRY_URL: undefined,
474+
MOCK_CLI_EXIT_CODE: '0',
475+
},
476+
);
477+
478+
expect(result.exitCode).toBe(0);
479+
expect(result.stdout).toContain('Uploaded bundles and sourcemaps to Sentry successfully');
480+
});
481+
482+
it('prefers plugins array over _internal.sentryBuildProperties', () => {
483+
createAssets(['bundle.js', 'bundle.js.map']);
484+
485+
const result = runScriptWithMockExpoConfig(
486+
{
487+
plugins: [
488+
[
489+
'@sentry/react-native/expo',
490+
{ organization: 'plugin-org', project: 'plugin-project', url: 'https://sentry.io/' },
491+
],
492+
],
493+
_internal: {
494+
sentryBuildProperties: {
495+
organization: 'internal-org',
496+
project: 'internal-project',
497+
url: 'https://sentry.io/',
498+
},
499+
},
500+
},
501+
{
502+
SENTRY_ORG: undefined,
503+
SENTRY_PROJECT: undefined,
504+
SENTRY_URL: undefined,
505+
MOCK_CLI_EXIT_CODE: '0',
506+
},
507+
);
508+
509+
expect(result.exitCode).toBe(0);
510+
expect(result.stdout).toContain('SENTRY_ORG resolved to plugin-org');
511+
});
512+
});
513+
297514
describe('sourcemap processing', () => {
298515
it('converts debugId to debug_id in sourcemaps', () => {
299516
createAssets(['bundle.js', 'bundle.js.map']);

0 commit comments

Comments
 (0)