@mpgd/cli 0.59.0 → 0.61.1

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.
Files changed (33) hide show
  1. package/dist/capacitor-shell-starter.d.ts +24 -0
  2. package/dist/capacitor-shell-starter.js +100 -8
  3. package/dist/crash-reporting-starter.d.ts +99 -0
  4. package/dist/crash-reporting-starter.js +271 -0
  5. package/dist/index.d.ts +5 -2
  6. package/dist/index.js +221 -14
  7. package/dist/ios-ipa-inspection.js +120 -1
  8. package/dist/native-build-info.json +2 -2
  9. package/dist/native-build-target.js +1617 -1185
  10. package/dist/native-deploy-run.d.ts +6 -0
  11. package/dist/native-deploy-run.js +19 -1
  12. package/dist/native-deploy-submission.d.ts +20 -1
  13. package/dist/native-deploy-submission.js +94 -7
  14. package/dist/native-shell-ios-push-files.d.ts +9 -0
  15. package/dist/native-shell-ios-push-files.js +65 -0
  16. package/dist/native-shell-store-policy.d.ts +109 -0
  17. package/dist/native-shell-store-policy.js +553 -7
  18. package/dist/play-internal-submission.d.ts +40 -4
  19. package/dist/play-internal-submission.js +215 -48
  20. package/dist/play-publisher-port.d.ts +26 -1
  21. package/dist/play-release-options.d.ts +97 -0
  22. package/dist/play-release-options.js +307 -0
  23. package/dist/play-release-promote.d.ts +59 -0
  24. package/dist/play-release-promote.js +235 -0
  25. package/dist/play-release-rollout.d.ts +57 -0
  26. package/dist/play-release-rollout.js +237 -0
  27. package/dist/play-sdk-adapter.js +26 -3
  28. package/dist/release-state.d.ts +6 -0
  29. package/dist/release-state.js +8 -0
  30. package/package.json +17 -17
  31. package/templates/phaser-game/apps/target-ait/package.json +2 -2
  32. package/templates/phaser-game/package.json +1 -1
  33. package/templates/phaser-game/vite.runtime-target-config.ts +4 -0
@@ -0,0 +1,307 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ /** The kit's original destination; Play's tracks guide also lists this track as `qa`. */
5
+ export const defaultPlayTrack = 'internal';
6
+ /** Play Console: "up to 500 Unicode characters per language". */
7
+ export const maximumPlayReleaseNoteCharacters = 500;
8
+ const maximumReleaseNotesFileBytes = 256 * 1024;
9
+ // A default name (`production`, `beta`, `alpha`, `internal`, `qa`), a custom
10
+ // closed-testing track name, or either behind a form-factor prefix
11
+ // (`wear:production`). Path-unsafe characters are refused before any request.
12
+ const trackNamePattern = /^(?:[a-z][a-z0-9_]{0,31}:)?[A-Za-z0-9](?:[A-Za-z0-9 ._-]{0,98}[A-Za-z0-9._-])?$/u;
13
+ const wellKnownTrackPattern = /^(?:[a-z][a-z0-9_]*:)?(?:internal|qa|alpha|beta|production)$/iu;
14
+ const languageTagPattern = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$/u;
15
+ const sha256Pattern = /^[a-f0-9]{64}$/u;
16
+ export const defaultPlayReleaseRequest = Object.freeze({
17
+ track: defaultPlayTrack,
18
+ status: 'completed',
19
+ });
20
+ /** Validate CLI release options; with none it returns the internal/completed default. */
21
+ export async function createPlayReleaseRequest(input) {
22
+ const track = input.track ?? defaultPlayTrack;
23
+ const status = input.status ?? 'completed';
24
+ if (status === 'halted') {
25
+ throw new Error('A new Google Play release cannot start halted; use "mpgd deploy rollout --action halt" '
26
+ + 'on the release once it is in progress.');
27
+ }
28
+ if (status !== 'draft' && status !== 'completed' && status !== 'inProgress') {
29
+ throw new Error('--status must be draft, completed or inProgress.');
30
+ }
31
+ const userFraction = input.userFraction === undefined
32
+ ? undefined
33
+ : parsePlayUserFraction(input.userFraction);
34
+ const releaseNotes = input.releaseNotesFile === undefined
35
+ ? undefined
36
+ : readPlayReleaseNotesFile(input.releaseNotesFile);
37
+ let mapping;
38
+ if (input.mappingFile !== undefined) {
39
+ const mappingFile = path.resolve(input.mappingFile);
40
+ mapping = { mappingFile, mappingSha256: await hashMappingFile(mappingFile) };
41
+ }
42
+ const request = {
43
+ track,
44
+ status,
45
+ ...(userFraction === undefined ? {} : { userFraction }),
46
+ ...(releaseNotes === undefined ? {} : { releaseNotes }),
47
+ ...(mapping ?? {}),
48
+ };
49
+ assertPlayReleaseRequest(request);
50
+ return request;
51
+ }
52
+ /** Re-check a programmatic request; the CLI path builds it with createPlayReleaseRequest. */
53
+ export function assertPlayReleaseRequest(request) {
54
+ assertPlayTrackName(request.track);
55
+ if (request.status !== 'draft' && request.status !== 'completed'
56
+ && request.status !== 'inProgress') {
57
+ throw new Error('Google Play release status must be draft, completed or inProgress.');
58
+ }
59
+ if (request.status === 'inProgress') {
60
+ if (request.userFraction === undefined) {
61
+ throw new Error('An inProgress (staged) Google Play release needs --user-fraction.');
62
+ }
63
+ assertPlayUserFraction(request.userFraction);
64
+ }
65
+ else if (request.userFraction !== undefined) {
66
+ throw new Error('--user-fraction applies only to an inProgress (staged) release.');
67
+ }
68
+ if (request.releaseNotes !== undefined) {
69
+ assertPlayReleaseNotes(request.releaseNotes);
70
+ }
71
+ if ((request.mappingFile === undefined) !== (request.mappingSha256 === undefined)
72
+ || (request.mappingFile !== undefined && !path.isAbsolute(request.mappingFile))
73
+ || (request.mappingSha256 !== undefined && !sha256Pattern.test(request.mappingSha256))) {
74
+ throw new Error('Google Play mapping file must be an absolute path with its SHA-256.');
75
+ }
76
+ }
77
+ export function assertPlayTrackName(track) {
78
+ if (!trackNamePattern.test(track)) {
79
+ throw new Error('Google Play track must be internal, alpha, beta, production, or a closed-testing '
80
+ + 'track name (letters, digits, space, ".", "_", "-"; optional form-factor prefix).');
81
+ }
82
+ }
83
+ /** `production` and form-factor production tracks such as `wear:production`. */
84
+ export function isProductionTrack(track) {
85
+ // Case-insensitive, so `Production` is never treated as a testing track.
86
+ return /^(?:[a-z][a-z0-9_]*:)?production$/iu.test(track.trim());
87
+ }
88
+ /**
89
+ * A default track (`internal`/`qa`, `alpha`, `beta`, `production`, optionally
90
+ * form-factor prefixed). Other names are closed-testing tracks, which "are
91
+ * created manually", so a missing one is a typo rather than an empty track.
92
+ */
93
+ export function isWellKnownPlayTrack(track) {
94
+ return wellKnownTrackPattern.test(track);
95
+ }
96
+ /** Production releases and every staged rollout need an explicit confirmation flag. */
97
+ export function playReleaseNeedsConfirmation(request) {
98
+ return isProductionTrack(request.track) || request.status === 'inProgress';
99
+ }
100
+ export function assertPlayReleaseConfirmed(request, confirmed) {
101
+ if (playReleaseNeedsConfirmation(request) && !confirmed) {
102
+ throw new Error(`Google Play ${request.track} ${request.status} release needs --confirm-production: `
103
+ + 'production releases and staged rollouts are never made without it.');
104
+ }
105
+ }
106
+ export function isDefaultPlayReleaseRequest(request) {
107
+ return request.track === defaultPlayTrack && request.status === 'completed'
108
+ && request.userFraction === undefined && request.releaseNotes === undefined
109
+ && request.mappingFile === undefined;
110
+ }
111
+ /**
112
+ * Binds a submission checkpoint to the release options that started it, so a
113
+ * resumed edit is never committed with another track or status. The original
114
+ * internal/completed request has no digest, which keeps its checkpoints as before.
115
+ */
116
+ export function playReleaseRequestDigest(request) {
117
+ if (isDefaultPlayReleaseRequest(request)) {
118
+ return undefined;
119
+ }
120
+ return createHash('sha256').update(JSON.stringify({
121
+ track: request.track,
122
+ status: request.status,
123
+ userFraction: request.userFraction ?? null,
124
+ releaseNotes: request.releaseNotes ?? [],
125
+ mappingSha256: request.mappingSha256 ?? null,
126
+ })).digest('hex');
127
+ }
128
+ export function parsePlayUserFraction(value) {
129
+ const fraction = typeof value === 'number'
130
+ ? value
131
+ : /^(?:0(?:\.\d+)?|\.\d+|1(?:\.0+)?)$/u.test(value.trim())
132
+ ? Number(value)
133
+ : Number.NaN;
134
+ assertPlayUserFraction(fraction);
135
+ return fraction;
136
+ }
137
+ /** Android Publisher v3 `Release.userFraction`: "0 < fraction < 1". */
138
+ export function assertPlayUserFraction(fraction) {
139
+ if (!Number.isFinite(fraction) || fraction <= 0 || fraction >= 1) {
140
+ throw new Error('--user-fraction must be a number greater than 0 and less than 1 (for example 0.05); '
141
+ + 'use --status completed (or --action complete) for 100%.');
142
+ }
143
+ }
144
+ export function readPlayReleaseNotesFile(file) {
145
+ const resolved = path.resolve(file);
146
+ if (!existsSync(resolved) || !statSync(resolved).isFile()) {
147
+ throw new Error(`Release notes file is missing: ${resolved}`);
148
+ }
149
+ if (statSync(resolved).size > maximumReleaseNotesFileBytes) {
150
+ throw new Error('Release notes file is larger than 256 KiB.');
151
+ }
152
+ return parsePlayReleaseNotes(readFileSync(resolved, 'utf8'));
153
+ }
154
+ /**
155
+ * Accepts the Play Console's own language-tag format, one block per language:
156
+ *
157
+ * <en-US>
158
+ * Bug fixes.
159
+ * </en-US>
160
+ *
161
+ * or JSON: `{ "en-US": "Bug fixes." }` or `[{ "language": "en-US", "text": "Bug fixes." }]`.
162
+ */
163
+ export function parsePlayReleaseNotes(source) {
164
+ const content = source.replace(/^/u, '');
165
+ const trimmed = content.trim();
166
+ if (trimmed === '') {
167
+ throw new Error('Release notes file is empty.');
168
+ }
169
+ let notes;
170
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
171
+ let parsed;
172
+ try {
173
+ parsed = JSON.parse(trimmed);
174
+ }
175
+ catch {
176
+ throw new Error('Release notes JSON is malformed.');
177
+ }
178
+ notes = Array.isArray(parsed)
179
+ ? parsed.map((entry) => {
180
+ if (typeof entry !== 'object' || entry === null
181
+ || typeof entry.language !== 'string'
182
+ || typeof entry.text !== 'string'
183
+ || Object.keys(entry).some((key) => key !== 'language' && key !== 'text')) {
184
+ throw new Error('Release notes JSON array entries need only string language and text.');
185
+ }
186
+ const { language, text } = entry;
187
+ return { language, text: text.trim() };
188
+ })
189
+ : typeof parsed === 'object' && parsed !== null
190
+ ? Object.entries(parsed).map(([language, text]) => {
191
+ if (typeof text !== 'string') {
192
+ throw new Error(`Release notes for ${language} must be a string.`);
193
+ }
194
+ return { language, text: text.trim() };
195
+ })
196
+ : [];
197
+ }
198
+ else {
199
+ notes = [];
200
+ const block = /<([A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*)>([\s\S]*?)<\/\1>/gu;
201
+ let consumed = 0;
202
+ for (const match of content.matchAll(block)) {
203
+ if (content.slice(consumed, match.index).trim() !== '') {
204
+ throw new Error('Release notes text must sit between <language> and </language> tags.');
205
+ }
206
+ notes.push({ language: match[1] ?? '', text: (match[2] ?? '').trim() });
207
+ consumed = match.index + match[0].length;
208
+ }
209
+ if (content.slice(consumed).trim() !== '') {
210
+ throw new Error('Release notes text must sit between <language> and </language> tags.');
211
+ }
212
+ }
213
+ assertPlayReleaseNotes(notes);
214
+ return notes;
215
+ }
216
+ export function assertPlayReleaseNotes(notes) {
217
+ if (notes.length === 0) {
218
+ throw new Error('Release notes need at least one language.');
219
+ }
220
+ const seen = new Set();
221
+ for (const note of notes) {
222
+ if (!languageTagPattern.test(note.language)) {
223
+ throw new Error(`Release notes language is not a BCP-47 tag: ${note.language}`);
224
+ }
225
+ const key = note.language.toLowerCase();
226
+ if (seen.has(key)) {
227
+ throw new Error(`Release notes repeat the language ${note.language}.`);
228
+ }
229
+ seen.add(key);
230
+ if (note.text.trim() === '') {
231
+ throw new Error(`Release notes for ${note.language} are empty.`);
232
+ }
233
+ const length = [...note.text].length;
234
+ if (length > maximumPlayReleaseNoteCharacters) {
235
+ throw new Error(`Release notes for ${note.language} are ${length} characters; Google Play allows `
236
+ + `${maximumPlayReleaseNoteCharacters} per language.`);
237
+ }
238
+ }
239
+ }
240
+ export async function hashMappingFile(mappingFile) {
241
+ if (!existsSync(mappingFile) || !statSync(mappingFile).isFile()) {
242
+ throw new Error(`R8 mapping file is missing: ${mappingFile}`);
243
+ }
244
+ if (statSync(mappingFile).size === 0) {
245
+ throw new Error(`R8 mapping file is empty: ${mappingFile}`);
246
+ }
247
+ const hash = createHash('sha256');
248
+ for await (const chunk of createReadStream(mappingFile)) {
249
+ hash.update(chunk);
250
+ }
251
+ return hash.digest('hex');
252
+ }
253
+ /** Build the Release body for a new bundle; the default matches the original body exactly. */
254
+ export function newPlayRelease(request, name, versionCode) {
255
+ return {
256
+ name,
257
+ status: request.status,
258
+ versionCodes: [String(versionCode)],
259
+ ...(request.userFraction === undefined ? {} : { userFraction: request.userFraction }),
260
+ ...(request.releaseNotes === undefined ? {} : {
261
+ releaseNotes: request.releaseNotes.map((note) => ({
262
+ language: note.language,
263
+ text: note.text,
264
+ })),
265
+ }),
266
+ };
267
+ }
268
+ /**
269
+ * Add a new release to the releases Play returned for the track. Every other
270
+ * release is sent back unchanged, except the ones the new release supersedes:
271
+ * a completed or staged release replaces an in-progress or halted rollout, and
272
+ * a draft replaces an older draft. A track with only completed releases (the
273
+ * usual internal track) therefore gets exactly the original appended body.
274
+ */
275
+ export function mergeNewPlayRelease(current, release) {
276
+ const superseded = [];
277
+ const kept = [];
278
+ for (const existing of current ?? []) {
279
+ const replaces = release.status === 'draft'
280
+ ? existing.status === 'draft'
281
+ : existing.status === 'inProgress' || existing.status === 'halted';
282
+ (replaces ? superseded : kept).push(existing);
283
+ }
284
+ return { releases: [...kept, release], superseded };
285
+ }
286
+ /**
287
+ * The Play Developer API answers "Only releases with status draft may be
288
+ * created on draft app." while an app has never been published. Google's API
289
+ * reference does not document it; it is reported by fastlane, the
290
+ * upload-google-play action and others, on edits.commit. Only a definitive 4xx
291
+ * with that text counts: the edit was not committed.
292
+ */
293
+ export function isPlayDraftAppRejection(error) {
294
+ if (typeof error !== 'object' || error === null) {
295
+ return false;
296
+ }
297
+ const value = error;
298
+ const raw = value.response?.status ?? value.code;
299
+ const status = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
300
+ if (!(status >= 400 && status < 500)) {
301
+ return false;
302
+ }
303
+ const messages = [value.message, value.response?.data?.error?.message]
304
+ .filter((message) => typeof message === 'string');
305
+ return messages.some((message) => /only releases with status draft may be created on draft app/iu
306
+ .test(message));
307
+ }
@@ -0,0 +1,59 @@
1
+ import type { PlayPublisher, PlayTrack, PlayTrackRelease } from './play-publisher-port.js';
2
+ import { type PlayNewReleaseStatus, type PlayReleaseNote } from './play-release-options.js';
3
+ /**
4
+ * Promote a release that is already on one track to another track: the
5
+ * target track's `edits.tracks.update` names the existing version codes, and
6
+ * nothing is uploaded. This is the Play Console's "promote release" for the
7
+ * build that passed testing (https://developers.google.com/android-publisher/tracks:
8
+ * a version code uploaded once is released on a track by `Edits.tracks: update`).
9
+ */
10
+ export interface PlayPromoteInput {
11
+ readonly packageName: string;
12
+ readonly fromTrack: string;
13
+ readonly toTrack: string;
14
+ /**
15
+ * Selects the source release that contains this version code; defaults to
16
+ * the source track's only completed release. The whole release (all its
17
+ * version codes) is promoted, never part of it.
18
+ */
19
+ readonly versionCode?: number;
20
+ /** Target release status; defaults to `completed`. */
21
+ readonly status?: PlayNewReleaseStatus;
22
+ /** Required for `inProgress`: 0 < fraction < 1. */
23
+ readonly userFraction?: number;
24
+ /** Defaults to the source release's notes. */
25
+ readonly releaseNotes?: readonly PlayReleaseNote[];
26
+ /** Required for production tracks and staged (inProgress) releases. */
27
+ readonly confirmProduction?: boolean;
28
+ /** `--approve`: required unless `dryRun`. */
29
+ readonly approved?: boolean;
30
+ /** Read both tracks in a throw-away edit and return the planned body without committing. */
31
+ readonly dryRun?: boolean;
32
+ readonly signal?: AbortSignal;
33
+ }
34
+ export interface PlayPromoteResult {
35
+ /** `committed`: Play accepted the edit. `unchanged`: already promoted. `dry-run`: nothing sent. */
36
+ readonly status: 'committed' | 'unchanged' | 'dry-run';
37
+ readonly packageName: string;
38
+ readonly fromTrack: string;
39
+ readonly toTrack: string;
40
+ readonly versionCodes: readonly string[];
41
+ readonly release: PlayTrackRelease;
42
+ /** Releases on the target track the promoted release replaces. */
43
+ readonly superseded: readonly PlayTrackRelease[];
44
+ readonly editId?: string;
45
+ /** The `edits.tracks.update` body (dry run only). */
46
+ readonly plannedTrack?: PlayTrack;
47
+ }
48
+ export declare class PlayPromoteUncertainError extends Error {
49
+ readonly stage: 'track-update' | 'validate' | 'commit';
50
+ readonly editId: string;
51
+ readonly versionCodes: readonly string[];
52
+ constructor(stage: 'track-update' | 'validate' | 'commit', editId: string, versionCodes: readonly string[]);
53
+ }
54
+ export declare function promotePlayRelease(input: PlayPromoteInput & {
55
+ readonly serviceAccountFile: string;
56
+ }): Promise<PlayPromoteResult>;
57
+ export declare function assertPlayPromoteInput(input: PlayPromoteInput): void;
58
+ /** Credential-free injection point for the mock-transport tests. */
59
+ export declare function promotePlayReleaseWithPublisher(input: PlayPromoteInput, publisher: PlayPublisher): Promise<PlayPromoteResult>;
@@ -0,0 +1,235 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { isDefinitiveHttpError, isHttpStatus, matchesRequestedRelease, } from './play-internal-submission.js';
3
+ import { assertPlayReleaseConfirmed, assertPlayReleaseNotes, assertPlayTrackName, assertPlayUserFraction, isWellKnownPlayTrack, mergeNewPlayRelease, } from './play-release-options.js';
4
+ export class PlayPromoteUncertainError extends Error {
5
+ stage;
6
+ editId;
7
+ versionCodes;
8
+ constructor(stage, editId, versionCodes) {
9
+ super(`Google Play promote ${stage} result is uncertain for edit ${editId}. Re-run the same `
10
+ + `command with --version-code ${versionCodes[0] ?? ''}: it reads the target track first `
11
+ + 'and reports "unchanged" if the promotion landed.');
12
+ this.stage = stage;
13
+ this.editId = editId;
14
+ this.versionCodes = versionCodes;
15
+ this.name = 'PlayPromoteUncertainError';
16
+ }
17
+ }
18
+ // Only a release that is being served can be promoted: a draft was never
19
+ // tested, and a halted one was stopped for a reason.
20
+ const promotableStatuses = ['completed', 'inProgress'];
21
+ export async function promotePlayRelease(input) {
22
+ if (!existsSync(input.serviceAccountFile) || !statSync(input.serviceAccountFile).isFile()) {
23
+ throw new Error('Google Play service account file is missing.');
24
+ }
25
+ assertPlayPromoteInput(input);
26
+ const { createServiceAccountPublisher } = await import(new URL('./play-sdk-adapter.js', import.meta.url).href);
27
+ return promotePlayReleaseWithPublisher(input, createServiceAccountPublisher(input.serviceAccountFile));
28
+ }
29
+ export function assertPlayPromoteInput(input) {
30
+ assertPlayTrackName(input.fromTrack);
31
+ assertPlayTrackName(input.toTrack);
32
+ if (sameTrack(input.fromTrack, input.toTrack)) {
33
+ throw new Error('--from and --to must name different Google Play tracks.');
34
+ }
35
+ if (input.versionCode !== undefined
36
+ && (!Number.isSafeInteger(input.versionCode) || input.versionCode < 1)) {
37
+ throw new Error('--version-code must be a positive integer.');
38
+ }
39
+ const status = input.status ?? 'completed';
40
+ if (status !== 'draft' && status !== 'completed' && status !== 'inProgress') {
41
+ throw new Error('--status must be draft, completed or inProgress.');
42
+ }
43
+ if (status === 'inProgress') {
44
+ if (input.userFraction === undefined) {
45
+ throw new Error('An inProgress (staged) Google Play release needs --user-fraction.');
46
+ }
47
+ assertPlayUserFraction(input.userFraction);
48
+ }
49
+ else if (input.userFraction !== undefined) {
50
+ throw new Error('--user-fraction applies only to an inProgress (staged) release.');
51
+ }
52
+ if (input.releaseNotes !== undefined) {
53
+ assertPlayReleaseNotes(input.releaseNotes);
54
+ }
55
+ }
56
+ /** Credential-free injection point for the mock-transport tests. */
57
+ export async function promotePlayReleaseWithPublisher(input, publisher) {
58
+ assertPlayPromoteInput(input);
59
+ const status = input.status ?? 'completed';
60
+ if (input.dryRun !== true && input.approved !== true) {
61
+ throw new Error('Promoting a Google Play release requires --approve.');
62
+ }
63
+ if (input.dryRun !== true) {
64
+ assertPlayReleaseConfirmed({ track: input.toTrack, status }, input.confirmProduction === true);
65
+ }
66
+ input.signal?.throwIfAborted();
67
+ const { packageName, fromTrack, toTrack } = input;
68
+ const editId = await publisher.insertEdit(packageName, input.signal);
69
+ let committed = false;
70
+ try {
71
+ const source = await readTrack(publisher, packageName, editId, fromTrack, input.signal, true);
72
+ const sourceRelease = selectSourceRelease(source.releases ?? [], input);
73
+ // Always the whole source release, so a re-run with any of its version
74
+ // codes (as PlayPromoteUncertainError suggests) asks for the same set.
75
+ const versionCodes = [...(sourceRelease.versionCodes ?? [])];
76
+ if (versionCodes.length === 0) {
77
+ throw new Error(`Google Play ${fromTrack} release has no version codes to promote.`);
78
+ }
79
+ const notes = input.releaseNotes ?? copiedNotes(sourceRelease);
80
+ const release = {
81
+ ...(typeof sourceRelease.name === 'string' ? { name: sourceRelease.name } : {}),
82
+ status,
83
+ versionCodes,
84
+ ...(input.userFraction === undefined ? {} : { userFraction: input.userFraction }),
85
+ ...(notes === undefined ? {} : { releaseNotes: notes }),
86
+ };
87
+ const target = await readTrack(publisher, packageName, editId, toTrack, input.signal, !isWellKnownPlayTrack(toTrack));
88
+ const targetReleases = target.releases ?? [];
89
+ const already = targetReleases.filter((item) => versionCodes.some((code) => item.versionCodes?.includes(code)));
90
+ if (already.length > 0) {
91
+ const same = already.length === 1 && already[0] !== undefined
92
+ && already[0].status === status
93
+ && sameCodes(already[0].versionCodes, versionCodes)
94
+ && matchesRequestedRelease(already[0], {
95
+ track: toTrack,
96
+ status,
97
+ ...(input.userFraction === undefined ? {} : { userFraction: input.userFraction }),
98
+ ...(input.releaseNotes === undefined ? {} : { releaseNotes: input.releaseNotes }),
99
+ }, status);
100
+ if (!same) {
101
+ throw new Error(`Google Play ${toTrack} already has version ${versionCodes.join(',')} as ${already.map((item) => String(item.status)).join(', ')} with other options; change it with "mpgd deploy rollout" or inspect it.`);
102
+ }
103
+ return {
104
+ status: 'unchanged',
105
+ packageName,
106
+ fromTrack,
107
+ toTrack,
108
+ versionCodes,
109
+ release: already[0] ?? release,
110
+ superseded: [],
111
+ };
112
+ }
113
+ if (status === 'inProgress'
114
+ && !targetReleases.some((item) => item.status === 'completed')) {
115
+ // Play Console: "Rollout percentage options are unavailable for a first release."
116
+ throw new Error(`Google Play ${toTrack} has no completed release yet, so a staged rollout cannot start; `
117
+ + 'promote with --status completed (or draft) first.');
118
+ }
119
+ const merged = mergeNewPlayRelease(targetReleases, release);
120
+ const desired = { track: toTrack, releases: merged.releases };
121
+ if (input.dryRun === true) {
122
+ return {
123
+ status: 'dry-run',
124
+ packageName,
125
+ fromTrack,
126
+ toTrack,
127
+ versionCodes,
128
+ release,
129
+ superseded: merged.superseded,
130
+ plannedTrack: desired,
131
+ };
132
+ }
133
+ try {
134
+ await publisher.updateTrack(packageName, editId, desired, input.signal);
135
+ }
136
+ catch (error) {
137
+ if (isDefinitiveHttpError(error)) {
138
+ throw error;
139
+ }
140
+ throw new PlayPromoteUncertainError('track-update', editId, versionCodes);
141
+ }
142
+ try {
143
+ await publisher.validateEdit(packageName, editId, input.signal);
144
+ }
145
+ catch (error) {
146
+ if (isDefinitiveHttpError(error)) {
147
+ throw error;
148
+ }
149
+ throw new PlayPromoteUncertainError('validate', editId, versionCodes);
150
+ }
151
+ try {
152
+ await publisher.commitEdit(packageName, editId, input.signal);
153
+ }
154
+ catch (error) {
155
+ if (isDefinitiveHttpError(error)) {
156
+ throw error;
157
+ }
158
+ committed = true; // Unknown: never delete an edit that may have been committed.
159
+ throw new PlayPromoteUncertainError('commit', editId, versionCodes);
160
+ }
161
+ committed = true;
162
+ return {
163
+ status: 'committed',
164
+ packageName,
165
+ fromTrack,
166
+ toTrack,
167
+ versionCodes,
168
+ release,
169
+ superseded: merged.superseded,
170
+ editId,
171
+ };
172
+ }
173
+ finally {
174
+ if (!committed) {
175
+ // Discard the uncommitted edit (dry run, no-op or refusal); a failure here is harmless.
176
+ await publisher.deleteEdit(packageName, editId, input.signal).catch(() => undefined);
177
+ }
178
+ }
179
+ }
180
+ async function readTrack(publisher, packageName, editId, track, signal, mustExist) {
181
+ try {
182
+ return await publisher.getTrack(packageName, editId, track, signal);
183
+ }
184
+ catch (error) {
185
+ if (isHttpStatus(error, 404)) {
186
+ if (mustExist) {
187
+ throw new Error(`Google Play has no ${track} track for ${packageName}.`);
188
+ }
189
+ return { track, releases: [] };
190
+ }
191
+ throw error;
192
+ }
193
+ }
194
+ function selectSourceRelease(releases, input) {
195
+ const listed = releases.map((release) => `${String(release.status)} [${(release.versionCodes ?? []).join(',')}]`).join('; ') || 'none';
196
+ if (input.versionCode !== undefined) {
197
+ const matches = releases.filter((release) => release.versionCodes?.includes(String(input.versionCode)));
198
+ const match = matches[0];
199
+ if (matches.length > 1) {
200
+ throw new Error(`Google Play ${input.fromTrack} has several releases with version code `
201
+ + `${input.versionCode}; inspect the track. Releases: ${listed}.`);
202
+ }
203
+ if (match === undefined) {
204
+ throw new Error(`Google Play ${input.fromTrack} has no release with version code ${input.versionCode}; `
205
+ + `only a version already on the source track can be promoted. Releases: ${listed}.`);
206
+ }
207
+ if (!promotableStatuses.includes(String(match.status))) {
208
+ throw new Error(`Google Play ${input.fromTrack} version ${input.versionCode} is ${String(match.status)}; `
209
+ + 'only a completed or in-progress release can be promoted.');
210
+ }
211
+ return match;
212
+ }
213
+ const completed = releases.filter((release) => release.status === 'completed');
214
+ if (completed.length !== 1 || completed[0] === undefined) {
215
+ throw new Error(`Google Play ${input.fromTrack} needs exactly one completed release to promote; pass `
216
+ + `--version-code. Releases: ${listed}.`);
217
+ }
218
+ return completed[0];
219
+ }
220
+ /** Same track, ignoring case and the `internal`/`qa` alias of the internal track. */
221
+ function sameTrack(left, right) {
222
+ const normal = (track) => track.trim().toLowerCase().replace(/(^|:)qa$/u, '$1internal');
223
+ return normal(left) === normal(right);
224
+ }
225
+ function copiedNotes(release) {
226
+ const notes = (release.releaseNotes ?? []).flatMap((note) => typeof note.language === 'string' && typeof note.text === 'string'
227
+ ? [{ language: note.language, text: note.text }]
228
+ : []);
229
+ return notes.length === 0 ? undefined : notes;
230
+ }
231
+ function sameCodes(observed, wanted) {
232
+ const left = [...(observed ?? [])].sort();
233
+ const right = [...wanted].sort();
234
+ return left.length === right.length && left.every((code, index) => code === right[index]);
235
+ }
@@ -0,0 +1,57 @@
1
+ import type { PlayPublisher, PlayTrack } from './play-publisher-port.js';
2
+ /**
3
+ * Changes to a release already on a track, without a new bundle. They follow
4
+ * the staged-rollout steps of https://developers.google.com/android-publisher/tracks:
5
+ * `update` changes an inProgress release's userFraction, `halt` sets it
6
+ * `halted`, `resume` sets a halted release back to `inProgress`, and
7
+ * `complete` sets it `completed` (100%).
8
+ */
9
+ export type PlayRolloutAction = 'update' | 'halt' | 'resume' | 'complete';
10
+ export declare const playRolloutActions: readonly PlayRolloutAction[];
11
+ export interface PlayRolloutInput {
12
+ readonly packageName: string;
13
+ readonly track: string;
14
+ readonly action: PlayRolloutAction;
15
+ /** Selects the release; defaults to the track's only release the action applies to. */
16
+ readonly versionCode?: number;
17
+ /** Required for `update`; optional for `resume` (else the halted release's own fraction). */
18
+ readonly userFraction?: number;
19
+ /** Required for production tracks and for any change that leaves a staged rollout running. */
20
+ readonly confirmProduction?: boolean;
21
+ /** Read the track in a throw-away edit and return the planned body without committing. */
22
+ readonly dryRun?: boolean;
23
+ readonly signal?: AbortSignal;
24
+ }
25
+ export interface PlayRolloutReleaseState {
26
+ readonly status: string;
27
+ readonly userFraction?: number;
28
+ }
29
+ export interface PlayRolloutResult {
30
+ /** `committed`: Play accepted the edit. `unchanged`: nothing to do. `dry-run`: nothing sent. */
31
+ readonly status: 'committed' | 'unchanged' | 'dry-run';
32
+ readonly packageName: string;
33
+ readonly track: string;
34
+ readonly action: PlayRolloutAction;
35
+ readonly versionCodes: readonly string[];
36
+ readonly before: PlayRolloutReleaseState;
37
+ readonly after: PlayRolloutReleaseState;
38
+ readonly editId?: string;
39
+ /** The `edits.tracks.update` body (dry run only). */
40
+ readonly plannedTrack?: PlayTrack;
41
+ }
42
+ export declare class PlayRolloutUncertainError extends Error {
43
+ readonly stage: 'track-update' | 'validate' | 'commit';
44
+ readonly editId: string;
45
+ /** The release that was being changed; pass one of them as --version-code on the re-run. */
46
+ readonly versionCodes: readonly string[];
47
+ constructor(stage: 'track-update' | 'validate' | 'commit', editId: string,
48
+ /** The release that was being changed; pass one of them as --version-code on the re-run. */
49
+ versionCodes?: readonly string[]);
50
+ }
51
+ /** Change one existing release on one named track with a service account. */
52
+ export declare function changePlayRollout(input: PlayRolloutInput & {
53
+ readonly serviceAccountFile: string;
54
+ }): Promise<PlayRolloutResult>;
55
+ export declare function assertPlayRolloutInput(input: PlayRolloutInput): void;
56
+ /** Credential-free injection point for the mock-transport tests. */
57
+ export declare function changePlayRolloutWithPublisher(input: PlayRolloutInput, publisher: PlayPublisher): Promise<PlayRolloutResult>;