From ff0a7b8582cfb962bc3c1cab607098769699c8e9 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 20 May 2026 12:47:51 -0700 Subject: [PATCH 1/9] [build-tools] Auto-upload embedded bundle after build when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set --- packages/build-tools/src/builders/android.ts | 11 +++ packages/build-tools/src/builders/ios.ts | 11 +++ .../src/utils/expoUpdatesEmbedded.ts | 95 +++++++++++++++++++ packages/eas-build-job/src/logs.ts | 3 + 4 files changed, 120 insertions(+) create mode 100644 packages/build-tools/src/utils/expoUpdatesEmbedded.ts diff --git a/packages/build-tools/src/builders/android.ts b/packages/build-tools/src/builders/android.ts index c48e80f631..accb8c6dec 100644 --- a/packages/build-tools/src/builders/android.ts +++ b/packages/build-tools/src/builders/android.ts @@ -30,8 +30,10 @@ import { import { uploadApplicationArchive } from '../utils/artifacts'; import { configureExpoUpdatesIfInstalledAsync, + isEASUpdateConfigured, resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync, } from '../utils/expoUpdates'; +import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded'; import { Hook, runHookIfPresent } from '../utils/hooks'; import { prepareExecutableAsync } from '../utils/prepareBuildExecutable'; @@ -208,6 +210,15 @@ async function buildAsync(ctx: BuildContext): Promise { }); }); + if ( + ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE && + (await isEASUpdateConfigured(ctx)) + ) { + await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => { + await uploadEmbeddedBundleAsync(ctx); + }); + } + await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => { if (ctx.isLocal) { ctx.logger.info('Local builds do not support saving cache.'); diff --git a/packages/build-tools/src/builders/ios.ts b/packages/build-tools/src/builders/ios.ts index 5d0ad25069..33da7a2257 100644 --- a/packages/build-tools/src/builders/ios.ts +++ b/packages/build-tools/src/builders/ios.ts @@ -23,8 +23,10 @@ import { saveCcacheAsync } from '../steps/functions/saveBuildCache'; import { uploadApplicationArchive } from '../utils/artifacts'; import { configureExpoUpdatesIfInstalledAsync, + isEASUpdateConfigured, resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync, } from '../utils/expoUpdates'; +import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded'; import { Hook, runHookIfPresent } from '../utils/hooks'; import { prepareExecutableAsync } from '../utils/prepareBuildExecutable'; import { getParentAndDescendantProcessPidsAsync } from '../utils/processes'; @@ -209,6 +211,15 @@ async function buildAsync(ctx: BuildContext): Promise { }); }); + if ( + ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE && + (await isEASUpdateConfigured(ctx)) + ) { + await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => { + await uploadEmbeddedBundleAsync(ctx); + }); + } + await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => { if (ctx.isLocal) { ctx.logger.info('Local builds do not support saving cache.'); diff --git a/packages/build-tools/src/utils/expoUpdatesEmbedded.ts b/packages/build-tools/src/utils/expoUpdatesEmbedded.ts new file mode 100644 index 0000000000..ea28c79bb3 --- /dev/null +++ b/packages/build-tools/src/utils/expoUpdatesEmbedded.ts @@ -0,0 +1,95 @@ +import { Android, BuildJob, Ios, Platform } from '@expo/eas-build-job'; +import { PipeMode } from '@expo/logger'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; +import StreamZip from 'node-stream-zip'; + +import { findArtifacts } from './artifacts'; +import { runEasCliCommand } from './easCli'; +import { resolveArtifactPath } from '../ios/resolve'; +import { BuildContext } from '../context'; + +export async function uploadEmbeddedBundleAsync(ctx: BuildContext): Promise { + const { platform } = ctx.job; + const channel = ctx.job.updates?.channel; + const projectDir = ctx.getReactNativeProjectDirectory(); + + const archivePattern = + platform === Platform.IOS + ? resolveArtifactPath(ctx as BuildContext) + : ((ctx as BuildContext).job.applicationArchivePath ?? + 'android/app/build/outputs/**/*.{apk,aab}'); + + const [archivePath] = await findArtifacts({ + rootDir: projectDir, + patternOrPath: archivePattern, + logger: null, + }).catch(() => [] as string[]); + + if (!channel || !archivePath) { + ctx.logger.warn( + `Skipping embedded bundle upload: ${!channel ? 'no channel configured for this build profile' : 'build archive not found'}.` + ); + ctx.markBuildPhaseHasWarnings(); + return; + } + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-embedded-bundle-')); + const zip = new StreamZip.async({ file: archivePath }); + try { + const entries = Object.values(await zip.entries()); + const bundleEntry = entries.find(e => + platform === Platform.IOS + ? e.name.endsWith('/main.jsbundle') + : e.name === 'assets/index.android.bundle' + ); + const manifestEntry = entries.find(e => + platform === Platform.IOS + ? e.name.includes('EXUpdates.bundle/app.manifest') + : e.name === 'assets/app.manifest' + ); + + if (!bundleEntry || !manifestEntry) { + ctx.logger.warn('Skipping embedded bundle upload: bundle or manifest not found in archive.'); + ctx.markBuildPhaseHasWarnings(); + return; + } + + const bundleName = platform === Platform.IOS ? 'main.jsbundle' : 'index.android.bundle'; + const bundlePath = path.join(tmpDir, bundleName); + const manifestPath = path.join(tmpDir, 'app.manifest'); + await zip.extract(bundleEntry.name, bundlePath); + await zip.extract(manifestEntry.name, manifestPath); + + const args = [ + 'update:embedded:upload', + '--platform', + platform, + '--bundle', + bundlePath, + '--manifest', + manifestPath, + '--channel', + channel, + '--non-interactive', + ]; + if (ctx.env.EAS_BUILD_ID) { + args.push('--build-id', ctx.env.EAS_BUILD_ID); + } + await runEasCliCommand({ + args, + options: { + cwd: projectDir, + env: ctx.env, + logger: ctx.logger, + mode: PipeMode.STDERR_ONLY_AS_STDOUT, + }, + }); + } catch (err: any) { + ctx.logger.warn({ err }, 'Failed to upload embedded bundle.'); + ctx.markBuildPhaseHasWarnings(); + } finally { + await zip.close(); + } +} diff --git a/packages/eas-build-job/src/logs.ts b/packages/eas-build-job/src/logs.ts index 38cdb8d832..8830f32e5f 100644 --- a/packages/eas-build-job/src/logs.ts +++ b/packages/eas-build-job/src/logs.ts @@ -27,6 +27,7 @@ export enum BuildPhase { */ UPLOAD_ARTIFACTS = 'UPLOAD_ARTIFACTS', UPLOAD_APPLICATION_ARCHIVE = 'UPLOAD_APPLICATION_ARCHIVE', + UPLOAD_EMBEDDED_BUNDLE = 'UPLOAD_EMBEDDED_BUNDLE', UPLOAD_BUILD_ARTIFACTS = 'UPLOAD_BUILD_ARTIFACTS', PREPARE_ARTIFACTS = 'PREPARE_ARTIFACTS', CLEAN_UP_CREDENTIALS = 'CLEAN_UP_CREDENTIALS', @@ -91,6 +92,7 @@ export const buildPhaseDisplayName: Record = { [BuildPhase.CACHE_STATS]: 'Cache stats', [BuildPhase.UPLOAD_ARTIFACTS]: 'Upload artifacts', [BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'Upload application archive', + [BuildPhase.UPLOAD_EMBEDDED_BUNDLE]: 'Upload embedded bundle', [BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'Upload build artifacts', [BuildPhase.PREPARE_ARTIFACTS]: 'Prepare artifacts', [BuildPhase.CLEAN_UP_CREDENTIALS]: 'Clean up credentials', @@ -157,6 +159,7 @@ export const buildPhaseWebsiteId: Record = { [BuildPhase.CACHE_STATS]: 'cache-stats', [BuildPhase.UPLOAD_ARTIFACTS]: 'upload-artifacts', [BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'upload-application-archive', + [BuildPhase.UPLOAD_EMBEDDED_BUNDLE]: 'upload-embedded-bundle', [BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'upload-build-artifacts', [BuildPhase.PREPARE_ARTIFACTS]: 'prepare-artifacts', [BuildPhase.CLEAN_UP_CREDENTIALS]: 'clean-up-credentials', From 15d4feeebd18596ea35137672cce04a0bec63d6a Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 20 May 2026 12:52:52 -0700 Subject: [PATCH 2/9] [build-tools] Add changelog entry for #3767 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f0a03c2a6..1c959f735f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] `eas go` now prompts to select an Expo SDK version interactively when `--sdk-version` is not provided. ([#3768](https://github.com/expo/eas-cli/pull/3768) by [@gwdp](https://github.com/gwdp)) - [eas-cli] Add `eas update:embedded:upload` command. ([#3720](https://github.com/expo/eas-cli/pull/3720) by [@gwdp](https://github.com/gwdp)) +- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp)) ### ๐Ÿ› Bug fixes From a5d7488392fc532d43bee3a50859734bcbaefde1 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 20 May 2026 14:19:44 -0700 Subject: [PATCH 3/9] Move isEASUpdateConfigured check inside uploadEmbeddedBundleAsync --- packages/build-tools/src/builders/android.ts | 6 +----- packages/build-tools/src/builders/ios.ts | 6 +----- packages/build-tools/src/utils/expoUpdatesEmbedded.ts | 6 ++++++ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/build-tools/src/builders/android.ts b/packages/build-tools/src/builders/android.ts index accb8c6dec..6ccff6ca09 100644 --- a/packages/build-tools/src/builders/android.ts +++ b/packages/build-tools/src/builders/android.ts @@ -30,7 +30,6 @@ import { import { uploadApplicationArchive } from '../utils/artifacts'; import { configureExpoUpdatesIfInstalledAsync, - isEASUpdateConfigured, resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync, } from '../utils/expoUpdates'; import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded'; @@ -210,10 +209,7 @@ async function buildAsync(ctx: BuildContext): Promise { }); }); - if ( - ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE && - (await isEASUpdateConfigured(ctx)) - ) { + if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) { await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => { await uploadEmbeddedBundleAsync(ctx); }); diff --git a/packages/build-tools/src/builders/ios.ts b/packages/build-tools/src/builders/ios.ts index 33da7a2257..2ad68487fc 100644 --- a/packages/build-tools/src/builders/ios.ts +++ b/packages/build-tools/src/builders/ios.ts @@ -23,7 +23,6 @@ import { saveCcacheAsync } from '../steps/functions/saveBuildCache'; import { uploadApplicationArchive } from '../utils/artifacts'; import { configureExpoUpdatesIfInstalledAsync, - isEASUpdateConfigured, resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync, } from '../utils/expoUpdates'; import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded'; @@ -211,10 +210,7 @@ async function buildAsync(ctx: BuildContext): Promise { }); }); - if ( - ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE && - (await isEASUpdateConfigured(ctx)) - ) { + if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) { await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => { await uploadEmbeddedBundleAsync(ctx); }); diff --git a/packages/build-tools/src/utils/expoUpdatesEmbedded.ts b/packages/build-tools/src/utils/expoUpdatesEmbedded.ts index ea28c79bb3..38949ac2ee 100644 --- a/packages/build-tools/src/utils/expoUpdatesEmbedded.ts +++ b/packages/build-tools/src/utils/expoUpdatesEmbedded.ts @@ -9,8 +9,14 @@ import { findArtifacts } from './artifacts'; import { runEasCliCommand } from './easCli'; import { resolveArtifactPath } from '../ios/resolve'; import { BuildContext } from '../context'; +import { isEASUpdateConfigured } from './expoUpdates'; export async function uploadEmbeddedBundleAsync(ctx: BuildContext): Promise { + if (!(await isEASUpdateConfigured(ctx))) { + ctx.markBuildPhaseSkipped(); + return; + } + const { platform } = ctx.job; const channel = ctx.job.updates?.channel; const projectDir = ctx.getReactNativeProjectDirectory(); From 99eff79028295960fb24031da7646e9501266311 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 20 May 2026 14:57:49 -0700 Subject: [PATCH 4/9] Skip simulator builds; use endsWith for Android archive entry matching --- .../build-tools/src/utils/expoUpdatesEmbedded.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/build-tools/src/utils/expoUpdatesEmbedded.ts b/packages/build-tools/src/utils/expoUpdatesEmbedded.ts index 38949ac2ee..7892ba08fd 100644 --- a/packages/build-tools/src/utils/expoUpdatesEmbedded.ts +++ b/packages/build-tools/src/utils/expoUpdatesEmbedded.ts @@ -18,6 +18,14 @@ export async function uploadEmbeddedBundleAsync(ctx: BuildContext): Pr } const { platform } = ctx.job; + if (platform === Platform.IOS && (ctx.job as Ios.Job).simulator) { + ctx.logger.info( + 'Skipping embedded bundle upload: simulator builds do not embed release update bundles.' + ); + ctx.markBuildPhaseSkipped(); + return; + } + const channel = ctx.job.updates?.channel; const projectDir = ctx.getReactNativeProjectDirectory(); @@ -48,12 +56,12 @@ export async function uploadEmbeddedBundleAsync(ctx: BuildContext): Pr const bundleEntry = entries.find(e => platform === Platform.IOS ? e.name.endsWith('/main.jsbundle') - : e.name === 'assets/index.android.bundle' + : e.name.endsWith('assets/index.android.bundle') ); const manifestEntry = entries.find(e => platform === Platform.IOS ? e.name.includes('EXUpdates.bundle/app.manifest') - : e.name === 'assets/app.manifest' + : e.name.endsWith('assets/app.manifest') ); if (!bundleEntry || !manifestEntry) { From dab0ff73c084e6bf5e1d8ba66363af8fe9b14a42 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 20 May 2026 15:56:45 -0700 Subject: [PATCH 5/9] Skip simulator builds; add tests for uploadEmbeddedBundleAsync --- .../__tests__/expoUpdatesEmbedded.test.ts | 200 ++++++++++++++++++ .../src/utils/expoUpdatesEmbedded.ts | 9 +- 2 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 packages/build-tools/src/utils/__tests__/expoUpdatesEmbedded.test.ts diff --git a/packages/build-tools/src/utils/__tests__/expoUpdatesEmbedded.test.ts b/packages/build-tools/src/utils/__tests__/expoUpdatesEmbedded.test.ts new file mode 100644 index 0000000000..639e192143 --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/expoUpdatesEmbedded.test.ts @@ -0,0 +1,200 @@ +import { Platform } from '@expo/eas-build-job'; + +import { BuildContext } from '../../context'; +import * as expoUpdates from '../expoUpdates'; +import { uploadEmbeddedBundleAsync } from '../expoUpdatesEmbedded'; +import * as easCli from '../easCli'; +import * as artifacts from '../artifacts'; + +jest.mock('../expoUpdates'); +jest.mock('../easCli'); +jest.mock('../artifacts'); + +const mockZipEntries = jest.fn(); +const mockZipExtract = jest.fn(); +const mockZipClose = jest.fn(); + +jest.mock('node-stream-zip', () => ({ + __esModule: true, + default: { + async: jest.fn(() => ({ + entries: mockZipEntries, + extract: mockZipExtract, + close: mockZipClose, + })), + }, +})); + +function zipEntryMap(entries: Record): Record { + return Object.fromEntries(Object.keys(entries).map(name => [name, { name }])); +} + +function makeCtx(overrides: { + platform: Platform; + simulator?: boolean; + channel?: string; + env?: Record; +}): BuildContext { + const job = + overrides.platform === Platform.IOS + ? { + platform: Platform.IOS, + simulator: overrides.simulator ?? false, + updates: overrides.channel ? { channel: overrides.channel } : undefined, + } + : { + platform: Platform.ANDROID, + updates: overrides.channel ? { channel: overrides.channel } : undefined, + }; + + return { + job, + env: overrides.env ?? {}, + appConfig: Promise.resolve({ + updates: { url: 'https://u.expo.dev/project-id' }, + }), + logger: { + info: jest.fn(), + warn: jest.fn(), + }, + markBuildPhaseSkipped: jest.fn(), + markBuildPhaseHasWarnings: jest.fn(), + getReactNativeProjectDirectory: () => '/project', + } as any; +} + +describe('uploadEmbeddedBundleAsync', () => { + beforeEach(() => { + jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(true); + jest.mocked(easCli.runEasCliCommand).mockResolvedValue({} as any); + jest.mocked(artifacts.findArtifacts).mockResolvedValue([]); + mockZipEntries.mockResolvedValue({}); + mockZipExtract.mockResolvedValue(undefined); + mockZipClose.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('skips when EAS Update is not configured', async () => { + jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(false); + const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled(); + expect(artifacts.findArtifacts).not.toHaveBeenCalled(); + }); + + it('warns when no channel is configured', async () => { + const ctx = makeCtx({ platform: Platform.ANDROID }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(ctx.logger.warn).toHaveBeenCalledWith( + 'Skipping embedded bundle upload: no channel configured for this build profile.' + ); + expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled(); + }); + + it('uploads from Android APK archives', async () => { + jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']); + mockZipEntries.mockResolvedValue( + zipEntryMap({ + 'assets/index.android.bundle': true, + 'assets/app.manifest': true, + }) + ); + const ctx = makeCtx({ + platform: Platform.ANDROID, + channel: 'production', + env: { EAS_BUILD_ID: 'build-123' }, + }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(mockZipExtract).toHaveBeenCalledWith( + 'assets/index.android.bundle', + expect.stringContaining('index.android.bundle') + ); + expect(easCli.runEasCliCommand).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining([ + 'update:embedded:upload', + '--platform', + Platform.ANDROID, + '--channel', + 'production', + '--build-id', + 'build-123', + ]), + }) + ); + }); + + it('uploads from Android AAB archives', async () => { + jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.aab']); + mockZipEntries.mockResolvedValue( + zipEntryMap({ + 'base/assets/index.android.bundle': true, + 'base/assets/app.manifest': true, + }) + ); + const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(mockZipExtract).toHaveBeenCalledWith( + 'base/assets/index.android.bundle', + expect.stringContaining('index.android.bundle') + ); + expect(easCli.runEasCliCommand).toHaveBeenCalled(); + }); + + it('uploads from iOS IPA archives', async () => { + jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/App.ipa']); + mockZipEntries.mockResolvedValue( + zipEntryMap({ + 'Payload/App.app/main.jsbundle': true, + 'Payload/App.app/EXUpdates.bundle/app.manifest': true, + }) + ); + const ctx = makeCtx({ platform: Platform.IOS, channel: 'production' }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(easCli.runEasCliCommand).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining(['--platform', Platform.IOS]), + }) + ); + }); + + it('skips simulator builds', async () => { + const ctx = makeCtx({ platform: Platform.IOS, simulator: true, channel: 'preview' }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled(); + expect(artifacts.findArtifacts).not.toHaveBeenCalled(); + }); + + it('warns when bundle or manifest is missing from the archive', async () => { + jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']); + mockZipEntries.mockResolvedValue( + zipEntryMap({ + 'assets/app.manifest': true, + }) + ); + const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(ctx.logger.warn).toHaveBeenCalledWith( + 'Skipping embedded bundle upload: bundle or manifest not found in archive.' + ); + expect(easCli.runEasCliCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/build-tools/src/utils/expoUpdatesEmbedded.ts b/packages/build-tools/src/utils/expoUpdatesEmbedded.ts index 7892ba08fd..779865ba07 100644 --- a/packages/build-tools/src/utils/expoUpdatesEmbedded.ts +++ b/packages/build-tools/src/utils/expoUpdatesEmbedded.ts @@ -19,9 +19,6 @@ export async function uploadEmbeddedBundleAsync(ctx: BuildContext): Pr const { platform } = ctx.job; if (platform === Platform.IOS && (ctx.job as Ios.Job).simulator) { - ctx.logger.info( - 'Skipping embedded bundle upload: simulator builds do not embed release update bundles.' - ); ctx.markBuildPhaseSkipped(); return; } @@ -50,6 +47,9 @@ export async function uploadEmbeddedBundleAsync(ctx: BuildContext): Pr } const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-embedded-bundle-')); + const bundleName = platform === Platform.IOS ? 'main.jsbundle' : 'index.android.bundle'; + const bundlePath = path.join(tmpDir, bundleName); + const manifestPath = path.join(tmpDir, 'app.manifest'); const zip = new StreamZip.async({ file: archivePath }); try { const entries = Object.values(await zip.entries()); @@ -70,9 +70,6 @@ export async function uploadEmbeddedBundleAsync(ctx: BuildContext): Pr return; } - const bundleName = platform === Platform.IOS ? 'main.jsbundle' : 'index.android.bundle'; - const bundlePath = path.join(tmpDir, bundleName); - const manifestPath = path.join(tmpDir, 'app.manifest'); await zip.extract(bundleEntry.name, bundlePath); await zip.extract(manifestEntry.name, manifestPath); From 553d02016ee631ff4bb1933d60c33ece406dcc64 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 20 May 2026 17:23:27 -0700 Subject: [PATCH 6/9] Simplify archive pattern branch for readability --- .../build-tools/src/utils/expoUpdatesEmbedded.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/build-tools/src/utils/expoUpdatesEmbedded.ts b/packages/build-tools/src/utils/expoUpdatesEmbedded.ts index 779865ba07..9702ed39dc 100644 --- a/packages/build-tools/src/utils/expoUpdatesEmbedded.ts +++ b/packages/build-tools/src/utils/expoUpdatesEmbedded.ts @@ -26,11 +26,14 @@ export async function uploadEmbeddedBundleAsync(ctx: BuildContext): Pr const channel = ctx.job.updates?.channel; const projectDir = ctx.getReactNativeProjectDirectory(); - const archivePattern = - platform === Platform.IOS - ? resolveArtifactPath(ctx as BuildContext) - : ((ctx as BuildContext).job.applicationArchivePath ?? - 'android/app/build/outputs/**/*.{apk,aab}'); + let archivePattern: string; + if (platform === Platform.IOS) { + archivePattern = resolveArtifactPath(ctx as BuildContext); + } else { + archivePattern = + (ctx as BuildContext).job.applicationArchivePath ?? + 'android/app/build/outputs/**/*.{apk,aab}'; + } const [archivePath] = await findArtifacts({ rootDir: projectDir, From 9468f390179a03dfc11d96105bbe208c7772a3fa Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 20 May 2026 17:44:15 -0700 Subject: [PATCH 7/9] Add tests for upload error paths and builder env gate --- .../src/builders/__tests__/android.test.ts | 37 +++++++++++++++ .../__tests__/expoUpdatesEmbedded.test.ts | 46 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/packages/build-tools/src/builders/__tests__/android.test.ts b/packages/build-tools/src/builders/__tests__/android.test.ts index 7722be8a4c..b6c706d601 100644 --- a/packages/build-tools/src/builders/__tests__/android.test.ts +++ b/packages/build-tools/src/builders/__tests__/android.test.ts @@ -6,6 +6,7 @@ import { createMockLogger } from '../../__tests__/utils/logger'; import { BuildContext } from '../../context'; import { Datadog } from '../../datadog'; import { restoreCredentials } from '../../android/credentials'; +import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded'; import androidBuilder from '../android'; import { runBuilderWithHooksAsync } from '../common'; import { @@ -57,6 +58,9 @@ jest.mock('../../utils/expoUpdates', () => ({ configureExpoUpdatesIfInstalledAsync: jest.fn(), resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null), })); +jest.mock('../../utils/expoUpdatesEmbedded', () => ({ + uploadEmbeddedBundleAsync: jest.fn(), +})); jest.mock('../../utils/hooks', () => ({ Hook: { POST_INSTALL: 'POST_INSTALL', @@ -269,4 +273,37 @@ describe(androidBuilder, () => { expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function)); }); + + it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => { + const ctx = new BuildContext(createTestAndroidJob(), { + workingdir: '/workingdir', + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + logger: createMockLogger(), + env: { + __API_SERVER_URL: 'http://api.expo.test', + EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1', + }, + uploadArtifact: jest.fn(), + }); + + await androidBuilder(ctx); + + expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx); + }); + + it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => { + const ctx = new BuildContext(createTestAndroidJob(), { + workingdir: '/workingdir', + logBuffer: { getLogs: () => [], getPhaseLogs: () => [] }, + logger: createMockLogger(), + env: { + __API_SERVER_URL: 'http://api.expo.test', + }, + uploadArtifact: jest.fn(), + }); + + await androidBuilder(ctx); + + expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled(); + }); }); diff --git a/packages/build-tools/src/utils/__tests__/expoUpdatesEmbedded.test.ts b/packages/build-tools/src/utils/__tests__/expoUpdatesEmbedded.test.ts index 639e192143..6b6ca60cc0 100644 --- a/packages/build-tools/src/utils/__tests__/expoUpdatesEmbedded.test.ts +++ b/packages/build-tools/src/utils/__tests__/expoUpdatesEmbedded.test.ts @@ -197,4 +197,50 @@ describe('uploadEmbeddedBundleAsync', () => { ); expect(easCli.runEasCliCommand).not.toHaveBeenCalled(); }); + + it('warns when build archive is not found', async () => { + jest.mocked(artifacts.findArtifacts).mockResolvedValue([]); + const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(ctx.logger.warn).toHaveBeenCalledWith( + 'Skipping embedded bundle upload: build archive not found.' + ); + expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled(); + expect(easCli.runEasCliCommand).not.toHaveBeenCalled(); + }); + + it('treats findArtifacts errors as no archive found', async () => { + jest.mocked(artifacts.findArtifacts).mockRejectedValue(new Error('glob failed')); + const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(ctx.logger.warn).toHaveBeenCalledWith( + 'Skipping embedded bundle upload: build archive not found.' + ); + expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled(); + expect(easCli.runEasCliCommand).not.toHaveBeenCalled(); + }); + + it('warns and continues when CLI upload throws', async () => { + jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']); + mockZipEntries.mockResolvedValue( + zipEntryMap({ + 'assets/index.android.bundle': true, + 'assets/app.manifest': true, + }) + ); + jest.mocked(easCli.runEasCliCommand).mockRejectedValue(new Error('upload failed')); + const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' }); + + await uploadEmbeddedBundleAsync(ctx); + + expect(ctx.logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ err: expect.any(Error) }), + 'Failed to upload embedded bundle.' + ); + expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled(); + }); }); From d5c6a28321aa2b6d692d0bd916fe5adb60fa218e Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 29 May 2026 09:48:52 -0700 Subject: [PATCH 8/9] Address review: explicit platform branch, channel check first, safe zip.close --- .../__tests__/expoUpdatesEmbedded.test.ts | 29 ++++++++++++++++++- .../src/utils/expoUpdatesEmbedded.ts | 21 ++++++++++---- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/build-tools/src/utils/__tests__/expoUpdatesEmbedded.test.ts b/packages/build-tools/src/utils/__tests__/expoUpdatesEmbedded.test.ts index 6b6ca60cc0..ca048cca7b 100644 --- a/packages/build-tools/src/utils/__tests__/expoUpdatesEmbedded.test.ts +++ b/packages/build-tools/src/utils/__tests__/expoUpdatesEmbedded.test.ts @@ -88,7 +88,7 @@ describe('uploadEmbeddedBundleAsync', () => { expect(artifacts.findArtifacts).not.toHaveBeenCalled(); }); - it('warns when no channel is configured', async () => { + it('warns when no channel is configured and does not look for the archive', async () => { const ctx = makeCtx({ platform: Platform.ANDROID }); await uploadEmbeddedBundleAsync(ctx); @@ -97,6 +97,17 @@ describe('uploadEmbeddedBundleAsync', () => { 'Skipping embedded bundle upload: no channel configured for this build profile.' ); expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled(); + expect(artifacts.findArtifacts).not.toHaveBeenCalled(); + }); + + it('throws for an unsupported platform', async () => { + const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' }); + (ctx.job as { platform: string }).platform = 'web'; + + await expect(uploadEmbeddedBundleAsync(ctx)).rejects.toThrow( + 'Uploading embedded updates is not supported for the web platform.' + ); + expect(artifacts.findArtifacts).not.toHaveBeenCalled(); }); it('uploads from Android APK archives', async () => { @@ -243,4 +254,20 @@ describe('uploadEmbeddedBundleAsync', () => { ); expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled(); }); + + it('swallows zip.close() failures so they do not mask the upload result', async () => { + jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']); + mockZipEntries.mockResolvedValue( + zipEntryMap({ + 'assets/index.android.bundle': true, + 'assets/app.manifest': true, + }) + ); + mockZipClose.mockRejectedValue(new Error('close failed')); + const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' }); + + await expect(uploadEmbeddedBundleAsync(ctx)).resolves.toBeUndefined(); + expect(easCli.runEasCliCommand).toHaveBeenCalled(); + expect(mockZipClose).toHaveBeenCalled(); + }); }); diff --git a/packages/build-tools/src/utils/expoUpdatesEmbedded.ts b/packages/build-tools/src/utils/expoUpdatesEmbedded.ts index 9702ed39dc..63a4c5572d 100644 --- a/packages/build-tools/src/utils/expoUpdatesEmbedded.ts +++ b/packages/build-tools/src/utils/expoUpdatesEmbedded.ts @@ -1,5 +1,6 @@ import { Android, BuildJob, Ios, Platform } from '@expo/eas-build-job'; import { PipeMode } from '@expo/logger'; +import { asyncResult } from '@expo/results'; import fs from 'fs-extra'; import os from 'os'; import path from 'path'; @@ -24,15 +25,25 @@ export async function uploadEmbeddedBundleAsync(ctx: BuildContext): Pr } const channel = ctx.job.updates?.channel; + if (!channel) { + ctx.logger.warn( + 'Skipping embedded bundle upload: no channel configured for this build profile.' + ); + ctx.markBuildPhaseHasWarnings(); + return; + } + const projectDir = ctx.getReactNativeProjectDirectory(); let archivePattern: string; if (platform === Platform.IOS) { archivePattern = resolveArtifactPath(ctx as BuildContext); - } else { + } else if (platform === Platform.ANDROID) { archivePattern = (ctx as BuildContext).job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}'; + } else { + throw new Error(`Uploading embedded updates is not supported for the ${platform} platform.`); } const [archivePath] = await findArtifacts({ @@ -41,10 +52,8 @@ export async function uploadEmbeddedBundleAsync(ctx: BuildContext): Pr logger: null, }).catch(() => [] as string[]); - if (!channel || !archivePath) { - ctx.logger.warn( - `Skipping embedded bundle upload: ${!channel ? 'no channel configured for this build profile' : 'build archive not found'}.` - ); + if (!archivePath) { + ctx.logger.warn('Skipping embedded bundle upload: build archive not found.'); ctx.markBuildPhaseHasWarnings(); return; } @@ -104,6 +113,6 @@ export async function uploadEmbeddedBundleAsync(ctx: BuildContext): Pr ctx.logger.warn({ err }, 'Failed to upload embedded bundle.'); ctx.markBuildPhaseHasWarnings(); } finally { - await zip.close(); + await asyncResult(zip.close()); } } From 73d1cf6e64bd68f59d5d1143465b3c63bd6383f1 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 29 May 2026 09:48:52 -0700 Subject: [PATCH 9/9] Move #3767 entry back under ## main after v20 release rebase --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c959f735f..45ecc342f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐ŸŽ‰ New features +- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp)) + ### ๐Ÿ› Bug fixes ### ๐Ÿงน Chores @@ -22,7 +24,6 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] `eas go` now prompts to select an Expo SDK version interactively when `--sdk-version` is not provided. ([#3768](https://github.com/expo/eas-cli/pull/3768) by [@gwdp](https://github.com/gwdp)) - [eas-cli] Add `eas update:embedded:upload` command. ([#3720](https://github.com/expo/eas-cli/pull/3720) by [@gwdp](https://github.com/gwdp)) -- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp)) ### ๐Ÿ› Bug fixes