diff --git a/.changeset/google-play-submission-messages.md b/.changeset/google-play-submission-messages.md new file mode 100644 index 0000000000..0883ca28ab --- /dev/null +++ b/.changeset/google-play-submission-messages.md @@ -0,0 +1,6 @@ +--- +'@expo/build-tools': patch +'@expo/eas-json': patch +--- + +Clarify Google Play submission messages and reject zero Android submission rollouts. diff --git a/packages/build-tools/src/steps/functions/__tests__/submitToGooglePlay.test.ts b/packages/build-tools/src/steps/functions/__tests__/submitToGooglePlay.test.ts index 401e8bcb46..5ea290b55b 100644 --- a/packages/build-tools/src/steps/functions/__tests__/submitToGooglePlay.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/submitToGooglePlay.test.ts @@ -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...', + ]); +}); diff --git a/packages/build-tools/src/steps/functions/submitToGooglePlay.ts b/packages/build-tools/src/steps/functions/submitToGooglePlay.ts index d6e4ee9157..74657e07a6 100644 --- a/packages/build-tools/src/steps/functions/submitToGooglePlay.ts +++ b/packages/build-tools/src/steps/functions/submitToGooglePlay.ts @@ -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)}`); @@ -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, @@ -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 { @@ -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.' ); } } diff --git a/packages/build-tools/src/steps/utils/android/GooglePlayUtils.ts b/packages/build-tools/src/steps/utils/android/GooglePlayUtils.ts index 89fa35d676..dfe822ce55 100644 --- a/packages/build-tools/src/steps/utils/android/GooglePlayUtils.ts +++ b/packages/build-tools/src/steps/utils/android/GooglePlayUtils.ts @@ -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 @@ -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) @@ -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 = @@ -247,7 +252,7 @@ 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') || @@ -255,10 +260,13 @@ export namespace GooglePlayUtils { ) { 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 = @@ -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, + } + ); } } diff --git a/packages/build-tools/src/steps/utils/android/__tests__/GooglePlaySubmission.test.ts b/packages/build-tools/src/steps/utils/android/__tests__/GooglePlaySubmission.test.ts index 78166eb40c..1a6033e8bd 100644 --- a/packages/build-tools/src/steps/utils/android/__tests__/GooglePlaySubmission.test.ts +++ b/packages/build-tools/src/steps/utils/android/__tests__/GooglePlaySubmission.test.ts @@ -18,15 +18,17 @@ afterEach(() => { function commit({ signal, changesNotSentForReview = false, + logger = createMockLogger(), }: { signal?: AbortSignal; changesNotSentForReview?: boolean; + logger?: ReturnType; } = {}) { return GooglePlayUtils.commitEditAsync(new GooglePlayClient(serviceAccount), { packageName, editId: 'edit', changesNotSentForReview, - logger: createMockLogger(), + logger, signal, }); } @@ -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 => { @@ -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, }); @@ -110,7 +130,7 @@ 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'); @@ -118,5 +138,5 @@ it.each([408, 429, 500, 503])('classifies temporary HTTP %s errors as system err }); 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); }); diff --git a/packages/eas-json/src/__tests__/submitProfiles-test.ts b/packages/eas-json/src/__tests__/submitProfiles-test.ts index 95cecb9288..4f4253572f 100644 --- a/packages/eas-json/src/__tests__/submitProfiles-test.ts +++ b/packages/eas-json/src/__tests__/submitProfiles-test.ts @@ -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: { @@ -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: { diff --git a/packages/eas-json/src/submit/schema.ts b/packages/eas-json/src/submit/schema.ts index f5108208b2..3c1ca7a52b 100644 --- a/packages/eas-json/src/submit/schema.ts +++ b/packages/eas-json/src/submit/schema.ts @@ -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(),