@gpc-cli/core 0.9.56 → 0.9.58
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.
- package/README.md +4 -3
- package/dist/chunk-QIYAOW6R.js +661 -0
- package/dist/chunk-QIYAOW6R.js.map +1 -0
- package/dist/index.d.ts +54 -1
- package/dist/index.js +4508 -4832
- package/dist/index.js.map +1 -1
- package/dist/releases-I5MYFNCV.js +31 -0
- package/dist/releases-I5MYFNCV.js.map +1 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
const result = await uploadRelease(context, {
|
|
27
27
|
file: "app.aab",
|
|
28
28
|
track: "internal",
|
|
29
|
+
validateOnly: true, // dry-run: server-side validation without committing
|
|
29
30
|
});
|
|
30
31
|
|
|
31
32
|
// Promote between tracks
|
|
@@ -37,7 +38,7 @@ await promoteRelease(context, {
|
|
|
37
38
|
|
|
38
39
|
// Check vitals
|
|
39
40
|
const vitals = await getVitalsOverview(context);
|
|
40
|
-
console.log(formatOutput(vitals, "table"));
|
|
41
|
+
console.log(formatOutput(vitals, "table")); // also: "json", "csv", "tsv"
|
|
41
42
|
|
|
42
43
|
// Analyze bundle size
|
|
43
44
|
const analysis = await analyzeBundle("./app.aab");
|
|
@@ -59,13 +60,13 @@ const analysis = await analyzeBundle("./app.aab");
|
|
|
59
60
|
| **Users** | `listUsers`, `inviteUser`, `updateUser`, `removeUser` |
|
|
60
61
|
| **Testers** | `listTesters`, `addTesters`, `removeTesters`, `importTestersFromCsv` |
|
|
61
62
|
| **Bundle** | `analyzeBundle`, `compareBundles` (zero-dependency AAB/APK size analysis) |
|
|
62
|
-
| **Publishing** | `publish` (end-to-end: upload + track + notes + commit)
|
|
63
|
+
| **Publishing** | `publish` (end-to-end: upload + track + notes + commit; supports `validateOnly` dry-run) |
|
|
63
64
|
| **Changelog** | `generateChangelog`, `fetchChangelog`, `formatChangelogEntry`, `buildLocaleBundle`, `renderPlayStore`, `renderMarkdown`, `renderJson`, `renderPrompt`, `translateBundle`, `resolveLocales` |
|
|
64
65
|
| **Validation** | `validateUploadFile`, `validateImage`, `validatePreSubmission` |
|
|
65
66
|
|
|
66
67
|
## Utilities
|
|
67
68
|
|
|
68
|
-
- **Output formatting**
|
|
69
|
+
- **Output formatting** - `formatOutput(data, format)` supports `"json"`, `"table"`, `"csv"`, `"tsv"`; plus `detectOutputFormat()`, `redactSensitive()`
|
|
69
70
|
- **Error hierarchy** — `GpcError`, `ConfigError`, `ApiError`, `NetworkError` with exit codes
|
|
70
71
|
- **Audit logging** — `initAudit()`, `writeAuditLog()` for write operation tracking
|
|
71
72
|
- **Path safety** — `safePath()`, `safePathWithin()` for path traversal prevention
|
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
// src/commands/releases.ts
|
|
2
|
+
import { stat as stat2 } from "fs/promises";
|
|
3
|
+
import { extname as extname2 } from "path";
|
|
4
|
+
import { PlayApiError } from "@gpc-cli/api";
|
|
5
|
+
|
|
6
|
+
// src/errors.ts
|
|
7
|
+
var GpcError = class extends Error {
|
|
8
|
+
constructor(message, code, exitCode, suggestion) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.exitCode = exitCode;
|
|
12
|
+
this.suggestion = suggestion;
|
|
13
|
+
this.name = "GpcError";
|
|
14
|
+
}
|
|
15
|
+
code;
|
|
16
|
+
exitCode;
|
|
17
|
+
suggestion;
|
|
18
|
+
toJSON() {
|
|
19
|
+
return {
|
|
20
|
+
success: false,
|
|
21
|
+
error: {
|
|
22
|
+
code: this.code,
|
|
23
|
+
message: this.message,
|
|
24
|
+
suggestion: this.suggestion
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var ConfigError = class extends GpcError {
|
|
30
|
+
constructor(message, code, suggestion) {
|
|
31
|
+
super(message, code, 1, suggestion);
|
|
32
|
+
this.name = "ConfigError";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var ApiError = class extends GpcError {
|
|
36
|
+
constructor(message, code, statusCode, suggestion) {
|
|
37
|
+
super(message, code, 4, suggestion);
|
|
38
|
+
this.statusCode = statusCode;
|
|
39
|
+
this.name = "ApiError";
|
|
40
|
+
}
|
|
41
|
+
statusCode;
|
|
42
|
+
};
|
|
43
|
+
var NetworkError = class extends GpcError {
|
|
44
|
+
constructor(message, suggestion) {
|
|
45
|
+
super(message, "NETWORK_ERROR", 5, suggestion);
|
|
46
|
+
this.name = "NetworkError";
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// src/utils/file-validation.ts
|
|
51
|
+
import { open, stat } from "fs/promises";
|
|
52
|
+
import { extname } from "path";
|
|
53
|
+
var ZIP_MAGIC = Buffer.from([80, 75, 3, 4]);
|
|
54
|
+
var MAX_APK_SIZE = 1024 * 1024 * 1024;
|
|
55
|
+
var MAX_AAB_SIZE = 2 * 1024 * 1024 * 1024;
|
|
56
|
+
var LARGE_FILE_THRESHOLD = 100 * 1024 * 1024;
|
|
57
|
+
async function validateUploadFile(filePath) {
|
|
58
|
+
const errors = [];
|
|
59
|
+
const warnings = [];
|
|
60
|
+
const ext = extname(filePath).toLowerCase();
|
|
61
|
+
let fileType = "unknown";
|
|
62
|
+
if (ext === ".aab") {
|
|
63
|
+
fileType = "aab";
|
|
64
|
+
} else if (ext === ".apk") {
|
|
65
|
+
fileType = "apk";
|
|
66
|
+
} else {
|
|
67
|
+
errors.push(`Unsupported file extension "${ext}". Expected .aab or .apk`);
|
|
68
|
+
}
|
|
69
|
+
let sizeBytes;
|
|
70
|
+
try {
|
|
71
|
+
const stats = await stat(filePath);
|
|
72
|
+
sizeBytes = stats.size;
|
|
73
|
+
if (sizeBytes === 0) {
|
|
74
|
+
errors.push("File is empty (0 bytes)");
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
errors.push(`File not found: ${filePath}`);
|
|
78
|
+
return { valid: false, fileType, sizeBytes: 0, errors, warnings };
|
|
79
|
+
}
|
|
80
|
+
if (fileType === "apk" && sizeBytes > MAX_APK_SIZE) {
|
|
81
|
+
errors.push(
|
|
82
|
+
`APK exceeds 1 GB limit (${formatSize(sizeBytes)}). Consider using AAB format instead.`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (fileType === "aab" && sizeBytes > MAX_AAB_SIZE) {
|
|
86
|
+
errors.push(`AAB exceeds 2 GB limit (${formatSize(sizeBytes)}).`);
|
|
87
|
+
}
|
|
88
|
+
if (sizeBytes > LARGE_FILE_THRESHOLD && errors.length === 0) {
|
|
89
|
+
warnings.push(
|
|
90
|
+
`Large file (${formatSize(sizeBytes)}). Upload may take a while on slow connections.`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (sizeBytes > 0) {
|
|
94
|
+
let fh;
|
|
95
|
+
try {
|
|
96
|
+
fh = await open(filePath, "r");
|
|
97
|
+
const buf = Buffer.alloc(4);
|
|
98
|
+
await fh.read(buf, 0, 4, 0);
|
|
99
|
+
if (!buf.equals(ZIP_MAGIC)) {
|
|
100
|
+
errors.push(
|
|
101
|
+
"File does not have valid ZIP magic bytes (PK\\x03\\x04). Both AAB and APK files must be valid ZIP archives."
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
errors.push("Unable to read file header for validation");
|
|
106
|
+
} finally {
|
|
107
|
+
await fh?.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
valid: errors.length === 0,
|
|
112
|
+
fileType,
|
|
113
|
+
sizeBytes,
|
|
114
|
+
errors,
|
|
115
|
+
warnings
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function formatSize(bytes) {
|
|
119
|
+
if (bytes >= 1024 * 1024 * 1024) {
|
|
120
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
121
|
+
}
|
|
122
|
+
if (bytes >= 1024 * 1024) {
|
|
123
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
124
|
+
}
|
|
125
|
+
if (bytes >= 1024) {
|
|
126
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
127
|
+
}
|
|
128
|
+
return `${bytes} B`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/commands/releases.ts
|
|
132
|
+
var BUNDLE_POLL_BACKOFF = [2e3, 3e3, 5e3, 8e3, 13e3];
|
|
133
|
+
async function waitForBundleProcessing(client, packageName, editId, versionCode, backoff = BUNDLE_POLL_BACKOFF) {
|
|
134
|
+
for (let i = 0; i < backoff.length; i++) {
|
|
135
|
+
const bundles = await client.bundles.list(packageName, editId);
|
|
136
|
+
if (bundles.some((b) => b.versionCode === versionCode)) return;
|
|
137
|
+
await new Promise((r) => setTimeout(r, backoff[i]));
|
|
138
|
+
}
|
|
139
|
+
throw new GpcError(
|
|
140
|
+
`Bundle versionCode ${versionCode} not ready after ${backoff.length} poll attempts (~${Math.round(backoff.reduce((a, b) => a + b, 0) / 1e3)}s)`,
|
|
141
|
+
"BUNDLE_PROCESSING_TIMEOUT",
|
|
142
|
+
4,
|
|
143
|
+
"The AAB is still being processed by Google. Retry the upload, or use --status draft and commit later."
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
async function withRetryOnConflict(client, packageName, operation) {
|
|
147
|
+
const edit = await client.edits.insert(packageName);
|
|
148
|
+
try {
|
|
149
|
+
return await operation(edit);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const isConflict = error instanceof PlayApiError && error.statusCode === 409;
|
|
152
|
+
if (!isConflict) {
|
|
153
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
154
|
+
});
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
158
|
+
});
|
|
159
|
+
const freshEdit = await client.edits.insert(packageName);
|
|
160
|
+
try {
|
|
161
|
+
return await operation(freshEdit);
|
|
162
|
+
} catch (retryError) {
|
|
163
|
+
await client.edits.delete(packageName, freshEdit.id).catch(() => {
|
|
164
|
+
});
|
|
165
|
+
throw retryError;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
var _consoleEditWarningShown = false;
|
|
170
|
+
function warnAboutConcurrentEdits() {
|
|
171
|
+
if (_consoleEditWarningShown) return;
|
|
172
|
+
_consoleEditWarningShown = true;
|
|
173
|
+
process.emitWarning?.(
|
|
174
|
+
"If the Play Console has pending changes, they may be discarded when this edit is committed. Avoid making changes in the Play Console while CLI operations are in progress.",
|
|
175
|
+
"ConcurrentEditWarning"
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
function warnIfEditExpiring(edit) {
|
|
179
|
+
if (!edit.expiryTimeSeconds) return;
|
|
180
|
+
const expiryMs = Number(edit.expiryTimeSeconds) * 1e3;
|
|
181
|
+
const remainingMs = expiryMs - Date.now();
|
|
182
|
+
if (remainingMs < 5 * 60 * 1e3 && remainingMs > 0) {
|
|
183
|
+
const minutes = Math.round(remainingMs / 6e4);
|
|
184
|
+
process.emitWarning?.(
|
|
185
|
+
`Edit session expires in ~${minutes} minute${minutes !== 1 ? "s" : ""}. Long uploads may fail. Consider starting a fresh operation.`,
|
|
186
|
+
"EditExpiryWarning"
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async function withFreshEdit(client, packageName, operation) {
|
|
191
|
+
const edit = await client.edits.insert(packageName);
|
|
192
|
+
try {
|
|
193
|
+
return await operation(edit.id);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error instanceof PlayApiError && error.code === "API_EDIT_EXPIRED") {
|
|
196
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
197
|
+
});
|
|
198
|
+
const freshEdit = await client.edits.insert(packageName);
|
|
199
|
+
try {
|
|
200
|
+
return await operation(freshEdit.id);
|
|
201
|
+
} catch (retryError) {
|
|
202
|
+
await client.edits.delete(packageName, freshEdit.id).catch(() => {
|
|
203
|
+
});
|
|
204
|
+
throw retryError;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
208
|
+
});
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function uploadRelease(client, packageName, filePath, options) {
|
|
213
|
+
const validation = await validateUploadFile(filePath);
|
|
214
|
+
if (options.dryRun) {
|
|
215
|
+
const plannedStatus = options.status || (options.userFraction ? "inProgress" : "completed");
|
|
216
|
+
let currentReleases = [];
|
|
217
|
+
const edit2 = await client.edits.insert(packageName);
|
|
218
|
+
try {
|
|
219
|
+
const trackData = await client.tracks.get(packageName, edit2.id, options.track);
|
|
220
|
+
currentReleases = (trackData.releases || []).map((r) => ({
|
|
221
|
+
versionCodes: r.versionCodes || [],
|
|
222
|
+
status: r.status,
|
|
223
|
+
...r.userFraction !== void 0 && { userFraction: r.userFraction }
|
|
224
|
+
}));
|
|
225
|
+
} catch {
|
|
226
|
+
} finally {
|
|
227
|
+
await client.edits.delete(packageName, edit2.id).catch(() => {
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
dryRun: true,
|
|
232
|
+
file: {
|
|
233
|
+
path: filePath,
|
|
234
|
+
valid: validation.valid,
|
|
235
|
+
errors: validation.errors,
|
|
236
|
+
warnings: validation.warnings
|
|
237
|
+
},
|
|
238
|
+
track: options.track,
|
|
239
|
+
currentReleases,
|
|
240
|
+
plannedRelease: {
|
|
241
|
+
status: plannedStatus,
|
|
242
|
+
...options.userFraction !== void 0 && { userFraction: options.userFraction }
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (!validation.valid) {
|
|
247
|
+
throw new GpcError(
|
|
248
|
+
`File validation failed:
|
|
249
|
+
${validation.errors.join("\n")}`,
|
|
250
|
+
"RELEASE_INVALID_FILE",
|
|
251
|
+
2,
|
|
252
|
+
"Check that the file is a valid AAB or APK and is not corrupted."
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
let fileSize = 0;
|
|
256
|
+
try {
|
|
257
|
+
const { size } = await stat2(filePath);
|
|
258
|
+
fileSize = size;
|
|
259
|
+
} catch {
|
|
260
|
+
}
|
|
261
|
+
if (options.onProgress) options.onProgress(0, fileSize);
|
|
262
|
+
const edit = await client.edits.insert(packageName);
|
|
263
|
+
warnIfEditExpiring(edit);
|
|
264
|
+
warnAboutConcurrentEdits();
|
|
265
|
+
try {
|
|
266
|
+
const isApk = extname2(filePath).toLowerCase() === ".apk";
|
|
267
|
+
const uploadOpts = {
|
|
268
|
+
...options.uploadOptions,
|
|
269
|
+
onProgress: (event) => {
|
|
270
|
+
if (options.onProgress) options.onProgress(event.bytesUploaded, event.totalBytes);
|
|
271
|
+
if (options.onUploadProgress) options.onUploadProgress(event);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
const bundle = isApk ? await client.apks.upload(packageName, edit.id, filePath, uploadOpts) : await client.bundles.upload(
|
|
275
|
+
packageName,
|
|
276
|
+
edit.id,
|
|
277
|
+
filePath,
|
|
278
|
+
uploadOpts,
|
|
279
|
+
options.deviceTierConfigId
|
|
280
|
+
);
|
|
281
|
+
if (!isApk) {
|
|
282
|
+
await waitForBundleProcessing(client, packageName, edit.id, bundle.versionCode);
|
|
283
|
+
}
|
|
284
|
+
if (options.mappingFile) {
|
|
285
|
+
await client.deobfuscation.upload(
|
|
286
|
+
packageName,
|
|
287
|
+
edit.id,
|
|
288
|
+
bundle.versionCode,
|
|
289
|
+
options.mappingFile,
|
|
290
|
+
options.mappingFileType
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const release = {
|
|
294
|
+
versionCodes: [String(bundle.versionCode)],
|
|
295
|
+
status: options.status || (options.userFraction ? "inProgress" : "completed"),
|
|
296
|
+
...options.userFraction && { userFraction: options.userFraction },
|
|
297
|
+
...options.releaseNotes && { releaseNotes: options.releaseNotes },
|
|
298
|
+
...options.releaseName && { name: options.releaseName }
|
|
299
|
+
};
|
|
300
|
+
await client.tracks.update(packageName, edit.id, options.track, release);
|
|
301
|
+
if (!options.commitOptions?.changesNotSentForReview) {
|
|
302
|
+
await client.edits.validate(packageName, edit.id);
|
|
303
|
+
}
|
|
304
|
+
if (options.validateOnly) {
|
|
305
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
306
|
+
});
|
|
307
|
+
return {
|
|
308
|
+
versionCode: bundle.versionCode,
|
|
309
|
+
track: options.track,
|
|
310
|
+
status: release.status,
|
|
311
|
+
validateOnly: true
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
await client.edits.commit(packageName, edit.id, options.commitOptions);
|
|
315
|
+
return {
|
|
316
|
+
versionCode: bundle.versionCode,
|
|
317
|
+
track: options.track,
|
|
318
|
+
status: release.status
|
|
319
|
+
};
|
|
320
|
+
} catch (error) {
|
|
321
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
322
|
+
});
|
|
323
|
+
throw error;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
async function getReleasesStatus(client, packageName, trackFilter) {
|
|
327
|
+
const edit = await client.edits.insert(packageName);
|
|
328
|
+
try {
|
|
329
|
+
const tracks = trackFilter ? [await client.tracks.get(packageName, edit.id, trackFilter)] : await client.tracks.list(packageName, edit.id);
|
|
330
|
+
await client.edits.delete(packageName, edit.id);
|
|
331
|
+
const results = [];
|
|
332
|
+
for (const track of tracks) {
|
|
333
|
+
for (const release of track.releases || []) {
|
|
334
|
+
results.push({
|
|
335
|
+
track: track.track,
|
|
336
|
+
status: release.status,
|
|
337
|
+
versionCodes: release.versionCodes || [],
|
|
338
|
+
userFraction: release.userFraction,
|
|
339
|
+
releaseNotes: release.releaseNotes
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return results;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
346
|
+
});
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
async function promoteRelease(client, packageName, fromTrack, toTrack, options) {
|
|
351
|
+
if (options?.userFraction && (options.userFraction <= 0 || options.userFraction > 1)) {
|
|
352
|
+
throw new GpcError(
|
|
353
|
+
"Rollout percentage must be between 0 and 1 (e.g., 0.1 for 10%)",
|
|
354
|
+
"RELEASE_INVALID_FRACTION",
|
|
355
|
+
2,
|
|
356
|
+
"Use a decimal value like 0.1 for 10%, 0.5 for 50%, or 1.0 for 100%."
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
return withRetryOnConflict(client, packageName, async (edit) => {
|
|
360
|
+
const sourceTrack = await client.tracks.get(packageName, edit.id, fromTrack);
|
|
361
|
+
const currentRelease = sourceTrack.releases?.find(
|
|
362
|
+
(r) => r.status === "completed" || r.status === "inProgress"
|
|
363
|
+
);
|
|
364
|
+
if (!currentRelease) {
|
|
365
|
+
throw new GpcError(
|
|
366
|
+
`No active release found on track "${fromTrack}"`,
|
|
367
|
+
"RELEASE_NOT_FOUND",
|
|
368
|
+
1,
|
|
369
|
+
`Ensure there is a completed or in-progress release on the "${fromTrack}" track before promoting.`
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
const release = {
|
|
373
|
+
versionCodes: currentRelease.versionCodes,
|
|
374
|
+
status: options?.status || (options?.userFraction ? "inProgress" : "completed"),
|
|
375
|
+
...options?.userFraction && { userFraction: options.userFraction },
|
|
376
|
+
releaseNotes: options?.releaseNotes || currentRelease.releaseNotes || []
|
|
377
|
+
};
|
|
378
|
+
await client.tracks.update(packageName, edit.id, toTrack, release);
|
|
379
|
+
if (!options?.commitOptions?.changesNotSentForReview) {
|
|
380
|
+
await client.edits.validate(packageName, edit.id);
|
|
381
|
+
}
|
|
382
|
+
await client.edits.commit(packageName, edit.id, options?.commitOptions);
|
|
383
|
+
return {
|
|
384
|
+
track: toTrack,
|
|
385
|
+
status: release.status,
|
|
386
|
+
versionCodes: release.versionCodes,
|
|
387
|
+
userFraction: release.userFraction
|
|
388
|
+
};
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
async function updateRollout(client, packageName, track, action, userFraction, commitOptions) {
|
|
392
|
+
const edit = await client.edits.insert(packageName);
|
|
393
|
+
try {
|
|
394
|
+
const trackData = await client.tracks.get(packageName, edit.id, track);
|
|
395
|
+
const currentRelease = trackData.releases?.find(
|
|
396
|
+
(r) => r.status === "inProgress" || r.status === "halted"
|
|
397
|
+
);
|
|
398
|
+
if (!currentRelease) {
|
|
399
|
+
throw new GpcError(
|
|
400
|
+
`No active rollout found on track "${track}"`,
|
|
401
|
+
"ROLLOUT_NOT_FOUND",
|
|
402
|
+
1,
|
|
403
|
+
`There is no in-progress or halted rollout on the "${track}" track. Start a staged rollout first with: gpc releases upload --track ${track} --status inProgress --fraction 0.1`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
let newStatus;
|
|
407
|
+
let newFraction;
|
|
408
|
+
switch (action) {
|
|
409
|
+
case "increase":
|
|
410
|
+
if (!userFraction)
|
|
411
|
+
throw new GpcError(
|
|
412
|
+
"--to <percentage> is required for rollout increase",
|
|
413
|
+
"ROLLOUT_MISSING_FRACTION",
|
|
414
|
+
2,
|
|
415
|
+
"Specify the target rollout percentage with --to, e.g.: gpc rollout increase --to 0.5"
|
|
416
|
+
);
|
|
417
|
+
if (userFraction <= 0 || userFraction > 1) {
|
|
418
|
+
throw new GpcError(
|
|
419
|
+
"Rollout percentage must be between 0 and 1 (e.g., 0.1 for 10%)",
|
|
420
|
+
"RELEASE_INVALID_FRACTION",
|
|
421
|
+
2,
|
|
422
|
+
"Use a decimal value like 0.1 for 10%, 0.5 for 50%, or 1.0 for 100%."
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
newStatus = "inProgress";
|
|
426
|
+
newFraction = userFraction;
|
|
427
|
+
break;
|
|
428
|
+
case "halt":
|
|
429
|
+
newStatus = "halted";
|
|
430
|
+
newFraction = currentRelease.userFraction;
|
|
431
|
+
break;
|
|
432
|
+
case "resume":
|
|
433
|
+
newStatus = "inProgress";
|
|
434
|
+
newFraction = currentRelease.userFraction;
|
|
435
|
+
break;
|
|
436
|
+
case "complete":
|
|
437
|
+
newStatus = "completed";
|
|
438
|
+
newFraction = void 0;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
const release = {
|
|
442
|
+
versionCodes: currentRelease.versionCodes,
|
|
443
|
+
status: newStatus,
|
|
444
|
+
...newFraction !== void 0 && { userFraction: newFraction },
|
|
445
|
+
releaseNotes: currentRelease.releaseNotes || []
|
|
446
|
+
};
|
|
447
|
+
await client.tracks.update(packageName, edit.id, track, release);
|
|
448
|
+
if (!commitOptions?.changesNotSentForReview) {
|
|
449
|
+
await client.edits.validate(packageName, edit.id);
|
|
450
|
+
}
|
|
451
|
+
await client.edits.commit(packageName, edit.id, commitOptions);
|
|
452
|
+
return {
|
|
453
|
+
track,
|
|
454
|
+
status: newStatus,
|
|
455
|
+
versionCodes: release.versionCodes,
|
|
456
|
+
userFraction: newFraction
|
|
457
|
+
};
|
|
458
|
+
} catch (error) {
|
|
459
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
460
|
+
});
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
async function listTracks(client, packageName) {
|
|
465
|
+
const edit = await client.edits.insert(packageName);
|
|
466
|
+
try {
|
|
467
|
+
const tracks = await client.tracks.list(packageName, edit.id);
|
|
468
|
+
await client.edits.delete(packageName, edit.id);
|
|
469
|
+
return tracks;
|
|
470
|
+
} catch (error) {
|
|
471
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
472
|
+
});
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
async function createTrack(client, packageName, trackName, commitOptions) {
|
|
477
|
+
if (!trackName || trackName.trim().length === 0) {
|
|
478
|
+
throw new GpcError(
|
|
479
|
+
"Track name must not be empty",
|
|
480
|
+
"TRACK_INVALID_NAME",
|
|
481
|
+
2,
|
|
482
|
+
"Provide a valid custom track name, e.g.: gpc tracks create my-qa-track"
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
const edit = await client.edits.insert(packageName);
|
|
486
|
+
try {
|
|
487
|
+
const track = await client.tracks.create(packageName, edit.id, trackName);
|
|
488
|
+
if (!commitOptions?.changesNotSentForReview) {
|
|
489
|
+
await client.edits.validate(packageName, edit.id);
|
|
490
|
+
}
|
|
491
|
+
await client.edits.commit(packageName, edit.id, commitOptions);
|
|
492
|
+
return track;
|
|
493
|
+
} catch (error) {
|
|
494
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
495
|
+
});
|
|
496
|
+
throw error;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
async function updateTrackConfig(client, packageName, trackName, config, commitOptions) {
|
|
500
|
+
if (!trackName || trackName.trim().length === 0) {
|
|
501
|
+
throw new GpcError(
|
|
502
|
+
"Track name must not be empty",
|
|
503
|
+
"TRACK_INVALID_NAME",
|
|
504
|
+
2,
|
|
505
|
+
"Provide a valid track name."
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
const edit = await client.edits.insert(packageName);
|
|
509
|
+
try {
|
|
510
|
+
const release = {
|
|
511
|
+
versionCodes: config["versionCodes"] || [],
|
|
512
|
+
status: config["status"] || "completed"
|
|
513
|
+
};
|
|
514
|
+
if (config["userFraction"] !== void 0) {
|
|
515
|
+
release.userFraction = config["userFraction"];
|
|
516
|
+
}
|
|
517
|
+
if (config["releaseNotes"]) {
|
|
518
|
+
release.releaseNotes = config["releaseNotes"];
|
|
519
|
+
}
|
|
520
|
+
if (config["name"]) {
|
|
521
|
+
release.name = config["name"];
|
|
522
|
+
}
|
|
523
|
+
const track = await client.tracks.update(packageName, edit.id, trackName, release);
|
|
524
|
+
if (!commitOptions?.changesNotSentForReview) {
|
|
525
|
+
await client.edits.validate(packageName, edit.id);
|
|
526
|
+
}
|
|
527
|
+
await client.edits.commit(packageName, edit.id, commitOptions);
|
|
528
|
+
return track;
|
|
529
|
+
} catch (error) {
|
|
530
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
531
|
+
});
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
async function fetchReleaseNotes(client, packageName, track) {
|
|
536
|
+
const edit = await client.edits.insert(packageName);
|
|
537
|
+
try {
|
|
538
|
+
const trackData = await client.tracks.get(packageName, edit.id, track);
|
|
539
|
+
const release = trackData.releases?.find((r) => r.status === "completed" || r.status === "inProgress") ?? trackData.releases?.[0];
|
|
540
|
+
if (!release) {
|
|
541
|
+
throw new GpcError(
|
|
542
|
+
`No release found on track "${track}" to copy notes from`,
|
|
543
|
+
"RELEASE_NOT_FOUND",
|
|
544
|
+
1,
|
|
545
|
+
`Ensure there is a release on the "${track}" track.`
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
return release.releaseNotes ?? [];
|
|
549
|
+
} finally {
|
|
550
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
async function applyReleaseNotes(client, packageName, track, releaseNotes, commitOptions) {
|
|
555
|
+
return withRetryOnConflict(client, packageName, async (edit) => {
|
|
556
|
+
const trackData = await client.tracks.get(packageName, edit.id, track);
|
|
557
|
+
const draft = trackData.releases?.find((r) => r.status === "draft");
|
|
558
|
+
if (!draft) {
|
|
559
|
+
throw new GpcError(
|
|
560
|
+
`No draft release found on track "${track}"`,
|
|
561
|
+
"RELEASE_NO_DRAFT",
|
|
562
|
+
1,
|
|
563
|
+
`Upload an AAB/APK first to create a draft, or check the --track value. Current track: "${track}".`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
const patched = {
|
|
567
|
+
...draft,
|
|
568
|
+
releaseNotes
|
|
569
|
+
};
|
|
570
|
+
await client.tracks.update(packageName, edit.id, track, patched);
|
|
571
|
+
if (!commitOptions?.changesNotSentForReview) {
|
|
572
|
+
await client.edits.validate(packageName, edit.id);
|
|
573
|
+
}
|
|
574
|
+
await client.edits.commit(packageName, edit.id, commitOptions);
|
|
575
|
+
return {
|
|
576
|
+
track,
|
|
577
|
+
versionCodes: draft.versionCodes || [],
|
|
578
|
+
localeCount: releaseNotes.length,
|
|
579
|
+
releaseNotes
|
|
580
|
+
};
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
async function diffReleases(client, packageName, fromTrack, toTrack) {
|
|
584
|
+
const edit = await client.edits.insert(packageName);
|
|
585
|
+
try {
|
|
586
|
+
const [fromData, toData] = await Promise.all([
|
|
587
|
+
client.tracks.get(packageName, edit.id, fromTrack),
|
|
588
|
+
client.tracks.get(packageName, edit.id, toTrack)
|
|
589
|
+
]);
|
|
590
|
+
await client.edits.delete(packageName, edit.id);
|
|
591
|
+
const fromRelease = fromData.releases?.[0];
|
|
592
|
+
const toRelease = toData.releases?.[0];
|
|
593
|
+
const diffs = [];
|
|
594
|
+
const fields = ["versionCodes", "status", "userFraction", "releaseNotes", "name"];
|
|
595
|
+
for (const field of fields) {
|
|
596
|
+
const v1 = fromRelease ? JSON.stringify(fromRelease[field] ?? null) : "null";
|
|
597
|
+
const v2 = toRelease ? JSON.stringify(toRelease[field] ?? null) : "null";
|
|
598
|
+
if (v1 !== v2) {
|
|
599
|
+
diffs.push({ field, track1Value: v1, track2Value: v2 });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return { fromTrack, toTrack, diffs };
|
|
603
|
+
} catch (error) {
|
|
604
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
605
|
+
});
|
|
606
|
+
throw error;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
async function uploadExternallyHosted(client, packageName, data, commitOptions) {
|
|
610
|
+
if (!data.externallyHostedUrl) {
|
|
611
|
+
throw new GpcError(
|
|
612
|
+
"externallyHostedUrl is required",
|
|
613
|
+
"EXTERNAL_APK_MISSING_URL",
|
|
614
|
+
2,
|
|
615
|
+
"Provide a valid URL for the externally hosted APK."
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
if (!data.packageName) {
|
|
619
|
+
throw new GpcError(
|
|
620
|
+
"packageName is required in externally hosted APK data",
|
|
621
|
+
"EXTERNAL_APK_MISSING_PACKAGE",
|
|
622
|
+
2,
|
|
623
|
+
"Include the packageName field in the APK configuration."
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
const edit = await client.edits.insert(packageName);
|
|
627
|
+
try {
|
|
628
|
+
const result = await client.apks.addExternallyHosted(packageName, edit.id, data);
|
|
629
|
+
if (!commitOptions?.changesNotSentForReview) {
|
|
630
|
+
await client.edits.validate(packageName, edit.id);
|
|
631
|
+
}
|
|
632
|
+
await client.edits.commit(packageName, edit.id, commitOptions);
|
|
633
|
+
return result;
|
|
634
|
+
} catch (error) {
|
|
635
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
636
|
+
});
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export {
|
|
642
|
+
GpcError,
|
|
643
|
+
ConfigError,
|
|
644
|
+
ApiError,
|
|
645
|
+
NetworkError,
|
|
646
|
+
validateUploadFile,
|
|
647
|
+
waitForBundleProcessing,
|
|
648
|
+
withFreshEdit,
|
|
649
|
+
uploadRelease,
|
|
650
|
+
getReleasesStatus,
|
|
651
|
+
promoteRelease,
|
|
652
|
+
updateRollout,
|
|
653
|
+
listTracks,
|
|
654
|
+
createTrack,
|
|
655
|
+
updateTrackConfig,
|
|
656
|
+
fetchReleaseNotes,
|
|
657
|
+
applyReleaseNotes,
|
|
658
|
+
diffReleases,
|
|
659
|
+
uploadExternallyHosted
|
|
660
|
+
};
|
|
661
|
+
//# sourceMappingURL=chunk-QIYAOW6R.js.map
|