Skip to content

Commit 0f9e023

Browse files
committed
Fixes for build hooks
1 parent 9b0d8e7 commit 0f9e023

File tree

8 files changed

+135
-36
lines changed

8 files changed

+135
-36
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@
88
99
## Unreleased
1010

11+
### Features
12+
13+
- EAS Build Hooks ([#5666](https://github.com/getsentry/sentry-react-native/pull/5666))
14+
15+
- Capture EAS build events in Sentry. Add the following to your `package.json`:
16+
17+
```json
18+
{
19+
"scripts": {
20+
"eas-build-on-complete": "sentry-eas-build-on-complete"
21+
}
22+
}
23+
```
24+
25+
Set `SENTRY_DSN` in your EAS secrets, and optionally `SENTRY_EAS_BUILD_CAPTURE_SUCCESS=true` to also capture successful builds.
26+
1127
### Dependencies
1228

1329
- Bump Android SDK from v8.32.0 to v8.33.0 ([#5684](https://github.com/getsentry/sentry-react-native/pull/5684))

packages/core/scripts/eas/build-on-complete.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
*
1111
* "eas-build-on-complete": "sentry-eas-build-on-complete"
1212
*
13+
* NOTE: Use EITHER this hook OR the separate on-error/on-success hooks, not both.
14+
* Using both will result in duplicate events being sent to Sentry.
15+
*
1316
* Required environment variables:
1417
* - SENTRY_DSN: Your Sentry DSN
1518
*

packages/core/scripts/eas/build-on-error.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
*
88
* "eas-build-on-error": "sentry-eas-build-on-error"
99
*
10+
* NOTE: Use EITHER this hook (with on-success) OR the on-complete hook, not both.
11+
* Using both will result in duplicate events being sent to Sentry.
12+
*
1013
* Required environment variables:
1114
* - SENTRY_DSN: Your Sentry DSN
1215
*

packages/core/scripts/eas/build-on-success.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
*
88
* "eas-build-on-success": "sentry-eas-build-on-success"
99
*
10+
* NOTE: Use EITHER this hook (with on-error) OR the on-complete hook, not both.
11+
* Using both will result in duplicate events being sent to Sentry.
12+
*
1013
* Required environment variables:
1114
* - SENTRY_DSN: Your Sentry DSN
1215
* - SENTRY_EAS_BUILD_CAPTURE_SUCCESS: Set to 'true' to capture successful builds

packages/core/scripts/eas/utils.js

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,26 @@
99
const path = require('path');
1010
const fs = require('fs');
1111

12+
/**
13+
* Merges parsed env vars into process.env without overwriting existing values.
14+
* This preserves EAS secrets and other pre-set environment variables.
15+
* @param {object} parsed - Parsed environment variables from dotenv
16+
*/
17+
function mergeEnvWithoutOverwrite(parsed) {
18+
for (const key of Object.keys(parsed)) {
19+
if (process.env[key] === undefined) {
20+
process.env[key] = parsed[key];
21+
}
22+
}
23+
}
24+
1225
/**
1326
* Loads environment variables from various sources:
1427
* - @expo/env (if available)
1528
* - .env file (via dotenv, if available)
1629
* - .env.sentry-build-plugin file
30+
*
31+
* NOTE: Existing environment variables (like EAS secrets) are NOT overwritten.
1732
*/
1833
function loadEnv() {
1934
// Try @expo/env first
@@ -26,7 +41,7 @@ function loadEnv() {
2641
if (fs.existsSync(dotenvPath)) {
2742
const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8');
2843
const dotenv = require('dotenv');
29-
Object.assign(process.env, dotenv.parse(dotenvFile));
44+
mergeEnvWithoutOverwrite(dotenv.parse(dotenvFile));
3045
}
3146
} catch (_e2) {
3247
// No dotenv available, continue with existing env vars
@@ -39,7 +54,7 @@ function loadEnv() {
3954
if (fs.existsSync(sentryEnvPath)) {
4055
const dotenvFile = fs.readFileSync(sentryEnvPath, 'utf-8');
4156
const dotenv = require('dotenv');
42-
Object.assign(process.env, dotenv.parse(dotenvFile));
57+
mergeEnvWithoutOverwrite(dotenv.parse(dotenvFile));
4358
}
4459
} catch (_e) {
4560
// Continue without .env.sentry-build-plugin

packages/core/src/js/tools/easBuildHooks.ts

Lines changed: 34 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
/* eslint-disable no-console */
1414
/* eslint-disable no-bitwise */
1515

16+
import type { DsnComponents } from '@sentry/core';
17+
import { dsnToString, makeDsn } from '@sentry/core';
18+
1619
const SENTRY_DSN_ENV = 'SENTRY_DSN';
1720
const EAS_BUILD_ENV = 'EAS_BUILD';
1821

@@ -44,13 +47,6 @@ export interface EASBuildHookOptions {
4447
successMessage?: string;
4548
}
4649

47-
interface ParsedDsn {
48-
protocol: string;
49-
host: string;
50-
projectId: string;
51-
publicKey: string;
52-
}
53-
5450
interface SentryEvent {
5551
event_id: string;
5652
timestamp: number;
@@ -92,18 +88,11 @@ export function getEASBuildEnv(): EASBuildEnv {
9288
};
9389
}
9490

95-
function parseDsn(dsn: string): ParsedDsn | undefined {
96-
try {
97-
const url = new URL(dsn);
98-
const projectId = url.pathname.replace('/', '');
99-
return { protocol: url.protocol.replace(':', ''), host: url.host, projectId, publicKey: url.username };
100-
} catch {
101-
return undefined;
102-
}
103-
}
104-
105-
function getEnvelopeEndpoint(dsn: ParsedDsn): string {
106-
return `${dsn.protocol}://${dsn.host}/api/${dsn.projectId}/envelope/?sentry_key=${dsn.publicKey}&sentry_version=7`;
91+
function getEnvelopeEndpoint(dsn: DsnComponents): string {
92+
const { protocol, host, port, path, projectId, publicKey } = dsn;
93+
const portStr = port ? `:${port}` : '';
94+
const pathStr = path ? `/${path}` : '';
95+
return `${protocol}://${host}${portStr}${pathStr}/api/${projectId}/envelope/?sentry_key=${publicKey}&sentry_version=7`;
10796
}
10897

10998
function generateEventId(): string {
@@ -154,19 +143,19 @@ function createEASBuildContext(env: EASBuildEnv): Record<string, unknown> {
154143
};
155144
}
156145

157-
function createEnvelope(event: SentryEvent, dsn: ParsedDsn): string {
146+
function createEnvelope(event: SentryEvent, dsn: DsnComponents): string {
158147
const envelopeHeaders = JSON.stringify({
159148
event_id: event.event_id,
160149
sent_at: new Date().toISOString(),
161-
dsn: `${dsn.protocol}://${dsn.publicKey}@${dsn.host}/${dsn.projectId}`,
150+
dsn: dsnToString(dsn),
162151
sdk: event.sdk,
163152
});
164153
const itemHeaders = JSON.stringify({ type: 'event', content_type: 'application/json' });
165154
const itemPayload = JSON.stringify(event);
166155
return `${envelopeHeaders}\n${itemHeaders}\n${itemPayload}`;
167156
}
168157

169-
async function sendEvent(event: SentryEvent, dsn: ParsedDsn): Promise<boolean> {
158+
async function sendEvent(event: SentryEvent, dsn: DsnComponents): Promise<boolean> {
170159
const endpoint = getEnvelopeEndpoint(dsn);
171160
const envelope = createEnvelope(event, dsn);
172161
try {
@@ -184,6 +173,18 @@ async function sendEvent(event: SentryEvent, dsn: ParsedDsn): Promise<boolean> {
184173
}
185174
}
186175

176+
function getReleaseFromEASEnv(env: EASBuildEnv): string | undefined {
177+
// Honour explicit override first
178+
if (process.env.SENTRY_RELEASE) {
179+
return process.env.SENTRY_RELEASE;
180+
}
181+
// Best approximation without bundle identifier: version+buildNumber
182+
if (env.EAS_BUILD_APP_VERSION && env.EAS_BUILD_APP_BUILD_VERSION) {
183+
return `${env.EAS_BUILD_APP_VERSION}+${env.EAS_BUILD_APP_BUILD_VERSION}`;
184+
}
185+
return env.EAS_BUILD_APP_VERSION;
186+
}
187+
187188
function createBaseEvent(
188189
level: 'error' | 'info' | 'warning',
189190
env: EASBuildEnv,
@@ -196,7 +197,7 @@ function createBaseEvent(
196197
level,
197198
logger: 'eas-build-hook',
198199
environment: 'eas-build',
199-
release: env.EAS_BUILD_APP_VERSION,
200+
release: getReleaseFromEASEnv(env),
200201
tags: { ...createEASBuildTags(env), ...customTags },
201202
contexts: { eas_build: createEASBuildContext(env), runtime: { name: 'node', version: process.version } },
202203
sdk: { name: 'sentry.javascript.react-native.eas-build-hooks', version: '1.0.0' },
@@ -205,17 +206,17 @@ function createBaseEvent(
205206

206207
/** Captures an EAS build error event. Call this from the eas-build-on-error hook. */
207208
export async function captureEASBuildError(options: EASBuildHookOptions = {}): Promise<void> {
208-
const dsn = options.dsn ?? process.env[SENTRY_DSN_ENV];
209-
if (!dsn) {
209+
const dsnString = options.dsn ?? process.env[SENTRY_DSN_ENV];
210+
if (!dsnString) {
210211
console.warn('[Sentry] No DSN provided. Set SENTRY_DSN environment variable or pass dsn option.');
211212
return;
212213
}
213214
if (!isEASBuild()) {
214215
console.warn('[Sentry] Not running in EAS Build environment. Skipping error capture.');
215216
return;
216217
}
217-
const parsedDsn = parseDsn(dsn);
218-
if (!parsedDsn) {
218+
const dsn = makeDsn(dsnString);
219+
if (!dsn) {
219220
console.error('[Sentry] Invalid DSN format.');
220221
return;
221222
}
@@ -228,7 +229,7 @@ export async function captureEASBuildError(options: EASBuildHookOptions = {}): P
228229
values: [{ type: 'EASBuildError', value: errorMessage, mechanism: { type: 'eas-build-hook', handled: true } }],
229230
};
230231
event.fingerprint = ['eas-build-error', env.EAS_BUILD_PLATFORM ?? 'unknown', env.EAS_BUILD_PROFILE ?? 'unknown'];
231-
const success = await sendEvent(event, parsedDsn);
232+
const success = await sendEvent(event, dsn);
232233
if (success) console.log('[Sentry] Build error captured.');
233234
}
234235

@@ -238,17 +239,17 @@ export async function captureEASBuildSuccess(options: EASBuildHookOptions = {}):
238239
console.log('[Sentry] Skipping successful build capture (captureSuccessfulBuilds is false).');
239240
return;
240241
}
241-
const dsn = options.dsn ?? process.env[SENTRY_DSN_ENV];
242-
if (!dsn) {
242+
const dsnString = options.dsn ?? process.env[SENTRY_DSN_ENV];
243+
if (!dsnString) {
243244
console.warn('[Sentry] No DSN provided. Set SENTRY_DSN environment variable or pass dsn option.');
244245
return;
245246
}
246247
if (!isEASBuild()) {
247248
console.warn('[Sentry] Not running in EAS Build environment. Skipping success capture.');
248249
return;
249250
}
250-
const parsedDsn = parseDsn(dsn);
251-
if (!parsedDsn) {
251+
const dsn = makeDsn(dsnString);
252+
if (!dsn) {
252253
console.error('[Sentry] Invalid DSN format.');
253254
return;
254255
}
@@ -259,7 +260,7 @@ export async function captureEASBuildSuccess(options: EASBuildHookOptions = {}):
259260
const event = createBaseEvent('info', env, { ...options.tags, 'eas.hook': 'on-success' });
260261
event.message = { formatted: successMessage };
261262
event.fingerprint = ['eas-build-success', env.EAS_BUILD_PLATFORM ?? 'unknown', env.EAS_BUILD_PROFILE ?? 'unknown'];
262-
const success = await sendEvent(event, parsedDsn);
263+
const success = await sendEvent(event, dsn);
263264
if (success) console.log('[Sentry] Build success captured.');
264265
}
265266

packages/core/test/tools/easBuildHooks.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,65 @@ describe('EAS Build Hooks', () => {
310310
});
311311
});
312312

313+
describe('release naming', () => {
314+
beforeEach(() => {
315+
process.env.EAS_BUILD = 'true';
316+
process.env.EAS_BUILD_PLATFORM = 'ios';
317+
process.env.SENTRY_DSN = 'https://key@sentry.io/123';
318+
delete process.env.SENTRY_RELEASE;
319+
delete process.env.EAS_BUILD_APP_VERSION;
320+
delete process.env.EAS_BUILD_APP_BUILD_VERSION;
321+
});
322+
323+
it('uses SENTRY_RELEASE when set', async () => {
324+
process.env.SENTRY_RELEASE = 'custom-release@1.0.0';
325+
process.env.EAS_BUILD_APP_VERSION = '2.0.0';
326+
327+
await captureEASBuildError();
328+
329+
const fetchCall = mockFetch.mock.calls[0];
330+
const body = fetchCall[1].body as string;
331+
const event = JSON.parse(body.split('\n')[2]);
332+
333+
expect(event.release).toBe('custom-release@1.0.0');
334+
});
335+
336+
it('combines version and build number when both are available', async () => {
337+
process.env.EAS_BUILD_APP_VERSION = '1.2.3';
338+
process.env.EAS_BUILD_APP_BUILD_VERSION = '42';
339+
340+
await captureEASBuildError();
341+
342+
const fetchCall = mockFetch.mock.calls[0];
343+
const body = fetchCall[1].body as string;
344+
const event = JSON.parse(body.split('\n')[2]);
345+
346+
expect(event.release).toBe('1.2.3+42');
347+
});
348+
349+
it('uses only version when build number is not available', async () => {
350+
process.env.EAS_BUILD_APP_VERSION = '1.2.3';
351+
352+
await captureEASBuildError();
353+
354+
const fetchCall = mockFetch.mock.calls[0];
355+
const body = fetchCall[1].body as string;
356+
const event = JSON.parse(body.split('\n')[2]);
357+
358+
expect(event.release).toBe('1.2.3');
359+
});
360+
361+
it('sets release to undefined when no version info is available', async () => {
362+
await captureEASBuildError();
363+
364+
const fetchCall = mockFetch.mock.calls[0];
365+
const body = fetchCall[1].body as string;
366+
const event = JSON.parse(body.split('\n')[2]);
367+
368+
expect(event.release).toBeUndefined();
369+
});
370+
});
371+
313372
describe('envelope format', () => {
314373
beforeEach(() => {
315374
process.env.EAS_BUILD = 'true';

samples/expo/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
"prebuild": "expo prebuild --clean --no-install",
1818
"set-version": "npx react-native-version --skip-tag --never-amend",
1919
"eas-build-pre-install": "npm i -g corepack && yarn install --no-immutable --inline-builds && yarn workspace @sentry/react-native build",
20-
"eas-build-on-error": "sentry-eas-build-on-error",
2120
"eas-build-on-complete": "sentry-eas-build-on-complete",
2221
"eas-update-configure": "eas update:configure",
2322
"eas-update-publish-development": "eas update --channel development --message 'Development update'",

0 commit comments

Comments
 (0)