Skip to content

Commit 2c1837f

Browse files
authored
[build-tools] Auto-upload embedded bundle after build when opt-in flag is set (#3767)
* [build-tools] Auto-upload embedded bundle after build when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set * [build-tools] Add changelog entry for #3767 * Move isEASUpdateConfigured check inside uploadEmbeddedBundleAsync * Skip simulator builds; use endsWith for Android archive entry matching * Skip simulator builds; add tests for uploadEmbeddedBundleAsync * Simplify archive pattern branch for readability * Add tests for upload error paths and builder env gate * Address review: explicit platform branch, channel check first, safe zip.close * Move #3767 entry back under ## main after v20 release rebase
1 parent 7bdc009 commit 2c1837f

7 files changed

Lines changed: 447 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages.
88

99
### 🎉 New features
1010

11+
- [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))
12+
1113
### 🐛 Bug fixes
1214

1315
### 🧹 Chores

packages/build-tools/src/builders/__tests__/android.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createMockLogger } from '../../__tests__/utils/logger';
66
import { BuildContext } from '../../context';
77
import { Datadog } from '../../datadog';
88
import { restoreCredentials } from '../../android/credentials';
9+
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
910
import androidBuilder from '../android';
1011
import { runBuilderWithHooksAsync } from '../common';
1112
import {
@@ -57,6 +58,9 @@ jest.mock('../../utils/expoUpdates', () => ({
5758
configureExpoUpdatesIfInstalledAsync: jest.fn(),
5859
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
5960
}));
61+
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
62+
uploadEmbeddedBundleAsync: jest.fn(),
63+
}));
6064
jest.mock('../../utils/hooks', () => ({
6165
Hook: {
6266
POST_INSTALL: 'POST_INSTALL',
@@ -269,4 +273,37 @@ describe(androidBuilder, () => {
269273

270274
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
271275
});
276+
277+
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
278+
const ctx = new BuildContext(createTestAndroidJob(), {
279+
workingdir: '/workingdir',
280+
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
281+
logger: createMockLogger(),
282+
env: {
283+
__API_SERVER_URL: 'http://api.expo.test',
284+
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
285+
},
286+
uploadArtifact: jest.fn(),
287+
});
288+
289+
await androidBuilder(ctx);
290+
291+
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
292+
});
293+
294+
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
295+
const ctx = new BuildContext(createTestAndroidJob(), {
296+
workingdir: '/workingdir',
297+
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
298+
logger: createMockLogger(),
299+
env: {
300+
__API_SERVER_URL: 'http://api.expo.test',
301+
},
302+
uploadArtifact: jest.fn(),
303+
});
304+
305+
await androidBuilder(ctx);
306+
307+
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
308+
});
272309
});

packages/build-tools/src/builders/android.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
configureExpoUpdatesIfInstalledAsync,
3333
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
3434
} from '../utils/expoUpdates';
35+
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
3536
import { Hook, runHookIfPresent } from '../utils/hooks';
3637
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
3738

@@ -208,6 +209,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
208209
});
209210
});
210211

212+
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
213+
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
214+
await uploadEmbeddedBundleAsync(ctx);
215+
});
216+
}
217+
211218
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
212219
if (ctx.isLocal) {
213220
ctx.logger.info('Local builds do not support saving cache.');

packages/build-tools/src/builders/ios.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
configureExpoUpdatesIfInstalledAsync,
2626
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
2727
} from '../utils/expoUpdates';
28+
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
2829
import { Hook, runHookIfPresent } from '../utils/hooks';
2930
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
3031
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
@@ -209,6 +210,12 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
209210
});
210211
});
211212

213+
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
214+
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
215+
await uploadEmbeddedBundleAsync(ctx);
216+
});
217+
}
218+
212219
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
213220
if (ctx.isLocal) {
214221
ctx.logger.info('Local builds do not support saving cache.');
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import { Platform } from '@expo/eas-build-job';
2+
3+
import { BuildContext } from '../../context';
4+
import * as expoUpdates from '../expoUpdates';
5+
import { uploadEmbeddedBundleAsync } from '../expoUpdatesEmbedded';
6+
import * as easCli from '../easCli';
7+
import * as artifacts from '../artifacts';
8+
9+
jest.mock('../expoUpdates');
10+
jest.mock('../easCli');
11+
jest.mock('../artifacts');
12+
13+
const mockZipEntries = jest.fn();
14+
const mockZipExtract = jest.fn();
15+
const mockZipClose = jest.fn();
16+
17+
jest.mock('node-stream-zip', () => ({
18+
__esModule: true,
19+
default: {
20+
async: jest.fn(() => ({
21+
entries: mockZipEntries,
22+
extract: mockZipExtract,
23+
close: mockZipClose,
24+
})),
25+
},
26+
}));
27+
28+
function zipEntryMap(entries: Record<string, true>): Record<string, { name: string }> {
29+
return Object.fromEntries(Object.keys(entries).map(name => [name, { name }]));
30+
}
31+
32+
function makeCtx(overrides: {
33+
platform: Platform;
34+
simulator?: boolean;
35+
channel?: string;
36+
env?: Record<string, string>;
37+
}): BuildContext<any> {
38+
const job =
39+
overrides.platform === Platform.IOS
40+
? {
41+
platform: Platform.IOS,
42+
simulator: overrides.simulator ?? false,
43+
updates: overrides.channel ? { channel: overrides.channel } : undefined,
44+
}
45+
: {
46+
platform: Platform.ANDROID,
47+
updates: overrides.channel ? { channel: overrides.channel } : undefined,
48+
};
49+
50+
return {
51+
job,
52+
env: overrides.env ?? {},
53+
appConfig: Promise.resolve({
54+
updates: { url: 'https://u.expo.dev/project-id' },
55+
}),
56+
logger: {
57+
info: jest.fn(),
58+
warn: jest.fn(),
59+
},
60+
markBuildPhaseSkipped: jest.fn(),
61+
markBuildPhaseHasWarnings: jest.fn(),
62+
getReactNativeProjectDirectory: () => '/project',
63+
} as any;
64+
}
65+
66+
describe('uploadEmbeddedBundleAsync', () => {
67+
beforeEach(() => {
68+
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(true);
69+
jest.mocked(easCli.runEasCliCommand).mockResolvedValue({} as any);
70+
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
71+
mockZipEntries.mockResolvedValue({});
72+
mockZipExtract.mockResolvedValue(undefined);
73+
mockZipClose.mockResolvedValue(undefined);
74+
});
75+
76+
afterEach(() => {
77+
jest.clearAllMocks();
78+
jest.restoreAllMocks();
79+
});
80+
81+
it('skips when EAS Update is not configured', async () => {
82+
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(false);
83+
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
84+
85+
await uploadEmbeddedBundleAsync(ctx);
86+
87+
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
88+
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
89+
});
90+
91+
it('warns when no channel is configured and does not look for the archive', async () => {
92+
const ctx = makeCtx({ platform: Platform.ANDROID });
93+
94+
await uploadEmbeddedBundleAsync(ctx);
95+
96+
expect(ctx.logger.warn).toHaveBeenCalledWith(
97+
'Skipping embedded bundle upload: no channel configured for this build profile.'
98+
);
99+
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
100+
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
101+
});
102+
103+
it('throws for an unsupported platform', async () => {
104+
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
105+
(ctx.job as { platform: string }).platform = 'web';
106+
107+
await expect(uploadEmbeddedBundleAsync(ctx)).rejects.toThrow(
108+
'Uploading embedded updates is not supported for the web platform.'
109+
);
110+
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
111+
});
112+
113+
it('uploads from Android APK archives', async () => {
114+
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
115+
mockZipEntries.mockResolvedValue(
116+
zipEntryMap({
117+
'assets/index.android.bundle': true,
118+
'assets/app.manifest': true,
119+
})
120+
);
121+
const ctx = makeCtx({
122+
platform: Platform.ANDROID,
123+
channel: 'production',
124+
env: { EAS_BUILD_ID: 'build-123' },
125+
});
126+
127+
await uploadEmbeddedBundleAsync(ctx);
128+
129+
expect(mockZipExtract).toHaveBeenCalledWith(
130+
'assets/index.android.bundle',
131+
expect.stringContaining('index.android.bundle')
132+
);
133+
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
134+
expect.objectContaining({
135+
args: expect.arrayContaining([
136+
'update:embedded:upload',
137+
'--platform',
138+
Platform.ANDROID,
139+
'--channel',
140+
'production',
141+
'--build-id',
142+
'build-123',
143+
]),
144+
})
145+
);
146+
});
147+
148+
it('uploads from Android AAB archives', async () => {
149+
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.aab']);
150+
mockZipEntries.mockResolvedValue(
151+
zipEntryMap({
152+
'base/assets/index.android.bundle': true,
153+
'base/assets/app.manifest': true,
154+
})
155+
);
156+
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
157+
158+
await uploadEmbeddedBundleAsync(ctx);
159+
160+
expect(mockZipExtract).toHaveBeenCalledWith(
161+
'base/assets/index.android.bundle',
162+
expect.stringContaining('index.android.bundle')
163+
);
164+
expect(easCli.runEasCliCommand).toHaveBeenCalled();
165+
});
166+
167+
it('uploads from iOS IPA archives', async () => {
168+
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/App.ipa']);
169+
mockZipEntries.mockResolvedValue(
170+
zipEntryMap({
171+
'Payload/App.app/main.jsbundle': true,
172+
'Payload/App.app/EXUpdates.bundle/app.manifest': true,
173+
})
174+
);
175+
const ctx = makeCtx({ platform: Platform.IOS, channel: 'production' });
176+
177+
await uploadEmbeddedBundleAsync(ctx);
178+
179+
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
180+
expect.objectContaining({
181+
args: expect.arrayContaining(['--platform', Platform.IOS]),
182+
})
183+
);
184+
});
185+
186+
it('skips simulator builds', async () => {
187+
const ctx = makeCtx({ platform: Platform.IOS, simulator: true, channel: 'preview' });
188+
189+
await uploadEmbeddedBundleAsync(ctx);
190+
191+
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
192+
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
193+
});
194+
195+
it('warns when bundle or manifest is missing from the archive', async () => {
196+
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
197+
mockZipEntries.mockResolvedValue(
198+
zipEntryMap({
199+
'assets/app.manifest': true,
200+
})
201+
);
202+
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
203+
204+
await uploadEmbeddedBundleAsync(ctx);
205+
206+
expect(ctx.logger.warn).toHaveBeenCalledWith(
207+
'Skipping embedded bundle upload: bundle or manifest not found in archive.'
208+
);
209+
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
210+
});
211+
212+
it('warns when build archive is not found', async () => {
213+
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
214+
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
215+
216+
await uploadEmbeddedBundleAsync(ctx);
217+
218+
expect(ctx.logger.warn).toHaveBeenCalledWith(
219+
'Skipping embedded bundle upload: build archive not found.'
220+
);
221+
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
222+
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
223+
});
224+
225+
it('treats findArtifacts errors as no archive found', async () => {
226+
jest.mocked(artifacts.findArtifacts).mockRejectedValue(new Error('glob failed'));
227+
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
228+
229+
await uploadEmbeddedBundleAsync(ctx);
230+
231+
expect(ctx.logger.warn).toHaveBeenCalledWith(
232+
'Skipping embedded bundle upload: build archive not found.'
233+
);
234+
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
235+
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
236+
});
237+
238+
it('warns and continues when CLI upload throws', async () => {
239+
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
240+
mockZipEntries.mockResolvedValue(
241+
zipEntryMap({
242+
'assets/index.android.bundle': true,
243+
'assets/app.manifest': true,
244+
})
245+
);
246+
jest.mocked(easCli.runEasCliCommand).mockRejectedValue(new Error('upload failed'));
247+
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
248+
249+
await uploadEmbeddedBundleAsync(ctx);
250+
251+
expect(ctx.logger.warn).toHaveBeenCalledWith(
252+
expect.objectContaining({ err: expect.any(Error) }),
253+
'Failed to upload embedded bundle.'
254+
);
255+
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
256+
});
257+
258+
it('swallows zip.close() failures so they do not mask the upload result', async () => {
259+
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
260+
mockZipEntries.mockResolvedValue(
261+
zipEntryMap({
262+
'assets/index.android.bundle': true,
263+
'assets/app.manifest': true,
264+
})
265+
);
266+
mockZipClose.mockRejectedValue(new Error('close failed'));
267+
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
268+
269+
await expect(uploadEmbeddedBundleAsync(ctx)).resolves.toBeUndefined();
270+
expect(easCli.runEasCliCommand).toHaveBeenCalled();
271+
expect(mockZipClose).toHaveBeenCalled();
272+
});
273+
});

0 commit comments

Comments
 (0)