From a244678391f363de62061d38e6dcefd184c7bda5 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 12 Feb 2026 16:26:45 +0100 Subject: [PATCH 1/8] EAS Build Hooks --- packages/core/package.json | 5 +- .../core/scripts/eas-build-on-complete.js | 104 +++++ packages/core/scripts/eas-build-on-error.js | 94 +++++ packages/core/scripts/eas-build-on-success.js | 96 +++++ packages/core/src/js/tools/easBuildHooks.ts | 277 ++++++++++++ .../core/test/tools/easBuildHooks.test.ts | 399 ++++++++++++++++++ samples/expo/package.json | 2 + 7 files changed, 976 insertions(+), 1 deletion(-) create mode 100755 packages/core/scripts/eas-build-on-complete.js create mode 100755 packages/core/scripts/eas-build-on-error.js create mode 100755 packages/core/scripts/eas-build-on-success.js create mode 100644 packages/core/src/js/tools/easBuildHooks.ts create mode 100644 packages/core/test/tools/easBuildHooks.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index b3f6bea829..320ffd3766 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,7 +45,10 @@ "lint:prettier": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --check \"{src,test,scripts,plugin/src}/**/**.ts\"" }, "bin": { - "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js" + "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js", + "sentry-eas-build-on-error": "scripts/eas-build-on-error.js", + "sentry-eas-build-on-success": "scripts/eas-build-on-success.js", + "sentry-eas-build-on-complete": "scripts/eas-build-on-complete.js" }, "keywords": [ "react-native", diff --git a/packages/core/scripts/eas-build-on-complete.js b/packages/core/scripts/eas-build-on-complete.js new file mode 100755 index 0000000000..9f8753ee54 --- /dev/null +++ b/packages/core/scripts/eas-build-on-complete.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * EAS Build Hook: on-complete + * + * This script captures EAS build completion events and reports them to Sentry. + * It uses the EAS_BUILD_STATUS environment variable to determine whether + * the build succeeded or failed. + * + * Add it to your package.json scripts: + * + * "eas-build-on-complete": "sentry-eas-build-on-complete" + * + * Required environment variables: + * - SENTRY_DSN: Your Sentry DSN + * + * Optional environment variables: + * - SENTRY_EAS_BUILD_CAPTURE_SUCCESS: Set to 'true' to also capture successful builds + * - SENTRY_EAS_BUILD_TAGS: JSON string of additional tags + * - SENTRY_EAS_BUILD_ERROR_MESSAGE: Custom error message for failed builds + * - SENTRY_EAS_BUILD_SUCCESS_MESSAGE: Custom success message for successful builds + * + * EAS Build provides: + * - EAS_BUILD_STATUS: 'finished' or 'errored' + * + * @see https://docs.expo.dev/build-reference/npm-hooks/ + * @see https://docs.sentry.io/platforms/react-native/ + */ + +const path = require('path'); +const fs = require('fs'); + +// Try to load environment variables +function loadEnv() { + // Try @expo/env first + try { + require('@expo/env').load('.'); + } catch (_e) { + // Fallback to dotenv if available + try { + const dotenvPath = path.join(process.cwd(), '.env'); + if (fs.existsSync(dotenvPath)) { + const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e2) { + // No dotenv available, continue with existing env vars + } + } + + // Also load .env.sentry-build-plugin if it exists + try { + const sentryEnvPath = path.join(process.cwd(), '.env.sentry-build-plugin'); + if (fs.existsSync(sentryEnvPath)) { + const dotenvFile = fs.readFileSync(sentryEnvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e) { + // Continue without .env.sentry-build-plugin + } +} + +async function main() { + loadEnv(); + + // Dynamically import the hooks module (it's compiled to dist/) + let captureEASBuildComplete; + try { + // Try the compiled output first + const hooks = require('../dist/js/tools/easBuildHooks.js'); + captureEASBuildComplete = hooks.captureEASBuildComplete; + } catch (_e) { + console.error('[Sentry] Could not load EAS build hooks module. Make sure @sentry/react-native is properly installed.'); + process.exit(1); + } + + // Parse options from environment variables + const options = { + dsn: process.env.SENTRY_DSN, + errorMessage: process.env.SENTRY_EAS_BUILD_ERROR_MESSAGE, + successMessage: process.env.SENTRY_EAS_BUILD_SUCCESS_MESSAGE, + captureSuccessfulBuilds: process.env.SENTRY_EAS_BUILD_CAPTURE_SUCCESS === 'true', + }; + + // Parse additional tags if provided + if (process.env.SENTRY_EAS_BUILD_TAGS) { + try { + options.tags = JSON.parse(process.env.SENTRY_EAS_BUILD_TAGS); + } catch (_e) { + console.warn('[Sentry] Could not parse SENTRY_EAS_BUILD_TAGS as JSON. Ignoring.'); + } + } + + try { + await captureEASBuildComplete(options); + console.log('[Sentry] EAS build complete hook finished.'); + } catch (error) { + console.error('[Sentry] Error in eas-build-on-complete hook:', error); + // Don't fail the build hook itself + } +} + +main(); diff --git a/packages/core/scripts/eas-build-on-error.js b/packages/core/scripts/eas-build-on-error.js new file mode 100755 index 0000000000..6c0a1d12e0 --- /dev/null +++ b/packages/core/scripts/eas-build-on-error.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * EAS Build Hook: on-error + * + * This script captures EAS build failures and reports them to Sentry. + * Add it to your package.json scripts: + * + * "eas-build-on-error": "sentry-eas-build-on-error" + * + * Required environment variables: + * - SENTRY_DSN: Your Sentry DSN + * + * Optional environment variables: + * - SENTRY_EAS_BUILD_TAGS: JSON string of additional tags + * - SENTRY_EAS_BUILD_ERROR_MESSAGE: Custom error message + * + * @see https://docs.expo.dev/build-reference/npm-hooks/ + * @see https://docs.sentry.io/platforms/react-native/ + */ + +const path = require('path'); +const fs = require('fs'); + +// Try to load environment variables +function loadEnv() { + // Try @expo/env first + try { + require('@expo/env').load('.'); + } catch (_e) { + // Fallback to dotenv if available + try { + const dotenvPath = path.join(process.cwd(), '.env'); + if (fs.existsSync(dotenvPath)) { + const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e2) { + // No dotenv available, continue with existing env vars + } + } + + // Also load .env.sentry-build-plugin if it exists + try { + const sentryEnvPath = path.join(process.cwd(), '.env.sentry-build-plugin'); + if (fs.existsSync(sentryEnvPath)) { + const dotenvFile = fs.readFileSync(sentryEnvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e) { + // Continue without .env.sentry-build-plugin + } +} + +async function main() { + loadEnv(); + + // Dynamically import the hooks module (it's compiled to dist/) + let captureEASBuildError; + try { + // Try the compiled output first + const hooks = require('../dist/js/tools/easBuildHooks.js'); + captureEASBuildError = hooks.captureEASBuildError; + } catch (_e) { + console.error('[Sentry] Could not load EAS build hooks module. Make sure @sentry/react-native is properly installed.'); + process.exit(1); + } + + // Parse options from environment variables + const options = { + dsn: process.env.SENTRY_DSN, + errorMessage: process.env.SENTRY_EAS_BUILD_ERROR_MESSAGE, + }; + + // Parse additional tags if provided + if (process.env.SENTRY_EAS_BUILD_TAGS) { + try { + options.tags = JSON.parse(process.env.SENTRY_EAS_BUILD_TAGS); + } catch (_e) { + console.warn('[Sentry] Could not parse SENTRY_EAS_BUILD_TAGS as JSON. Ignoring.'); + } + } + + try { + await captureEASBuildError(options); + console.log('[Sentry] EAS build error hook completed.'); + } catch (error) { + console.error('[Sentry] Error in eas-build-on-error hook:', error); + // Don't fail the build hook itself + } +} + +main(); diff --git a/packages/core/scripts/eas-build-on-success.js b/packages/core/scripts/eas-build-on-success.js new file mode 100755 index 0000000000..af6790c807 --- /dev/null +++ b/packages/core/scripts/eas-build-on-success.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +/** + * EAS Build Hook: on-success + * + * This script captures EAS build successes and reports them to Sentry. + * Add it to your package.json scripts: + * + * "eas-build-on-success": "sentry-eas-build-on-success" + * + * Required environment variables: + * - SENTRY_DSN: Your Sentry DSN + * - SENTRY_EAS_BUILD_CAPTURE_SUCCESS: Set to 'true' to capture successful builds + * + * Optional environment variables: + * - SENTRY_EAS_BUILD_TAGS: JSON string of additional tags + * - SENTRY_EAS_BUILD_SUCCESS_MESSAGE: Custom success message + * + * @see https://docs.expo.dev/build-reference/npm-hooks/ + * @see https://docs.sentry.io/platforms/react-native/ + */ + +const path = require('path'); +const fs = require('fs'); + +// Try to load environment variables +function loadEnv() { + // Try @expo/env first + try { + require('@expo/env').load('.'); + } catch (_e) { + // Fallback to dotenv if available + try { + const dotenvPath = path.join(process.cwd(), '.env'); + if (fs.existsSync(dotenvPath)) { + const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e2) { + // No dotenv available, continue with existing env vars + } + } + + // Also load .env.sentry-build-plugin if it exists + try { + const sentryEnvPath = path.join(process.cwd(), '.env.sentry-build-plugin'); + if (fs.existsSync(sentryEnvPath)) { + const dotenvFile = fs.readFileSync(sentryEnvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e) { + // Continue without .env.sentry-build-plugin + } +} + +async function main() { + loadEnv(); + + // Dynamically import the hooks module (it's compiled to dist/) + let captureEASBuildSuccess; + try { + // Try the compiled output first + const hooks = require('../dist/js/tools/easBuildHooks.js'); + captureEASBuildSuccess = hooks.captureEASBuildSuccess; + } catch (_e) { + console.error('[Sentry] Could not load EAS build hooks module. Make sure @sentry/react-native is properly installed.'); + process.exit(1); + } + + // Parse options from environment variables + const options = { + dsn: process.env.SENTRY_DSN, + successMessage: process.env.SENTRY_EAS_BUILD_SUCCESS_MESSAGE, + captureSuccessfulBuilds: process.env.SENTRY_EAS_BUILD_CAPTURE_SUCCESS === 'true', + }; + + // Parse additional tags if provided + if (process.env.SENTRY_EAS_BUILD_TAGS) { + try { + options.tags = JSON.parse(process.env.SENTRY_EAS_BUILD_TAGS); + } catch (_e) { + console.warn('[Sentry] Could not parse SENTRY_EAS_BUILD_TAGS as JSON. Ignoring.'); + } + } + + try { + await captureEASBuildSuccess(options); + console.log('[Sentry] EAS build success hook completed.'); + } catch (error) { + console.error('[Sentry] Error in eas-build-on-success hook:', error); + // Don't fail the build hook itself + } +} + +main(); diff --git a/packages/core/src/js/tools/easBuildHooks.ts b/packages/core/src/js/tools/easBuildHooks.ts new file mode 100644 index 0000000000..20bbdffd87 --- /dev/null +++ b/packages/core/src/js/tools/easBuildHooks.ts @@ -0,0 +1,277 @@ +/** + * EAS Build Hooks for Sentry + * + * This module provides utilities for capturing EAS build lifecycle events + * and sending them to Sentry. It supports the following EAS npm hooks: + * - eas-build-on-error: Captures build failures + * - eas-build-on-success: Captures successful builds (optional) + * - eas-build-on-complete: Captures build completion with metrics + * + * @see https://docs.expo.dev/build-reference/npm-hooks/ + */ + +/* eslint-disable no-console */ +/* eslint-disable no-bitwise */ + +const SENTRY_DSN_ENV = 'SENTRY_DSN'; +const EAS_BUILD_ENV = 'EAS_BUILD'; + +/** + * Environment variables provided by EAS Build. + * @see https://docs.expo.dev/build-reference/variables/ + */ +export interface EASBuildEnv { + EAS_BUILD?: string; + EAS_BUILD_ID?: string; + EAS_BUILD_PLATFORM?: string; + EAS_BUILD_PROFILE?: string; + EAS_BUILD_PROJECT_ID?: string; + EAS_BUILD_GIT_COMMIT_HASH?: string; + EAS_BUILD_RUN_FROM_CI?: string; + EAS_BUILD_STATUS?: string; + EAS_BUILD_APP_VERSION?: string; + EAS_BUILD_APP_BUILD_VERSION?: string; + EAS_BUILD_USERNAME?: string; + EAS_BUILD_WORKINGDIR?: string; +} + +/** Options for configuring EAS build hook behavior. */ +export interface EASBuildHookOptions { + dsn?: string; + tags?: Record; + captureSuccessfulBuilds?: boolean; + errorMessage?: string; + successMessage?: string; +} + +interface ParsedDsn { + protocol: string; + host: string; + projectId: string; + publicKey: string; +} + +interface SentryEvent { + event_id: string; + timestamp: number; + platform: string; + level: 'error' | 'info' | 'warning'; + logger: string; + environment: string; + release?: string; + tags: Record; + contexts: Record>; + message?: { formatted: string }; + exception?: { + values: Array<{ type: string; value: string; mechanism: { type: string; handled: boolean } }>; + }; + fingerprint?: string[]; + sdk: { name: string; version: string }; +} + +/** Checks if the current environment is an EAS Build. */ +export function isEASBuild(): boolean { + return process.env[EAS_BUILD_ENV] === 'true'; +} + +/** Gets the EAS build environment variables. */ +export function getEASBuildEnv(): EASBuildEnv { + return { + EAS_BUILD: process.env.EAS_BUILD, + EAS_BUILD_ID: process.env.EAS_BUILD_ID, + EAS_BUILD_PLATFORM: process.env.EAS_BUILD_PLATFORM, + EAS_BUILD_PROFILE: process.env.EAS_BUILD_PROFILE, + EAS_BUILD_PROJECT_ID: process.env.EAS_BUILD_PROJECT_ID, + EAS_BUILD_GIT_COMMIT_HASH: process.env.EAS_BUILD_GIT_COMMIT_HASH, + EAS_BUILD_RUN_FROM_CI: process.env.EAS_BUILD_RUN_FROM_CI, + EAS_BUILD_STATUS: process.env.EAS_BUILD_STATUS, + EAS_BUILD_APP_VERSION: process.env.EAS_BUILD_APP_VERSION, + EAS_BUILD_APP_BUILD_VERSION: process.env.EAS_BUILD_APP_BUILD_VERSION, + EAS_BUILD_USERNAME: process.env.EAS_BUILD_USERNAME, + EAS_BUILD_WORKINGDIR: process.env.EAS_BUILD_WORKINGDIR, + }; +} + +function parseDsn(dsn: string): ParsedDsn | undefined { + try { + const url = new URL(dsn); + const projectId = url.pathname.replace('/', ''); + return { protocol: url.protocol.replace(':', ''), host: url.host, projectId, publicKey: url.username }; + } catch { + return undefined; + } +} + +function getEnvelopeEndpoint(dsn: ParsedDsn): string { + return `${dsn.protocol}://${dsn.host}/api/${dsn.projectId}/envelope/?sentry_key=${dsn.publicKey}&sentry_version=7`; +} + +function generateEventId(): string { + const bytes = new Uint8Array(16); + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + crypto.getRandomValues(bytes); + } else { + for (let i = 0; i < 16; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + const byte6 = bytes[6]; + const byte8 = bytes[8]; + if (byte6 !== undefined && byte8 !== undefined) { + bytes[6] = (byte6 & 0x0f) | 0x40; + bytes[8] = (byte8 & 0x3f) | 0x80; + } + return Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +function createEASBuildTags(env: EASBuildEnv): Record { + const tags: Record = {}; + if (env.EAS_BUILD_PLATFORM) tags['eas.platform'] = env.EAS_BUILD_PLATFORM; + if (env.EAS_BUILD_PROFILE) tags['eas.profile'] = env.EAS_BUILD_PROFILE; + if (env.EAS_BUILD_ID) tags['eas.build_id'] = env.EAS_BUILD_ID; + if (env.EAS_BUILD_PROJECT_ID) tags['eas.project_id'] = env.EAS_BUILD_PROJECT_ID; + if (env.EAS_BUILD_RUN_FROM_CI) tags['eas.from_ci'] = env.EAS_BUILD_RUN_FROM_CI; + if (env.EAS_BUILD_STATUS) tags['eas.status'] = env.EAS_BUILD_STATUS; + if (env.EAS_BUILD_USERNAME) tags['eas.username'] = env.EAS_BUILD_USERNAME; + return tags; +} + +function createEASBuildContext(env: EASBuildEnv): Record { + return { + build_id: env.EAS_BUILD_ID, + platform: env.EAS_BUILD_PLATFORM, + profile: env.EAS_BUILD_PROFILE, + project_id: env.EAS_BUILD_PROJECT_ID, + git_commit: env.EAS_BUILD_GIT_COMMIT_HASH, + from_ci: env.EAS_BUILD_RUN_FROM_CI === 'true', + status: env.EAS_BUILD_STATUS, + app_version: env.EAS_BUILD_APP_VERSION, + build_version: env.EAS_BUILD_APP_BUILD_VERSION, + username: env.EAS_BUILD_USERNAME, + working_dir: env.EAS_BUILD_WORKINGDIR, + }; +} + +function createEnvelope(event: SentryEvent, dsn: ParsedDsn): string { + const envelopeHeaders = JSON.stringify({ + event_id: event.event_id, + sent_at: new Date().toISOString(), + dsn: `${dsn.protocol}://${dsn.publicKey}@${dsn.host}/${dsn.projectId}`, + sdk: event.sdk, + }); + const itemHeaders = JSON.stringify({ type: 'event', content_type: 'application/json' }); + const itemPayload = JSON.stringify(event); + return `${envelopeHeaders}\n${itemHeaders}\n${itemPayload}`; +} + +async function sendEvent(event: SentryEvent, dsn: ParsedDsn): Promise { + const endpoint = getEnvelopeEndpoint(dsn); + const envelope = createEnvelope(event, dsn); + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + body: envelope, + }); + if (response.status >= 200 && response.status < 300) return true; + console.warn(`[Sentry] Failed to send event: HTTP ${response.status}`); + return false; + } catch (error) { + console.error('[Sentry] Failed to send event:', error); + return false; + } +} + +function createBaseEvent( + level: 'error' | 'info' | 'warning', + env: EASBuildEnv, + customTags?: Record, +): SentryEvent { + return { + event_id: generateEventId(), + timestamp: Date.now() / 1000, + platform: 'node', + level, + logger: 'eas-build-hook', + environment: 'eas-build', + release: env.EAS_BUILD_APP_VERSION, + tags: { ...createEASBuildTags(env), ...customTags }, + contexts: { eas_build: createEASBuildContext(env), runtime: { name: 'node', version: process.version } }, + sdk: { name: 'sentry.javascript.react-native.eas-build-hooks', version: '1.0.0' }, + }; +} + +/** Captures an EAS build error event. Call this from the eas-build-on-error hook. */ +export async function captureEASBuildError(options: EASBuildHookOptions = {}): Promise { + const dsn = options.dsn ?? process.env[SENTRY_DSN_ENV]; + if (!dsn) { + console.warn('[Sentry] No DSN provided. Set SENTRY_DSN environment variable or pass dsn option.'); + return; + } + if (!isEASBuild()) { + console.warn('[Sentry] Not running in EAS Build environment. Skipping error capture.'); + return; + } + const parsedDsn = parseDsn(dsn); + if (!parsedDsn) { + console.error('[Sentry] Invalid DSN format.'); + return; + } + const env = getEASBuildEnv(); + const errorMessage = + options.errorMessage ?? `EAS Build Failed: ${env.EAS_BUILD_PLATFORM ?? 'unknown'} (${env.EAS_BUILD_PROFILE ?? 'unknown'})`; + const event = createBaseEvent('error', env, { ...options.tags, 'eas.hook': 'on-error' }); + event.exception = { + values: [{ type: 'EASBuildError', value: errorMessage, mechanism: { type: 'eas-build-hook', handled: true } }], + }; + event.fingerprint = ['eas-build-error', env.EAS_BUILD_PLATFORM ?? 'unknown', env.EAS_BUILD_PROFILE ?? 'unknown']; + const success = await sendEvent(event, parsedDsn); + if (success) console.log('[Sentry] Build error captured.'); +} + +/** Captures an EAS build success event. Call this from the eas-build-on-success hook. */ +export async function captureEASBuildSuccess(options: EASBuildHookOptions = {}): Promise { + if (!options.captureSuccessfulBuilds) { + console.log('[Sentry] Skipping successful build capture (captureSuccessfulBuilds is false).'); + return; + } + const dsn = options.dsn ?? process.env[SENTRY_DSN_ENV]; + if (!dsn) { + console.warn('[Sentry] No DSN provided. Set SENTRY_DSN environment variable or pass dsn option.'); + return; + } + if (!isEASBuild()) { + console.warn('[Sentry] Not running in EAS Build environment. Skipping success capture.'); + return; + } + const parsedDsn = parseDsn(dsn); + if (!parsedDsn) { + console.error('[Sentry] Invalid DSN format.'); + return; + } + const env = getEASBuildEnv(); + const successMessage = + options.successMessage ?? `EAS Build Succeeded: ${env.EAS_BUILD_PLATFORM ?? 'unknown'} (${env.EAS_BUILD_PROFILE ?? 'unknown'})`; + const event = createBaseEvent('info', env, { ...options.tags, 'eas.hook': 'on-success' }); + event.message = { formatted: successMessage }; + event.fingerprint = ['eas-build-success', env.EAS_BUILD_PLATFORM ?? 'unknown', env.EAS_BUILD_PROFILE ?? 'unknown']; + const success = await sendEvent(event, parsedDsn); + if (success) console.log('[Sentry] Build success captured.'); +} + +/** Captures an EAS build completion event with status. Call this from the eas-build-on-complete hook. */ +export async function captureEASBuildComplete(options: EASBuildHookOptions = {}): Promise { + const env = getEASBuildEnv(); + const status = env.EAS_BUILD_STATUS; + if (status === 'errored') { + await captureEASBuildError(options); + return; + } + if (status === 'finished' && options.captureSuccessfulBuilds) { + await captureEASBuildSuccess({ ...options, captureSuccessfulBuilds: true }); + return; + } + console.log(`[Sentry] Build completed with status: ${status ?? 'unknown'}. No event captured.`); +} diff --git a/packages/core/test/tools/easBuildHooks.test.ts b/packages/core/test/tools/easBuildHooks.test.ts new file mode 100644 index 0000000000..60d72168af --- /dev/null +++ b/packages/core/test/tools/easBuildHooks.test.ts @@ -0,0 +1,399 @@ +import { + captureEASBuildComplete, + captureEASBuildError, + captureEASBuildSuccess, + getEASBuildEnv, + isEASBuild, +} from '../../src/js/tools/easBuildHooks'; + +// Mock fetch +const mockFetch = jest.fn(); + +// @ts-expect-error - Mocking global fetch +global.fetch = mockFetch; + +describe('EAS Build Hooks', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset environment + process.env = { ...originalEnv }; + // Default successful fetch response + mockFetch.mockResolvedValue({ + status: 200, + headers: { + get: jest.fn().mockReturnValue(null), + }, + }); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('isEASBuild', () => { + it('returns true when EAS_BUILD is "true"', () => { + process.env.EAS_BUILD = 'true'; + expect(isEASBuild()).toBe(true); + }); + + it('returns false when EAS_BUILD is not set', () => { + delete process.env.EAS_BUILD; + expect(isEASBuild()).toBe(false); + }); + + it('returns false when EAS_BUILD is "false"', () => { + process.env.EAS_BUILD = 'false'; + expect(isEASBuild()).toBe(false); + }); + + it('returns false when EAS_BUILD is empty', () => { + process.env.EAS_BUILD = ''; + expect(isEASBuild()).toBe(false); + }); + }); + + describe('getEASBuildEnv', () => { + it('returns all EAS build environment variables', () => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_ID = 'build-123'; + process.env.EAS_BUILD_PLATFORM = 'ios'; + process.env.EAS_BUILD_PROFILE = 'production'; + process.env.EAS_BUILD_PROJECT_ID = 'project-456'; + process.env.EAS_BUILD_GIT_COMMIT_HASH = 'abc123'; + process.env.EAS_BUILD_RUN_FROM_CI = 'true'; + process.env.EAS_BUILD_STATUS = 'finished'; + process.env.EAS_BUILD_APP_VERSION = '1.0.0'; + process.env.EAS_BUILD_APP_BUILD_VERSION = '42'; + process.env.EAS_BUILD_USERNAME = 'testuser'; + process.env.EAS_BUILD_WORKINGDIR = '/build/workdir'; + + const env = getEASBuildEnv(); + + expect(env).toEqual({ + EAS_BUILD: 'true', + EAS_BUILD_ID: 'build-123', + EAS_BUILD_PLATFORM: 'ios', + EAS_BUILD_PROFILE: 'production', + EAS_BUILD_PROJECT_ID: 'project-456', + EAS_BUILD_GIT_COMMIT_HASH: 'abc123', + EAS_BUILD_RUN_FROM_CI: 'true', + EAS_BUILD_STATUS: 'finished', + EAS_BUILD_APP_VERSION: '1.0.0', + EAS_BUILD_APP_BUILD_VERSION: '42', + EAS_BUILD_USERNAME: 'testuser', + EAS_BUILD_WORKINGDIR: '/build/workdir', + }); + }); + + it('returns undefined for unset variables', () => { + delete process.env.EAS_BUILD; + delete process.env.EAS_BUILD_ID; + + const env = getEASBuildEnv(); + + expect(env.EAS_BUILD).toBeUndefined(); + expect(env.EAS_BUILD_ID).toBeUndefined(); + }); + }); + + describe('captureEASBuildError', () => { + beforeEach(() => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_PLATFORM = 'android'; + process.env.EAS_BUILD_PROFILE = 'preview'; + process.env.SENTRY_DSN = 'https://key@sentry.io/123'; + }); + + it('does not capture when DSN is not set', async () => { + delete process.env.SENTRY_DSN; + + await captureEASBuildError(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not capture when not in EAS build environment', async () => { + process.env.EAS_BUILD = 'false'; + + await captureEASBuildError(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('sends error event to Sentry', async () => { + await captureEASBuildError(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('sentry.io/api/123/envelope'), + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + body: expect.stringContaining('EASBuildError'), + }), + ); + }); + + it('includes EAS build tags in the event', async () => { + process.env.EAS_BUILD_ID = 'build-xyz'; + process.env.EAS_BUILD_PROJECT_ID = 'proj-abc'; + + await captureEASBuildError(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"eas.platform":"android"'); + expect(body).toContain('"eas.profile":"preview"'); + expect(body).toContain('"eas.build_id":"build-xyz"'); + expect(body).toContain('"eas.hook":"on-error"'); + }); + + it('uses custom error message when provided', async () => { + await captureEASBuildError({ errorMessage: 'Custom build failure' }); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('Custom build failure'); + }); + + it('uses DSN from options if provided', async () => { + delete process.env.SENTRY_DSN; + + await captureEASBuildError({ dsn: 'https://custom@other.sentry.io/456' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('other.sentry.io/api/456/envelope'), + expect.anything(), + ); + }); + + it('includes fingerprint for grouping', async () => { + await captureEASBuildError(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"fingerprint":["eas-build-error","android","preview"]'); + }); + + it('includes custom tags from options', async () => { + await captureEASBuildError({ + tags: { + 'custom.tag': 'custom-value', + }, + }); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"custom.tag":"custom-value"'); + }); + + it('handles invalid DSN gracefully', async () => { + process.env.SENTRY_DSN = 'invalid-dsn'; + + await captureEASBuildError(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('captureEASBuildSuccess', () => { + beforeEach(() => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_PLATFORM = 'ios'; + process.env.EAS_BUILD_PROFILE = 'production'; + process.env.SENTRY_DSN = 'https://key@sentry.io/123'; + }); + + it('does not capture by default (captureSuccessfulBuilds is false)', async () => { + await captureEASBuildSuccess(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('captures success when captureSuccessfulBuilds is true', async () => { + await captureEASBuildSuccess({ captureSuccessfulBuilds: true }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"level":"info"'); + expect(body).toContain('EAS Build Succeeded'); + expect(body).toContain('"eas.hook":"on-success"'); + }); + + it('uses custom success message when provided', async () => { + await captureEASBuildSuccess({ + captureSuccessfulBuilds: true, + successMessage: 'Build completed successfully!', + }); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('Build completed successfully!'); + }); + + it('does not capture when DSN is not set', async () => { + delete process.env.SENTRY_DSN; + + await captureEASBuildSuccess({ captureSuccessfulBuilds: true }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('captureEASBuildComplete', () => { + beforeEach(() => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_PLATFORM = 'android'; + process.env.EAS_BUILD_PROFILE = 'development'; + process.env.SENTRY_DSN = 'https://key@sentry.io/123'; + }); + + it('captures error when EAS_BUILD_STATUS is "errored"', async () => { + process.env.EAS_BUILD_STATUS = 'errored'; + + await captureEASBuildComplete(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"level":"error"'); + expect(body).toContain('EASBuildError'); + }); + + it('captures success when EAS_BUILD_STATUS is "finished" and captureSuccessfulBuilds is true', async () => { + process.env.EAS_BUILD_STATUS = 'finished'; + + await captureEASBuildComplete({ captureSuccessfulBuilds: true }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"level":"info"'); + expect(body).toContain('EAS Build Succeeded'); + }); + + it('does not capture success when EAS_BUILD_STATUS is "finished" but captureSuccessfulBuilds is false', async () => { + process.env.EAS_BUILD_STATUS = 'finished'; + + await captureEASBuildComplete({ captureSuccessfulBuilds: false }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not capture anything when status is unknown', async () => { + process.env.EAS_BUILD_STATUS = 'unknown'; + + await captureEASBuildComplete(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not capture when status is undefined and captureSuccessfulBuilds is false', async () => { + delete process.env.EAS_BUILD_STATUS; + + await captureEASBuildComplete(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('envelope format', () => { + beforeEach(() => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_PLATFORM = 'ios'; + process.env.EAS_BUILD_PROFILE = 'staging'; + process.env.SENTRY_DSN = 'https://publickey@sentry.io/123'; + }); + + it('creates valid envelope with correct headers', async () => { + await captureEASBuildError(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body as string; + const lines = body.split('\n'); + + // Envelope should have 3 lines: envelope header, item header, item payload + expect(lines.length).toBe(3); + + // Parse and verify envelope header + const envelopeHeader = JSON.parse(lines[0]); + expect(envelopeHeader).toHaveProperty('event_id'); + expect(envelopeHeader).toHaveProperty('sent_at'); + expect(envelopeHeader.dsn).toContain('sentry.io/123'); + + // Parse and verify item header + const itemHeader = JSON.parse(lines[1]); + expect(itemHeader.type).toBe('event'); + expect(itemHeader.content_type).toBe('application/json'); + + // Parse and verify event payload + const event = JSON.parse(lines[2]); + expect(event.platform).toBe('node'); + expect(event.environment).toBe('eas-build'); + expect(event.level).toBe('error'); + }); + + it('includes EAS build context in the event', async () => { + process.env.EAS_BUILD_ID = 'build-context-test'; + process.env.EAS_BUILD_GIT_COMMIT_HASH = 'commit123'; + process.env.EAS_BUILD_RUN_FROM_CI = 'true'; + + await captureEASBuildError(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body as string; + const lines = body.split('\n'); + const event = JSON.parse(lines[2]); + + expect(event.contexts.eas_build).toEqual( + expect.objectContaining({ + build_id: 'build-context-test', + platform: 'ios', + profile: 'staging', + git_commit: 'commit123', + from_ci: true, + }), + ); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_PLATFORM = 'ios'; + process.env.SENTRY_DSN = 'https://key@sentry.io/123'; + }); + + it('handles fetch failure gracefully', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + // Should not throw + await expect(captureEASBuildError()).resolves.not.toThrow(); + }); + + it('handles non-2xx response gracefully', async () => { + mockFetch.mockResolvedValue({ + status: 429, + headers: { + get: jest.fn().mockReturnValue(null), + }, + }); + + // Should not throw + await expect(captureEASBuildError()).resolves.not.toThrow(); + }); + }); +}); diff --git a/samples/expo/package.json b/samples/expo/package.json index be1155082e..4e13b1c1e9 100644 --- a/samples/expo/package.json +++ b/samples/expo/package.json @@ -17,6 +17,8 @@ "prebuild": "expo prebuild --clean --no-install", "set-version": "npx react-native-version --skip-tag --never-amend", "eas-build-pre-install": "npm i -g corepack && yarn install --no-immutable --inline-builds && yarn workspace @sentry/react-native build", + "eas-build-on-error": "sentry-eas-build-on-error", + "eas-build-on-complete": "sentry-eas-build-on-complete", "eas-update-configure": "eas update:configure", "eas-update-publish-development": "eas update --channel development --message 'Development update'", "eas-build-development-android": "eas build --profile development --platform android" From ca650bd52b8858b32c7f4528734dcddde32092cb Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Fri, 13 Feb 2026 16:35:37 +0100 Subject: [PATCH 2/8] Fixes --- .../core/test/tools/easBuildHooks.test.ts | 399 ------------------ 1 file changed, 399 deletions(-) delete mode 100644 packages/core/test/tools/easBuildHooks.test.ts diff --git a/packages/core/test/tools/easBuildHooks.test.ts b/packages/core/test/tools/easBuildHooks.test.ts deleted file mode 100644 index 60d72168af..0000000000 --- a/packages/core/test/tools/easBuildHooks.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { - captureEASBuildComplete, - captureEASBuildError, - captureEASBuildSuccess, - getEASBuildEnv, - isEASBuild, -} from '../../src/js/tools/easBuildHooks'; - -// Mock fetch -const mockFetch = jest.fn(); - -// @ts-expect-error - Mocking global fetch -global.fetch = mockFetch; - -describe('EAS Build Hooks', () => { - const originalEnv = process.env; - - beforeEach(() => { - jest.clearAllMocks(); - // Reset environment - process.env = { ...originalEnv }; - // Default successful fetch response - mockFetch.mockResolvedValue({ - status: 200, - headers: { - get: jest.fn().mockReturnValue(null), - }, - }); - }); - - afterAll(() => { - process.env = originalEnv; - }); - - describe('isEASBuild', () => { - it('returns true when EAS_BUILD is "true"', () => { - process.env.EAS_BUILD = 'true'; - expect(isEASBuild()).toBe(true); - }); - - it('returns false when EAS_BUILD is not set', () => { - delete process.env.EAS_BUILD; - expect(isEASBuild()).toBe(false); - }); - - it('returns false when EAS_BUILD is "false"', () => { - process.env.EAS_BUILD = 'false'; - expect(isEASBuild()).toBe(false); - }); - - it('returns false when EAS_BUILD is empty', () => { - process.env.EAS_BUILD = ''; - expect(isEASBuild()).toBe(false); - }); - }); - - describe('getEASBuildEnv', () => { - it('returns all EAS build environment variables', () => { - process.env.EAS_BUILD = 'true'; - process.env.EAS_BUILD_ID = 'build-123'; - process.env.EAS_BUILD_PLATFORM = 'ios'; - process.env.EAS_BUILD_PROFILE = 'production'; - process.env.EAS_BUILD_PROJECT_ID = 'project-456'; - process.env.EAS_BUILD_GIT_COMMIT_HASH = 'abc123'; - process.env.EAS_BUILD_RUN_FROM_CI = 'true'; - process.env.EAS_BUILD_STATUS = 'finished'; - process.env.EAS_BUILD_APP_VERSION = '1.0.0'; - process.env.EAS_BUILD_APP_BUILD_VERSION = '42'; - process.env.EAS_BUILD_USERNAME = 'testuser'; - process.env.EAS_BUILD_WORKINGDIR = '/build/workdir'; - - const env = getEASBuildEnv(); - - expect(env).toEqual({ - EAS_BUILD: 'true', - EAS_BUILD_ID: 'build-123', - EAS_BUILD_PLATFORM: 'ios', - EAS_BUILD_PROFILE: 'production', - EAS_BUILD_PROJECT_ID: 'project-456', - EAS_BUILD_GIT_COMMIT_HASH: 'abc123', - EAS_BUILD_RUN_FROM_CI: 'true', - EAS_BUILD_STATUS: 'finished', - EAS_BUILD_APP_VERSION: '1.0.0', - EAS_BUILD_APP_BUILD_VERSION: '42', - EAS_BUILD_USERNAME: 'testuser', - EAS_BUILD_WORKINGDIR: '/build/workdir', - }); - }); - - it('returns undefined for unset variables', () => { - delete process.env.EAS_BUILD; - delete process.env.EAS_BUILD_ID; - - const env = getEASBuildEnv(); - - expect(env.EAS_BUILD).toBeUndefined(); - expect(env.EAS_BUILD_ID).toBeUndefined(); - }); - }); - - describe('captureEASBuildError', () => { - beforeEach(() => { - process.env.EAS_BUILD = 'true'; - process.env.EAS_BUILD_PLATFORM = 'android'; - process.env.EAS_BUILD_PROFILE = 'preview'; - process.env.SENTRY_DSN = 'https://key@sentry.io/123'; - }); - - it('does not capture when DSN is not set', async () => { - delete process.env.SENTRY_DSN; - - await captureEASBuildError(); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('does not capture when not in EAS build environment', async () => { - process.env.EAS_BUILD = 'false'; - - await captureEASBuildError(); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('sends error event to Sentry', async () => { - await captureEASBuildError(); - - expect(mockFetch).toHaveBeenCalledTimes(1); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('sentry.io/api/123/envelope'), - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/x-sentry-envelope', - }, - body: expect.stringContaining('EASBuildError'), - }), - ); - }); - - it('includes EAS build tags in the event', async () => { - process.env.EAS_BUILD_ID = 'build-xyz'; - process.env.EAS_BUILD_PROJECT_ID = 'proj-abc'; - - await captureEASBuildError(); - - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body; - - expect(body).toContain('"eas.platform":"android"'); - expect(body).toContain('"eas.profile":"preview"'); - expect(body).toContain('"eas.build_id":"build-xyz"'); - expect(body).toContain('"eas.hook":"on-error"'); - }); - - it('uses custom error message when provided', async () => { - await captureEASBuildError({ errorMessage: 'Custom build failure' }); - - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body; - - expect(body).toContain('Custom build failure'); - }); - - it('uses DSN from options if provided', async () => { - delete process.env.SENTRY_DSN; - - await captureEASBuildError({ dsn: 'https://custom@other.sentry.io/456' }); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('other.sentry.io/api/456/envelope'), - expect.anything(), - ); - }); - - it('includes fingerprint for grouping', async () => { - await captureEASBuildError(); - - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body; - - expect(body).toContain('"fingerprint":["eas-build-error","android","preview"]'); - }); - - it('includes custom tags from options', async () => { - await captureEASBuildError({ - tags: { - 'custom.tag': 'custom-value', - }, - }); - - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body; - - expect(body).toContain('"custom.tag":"custom-value"'); - }); - - it('handles invalid DSN gracefully', async () => { - process.env.SENTRY_DSN = 'invalid-dsn'; - - await captureEASBuildError(); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - }); - - describe('captureEASBuildSuccess', () => { - beforeEach(() => { - process.env.EAS_BUILD = 'true'; - process.env.EAS_BUILD_PLATFORM = 'ios'; - process.env.EAS_BUILD_PROFILE = 'production'; - process.env.SENTRY_DSN = 'https://key@sentry.io/123'; - }); - - it('does not capture by default (captureSuccessfulBuilds is false)', async () => { - await captureEASBuildSuccess(); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('captures success when captureSuccessfulBuilds is true', async () => { - await captureEASBuildSuccess({ captureSuccessfulBuilds: true }); - - expect(mockFetch).toHaveBeenCalledTimes(1); - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body; - - expect(body).toContain('"level":"info"'); - expect(body).toContain('EAS Build Succeeded'); - expect(body).toContain('"eas.hook":"on-success"'); - }); - - it('uses custom success message when provided', async () => { - await captureEASBuildSuccess({ - captureSuccessfulBuilds: true, - successMessage: 'Build completed successfully!', - }); - - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body; - - expect(body).toContain('Build completed successfully!'); - }); - - it('does not capture when DSN is not set', async () => { - delete process.env.SENTRY_DSN; - - await captureEASBuildSuccess({ captureSuccessfulBuilds: true }); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - }); - - describe('captureEASBuildComplete', () => { - beforeEach(() => { - process.env.EAS_BUILD = 'true'; - process.env.EAS_BUILD_PLATFORM = 'android'; - process.env.EAS_BUILD_PROFILE = 'development'; - process.env.SENTRY_DSN = 'https://key@sentry.io/123'; - }); - - it('captures error when EAS_BUILD_STATUS is "errored"', async () => { - process.env.EAS_BUILD_STATUS = 'errored'; - - await captureEASBuildComplete(); - - expect(mockFetch).toHaveBeenCalledTimes(1); - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body; - - expect(body).toContain('"level":"error"'); - expect(body).toContain('EASBuildError'); - }); - - it('captures success when EAS_BUILD_STATUS is "finished" and captureSuccessfulBuilds is true', async () => { - process.env.EAS_BUILD_STATUS = 'finished'; - - await captureEASBuildComplete({ captureSuccessfulBuilds: true }); - - expect(mockFetch).toHaveBeenCalledTimes(1); - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body; - - expect(body).toContain('"level":"info"'); - expect(body).toContain('EAS Build Succeeded'); - }); - - it('does not capture success when EAS_BUILD_STATUS is "finished" but captureSuccessfulBuilds is false', async () => { - process.env.EAS_BUILD_STATUS = 'finished'; - - await captureEASBuildComplete({ captureSuccessfulBuilds: false }); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('does not capture anything when status is unknown', async () => { - process.env.EAS_BUILD_STATUS = 'unknown'; - - await captureEASBuildComplete(); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('does not capture when status is undefined and captureSuccessfulBuilds is false', async () => { - delete process.env.EAS_BUILD_STATUS; - - await captureEASBuildComplete(); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - }); - - describe('envelope format', () => { - beforeEach(() => { - process.env.EAS_BUILD = 'true'; - process.env.EAS_BUILD_PLATFORM = 'ios'; - process.env.EAS_BUILD_PROFILE = 'staging'; - process.env.SENTRY_DSN = 'https://publickey@sentry.io/123'; - }); - - it('creates valid envelope with correct headers', async () => { - await captureEASBuildError(); - - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body as string; - const lines = body.split('\n'); - - // Envelope should have 3 lines: envelope header, item header, item payload - expect(lines.length).toBe(3); - - // Parse and verify envelope header - const envelopeHeader = JSON.parse(lines[0]); - expect(envelopeHeader).toHaveProperty('event_id'); - expect(envelopeHeader).toHaveProperty('sent_at'); - expect(envelopeHeader.dsn).toContain('sentry.io/123'); - - // Parse and verify item header - const itemHeader = JSON.parse(lines[1]); - expect(itemHeader.type).toBe('event'); - expect(itemHeader.content_type).toBe('application/json'); - - // Parse and verify event payload - const event = JSON.parse(lines[2]); - expect(event.platform).toBe('node'); - expect(event.environment).toBe('eas-build'); - expect(event.level).toBe('error'); - }); - - it('includes EAS build context in the event', async () => { - process.env.EAS_BUILD_ID = 'build-context-test'; - process.env.EAS_BUILD_GIT_COMMIT_HASH = 'commit123'; - process.env.EAS_BUILD_RUN_FROM_CI = 'true'; - - await captureEASBuildError(); - - const fetchCall = mockFetch.mock.calls[0]; - const body = fetchCall[1].body as string; - const lines = body.split('\n'); - const event = JSON.parse(lines[2]); - - expect(event.contexts.eas_build).toEqual( - expect.objectContaining({ - build_id: 'build-context-test', - platform: 'ios', - profile: 'staging', - git_commit: 'commit123', - from_ci: true, - }), - ); - }); - }); - - describe('error handling', () => { - beforeEach(() => { - process.env.EAS_BUILD = 'true'; - process.env.EAS_BUILD_PLATFORM = 'ios'; - process.env.SENTRY_DSN = 'https://key@sentry.io/123'; - }); - - it('handles fetch failure gracefully', async () => { - mockFetch.mockRejectedValue(new Error('Network error')); - - // Should not throw - await expect(captureEASBuildError()).resolves.not.toThrow(); - }); - - it('handles non-2xx response gracefully', async () => { - mockFetch.mockResolvedValue({ - status: 429, - headers: { - get: jest.fn().mockReturnValue(null), - }, - }); - - // Should not throw - await expect(captureEASBuildError()).resolves.not.toThrow(); - }); - }); -}); From d50d21506298f242a0da5c3ad9770f5383f07507 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 23 Feb 2026 12:11:07 +0100 Subject: [PATCH 3/8] Code separation --- .../core/scripts/eas-build-on-complete.js | 67 +----------- packages/core/scripts/eas-build-on-error.js | 67 +----------- packages/core/scripts/eas-build-on-success.js | 67 +----------- packages/core/scripts/eas-build-utils.js | 102 ++++++++++++++++++ 4 files changed, 114 insertions(+), 189 deletions(-) create mode 100644 packages/core/scripts/eas-build-utils.js diff --git a/packages/core/scripts/eas-build-on-complete.js b/packages/core/scripts/eas-build-on-complete.js index 9f8753ee54..11e6b9f715 100755 --- a/packages/core/scripts/eas-build-on-complete.js +++ b/packages/core/scripts/eas-build-on-complete.js @@ -26,79 +26,20 @@ * @see https://docs.sentry.io/platforms/react-native/ */ -const path = require('path'); -const fs = require('fs'); - -// Try to load environment variables -function loadEnv() { - // Try @expo/env first - try { - require('@expo/env').load('.'); - } catch (_e) { - // Fallback to dotenv if available - try { - const dotenvPath = path.join(process.cwd(), '.env'); - if (fs.existsSync(dotenvPath)) { - const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); - const dotenv = require('dotenv'); - Object.assign(process.env, dotenv.parse(dotenvFile)); - } - } catch (_e2) { - // No dotenv available, continue with existing env vars - } - } - - // Also load .env.sentry-build-plugin if it exists - try { - const sentryEnvPath = path.join(process.cwd(), '.env.sentry-build-plugin'); - if (fs.existsSync(sentryEnvPath)) { - const dotenvFile = fs.readFileSync(sentryEnvPath, 'utf-8'); - const dotenv = require('dotenv'); - Object.assign(process.env, dotenv.parse(dotenvFile)); - } - } catch (_e) { - // Continue without .env.sentry-build-plugin - } -} +const { loadEnv, loadHooksModule, parseBaseOptions, runHook } = require('./eas-build-utils'); async function main() { loadEnv(); - // Dynamically import the hooks module (it's compiled to dist/) - let captureEASBuildComplete; - try { - // Try the compiled output first - const hooks = require('../dist/js/tools/easBuildHooks.js'); - captureEASBuildComplete = hooks.captureEASBuildComplete; - } catch (_e) { - console.error('[Sentry] Could not load EAS build hooks module. Make sure @sentry/react-native is properly installed.'); - process.exit(1); - } - - // Parse options from environment variables + const hooks = loadHooksModule(); const options = { - dsn: process.env.SENTRY_DSN, + ...parseBaseOptions(), errorMessage: process.env.SENTRY_EAS_BUILD_ERROR_MESSAGE, successMessage: process.env.SENTRY_EAS_BUILD_SUCCESS_MESSAGE, captureSuccessfulBuilds: process.env.SENTRY_EAS_BUILD_CAPTURE_SUCCESS === 'true', }; - // Parse additional tags if provided - if (process.env.SENTRY_EAS_BUILD_TAGS) { - try { - options.tags = JSON.parse(process.env.SENTRY_EAS_BUILD_TAGS); - } catch (_e) { - console.warn('[Sentry] Could not parse SENTRY_EAS_BUILD_TAGS as JSON. Ignoring.'); - } - } - - try { - await captureEASBuildComplete(options); - console.log('[Sentry] EAS build complete hook finished.'); - } catch (error) { - console.error('[Sentry] Error in eas-build-on-complete hook:', error); - // Don't fail the build hook itself - } + await runHook('on-complete', () => hooks.captureEASBuildComplete(options)); } main(); diff --git a/packages/core/scripts/eas-build-on-error.js b/packages/core/scripts/eas-build-on-error.js index 6c0a1d12e0..af96a5cb34 100755 --- a/packages/core/scripts/eas-build-on-error.js +++ b/packages/core/scripts/eas-build-on-error.js @@ -18,77 +18,18 @@ * @see https://docs.sentry.io/platforms/react-native/ */ -const path = require('path'); -const fs = require('fs'); - -// Try to load environment variables -function loadEnv() { - // Try @expo/env first - try { - require('@expo/env').load('.'); - } catch (_e) { - // Fallback to dotenv if available - try { - const dotenvPath = path.join(process.cwd(), '.env'); - if (fs.existsSync(dotenvPath)) { - const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); - const dotenv = require('dotenv'); - Object.assign(process.env, dotenv.parse(dotenvFile)); - } - } catch (_e2) { - // No dotenv available, continue with existing env vars - } - } - - // Also load .env.sentry-build-plugin if it exists - try { - const sentryEnvPath = path.join(process.cwd(), '.env.sentry-build-plugin'); - if (fs.existsSync(sentryEnvPath)) { - const dotenvFile = fs.readFileSync(sentryEnvPath, 'utf-8'); - const dotenv = require('dotenv'); - Object.assign(process.env, dotenv.parse(dotenvFile)); - } - } catch (_e) { - // Continue without .env.sentry-build-plugin - } -} +const { loadEnv, loadHooksModule, parseBaseOptions, runHook } = require('./eas-build-utils'); async function main() { loadEnv(); - // Dynamically import the hooks module (it's compiled to dist/) - let captureEASBuildError; - try { - // Try the compiled output first - const hooks = require('../dist/js/tools/easBuildHooks.js'); - captureEASBuildError = hooks.captureEASBuildError; - } catch (_e) { - console.error('[Sentry] Could not load EAS build hooks module. Make sure @sentry/react-native is properly installed.'); - process.exit(1); - } - - // Parse options from environment variables + const hooks = loadHooksModule(); const options = { - dsn: process.env.SENTRY_DSN, + ...parseBaseOptions(), errorMessage: process.env.SENTRY_EAS_BUILD_ERROR_MESSAGE, }; - // Parse additional tags if provided - if (process.env.SENTRY_EAS_BUILD_TAGS) { - try { - options.tags = JSON.parse(process.env.SENTRY_EAS_BUILD_TAGS); - } catch (_e) { - console.warn('[Sentry] Could not parse SENTRY_EAS_BUILD_TAGS as JSON. Ignoring.'); - } - } - - try { - await captureEASBuildError(options); - console.log('[Sentry] EAS build error hook completed.'); - } catch (error) { - console.error('[Sentry] Error in eas-build-on-error hook:', error); - // Don't fail the build hook itself - } + await runHook('on-error', () => hooks.captureEASBuildError(options)); } main(); diff --git a/packages/core/scripts/eas-build-on-success.js b/packages/core/scripts/eas-build-on-success.js index af6790c807..eec7506290 100755 --- a/packages/core/scripts/eas-build-on-success.js +++ b/packages/core/scripts/eas-build-on-success.js @@ -19,78 +19,19 @@ * @see https://docs.sentry.io/platforms/react-native/ */ -const path = require('path'); -const fs = require('fs'); - -// Try to load environment variables -function loadEnv() { - // Try @expo/env first - try { - require('@expo/env').load('.'); - } catch (_e) { - // Fallback to dotenv if available - try { - const dotenvPath = path.join(process.cwd(), '.env'); - if (fs.existsSync(dotenvPath)) { - const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); - const dotenv = require('dotenv'); - Object.assign(process.env, dotenv.parse(dotenvFile)); - } - } catch (_e2) { - // No dotenv available, continue with existing env vars - } - } - - // Also load .env.sentry-build-plugin if it exists - try { - const sentryEnvPath = path.join(process.cwd(), '.env.sentry-build-plugin'); - if (fs.existsSync(sentryEnvPath)) { - const dotenvFile = fs.readFileSync(sentryEnvPath, 'utf-8'); - const dotenv = require('dotenv'); - Object.assign(process.env, dotenv.parse(dotenvFile)); - } - } catch (_e) { - // Continue without .env.sentry-build-plugin - } -} +const { loadEnv, loadHooksModule, parseBaseOptions, runHook } = require('./eas-build-utils'); async function main() { loadEnv(); - // Dynamically import the hooks module (it's compiled to dist/) - let captureEASBuildSuccess; - try { - // Try the compiled output first - const hooks = require('../dist/js/tools/easBuildHooks.js'); - captureEASBuildSuccess = hooks.captureEASBuildSuccess; - } catch (_e) { - console.error('[Sentry] Could not load EAS build hooks module. Make sure @sentry/react-native is properly installed.'); - process.exit(1); - } - - // Parse options from environment variables + const hooks = loadHooksModule(); const options = { - dsn: process.env.SENTRY_DSN, + ...parseBaseOptions(), successMessage: process.env.SENTRY_EAS_BUILD_SUCCESS_MESSAGE, captureSuccessfulBuilds: process.env.SENTRY_EAS_BUILD_CAPTURE_SUCCESS === 'true', }; - // Parse additional tags if provided - if (process.env.SENTRY_EAS_BUILD_TAGS) { - try { - options.tags = JSON.parse(process.env.SENTRY_EAS_BUILD_TAGS); - } catch (_e) { - console.warn('[Sentry] Could not parse SENTRY_EAS_BUILD_TAGS as JSON. Ignoring.'); - } - } - - try { - await captureEASBuildSuccess(options); - console.log('[Sentry] EAS build success hook completed.'); - } catch (error) { - console.error('[Sentry] Error in eas-build-on-success hook:', error); - // Don't fail the build hook itself - } + await runHook('on-success', () => hooks.captureEASBuildSuccess(options)); } main(); diff --git a/packages/core/scripts/eas-build-utils.js b/packages/core/scripts/eas-build-utils.js new file mode 100644 index 0000000000..76fca45eca --- /dev/null +++ b/packages/core/scripts/eas-build-utils.js @@ -0,0 +1,102 @@ +/** + * Shared utilities for EAS Build Hook scripts. + * + * @see https://docs.expo.dev/build-reference/npm-hooks/ + */ + +const path = require('path'); +const fs = require('fs'); + +/** + * Loads environment variables from various sources: + * - @expo/env (if available) + * - .env file (via dotenv, if available) + * - .env.sentry-build-plugin file + */ +function loadEnv() { + // Try @expo/env first + try { + require('@expo/env').load('.'); + } catch (_e) { + // Fallback to dotenv if available + try { + const dotenvPath = path.join(process.cwd(), '.env'); + if (fs.existsSync(dotenvPath)) { + const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e2) { + // No dotenv available, continue with existing env vars + } + } + + // Also load .env.sentry-build-plugin if it exists + try { + const sentryEnvPath = path.join(process.cwd(), '.env.sentry-build-plugin'); + if (fs.existsSync(sentryEnvPath)) { + const dotenvFile = fs.readFileSync(sentryEnvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e) { + // Continue without .env.sentry-build-plugin + } +} + +/** + * Loads the EAS build hooks module from the compiled output. + * @returns {object} The hooks module exports + * @throws {Error} If the module cannot be loaded + */ +function loadHooksModule() { + try { + return require('../dist/js/tools/easBuildHooks.js'); + } catch (_e) { + console.error('[Sentry] Could not load EAS build hooks module. Make sure @sentry/react-native is properly installed.'); + process.exit(1); + } +} + +/** + * Parses common options from environment variables. + * @returns {object} Parsed options object + */ +function parseBaseOptions() { + const options = { + dsn: process.env.SENTRY_DSN, + }; + + // Parse additional tags if provided + if (process.env.SENTRY_EAS_BUILD_TAGS) { + try { + options.tags = JSON.parse(process.env.SENTRY_EAS_BUILD_TAGS); + } catch (_e) { + console.warn('[Sentry] Could not parse SENTRY_EAS_BUILD_TAGS as JSON. Ignoring.'); + } + } + + return options; +} + +/** + * Wraps an async hook function with error handling. + * @param {string} hookName - Name of the hook for logging + * @param {Function} hookFn - Async function to execute + */ +async function runHook(hookName, hookFn) { + try { + await hookFn(); + console.log(`[Sentry] EAS build ${hookName} hook completed.`); + } catch (error) { + console.error(`[Sentry] Error in eas-build-${hookName} hook:`, error); + // Don't fail the build hook itself + } +} + +module.exports = { + loadEnv, + loadHooksModule, + parseBaseOptions, + runHook, +}; From f8ac8f0a04886aab208f48e98bd5e841759a4a06 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 23 Feb 2026 12:14:38 +0100 Subject: [PATCH 4/8] Better code separation --- packages/core/package.json | 6 +- .../build-on-complete.js} | 2 +- .../build-on-error.js} | 2 +- .../build-on-success.js} | 2 +- .../{eas-build-utils.js => eas/utils.js} | 4 +- .../core/test/tools/easBuildHooks.test.ts | 399 ++++++++++++++++++ 6 files changed, 408 insertions(+), 7 deletions(-) rename packages/core/scripts/{eas-build-on-complete.js => eas/build-on-complete.js} (97%) rename packages/core/scripts/{eas-build-on-error.js => eas/build-on-error.js} (96%) rename packages/core/scripts/{eas-build-on-success.js => eas/build-on-success.js} (97%) rename packages/core/scripts/{eas-build-utils.js => eas/utils.js} (96%) create mode 100644 packages/core/test/tools/easBuildHooks.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index 320ffd3766..75285105a1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,9 +46,9 @@ }, "bin": { "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js", - "sentry-eas-build-on-error": "scripts/eas-build-on-error.js", - "sentry-eas-build-on-success": "scripts/eas-build-on-success.js", - "sentry-eas-build-on-complete": "scripts/eas-build-on-complete.js" + "sentry-eas-build-on-error": "scripts/eas/build-on-error.js", + "sentry-eas-build-on-success": "scripts/eas/build-on-success.js", + "sentry-eas-build-on-complete": "scripts/eas/build-on-complete.js" }, "keywords": [ "react-native", diff --git a/packages/core/scripts/eas-build-on-complete.js b/packages/core/scripts/eas/build-on-complete.js similarity index 97% rename from packages/core/scripts/eas-build-on-complete.js rename to packages/core/scripts/eas/build-on-complete.js index 11e6b9f715..c7988736eb 100755 --- a/packages/core/scripts/eas-build-on-complete.js +++ b/packages/core/scripts/eas/build-on-complete.js @@ -26,7 +26,7 @@ * @see https://docs.sentry.io/platforms/react-native/ */ -const { loadEnv, loadHooksModule, parseBaseOptions, runHook } = require('./eas-build-utils'); +const { loadEnv, loadHooksModule, parseBaseOptions, runHook } = require('./utils'); async function main() { loadEnv(); diff --git a/packages/core/scripts/eas-build-on-error.js b/packages/core/scripts/eas/build-on-error.js similarity index 96% rename from packages/core/scripts/eas-build-on-error.js rename to packages/core/scripts/eas/build-on-error.js index af96a5cb34..186cf2f32d 100755 --- a/packages/core/scripts/eas-build-on-error.js +++ b/packages/core/scripts/eas/build-on-error.js @@ -18,7 +18,7 @@ * @see https://docs.sentry.io/platforms/react-native/ */ -const { loadEnv, loadHooksModule, parseBaseOptions, runHook } = require('./eas-build-utils'); +const { loadEnv, loadHooksModule, parseBaseOptions, runHook } = require('./utils'); async function main() { loadEnv(); diff --git a/packages/core/scripts/eas-build-on-success.js b/packages/core/scripts/eas/build-on-success.js similarity index 97% rename from packages/core/scripts/eas-build-on-success.js rename to packages/core/scripts/eas/build-on-success.js index eec7506290..21e28d65f5 100755 --- a/packages/core/scripts/eas-build-on-success.js +++ b/packages/core/scripts/eas/build-on-success.js @@ -19,7 +19,7 @@ * @see https://docs.sentry.io/platforms/react-native/ */ -const { loadEnv, loadHooksModule, parseBaseOptions, runHook } = require('./eas-build-utils'); +const { loadEnv, loadHooksModule, parseBaseOptions, runHook } = require('./utils'); async function main() { loadEnv(); diff --git a/packages/core/scripts/eas-build-utils.js b/packages/core/scripts/eas/utils.js similarity index 96% rename from packages/core/scripts/eas-build-utils.js rename to packages/core/scripts/eas/utils.js index 76fca45eca..58ac810155 100644 --- a/packages/core/scripts/eas-build-utils.js +++ b/packages/core/scripts/eas/utils.js @@ -4,6 +4,8 @@ * @see https://docs.expo.dev/build-reference/npm-hooks/ */ +/* eslint-disable no-console */ + const path = require('path'); const fs = require('fs'); @@ -51,7 +53,7 @@ function loadEnv() { */ function loadHooksModule() { try { - return require('../dist/js/tools/easBuildHooks.js'); + return require('../../dist/js/tools/easBuildHooks.js'); } catch (_e) { console.error('[Sentry] Could not load EAS build hooks module. Make sure @sentry/react-native is properly installed.'); process.exit(1); diff --git a/packages/core/test/tools/easBuildHooks.test.ts b/packages/core/test/tools/easBuildHooks.test.ts new file mode 100644 index 0000000000..60d72168af --- /dev/null +++ b/packages/core/test/tools/easBuildHooks.test.ts @@ -0,0 +1,399 @@ +import { + captureEASBuildComplete, + captureEASBuildError, + captureEASBuildSuccess, + getEASBuildEnv, + isEASBuild, +} from '../../src/js/tools/easBuildHooks'; + +// Mock fetch +const mockFetch = jest.fn(); + +// @ts-expect-error - Mocking global fetch +global.fetch = mockFetch; + +describe('EAS Build Hooks', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset environment + process.env = { ...originalEnv }; + // Default successful fetch response + mockFetch.mockResolvedValue({ + status: 200, + headers: { + get: jest.fn().mockReturnValue(null), + }, + }); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('isEASBuild', () => { + it('returns true when EAS_BUILD is "true"', () => { + process.env.EAS_BUILD = 'true'; + expect(isEASBuild()).toBe(true); + }); + + it('returns false when EAS_BUILD is not set', () => { + delete process.env.EAS_BUILD; + expect(isEASBuild()).toBe(false); + }); + + it('returns false when EAS_BUILD is "false"', () => { + process.env.EAS_BUILD = 'false'; + expect(isEASBuild()).toBe(false); + }); + + it('returns false when EAS_BUILD is empty', () => { + process.env.EAS_BUILD = ''; + expect(isEASBuild()).toBe(false); + }); + }); + + describe('getEASBuildEnv', () => { + it('returns all EAS build environment variables', () => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_ID = 'build-123'; + process.env.EAS_BUILD_PLATFORM = 'ios'; + process.env.EAS_BUILD_PROFILE = 'production'; + process.env.EAS_BUILD_PROJECT_ID = 'project-456'; + process.env.EAS_BUILD_GIT_COMMIT_HASH = 'abc123'; + process.env.EAS_BUILD_RUN_FROM_CI = 'true'; + process.env.EAS_BUILD_STATUS = 'finished'; + process.env.EAS_BUILD_APP_VERSION = '1.0.0'; + process.env.EAS_BUILD_APP_BUILD_VERSION = '42'; + process.env.EAS_BUILD_USERNAME = 'testuser'; + process.env.EAS_BUILD_WORKINGDIR = '/build/workdir'; + + const env = getEASBuildEnv(); + + expect(env).toEqual({ + EAS_BUILD: 'true', + EAS_BUILD_ID: 'build-123', + EAS_BUILD_PLATFORM: 'ios', + EAS_BUILD_PROFILE: 'production', + EAS_BUILD_PROJECT_ID: 'project-456', + EAS_BUILD_GIT_COMMIT_HASH: 'abc123', + EAS_BUILD_RUN_FROM_CI: 'true', + EAS_BUILD_STATUS: 'finished', + EAS_BUILD_APP_VERSION: '1.0.0', + EAS_BUILD_APP_BUILD_VERSION: '42', + EAS_BUILD_USERNAME: 'testuser', + EAS_BUILD_WORKINGDIR: '/build/workdir', + }); + }); + + it('returns undefined for unset variables', () => { + delete process.env.EAS_BUILD; + delete process.env.EAS_BUILD_ID; + + const env = getEASBuildEnv(); + + expect(env.EAS_BUILD).toBeUndefined(); + expect(env.EAS_BUILD_ID).toBeUndefined(); + }); + }); + + describe('captureEASBuildError', () => { + beforeEach(() => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_PLATFORM = 'android'; + process.env.EAS_BUILD_PROFILE = 'preview'; + process.env.SENTRY_DSN = 'https://key@sentry.io/123'; + }); + + it('does not capture when DSN is not set', async () => { + delete process.env.SENTRY_DSN; + + await captureEASBuildError(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not capture when not in EAS build environment', async () => { + process.env.EAS_BUILD = 'false'; + + await captureEASBuildError(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('sends error event to Sentry', async () => { + await captureEASBuildError(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('sentry.io/api/123/envelope'), + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + body: expect.stringContaining('EASBuildError'), + }), + ); + }); + + it('includes EAS build tags in the event', async () => { + process.env.EAS_BUILD_ID = 'build-xyz'; + process.env.EAS_BUILD_PROJECT_ID = 'proj-abc'; + + await captureEASBuildError(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"eas.platform":"android"'); + expect(body).toContain('"eas.profile":"preview"'); + expect(body).toContain('"eas.build_id":"build-xyz"'); + expect(body).toContain('"eas.hook":"on-error"'); + }); + + it('uses custom error message when provided', async () => { + await captureEASBuildError({ errorMessage: 'Custom build failure' }); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('Custom build failure'); + }); + + it('uses DSN from options if provided', async () => { + delete process.env.SENTRY_DSN; + + await captureEASBuildError({ dsn: 'https://custom@other.sentry.io/456' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('other.sentry.io/api/456/envelope'), + expect.anything(), + ); + }); + + it('includes fingerprint for grouping', async () => { + await captureEASBuildError(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"fingerprint":["eas-build-error","android","preview"]'); + }); + + it('includes custom tags from options', async () => { + await captureEASBuildError({ + tags: { + 'custom.tag': 'custom-value', + }, + }); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"custom.tag":"custom-value"'); + }); + + it('handles invalid DSN gracefully', async () => { + process.env.SENTRY_DSN = 'invalid-dsn'; + + await captureEASBuildError(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('captureEASBuildSuccess', () => { + beforeEach(() => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_PLATFORM = 'ios'; + process.env.EAS_BUILD_PROFILE = 'production'; + process.env.SENTRY_DSN = 'https://key@sentry.io/123'; + }); + + it('does not capture by default (captureSuccessfulBuilds is false)', async () => { + await captureEASBuildSuccess(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('captures success when captureSuccessfulBuilds is true', async () => { + await captureEASBuildSuccess({ captureSuccessfulBuilds: true }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"level":"info"'); + expect(body).toContain('EAS Build Succeeded'); + expect(body).toContain('"eas.hook":"on-success"'); + }); + + it('uses custom success message when provided', async () => { + await captureEASBuildSuccess({ + captureSuccessfulBuilds: true, + successMessage: 'Build completed successfully!', + }); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('Build completed successfully!'); + }); + + it('does not capture when DSN is not set', async () => { + delete process.env.SENTRY_DSN; + + await captureEASBuildSuccess({ captureSuccessfulBuilds: true }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('captureEASBuildComplete', () => { + beforeEach(() => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_PLATFORM = 'android'; + process.env.EAS_BUILD_PROFILE = 'development'; + process.env.SENTRY_DSN = 'https://key@sentry.io/123'; + }); + + it('captures error when EAS_BUILD_STATUS is "errored"', async () => { + process.env.EAS_BUILD_STATUS = 'errored'; + + await captureEASBuildComplete(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"level":"error"'); + expect(body).toContain('EASBuildError'); + }); + + it('captures success when EAS_BUILD_STATUS is "finished" and captureSuccessfulBuilds is true', async () => { + process.env.EAS_BUILD_STATUS = 'finished'; + + await captureEASBuildComplete({ captureSuccessfulBuilds: true }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body; + + expect(body).toContain('"level":"info"'); + expect(body).toContain('EAS Build Succeeded'); + }); + + it('does not capture success when EAS_BUILD_STATUS is "finished" but captureSuccessfulBuilds is false', async () => { + process.env.EAS_BUILD_STATUS = 'finished'; + + await captureEASBuildComplete({ captureSuccessfulBuilds: false }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not capture anything when status is unknown', async () => { + process.env.EAS_BUILD_STATUS = 'unknown'; + + await captureEASBuildComplete(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not capture when status is undefined and captureSuccessfulBuilds is false', async () => { + delete process.env.EAS_BUILD_STATUS; + + await captureEASBuildComplete(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('envelope format', () => { + beforeEach(() => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_PLATFORM = 'ios'; + process.env.EAS_BUILD_PROFILE = 'staging'; + process.env.SENTRY_DSN = 'https://publickey@sentry.io/123'; + }); + + it('creates valid envelope with correct headers', async () => { + await captureEASBuildError(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body as string; + const lines = body.split('\n'); + + // Envelope should have 3 lines: envelope header, item header, item payload + expect(lines.length).toBe(3); + + // Parse and verify envelope header + const envelopeHeader = JSON.parse(lines[0]); + expect(envelopeHeader).toHaveProperty('event_id'); + expect(envelopeHeader).toHaveProperty('sent_at'); + expect(envelopeHeader.dsn).toContain('sentry.io/123'); + + // Parse and verify item header + const itemHeader = JSON.parse(lines[1]); + expect(itemHeader.type).toBe('event'); + expect(itemHeader.content_type).toBe('application/json'); + + // Parse and verify event payload + const event = JSON.parse(lines[2]); + expect(event.platform).toBe('node'); + expect(event.environment).toBe('eas-build'); + expect(event.level).toBe('error'); + }); + + it('includes EAS build context in the event', async () => { + process.env.EAS_BUILD_ID = 'build-context-test'; + process.env.EAS_BUILD_GIT_COMMIT_HASH = 'commit123'; + process.env.EAS_BUILD_RUN_FROM_CI = 'true'; + + await captureEASBuildError(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body as string; + const lines = body.split('\n'); + const event = JSON.parse(lines[2]); + + expect(event.contexts.eas_build).toEqual( + expect.objectContaining({ + build_id: 'build-context-test', + platform: 'ios', + profile: 'staging', + git_commit: 'commit123', + from_ci: true, + }), + ); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_PLATFORM = 'ios'; + process.env.SENTRY_DSN = 'https://key@sentry.io/123'; + }); + + it('handles fetch failure gracefully', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + // Should not throw + await expect(captureEASBuildError()).resolves.not.toThrow(); + }); + + it('handles non-2xx response gracefully', async () => { + mockFetch.mockResolvedValue({ + status: 429, + headers: { + get: jest.fn().mockReturnValue(null), + }, + }); + + // Should not throw + await expect(captureEASBuildError()).resolves.not.toThrow(); + }); + }); +}); From 9b0d8e767ecbea8e940bd5284ca203a1e7ab4236 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 23 Feb 2026 13:17:21 +0100 Subject: [PATCH 5/8] Fixes, yarn fix --- packages/core/package.json | 4 ++-- packages/core/src/js/tools/easBuildHooks.ts | 6 ++++-- samples/expo/app.json | 6 ++---- yarn.lock | 3 +++ 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 2b86945ffe..bcbe56dd89 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,10 +45,10 @@ "lint:prettier": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --check \"{src,test,scripts,plugin/src}/**/**.ts\"" }, "bin": { - "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js", + "sentry-eas-build-on-complete": "scripts/eas/build-on-complete.js", "sentry-eas-build-on-error": "scripts/eas/build-on-error.js", "sentry-eas-build-on-success": "scripts/eas/build-on-success.js", - "sentry-eas-build-on-complete": "scripts/eas/build-on-complete.js" + "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js" }, "keywords": [ "react-native", diff --git a/packages/core/src/js/tools/easBuildHooks.ts b/packages/core/src/js/tools/easBuildHooks.ts index 20bbdffd87..71b6be4b4c 100644 --- a/packages/core/src/js/tools/easBuildHooks.ts +++ b/packages/core/src/js/tools/easBuildHooks.ts @@ -221,7 +221,8 @@ export async function captureEASBuildError(options: EASBuildHookOptions = {}): P } const env = getEASBuildEnv(); const errorMessage = - options.errorMessage ?? `EAS Build Failed: ${env.EAS_BUILD_PLATFORM ?? 'unknown'} (${env.EAS_BUILD_PROFILE ?? 'unknown'})`; + options.errorMessage ?? + `EAS Build Failed: ${env.EAS_BUILD_PLATFORM ?? 'unknown'} (${env.EAS_BUILD_PROFILE ?? 'unknown'})`; const event = createBaseEvent('error', env, { ...options.tags, 'eas.hook': 'on-error' }); event.exception = { values: [{ type: 'EASBuildError', value: errorMessage, mechanism: { type: 'eas-build-hook', handled: true } }], @@ -253,7 +254,8 @@ export async function captureEASBuildSuccess(options: EASBuildHookOptions = {}): } const env = getEASBuildEnv(); const successMessage = - options.successMessage ?? `EAS Build Succeeded: ${env.EAS_BUILD_PLATFORM ?? 'unknown'} (${env.EAS_BUILD_PROFILE ?? 'unknown'})`; + options.successMessage ?? + `EAS Build Succeeded: ${env.EAS_BUILD_PLATFORM ?? 'unknown'} (${env.EAS_BUILD_PROFILE ?? 'unknown'})`; const event = createBaseEvent('info', env, { ...options.tags, 'eas.hook': 'on-success' }); event.message = { formatted: successMessage }; event.fingerprint = ['eas-build-success', env.EAS_BUILD_PLATFORM ?? 'unknown', env.EAS_BUILD_PROFILE ?? 'unknown']; diff --git a/samples/expo/app.json b/samples/expo/app.json index 46940a4f12..8bf172173c 100644 --- a/samples/expo/app.json +++ b/samples/expo/app.json @@ -14,9 +14,7 @@ "resizeMode": "contain", "backgroundColor": "#ffffff" }, - "assetBundlePatterns": [ - "**/*" - ], + "assetBundlePatterns": ["**/*"], "ios": { "supportsTablet": true, "bundleIdentifier": "io.sentry.expo.sample", @@ -91,4 +89,4 @@ "url": "https://u.expo.dev/00000000-0000-0000-0000-000000000000" } } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 6fc5970fa7..90346730f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11337,6 +11337,9 @@ __metadata: expo: optional: true bin: + sentry-eas-build-on-complete: scripts/eas/build-on-complete.js + sentry-eas-build-on-error: scripts/eas/build-on-error.js + sentry-eas-build-on-success: scripts/eas/build-on-success.js sentry-expo-upload-sourcemaps: scripts/expo-upload-sourcemaps.js languageName: unknown linkType: soft From 0f9e0232bb3c10df7dd0a91d760739efe1e774a7 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 24 Feb 2026 15:07:11 +0100 Subject: [PATCH 6/8] Fixes for build hooks --- CHANGELOG.md | 16 +++++ .../core/scripts/eas/build-on-complete.js | 3 + packages/core/scripts/eas/build-on-error.js | 3 + packages/core/scripts/eas/build-on-success.js | 3 + packages/core/scripts/eas/utils.js | 19 +++++- packages/core/src/js/tools/easBuildHooks.ts | 67 ++++++++++--------- .../core/test/tools/easBuildHooks.test.ts | 59 ++++++++++++++++ samples/expo/package.json | 1 - 8 files changed, 135 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df3bc45fe3..98690ba90f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ ## Unreleased +### Features + +- EAS Build Hooks ([#5666](https://github.com/getsentry/sentry-react-native/pull/5666)) + + - Capture EAS build events in Sentry. Add the following to your `package.json`: + + ```json + { + "scripts": { + "eas-build-on-complete": "sentry-eas-build-on-complete" + } + } + ``` + + Set `SENTRY_DSN` in your EAS secrets, and optionally `SENTRY_EAS_BUILD_CAPTURE_SUCCESS=true` to also capture successful builds. + ### Dependencies - Bump Android SDK from v8.32.0 to v8.33.0 ([#5684](https://github.com/getsentry/sentry-react-native/pull/5684)) diff --git a/packages/core/scripts/eas/build-on-complete.js b/packages/core/scripts/eas/build-on-complete.js index c7988736eb..6049cd3825 100755 --- a/packages/core/scripts/eas/build-on-complete.js +++ b/packages/core/scripts/eas/build-on-complete.js @@ -10,6 +10,9 @@ * * "eas-build-on-complete": "sentry-eas-build-on-complete" * + * NOTE: Use EITHER this hook OR the separate on-error/on-success hooks, not both. + * Using both will result in duplicate events being sent to Sentry. + * * Required environment variables: * - SENTRY_DSN: Your Sentry DSN * diff --git a/packages/core/scripts/eas/build-on-error.js b/packages/core/scripts/eas/build-on-error.js index 186cf2f32d..24f96b3994 100755 --- a/packages/core/scripts/eas/build-on-error.js +++ b/packages/core/scripts/eas/build-on-error.js @@ -7,6 +7,9 @@ * * "eas-build-on-error": "sentry-eas-build-on-error" * + * NOTE: Use EITHER this hook (with on-success) OR the on-complete hook, not both. + * Using both will result in duplicate events being sent to Sentry. + * * Required environment variables: * - SENTRY_DSN: Your Sentry DSN * diff --git a/packages/core/scripts/eas/build-on-success.js b/packages/core/scripts/eas/build-on-success.js index 21e28d65f5..02b6ef4528 100755 --- a/packages/core/scripts/eas/build-on-success.js +++ b/packages/core/scripts/eas/build-on-success.js @@ -7,6 +7,9 @@ * * "eas-build-on-success": "sentry-eas-build-on-success" * + * NOTE: Use EITHER this hook (with on-error) OR the on-complete hook, not both. + * Using both will result in duplicate events being sent to Sentry. + * * Required environment variables: * - SENTRY_DSN: Your Sentry DSN * - SENTRY_EAS_BUILD_CAPTURE_SUCCESS: Set to 'true' to capture successful builds diff --git a/packages/core/scripts/eas/utils.js b/packages/core/scripts/eas/utils.js index 58ac810155..207fc408de 100644 --- a/packages/core/scripts/eas/utils.js +++ b/packages/core/scripts/eas/utils.js @@ -9,11 +9,26 @@ const path = require('path'); const fs = require('fs'); +/** + * Merges parsed env vars into process.env without overwriting existing values. + * This preserves EAS secrets and other pre-set environment variables. + * @param {object} parsed - Parsed environment variables from dotenv + */ +function mergeEnvWithoutOverwrite(parsed) { + for (const key of Object.keys(parsed)) { + if (process.env[key] === undefined) { + process.env[key] = parsed[key]; + } + } +} + /** * Loads environment variables from various sources: * - @expo/env (if available) * - .env file (via dotenv, if available) * - .env.sentry-build-plugin file + * + * NOTE: Existing environment variables (like EAS secrets) are NOT overwritten. */ function loadEnv() { // Try @expo/env first @@ -26,7 +41,7 @@ function loadEnv() { if (fs.existsSync(dotenvPath)) { const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); const dotenv = require('dotenv'); - Object.assign(process.env, dotenv.parse(dotenvFile)); + mergeEnvWithoutOverwrite(dotenv.parse(dotenvFile)); } } catch (_e2) { // No dotenv available, continue with existing env vars @@ -39,7 +54,7 @@ function loadEnv() { if (fs.existsSync(sentryEnvPath)) { const dotenvFile = fs.readFileSync(sentryEnvPath, 'utf-8'); const dotenv = require('dotenv'); - Object.assign(process.env, dotenv.parse(dotenvFile)); + mergeEnvWithoutOverwrite(dotenv.parse(dotenvFile)); } } catch (_e) { // Continue without .env.sentry-build-plugin diff --git a/packages/core/src/js/tools/easBuildHooks.ts b/packages/core/src/js/tools/easBuildHooks.ts index 71b6be4b4c..792a67873d 100644 --- a/packages/core/src/js/tools/easBuildHooks.ts +++ b/packages/core/src/js/tools/easBuildHooks.ts @@ -13,6 +13,9 @@ /* eslint-disable no-console */ /* eslint-disable no-bitwise */ +import type { DsnComponents } from '@sentry/core'; +import { dsnToString, makeDsn } from '@sentry/core'; + const SENTRY_DSN_ENV = 'SENTRY_DSN'; const EAS_BUILD_ENV = 'EAS_BUILD'; @@ -44,13 +47,6 @@ export interface EASBuildHookOptions { successMessage?: string; } -interface ParsedDsn { - protocol: string; - host: string; - projectId: string; - publicKey: string; -} - interface SentryEvent { event_id: string; timestamp: number; @@ -92,18 +88,11 @@ export function getEASBuildEnv(): EASBuildEnv { }; } -function parseDsn(dsn: string): ParsedDsn | undefined { - try { - const url = new URL(dsn); - const projectId = url.pathname.replace('/', ''); - return { protocol: url.protocol.replace(':', ''), host: url.host, projectId, publicKey: url.username }; - } catch { - return undefined; - } -} - -function getEnvelopeEndpoint(dsn: ParsedDsn): string { - return `${dsn.protocol}://${dsn.host}/api/${dsn.projectId}/envelope/?sentry_key=${dsn.publicKey}&sentry_version=7`; +function getEnvelopeEndpoint(dsn: DsnComponents): string { + const { protocol, host, port, path, projectId, publicKey } = dsn; + const portStr = port ? `:${port}` : ''; + const pathStr = path ? `/${path}` : ''; + return `${protocol}://${host}${portStr}${pathStr}/api/${projectId}/envelope/?sentry_key=${publicKey}&sentry_version=7`; } function generateEventId(): string { @@ -154,11 +143,11 @@ function createEASBuildContext(env: EASBuildEnv): Record { }; } -function createEnvelope(event: SentryEvent, dsn: ParsedDsn): string { +function createEnvelope(event: SentryEvent, dsn: DsnComponents): string { const envelopeHeaders = JSON.stringify({ event_id: event.event_id, sent_at: new Date().toISOString(), - dsn: `${dsn.protocol}://${dsn.publicKey}@${dsn.host}/${dsn.projectId}`, + dsn: dsnToString(dsn), sdk: event.sdk, }); const itemHeaders = JSON.stringify({ type: 'event', content_type: 'application/json' }); @@ -166,7 +155,7 @@ function createEnvelope(event: SentryEvent, dsn: ParsedDsn): string { return `${envelopeHeaders}\n${itemHeaders}\n${itemPayload}`; } -async function sendEvent(event: SentryEvent, dsn: ParsedDsn): Promise { +async function sendEvent(event: SentryEvent, dsn: DsnComponents): Promise { const endpoint = getEnvelopeEndpoint(dsn); const envelope = createEnvelope(event, dsn); try { @@ -184,6 +173,18 @@ async function sendEvent(event: SentryEvent, dsn: ParsedDsn): Promise { } } +function getReleaseFromEASEnv(env: EASBuildEnv): string | undefined { + // Honour explicit override first + if (process.env.SENTRY_RELEASE) { + return process.env.SENTRY_RELEASE; + } + // Best approximation without bundle identifier: version+buildNumber + if (env.EAS_BUILD_APP_VERSION && env.EAS_BUILD_APP_BUILD_VERSION) { + return `${env.EAS_BUILD_APP_VERSION}+${env.EAS_BUILD_APP_BUILD_VERSION}`; + } + return env.EAS_BUILD_APP_VERSION; +} + function createBaseEvent( level: 'error' | 'info' | 'warning', env: EASBuildEnv, @@ -196,7 +197,7 @@ function createBaseEvent( level, logger: 'eas-build-hook', environment: 'eas-build', - release: env.EAS_BUILD_APP_VERSION, + release: getReleaseFromEASEnv(env), tags: { ...createEASBuildTags(env), ...customTags }, contexts: { eas_build: createEASBuildContext(env), runtime: { name: 'node', version: process.version } }, sdk: { name: 'sentry.javascript.react-native.eas-build-hooks', version: '1.0.0' }, @@ -205,8 +206,8 @@ function createBaseEvent( /** Captures an EAS build error event. Call this from the eas-build-on-error hook. */ export async function captureEASBuildError(options: EASBuildHookOptions = {}): Promise { - const dsn = options.dsn ?? process.env[SENTRY_DSN_ENV]; - if (!dsn) { + const dsnString = options.dsn ?? process.env[SENTRY_DSN_ENV]; + if (!dsnString) { console.warn('[Sentry] No DSN provided. Set SENTRY_DSN environment variable or pass dsn option.'); return; } @@ -214,8 +215,8 @@ export async function captureEASBuildError(options: EASBuildHookOptions = {}): P console.warn('[Sentry] Not running in EAS Build environment. Skipping error capture.'); return; } - const parsedDsn = parseDsn(dsn); - if (!parsedDsn) { + const dsn = makeDsn(dsnString); + if (!dsn) { console.error('[Sentry] Invalid DSN format.'); return; } @@ -228,7 +229,7 @@ export async function captureEASBuildError(options: EASBuildHookOptions = {}): P values: [{ type: 'EASBuildError', value: errorMessage, mechanism: { type: 'eas-build-hook', handled: true } }], }; event.fingerprint = ['eas-build-error', env.EAS_BUILD_PLATFORM ?? 'unknown', env.EAS_BUILD_PROFILE ?? 'unknown']; - const success = await sendEvent(event, parsedDsn); + const success = await sendEvent(event, dsn); if (success) console.log('[Sentry] Build error captured.'); } @@ -238,8 +239,8 @@ export async function captureEASBuildSuccess(options: EASBuildHookOptions = {}): console.log('[Sentry] Skipping successful build capture (captureSuccessfulBuilds is false).'); return; } - const dsn = options.dsn ?? process.env[SENTRY_DSN_ENV]; - if (!dsn) { + const dsnString = options.dsn ?? process.env[SENTRY_DSN_ENV]; + if (!dsnString) { console.warn('[Sentry] No DSN provided. Set SENTRY_DSN environment variable or pass dsn option.'); return; } @@ -247,8 +248,8 @@ export async function captureEASBuildSuccess(options: EASBuildHookOptions = {}): console.warn('[Sentry] Not running in EAS Build environment. Skipping success capture.'); return; } - const parsedDsn = parseDsn(dsn); - if (!parsedDsn) { + const dsn = makeDsn(dsnString); + if (!dsn) { console.error('[Sentry] Invalid DSN format.'); return; } @@ -259,7 +260,7 @@ export async function captureEASBuildSuccess(options: EASBuildHookOptions = {}): const event = createBaseEvent('info', env, { ...options.tags, 'eas.hook': 'on-success' }); event.message = { formatted: successMessage }; event.fingerprint = ['eas-build-success', env.EAS_BUILD_PLATFORM ?? 'unknown', env.EAS_BUILD_PROFILE ?? 'unknown']; - const success = await sendEvent(event, parsedDsn); + const success = await sendEvent(event, dsn); if (success) console.log('[Sentry] Build success captured.'); } diff --git a/packages/core/test/tools/easBuildHooks.test.ts b/packages/core/test/tools/easBuildHooks.test.ts index 60d72168af..99141ac36c 100644 --- a/packages/core/test/tools/easBuildHooks.test.ts +++ b/packages/core/test/tools/easBuildHooks.test.ts @@ -310,6 +310,65 @@ describe('EAS Build Hooks', () => { }); }); + describe('release naming', () => { + beforeEach(() => { + process.env.EAS_BUILD = 'true'; + process.env.EAS_BUILD_PLATFORM = 'ios'; + process.env.SENTRY_DSN = 'https://key@sentry.io/123'; + delete process.env.SENTRY_RELEASE; + delete process.env.EAS_BUILD_APP_VERSION; + delete process.env.EAS_BUILD_APP_BUILD_VERSION; + }); + + it('uses SENTRY_RELEASE when set', async () => { + process.env.SENTRY_RELEASE = 'custom-release@1.0.0'; + process.env.EAS_BUILD_APP_VERSION = '2.0.0'; + + await captureEASBuildError(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body as string; + const event = JSON.parse(body.split('\n')[2]); + + expect(event.release).toBe('custom-release@1.0.0'); + }); + + it('combines version and build number when both are available', async () => { + process.env.EAS_BUILD_APP_VERSION = '1.2.3'; + process.env.EAS_BUILD_APP_BUILD_VERSION = '42'; + + await captureEASBuildError(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body as string; + const event = JSON.parse(body.split('\n')[2]); + + expect(event.release).toBe('1.2.3+42'); + }); + + it('uses only version when build number is not available', async () => { + process.env.EAS_BUILD_APP_VERSION = '1.2.3'; + + await captureEASBuildError(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body as string; + const event = JSON.parse(body.split('\n')[2]); + + expect(event.release).toBe('1.2.3'); + }); + + it('sets release to undefined when no version info is available', async () => { + await captureEASBuildError(); + + const fetchCall = mockFetch.mock.calls[0]; + const body = fetchCall[1].body as string; + const event = JSON.parse(body.split('\n')[2]); + + expect(event.release).toBeUndefined(); + }); + }); + describe('envelope format', () => { beforeEach(() => { process.env.EAS_BUILD = 'true'; diff --git a/samples/expo/package.json b/samples/expo/package.json index edc6f20e41..94f2790cd3 100644 --- a/samples/expo/package.json +++ b/samples/expo/package.json @@ -17,7 +17,6 @@ "prebuild": "expo prebuild --clean --no-install", "set-version": "npx react-native-version --skip-tag --never-amend", "eas-build-pre-install": "npm i -g corepack && yarn install --no-immutable --inline-builds && yarn workspace @sentry/react-native build", - "eas-build-on-error": "sentry-eas-build-on-error", "eas-build-on-complete": "sentry-eas-build-on-complete", "eas-update-configure": "eas update:configure", "eas-update-publish-development": "eas update --channel development --message 'Development update'", From 0c98e594c89012839ff4feb3902b52149bc0dab5 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 25 Feb 2026 09:33:29 +0100 Subject: [PATCH 7/8] PR post-review fixes --- packages/core/scripts/eas/build-on-complete.js | 5 ++++- packages/core/scripts/eas/build-on-error.js | 5 ++++- packages/core/scripts/eas/build-on-success.js | 5 ++++- packages/core/scripts/eas/utils.js | 7 ++++++- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/core/scripts/eas/build-on-complete.js b/packages/core/scripts/eas/build-on-complete.js index 6049cd3825..70c519e97e 100755 --- a/packages/core/scripts/eas/build-on-complete.js +++ b/packages/core/scripts/eas/build-on-complete.js @@ -45,4 +45,7 @@ async function main() { await runHook('on-complete', () => hooks.captureEASBuildComplete(options)); } -main(); +main().catch(error => { + console.error('[Sentry] Unexpected error in eas-build-on-complete hook:', error); + process.exit(1); +}); diff --git a/packages/core/scripts/eas/build-on-error.js b/packages/core/scripts/eas/build-on-error.js index 24f96b3994..f1b2e21cd9 100755 --- a/packages/core/scripts/eas/build-on-error.js +++ b/packages/core/scripts/eas/build-on-error.js @@ -35,4 +35,7 @@ async function main() { await runHook('on-error', () => hooks.captureEASBuildError(options)); } -main(); +main().catch(error => { + console.error('[Sentry] Unexpected error in eas-build-on-error hook:', error); + process.exit(1); +}); diff --git a/packages/core/scripts/eas/build-on-success.js b/packages/core/scripts/eas/build-on-success.js index 02b6ef4528..b907e7d5cd 100755 --- a/packages/core/scripts/eas/build-on-success.js +++ b/packages/core/scripts/eas/build-on-success.js @@ -37,4 +37,7 @@ async function main() { await runHook('on-success', () => hooks.captureEASBuildSuccess(options)); } -main(); +main().catch(error => { + console.error('[Sentry] Unexpected error in eas-build-on-success hook:', error); + process.exit(1); +}); diff --git a/packages/core/scripts/eas/utils.js b/packages/core/scripts/eas/utils.js index 207fc408de..01a1fa5d24 100644 --- a/packages/core/scripts/eas/utils.js +++ b/packages/core/scripts/eas/utils.js @@ -87,7 +87,12 @@ function parseBaseOptions() { // Parse additional tags if provided if (process.env.SENTRY_EAS_BUILD_TAGS) { try { - options.tags = JSON.parse(process.env.SENTRY_EAS_BUILD_TAGS); + const parsed = JSON.parse(process.env.SENTRY_EAS_BUILD_TAGS); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + options.tags = parsed; + } else { + console.warn('[Sentry] SENTRY_EAS_BUILD_TAGS must be a JSON object (e.g., {"key":"value"}). Ignoring.'); + } } catch (_e) { console.warn('[Sentry] Could not parse SENTRY_EAS_BUILD_TAGS as JSON. Ignoring.'); } From d8aed6ed8d4426e51441f5ab3a18ccdf1c217066 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 26 Feb 2026 15:22:42 +0100 Subject: [PATCH 8/8] Fixed hooks files --- .../core/scripts/eas/build-on-complete.js | 2 ++ packages/core/scripts/eas/build-on-error.js | 1 + packages/core/scripts/eas/build-on-success.js | 1 + packages/core/src/js/tools/easBuildHooks.ts | 25 ++----------------- 4 files changed, 6 insertions(+), 23 deletions(-) diff --git a/packages/core/scripts/eas/build-on-complete.js b/packages/core/scripts/eas/build-on-complete.js index 70c519e97e..7886838637 100755 --- a/packages/core/scripts/eas/build-on-complete.js +++ b/packages/core/scripts/eas/build-on-complete.js @@ -27,6 +27,7 @@ * * @see https://docs.expo.dev/build-reference/npm-hooks/ * @see https://docs.sentry.io/platforms/react-native/ + * */ const { loadEnv, loadHooksModule, parseBaseOptions, runHook } = require('./utils'); @@ -46,6 +47,7 @@ async function main() { } main().catch(error => { + // eslint-disable-next-line no-console console.error('[Sentry] Unexpected error in eas-build-on-complete hook:', error); process.exit(1); }); diff --git a/packages/core/scripts/eas/build-on-error.js b/packages/core/scripts/eas/build-on-error.js index f1b2e21cd9..7e8252be5a 100755 --- a/packages/core/scripts/eas/build-on-error.js +++ b/packages/core/scripts/eas/build-on-error.js @@ -36,6 +36,7 @@ async function main() { } main().catch(error => { + // eslint-disable-next-line no-console console.error('[Sentry] Unexpected error in eas-build-on-error hook:', error); process.exit(1); }); diff --git a/packages/core/scripts/eas/build-on-success.js b/packages/core/scripts/eas/build-on-success.js index b907e7d5cd..808c1c4119 100755 --- a/packages/core/scripts/eas/build-on-success.js +++ b/packages/core/scripts/eas/build-on-success.js @@ -38,6 +38,7 @@ async function main() { } main().catch(error => { + // eslint-disable-next-line no-console console.error('[Sentry] Unexpected error in eas-build-on-success hook:', error); process.exit(1); }); diff --git a/packages/core/src/js/tools/easBuildHooks.ts b/packages/core/src/js/tools/easBuildHooks.ts index 792a67873d..c3ca410e9f 100644 --- a/packages/core/src/js/tools/easBuildHooks.ts +++ b/packages/core/src/js/tools/easBuildHooks.ts @@ -11,10 +11,9 @@ */ /* eslint-disable no-console */ -/* eslint-disable no-bitwise */ import type { DsnComponents } from '@sentry/core'; -import { dsnToString, makeDsn } from '@sentry/core'; +import { dsnToString, makeDsn, uuid4 } from '@sentry/core'; const SENTRY_DSN_ENV = 'SENTRY_DSN'; const EAS_BUILD_ENV = 'EAS_BUILD'; @@ -95,26 +94,6 @@ function getEnvelopeEndpoint(dsn: DsnComponents): string { return `${protocol}://${host}${portStr}${pathStr}/api/${projectId}/envelope/?sentry_key=${publicKey}&sentry_version=7`; } -function generateEventId(): string { - const bytes = new Uint8Array(16); - if (typeof crypto !== 'undefined' && crypto.getRandomValues) { - crypto.getRandomValues(bytes); - } else { - for (let i = 0; i < 16; i++) { - bytes[i] = Math.floor(Math.random() * 256); - } - } - const byte6 = bytes[6]; - const byte8 = bytes[8]; - if (byte6 !== undefined && byte8 !== undefined) { - bytes[6] = (byte6 & 0x0f) | 0x40; - bytes[8] = (byte8 & 0x3f) | 0x80; - } - return Array.from(bytes) - .map(b => b.toString(16).padStart(2, '0')) - .join(''); -} - function createEASBuildTags(env: EASBuildEnv): Record { const tags: Record = {}; if (env.EAS_BUILD_PLATFORM) tags['eas.platform'] = env.EAS_BUILD_PLATFORM; @@ -191,7 +170,7 @@ function createBaseEvent( customTags?: Record, ): SentryEvent { return { - event_id: generateEventId(), + event_id: uuid4(), timestamp: Date.now() / 1000, platform: 'node', level,