Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/google-play-submission-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@expo/build-tools': patch
'@expo/eas-json': patch
---

Clarify Google Play submission messages and reject zero Android submission rollouts.
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,32 @@ it('preserves the cause when the service-account file cannot be read', async ()
read.mockRestore();
}
});

it('logs a formatted rollout and confirms the track update before committing', async () => {
upload();
const task = submission({ rollout: 0.29 });
const info = jest.spyOn(task.step.ctx.logger, 'info');
const updatedMessage = 'Updated Google Play track "internal" in the edit.';
let loggedBeforeUpdate = false;
let messagesBeforeCommit: unknown[] = [];
api()
.put(`${editPath}/tracks/internal`)
.reply(() => {
loggedBeforeUpdate = info.mock.calls.some(([message]) => message === updatedMessage);
return [200, { track: 'internal' }];
});
api()
.post(`${editPath}:commit`)
.query({})
.reply(() => {
messagesBeforeCommit = info.mock.calls.map(([message]) => message);
return [200, { id: 'edit' }];
});
await task.run();
expect(info).toHaveBeenCalledWith('Release status: inProgress. Rollout: 29%.');
expect(loggedBeforeUpdate).toBe(false);
expect(messagesBeforeCommit.slice(-2)).toEqual([
updatedMessage,
'Committing the Google Play edit...',
]);
});
21 changes: 17 additions & 4 deletions packages/build-tools/src/steps/functions/submitToGooglePlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,19 @@ async function submitToGooglePlayAsync({
`Submitting ${packageName} (${artifactType}) to track ${JSON.stringify(submission.track)}.`
);
logger.info(
`Requested status: ${submission.release_status}; rollout: ${submission.rollout ?? 'none'}. Effective status: ${release.status}; rollout: ${release.userFraction ?? 'none'}. changes_not_sent_for_review: ${submission.changes_not_sent_for_review}.`
`Release status: ${release.status}. Rollout: ${release.userFraction === undefined ? 'none' : new Intl.NumberFormat('en-US', { style: 'percent', maximumSignificantDigits: 15 }).format(release.userFraction)}.`
);
if (submission.release_status !== release.status) {
logger.info(
release.status === 'completed'
? 'A 100% rollout is submitted as a completed release.'
: 'A partial rollout is submitted as an in-progress release.'
);
}
logger.info(
submission.changes_not_sent_for_review
? 'Save changes without sending them for review.'
: 'Send changes for review.'
);
if (submission.changelog) {
logger.info(`Changelog (en-US): ${JSON.stringify(submission.changelog)}`);
Expand Down Expand Up @@ -216,7 +228,8 @@ async function submitToGooglePlayAsync({
changelog: submission.changelog ? { locale: 'en-US', text: submission.changelog } : undefined,
signal,
});
logger.info('Updated Google Play track ' + JSON.stringify(submission.track) + ' in the edit.');
logger.info(`Updated Google Play track ${JSON.stringify(submission.track)} in the edit.`);
logger.info('Committing the Google Play edit...');
// Commit also validates the edit.
await GooglePlayUtils.commitEditAsync(client, {
packageName,
Expand All @@ -232,7 +245,7 @@ async function submitToGooglePlayAsync({
return versionCode;
} catch (error) {
commitOutcomeUnknown = error instanceof GooglePlayCommitOutcomeUnknownError;
throw GooglePlayUtils.mapGooglePlayError(error);
throw GooglePlayUtils.mapGooglePlayError(error, submission.package_name);
} finally {
if (editId && !committed && !commitOutcomeUnknown) {
try {
Expand All @@ -243,7 +256,7 @@ async function submitToGooglePlayAsync({
});
} catch {
logger.warn(
'Could not delete the Google Play edit after failure. The original submission error is preserved.'
'Could not discard the pending Google Play changes. Check the release in Play Console before submitting again.'
);
}
}
Expand Down
33 changes: 22 additions & 11 deletions packages/build-tools/src/steps/utils/android/GooglePlayUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,17 @@ export namespace GooglePlayUtils {
error.status === 429
) {
throw new GooglePlayCommitOutcomeUnknownError(
'Google Play commit outcome is unknown. Check the release in Play Console before you submit again. The commit was not retried.',
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again. The request was not retried because it may have succeeded.`,
{ cause: error }
);
}
throw error;
}
logger.info(
changesNotSentForReview
? 'Google Play saved the changes without sending them for review. Send them for review at https://play.google.com/console/.'
: 'Google Play saved the release using its normal review process. Check its review and publishing status at https://play.google.com/console/.'
);
};
try {
// Omit the optional parameter for normal review submission. Some app states
Expand Down Expand Up @@ -206,7 +211,7 @@ export namespace GooglePlayUtils {
}
}

export function mapGooglePlayError(error: unknown): unknown {
export function mapGooglePlayError(error: unknown, packageName: string): unknown {
if (
(error instanceof GooglePlayApiError || error instanceof GooglePlayAuthRequestError) &&
(error.status === 408 || error.status === 429 || error.status >= 500)
Expand Down Expand Up @@ -235,7 +240,7 @@ export namespace GooglePlayUtils {
const message = error.apiMessage;
const reasons = new Set(error.reasons);
let code = 'EAS_GOOGLE_PLAY_REQUEST_FAILED';
let detail = `Google Play rejected the request (HTTP ${error.status}; reasons: ${error.reasons.join(', ') || 'unspecified'}). Check the app and release settings in Play Console. Concurrent edits or Console changes can invalidate an edit.`;
let detail = `Google Play rejected the request (HTTP ${error.status})${message ? `: ${message}` : '.'} Check the app settings before submitting again.`;
if (message.includes('Package not found')) {
code = 'EAS_GOOGLE_PLAY_FIRST_UPLOAD';
detail =
Expand All @@ -247,18 +252,21 @@ export namespace GooglePlayUtils {
) {
code = 'EAS_GOOGLE_PLAY_VERSION_CODE_USED';
detail =
'This version code was already uploaded. Increase the Android version code and build a new binary.';
'This version code was already uploaded. Check whether the intended release already exists. To upload a new binary, increase the Android version code and build again.';
} else if (
/privacy policy/i.test(message) ||
reasons.has('apkNotificationMessageKeyPermissionsRequirePrivacyPolicy') ||
message.includes('apkNotificationMessageKeyPermissionsRequirePrivacyPolicy')
) {
code = 'EAS_GOOGLE_PLAY_PRIVACY_POLICY_REQUIRED';
detail = 'This app requires a privacy policy. Add it in Play Console before you submit.';
} else if (message.includes('Only releases with status draft may be created on draft app')) {
} else if (
message.includes('Only releases with status draft may be created on draft app') ||
message.includes('Staged rollout is not permitted on draft app')
) {
code = 'EAS_GOOGLE_PLAY_DRAFT_APP';
detail =
'This app is a draft in Play Console. Complete its required metadata or submit with release_status: draft.';
'This app is still a draft in Play Console. Submit a draft release without a rollout, or complete the app setup in Play Console before submitting a release to testers. Use releaseStatus: "draft" in eas.json, or release_status: "draft" in a workflow.';
} else if (error.status === 409 || /edit.*(?:expired|invalid|not found)/i.test(message)) {
code = 'EAS_GOOGLE_PLAY_EDIT_INVALID';
detail =
Expand All @@ -269,11 +277,14 @@ export namespace GooglePlayUtils {
'The Google service account cannot submit this app. Check its key and app permissions in Play Console.';
} else if (error.status === 400) {
code = 'EAS_GOOGLE_PLAY_INVALID_RELEASE';
detail =
'Google Play rejected the release or review settings. Check the track, release status, rollout, and review settings in Play Console.';
detail = `Google Play rejected the release or review settings${message ? `: ${message}` : '.'} Check the track, release status, rollout, and review settings in Play Console.`;
}
return new UserError(code, message ? `${detail} Google Play said: ${message}` : detail, {
cause: error,
});
return new UserError(
code,
`${detail}\n\nApp: ${packageName}\nGoogle Play Console: https://play.google.com/console/`,
{
cause: error,
}
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,17 @@ afterEach(() => {
function commit({
signal,
changesNotSentForReview = false,
logger = createMockLogger(),
}: {
signal?: AbortSignal;
changesNotSentForReview?: boolean;
logger?: ReturnType<typeof createMockLogger>;
} = {}) {
return GooglePlayUtils.commitEditAsync(new GooglePlayClient(serviceAccount), {
packageName,
editId: 'edit',
changesNotSentForReview,
logger: createMockLogger(),
logger,
signal,
});
}
Expand Down Expand Up @@ -57,10 +59,16 @@ it.each([
] as const)(
'retries the rejected review setting (requested=%s)',
async (changesNotSentForReview, initialQuery, message, retryQuery) => {
const logger = createMockLogger();
mockToken();
api().post(`${editPath}:commit`).query(initialQuery).reply(400, { error: { message } });
api().post(`${editPath}:commit`).query(retryQuery).reply(200, { id: 'edit' });
await expect(commit({ changesNotSentForReview })).resolves.toBeUndefined();
await expect(commit({ changesNotSentForReview, logger })).resolves.toBeUndefined();
expect(logger.info).toHaveBeenLastCalledWith(
changesNotSentForReview
? 'Google Play saved the release using its normal review process. Check its review and publishing status at https://play.google.com/console/.'
: 'Google Play saved the changes without sending them for review. Send them for review at https://play.google.com/console/.'
);
}
);
it.each([408, 429, 503])('does not retry an uncertain HTTP %s commit', async status => {
Expand All @@ -86,21 +94,33 @@ it.each([
[403, 'wrong signing key', 'EAS_GOOGLE_PLAY_REQUEST_FAILED'],
[400, 'privacy policy required', 'EAS_GOOGLE_PLAY_PRIVACY_POLICY_REQUIRED'],
[400, 'Only releases with status draft may be created on draft app', 'EAS_GOOGLE_PLAY_DRAFT_APP'],
[400, 'Staged rollout is not permitted on draft app', 'EAS_GOOGLE_PLAY_DRAFT_APP'],
[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);
const mapped = GooglePlayUtils.mapGooglePlayError(original, packageName);
expect(mapped).toBeInstanceOf(UserError);
expect(mapped).toMatchObject({
errorCode: code,
message: expect.stringContaining(`Google Play said: ${message}`),
cause: original,
});
if (code === 'EAS_GOOGLE_PLAY_REQUEST_FAILED' || code === 'EAS_GOOGLE_PLAY_INVALID_RELEASE') {
expect((mapped as Error).message).toContain(message);
} else {
expect((mapped as Error).message).not.toContain(message);
}
expect((mapped as Error).message).toContain(`App: ${packageName}`);
expect((mapped as Error).message).toContain('https://play.google.com/console/');
expect((mapped as Error).message).not.toContain('Google Play error:');
if (code === 'EAS_GOOGLE_PLAY_DRAFT_APP') {
expect((mapped as Error).message).toContain('without a rollout');
expect((mapped as Error).message).toContain('releaseStatus: "draft" in eas.json');
}
});
it('maps a rejected OAuth key to a credential error', () => {
const original = new GooglePlayAuthRequestError(400, 'invalid_grant');
expect(GooglePlayUtils.mapGooglePlayError(original)).toMatchObject({
expect(GooglePlayUtils.mapGooglePlayError(original, packageName)).toMatchObject({
errorCode: 'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
cause: original,
});
Expand All @@ -110,13 +130,13 @@ 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);
const mapped = GooglePlayUtils.mapGooglePlayError(original, packageName);
expect(mapped).toBeInstanceOf(SystemError);
expect(mapped).toMatchObject({ 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)).toBe(error);
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toBe(error);
});
4 changes: 2 additions & 2 deletions packages/eas-json/src/__tests__/submitProfiles-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,7 @@ test(`android config with releaseStatus ${AndroidReleaseStatus.inProgress} is va
});
});

test.each([0, 1, 0.2, 0.75, 0.22])('rollout value %s is valid', async rollout => {
test.each([1, 0.2, 0.75, 0.22])('rollout value %s is valid', async rollout => {
await fs.writeJson('/project/eas.json', {
submit: {
production: {
Expand Down Expand Up @@ -669,7 +669,7 @@ test.each([0, 1, 0.2, 0.75, 0.22])('rollout value %s is valid', async rollout =>
});
});

test.each([-5, 12, 1.2, 1.01, 99])('rollout value %s is invalid', async rollout => {
test.each([0, -5, 12, 1.2, 1.01, 99])('rollout value %s is invalid', async rollout => {
await fs.writeJson('/project/eas.json', {
submit: {
production: {
Expand Down
2 changes: 1 addition & 1 deletion packages/eas-json/src/submit/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const AndroidSubmitProfileSchema = Joi.object({
.default(AndroidReleaseStatus.completed),
changesNotSentForReview: Joi.boolean().default(false),
applicationId: Joi.string(),
rollout: Joi.number().min(0).max(1).when('releaseStatus', {
rollout: Joi.number().greater(0).max(1).when('releaseStatus', {
is: AndroidReleaseStatus.inProgress,
then: Joi.required(),
otherwise: Joi.forbidden(),
Expand Down
Loading