Compare commits

...
Author SHA1 Message Date
evanbacon c8d4262aa7 Delete remote previews when removed from config
Update PreviewsTask to only sync/upload preview sets when previews are configured and to delete remote previews that are no longer present in the config. The task now retrieves existing preview sets, syncs configured preview types, and iterates existing sets to remove previews whose types were removed from the config (using preview.deleteAsync with logging). Add a unit test to cover deletion of remote previews and a small typing fix in the nock mock (cast adapter to any) to satisfy TypeScript.
2026-04-17 10:36:40 -07:00
Mustafa Abban b22d89e41f [build-tools] remove experimental prefix from gradle cache env var (#3613) 2026-04-16 15:34:28 -04:00
Szymon Świerk 81fe0f237f extend the app_store_connect.build_upload interpolation context (#3599) 2026-04-16 15:50:39 +02:00
Douglas Lowder b9ddc04cf7 [eas-cli] Improve observe:metrics handling of app version update IDs (#3609)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->

<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Currently the output of observe:metrics is unusable if an app version has many updates. Since the metrics are not broken out by update, it makes sense to make two changes:

- Remove update IDs from the display table
- Add the update IDs to the JSON output as an array, in case they are needed as input to other commands, or to AI

Additionally, made a change so that if a project ID is manually passed in, the observe commands do not unnecessarily try to create a new EAS project where none is needed.

# Test Plan

- Unit tests pass
- Tested manually against existing projects with metrics
2026-04-15 12:04:40 -07:00
Expo CI d6a8a4f694 update CHANGELOG.md 2026-04-14 18:51:07 +00:00
14 changed files with 295 additions and 59 deletions

No files matched your search

+9 -3
View File
@@ -8,13 +8,19 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [build-tools] Add `eas/read_package_json` and `eas/read_app_config` functions ([#3585](https://github.com/expo/eas-cli/pull/3585) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `--skip-bundler` to `eas build:dev` to install and run a development build without starting the local Expo/Metro server. ([#3604](https://github.com/expo/eas-cli/pull/3604) by [@mmichels-brex](https://github.com/mmichels-brex))
### 🐛 Bug fixes
- [eas-cli] Fixes for `observe` commands, including an issue for apps with many update IDs. ([#3609](https://github.com/expo/eas-cli/pull/3609) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
## [18.7.0](https://github.com/expo/eas-cli/releases/tag/v18.7.0) - 2026-04-14
### 🎉 New features
- [build-tools] Add `eas/read_package_json` and `eas/read_app_config` functions ([#3585](https://github.com/expo/eas-cli/pull/3585) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `--skip-bundler` to `eas build:dev` to install and run a development build without starting the local Expo/Metro server. ([#3604](https://github.com/expo/eas-cli/pull/3604) by [@mmichels-brex](https://github.com/mmichels-brex))
## [18.6.0](https://github.com/expo/eas-cli/releases/tag/v18.6.0) - 2026-04-10
### 🎉 New features
@@ -200,7 +200,7 @@ export async function restoreGradleCacheAsync({
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
if (env.EAS_GRADLE_CACHE !== '1') {
return;
}
@@ -161,7 +161,7 @@ export async function saveGradleCacheAsync({
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
if (env.EAS_GRADLE_CACHE !== '1') {
return;
}
@@ -56,6 +56,110 @@ describe('StaticWorkflowInterpolationContextZ', () => {
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with cf_bundle_version', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'processing',
cf_bundle_version: '42',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with build.id', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'complete',
build: {
id: 'build-abc123',
},
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with both cf_bundle_version and build.id', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'complete',
cf_bundle_version: '42',
build: {
id: 'build-abc123',
},
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect context without build_upload', () => {
const context = {
after: {},
+6
View File
@@ -256,6 +256,12 @@ const AppStoreConnectContextZ = z.looseObject({
.looseObject({
id: z.string(),
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
cf_bundle_version: z.string().optional(),
build: z
.looseObject({
id: z.string(),
})
.optional(),
})
.optional(),
external_beta: z
@@ -199,6 +199,8 @@ describe(ObserveMetrics, () => {
expect.any(Map),
expect.any(Array),
['min', 'average'],
expect.any(Map),
expect.any(Map),
expect.any(Map)
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
@@ -81,16 +81,29 @@ export default class ObserveEvents extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveEvents);
const {
projectId: contextProjectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveEvents, {
nonInteractive: flags['non-interactive'],
});
const projectId = flags['project-id'] ?? contextProjectId;
let projectId: string;
let graphqlClient;
if (flags['project-id']) {
projectId = flags['project-id'];
const ctx = await this.getContextAsync(
{ contextDefinition: ObserveEvents.loggedInOnlyContextDefinition },
{ nonInteractive: flags['non-interactive'] }
);
graphqlClient = ctx.loggedIn.graphqlClient;
} else {
const ctx = await this.getContextAsync(ObserveEvents, {
nonInteractive: flags['non-interactive'],
});
projectId = ctx.projectId;
graphqlClient = ctx.loggedIn.graphqlClient;
}
if (flags.json) {
enableJsonOutput();
@@ -78,16 +78,29 @@ export default class ObserveMetrics extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveMetrics);
const {
projectId: contextProjectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveMetrics, {
nonInteractive: flags['non-interactive'],
});
const projectId = flags['project-id'] ?? contextProjectId;
let projectId: string;
let graphqlClient;
if (flags['project-id']) {
projectId = flags['project-id'];
const ctx = await this.getContextAsync(
{ contextDefinition: ObserveMetrics.loggedInOnlyContextDefinition },
{ nonInteractive: flags['non-interactive'] }
);
graphqlClient = ctx.loggedIn.graphqlClient;
} else {
const ctx = await this.getContextAsync(ObserveMetrics, {
nonInteractive: flags['non-interactive'],
});
projectId = ctx.projectId;
graphqlClient = ctx.loggedIn.graphqlClient;
}
if (flags.json) {
enableJsonOutput();
@@ -122,7 +135,14 @@ export default class ObserveMetrics extends EasCommand {
if (flags.json) {
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_JSON;
printJsonOnlyOutput(
buildObserveMetricsJson(metricsMap, metricNames, stats, totalEventCounts)
buildObserveMetricsJson(
metricsMap,
metricNames,
stats,
totalEventCounts,
buildNumbersMap,
updateIdsMap
)
);
} else {
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE;
@@ -131,7 +151,6 @@ export default class ObserveMetrics extends EasCommand {
buildObserveMetricsTable(metricsMap, metricNames, stats, {
daysBack,
buildNumbersMap,
updateIdsMap,
totalEventCounts,
})
);
@@ -42,16 +42,29 @@ export default class ObserveVersions extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveVersions);
const {
projectId: contextProjectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveVersions, {
nonInteractive: flags['non-interactive'],
});
const projectId = flags['project-id'] ?? contextProjectId;
let projectId: string;
let graphqlClient;
if (flags['project-id']) {
projectId = flags['project-id'];
const ctx = await this.getContextAsync(
{ contextDefinition: ObserveVersions.loggedInOnlyContextDefinition },
{ nonInteractive: flags['non-interactive'] }
);
graphqlClient = ctx.loggedIn.graphqlClient;
} else {
const ctx = await this.getContextAsync(ObserveVersions, {
nonInteractive: flags['non-interactive'],
});
projectId = ctx.projectId;
graphqlClient = ctx.loggedIn.graphqlClient;
}
if (flags.json) {
enableJsonOutput();
@@ -5,4 +5,4 @@ import { getAdapter } from 'axios';
// see: https://github.com/nock/nock#axios
export { default } from 'nock';
getRequestClient().defaults.adapter = getAdapter('http');
getRequestClient().defaults.adapter = getAdapter('http') as any;
@@ -443,5 +443,54 @@ describe(PreviewsTask, () => {
})
);
});
it('deletes remote previews when preview type is removed from config', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
// No previews configured — the IPHONE_67 preview should be deleted
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const existingPreview = new AppPreview(requestContext, 'PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: null,
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
const deleteScope = nock('https://api.appstoreconnect.apple.com')
.delete(`/v1/${AppPreview.type}/PV_1`)
.reply(204);
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
previewTypeMap.set(
PreviewType.IPHONE_67,
new AppPreviewSet(requestContext, 'PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [existingPreview],
} as any)
);
const previewSets = new Map();
previewSets.set('en-US', previewTypeMap);
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(deleteScope.isDone()).toBeTruthy();
});
});
});
@@ -136,9 +136,7 @@ export class PreviewsTask extends AppleTask {
for (const localeCode of locales) {
const previews = config.getPreviews(localeCode);
if (!previews || Object.keys(previews).length === 0) {
continue;
}
const existingSets = context.previewSets.get(localeCode);
const localization = context.versionLocales.find(l => l.attributes.locale === localeCode);
if (!localization) {
@@ -146,18 +144,39 @@ export class PreviewsTask extends AppleTask {
continue;
}
for (const [previewType, previewConfig] of Object.entries(previews)) {
if (!previewConfig) {
continue;
}
// Upload/sync configured previews
if (previews) {
for (const [previewType, previewConfig] of Object.entries(previews)) {
if (!previewConfig) {
continue;
}
await syncPreviewSetAsync(
context.projectDir,
localization,
previewType as PreviewType,
normalizePreviewConfig(previewConfig),
context.previewSets.get(localeCode)
);
await syncPreviewSetAsync(
context.projectDir,
localization,
previewType as PreviewType,
normalizePreviewConfig(previewConfig),
existingSets
);
}
}
// Delete remote previews that are no longer in config
if (existingSets) {
for (const [previewType, previewSet] of existingSets) {
if (previews?.[previewType]) {
continue;
}
const existingPreviews = previewSet.attributes.appPreviews || [];
for (const preview of existingPreviews) {
await logAsync(() => preview.deleteAsync(), {
pending: `Deleting video preview ${chalk.bold(preview.attributes.fileName)} (${localeCode})...`,
success: `Deleted video preview ${chalk.bold(preview.attributes.fileName)} (${localeCode})`,
failure: `Failed deleting video preview ${chalk.bold(preview.attributes.fileName)} (${localeCode})`,
});
}
}
}
}
}
@@ -126,6 +126,8 @@ describe(buildObserveMetricsJson, () => {
expect(result.versions[0]).toEqual({
appVersion: '1.0.0',
platform: AppPlatform.Ios,
buildNumbers: [],
updateIds: [],
metrics: {
'expo.app_startup.tti': {
min: 0.1,
+19 -16
View File
@@ -105,6 +105,8 @@ export type MetricValuesJson = Partial<Record<StatisticKey, number | null>>;
export interface ObserveMetricsVersionResult {
appVersion: string;
platform: AppPlatform;
buildNumbers: string[];
updateIds: string[];
metrics: Record<string, MetricValuesJson>;
}
@@ -117,7 +119,9 @@ export function buildObserveMetricsJson(
metricsMap: ObserveMetricsMap,
metricNames: string[],
stats: StatisticKey[],
totalEventCounts?: Map<string, number>
totalEventCounts?: Map<string, number>,
buildNumbersMap?: BuildNumbersMap,
updateIdsMap?: UpdateIdsMap
): ObserveMetricsJsonOutput {
const versions: ObserveMetricsVersionResult[] = [];
@@ -134,7 +138,13 @@ export function buildObserveMetricsJson(
metrics[metricName] = statValues;
}
versions.push({ appVersion, platform, metrics });
versions.push({
appVersion,
platform,
buildNumbers: buildNumbersMap?.get(key) ?? [],
updateIds: updateIdsMap?.get(key) ?? [],
metrics,
});
}
// Group total event counts by metric → platform
@@ -188,7 +198,6 @@ export function buildObserveMetricsTable(
options?: {
daysBack?: number;
buildNumbersMap?: BuildNumbersMap;
updateIdsMap?: UpdateIdsMap;
totalEventCounts?: Map<string, number>;
}
): string {
@@ -232,12 +241,7 @@ export function buildObserveMetricsTable(
}
}
}
// Check if any version has updates
const hasUpdates = options?.updateIdsMap
? Array.from(options.updateIdsMap.values()).some(ids => ids.length > 0)
: false;
const headers = ['App Version', ...(hasUpdates ? ['Updates'] : []), ...metricHeaders];
const headers = ['App Version', ...metricHeaders];
const sections: string[] = [chalk.bold(summaryLine)];
@@ -245,16 +249,14 @@ export function buildObserveMetricsTable(
sections.push('');
sections.push(chalk.bold(appPlatformDisplayNames[platform]));
const rows: string[][] = platformResults.map(result => {
const rows: string[][] = [];
for (const result of platformResults) {
const key = makeMetricsKey(result.appVersion, result.platform);
const buildNumbers = options?.buildNumbersMap?.get(key);
const versionLabel = buildNumbers?.length
? `${result.appVersion} (${buildNumbers.join(', ')})`
: result.appVersion;
const updateIds = options?.updateIdsMap?.get(key);
const updatesLabel = updateIds?.length ? updateIds.join(', ') : '';
const metricCells: string[] = [];
for (const m of metricNames) {
const values = result.metrics[m];
@@ -272,8 +274,9 @@ export function buildObserveMetricsTable(
}
}
}
return [versionLabel, ...(hasUpdates ? [updatesLabel] : []), ...metricCells];
});
rows.push([versionLabel, ...metricCells]);
}
let footerRow: string[] | undefined;
if (options?.totalEventCounts) {
@@ -283,7 +286,7 @@ export function buildObserveMetricsTable(
countCells.push(count != null ? count.toLocaleString() : '-');
}
if (countCells.some(c => c !== '-')) {
footerRow = ['Total events', ...(hasUpdates ? [''] : []), ...countCells];
footerRow = ['Total events', ...countCells];
}
}