mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
240e8832be | ||
|
|
73d1cf6e64 | ||
|
|
d5c6a28321 | ||
|
|
9468f39017 | ||
|
|
553d02016e | ||
|
|
dab0ff73c0 | ||
|
|
99eff79028 | ||
|
|
a5d7488392 | ||
|
|
15d4feeebd | ||
|
|
ff0a7b8582 |
No files matched your search
@@ -8,6 +8,9 @@ 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))
|
||||
- [eas-cli] Add `eas update:embedded:view` command. ([#3721](https://github.com/expo/eas-cli/pull/3721) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
|
||||
} from '../utils/expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
|
||||
@@ -208,6 +209,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
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.');
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
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 +210,12 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
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.');
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
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<string, true>): Record<string, { name: string }> {
|
||||
return Object.fromEntries(Object.keys(entries).map(name => [name, { name }]));
|
||||
}
|
||||
|
||||
function makeCtx(overrides: {
|
||||
platform: Platform;
|
||||
simulator?: boolean;
|
||||
channel?: string;
|
||||
env?: Record<string, string>;
|
||||
}): BuildContext<any> {
|
||||
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 and does not look for the archive', 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();
|
||||
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 () => {
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
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';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
|
||||
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<BuildJob>): Promise<void> {
|
||||
if (!(await isEASUpdateConfigured(ctx))) {
|
||||
ctx.markBuildPhaseSkipped();
|
||||
return;
|
||||
}
|
||||
|
||||
const { platform } = ctx.job;
|
||||
if (platform === Platform.IOS && (ctx.job as Ios.Job).simulator) {
|
||||
ctx.markBuildPhaseSkipped();
|
||||
return;
|
||||
}
|
||||
|
||||
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<Ios.Job>);
|
||||
} else if (platform === Platform.ANDROID) {
|
||||
archivePattern =
|
||||
(ctx as BuildContext<Android.Job>).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({
|
||||
rootDir: projectDir,
|
||||
patternOrPath: archivePattern,
|
||||
logger: null,
|
||||
}).catch(() => [] as string[]);
|
||||
|
||||
if (!archivePath) {
|
||||
ctx.logger.warn('Skipping embedded bundle upload: build archive not found.');
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
return;
|
||||
}
|
||||
|
||||
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());
|
||||
const bundleEntry = entries.find(e =>
|
||||
platform === Platform.IOS
|
||||
? e.name.endsWith('/main.jsbundle')
|
||||
: e.name.endsWith('assets/index.android.bundle')
|
||||
);
|
||||
const manifestEntry = entries.find(e =>
|
||||
platform === Platform.IOS
|
||||
? e.name.includes('EXUpdates.bundle/app.manifest')
|
||||
: e.name.endsWith('assets/app.manifest')
|
||||
);
|
||||
|
||||
if (!bundleEntry || !manifestEntry) {
|
||||
ctx.logger.warn('Skipping embedded bundle upload: bundle or manifest not found in archive.');
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
return;
|
||||
}
|
||||
|
||||
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 asyncResult(zip.close());
|
||||
}
|
||||
}
|
||||
@@ -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, string> = {
|
||||
[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, string> = {
|
||||
[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',
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Args, Errors } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { EasJsonOnlyFlag } from '../../../commandUtils/flags';
|
||||
import {
|
||||
EmbeddedUpdateFragment,
|
||||
EmbeddedUpdateQuery,
|
||||
isEmbeddedUpdateNotFoundError,
|
||||
} from '../../../graphql/queries/EmbeddedUpdateQuery';
|
||||
import Log from '../../../log';
|
||||
import formatFields from '../../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
export default class UpdateEmbeddedView extends EasCommand {
|
||||
static override description = 'view details of an embedded update registered with EAS Update';
|
||||
|
||||
static override args = {
|
||||
id: Args.string({
|
||||
required: true,
|
||||
description: 'The ID of the embedded update (manifest UUID from app.manifest).',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...EasJsonOnlyFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { id: embeddedUpdateId },
|
||||
flags: { json: jsonFlag },
|
||||
} = await this.parse(UpdateEmbeddedView);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(UpdateEmbeddedView, { nonInteractive: true });
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
let embeddedUpdate;
|
||||
try {
|
||||
embeddedUpdate = await EmbeddedUpdateQuery.viewByIdAsync(graphqlClient, {
|
||||
embeddedUpdateId,
|
||||
appId: projectId,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
if (isEmbeddedUpdateNotFoundError(e)) {
|
||||
Errors.error(
|
||||
`No embedded update found with id "${embeddedUpdateId}" for this project. ` +
|
||||
`Verify the id is correct and belongs to this app.`,
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(embeddedUpdate);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.log(formatEmbeddedUpdate(embeddedUpdate));
|
||||
}
|
||||
}
|
||||
|
||||
export function formatEmbeddedUpdate(embeddedUpdate: EmbeddedUpdateFragment): string {
|
||||
return formatFields([
|
||||
{ label: 'ID', value: embeddedUpdate.id },
|
||||
{ label: 'Platform', value: embeddedUpdate.platform.toLowerCase() },
|
||||
{ label: 'Runtime version', value: embeddedUpdate.runtimeVersion },
|
||||
{ label: 'Channel', value: embeddedUpdate.channel },
|
||||
{ label: 'Created at', value: new Date(embeddedUpdate.createdAt).toLocaleString() },
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppPlatform, EmbeddedUpdate } from '../generated';
|
||||
import { Connection } from '../../utils/relay';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
|
||||
export function isEmbeddedUpdateNotFoundError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof CombinedError &&
|
||||
error.graphQLErrors.some(e => e.extensions?.['errorCode'] === 'EMBEDDED_UPDATE_NOT_FOUND')
|
||||
);
|
||||
}
|
||||
|
||||
// Query result types are defined manually because the embeddedUpdates query fields
|
||||
// are not yet included in the GraphQL codegen schema.
|
||||
export type EmbeddedUpdateFragment = Pick<
|
||||
EmbeddedUpdate,
|
||||
'id' | 'platform' | 'runtimeVersion' | 'channel' | 'createdAt'
|
||||
>;
|
||||
|
||||
type ViewEmbeddedUpdateByIdQueryResult = {
|
||||
embeddedUpdates: {
|
||||
byId: EmbeddedUpdateFragment;
|
||||
};
|
||||
};
|
||||
|
||||
type ViewEmbeddedUpdateByIdQueryVariables = {
|
||||
embeddedUpdateId: string;
|
||||
appId: string;
|
||||
};
|
||||
|
||||
type ViewEmbeddedUpdatesPaginatedQueryResult = {
|
||||
app: {
|
||||
byId: {
|
||||
embeddedUpdatesPaginated: {
|
||||
edges: { cursor: string; node: EmbeddedUpdateFragment }[];
|
||||
pageInfo: {
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
startCursor: string | null;
|
||||
endCursor: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type ViewEmbeddedUpdatesPaginatedQueryVariables = {
|
||||
appId: string;
|
||||
first: number;
|
||||
after?: string;
|
||||
filter?: EmbeddedUpdateFilter;
|
||||
};
|
||||
|
||||
export type EmbeddedUpdateFilter = {
|
||||
platform?: AppPlatform;
|
||||
runtimeVersion?: string;
|
||||
channel?: string;
|
||||
};
|
||||
|
||||
export const EmbeddedUpdateQuery = {
|
||||
async viewByIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ embeddedUpdateId, appId }: { embeddedUpdateId: string; appId: string }
|
||||
): Promise<EmbeddedUpdateFragment> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<ViewEmbeddedUpdateByIdQueryResult, ViewEmbeddedUpdateByIdQueryVariables>(
|
||||
gql`
|
||||
query ViewEmbeddedUpdateById($embeddedUpdateId: ID!, $appId: ID!) {
|
||||
embeddedUpdates {
|
||||
byId(embeddedUpdateId: $embeddedUpdateId, appId: $appId) {
|
||||
id
|
||||
platform
|
||||
runtimeVersion
|
||||
channel
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ embeddedUpdateId, appId },
|
||||
{ additionalTypenames: ['EmbeddedUpdate'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.embeddedUpdates.byId;
|
||||
},
|
||||
|
||||
async viewPaginatedAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
filter,
|
||||
first,
|
||||
after,
|
||||
}: {
|
||||
appId: string;
|
||||
filter?: EmbeddedUpdateFilter;
|
||||
first: number;
|
||||
after?: string;
|
||||
}
|
||||
): Promise<Connection<EmbeddedUpdateFragment>> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<ViewEmbeddedUpdatesPaginatedQueryResult, ViewEmbeddedUpdatesPaginatedQueryVariables>(
|
||||
gql`
|
||||
query ViewEmbeddedUpdatesPaginated(
|
||||
$appId: String!
|
||||
$first: Int!
|
||||
$after: String
|
||||
$filter: EmbeddedUpdateFilterInput
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
embeddedUpdatesPaginated(first: $first, after: $after, filter: $filter) {
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
platform
|
||||
runtimeVersion
|
||||
channel
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appId, first, after, filter },
|
||||
{ additionalTypenames: ['EmbeddedUpdate'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.app.byId.embeddedUpdatesPaginated;
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user