Compare commits

...
7 changed files with 334 additions and 27 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
'@expo/build-tools': patch
---
Name the failed Google Play submission phase and include Google's error details in submission errors.
Keep bounded, redacted Google Play error responses in structured warning logs and error metadata for diagnostics.
@@ -1,8 +1,10 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { createLogger } from '@expo/logger';
import nock from 'nock';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { Writable } from 'node:stream';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import {
@@ -15,6 +17,7 @@ import {
import { createSubmitToGooglePlayBuildFunction } from '../submitToGooglePlay';
jest.unmock('node-fetch');
jest.unmock('@expo/logger');
jest.mock('promise-retry', () => {
const actual = jest.requireActual('promise-retry');
return (fn: Parameters<typeof actual>[0], options: object) =>
@@ -145,11 +148,18 @@ it('runs the step with a string rollout and validates its required output', asyn
await step.executeAsync();
expect(step.outputs[0].value).toBe('145');
});
it.each(['upload', 'track', 'abort'] as const)(
it.each([
['upload', 'Could not upload the AAB. '],
['track', 'Could not update track "internal". '],
['commit', 'Could not commit the Google Play edit. '],
['abort', undefined],
] as const)(
'deletes the edit after %s failure and leaves outputs unset',
async failure => {
async (failure, phase) => {
const controller = new AbortController();
const abortReason = new Error('step timed out');
const task = submission({}, controller.signal);
const rejected = { error: { message: 'rejected' } };
mockToken();
api().post(createPath).reply(200, { id: 'edit' });
api()
@@ -157,20 +167,30 @@ it.each(['upload', 'track', 'abort'] as const)(
.query({ uploadType: 'resumable' })
.reply(() => {
if (failure === 'abort') {
controller.abort(new Error('step timed out'));
controller.abort(abortReason);
}
return failure === 'upload'
? [400, { error: { message: 'rejected binary' } }]
? [400, rejected]
: [200, '', { Location: `https://androidpublisher.googleapis.com${session}` }];
});
if (failure === 'track') {
nock('https://androidpublisher.googleapis.com').put(session).reply(200, { versionCode: 145 });
api()
.put(`${editPath}/tracks/internal`)
.reply(400, { error: { message: 'unknown track' } });
api().put(`${editPath}/tracks/internal`).reply(400, rejected);
}
if (failure === 'commit') {
nock('https://androidpublisher.googleapis.com').put(session).reply(200, { versionCode: 145 });
api().put(`${editPath}/tracks/internal`).reply(200, { track: 'internal' });
api().post(`${editPath}:commit`).query({}).reply(400, rejected);
}
api().delete(editPath).reply(204);
await expect(task.run()).rejects.toThrow();
if (phase) {
await expect(task.run()).rejects.toMatchObject({
errorCode: 'EAS_GOOGLE_PLAY_INVALID_RELEASE',
message: expect.stringContaining(phase),
});
} else {
await expect(task.run()).rejects.toBe(abortReason);
}
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
}
);
@@ -184,11 +204,62 @@ it('preserves the original failure when cleanup fails', async () => {
cause: expect.objectContaining({ apiMessage: 'unknown track' }),
});
});
it('logs structured response diagnostics at warning level and includes them in Sentry metadata only', async () => {
upload();
const task = submission();
const records: Record<string, unknown>[] = [];
const logger = createLogger({
name: 'submission-test',
streams: [
{
level: 'info',
stream: new Writable({
write(chunk, _encoding, callback) {
records.push(JSON.parse(chunk.toString()));
callback();
},
}),
},
],
});
jest.spyOn(task.step.ctx.logger, 'warn').mockImplementation(logger.warn.bind(logger));
api()
.put(`${editPath}/tracks/internal`)
.reply(400, {
error: {
message: 'Rejected',
status: 'FAILED_PRECONDITION',
details: [{ '@type': 'unknown', diagnostic: 'DIAGNOSTIC_ONLY', access_token: 'SECRET' }],
},
});
api().delete(editPath).reply(204);
const error = await task.run().catch(error => error);
expect(error).toBeInstanceOf(UserError);
if (!(error instanceof UserError)) {
throw error;
}
expect(records).toEqual([
expect.objectContaining({
level: 40,
msg: 'Google Play submission failed.',
googlePlayHttpStatus: 400,
googlePlayErrorResponse: expect.any(Object),
}),
]);
expect(records[0].googlePlayErrorResponse).toEqual(error.metadata?.googlePlayErrorResponse);
expect(JSON.stringify(error.metadata)).toContain('DIAGNOSTIC_ONLY');
expect(JSON.stringify(error.metadata)).not.toContain('SECRET');
expect(JSON.stringify(error.toExternalExpoError())).not.toContain('DIAGNOSTIC_ONLY');
});
it('does not delete the edit or publish outputs when commit outcome is unknown', async () => {
upload();
api().put(`${editPath}/tracks/internal`).reply(200, { track: 'internal' });
api().post(`${editPath}:commit`).query(true).reply(503);
api().post(`${editPath}:commit`).query(true).reply(404);
api()
.post(`${editPath}:commit`)
.query(true)
.reply(404, { error: { message: 'Edit missing', status: 'NOT_FOUND' } });
let deleted = false;
api()
.delete(editPath)
@@ -198,7 +269,14 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
return [204];
});
const task = submission();
await expect(task.run()).rejects.toBeInstanceOf(SystemError);
const error = await task.run().catch(error => error);
expect(error).toBeInstanceOf(SystemError);
expect(error).toMatchObject({
metadata: {
googlePlayHttpStatus: 404,
googlePlayErrorResponse: { error: { message: 'Edit missing', status: 'NOT_FOUND' } },
},
});
expect(deleted).toBe(false);
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
});
@@ -179,6 +179,8 @@ async function submitToGooglePlayAsync({
let editId: string | undefined;
let committed = false;
let commitOutcomeUnknown = false;
// The submission phase in progress, named in Google Play errors.
let action = 'create a Google Play edit';
try {
const packageName = submission.package_name;
logger.info(
@@ -203,6 +205,7 @@ async function submitToGooglePlayAsync({
logger.info(`Changelog (en-US): ${JSON.stringify(submission.changelog)}`);
}
editId = (await GooglePlayUtils.createEditAsync(client, { packageName, signal })).id;
action = `upload the ${artifactType.toUpperCase()}`;
let lastPercent: number | undefined;
const { versionCode } = await GooglePlayUtils.uploadApplicationAsync(client, {
packageName,
@@ -219,6 +222,7 @@ async function submitToGooglePlayAsync({
},
});
logger.info(`Uploaded version code: ${versionCode}.`);
action = `update track ${JSON.stringify(submission.track)}`;
await GooglePlayUtils.updateTrackAsync(client, {
packageName,
editId,
@@ -230,6 +234,7 @@ async function submitToGooglePlayAsync({
});
logger.info(`Updated Google Play track ${JSON.stringify(submission.track)} in the edit.`);
logger.info('Committing the Google Play edit...');
action = 'commit the Google Play edit';
// Commit also validates the edit.
await GooglePlayUtils.commitEditAsync(client, {
packageName,
@@ -245,7 +250,14 @@ async function submitToGooglePlayAsync({
return versionCode;
} catch (error) {
commitOutcomeUnknown = error instanceof GooglePlayCommitOutcomeUnknownError;
throw GooglePlayUtils.mapGooglePlayError(error, submission.package_name);
const metadata = GooglePlayUtils.getGooglePlayErrorMetadata(error);
if (metadata) {
logger.warn(metadata, 'Google Play submission failed.');
}
throw GooglePlayUtils.mapGooglePlayError(error, {
packageName: submission.package_name,
action,
});
} finally {
if (editId && !committed && !commitOutcomeUnknown) {
try {
@@ -254,8 +266,9 @@ async function submitToGooglePlayAsync({
editId,
signal: AbortSignal.timeout(30_000),
});
} catch {
} catch (error) {
logger.warn(
GooglePlayUtils.getGooglePlayErrorMetadata(error) ?? {},
'Could not discard the pending Google Play changes. Check the release in Play Console before submitting again.'
);
}
@@ -1,11 +1,51 @@
import { Response } from 'node-fetch';
import { z } from 'zod';
const MAX_DETAILS = 5;
const MAX_DETAIL_LENGTH = 500;
// Reads only the human-readable parts of known google.rpc details. Other types and
// fields, such as ErrorInfo metadata, are skipped.
const DetailZ = z
.union([
z
.object({
'@type': z.literal('type.googleapis.com/google.rpc.ErrorInfo'),
reason: z.string(),
})
.transform(detail => [detail.reason]),
z
.object({
'@type': z.literal('type.googleapis.com/google.rpc.PreconditionFailure'),
violations: z.array(z.object({ type: z.string().optional(), description: z.string() })),
})
.transform(detail =>
detail.violations.map(({ type, description }) =>
type ? `${type}: ${description}` : description
)
),
z
.object({
'@type': z.literal('type.googleapis.com/google.rpc.BadRequest'),
fieldViolations: z.array(
z.object({ field: z.string().optional(), description: z.string() })
),
})
.transform(detail =>
detail.fieldViolations.map(({ field, description }) =>
field ? `${field}: ${description}` : description
)
),
])
.catch([]);
export class GooglePlayApiError extends Error {
constructor(
readonly status: number,
readonly apiMessage: string,
readonly reasons: string[]
readonly reasons: string[],
readonly details: string[] = [],
readonly responseBody?: unknown
) {
super(`Google Play request failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : '.'}`);
}
@@ -30,13 +70,60 @@ export class GooglePlayApiError extends Error {
error: z.object({
message: z.string().optional(),
errors: z.array(z.object({ reason: z.string().optional() })).optional(),
// A malformed detail must not hide the primary message.
details: z.array(DetailZ).catch([]),
}),
})
.safeParse(data);
return new GooglePlayApiError(
response.status,
parsed.data?.error.message ?? '',
parsed.data?.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? []
parsed.data?.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? [],
(parsed.data?.error.details ?? [])
.flat()
.slice(0, MAX_DETAILS)
.map(detail => detail.slice(0, MAX_DETAIL_LENGTH)),
data === undefined ? undefined : sanitizeResponseBody(data)
);
}
}
// Structured log properties are not covered by the worker's message secret filter.
// Never retain headers, request bodies, non-JSON responses or unbounded API data.
function sanitizeResponseBody(data: unknown): unknown {
let remainingNodes = 200;
const sanitize = (value: unknown, depth: number): unknown => {
if (--remainingNodes < 0 || depth > 6) {
return '[truncated]';
}
if (typeof value === 'string') {
return value
.replace(
/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g,
'[redacted]'
)
.replace(/https?:\/\/\S+/gi, '[URL redacted]')
.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]')
.slice(0, 2000);
}
if (Array.isArray(value)) {
return value.slice(0, 50).map(item => sanitize(item, depth + 1));
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value)
.slice(0, 50)
.map(([key, item]) => [
key.slice(0, 100),
/token|secret|password|authorization|cookie|credential|private.?key|email/i.test(key)
? '[redacted]'
: sanitize(item, depth + 1),
])
);
}
return value;
};
const sanitized = sanitize(data, 0);
const json = JSON.stringify(sanitized);
return json.length > 16000 ? { truncated: true, preview: json.slice(0, 16000) } : sanitized;
}
@@ -168,7 +168,7 @@ export namespace GooglePlayUtils {
) {
throw new GooglePlayCommitOutcomeUnknownError(
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
{ cause: error }
{ cause: error, metadata: getGooglePlayErrorMetadata(error) }
);
}
throw error;
@@ -210,17 +210,22 @@ export namespace GooglePlayUtils {
}
}
export function mapGooglePlayError(error: unknown, packageName: string): unknown {
// `action` names the failed submission phase, e.g. "create a Google Play edit".
// Other errors already describe themselves and are returned unchanged.
export function mapGooglePlayError(
error: unknown,
{ packageName, action }: { packageName: string; action: string }
): unknown {
if (error instanceof AggregateError) {
return mapGooglePlayError(error.cause, packageName);
return mapGooglePlayError(error.cause, { packageName, action });
}
if (
(error instanceof GooglePlayApiError || error instanceof GooglePlayAuthRequestError) &&
GooglePlayApiError.isRetryableStatus(error.status)
) {
return new SystemError(
`Google Play is temporarily unavailable or rate limited (HTTP ${error.status}). Try again later.`,
{ cause: error }
`Could not ${action}. Google Play is temporarily unavailable or rate limited (HTTP ${error.status}). Try again later.`,
{ cause: error, metadata: getGooglePlayErrorMetadata(error) }
);
}
if (
@@ -231,7 +236,7 @@ export namespace GooglePlayUtils {
) {
return new UserError(
'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
`Google rejected the service-account key (${error.errorCode ?? `HTTP ${error.status}`}). Check that the key is valid and the service account is enabled.`,
`Could not ${action}. Google rejected the service-account key (${error.errorCode ?? `HTTP ${error.status}`}). Check that the key is valid and the service account is enabled.`,
{ cause: error }
);
}
@@ -280,12 +285,28 @@ export namespace GooglePlayUtils {
code = 'EAS_GOOGLE_PLAY_INVALID_RELEASE';
detail = `Google Play rejected the release or review settings${message ? `: ${message}` : '.'} Check the track, release status, rollout, and review settings in Play Console.`;
}
const googleDetails = error.details.length
? `\n\nGoogle Play details:\n${error.details.map(item => `- ${item}`).join('\n')}`
: '';
return new UserError(
code,
`${detail}\n\nApp: ${packageName}\nGoogle Play Console: https://play.google.com/console/`,
`Could not ${action}. ${detail}${googleDetails}\n\nApp: ${packageName}\nGoogle Play Console: https://play.google.com/console/`,
{
cause: error,
metadata: getGooglePlayErrorMetadata(error),
}
);
}
export function getGooglePlayErrorMetadata(error: unknown): Record<string, unknown> | undefined {
// Retries and uncertain commits wrap the last API error in cause.
const seen = new Set<Error>();
for (let cause = error; cause instanceof Error && !seen.has(cause); cause = cause.cause) {
seen.add(cause);
if (cause instanceof GooglePlayApiError && cause.responseBody !== undefined) {
return { googlePlayHttpStatus: cause.status, googlePlayErrorResponse: cause.responseBody };
}
}
return undefined;
}
}
@@ -0,0 +1,92 @@
import { Response } from 'node-fetch';
import { GooglePlayApiError } from '../GooglePlayApiError';
jest.unmock('node-fetch');
function fromBody(error: unknown): Promise<GooglePlayApiError> {
return GooglePlayApiError.fromResponseAsync(
new Response(JSON.stringify({ error }), { status: 400 })
);
}
it('keeps readable Google details and skips metadata, unknown types and malformed entries', async () => {
const error = await fromBody({
message: 'Precondition check failed.',
errors: [{ reason: 'failedPrecondition' }],
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'APP_NOT_READY',
metadata: { project: 'PRIVATE' },
},
{
'@type': 'type.googleapis.com/google.rpc.PreconditionFailure',
violations: [{ type: 'APP_SETUP', subject: 'PRIVATE', description: 'Complete setup.' }],
},
{
'@type': 'type.googleapis.com/google.rpc.BadRequest',
fieldViolations: [{ field: 'releases[0]', description: 'Invalid rollout.' }],
},
{ '@type': 'type.googleapis.com/google.rpc.DebugInfo', detail: 'PRIVATE' },
{ '@type': 'type.googleapis.com/google.rpc.PreconditionFailure', violations: 'invalid' },
null,
],
});
expect(error).toMatchObject({
apiMessage: 'Precondition check failed.',
reasons: ['failedPrecondition'],
details: ['APP_NOT_READY', 'APP_SETUP: Complete setup.', 'releases[0]: Invalid rollout.'],
});
expect(JSON.stringify(error.details)).not.toContain('PRIVATE');
});
it('retains the JSON response with bounded and redacted diagnostic fields', async () => {
const error = await fromBody({
message: 'Denied',
status: 'FAILED_PRECONDITION',
details: [
{
'@type': 'unknown',
explanation: 'Useful extra detail',
metadata: {
access_token: 'SECRET',
password: 'SECRET',
url: 'https://example.com?token=SECRET',
description: 'Bearer SECRET',
private_key: 'SECRET',
},
},
],
extra: 'x'.repeat(100000),
});
const body = JSON.stringify(error.responseBody);
expect(body).toContain('Useful extra detail');
expect(body).toContain('FAILED_PRECONDITION');
expect(body).not.toContain('SECRET');
expect(body.length).toBeLessThan(17000);
});
it('does not retain a non-JSON error response', async () => {
const error = await GooglePlayApiError.fromResponseAsync(new Response('SECRET', { status: 502 }));
expect(error.responseBody).toBeUndefined();
});
it('keeps the primary error when details is not a list', async () => {
const error = await fromBody({ message: 'Denied', details: { '@type': 'unknown' } });
expect(error).toMatchObject({ apiMessage: 'Denied', details: [] });
});
it('bounds the number and length of details', async () => {
const error = await fromBody({
message: 'Invalid request.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.BadRequest',
fieldViolations: Array.from({ length: 20 }, () => ({ description: 'x'.repeat(10_000) })),
},
],
});
expect(error.details).toHaveLength(5);
expect(error.details.every(detail => detail.length === 500)).toBe(true);
});
@@ -14,6 +14,7 @@ jest.mock('promise-retry', () => {
return (fn: Parameters<typeof actual>[0], options: object) =>
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
});
const action = 'upload the AAB';
beforeEach(() => nock.disableNetConnect());
afterEach(() => {
const pending = nock.pendingMocks();
@@ -105,13 +106,17 @@ it.each([
[409, 'edit invalid', 'EAS_GOOGLE_PLAY_EDIT_INVALID'],
[400, 'unknown track', 'EAS_GOOGLE_PLAY_INVALID_RELEASE'],
] as const)('keeps the Google error behind %s %s', (status, message, code) => {
const original = new GooglePlayApiError(status, message, []);
const mapped = GooglePlayUtils.mapGooglePlayError(original, packageName);
const original = new GooglePlayApiError(status, message, [], ['APP_SETUP: Complete setup.']);
const mapped = GooglePlayUtils.mapGooglePlayError(original, { packageName, action });
expect(mapped).toBeInstanceOf(UserError);
expect(mapped).toMatchObject({
errorCode: code,
cause: original,
});
expect((mapped as Error).message).toMatch(/^Could not upload the AAB\. /);
expect((mapped as Error).message).toContain(
'Google Play details:\n- APP_SETUP: Complete setup.\n\nApp:'
);
if (code === 'EAS_GOOGLE_PLAY_REQUEST_FAILED' || code === 'EAS_GOOGLE_PLAY_INVALID_RELEASE') {
expect((mapped as Error).message).toContain(message);
} else {
@@ -127,8 +132,9 @@ it.each([
});
it('maps a rejected OAuth key to a credential error', () => {
const original = new GooglePlayAuthRequestError(400, 'invalid_grant');
expect(GooglePlayUtils.mapGooglePlayError(original, packageName)).toMatchObject({
expect(GooglePlayUtils.mapGooglePlayError(original, { packageName, action })).toMatchObject({
errorCode: 'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
message: expect.stringMatching(/^Could not upload the AAB\. /),
cause: original,
});
});
@@ -137,13 +143,16 @@ it.each([408, 429, 500, 503])('classifies temporary HTTP %s errors as system err
new GooglePlayApiError(status, 'temporary failure', []),
new GooglePlayAuthRequestError(status, 'temporarily_unavailable'),
]) {
const mapped = GooglePlayUtils.mapGooglePlayError(original, packageName);
const mapped = GooglePlayUtils.mapGooglePlayError(original, { packageName, action });
expect(mapped).toBeInstanceOf(SystemError);
expect(mapped).toMatchObject({ cause: original });
expect(mapped).toMatchObject({
message: expect.stringMatching(/^Could not upload the AAB\. /),
cause: original,
});
expect((mapped as Error).message).not.toContain('settings');
}
});
it('keeps non-Google errors unchanged', () => {
const error = new SystemError('file failure');
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toBe(error);
expect(GooglePlayUtils.mapGooglePlayError(error, { packageName, action })).toBe(error);
});