mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e343fff84 | ||
|
|
7a40d10ca7 |
No files matched your search
@@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas update:upload-embedded` command to upload the JS bundle embedded in a native build so EAS Update can generate bsdiff patches against it. ([#3699](https://github.com/expo/eas-cli/pull/3699) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Update the minimum Expo version required for iOS precompiled modules. ([#3677](https://github.com/expo/eas-cli/pull/3677) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { Updates } from '@expo/config-plugins';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { ChannelNotFoundError } from '../../../channel/errors';
|
||||
import { EmbeddedUpdateAssetMutation } from '../../../graphql/mutations/EmbeddedUpdateAssetMutation';
|
||||
import {
|
||||
EmbeddedUpdateMutation,
|
||||
isEmbeddedUpdateAssetNotReadyError,
|
||||
isEmbeddedUpdateConflictError,
|
||||
} from '../../../graphql/mutations/EmbeddedUpdateMutation';
|
||||
import { AppPlatform } from '../../../graphql/generated';
|
||||
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../../log';
|
||||
import * as uploads from '../../../uploads';
|
||||
import * as promise from '../../../utils/promise';
|
||||
import UpdateUploadEmbedded from '../upload-embedded';
|
||||
|
||||
jest.mock('fs', () => jest.requireActual('memfs').fs);
|
||||
jest.mock('@expo/config-plugins', () => ({
|
||||
Updates: { getRuntimeVersionNullableAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../graphql/mutations/EmbeddedUpdateAssetMutation', () => ({
|
||||
EmbeddedUpdateAssetMutation: { getSignedUploadSpecAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../graphql/mutations/EmbeddedUpdateMutation', () => ({
|
||||
EmbeddedUpdateMutation: { uploadEmbeddedUpdateAsync: jest.fn() },
|
||||
isEmbeddedUpdateAssetNotReadyError: jest.fn(),
|
||||
isEmbeddedUpdateConflictError: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../graphql/queries/ChannelQuery', () => ({
|
||||
ChannelQuery: { viewUpdateChannelBasicInfoAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../uploads');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/promise', () => ({
|
||||
sleepAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const mockGetRuntimeVersion = jest.mocked(Updates.getRuntimeVersionNullableAsync);
|
||||
const mockGetSignedUploadSpec = jest.mocked(EmbeddedUpdateAssetMutation.getSignedUploadSpecAsync);
|
||||
const mockViewChannel = jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync);
|
||||
const mockUpload = jest.mocked(uploads.uploadWithPresignedPostWithRetryAsync);
|
||||
const mockLogLog = jest.mocked(Log.log);
|
||||
const mockUploadEmbeddedUpdate = jest.mocked(EmbeddedUpdateMutation.uploadEmbeddedUpdateAsync);
|
||||
const mockSleepAsync = jest.mocked(promise.sleepAsync);
|
||||
const mockIsEmbeddedUpdateAssetNotReadyError = jest.mocked(isEmbeddedUpdateAssetNotReadyError);
|
||||
const mockIsEmbeddedUpdateConflictError = jest.mocked(isEmbeddedUpdateConflictError);
|
||||
|
||||
const BUNDLE_PATH = '/project/app.bundle';
|
||||
const MANIFEST_PATH = '/project/app.manifest';
|
||||
const VALID_UUID = 'a1b2c3d4-1234-4000-8000-000000000000';
|
||||
const VALID_MANIFEST = JSON.stringify({ id: VALID_UUID });
|
||||
|
||||
const BASE_ARGV = [
|
||||
'--platform',
|
||||
Platform.IOS,
|
||||
'--bundle',
|
||||
BUNDLE_PATH,
|
||||
'--manifest',
|
||||
MANIFEST_PATH,
|
||||
'--channel',
|
||||
'production',
|
||||
];
|
||||
|
||||
const MOCK_CONTEXT = {
|
||||
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
|
||||
privateProjectConfig: {
|
||||
projectId: 'project-123',
|
||||
exp: { name: 'test', slug: 'test' },
|
||||
projectDir: '/project',
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_EMBEDDED_UPDATE = {
|
||||
id: 'embedded-update-id-abc',
|
||||
platform: AppPlatform.Ios,
|
||||
runtimeVersion: '1.0.0',
|
||||
channelId: 'channel-id-123',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
describe(UpdateUploadEmbedded, () => {
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vol.reset();
|
||||
vol.fromJSON({
|
||||
[BUNDLE_PATH]: 'bundle-bytes',
|
||||
[MANIFEST_PATH]: VALID_MANIFEST,
|
||||
});
|
||||
mockGetRuntimeVersion.mockResolvedValue('1.0.0');
|
||||
mockViewChannel.mockResolvedValue({ id: 'channel-id-123', name: 'production' } as any);
|
||||
mockGetSignedUploadSpec.mockResolvedValue({
|
||||
storageKey: 'storage-key-abc',
|
||||
presignedUrl: 'https://storage.googleapis.com/upload-bucket',
|
||||
fields: { key: 'obj-key', policy: 'abc123' },
|
||||
});
|
||||
mockUpload.mockResolvedValue(undefined as any);
|
||||
mockUploadEmbeddedUpdate.mockResolvedValue(MOCK_EMBEDDED_UPDATE);
|
||||
mockIsEmbeddedUpdateAssetNotReadyError.mockReturnValue(false);
|
||||
mockIsEmbeddedUpdateConflictError.mockReturnValue(false);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): UpdateUploadEmbedded {
|
||||
const command = new UpdateUploadEmbedded(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
|
||||
return command;
|
||||
}
|
||||
|
||||
describe('file existence checks', () => {
|
||||
it('runs successfully when both files exist', async () => {
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await command.runAsync();
|
||||
expect(mockLogLog).toHaveBeenCalledWith(expect.stringContaining('production'));
|
||||
});
|
||||
|
||||
it('errors with message when bundle file does not exist', async () => {
|
||||
vol.reset();
|
||||
vol.fromJSON({ [MANIFEST_PATH]: VALID_MANIFEST });
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await expect(command.runAsync()).rejects.toThrow(/Bundle file not found/);
|
||||
});
|
||||
|
||||
it('errors with message when manifest file does not exist', async () => {
|
||||
vol.reset();
|
||||
vol.fromJSON({ [BUNDLE_PATH]: 'bundle-bytes' });
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await expect(command.runAsync()).rejects.toThrow(/Manifest file not found/);
|
||||
});
|
||||
|
||||
it('includes build-id in log when provided', async () => {
|
||||
const command = createCommand([...BASE_ARGV, '--build-id', 'build-uuid-123']);
|
||||
await command.runAsync();
|
||||
expect(mockLogLog).toHaveBeenCalledWith(expect.stringContaining('build-uuid-123'));
|
||||
});
|
||||
|
||||
it('accepts android platform', async () => {
|
||||
const argv = [...BASE_ARGV];
|
||||
argv[1] = Platform.ANDROID;
|
||||
const command = createCommand(argv);
|
||||
await command.runAsync();
|
||||
expect(mockLogLog).toHaveBeenCalledWith(expect.stringContaining(Platform.ANDROID));
|
||||
});
|
||||
});
|
||||
|
||||
describe('manifest parsing', () => {
|
||||
it('reads embeddedUpdateId from manifest and passes platform directly to getRuntimeVersion', async () => {
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await command.runAsync();
|
||||
expect(mockGetRuntimeVersion).toHaveBeenCalledWith(
|
||||
MOCK_CONTEXT.privateProjectConfig.projectDir,
|
||||
MOCK_CONTEXT.privateProjectConfig.exp,
|
||||
Platform.IOS
|
||||
);
|
||||
});
|
||||
|
||||
it('errors when manifest contains invalid JSON', async () => {
|
||||
vol.fromJSON({ [MANIFEST_PATH]: 'not-json', [BUNDLE_PATH]: 'bytes' });
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await expect(command.runAsync()).rejects.toThrow(/not valid JSON/);
|
||||
});
|
||||
|
||||
it('errors when manifest id is missing', async () => {
|
||||
vol.fromJSON({ [MANIFEST_PATH]: JSON.stringify({ noId: true }), [BUNDLE_PATH]: 'bytes' });
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await expect(command.runAsync()).rejects.toThrow(/"id" field/);
|
||||
});
|
||||
|
||||
it('errors when manifest id is not a valid UUID', async () => {
|
||||
vol.fromJSON({ [MANIFEST_PATH]: JSON.stringify({ id: 'not-a-uuid' }), [BUNDLE_PATH]: 'bytes' });
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await expect(command.runAsync()).rejects.toThrow(/is not a UUID/);
|
||||
});
|
||||
|
||||
it('errors when runtimeVersion cannot be resolved', async () => {
|
||||
mockGetRuntimeVersion.mockResolvedValue(null);
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await expect(command.runAsync()).rejects.toThrow(/runtimeVersion/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel resolution', () => {
|
||||
it('resolves channel by name and app id', async () => {
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await command.runAsync();
|
||||
expect(mockViewChannel).toHaveBeenCalledWith(
|
||||
MOCK_CONTEXT.loggedIn.graphqlClient,
|
||||
{ appId: MOCK_CONTEXT.privateProjectConfig.projectId, channelName: 'production' }
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates ChannelNotFoundError when channel does not exist', async () => {
|
||||
mockViewChannel.mockRejectedValue(new ChannelNotFoundError('Channel not found'));
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await expect(command.runAsync()).rejects.toThrow(ChannelNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bundle upload', () => {
|
||||
it('requests a presigned URL and uploads the bundle', async () => {
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await command.runAsync();
|
||||
expect(mockGetSignedUploadSpec).toHaveBeenCalledWith(
|
||||
MOCK_CONTEXT.loggedIn.graphqlClient,
|
||||
expect.objectContaining({
|
||||
appId: MOCK_CONTEXT.privateProjectConfig.projectId,
|
||||
contentType: 'application/javascript',
|
||||
})
|
||||
);
|
||||
expect(mockUpload).toHaveBeenCalledWith(
|
||||
BUNDLE_PATH,
|
||||
{ url: 'https://storage.googleapis.com/upload-bucket', fields: { key: 'obj-key', policy: 'abc123' } },
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates upload errors', async () => {
|
||||
mockUpload.mockRejectedValue(new Error('upload failed'));
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await expect(command.runAsync()).rejects.toThrow('upload failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutation registration', () => {
|
||||
it('calls uploadEmbeddedUpdateAsync with all collected inputs including AppPlatform enum', async () => {
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await command.runAsync();
|
||||
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledWith(
|
||||
MOCK_CONTEXT.loggedIn.graphqlClient,
|
||||
expect.objectContaining({
|
||||
appId: MOCK_CONTEXT.privateProjectConfig.projectId,
|
||||
platform: AppPlatform.Ios,
|
||||
runtimeVersion: '1.0.0',
|
||||
channelId: 'channel-id-123',
|
||||
embeddedUpdateId: VALID_UUID,
|
||||
launchAssetStorageKey: 'storage-key-abc',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the embedded update id on success', async () => {
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await command.runAsync();
|
||||
expect(mockLogLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining(MOCK_EMBEDDED_UPDATE.id)
|
||||
);
|
||||
});
|
||||
|
||||
it('passes build-id as turtleBuildId when provided', async () => {
|
||||
const command = createCommand([...BASE_ARGV, '--build-id', 'turtle-build-xyz']);
|
||||
await command.runAsync();
|
||||
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ turtleBuildId: 'turtle-build-xyz' })
|
||||
);
|
||||
});
|
||||
|
||||
it('retries on ASSET_NOT_READY: succeeds on second attempt, sleeps once with first delay', async () => {
|
||||
const assetNotReadyError = new Error('asset not ready');
|
||||
mockIsEmbeddedUpdateAssetNotReadyError.mockImplementation(e => e === assetNotReadyError);
|
||||
mockUploadEmbeddedUpdate
|
||||
.mockRejectedValueOnce(assetNotReadyError)
|
||||
.mockResolvedValueOnce(MOCK_EMBEDDED_UPDATE);
|
||||
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(mockSleepAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockSleepAsync).toHaveBeenCalledWith(3_000);
|
||||
expect(mockLogLog).toHaveBeenCalledWith(expect.stringContaining(MOCK_EMBEDDED_UPDATE.id));
|
||||
});
|
||||
|
||||
it('exhausts all 10 attempts on ASSET_NOT_READY, sleeping 9 times then throwing', async () => {
|
||||
const assetNotReadyError = new Error('asset not ready');
|
||||
mockIsEmbeddedUpdateAssetNotReadyError.mockImplementation(e => e === assetNotReadyError);
|
||||
mockUploadEmbeddedUpdate.mockRejectedValue(assetNotReadyError);
|
||||
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(10);
|
||||
expect(mockSleepAsync).toHaveBeenCalledTimes(9);
|
||||
});
|
||||
|
||||
it('throws a user-facing error on CONFLICT without retrying', async () => {
|
||||
const conflictError = new Error('conflict');
|
||||
mockIsEmbeddedUpdateConflictError.mockImplementation(e => e === conflictError);
|
||||
mockUploadEmbeddedUpdate.mockRejectedValue(conflictError);
|
||||
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await expect(command.runAsync()).rejects.toThrow(/already registered/);
|
||||
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockSleepAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates unexpected mutation errors immediately without retrying', async () => {
|
||||
const unexpectedError = new Error('network timeout');
|
||||
mockUploadEmbeddedUpdate.mockRejectedValue(unexpectedError);
|
||||
|
||||
const command = createCommand(BASE_ARGV);
|
||||
await expect(command.runAsync()).rejects.toThrow('network timeout');
|
||||
expect(mockUploadEmbeddedUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { Updates } from '@expo/config-plugins';
|
||||
import { Errors, Flags } from '@oclif/core';
|
||||
import fs from 'fs';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { EmbeddedUpdateAssetMutation } from '../../graphql/mutations/EmbeddedUpdateAssetMutation';
|
||||
import {
|
||||
EmbeddedUpdateMutation,
|
||||
EmbeddedUpdateResult,
|
||||
isEmbeddedUpdateAssetNotReadyError,
|
||||
isEmbeddedUpdateConflictError,
|
||||
} from '../../graphql/mutations/EmbeddedUpdateMutation';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import { toAppPlatform } from '../../graphql/types/AppPlatform';
|
||||
import Log from '../../log';
|
||||
import { readEmbeddedManifest } from '../../update/embeddedManifest';
|
||||
import { PresignedPost, uploadWithPresignedPostWithRetryAsync } from '../../uploads';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
|
||||
const MAX_ATTEMPTS = 10;
|
||||
const RETRY_BASE_DELAY_MS = 3_000;
|
||||
const RETRY_MAX_DELAY_MS = 10_000;
|
||||
|
||||
export default class UpdateUploadEmbedded extends EasCommand {
|
||||
static override description =
|
||||
'upload the JS bundle embedded in a native build so EAS Update can generate bsdiff patches against it';
|
||||
|
||||
static override examples = [
|
||||
'$ eas update:upload-embedded --platform ios --bundle ios/build/App.app/main.jsbundle --manifest ios/build/App.app/app.manifest --channel production',
|
||||
'$ eas update:upload-embedded --platform android --bundle android/app/src/main/assets/index.android.bundle --manifest android/app/src/main/assets/app.manifest --channel production --build-id <BUILD-ID>',
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
char: 'p',
|
||||
description: 'Platform of the embedded bundle',
|
||||
options: [Platform.IOS, Platform.ANDROID] as const,
|
||||
required: true,
|
||||
})(),
|
||||
bundle: Flags.string({
|
||||
description: 'Path to the embedded JS bundle file',
|
||||
required: true,
|
||||
}),
|
||||
manifest: Flags.string({
|
||||
description: 'Path to the app.manifest file embedded in the build',
|
||||
required: true,
|
||||
}),
|
||||
channel: Flags.string({
|
||||
description: 'Channel name the embedded update should be associated with',
|
||||
required: true,
|
||||
}),
|
||||
'build-id': Flags.string({
|
||||
description:
|
||||
'EAS Build ID that produced this binary (required when invoked from EAS Build)',
|
||||
required: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(UpdateUploadEmbedded);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
const platform = flags.platform;
|
||||
const bundlePath = flags.bundle;
|
||||
const manifestPath = flags.manifest;
|
||||
const channelName = flags.channel;
|
||||
const buildId = flags['build-id'];
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
privateProjectConfig: { projectId, exp, projectDir },
|
||||
} = await this.getContextAsync(UpdateUploadEmbedded, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (!fs.existsSync(bundlePath)) {
|
||||
Errors.error(
|
||||
`Bundle file not found at "${bundlePath}". Check that the path is correct and points to the JS bundle in your native build output.`,
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
Errors.error(
|
||||
`Manifest file not found at "${manifestPath}". Check that the path points to the app.manifest in your native build output ` +
|
||||
`(iOS: <App>.app/app.manifest, Android: assets/app.manifest).`,
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: embeddedUpdateId } = readEmbeddedManifest(manifestPath);
|
||||
|
||||
const runtimeVersion = await Updates.getRuntimeVersionNullableAsync(projectDir, exp, platform);
|
||||
if (runtimeVersion === null) {
|
||||
Errors.error(
|
||||
`Could not resolve runtimeVersion for platform "${platform}". ` +
|
||||
`Ensure runtimeVersion is set in your app.json under the expo key.`,
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
|
||||
const appPlatform = toAppPlatform(platform);
|
||||
const channel = await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName,
|
||||
});
|
||||
const channelId = channel.id;
|
||||
|
||||
Log.log(
|
||||
`Uploading embedded ${platform} bundle to channel "${channelName}"` +
|
||||
(buildId ? ` (build ${buildId})` : '')
|
||||
);
|
||||
|
||||
const contentType = 'application/javascript';
|
||||
const uploadSpec = await EmbeddedUpdateAssetMutation.getSignedUploadSpecAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
contentType,
|
||||
});
|
||||
const presignedPost: PresignedPost = {
|
||||
url: uploadSpec.presignedUrl,
|
||||
fields: uploadSpec.fields,
|
||||
};
|
||||
await uploadWithPresignedPostWithRetryAsync(bundlePath, presignedPost, () => {
|
||||
Log.debug('Uploading bundle...');
|
||||
});
|
||||
|
||||
Log.debug(
|
||||
`Bundle uploaded. storageKey: ${uploadSpec.storageKey}, embeddedUpdateId: ${embeddedUpdateId}, runtimeVersion: ${runtimeVersion}, channelId: ${channelId}`
|
||||
);
|
||||
|
||||
let embeddedUpdate: EmbeddedUpdateResult | undefined;
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
embeddedUpdate = await EmbeddedUpdateMutation.uploadEmbeddedUpdateAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
platform: appPlatform,
|
||||
runtimeVersion,
|
||||
channelId,
|
||||
embeddedUpdateId,
|
||||
launchAssetStorageKey: uploadSpec.storageKey,
|
||||
turtleBuildId: buildId,
|
||||
});
|
||||
break;
|
||||
} catch (e: unknown) {
|
||||
if (isEmbeddedUpdateConflictError(e)) {
|
||||
Errors.error(
|
||||
`An embedded update is already registered for this build (manifest id: ${embeddedUpdateId}). ` +
|
||||
`Each native binary can only have one embedded update. ` +
|
||||
`If you re-ran this command by mistake, no action is needed. ` +
|
||||
`To replace the existing registration, delete it from the Expo dashboard first, then re-run this command.`,
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
const delayMs = attempt < MAX_ATTEMPTS
|
||||
? Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS)
|
||||
: undefined;
|
||||
if (isEmbeddedUpdateAssetNotReadyError(e) && delayMs !== undefined) {
|
||||
Log.log(
|
||||
`Asset not yet finalized, retrying (attempt ${attempt + 1}/${MAX_ATTEMPTS})...`
|
||||
);
|
||||
await sleepAsync(delayMs);
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (embeddedUpdate === undefined) {
|
||||
throw new Error(
|
||||
`The uploaded bundle was not processed after ${MAX_ATTEMPTS} attempts. ` +
|
||||
`Storage finalization is taking longer than expected — wait a moment and try re-running the command.`
|
||||
);
|
||||
}
|
||||
|
||||
Log.log(
|
||||
`Embedded update registered: ${embeddedUpdate.id} (${platform}, runtimeVersion ${runtimeVersion}, channel "${channelName}")`
|
||||
);
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(embeddedUpdate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
|
||||
export type EmbeddedUpdateAssetUploadSpec = {
|
||||
storageKey: string;
|
||||
presignedUrl: string;
|
||||
fields: Record<string, string>;
|
||||
};
|
||||
|
||||
type GetSignedEmbeddedUploadSpecMutationResult = {
|
||||
embeddedUpdateAsset: {
|
||||
getSignedEmbeddedUpdateAssetUploadSpecifications: EmbeddedUpdateAssetUploadSpec;
|
||||
};
|
||||
};
|
||||
|
||||
export const EmbeddedUpdateAssetMutation = {
|
||||
async getSignedUploadSpecAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, contentType }: { appId: string; contentType: string }
|
||||
): Promise<EmbeddedUpdateAssetUploadSpec> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<GetSignedEmbeddedUploadSpecMutationResult>(
|
||||
gql`
|
||||
mutation GetSignedEmbeddedUpdateAssetUploadSpec(
|
||||
$appId: ID!
|
||||
$contentType: String!
|
||||
) {
|
||||
embeddedUpdateAsset {
|
||||
getSignedEmbeddedUpdateAssetUploadSpecifications(
|
||||
appId: $appId
|
||||
contentType: $contentType
|
||||
) {
|
||||
storageKey
|
||||
presignedUrl
|
||||
fields
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appId, contentType }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.embeddedUpdateAsset.getSignedEmbeddedUpdateAssetUploadSpecifications;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppPlatform } from '../generated';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
|
||||
export type EmbeddedUpdateResult = {
|
||||
id: string;
|
||||
platform: AppPlatform;
|
||||
runtimeVersion: string;
|
||||
channelId: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type UploadEmbeddedUpdateInput = {
|
||||
appId: string;
|
||||
platform: AppPlatform;
|
||||
runtimeVersion: string;
|
||||
channelId: string;
|
||||
embeddedUpdateId: string;
|
||||
launchAssetStorageKey: string;
|
||||
turtleBuildId?: string;
|
||||
};
|
||||
|
||||
type UploadEmbeddedUpdateMutationResult = {
|
||||
embeddedUpdate: {
|
||||
uploadEmbeddedUpdate: EmbeddedUpdateResult;
|
||||
};
|
||||
};
|
||||
|
||||
export function isEmbeddedUpdateAssetNotReadyError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof CombinedError &&
|
||||
error.graphQLErrors.some(
|
||||
e => e.extensions?.['errorCode'] === 'EMBEDDED_UPDATE_ASSET_NOT_READY'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function isEmbeddedUpdateConflictError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof CombinedError &&
|
||||
error.graphQLErrors.some(e => e.extensions?.['errorCode'] === 'EMBEDDED_UPDATE_CONFLICT')
|
||||
);
|
||||
}
|
||||
|
||||
export const EmbeddedUpdateMutation = {
|
||||
async uploadEmbeddedUpdateAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
input: UploadEmbeddedUpdateInput
|
||||
): Promise<EmbeddedUpdateResult> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<UploadEmbeddedUpdateMutationResult>(
|
||||
gql`
|
||||
mutation UploadEmbeddedUpdate($input: UploadEmbeddedUpdateInput!) {
|
||||
embeddedUpdate {
|
||||
uploadEmbeddedUpdate(input: $input) {
|
||||
id
|
||||
platform
|
||||
runtimeVersion
|
||||
channelId
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ input }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.embeddedUpdate.uploadEmbeddedUpdate;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
|
||||
import {
|
||||
isEmbeddedUpdateAssetNotReadyError,
|
||||
isEmbeddedUpdateConflictError,
|
||||
} from '../EmbeddedUpdateMutation';
|
||||
|
||||
function makeCombinedError(errorCode: string): CombinedError {
|
||||
return new CombinedError({
|
||||
graphQLErrors: [{ message: 'error', extensions: { errorCode } }],
|
||||
});
|
||||
}
|
||||
|
||||
describe('isEmbeddedUpdateAssetNotReadyError', () => {
|
||||
it('returns true for CombinedError with EMBEDDED_UPDATE_ASSET_NOT_READY errorCode', () => {
|
||||
expect(
|
||||
isEmbeddedUpdateAssetNotReadyError(makeCombinedError('EMBEDDED_UPDATE_ASSET_NOT_READY'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for CombinedError with a different errorCode', () => {
|
||||
expect(
|
||||
isEmbeddedUpdateAssetNotReadyError(makeCombinedError('EMBEDDED_UPDATE_CONFLICT'))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for CombinedError with no extensions', () => {
|
||||
expect(
|
||||
isEmbeddedUpdateAssetNotReadyError(new CombinedError({ graphQLErrors: [{ message: 'oops' }] }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a plain Error', () => {
|
||||
expect(isEmbeddedUpdateAssetNotReadyError(new Error('plain error'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for null and non-error values', () => {
|
||||
expect(isEmbeddedUpdateAssetNotReadyError(null)).toBe(false);
|
||||
expect(isEmbeddedUpdateAssetNotReadyError('string')).toBe(false);
|
||||
expect(isEmbeddedUpdateAssetNotReadyError(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmbeddedUpdateConflictError', () => {
|
||||
it('returns true for CombinedError with EMBEDDED_UPDATE_CONFLICT errorCode', () => {
|
||||
expect(
|
||||
isEmbeddedUpdateConflictError(makeCombinedError('EMBEDDED_UPDATE_CONFLICT'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for CombinedError with a different errorCode', () => {
|
||||
expect(
|
||||
isEmbeddedUpdateConflictError(makeCombinedError('EMBEDDED_UPDATE_ASSET_NOT_READY'))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a plain Error', () => {
|
||||
expect(isEmbeddedUpdateConflictError(new Error('plain error'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for null', () => {
|
||||
expect(isEmbeddedUpdateConflictError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import fs from 'fs';
|
||||
import * as uuid from 'uuid';
|
||||
|
||||
export type EmbeddedManifest = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function readEmbeddedManifest(manifestPath: string): EmbeddedManifest {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(manifestPath, 'utf8');
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Could not read manifest file at "${manifestPath}". ` +
|
||||
`The file exists but could not be read — check that you have permission to access it.`
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Manifest file at "${manifestPath}" is not valid JSON. ` +
|
||||
`This usually means the file is corrupted or you pointed at the wrong file. ` +
|
||||
`Rebuild your app and check that the path points to the app.manifest in your native build output.`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
typeof (parsed as Record<string, unknown>).id !== 'string'
|
||||
) {
|
||||
throw new Error(
|
||||
`Manifest at "${manifestPath}" is missing the required "id" field. ` +
|
||||
`Make sure you're pointing at the app.manifest generated by expo-updates during your native build, not a different JSON file.`
|
||||
);
|
||||
}
|
||||
|
||||
const id = (parsed as Record<string, string>).id;
|
||||
if (!uuid.validate(id)) {
|
||||
throw new Error(
|
||||
`Manifest at "${manifestPath}" has an invalid "id" field ("${id}" is not a UUID). ` +
|
||||
`Make sure you're pointing at the app.manifest generated by expo-updates during your native build.`
|
||||
);
|
||||
}
|
||||
|
||||
return { id };
|
||||
}
|
||||
Reference in new issue
Block a user