@mpgd/cli 0.58.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { createReadStream, existsSync, statSync } from 'node:fs';
3
3
  import { inspectAndroidBundleSigner } from './android-bundle-signer.js';
4
+ import { assertPlayReleaseConfirmed, assertPlayReleaseRequest, defaultPlayReleaseRequest, hashMappingFile, isPlayDraftAppRejection, isWellKnownPlayTrack, mergeNewPlayRelease, newPlayRelease, playReleaseNeedsConfirmation, } from './play-release-options.js';
4
5
  const sha256Pattern = /^[a-f0-9]{64}$/u;
5
6
  const editIdPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
6
- const trackName = 'internal';
7
7
  const bundleUploadTimeoutMs = 120_000;
8
8
  export class PlaySubmissionUncertainError extends Error {
9
9
  stage;
@@ -24,9 +24,45 @@ export async function submitVerifiedAndroidBundle(input) {
24
24
  const publisher = createServiceAccountPublisher(input.serviceAccountFile);
25
25
  return submitVerifiedAndroidBundleWithPublisher(input, publisher);
26
26
  }
27
+ /** Verify the record and artifact, then describe the edit without calling Google Play. */
28
+ export async function planVerifiedAndroidBundleSubmission(input) {
29
+ const request = input.release ?? defaultPlayReleaseRequest;
30
+ assertPlayReleaseRequest(request);
31
+ const versionCode = await preflight(input);
32
+ await assertMappingUnchanged(request);
33
+ return {
34
+ dryRun: true,
35
+ packageName: input.packageName,
36
+ track: request.track,
37
+ versionCode,
38
+ bundleSha256: input.record.artifactSha256,
39
+ release: newPlayRelease(request, input.record.releaseKey, versionCode),
40
+ ...(request.mappingFile === undefined || request.mappingSha256 === undefined ? {} : {
41
+ mapping: { file: request.mappingFile, sha256: request.mappingSha256 },
42
+ }),
43
+ requiresConfirmProduction: playReleaseNeedsConfirmation(request),
44
+ operations: [
45
+ 'edits.insert',
46
+ 'edits.bundles.list',
47
+ `edits.tracks.get ${request.track}`,
48
+ 'edits.bundles.upload (skipped when the edit already has this version code)',
49
+ ...(request.mappingFile === undefined
50
+ ? [] : ['edits.deobfuscationfiles.upload proguard']),
51
+ `edits.tracks.update ${request.track} (other releases kept; a ${request.status === 'draft' ? 'draft replaces an older draft'
52
+ : 'new release replaces an in-progress or halted rollout'})`,
53
+ 'edits.validate',
54
+ 'edits.commit changesInReviewBehavior=ERROR_IF_IN_REVIEW',
55
+ ],
56
+ };
57
+ }
27
58
  /** Internal injection point for credential-free SDK contract tests. */
28
59
  export async function submitVerifiedAndroidBundleWithPublisher(input, publisher, requestRootUrl) {
60
+ const request = input.release ?? defaultPlayReleaseRequest;
61
+ assertPlayReleaseRequest(request);
62
+ assertPlayReleaseConfirmed(request, input.confirmProduction === true);
63
+ const trackName = request.track;
29
64
  const versionCode = await preflight(input);
65
+ await assertMappingUnchanged(request);
30
66
  const expectedSha256 = input.record.artifactSha256;
31
67
  const packageName = input.packageName;
32
68
  if (input.resumeEditId !== undefined && !editIdPattern.test(input.resumeEditId)) {
@@ -35,14 +71,22 @@ export async function submitVerifiedAndroidBundleWithPublisher(input, publisher,
35
71
  input.signal?.throwIfAborted();
36
72
  let editId = input.resumeEditId ?? await publisher.insertEdit(packageName, input.signal);
37
73
  let freshEdit = input.resumeEditId === undefined;
38
- if (input.resumeEditId === undefined) {
74
+ // A new edit is checkpointed after the read-only checks below and before
75
+ // anything is uploaded or committed, so a refused request leaves no edit to
76
+ // reconcile and can be retried with corrected options.
77
+ let editPersisted = input.resumeEditId !== undefined;
78
+ const persistEdit = async () => {
79
+ if (editPersisted) {
80
+ return;
81
+ }
39
82
  try {
40
83
  await input.onEditCreated?.(editId);
41
84
  }
42
85
  catch {
43
86
  throw new PlaySubmissionUncertainError('checkpoint', editId);
44
87
  }
45
- }
88
+ editPersisted = true;
89
+ };
46
90
  let bundles;
47
91
  try {
48
92
  bundles = await publisher.listBundles(packageName, editId, input.signal);
@@ -52,39 +96,73 @@ export async function submitVerifiedAndroidBundleWithPublisher(input, publisher,
52
96
  throw error;
53
97
  }
54
98
  // A successful commit may have invalidated the old edit while its response
55
- // was lost. Open a new snapshot solely to inspect the live internal track.
99
+ // was lost. Open a new snapshot solely to inspect the live track.
56
100
  editId = await publisher.insertEdit(packageName, input.signal);
57
101
  freshEdit = true;
58
- try {
59
- await input.onEditCreated?.(editId);
60
- }
61
- catch {
62
- throw new PlaySubmissionUncertainError('checkpoint', editId);
63
- }
102
+ editPersisted = false;
103
+ await persistEdit();
64
104
  bundles = await publisher.listBundles(packageName, editId, input.signal);
65
105
  }
66
106
  let existing = findVersionBundle(bundles, versionCode, expectedSha256);
67
- const currentTrack = await getInternalTrack(publisher, packageName, editId, input.signal);
107
+ const currentTrack = await getTrackOrEmpty(publisher, packageName, editId, trackName, input.signal, !isWellKnownPlayTrack(trackName));
108
+ const release = newPlayRelease(request, input.record.releaseKey, versionCode);
109
+ const commit = async (status) => {
110
+ const committed = await validateAndCommit({
111
+ publisher, packageName, editId, trackName, versionCode, release, status,
112
+ currentTrack, ...(input.signal === undefined ? {} : { signal: input.signal }),
113
+ ...(input.onDraftAppFallback === undefined ? {} : {
114
+ onDraftAppFallback: input.onDraftAppFallback,
115
+ }),
116
+ });
117
+ return result(input, trackName, editId, versionCode, false, committed);
118
+ };
68
119
  if (hasVersion(currentTrack.releases, versionCode)) {
69
120
  if (existing === undefined) {
70
- throw new Error('Google Play internal track has this version without matching bundle bytes.');
121
+ throw new Error(`Google Play ${trackName} track has this version without matching bundle bytes.`);
122
+ }
123
+ // Our own edit (resumed), or the live track after that edit was committed
124
+ // and its response lost (a resume that found the edit gone), may hold this
125
+ // release as a draft-app fallback.
126
+ const fallbackDraft = request.status !== 'draft' && (!freshEdit
127
+ ? hasVersionWithStatus(currentTrack.releases, versionCode, 'draft')
128
+ : input.resumeEditId !== undefined && (currentTrack.releases ?? []).some((item) => item.status === 'draft' && item.name === input.record.releaseKey
129
+ && item.versionCodes?.includes(String(versionCode))));
130
+ if (!hasVersionWithStatus(currentTrack.releases, versionCode, request.status)
131
+ && !fallbackDraft) {
132
+ throw new Error(`Google Play ${trackName} track version is not ${request.status}; inspect it before resubmitting.`);
71
133
  }
72
- if (!hasCompletedVersion(currentTrack.releases, versionCode)) {
73
- throw new Error('Google Play internal track version is not completed; inspect it before resubmitting.');
134
+ const observedStatus = fallbackDraft
135
+ && !hasVersionWithStatus(currentTrack.releases, versionCode, request.status)
136
+ ? 'draft' : request.status;
137
+ const observed = (currentTrack.releases ?? []).find((item) => item.status === observedStatus
138
+ && item.versionCodes?.includes(String(versionCode)));
139
+ if (observed !== undefined && !matchesRequestedRelease(observed, request, observedStatus)) {
140
+ throw new Error(`Google Play ${trackName} track already has this version with another rollout fraction `
141
+ + 'or other release notes; change it with "mpgd deploy rollout" or inspect it.');
74
142
  }
75
143
  if (freshEdit) {
76
- return result(input, editId, versionCode, true);
144
+ await persistEdit();
145
+ if (request.mappingFile !== undefined) {
146
+ // The release may have reached Play without this mapping: add it to
147
+ // the version in an edit of its own.
148
+ await uploadMapping(publisher, request, packageName, editId, versionCode, input.signal, requestRootUrl);
149
+ return { ...await commit(observedStatus), alreadyCommitted: true };
150
+ }
151
+ return result(input, trackName, editId, versionCode, true, {
152
+ status: observedStatus,
153
+ draftAppFallback: observedStatus !== request.status,
154
+ });
77
155
  }
78
156
  // A resumed edit sees its own uncommitted track. Publish it before success.
79
- await publisher.validateEdit(packageName, editId, input.signal);
80
- try {
81
- await publisher.commitEdit(packageName, editId, input.signal);
82
- }
83
- catch {
84
- throw new PlaySubmissionUncertainError('commit', editId);
85
- }
86
- return result(input, editId, versionCode, false);
157
+ await uploadMapping(publisher, request, packageName, editId, versionCode, input.signal, requestRootUrl);
158
+ return commit(observedStatus);
159
+ }
160
+ if (playReleaseStartsRollout(request.status, currentTrack.releases)) {
161
+ // Play Console: "Rollout percentage options are unavailable for a first release."
162
+ throw new Error(`Google Play ${trackName} has no completed release yet, so a staged rollout cannot start; `
163
+ + 'release it with --status completed (or draft) first.');
87
164
  }
165
+ await persistEdit();
88
166
  if (existing === undefined) {
89
167
  try {
90
168
  const uploaded = await publisher.uploadBundle({
@@ -113,23 +191,69 @@ export async function submitVerifiedAndroidBundleWithPublisher(input, publisher,
113
191
  }
114
192
  }
115
193
  }
116
- const release = {
117
- name: input.record.releaseKey,
118
- status: 'completed',
119
- versionCodes: [String(versionCode)],
120
- };
194
+ await uploadMapping(publisher, request, packageName, editId, versionCode, input.signal, requestRootUrl);
121
195
  const desired = {
122
196
  track: trackName,
123
- releases: [...(currentTrack.releases ?? []), release],
197
+ releases: mergeNewPlayRelease(currentTrack.releases, release).releases,
124
198
  };
199
+ await updateTrackChecked(publisher, packageName, editId, trackName, desired, versionCode, request.status, input.signal);
200
+ return commit(request.status);
201
+ }
202
+ async function validateAndCommit(input) {
203
+ const { publisher, packageName, editId } = input;
204
+ try {
205
+ await publisher.validateEdit(packageName, editId, input.signal);
206
+ }
207
+ catch (error) {
208
+ if (input.status !== 'draft' && isPlayDraftAppRejection(error)) {
209
+ return commitDraftFallback(input);
210
+ }
211
+ if (isDefinitiveHttpError(error)) {
212
+ throw error;
213
+ }
214
+ throw new PlaySubmissionUncertainError('validate', editId);
215
+ }
216
+ try {
217
+ await publisher.commitEdit(packageName, editId, input.signal);
218
+ return { status: input.status, draftAppFallback: input.status === 'draft'
219
+ && input.release.status !== 'draft' };
220
+ }
221
+ catch (error) {
222
+ // A definitive draft-app rejection means nothing was committed.
223
+ if (input.status !== 'draft' && isPlayDraftAppRejection(error)) {
224
+ return commitDraftFallback(input);
225
+ }
226
+ // Inserting a new edit invalidates the first edit for this API user. A
227
+ // failed commit response cannot safely be reconciled by creating one.
228
+ throw new PlaySubmissionUncertainError('commit', editId);
229
+ }
230
+ }
231
+ /**
232
+ * Play accepts only draft releases until an app's first release is rolled out
233
+ * from Play Console. Re-stage the same version as a draft in the same edit and
234
+ * commit that, so the operator can roll it out from the console.
235
+ */
236
+ async function commitDraftFallback(input) {
237
+ input.onDraftAppFallback?.(input.trackName);
238
+ const { userFraction: _fraction, ...withoutFraction } = input.release;
239
+ const draft = { ...withoutFraction, status: 'draft' };
240
+ const others = (input.currentTrack.releases ?? []).filter((release) => !release.versionCodes?.includes(String(input.versionCode)));
241
+ await updateTrackChecked(input.publisher, input.packageName, input.editId, input.trackName, {
242
+ track: input.trackName,
243
+ releases: mergeNewPlayRelease(others, draft).releases,
244
+ }, input.versionCode, 'draft', input.signal);
245
+ await validateAndCommit({ ...input, status: 'draft' });
246
+ return { status: 'draft', draftAppFallback: true };
247
+ }
248
+ async function updateTrackChecked(publisher, packageName, editId, trackName, desired, versionCode, status, signal) {
125
249
  try {
126
- await publisher.updateTrack(packageName, editId, desired, input.signal);
250
+ await publisher.updateTrack(packageName, editId, desired, signal);
127
251
  }
128
252
  catch (error) {
129
253
  let applied;
130
254
  try {
131
- const observed = await getInternalTrack(publisher, packageName, editId, input.signal);
132
- applied = hasCompletedVersion(observed.releases, versionCode);
255
+ const observed = await getTrackOrEmpty(publisher, packageName, editId, trackName, signal);
256
+ applied = hasVersionWithStatus(observed.releases, versionCode, status);
133
257
  }
134
258
  catch {
135
259
  throw new PlaySubmissionUncertainError('track-update', editId);
@@ -138,24 +262,61 @@ export async function submitVerifiedAndroidBundleWithPublisher(input, publisher,
138
262
  throw error;
139
263
  }
140
264
  }
265
+ }
266
+ async function uploadMapping(publisher, request, packageName, editId, versionCode, signal, requestRootUrl) {
267
+ if (request.mappingFile === undefined) {
268
+ return;
269
+ }
270
+ // Re-uploading replaces the edit's mapping for this version code, so a lost
271
+ // response is safe to retry on the same edit.
141
272
  try {
142
- await publisher.validateEdit(packageName, editId, input.signal);
273
+ await publisher.uploadDeobfuscationFile({
274
+ packageName,
275
+ editId,
276
+ versionCode,
277
+ mappingFile: request.mappingFile,
278
+ timeoutMs: bundleUploadTimeoutMs,
279
+ ...(signal === undefined ? {} : { signal }),
280
+ ...(requestRootUrl === undefined ? {} : { requestRootUrl }),
281
+ });
143
282
  }
144
283
  catch (error) {
145
284
  if (isDefinitiveHttpError(error)) {
146
285
  throw error;
147
286
  }
148
- throw new PlaySubmissionUncertainError('validate', editId);
287
+ throw new PlaySubmissionUncertainError('deobfuscation-upload', editId);
149
288
  }
150
- try {
151
- await publisher.commitEdit(packageName, editId, input.signal);
152
- return result(input, editId, versionCode, false);
289
+ }
290
+ async function assertMappingUnchanged(request) {
291
+ if (request.mappingFile !== undefined
292
+ && await hashMappingFile(request.mappingFile) !== request.mappingSha256) {
293
+ throw new Error('R8 mapping file changed after the release request was created.');
153
294
  }
154
- catch {
155
- // Inserting a new edit invalidates the first edit for this API user. A
156
- // failed commit response cannot safely be reconciled by creating one.
157
- throw new PlaySubmissionUncertainError('commit', editId);
295
+ }
296
+ /**
297
+ * The release already on the track matches what this request would create:
298
+ * the rollout fraction (a draft-app fallback has none) and, when given, the
299
+ * release notes in any order.
300
+ */
301
+ export function matchesRequestedRelease(observed, request, observedStatus) {
302
+ const fraction = typeof observed.userFraction === 'number' ? observed.userFraction : undefined;
303
+ if (observedStatus === request.status && fraction !== request.userFraction) {
304
+ return false;
305
+ }
306
+ if (request.releaseNotes === undefined) {
307
+ return true;
158
308
  }
309
+ const key = (language, text) => JSON.stringify([language ?? '', text ?? '']);
310
+ const observedNotes = (observed.releaseNotes ?? []).map((note) => key(note.language, note.text))
311
+ .sort();
312
+ const wanted = request.releaseNotes.map((note) => key(note.language, note.text)).sort();
313
+ return observedNotes.length === wanted.length
314
+ && observedNotes.every((note, index) => note === wanted[index]);
315
+ }
316
+ /** A staged rollout needs a completed release on the track to stage over. */
317
+ function playReleaseStartsRollout(status, releases) {
318
+ return status === 'inProgress'
319
+ && !(releases ?? []).some((release) => release.status === 'completed');
159
320
  }
160
321
  async function preflight(input) {
161
322
  input.signal?.throwIfAborted();
@@ -195,25 +356,29 @@ function findVersionBundle(bundles, versionCode, sha256) {
195
356
  }
196
357
  return sameVersion;
197
358
  }
198
- async function getInternalTrack(publisher, packageName, editId, signal) {
359
+ async function getTrackOrEmpty(publisher, packageName, editId, trackName, signal, mustExist = false) {
199
360
  try {
200
- return await publisher.getTrack(packageName, editId, signal);
361
+ return await publisher.getTrack(packageName, editId, trackName, signal);
201
362
  }
202
363
  catch (error) {
203
364
  if (isHttpStatus(error, 404)) {
365
+ if (mustExist) {
366
+ throw new Error(`Google Play has no closed-testing track named "${trackName}"; create it in Play `
367
+ + 'Console or check the name.');
368
+ }
204
369
  return { track: trackName, releases: [] };
205
370
  }
206
371
  throw error;
207
372
  }
208
373
  }
209
- function isHttpStatus(error, status) {
374
+ export function isHttpStatus(error, status) {
210
375
  if (typeof error !== 'object' || error === null) {
211
376
  return false;
212
377
  }
213
378
  const value = error;
214
379
  return value.code === status || value.response?.status === status;
215
380
  }
216
- function isDefinitiveHttpError(error) {
381
+ export function isDefinitiveHttpError(error) {
217
382
  if (typeof error !== 'object' || error === null) {
218
383
  return false;
219
384
  }
@@ -230,16 +395,18 @@ function isDefinitiveHttpError(error) {
230
395
  function hasVersion(releases, versionCode) {
231
396
  return releases?.some((release) => release.versionCodes?.includes(String(versionCode))) ?? false;
232
397
  }
233
- function hasCompletedVersion(releases, versionCode) {
234
- return releases?.some((release) => release.status === 'completed'
398
+ function hasVersionWithStatus(releases, versionCode, status) {
399
+ return releases?.some((release) => release.status === status
235
400
  && release.versionCodes?.includes(String(versionCode))) ?? false;
236
401
  }
237
- function result(input, editId, versionCode, alreadyCommitted) {
402
+ function result(input, trackName, editId, versionCode, alreadyCommitted, committed) {
238
403
  return {
239
404
  status: 'committed',
240
405
  packageName: input.packageName,
241
406
  editId,
242
407
  track: trackName,
408
+ releaseStatus: committed.status,
409
+ draftAppFallback: committed.draftAppFallback,
243
410
  versionCode,
244
411
  bundleSha256: input.record.artifactSha256,
245
412
  alreadyCommitted,
@@ -3,10 +3,22 @@ export interface PlayBundle {
3
3
  readonly versionCode?: number | null;
4
4
  readonly sha256?: string | null;
5
5
  }
6
+ /** Android Publisher v3 `LocalizedText`: a BCP-47 language tag and its text. */
7
+ export interface PlayLocalizedText {
8
+ readonly language?: string | null | undefined;
9
+ readonly text?: string | null | undefined;
10
+ }
11
+ /**
12
+ * Android Publisher v3 `Release`. Releases read from Play keep every field Play
13
+ * returned (for example `countryTargeting`), so a track update that sends them
14
+ * back leaves them unchanged.
15
+ */
6
16
  export interface PlayTrackRelease {
7
17
  readonly name?: string | null | undefined;
8
18
  readonly status?: string | null | undefined;
9
19
  readonly versionCodes?: readonly string[] | null | undefined;
20
+ readonly userFraction?: number | null | undefined;
21
+ readonly releaseNotes?: readonly PlayLocalizedText[] | null | undefined;
10
22
  }
11
23
  export interface PlayTrack {
12
24
  readonly track?: string | null | undefined;
@@ -23,8 +35,21 @@ export interface PlayPublisher {
23
35
  readonly requestRootUrl?: string;
24
36
  readonly signal?: AbortSignal;
25
37
  }): Promise<PlayBundle>;
26
- getTrack(packageName: string, editId: string, signal?: AbortSignal): Promise<PlayTrack>;
38
+ /** `edits.deobfuscationfiles.upload` with type `proguard` (an R8/ProGuard mapping). */
39
+ uploadDeobfuscationFile(input: {
40
+ readonly packageName: string;
41
+ readonly editId: string;
42
+ readonly versionCode: number;
43
+ readonly mappingFile: string;
44
+ readonly timeoutMs: number;
45
+ readonly requestRootUrl?: string;
46
+ readonly signal?: AbortSignal;
47
+ }): Promise<void>;
48
+ getTrack(packageName: string, editId: string, track: string, signal?: AbortSignal): Promise<PlayTrack>;
49
+ /** Updates the track named by `track.track`. */
27
50
  updateTrack(packageName: string, editId: string, track: PlayTrack, signal?: AbortSignal): Promise<void>;
28
51
  validateEdit(packageName: string, editId: string, signal?: AbortSignal): Promise<void>;
29
52
  commitEdit(packageName: string, editId: string, signal?: AbortSignal): Promise<void>;
53
+ /** Discards an uncommitted edit (`edits.delete`), used after a read-only dry run. */
54
+ deleteEdit(packageName: string, editId: string, signal?: AbortSignal): Promise<void>;
30
55
  }
@@ -0,0 +1,97 @@
1
+ import type { PlayTrackRelease } from './play-publisher-port.js';
2
+ /**
3
+ * Android Publisher v3 `Release.status` values the kit writes. `halted` is set
4
+ * only on an existing rollout (`mpgd deploy rollout --action halt`): a new
5
+ * bundle cannot start out halted.
6
+ * https://developers.google.com/android-publisher/api-ref/rest/v3/edits.tracks#status
7
+ */
8
+ export type PlayReleaseStatus = 'draft' | 'completed' | 'inProgress' | 'halted';
9
+ export type PlayNewReleaseStatus = Exclude<PlayReleaseStatus, 'halted'>;
10
+ /** The kit's original destination; Play's tracks guide also lists this track as `qa`. */
11
+ export declare const defaultPlayTrack = "internal";
12
+ /** Play Console: "up to 500 Unicode characters per language". */
13
+ export declare const maximumPlayReleaseNoteCharacters = 500;
14
+ /** What one `deploy submit`/`deploy run` asks Play to do with the new bundle. */
15
+ export interface PlayReleaseRequest {
16
+ readonly track: string;
17
+ readonly status: PlayNewReleaseStatus;
18
+ /** Required for `inProgress`: 0 < fraction < 1. */
19
+ readonly userFraction?: number;
20
+ readonly releaseNotes?: readonly PlayReleaseNote[];
21
+ /** Absolute path of an R8/ProGuard `mapping.txt` for this exact build. */
22
+ readonly mappingFile?: string;
23
+ /** SHA-256 of `mappingFile`, fixed when the request is created. */
24
+ readonly mappingSha256?: string;
25
+ }
26
+ export interface PlayReleaseNote {
27
+ readonly language: string;
28
+ readonly text: string;
29
+ }
30
+ export declare const defaultPlayReleaseRequest: PlayReleaseRequest;
31
+ export interface CreatePlayReleaseRequestInput {
32
+ readonly track?: string | undefined;
33
+ readonly status?: string | undefined;
34
+ readonly userFraction?: string | number | undefined;
35
+ readonly releaseNotesFile?: string | undefined;
36
+ readonly mappingFile?: string | undefined;
37
+ }
38
+ /** Validate CLI release options; with none it returns the internal/completed default. */
39
+ export declare function createPlayReleaseRequest(input: CreatePlayReleaseRequestInput): Promise<PlayReleaseRequest>;
40
+ /** Re-check a programmatic request; the CLI path builds it with createPlayReleaseRequest. */
41
+ export declare function assertPlayReleaseRequest(request: PlayReleaseRequest): void;
42
+ export declare function assertPlayTrackName(track: string): void;
43
+ /** `production` and form-factor production tracks such as `wear:production`. */
44
+ export declare function isProductionTrack(track: string): boolean;
45
+ /**
46
+ * A default track (`internal`/`qa`, `alpha`, `beta`, `production`, optionally
47
+ * form-factor prefixed). Other names are closed-testing tracks, which "are
48
+ * created manually", so a missing one is a typo rather than an empty track.
49
+ */
50
+ export declare function isWellKnownPlayTrack(track: string): boolean;
51
+ /** Production releases and every staged rollout need an explicit confirmation flag. */
52
+ export declare function playReleaseNeedsConfirmation(request: Pick<PlayReleaseRequest, 'track' | 'status'>): boolean;
53
+ export declare function assertPlayReleaseConfirmed(request: Pick<PlayReleaseRequest, 'track' | 'status'>, confirmed: boolean): void;
54
+ export declare function isDefaultPlayReleaseRequest(request: PlayReleaseRequest): boolean;
55
+ /**
56
+ * Binds a submission checkpoint to the release options that started it, so a
57
+ * resumed edit is never committed with another track or status. The original
58
+ * internal/completed request has no digest, which keeps its checkpoints as before.
59
+ */
60
+ export declare function playReleaseRequestDigest(request: PlayReleaseRequest): string | undefined;
61
+ export declare function parsePlayUserFraction(value: string | number): number;
62
+ /** Android Publisher v3 `Release.userFraction`: "0 < fraction < 1". */
63
+ export declare function assertPlayUserFraction(fraction: number): void;
64
+ export declare function readPlayReleaseNotesFile(file: string): PlayReleaseNote[];
65
+ /**
66
+ * Accepts the Play Console's own language-tag format, one block per language:
67
+ *
68
+ * <en-US>
69
+ * Bug fixes.
70
+ * </en-US>
71
+ *
72
+ * or JSON: `{ "en-US": "Bug fixes." }` or `[{ "language": "en-US", "text": "Bug fixes." }]`.
73
+ */
74
+ export declare function parsePlayReleaseNotes(source: string): PlayReleaseNote[];
75
+ export declare function assertPlayReleaseNotes(notes: readonly PlayReleaseNote[]): void;
76
+ export declare function hashMappingFile(mappingFile: string): Promise<string>;
77
+ /** Build the Release body for a new bundle; the default matches the original body exactly. */
78
+ export declare function newPlayRelease(request: PlayReleaseRequest, name: string, versionCode: number): PlayTrackRelease;
79
+ /**
80
+ * Add a new release to the releases Play returned for the track. Every other
81
+ * release is sent back unchanged, except the ones the new release supersedes:
82
+ * a completed or staged release replaces an in-progress or halted rollout, and
83
+ * a draft replaces an older draft. A track with only completed releases (the
84
+ * usual internal track) therefore gets exactly the original appended body.
85
+ */
86
+ export declare function mergeNewPlayRelease(current: readonly PlayTrackRelease[] | null | undefined, release: PlayTrackRelease): {
87
+ readonly releases: PlayTrackRelease[];
88
+ readonly superseded: PlayTrackRelease[];
89
+ };
90
+ /**
91
+ * The Play Developer API answers "Only releases with status draft may be
92
+ * created on draft app." while an app has never been published. Google's API
93
+ * reference does not document it; it is reported by fastlane, the
94
+ * upload-google-play action and others, on edits.commit. Only a definitive 4xx
95
+ * with that text counts: the edit was not committed.
96
+ */
97
+ export declare function isPlayDraftAppRejection(error: unknown): boolean;