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/package.json b/packages/core/package.json index b367cf6cb9..bcbe56dd89 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,6 +45,9 @@ "lint:prettier": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --check \"{src,test,scripts,plugin/src}/**/**.ts\"" }, "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" }, "keywords": [ 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..70c519e97e --- /dev/null +++ b/packages/core/scripts/eas/build-on-complete.js @@ -0,0 +1,51 @@ +#!/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" + * + * 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 + * + * 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 { loadEnv, loadHooksModule, parseBaseOptions, runHook } = require('./utils'); + +async function main() { + loadEnv(); + + const hooks = loadHooksModule(); + const options = { + ...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', + }; + + await runHook('on-complete', () => hooks.captureEASBuildComplete(options)); +} + +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 new file mode 100755 index 0000000000..f1b2e21cd9 --- /dev/null +++ b/packages/core/scripts/eas/build-on-error.js @@ -0,0 +1,41 @@ +#!/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" + * + * 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 + * + * 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 { loadEnv, loadHooksModule, parseBaseOptions, runHook } = require('./utils'); + +async function main() { + loadEnv(); + + const hooks = loadHooksModule(); + const options = { + ...parseBaseOptions(), + errorMessage: process.env.SENTRY_EAS_BUILD_ERROR_MESSAGE, + }; + + await runHook('on-error', () => hooks.captureEASBuildError(options)); +} + +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 new file mode 100755 index 0000000000..b907e7d5cd --- /dev/null +++ b/packages/core/scripts/eas/build-on-success.js @@ -0,0 +1,43 @@ +#!/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" + * + * 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 + * + * 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 { loadEnv, loadHooksModule, parseBaseOptions, runHook } = require('./utils'); + +async function main() { + loadEnv(); + + const hooks = loadHooksModule(); + const options = { + ...parseBaseOptions(), + successMessage: process.env.SENTRY_EAS_BUILD_SUCCESS_MESSAGE, + captureSuccessfulBuilds: process.env.SENTRY_EAS_BUILD_CAPTURE_SUCCESS === 'true', + }; + + await runHook('on-success', () => hooks.captureEASBuildSuccess(options)); +} + +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 new file mode 100644 index 0000000000..01a1fa5d24 --- /dev/null +++ b/packages/core/scripts/eas/utils.js @@ -0,0 +1,124 @@ +/** + * Shared utilities for EAS Build Hook scripts. + * + * @see https://docs.expo.dev/build-reference/npm-hooks/ + */ + +/* eslint-disable no-console */ + +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 + 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'); + mergeEnvWithoutOverwrite(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'); + mergeEnvWithoutOverwrite(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 { + 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.'); + } + } + + 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, +}; diff --git a/packages/core/src/js/tools/easBuildHooks.ts b/packages/core/src/js/tools/easBuildHooks.ts new file mode 100644 index 0000000000..792a67873d --- /dev/null +++ b/packages/core/src/js/tools/easBuildHooks.ts @@ -0,0 +1,280 @@ +/** + * 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 */ + +import type { DsnComponents } from '@sentry/core'; +import { dsnToString, makeDsn } from '@sentry/core'; + +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 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 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 { + 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: DsnComponents): string { + const envelopeHeaders = JSON.stringify({ + event_id: event.event_id, + sent_at: new Date().toISOString(), + dsn: dsnToString(dsn), + 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: DsnComponents): 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 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, + customTags?: Record, +): SentryEvent { + return { + event_id: generateEventId(), + timestamp: Date.now() / 1000, + platform: 'node', + level, + logger: 'eas-build-hook', + environment: 'eas-build', + 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' }, + }; +} + +/** Captures an EAS build error event. Call this from the eas-build-on-error hook. */ +export async function captureEASBuildError(options: EASBuildHookOptions = {}): Promise { + 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; + } + if (!isEASBuild()) { + console.warn('[Sentry] Not running in EAS Build environment. Skipping error capture.'); + return; + } + const dsn = makeDsn(dsnString); + if (!dsn) { + 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, dsn); + 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 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; + } + if (!isEASBuild()) { + console.warn('[Sentry] Not running in EAS Build environment. Skipping success capture.'); + return; + } + const dsn = makeDsn(dsnString); + if (!dsn) { + 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, dsn); + 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..99141ac36c --- /dev/null +++ b/packages/core/test/tools/easBuildHooks.test.ts @@ -0,0 +1,458 @@ +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('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'; + 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/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/samples/expo/package.json b/samples/expo/package.json index 4d6db37c2f..94f2790cd3 100644 --- a/samples/expo/package.json +++ b/samples/expo/package.json @@ -17,6 +17,7 @@ "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-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" 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