@mpgd/cli 0.67.0 → 0.69.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.
- package/dist/app-store-release-asc.d.ts +39 -0
- package/dist/app-store-release-asc.js +274 -0
- package/dist/app-store-release-command.d.ts +73 -0
- package/dist/app-store-release-command.js +280 -0
- package/dist/app-store-release-port.d.ts +168 -0
- package/dist/app-store-release-port.js +24 -0
- package/dist/app-store-release.d.ts +238 -0
- package/dist/app-store-release.js +1246 -0
- package/dist/asc-session.d.ts +6 -2
- package/dist/asc-session.js +12 -3
- package/dist/index.d.ts +5 -0
- package/dist/index.js +289 -2
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +7 -4
- package/dist/native-deploy-submission.d.ts +11 -0
- package/dist/native-deploy-submission.js +26 -2
- package/dist/play-release-options.d.ts +11 -0
- package/dist/play-release-options.js +40 -14
- package/dist/store-presence-app-store.js +6 -4
- package/dist/store-products-app-store.js +3 -3
- package/dist/testflight-distribution-asc.d.ts +35 -0
- package/dist/testflight-distribution-asc.js +170 -0
- package/dist/testflight-distribution-port.d.ts +73 -0
- package/dist/testflight-distribution-port.js +6 -0
- package/dist/testflight-distribution.d.ts +162 -0
- package/dist/testflight-distribution.js +642 -0
- package/dist/testflight-submission.d.ts +11 -0
- package/dist/testflight-submission.js +20 -1
- package/package.json +18 -18
- package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +22 -3
|
@@ -0,0 +1,1246 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { ReleaseProcessError } from './deploy-process.js';
|
|
3
|
+
import { describeTextChange, normalizeLineBreaks, } from './store-presence.js';
|
|
4
|
+
import { stableSha256, StorePlanStaleError } from './store-sync.js';
|
|
5
|
+
/**
|
|
6
|
+
* App Store Connect release: everything between "the build is in
|
|
7
|
+
* TestFlight" and "the version is submitted for review", planned from App
|
|
8
|
+
* Store Connect and the game files, then applied step by step.
|
|
9
|
+
*
|
|
10
|
+
* The plan reads; apply re-plans, refuses when App Store Connect or the
|
|
11
|
+
* files moved past the reviewed plan, and writes in a fixed order: the
|
|
12
|
+
* version (created or renamed, then its copyright and release options), its
|
|
13
|
+
* build, What's New, App Review information, the phased release, the review
|
|
14
|
+
* submission with the version and the in-app purchases that go with it, and
|
|
15
|
+
* the submission itself. App Store Connect has no transaction: a failed
|
|
16
|
+
* apply names the steps already written, and a new plan lists what is left.
|
|
17
|
+
*
|
|
18
|
+
* A version that does not exist yet is created alone: App Store Connect
|
|
19
|
+
* then needs its store page (`mpgd store presence apply`) before anything
|
|
20
|
+
* else can be checked, so the next plan picks up from there.
|
|
21
|
+
*/
|
|
22
|
+
export const appStoreReleasePlanCommand = 'mpgd store release plan';
|
|
23
|
+
// App Store Connect `AppVersionState` (and the legacy `AppStoreVersionState` spellings).
|
|
24
|
+
// READY_FOR_REVIEW: the version is in a review submission that was not submitted
|
|
25
|
+
// yet (a partial apply), so it is prepared and submitted like the others.
|
|
26
|
+
const editableVersionStates = new Set([
|
|
27
|
+
'PREPARE_FOR_SUBMISSION',
|
|
28
|
+
'READY_FOR_REVIEW',
|
|
29
|
+
'DEVELOPER_REJECTED',
|
|
30
|
+
'REJECTED',
|
|
31
|
+
'METADATA_REJECTED',
|
|
32
|
+
'INVALID_BINARY',
|
|
33
|
+
]);
|
|
34
|
+
const renameableVersionStates = new Set(['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED']);
|
|
35
|
+
const submittedVersionStates = new Set([
|
|
36
|
+
'WAITING_FOR_REVIEW',
|
|
37
|
+
'IN_REVIEW',
|
|
38
|
+
'WAITING_FOR_EXPORT_COMPLIANCE',
|
|
39
|
+
]);
|
|
40
|
+
const approvedVersionStates = new Set([
|
|
41
|
+
'PENDING_DEVELOPER_RELEASE',
|
|
42
|
+
'PENDING_APPLE_RELEASE',
|
|
43
|
+
'ACCEPTED',
|
|
44
|
+
]);
|
|
45
|
+
const releasedVersionStates = new Set([
|
|
46
|
+
'READY_FOR_DISTRIBUTION',
|
|
47
|
+
'PROCESSING_FOR_DISTRIBUTION',
|
|
48
|
+
'REPLACED_WITH_NEW_VERSION',
|
|
49
|
+
'READY_FOR_SALE',
|
|
50
|
+
'DEVELOPER_REMOVED_FROM_SALE',
|
|
51
|
+
'REMOVED_FROM_SALE',
|
|
52
|
+
]);
|
|
53
|
+
const editableAppInfoStates = new Set(['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED']);
|
|
54
|
+
const liveAppInfoStates = new Set(['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE']);
|
|
55
|
+
// `InAppPurchaseState` values that need work before the purchase can be reviewed.
|
|
56
|
+
const unfinishedIapStates = new Set([
|
|
57
|
+
'MISSING_METADATA',
|
|
58
|
+
'WAITING_FOR_UPLOAD',
|
|
59
|
+
'PROCESSING_CONTENT',
|
|
60
|
+
'DEVELOPER_ACTION_NEEDED',
|
|
61
|
+
'REJECTED',
|
|
62
|
+
]);
|
|
63
|
+
/** An in-app purchase of this type has been, or is being, reviewed. */
|
|
64
|
+
const reviewedIapStates = new Set([
|
|
65
|
+
'APPROVED',
|
|
66
|
+
'PENDING_BINARY_APPROVAL',
|
|
67
|
+
'WAITING_FOR_REVIEW',
|
|
68
|
+
'IN_REVIEW',
|
|
69
|
+
]);
|
|
70
|
+
// `InAppPurchaseVersion.state` of the version a READY_TO_SUBMIT purchase submits.
|
|
71
|
+
const submittableIapVersionStates = new Set([
|
|
72
|
+
'PREPARE_FOR_SUBMISSION',
|
|
73
|
+
'READY_FOR_REVIEW',
|
|
74
|
+
'REJECTED',
|
|
75
|
+
'DEVELOPER_REJECTED',
|
|
76
|
+
]);
|
|
77
|
+
const iphoneDisplayTypes = ['APP_IPHONE_67', 'APP_IPHONE_65'];
|
|
78
|
+
const ipadDisplayTypes = ['APP_IPAD_PRO_3GEN_129', 'APP_IPAD_PRO_129'];
|
|
79
|
+
const manualSteps = [
|
|
80
|
+
'App Privacy answers (App Store Connect > App Privacy): the public App Store Connect API has no resource '
|
|
81
|
+
+ 'for them, so the kit can neither write nor check them.',
|
|
82
|
+
'Age rating answers: the plan only checks that the questionnaire was answered; answer it in App Store '
|
|
83
|
+
+ 'Connect (App Information > Age Rating).',
|
|
84
|
+
'Sandbox testers, pricing and availability of the app, content rights, the app category and the '
|
|
85
|
+
+ 'export compliance documentation of a build that uses non-exempt encryption stay in App Store Connect.',
|
|
86
|
+
];
|
|
87
|
+
function versionState(version) {
|
|
88
|
+
return String(version.state ?? version.appStoreState ?? '');
|
|
89
|
+
}
|
|
90
|
+
function inStates(version, states) {
|
|
91
|
+
return states.has(String(version.state)) || states.has(String(version.appStoreState));
|
|
92
|
+
}
|
|
93
|
+
/** Read App Store Connect and plan; never writes. */
|
|
94
|
+
export async function planAppStoreRelease(input, ports) {
|
|
95
|
+
return (await planWithWrites(input, ports)).plan;
|
|
96
|
+
}
|
|
97
|
+
async function planWithWrites(input, ports) {
|
|
98
|
+
const appStore = requireAppStore(input.presence);
|
|
99
|
+
const { request } = input;
|
|
100
|
+
assertRequest(request);
|
|
101
|
+
const app = await ports.release.readApp(input.appId);
|
|
102
|
+
if (app.bundleId !== input.bundleId) {
|
|
103
|
+
throw new Error(`App Store Connect app ${input.appId} has bundle ID ${app.bundleId}, not ${input.bundleId} from the deployment profile.`);
|
|
104
|
+
}
|
|
105
|
+
const blockers = [];
|
|
106
|
+
const notes = [];
|
|
107
|
+
const remote = { app };
|
|
108
|
+
const versions = await ports.release.listVersions(input.appId);
|
|
109
|
+
remote.versions = versions;
|
|
110
|
+
const first = !versions.some((version) => inStates(version, releasedVersionStates));
|
|
111
|
+
const exact = versions.filter((version) => version.versionString === request.marketingVersion);
|
|
112
|
+
if (exact.length > 1) {
|
|
113
|
+
throw new Error(`App Store Connect lists iOS version ${request.marketingVersion} ${exact.length} times.`);
|
|
114
|
+
}
|
|
115
|
+
const [named] = exact;
|
|
116
|
+
const base = {
|
|
117
|
+
appId: input.appId,
|
|
118
|
+
bundleId: input.bundleId,
|
|
119
|
+
marketingVersion: request.marketingVersion,
|
|
120
|
+
};
|
|
121
|
+
// A version that is submitted, approved or released has nothing left to prepare;
|
|
122
|
+
// re-applying the plan that got it there is a no-op.
|
|
123
|
+
if (named !== undefined && inStates(named, releasedVersionStates)) {
|
|
124
|
+
blockers.push(`iOS version ${named.versionString} is already ${versionState(named)}: submit a new marketing version.`);
|
|
125
|
+
}
|
|
126
|
+
if (named !== undefined && (inStates(named, submittedVersionStates) || inStates(named, approvedVersionStates)
|
|
127
|
+
|| inStates(named, releasedVersionStates))) {
|
|
128
|
+
const stage = inStates(named, releasedVersionStates)
|
|
129
|
+
? 'released'
|
|
130
|
+
: inStates(named, approvedVersionStates)
|
|
131
|
+
? 'approved'
|
|
132
|
+
: 'submitted';
|
|
133
|
+
notes.push(stage === 'released'
|
|
134
|
+
? `Version ${named.versionString} is released; follow it with "mpgd store release status".`
|
|
135
|
+
: stage === 'approved'
|
|
136
|
+
? `Version ${named.versionString} is ${versionState(named)}: App Review approved it. `
|
|
137
|
+
+ 'Release it with "mpgd store release publish" when its release type is manual.'
|
|
138
|
+
: `Version ${named.versionString} is ${versionState(named)}: it is already submitted. `
|
|
139
|
+
+ 'Follow it with "mpgd store release status".');
|
|
140
|
+
return finish({
|
|
141
|
+
base,
|
|
142
|
+
stage,
|
|
143
|
+
version: { action: 'reuse', id: named.id, state: versionState(named), first },
|
|
144
|
+
writes: { whatsNew: [] },
|
|
145
|
+
blockers,
|
|
146
|
+
notes,
|
|
147
|
+
remote,
|
|
148
|
+
request,
|
|
149
|
+
demoAccount: input.demoAccount,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (named !== undefined && !inStates(named, editableVersionStates)) {
|
|
153
|
+
throw new Error(`iOS version ${named.versionString} is ${versionState(named)}; the kit cannot prepare it.`);
|
|
154
|
+
}
|
|
155
|
+
const editable = versions.filter((version) => inStates(version, editableVersionStates));
|
|
156
|
+
const inFlight = versions.filter((version) => version.id !== named?.id
|
|
157
|
+
&& (inStates(version, submittedVersionStates) || inStates(version, approvedVersionStates)));
|
|
158
|
+
let version = named !== undefined && inStates(named, editableVersionStates)
|
|
159
|
+
? named
|
|
160
|
+
: undefined;
|
|
161
|
+
let rename;
|
|
162
|
+
if (version === undefined) {
|
|
163
|
+
if (editable.length > 1) {
|
|
164
|
+
throw new Error(`App Store Connect app ${input.appId} has ${editable.length} editable iOS versions; finish or remove all but one.`);
|
|
165
|
+
}
|
|
166
|
+
const [other] = editable;
|
|
167
|
+
if (other !== undefined) {
|
|
168
|
+
if (!inStates(other, renameableVersionStates)) {
|
|
169
|
+
blockers.push(`iOS version ${other.versionString} is ${versionState(other)}: App Store Connect keeps one version in `
|
|
170
|
+
+ `preparation. Resubmit or remove it in App Store Connect, or plan ${other.versionString} instead.`);
|
|
171
|
+
}
|
|
172
|
+
version = other;
|
|
173
|
+
rename = request.marketingVersion;
|
|
174
|
+
notes.push(`App Store Connect has version ${other.versionString} in preparation; the plan renames it to `
|
|
175
|
+
+ `${request.marketingVersion} (iPhone and iPad share one version).`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const other of inFlight) {
|
|
179
|
+
blockers.push(`iOS version ${other.versionString} is ${versionState(other)}: wait until App Review finishes it `
|
|
180
|
+
+ '(or remove it from review in App Store Connect) before preparing another.');
|
|
181
|
+
}
|
|
182
|
+
if (version === undefined) {
|
|
183
|
+
// A new version: create it alone; its store page comes next.
|
|
184
|
+
const copyright = appStore.copyright;
|
|
185
|
+
notes.push(`Version ${request.marketingVersion} does not exist yet: apply creates it, then run `
|
|
186
|
+
+ '"mpgd store presence plan --store app-store" and apply it to fill its store page, and plan the '
|
|
187
|
+
+ 'release again for the build, App Review information and the submission.');
|
|
188
|
+
const builds = await ports.release.listBuilds({
|
|
189
|
+
appId: input.appId,
|
|
190
|
+
marketingVersion: request.marketingVersion,
|
|
191
|
+
...(request.buildNumber === undefined ? {} : { buildNumber: request.buildNumber }),
|
|
192
|
+
});
|
|
193
|
+
remote.builds = builds;
|
|
194
|
+
const buildBlockers = [];
|
|
195
|
+
selectBuild(builds, request, buildBlockers, notes);
|
|
196
|
+
notes.push(...buildBlockers.map((blocker) => `Later: ${blocker}`));
|
|
197
|
+
return finish({
|
|
198
|
+
base,
|
|
199
|
+
stage: 'create-version',
|
|
200
|
+
version: { action: 'create', first },
|
|
201
|
+
writes: {
|
|
202
|
+
createVersion: {
|
|
203
|
+
// A scheduled release needs its date, which only an update sets: create it manual first.
|
|
204
|
+
releaseType: request.releaseType === 'SCHEDULED' ? 'MANUAL' : request.releaseType,
|
|
205
|
+
...(copyright === undefined ? {} : { copyright }),
|
|
206
|
+
},
|
|
207
|
+
whatsNew: [],
|
|
208
|
+
},
|
|
209
|
+
blockers,
|
|
210
|
+
notes,
|
|
211
|
+
remote,
|
|
212
|
+
request,
|
|
213
|
+
demoAccount: input.demoAccount,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
// The version in preparation: read everything the submission depends on.
|
|
217
|
+
const [localizations, builds, attached, reviewDetail, phased, appInfos, iaps, submissions] = await Promise.all([
|
|
218
|
+
ports.presence.listVersionLocalizations(version.id),
|
|
219
|
+
ports.release.listBuilds({
|
|
220
|
+
appId: input.appId,
|
|
221
|
+
marketingVersion: request.marketingVersion,
|
|
222
|
+
...(request.buildNumber === undefined ? {} : { buildNumber: request.buildNumber }),
|
|
223
|
+
}),
|
|
224
|
+
ports.release.readAttachedBuild(version.id),
|
|
225
|
+
ports.release.readReviewDetail(version.id),
|
|
226
|
+
ports.release.readPhasedRelease(version.id),
|
|
227
|
+
ports.presence.listAppInfos(input.appId),
|
|
228
|
+
ports.iap.listIaps(input.appId),
|
|
229
|
+
ports.release.listReviewSubmissions(input.appId),
|
|
230
|
+
]);
|
|
231
|
+
Object.assign(remote, {
|
|
232
|
+
version,
|
|
233
|
+
localizations,
|
|
234
|
+
builds,
|
|
235
|
+
attached,
|
|
236
|
+
reviewDetail,
|
|
237
|
+
phased,
|
|
238
|
+
appInfos,
|
|
239
|
+
iaps,
|
|
240
|
+
submissions,
|
|
241
|
+
});
|
|
242
|
+
// Version settings: copyright and release options.
|
|
243
|
+
const versionUpdate = {};
|
|
244
|
+
if (appStore.copyright === undefined) {
|
|
245
|
+
if ((version.copyright ?? '') === '') {
|
|
246
|
+
blockers.push('The version has no copyright line: add appStore.copyright (for example "2026 Your Studio") to the '
|
|
247
|
+
+ 'store presence file.');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
else if (appStore.copyright !== version.copyright) {
|
|
251
|
+
versionUpdate.copyright = appStore.copyright;
|
|
252
|
+
}
|
|
253
|
+
if (request.releaseType !== version.releaseType) {
|
|
254
|
+
versionUpdate.releaseType = request.releaseType;
|
|
255
|
+
}
|
|
256
|
+
if (request.releaseType === 'SCHEDULED' && request.earliestReleaseDate !== undefined
|
|
257
|
+
&& Date.parse(request.earliestReleaseDate) !== Date.parse(version.earliestReleaseDate ?? '')) {
|
|
258
|
+
versionUpdate.releaseType = request.releaseType;
|
|
259
|
+
versionUpdate.earliestReleaseDate = request.earliestReleaseDate;
|
|
260
|
+
// Only a date about to be written must lie ahead; a past one already set is App Store Connect's.
|
|
261
|
+
if (Date.parse(request.earliestReleaseDate) <= (input.now?.() ?? new Date()).getTime()) {
|
|
262
|
+
throw new Error(`The release date ${request.earliestReleaseDate} is not in the future.`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// The build.
|
|
266
|
+
const selected = selectBuild(builds, request, blockers, notes);
|
|
267
|
+
const build = selected === undefined
|
|
268
|
+
? undefined
|
|
269
|
+
: {
|
|
270
|
+
id: selected.id,
|
|
271
|
+
buildNumber: selected.buildNumber,
|
|
272
|
+
action: attached?.id === selected.id ? 'unchanged' : 'attach',
|
|
273
|
+
...(attached !== undefined && attached.id !== selected.id
|
|
274
|
+
? { replaces: attached.buildNumber ?? attached.id }
|
|
275
|
+
: {}),
|
|
276
|
+
};
|
|
277
|
+
// Localizations: every locale of the file is on the version, What's New on an update.
|
|
278
|
+
const whatsNew = [];
|
|
279
|
+
const byLocale = new Map(localizations.map((entry) => [entry.locale.toLowerCase(), entry]));
|
|
280
|
+
const fileLocales = Object.keys(appStore.localizations);
|
|
281
|
+
const fileLocaleKeys = new Set(fileLocales.map((locale) => locale.toLowerCase()));
|
|
282
|
+
for (const locale of fileLocales) {
|
|
283
|
+
const remoteLocale = byLocale.get(locale.toLowerCase());
|
|
284
|
+
if (remoteLocale === undefined) {
|
|
285
|
+
blockers.push(`${locale}: the version has no ${locale} localization on App Store Connect. Run "mpgd store presence `
|
|
286
|
+
+ 'plan --store app-store" and apply it first.');
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (first) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const desired = appStore.localizations[locale]?.whatsNew;
|
|
293
|
+
if (desired === undefined) {
|
|
294
|
+
if ((remoteLocale.whatsNew ?? '') === '') {
|
|
295
|
+
blockers.push(`${locale}: What's New is required for an update; add appStore.localizations.${locale}.whatsNew.`);
|
|
296
|
+
}
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const text = normalizeLineBreaks(desired);
|
|
300
|
+
const change = describeTextChange('whatsNew', remoteLocale.whatsNew, text);
|
|
301
|
+
if (change !== undefined) {
|
|
302
|
+
whatsNew.push({ locale: remoteLocale.locale, localizationId: remoteLocale.id, text, change });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
for (const entry of localizations) {
|
|
306
|
+
if (!fileLocaleKeys.has(entry.locale.toLowerCase())) {
|
|
307
|
+
if (!first && (entry.whatsNew ?? '') === '') {
|
|
308
|
+
blockers.push(`${entry.locale}: on App Store Connect but not in the store presence file, and it has no What's New. `
|
|
309
|
+
+ `Add appStore.localizations.${entry.locale} to the file (or remove the locale in App Store Connect).`);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
notes.push(`${entry.locale}: on App Store Connect but not in the store presence file; left as it is.`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (first && fileLocales.some((locale) => appStore.localizations[locale]?.whatsNew !== undefined)) {
|
|
317
|
+
notes.push("This is the app's first version: App Store Connect takes no What's New, so it is left out.");
|
|
318
|
+
}
|
|
319
|
+
// Screenshots: App Review needs the iPhone set and, for a universal app, the 13-inch iPad set.
|
|
320
|
+
const screenshotLocale = pickScreenshotLocale(localizations, app.primaryLocale);
|
|
321
|
+
if (screenshotLocale !== undefined) {
|
|
322
|
+
const sets = await ports.presence.listScreenshotSets(screenshotLocale.id);
|
|
323
|
+
remote.screenshotSets = sets;
|
|
324
|
+
const has = (types) => sets.some((set) => types.includes(set.displayType)
|
|
325
|
+
&& set.screenshots.some((shot) => shot.deliveryState === undefined || shot.deliveryState === 'COMPLETE'));
|
|
326
|
+
if (!has(iphoneDisplayTypes)) {
|
|
327
|
+
blockers.push(`${screenshotLocale.locale}: no 6.9-inch (or 6.5-inch) iPhone screenshots on the version. Add `
|
|
328
|
+
+ `appStore.localizations.${screenshotLocale.locale}.screenshots.iphone69 and apply the store presence.`);
|
|
329
|
+
}
|
|
330
|
+
if (request.universal && !has(ipadDisplayTypes)) {
|
|
331
|
+
blockers.push(`${screenshotLocale.locale}: the app runs on iPad (mpgd.targets.json ios metadata.iosDeviceFamily) `
|
|
332
|
+
+ 'but the version has no 13-inch iPad screenshots. Add '
|
|
333
|
+
+ `appStore.localizations.${screenshotLocale.locale}.screenshots.ipad13 and apply the store presence.`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Age rating: answered on the app information record that goes with this version.
|
|
337
|
+
const appInfo = appInfos.find((info) => editableAppInfoStates.has(String(info.state)))
|
|
338
|
+
?? appInfos.find((info) => liveAppInfoStates.has(String(info.state)))
|
|
339
|
+
?? appInfos[0];
|
|
340
|
+
const ageRating = appInfo === undefined
|
|
341
|
+
? undefined
|
|
342
|
+
: await ports.release.readAgeRating(appInfo.id);
|
|
343
|
+
remote.ageRating = ageRating ?? null;
|
|
344
|
+
if (ageRating === undefined || ageRating.answered.length === 0) {
|
|
345
|
+
blockers.push('The age rating questionnaire is not answered: answer it in App Store Connect (App Information > '
|
|
346
|
+
+ 'Age Rating), then plan again.');
|
|
347
|
+
}
|
|
348
|
+
// App Review information.
|
|
349
|
+
const review = planReviewDetail(appStore.review, reviewDetail, input.demoAccount, input.demoAccountProblems ?? [], request, blockers, notes);
|
|
350
|
+
// Phased release.
|
|
351
|
+
const phasedRelease = planPhasedRelease(request.phasedRelease, phased, notes);
|
|
352
|
+
// In-app purchases and the review submission.
|
|
353
|
+
const openSubmissions = submissions.filter((submission) => submission.state !== 'COMPLETE');
|
|
354
|
+
const items = new Map();
|
|
355
|
+
for (const submission of openSubmissions) {
|
|
356
|
+
items.set(submission.id, await ports.release.listReviewSubmissionItems(submission.id));
|
|
357
|
+
}
|
|
358
|
+
remote.items = Object.fromEntries([...items.entries()].sort(([left], [right]) => left.localeCompare(right)));
|
|
359
|
+
const plannedIaps = await planIaps(iaps, request.skipIaps, ports.iap, blockers, notes, remote);
|
|
360
|
+
const submission = planSubmission({
|
|
361
|
+
versionId: version.id,
|
|
362
|
+
openSubmissions,
|
|
363
|
+
items,
|
|
364
|
+
iaps: plannedIaps,
|
|
365
|
+
blockers,
|
|
366
|
+
notes,
|
|
367
|
+
});
|
|
368
|
+
const iapsWithItems = plannedIaps.map((iap) => iap.action === 'attach' && iap.versionId !== undefined && submission.attachedIapVersions.has(iap.versionId)
|
|
369
|
+
? { ...iap, action: 'attached' }
|
|
370
|
+
: iap);
|
|
371
|
+
return finish({
|
|
372
|
+
base,
|
|
373
|
+
stage: 'prepare',
|
|
374
|
+
version: {
|
|
375
|
+
action: rename === undefined ? 'reuse' : 'rename',
|
|
376
|
+
id: version.id,
|
|
377
|
+
...(rename === undefined ? {} : { from: version.versionString }),
|
|
378
|
+
state: versionState(version),
|
|
379
|
+
first,
|
|
380
|
+
},
|
|
381
|
+
...(Object.keys(versionUpdate).length === 0 ? {} : { versionUpdate }),
|
|
382
|
+
...(build === undefined ? {} : { build }),
|
|
383
|
+
writes: {
|
|
384
|
+
...(rename === undefined ? {} : { renameVersion: rename }),
|
|
385
|
+
whatsNew: whatsNew.map(({ locale, localizationId, text }) => ({
|
|
386
|
+
locale,
|
|
387
|
+
localizationId,
|
|
388
|
+
text,
|
|
389
|
+
})),
|
|
390
|
+
...(review.write === undefined ? {} : { reviewDetail: review.write }),
|
|
391
|
+
},
|
|
392
|
+
whatsNew: whatsNew.map(({ locale, localizationId, change }) => ({
|
|
393
|
+
locale,
|
|
394
|
+
localizationId,
|
|
395
|
+
change,
|
|
396
|
+
})),
|
|
397
|
+
reviewDetail: review.plan,
|
|
398
|
+
phasedRelease,
|
|
399
|
+
iaps: iapsWithItems,
|
|
400
|
+
submission: submission.plan,
|
|
401
|
+
blockers,
|
|
402
|
+
notes,
|
|
403
|
+
remote,
|
|
404
|
+
request,
|
|
405
|
+
demoAccount: input.demoAccount,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
function requireAppStore(presence) {
|
|
409
|
+
if (presence.appStore === undefined) {
|
|
410
|
+
throw new Error('The store presence file has no appStore section.');
|
|
411
|
+
}
|
|
412
|
+
return presence.appStore;
|
|
413
|
+
}
|
|
414
|
+
const marketingVersionPattern = /^\d+(?:\.\d+){0,2}$/u;
|
|
415
|
+
const buildNumberPattern = /^\d+(?:\.\d+){0,2}$/u;
|
|
416
|
+
const productIdPattern = /^[A-Za-z0-9._-]{1,100}$/u;
|
|
417
|
+
function assertRequest(request) {
|
|
418
|
+
if (!marketingVersionPattern.test(request.marketingVersion)) {
|
|
419
|
+
throw new Error(`Marketing version ${request.marketingVersion} must look like 1.2 or 1.2.3.`);
|
|
420
|
+
}
|
|
421
|
+
if (request.buildNumber !== undefined && !buildNumberPattern.test(request.buildNumber)) {
|
|
422
|
+
throw new Error(`Build number ${request.buildNumber} must look like 42 or 1.0.42.`);
|
|
423
|
+
}
|
|
424
|
+
if (!['MANUAL', 'AFTER_APPROVAL', 'SCHEDULED'].includes(request.releaseType)) {
|
|
425
|
+
throw new Error('The release type must be manual, after-approval or scheduled.');
|
|
426
|
+
}
|
|
427
|
+
if (request.releaseType === 'SCHEDULED') {
|
|
428
|
+
const date = request.earliestReleaseDate === undefined
|
|
429
|
+
? Number.NaN
|
|
430
|
+
: Date.parse(request.earliestReleaseDate);
|
|
431
|
+
// An offset is required: without one, Date.parse reads local time and App Store Connect may read UTC.
|
|
432
|
+
if (!Number.isFinite(date)
|
|
433
|
+
|| !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/u
|
|
434
|
+
.test(request.earliestReleaseDate ?? '')) {
|
|
435
|
+
throw new Error('A scheduled release needs --release-date as an ISO 8601 date and time, for example 2026-12-01T09:00:00+09:00.');
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
else if (request.earliestReleaseDate !== undefined) {
|
|
439
|
+
throw new Error('--release-date applies only to --release-type scheduled.');
|
|
440
|
+
}
|
|
441
|
+
for (const productId of request.skipIaps) {
|
|
442
|
+
if (!productIdPattern.test(productId)) {
|
|
443
|
+
throw new Error(`--skip-iap ${productId} is not an App Store product ID.`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/** Build numbers compare as dot-separated integers. */
|
|
448
|
+
function compareBuildNumbers(left, right) {
|
|
449
|
+
const a = left.split('.').map(Number);
|
|
450
|
+
const b = right.split('.').map(Number);
|
|
451
|
+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
|
452
|
+
const difference = (a[index] ?? 0) - (b[index] ?? 0);
|
|
453
|
+
if (difference !== 0) {
|
|
454
|
+
return difference;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The build to submit: the named build number, else the newest build of the
|
|
461
|
+
* marketing version. Anything App Review would refuse is a blocker.
|
|
462
|
+
*/
|
|
463
|
+
function selectBuild(builds, request, blockers, notes) {
|
|
464
|
+
const label = (build) => `Build ${request.marketingVersion} (${build.buildNumber})`;
|
|
465
|
+
let build;
|
|
466
|
+
if (request.buildNumber !== undefined) {
|
|
467
|
+
const matching = builds.filter((candidate) => candidate.buildNumber === request.buildNumber);
|
|
468
|
+
if (matching.length > 1) {
|
|
469
|
+
throw new Error(`App Store Connect lists build ${request.marketingVersion} (${request.buildNumber}) twice.`);
|
|
470
|
+
}
|
|
471
|
+
build = matching[0];
|
|
472
|
+
if (build === undefined) {
|
|
473
|
+
blockers.push(`${label({ buildNumber: request.buildNumber })} is not on App Store Connect: upload it with "mpgd deploy run", `
|
|
474
|
+
+ 'or wait a few minutes for App Store Connect to list it, then plan again.');
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
// A build number that is not dot-separated integers cannot be ordered; it is never picked as the newest.
|
|
480
|
+
build = builds.filter((candidate) => buildNumberPattern.test(candidate.buildNumber))
|
|
481
|
+
.sort((left, right) => compareBuildNumbers(right.buildNumber, left.buildNumber))[0];
|
|
482
|
+
if (build === undefined) {
|
|
483
|
+
blockers.push(`No build of version ${request.marketingVersion} is on App Store Connect: upload one with "mpgd deploy run".`);
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
notes.push(`${label(build)} is the newest build of the version; pass --build-number to choose another.`);
|
|
487
|
+
}
|
|
488
|
+
if (request.recordedBuildId !== undefined && build.id !== request.recordedBuildId) {
|
|
489
|
+
throw new Error(`${label(build)} is App Store Connect build ${build.id}, not ${request.recordedBuildId} that deploy run recorded.`);
|
|
490
|
+
}
|
|
491
|
+
const state = String(build.processingState ?? '');
|
|
492
|
+
if (state === 'PROCESSING') {
|
|
493
|
+
blockers.push(`${label(build)} is still processing on App Store Connect; plan again when it is VALID.`);
|
|
494
|
+
}
|
|
495
|
+
else if (state === 'FAILED' || state === 'INVALID') {
|
|
496
|
+
blockers.push(`${label(build)} is ${state} on App Store Connect (see the email Apple sent); upload a new build.`);
|
|
497
|
+
}
|
|
498
|
+
else if (state !== 'VALID') {
|
|
499
|
+
blockers.push(`${label(build)} has processing state ${state || 'unknown'}; plan again later.`);
|
|
500
|
+
}
|
|
501
|
+
else if (build.expired === true) {
|
|
502
|
+
blockers.push(`${label(build)} has expired; upload a new build.`);
|
|
503
|
+
}
|
|
504
|
+
else if (build.usesNonExemptEncryption === undefined) {
|
|
505
|
+
blockers.push(`${label(build)} is missing export compliance: answer it for the build in App Store Connect, or ship `
|
|
506
|
+
+ 'ITSAppUsesNonExemptEncryption in Info.plist for later builds.');
|
|
507
|
+
}
|
|
508
|
+
else if (build.usesNonExemptEncryption) {
|
|
509
|
+
notes.push(`${label(build)} declares non-exempt encryption: App Review needs its export compliance documentation `
|
|
510
|
+
+ 'approved in App Store Connect.');
|
|
511
|
+
}
|
|
512
|
+
return build;
|
|
513
|
+
}
|
|
514
|
+
function pickScreenshotLocale(localizations, primaryLocale) {
|
|
515
|
+
if (primaryLocale !== undefined) {
|
|
516
|
+
const primary = localizations.find((entry) => entry.locale.toLowerCase() === primaryLocale.toLowerCase());
|
|
517
|
+
if (primary !== undefined) {
|
|
518
|
+
return primary;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return localizations[0];
|
|
522
|
+
}
|
|
523
|
+
function sha256(value) {
|
|
524
|
+
return createHash('sha256').update(value).digest('hex');
|
|
525
|
+
}
|
|
526
|
+
function planReviewDetail(review, remote, demoAccount, demoAccountProblems, request, blockers, notes) {
|
|
527
|
+
if (review === undefined) {
|
|
528
|
+
const complete = remote !== undefined && [
|
|
529
|
+
remote.contactFirstName,
|
|
530
|
+
remote.contactLastName,
|
|
531
|
+
remote.contactEmail,
|
|
532
|
+
remote.contactPhone,
|
|
533
|
+
].every((value) => (value ?? '') !== '');
|
|
534
|
+
if (complete) {
|
|
535
|
+
notes.push('The store presence file has no appStore.review: the App Review information stays as it is.');
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
blockers.push('App Review needs a contact: add appStore.review.contact (first and last name, email, phone) to the store '
|
|
539
|
+
+ 'presence file.');
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
plan: { action: 'unchanged', ...(remote === undefined ? {} : { id: remote.id }), fields: [] },
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
if (review.demoAccount !== undefined && demoAccount === undefined) {
|
|
546
|
+
// Nothing of the review detail is planned without the account: the demo
|
|
547
|
+
// name decides what changes, and apply refuses a plan with blockers anyway.
|
|
548
|
+
blockers.push(...(demoAccountProblems.length > 0
|
|
549
|
+
? demoAccountProblems
|
|
550
|
+
: [`Set ${review.demoAccount.nameEnv} and ${review.demoAccount.passwordEnv} (appStore.review.demoAccount) in the environment, then plan again.`]));
|
|
551
|
+
return {
|
|
552
|
+
plan: { action: 'unchanged', ...(remote === undefined ? {} : { id: remote.id }), fields: [] },
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
const desired = {
|
|
556
|
+
contactFirstName: review.contact.firstName,
|
|
557
|
+
contactLastName: review.contact.lastName,
|
|
558
|
+
contactEmail: review.contact.email,
|
|
559
|
+
contactPhone: review.contact.phone,
|
|
560
|
+
...(review.notes === undefined ? {} : { notes: normalizeLineBreaks(review.notes) }),
|
|
561
|
+
demoAccountRequired: review.demoAccount !== undefined,
|
|
562
|
+
...(demoAccount === undefined ? {} : { demoAccountName: demoAccount.name }),
|
|
563
|
+
};
|
|
564
|
+
const fields = {};
|
|
565
|
+
const changed = [];
|
|
566
|
+
for (const [field, value] of Object.entries(desired)) {
|
|
567
|
+
const current = remote === undefined
|
|
568
|
+
? undefined
|
|
569
|
+
: field === 'demoAccountRequired'
|
|
570
|
+
? remote.demoAccountRequired
|
|
571
|
+
: typeof remote[field] === 'string'
|
|
572
|
+
? normalizeLineBreaks(String(remote[field]))
|
|
573
|
+
: undefined;
|
|
574
|
+
if (current !== value) {
|
|
575
|
+
fields[field] = value;
|
|
576
|
+
changed.push(field);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (demoAccount !== undefined
|
|
580
|
+
&& (remote === undefined || !remote.demoAccountPasswordSet || request.rotateDemoPassword
|
|
581
|
+
|| changed.includes('demoAccountName') || changed.includes('demoAccountRequired'))) {
|
|
582
|
+
fields.demoAccountPassword = demoAccount.password;
|
|
583
|
+
changed.push('demoAccountPassword');
|
|
584
|
+
}
|
|
585
|
+
if (demoAccount !== undefined && !changed.includes('demoAccountPassword')) {
|
|
586
|
+
notes.push('The demo account password on App Store Connect is kept (asc never shows it); plan with '
|
|
587
|
+
+ '--rotate-demo-password to send it again.');
|
|
588
|
+
}
|
|
589
|
+
if (changed.length === 0 && remote !== undefined) {
|
|
590
|
+
return { plan: { action: 'unchanged', id: remote.id, fields: [] } };
|
|
591
|
+
}
|
|
592
|
+
return remote === undefined
|
|
593
|
+
? {
|
|
594
|
+
plan: { action: 'create', fields: changed },
|
|
595
|
+
write: { action: 'create', fields: fields },
|
|
596
|
+
}
|
|
597
|
+
: {
|
|
598
|
+
plan: { action: 'update', id: remote.id, fields: changed },
|
|
599
|
+
write: { action: 'update', id: remote.id, fields: fields },
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
function planPhasedRelease(desired, remote, notes) {
|
|
603
|
+
if (desired) {
|
|
604
|
+
return remote === undefined
|
|
605
|
+
? { action: 'create' }
|
|
606
|
+
: { action: 'unchanged', id: remote.id, state: remote.state };
|
|
607
|
+
}
|
|
608
|
+
if (remote === undefined) {
|
|
609
|
+
return { action: 'unchanged' };
|
|
610
|
+
}
|
|
611
|
+
if (remote.state === 'INACTIVE') {
|
|
612
|
+
return { action: 'delete', id: remote.id, state: remote.state };
|
|
613
|
+
}
|
|
614
|
+
notes.push(`The phased release is ${remote.state}; it is left as it is.`);
|
|
615
|
+
return { action: 'unchanged', id: remote.id, state: remote.state };
|
|
616
|
+
}
|
|
617
|
+
async function planIaps(iaps, skipIaps, port, blockers, notes, remote) {
|
|
618
|
+
const known = new Set(iaps.map((iap) => iap.productId));
|
|
619
|
+
for (const productId of skipIaps) {
|
|
620
|
+
if (!known.has(productId)) {
|
|
621
|
+
throw new Error(`--skip-iap ${productId} is not an in-app purchase of this app on App Store Connect.`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const skip = new Set(skipIaps);
|
|
625
|
+
const iapVersions = {};
|
|
626
|
+
const planned = [];
|
|
627
|
+
for (const iap of [...iaps].sort((left, right) => left.productId.localeCompare(right.productId))) {
|
|
628
|
+
const state = String(iap.state ?? '');
|
|
629
|
+
const base = { productId: iap.productId, iapId: iap.id, type: iap.inAppPurchaseType, state };
|
|
630
|
+
if (skip.has(iap.productId)) {
|
|
631
|
+
planned.push({ ...base, action: 'skip' });
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
if (state === 'READY_TO_SUBMIT') {
|
|
635
|
+
const versions = await port.listVersions(iap.id);
|
|
636
|
+
iapVersions[iap.id] = versions;
|
|
637
|
+
const submittable = versions.filter((version) => submittableIapVersionStates.has(String(version.state)));
|
|
638
|
+
const [version] = submittable;
|
|
639
|
+
if (submittable.length !== 1 || version === undefined) {
|
|
640
|
+
blockers.push(`${iap.productId} is READY_TO_SUBMIT but has ${submittable.length} versions that could go to review; `
|
|
641
|
+
+ 'finish or remove the extra one in App Store Connect.');
|
|
642
|
+
planned.push({ ...base, action: 'blocked' });
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
planned.push({ ...base, action: 'attach', versionId: version.id });
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
if (unfinishedIapStates.has(state)) {
|
|
649
|
+
blockers.push(state === 'PROCESSING_CONTENT'
|
|
650
|
+
? `${iap.productId} is still processing on App Store Connect; plan again later.`
|
|
651
|
+
: `${iap.productId} is ${state}: finish it with "mpgd store products apply --store app-store" (or in App `
|
|
652
|
+
+ `Store Connect), or leave it out of this submission with --skip-iap ${iap.productId}.`);
|
|
653
|
+
planned.push({ ...base, action: 'blocked' });
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
planned.push({ ...base, action: 'none' });
|
|
657
|
+
}
|
|
658
|
+
remote.iapVersions = iapVersions;
|
|
659
|
+
// Apple reviews an app's first purchase of each type only with an app version.
|
|
660
|
+
for (const type of [...new Set(iaps.map((iap) => iap.inAppPurchaseType))].sort()) {
|
|
661
|
+
const reviewed = iaps.some((iap) => iap.inAppPurchaseType === type && reviewedIapStates.has(String(iap.state)));
|
|
662
|
+
if (reviewed) {
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const going = planned.filter((iap) => iap.type === type && iap.action === 'attach');
|
|
666
|
+
if (going.length > 0) {
|
|
667
|
+
notes.push(`${going.map((iap) => iap.productId).join(', ')}: the app's first ${type.toLowerCase().replace(/_/gu, '-')} `
|
|
668
|
+
+ 'purchase(s), which App Review takes only together with an app version; they go with this one.');
|
|
669
|
+
}
|
|
670
|
+
else if (planned.some((iap) => iap.type === type && iap.action === 'skip')) {
|
|
671
|
+
notes.push(`No ${type.toLowerCase().replace(/_/gu, '-')} purchase goes with this version: the skipped ones can be sold only `
|
|
672
|
+
+ 'after they are submitted with a later version.');
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return planned;
|
|
676
|
+
}
|
|
677
|
+
function planSubmission(input) {
|
|
678
|
+
const live = (submissionId) => [...(input.items.get(submissionId) ?? [])].filter((item) => item.state !== 'REMOVED');
|
|
679
|
+
const plannedIapVersions = new Set(input.iaps.flatMap((iap) => iap.versionId === undefined ? [] : [iap.versionId]));
|
|
680
|
+
const holding = input.openSubmissions.find((submission) => live(submission.id).some((item) => item.appStoreVersionId === input.versionId));
|
|
681
|
+
const draft = holding ?? input.openSubmissions.find((submission) => submission.state === 'READY_FOR_REVIEW');
|
|
682
|
+
for (const submission of input.openSubmissions) {
|
|
683
|
+
if (submission.id === draft?.id) {
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
input.blockers.push(`Review submission ${submission.id} is ${submission.state}: App Store Connect reviews one iOS submission at `
|
|
687
|
+
+ 'a time. Wait for it to finish (or cancel it in App Store Connect) before submitting this version.');
|
|
688
|
+
}
|
|
689
|
+
if (draft === undefined) {
|
|
690
|
+
return {
|
|
691
|
+
plan: { action: 'create', addVersion: true, submit: true },
|
|
692
|
+
attachedIapVersions: new Set(),
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
if (draft.state === 'UNRESOLVED_ISSUES') {
|
|
696
|
+
input.blockers.push(`Review submission ${draft.id} has unresolved issues from App Review: read the message in App Store Connect, `
|
|
697
|
+
+ 'fix the version, and resubmit it there (the kit does not resubmit a returned submission).');
|
|
698
|
+
}
|
|
699
|
+
else if (draft.state === 'CANCELING' || draft.state === 'COMPLETING') {
|
|
700
|
+
input.blockers.push(`Review submission ${draft.id} is ${draft.state}; plan again in a few minutes.`);
|
|
701
|
+
}
|
|
702
|
+
const items = live(draft.id);
|
|
703
|
+
// In-app purchase items belong to this app's version review even when the plan
|
|
704
|
+
// did not add them (added by hand after a refused add, say); anything else does not.
|
|
705
|
+
const unrelated = items.filter((item) => item.appStoreVersionId !== input.versionId
|
|
706
|
+
&& item.inAppPurchaseVersionId === undefined);
|
|
707
|
+
const byHand = items.filter((item) => item.inAppPurchaseVersionId !== undefined
|
|
708
|
+
&& !plannedIapVersions.has(item.inAppPurchaseVersionId));
|
|
709
|
+
if (byHand.length > 0) {
|
|
710
|
+
input.notes.push(`Review submission ${draft.id} also holds ${byHand.length} in-app purchase item(s) the plan did not add (${byHand.map((item) => item.id).join(', ')}); they are submitted with the version.`);
|
|
711
|
+
}
|
|
712
|
+
if (unrelated.length > 0 && draft.state === 'READY_FOR_REVIEW') {
|
|
713
|
+
input.blockers.push(`Review submission ${draft.id} already holds ${unrelated.length} other item(s) (${unrelated.map((item) => item.id).join(', ')}); remove them in App Store Connect or submit them first.`);
|
|
714
|
+
}
|
|
715
|
+
const attachedIapVersions = new Set(items.flatMap((item) => item.inAppPurchaseVersionId === undefined ? [] : [item.inAppPurchaseVersionId]));
|
|
716
|
+
const submitted = draft.state === 'WAITING_FOR_REVIEW' || draft.state === 'IN_REVIEW';
|
|
717
|
+
return {
|
|
718
|
+
plan: {
|
|
719
|
+
action: submitted ? 'submitted' : 'reuse',
|
|
720
|
+
id: draft.id,
|
|
721
|
+
state: draft.state,
|
|
722
|
+
addVersion: !items.some((item) => item.appStoreVersionId === input.versionId),
|
|
723
|
+
submit: !submitted,
|
|
724
|
+
},
|
|
725
|
+
attachedIapVersions,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
function finish(input) {
|
|
729
|
+
const iaps = input.iaps ?? [];
|
|
730
|
+
const submission = input.submission ?? { action: 'none', addVersion: false, submit: false };
|
|
731
|
+
const reviewDetail = input.reviewDetail ?? { action: 'unchanged', fields: [] };
|
|
732
|
+
const phasedRelease = input.phasedRelease ?? { action: 'unchanged' };
|
|
733
|
+
const whatsNew = input.whatsNew ?? [];
|
|
734
|
+
const plan = {
|
|
735
|
+
schemaVersion: 1,
|
|
736
|
+
store: 'app-store',
|
|
737
|
+
...input.base,
|
|
738
|
+
stage: input.stage,
|
|
739
|
+
version: input.version,
|
|
740
|
+
...(input.versionUpdate === undefined ? {} : { versionUpdate: input.versionUpdate }),
|
|
741
|
+
...(input.build === undefined ? {} : { build: input.build }),
|
|
742
|
+
whatsNew,
|
|
743
|
+
reviewDetail,
|
|
744
|
+
phasedRelease,
|
|
745
|
+
iaps,
|
|
746
|
+
submission,
|
|
747
|
+
};
|
|
748
|
+
const operations = describeOperations({ ...plan, writes: input.writes, request: input.request });
|
|
749
|
+
const desired = {
|
|
750
|
+
request: input.request,
|
|
751
|
+
copyright: input.versionUpdate?.copyright ?? null,
|
|
752
|
+
whatsNew: input.writes.whatsNew,
|
|
753
|
+
reviewDetail: input.writes.reviewDetail === undefined
|
|
754
|
+
? null
|
|
755
|
+
: {
|
|
756
|
+
...input.writes.reviewDetail,
|
|
757
|
+
fields: {
|
|
758
|
+
...input.writes.reviewDetail.fields,
|
|
759
|
+
demoAccountName: input.writes.reviewDetail.fields.demoAccountName === undefined
|
|
760
|
+
? undefined
|
|
761
|
+
: sha256(input.writes.reviewDetail.fields.demoAccountName),
|
|
762
|
+
demoAccountPassword: input.writes.reviewDetail.fields.demoAccountPassword === undefined
|
|
763
|
+
? undefined
|
|
764
|
+
: 'set',
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
demoAccountName: input.demoAccount === undefined ? null : sha256(input.demoAccount.name),
|
|
768
|
+
operations,
|
|
769
|
+
};
|
|
770
|
+
return {
|
|
771
|
+
plan: {
|
|
772
|
+
...plan,
|
|
773
|
+
operations,
|
|
774
|
+
blockers: input.blockers,
|
|
775
|
+
notes: input.notes,
|
|
776
|
+
manual: manualSteps,
|
|
777
|
+
changed: operations.length > 0,
|
|
778
|
+
remoteSha256: stableSha256(input.remote),
|
|
779
|
+
desiredSha256: stableSha256(desired),
|
|
780
|
+
},
|
|
781
|
+
writes: input.writes,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
function describeOperations(plan) {
|
|
785
|
+
const operations = [];
|
|
786
|
+
const { writes } = plan;
|
|
787
|
+
if (writes.createVersion !== undefined) {
|
|
788
|
+
operations.push(`create iOS version ${plan.marketingVersion} (release ${writes.createVersion.releaseType}${writes.createVersion.copyright === undefined ? '' : `, copyright "${writes.createVersion.copyright}"`})`);
|
|
789
|
+
return operations;
|
|
790
|
+
}
|
|
791
|
+
if (writes.renameVersion !== undefined) {
|
|
792
|
+
operations.push(`rename iOS version ${String(plan.version.from)} to ${writes.renameVersion}`);
|
|
793
|
+
}
|
|
794
|
+
if (plan.versionUpdate !== undefined) {
|
|
795
|
+
const parts = [
|
|
796
|
+
plan.versionUpdate.copyright === undefined ? undefined : `copyright "${plan.versionUpdate.copyright}"`,
|
|
797
|
+
plan.versionUpdate.releaseType === undefined ? undefined : `release ${plan.versionUpdate.releaseType}`,
|
|
798
|
+
plan.versionUpdate.earliestReleaseDate === undefined
|
|
799
|
+
? undefined
|
|
800
|
+
: `not before ${plan.versionUpdate.earliestReleaseDate}`,
|
|
801
|
+
].filter((part) => part !== undefined);
|
|
802
|
+
operations.push(`update version settings: ${parts.join(', ')}`);
|
|
803
|
+
}
|
|
804
|
+
if (plan.build?.action === 'attach') {
|
|
805
|
+
operations.push(`attach build ${plan.build.buildNumber} (${plan.build.id})${plan.build.replaces === undefined ? '' : `, replacing build ${plan.build.replaces}`}`);
|
|
806
|
+
}
|
|
807
|
+
for (const entry of plan.whatsNew) {
|
|
808
|
+
operations.push(`write What's New for ${entry.locale} (${entry.change.toLength} characters)`);
|
|
809
|
+
}
|
|
810
|
+
if (plan.reviewDetail.action !== 'unchanged') {
|
|
811
|
+
operations.push(`${plan.reviewDetail.action} App Review information: ${plan.reviewDetail.fields.join(', ')}`);
|
|
812
|
+
}
|
|
813
|
+
if (plan.phasedRelease.action === 'create') {
|
|
814
|
+
operations.push('turn on phased release (7 days, starting when the version is released)');
|
|
815
|
+
}
|
|
816
|
+
else if (plan.phasedRelease.action === 'delete') {
|
|
817
|
+
operations.push('turn off phased release');
|
|
818
|
+
}
|
|
819
|
+
if (plan.submission.action === 'create') {
|
|
820
|
+
operations.push('create an iOS review submission');
|
|
821
|
+
}
|
|
822
|
+
if (plan.submission.addVersion) {
|
|
823
|
+
operations.push(`add version ${plan.marketingVersion} to the review submission`);
|
|
824
|
+
}
|
|
825
|
+
for (const iap of plan.iaps) {
|
|
826
|
+
if (iap.action === 'attach') {
|
|
827
|
+
operations.push(`add in-app purchase ${iap.productId} (version ${String(iap.versionId)}) to the review submission`);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (plan.submission.submit) {
|
|
831
|
+
operations.push('submit for App Review');
|
|
832
|
+
}
|
|
833
|
+
return operations;
|
|
834
|
+
}
|
|
835
|
+
export class AppStoreReleaseApplyError extends Error {
|
|
836
|
+
stage;
|
|
837
|
+
written;
|
|
838
|
+
definitive;
|
|
839
|
+
detail;
|
|
840
|
+
constructor(stage,
|
|
841
|
+
/** The writes that landed before the failure. */
|
|
842
|
+
written, cause,
|
|
843
|
+
/** App Store Connect refused the step (asc exited with a 4xx-class code): nothing from it landed. */
|
|
844
|
+
definitive = false,
|
|
845
|
+
/** What to do instead of re-running the same plan, when re-running cannot help. */
|
|
846
|
+
detail) {
|
|
847
|
+
super(`App Store Connect ${stage} ${definitive ? 'failed; nothing from this step landed' : 'failed or its result is unknown'} (${cause instanceof Error ? cause.message : String(cause)}). Written before it: ${written.join('; ') || 'nothing'}. ${detail ?? `Run "${appStoreReleasePlanCommand}" again: it reads App Store `
|
|
848
|
+
+ 'Connect, finds what already landed (the version, its build, an open submission) and lists what is left.'}`, { cause });
|
|
849
|
+
this.stage = stage;
|
|
850
|
+
this.written = written;
|
|
851
|
+
this.definitive = definitive;
|
|
852
|
+
this.detail = detail;
|
|
853
|
+
this.name = 'AppStoreReleaseApplyError';
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
/** Refuse a plan with blockers, with every blocker named. */
|
|
857
|
+
export function assertNoBlockers(plan) {
|
|
858
|
+
if (plan.blockers.length > 0) {
|
|
859
|
+
throw new Error(`The release plan has ${plan.blockers.length} blocker(s); fix them and plan again:\n${plan.blockers.map((blocker) => `- ${blocker}`).join('\n')}`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Apply a reviewed plan: re-plan, refuse when App Store Connect or the files
|
|
864
|
+
* moved past it or it has blockers, then write step by step. A plan whose
|
|
865
|
+
* changes already landed is a no-op, so a re-run is safe. `dryRun` stops
|
|
866
|
+
* after the checks and returns the writes it would make.
|
|
867
|
+
*/
|
|
868
|
+
export async function applyAppStoreRelease(input) {
|
|
869
|
+
const { saved, ports } = input;
|
|
870
|
+
const { appId } = input.plan;
|
|
871
|
+
const dryRun = input.dryRun === true;
|
|
872
|
+
if (!input.approved && !dryRun) {
|
|
873
|
+
throw new Error('Submitting the App Store release requires --approve.');
|
|
874
|
+
}
|
|
875
|
+
if (saved.appId !== appId || saved.bundleId !== input.plan.bundleId
|
|
876
|
+
|| saved.marketingVersion !== input.plan.request.marketingVersion) {
|
|
877
|
+
throw new StorePlanStaleError(`The deployment profile or request now names app ${appId} (${input.plan.bundleId}) version `
|
|
878
|
+
+ `${input.plan.request.marketingVersion}, not the reviewed plan's.`, appStoreReleasePlanCommand);
|
|
879
|
+
}
|
|
880
|
+
const { plan: current, writes } = await planWithWrites(input.plan, ports);
|
|
881
|
+
const base = { appId, marketingVersion: current.marketingVersion, manual: current.manual };
|
|
882
|
+
if (!current.changed) {
|
|
883
|
+
return {
|
|
884
|
+
...base,
|
|
885
|
+
status: 'unchanged',
|
|
886
|
+
steps: [],
|
|
887
|
+
...(current.version.id === undefined ? {} : { versionId: current.version.id }),
|
|
888
|
+
...(current.submission.id === undefined ? {} : { submissionId: current.submission.id }),
|
|
889
|
+
next: current.notes,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
if (current.remoteSha256 !== saved.remoteSha256 || current.desiredSha256 !== saved.desiredSha256) {
|
|
893
|
+
throw new StorePlanStaleError('App Store Connect, the store presence file or the demo account changed since the plan.', appStoreReleasePlanCommand);
|
|
894
|
+
}
|
|
895
|
+
assertNoBlockers(current);
|
|
896
|
+
if (dryRun) {
|
|
897
|
+
return {
|
|
898
|
+
...base,
|
|
899
|
+
status: 'dry-run',
|
|
900
|
+
steps: current.operations,
|
|
901
|
+
...(current.version.id === undefined ? {} : { versionId: current.version.id }),
|
|
902
|
+
next: [],
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
const steps = [];
|
|
906
|
+
let stage = 'start';
|
|
907
|
+
const step = async (name, action) => {
|
|
908
|
+
stage = name;
|
|
909
|
+
const result = await action();
|
|
910
|
+
steps.push(name);
|
|
911
|
+
return result;
|
|
912
|
+
};
|
|
913
|
+
try {
|
|
914
|
+
if (writes.createVersion !== undefined) {
|
|
915
|
+
const create = writes.createVersion;
|
|
916
|
+
const versionId = await step(`create version ${current.marketingVersion}`, () => ports.release.createVersion({
|
|
917
|
+
appId,
|
|
918
|
+
versionString: current.marketingVersion,
|
|
919
|
+
releaseType: create.releaseType,
|
|
920
|
+
...(create.copyright === undefined ? {} : { copyright: create.copyright }),
|
|
921
|
+
}));
|
|
922
|
+
return {
|
|
923
|
+
...base,
|
|
924
|
+
status: 'created-version',
|
|
925
|
+
steps,
|
|
926
|
+
versionId,
|
|
927
|
+
next: [
|
|
928
|
+
`Run "mpgd store presence plan --store app-store --out <plan>" and apply it to fill version `
|
|
929
|
+
+ `${current.marketingVersion}'s store page.`,
|
|
930
|
+
`Then run "${appStoreReleasePlanCommand}" again for the build, App Review information and the submission.`,
|
|
931
|
+
],
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
const versionId = current.version.id;
|
|
935
|
+
if (versionId === undefined) {
|
|
936
|
+
throw new Error('The plan has no version to prepare.');
|
|
937
|
+
}
|
|
938
|
+
if (writes.renameVersion !== undefined) {
|
|
939
|
+
const versionString = writes.renameVersion;
|
|
940
|
+
await step(`rename version to ${versionString}`, () => ports.release.updateVersion({ versionId, versionString }));
|
|
941
|
+
}
|
|
942
|
+
if (current.versionUpdate !== undefined) {
|
|
943
|
+
const update = current.versionUpdate;
|
|
944
|
+
await step('version settings', () => ports.release.updateVersion({ versionId, ...update }));
|
|
945
|
+
}
|
|
946
|
+
if (current.build?.action === 'attach') {
|
|
947
|
+
const buildId = current.build.id;
|
|
948
|
+
await step(`attach build ${current.build.buildNumber}`, () => ports.release.attachBuild({ versionId, buildId }));
|
|
949
|
+
}
|
|
950
|
+
for (const entry of writes.whatsNew) {
|
|
951
|
+
await step(`What's New ${entry.locale}`, () => ports.presence.updateVersionLocalization({
|
|
952
|
+
localizationId: entry.localizationId,
|
|
953
|
+
fields: { whatsNew: entry.text },
|
|
954
|
+
}));
|
|
955
|
+
}
|
|
956
|
+
const review = writes.reviewDetail;
|
|
957
|
+
if (review !== undefined) {
|
|
958
|
+
if (review.action === 'create') {
|
|
959
|
+
await step('App Review information create', () => ports.release.createReviewDetail({ versionId, fields: review.fields }));
|
|
960
|
+
}
|
|
961
|
+
else if (review.id !== undefined) {
|
|
962
|
+
const detailId = review.id;
|
|
963
|
+
await step('App Review information update', () => ports.release.updateReviewDetail({ detailId, fields: review.fields }));
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
if (current.phasedRelease.action === 'create') {
|
|
967
|
+
await step('phased release on', () => ports.release.createPhasedRelease(versionId));
|
|
968
|
+
}
|
|
969
|
+
else if (current.phasedRelease.action === 'delete' && current.phasedRelease.id !== undefined) {
|
|
970
|
+
const phasedId = current.phasedRelease.id;
|
|
971
|
+
await step('phased release off', () => ports.release.deletePhasedRelease(phasedId));
|
|
972
|
+
}
|
|
973
|
+
let submissionId = current.submission.id;
|
|
974
|
+
if (current.submission.action === 'create') {
|
|
975
|
+
submissionId = await step('create review submission', () => ports.release.createReviewSubmission(appId));
|
|
976
|
+
}
|
|
977
|
+
if (submissionId === undefined) {
|
|
978
|
+
throw new Error('The plan has no review submission.');
|
|
979
|
+
}
|
|
980
|
+
const id = submissionId;
|
|
981
|
+
if (current.submission.addVersion) {
|
|
982
|
+
await step(`add version ${current.marketingVersion} to submission ${id}`, () => ports.release.addReviewSubmissionItem({
|
|
983
|
+
submissionId: id,
|
|
984
|
+
type: 'appStoreVersions',
|
|
985
|
+
id: versionId,
|
|
986
|
+
}));
|
|
987
|
+
}
|
|
988
|
+
// An `inAppPurchaseVersions` item: ReviewSubmissionItemCreateRequest.Data.Relationships
|
|
989
|
+
// has `inAppPurchaseVersion` (App Store Connect API, read 2026-10-31), and asc 5.5.0
|
|
990
|
+
// names the type ReviewSubmissionItemTypeInAppPurchaseVersion in
|
|
991
|
+
// internal/asc/review_submission_items.go (`iap versions submit` posts the same item).
|
|
992
|
+
// Not yet run against a live account: a refusal here would recur on every re-plan,
|
|
993
|
+
// so it names the way out instead of "plan again".
|
|
994
|
+
const attaching = current.iaps.filter((iap) => iap.action === 'attach' && iap.versionId !== undefined);
|
|
995
|
+
for (const [index, iap] of attaching.entries()) {
|
|
996
|
+
const iapVersionId = String(iap.versionId);
|
|
997
|
+
try {
|
|
998
|
+
await step(`add in-app purchase ${iap.productId} to submission ${id}`, () => ports.release.addReviewSubmissionItem({
|
|
999
|
+
submissionId: id,
|
|
1000
|
+
type: 'inAppPurchaseVersions',
|
|
1001
|
+
id: iapVersionId,
|
|
1002
|
+
}));
|
|
1003
|
+
}
|
|
1004
|
+
catch (error) {
|
|
1005
|
+
if (!isDefinitiveAscFailure(error)) {
|
|
1006
|
+
throw error;
|
|
1007
|
+
}
|
|
1008
|
+
const left = attaching.slice(index).map((entry) => entry.productId);
|
|
1009
|
+
throw new AppStoreReleaseApplyError(stage, steps, error, true, `App Store Connect refused the in-app purchase item, so the same plan would fail here again. Add ${left.join(', ')} to version ${current.marketingVersion} by hand in App Store Connect (the version `
|
|
1010
|
+
+ 'page, In-App Purchases and Subscriptions), or leave them for a later version; then run '
|
|
1011
|
+
+ `"${appStoreReleasePlanCommand} --skip-iap ${left.join(',')}" and apply it to submit the version with `
|
|
1012
|
+
+ 'what is already in the submission.');
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
if (current.submission.submit) {
|
|
1016
|
+
await step(`submit ${id} for review`, () => ports.release.submitReviewSubmission(id));
|
|
1017
|
+
}
|
|
1018
|
+
return {
|
|
1019
|
+
...base,
|
|
1020
|
+
status: 'submitted',
|
|
1021
|
+
steps,
|
|
1022
|
+
versionId,
|
|
1023
|
+
submissionId: id,
|
|
1024
|
+
next: ['Follow the review with "mpgd store release status".'],
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
catch (error) {
|
|
1028
|
+
if (error instanceof AppStoreReleaseApplyError) {
|
|
1029
|
+
throw error;
|
|
1030
|
+
}
|
|
1031
|
+
throw new AppStoreReleaseApplyError(stage, steps, error, isDefinitiveAscFailure(error));
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* asc 5.5.0 exit codes (`cmd/exit_codes.go`): 2 usage, 3 auth, 4 not found,
|
|
1036
|
+
* 5 conflict, and 10 + (status - 400) for other 4xx answers. Those are App
|
|
1037
|
+
* Store Connect (or asc) refusing the request; 1 (generic, including network
|
|
1038
|
+
* failures), 5xx codes, a timeout or an abort leave the result unknown.
|
|
1039
|
+
*/
|
|
1040
|
+
function isDefinitiveAscFailure(error) {
|
|
1041
|
+
if (!(error instanceof ReleaseProcessError) || error.reason !== 'exit' || error.exitCode === undefined) {
|
|
1042
|
+
return false;
|
|
1043
|
+
}
|
|
1044
|
+
const code = error.exitCode;
|
|
1045
|
+
const timeoutOrRateLimit = code === 10 + (408 - 400) || code === 10 + (429 - 400);
|
|
1046
|
+
return (code >= 2 && code <= 5) || (code >= 10 && code < 60 && !timeoutOrRateLimit);
|
|
1047
|
+
}
|
|
1048
|
+
/** The plan for the terminal. */
|
|
1049
|
+
export function formatAppStoreReleasePlan(plan) {
|
|
1050
|
+
const lines = [
|
|
1051
|
+
`App Store Connect app ${plan.appId} (${plan.bundleId}), iOS version ${plan.marketingVersion}: ${{
|
|
1052
|
+
'create-version': 'create the version',
|
|
1053
|
+
prepare: plan.changed ? 'prepare and submit for review' : 'nothing to change',
|
|
1054
|
+
submitted: 'already submitted',
|
|
1055
|
+
approved: 'approved',
|
|
1056
|
+
released: 'already released',
|
|
1057
|
+
}[plan.stage]}${plan.version.state === undefined ? '' : ` (${plan.version.state})`}`,
|
|
1058
|
+
];
|
|
1059
|
+
if (plan.build !== undefined) {
|
|
1060
|
+
lines.push(` build ${plan.build.buildNumber} (${plan.build.id})${plan.build.action === 'unchanged' ? ', attached' : ''}`);
|
|
1061
|
+
}
|
|
1062
|
+
for (const iap of plan.iaps) {
|
|
1063
|
+
if (iap.action !== 'none') {
|
|
1064
|
+
lines.push(` iap ${iap.productId} ${iap.state}: ${iap.action}`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
for (const [index, operation] of plan.operations.entries()) {
|
|
1068
|
+
lines.push(` ${String(index + 1).padStart(2)}. ${operation}`);
|
|
1069
|
+
}
|
|
1070
|
+
for (const blocker of plan.blockers) {
|
|
1071
|
+
lines.push(` BLOCKED ${blocker}`);
|
|
1072
|
+
}
|
|
1073
|
+
for (const note of plan.notes) {
|
|
1074
|
+
lines.push(` note ${note}`);
|
|
1075
|
+
}
|
|
1076
|
+
for (const step of plan.manual) {
|
|
1077
|
+
lines.push(` manual ${step}`);
|
|
1078
|
+
}
|
|
1079
|
+
return lines.join('\n');
|
|
1080
|
+
}
|
|
1081
|
+
/** Where a version stands: its state, build, review submission and phased release. Reads only. */
|
|
1082
|
+
export async function readAppStoreReleaseStatus(input, port) {
|
|
1083
|
+
const app = await port.readApp(input.appId);
|
|
1084
|
+
if (app.bundleId !== input.bundleId) {
|
|
1085
|
+
throw new Error(`App Store Connect app ${input.appId} has bundle ID ${app.bundleId}, not ${input.bundleId} from the deployment profile.`);
|
|
1086
|
+
}
|
|
1087
|
+
const versions = await port.listVersions(input.appId);
|
|
1088
|
+
const version = input.marketingVersion === undefined
|
|
1089
|
+
? [...versions].sort((left, right) => String(right.createdDate ?? '').localeCompare(String(left.createdDate ?? ''))
|
|
1090
|
+
// Without dates, the version not yet released is the one being worked on.
|
|
1091
|
+
|| Number(inStates(left, releasedVersionStates)) - Number(inStates(right, releasedVersionStates)))[0]
|
|
1092
|
+
: versions.find((candidate) => candidate.versionString === input.marketingVersion);
|
|
1093
|
+
if (version === undefined) {
|
|
1094
|
+
return {
|
|
1095
|
+
appId: input.appId,
|
|
1096
|
+
summary: input.marketingVersion === undefined
|
|
1097
|
+
? 'The app has no iOS version on App Store Connect yet.'
|
|
1098
|
+
: `iOS version ${input.marketingVersion} does not exist yet: "${appStoreReleasePlanCommand}" creates it.`,
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
const [build, phased, submissions] = await Promise.all([
|
|
1102
|
+
port.readAttachedBuild(version.id),
|
|
1103
|
+
port.readPhasedRelease(version.id),
|
|
1104
|
+
port.listReviewSubmissions(input.appId),
|
|
1105
|
+
]);
|
|
1106
|
+
// The submission holding this version: every open one, then the newest
|
|
1107
|
+
// completed ones, at most `statusCompletedSubmissions` of them and none
|
|
1108
|
+
// submitted before the version was created (one asc call per submission).
|
|
1109
|
+
let submission;
|
|
1110
|
+
const open = submissions.filter((candidate) => candidate.state !== 'COMPLETE');
|
|
1111
|
+
const completed = submissions
|
|
1112
|
+
.filter((candidate) => candidate.state === 'COMPLETE')
|
|
1113
|
+
.sort((left, right) => String(right.submittedDate ?? '').localeCompare(String(left.submittedDate ?? '')));
|
|
1114
|
+
const createdAt = Date.parse(version.createdDate ?? '');
|
|
1115
|
+
const recent = completed.filter((candidate) => !Number.isFinite(createdAt)
|
|
1116
|
+
|| !Number.isFinite(Date.parse(candidate.submittedDate ?? ''))
|
|
1117
|
+
|| Date.parse(candidate.submittedDate ?? '') >= createdAt);
|
|
1118
|
+
const searched = [...open, ...recent.slice(0, statusCompletedSubmissions)];
|
|
1119
|
+
for (const candidate of searched) {
|
|
1120
|
+
const items = await port.listReviewSubmissionItems(candidate.id);
|
|
1121
|
+
if (items.some((item) => item.appStoreVersionId === version.id)) {
|
|
1122
|
+
submission = {
|
|
1123
|
+
id: candidate.id,
|
|
1124
|
+
state: candidate.state,
|
|
1125
|
+
...(candidate.submittedDate === undefined ? {} : { submittedDate: candidate.submittedDate }),
|
|
1126
|
+
items,
|
|
1127
|
+
};
|
|
1128
|
+
break;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
const unsearched = submissions.length - searched.length;
|
|
1132
|
+
const state = versionState(version);
|
|
1133
|
+
return {
|
|
1134
|
+
appId: input.appId,
|
|
1135
|
+
version: {
|
|
1136
|
+
id: version.id,
|
|
1137
|
+
versionString: version.versionString,
|
|
1138
|
+
state,
|
|
1139
|
+
...(version.releaseType === undefined ? {} : { releaseType: version.releaseType }),
|
|
1140
|
+
...(version.earliestReleaseDate === undefined ? {} : { earliestReleaseDate: version.earliestReleaseDate }),
|
|
1141
|
+
...(build === undefined ? {} : { build }),
|
|
1142
|
+
},
|
|
1143
|
+
...(submission === undefined ? {} : { submission }),
|
|
1144
|
+
...(phased === undefined ? {} : { phasedRelease: phased }),
|
|
1145
|
+
summary: `${summarize(version, state, submission, phased)}${submission === undefined && unsearched > 0
|
|
1146
|
+
? ` No review submission holding it among the open ones and the ${statusCompletedSubmissions} newest `
|
|
1147
|
+
+ `completed ones since it was created; ${unsearched} older submission(s) were not read.`
|
|
1148
|
+
: ''}`,
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
/** Completed review submissions `status` reads at most, newest first. */
|
|
1152
|
+
const statusCompletedSubmissions = 5;
|
|
1153
|
+
function summarize(version, state, submission, phased) {
|
|
1154
|
+
const name = `Version ${version.versionString}`;
|
|
1155
|
+
if (submission?.state === 'UNRESOLVED_ISSUES' || state === 'REJECTED' || state === 'METADATA_REJECTED') {
|
|
1156
|
+
return `${name} was returned by App Review: read the message in App Store Connect, fix it, and resubmit there.`;
|
|
1157
|
+
}
|
|
1158
|
+
if (inStates(version, editableVersionStates)) {
|
|
1159
|
+
return submission?.state === 'READY_FOR_REVIEW'
|
|
1160
|
+
? `${name} is in preparation with an unsubmitted review submission ${submission.id}; apply the release plan to submit it.`
|
|
1161
|
+
: `${name} is in preparation (${state}); "${appStoreReleasePlanCommand}" lists what is left before review.`;
|
|
1162
|
+
}
|
|
1163
|
+
if (state === 'WAITING_FOR_REVIEW') {
|
|
1164
|
+
return `${name} is waiting for App Review${submission?.submittedDate === undefined ? '' : ` (submitted ${submission.submittedDate})`}.`;
|
|
1165
|
+
}
|
|
1166
|
+
if (state === 'IN_REVIEW') {
|
|
1167
|
+
return `${name} is in review.`;
|
|
1168
|
+
}
|
|
1169
|
+
if (state === 'WAITING_FOR_EXPORT_COMPLIANCE') {
|
|
1170
|
+
return `${name} waits for the build's export compliance answer in App Store Connect.`;
|
|
1171
|
+
}
|
|
1172
|
+
if (state === 'PENDING_DEVELOPER_RELEASE') {
|
|
1173
|
+
return `${name} is approved and waits for you: release it with "mpgd store release publish".`;
|
|
1174
|
+
}
|
|
1175
|
+
if (inStates(version, approvedVersionStates)) {
|
|
1176
|
+
return `${name} is approved (${state}); App Store Connect releases it as its release type says.`;
|
|
1177
|
+
}
|
|
1178
|
+
if (inStates(version, releasedVersionStates)) {
|
|
1179
|
+
return phased === undefined || phased.state === 'COMPLETE'
|
|
1180
|
+
? `${name} is released (${state}).`
|
|
1181
|
+
: `${name} is released with a phased release ${phased.state}${phased.currentDayNumber === undefined ? '' : ` on day ${phased.currentDayNumber} of 7`}.`;
|
|
1182
|
+
}
|
|
1183
|
+
return `${name} is ${state}.`;
|
|
1184
|
+
}
|
|
1185
|
+
/** Release an approved version that waits for a manual release. */
|
|
1186
|
+
export async function publishAppStoreVersion(input, port) {
|
|
1187
|
+
if (!input.approved) {
|
|
1188
|
+
throw new Error('Releasing the App Store version requires --approve.');
|
|
1189
|
+
}
|
|
1190
|
+
const version = await findVersion(input, port);
|
|
1191
|
+
const state = versionState(version);
|
|
1192
|
+
if (inStates(version, releasedVersionStates)) {
|
|
1193
|
+
return { status: 'unchanged', versionId: version.id, state };
|
|
1194
|
+
}
|
|
1195
|
+
if (!inStates(version, new Set(['PENDING_DEVELOPER_RELEASE']))) {
|
|
1196
|
+
throw new Error(`Version ${version.versionString} is ${state}; only an approved version in PENDING_DEVELOPER_RELEASE is released by hand.`);
|
|
1197
|
+
}
|
|
1198
|
+
await port.releaseVersion(version.id);
|
|
1199
|
+
return { status: 'released', versionId: version.id, state };
|
|
1200
|
+
}
|
|
1201
|
+
/** Pause, resume or complete the phased release of a released version. */
|
|
1202
|
+
export async function changeAppStorePhasedRelease(input, port) {
|
|
1203
|
+
if (!['pause', 'resume', 'complete'].includes(input.action)) {
|
|
1204
|
+
throw new Error('--action must be pause, resume or complete.');
|
|
1205
|
+
}
|
|
1206
|
+
if (!input.approved) {
|
|
1207
|
+
throw new Error('Changing the phased release requires --approve.');
|
|
1208
|
+
}
|
|
1209
|
+
const version = await findVersion(input, port);
|
|
1210
|
+
const phased = await port.readPhasedRelease(version.id);
|
|
1211
|
+
if (phased === undefined) {
|
|
1212
|
+
throw new Error(`Version ${version.versionString} has no phased release.`);
|
|
1213
|
+
}
|
|
1214
|
+
const target = {
|
|
1215
|
+
pause: 'PAUSED',
|
|
1216
|
+
resume: 'ACTIVE',
|
|
1217
|
+
complete: 'COMPLETE',
|
|
1218
|
+
}[input.action];
|
|
1219
|
+
if (phased.state === target) {
|
|
1220
|
+
return { status: 'unchanged', phasedReleaseId: phased.id, state: phased.state };
|
|
1221
|
+
}
|
|
1222
|
+
const allowed = {
|
|
1223
|
+
PAUSED: ['ACTIVE'],
|
|
1224
|
+
ACTIVE: ['PAUSED'],
|
|
1225
|
+
COMPLETE: ['ACTIVE', 'PAUSED'],
|
|
1226
|
+
};
|
|
1227
|
+
if (!allowed[target].includes(phased.state)) {
|
|
1228
|
+
throw new Error(phased.state === 'INACTIVE'
|
|
1229
|
+
? `The phased release of ${version.versionString} has not started; it starts when the version is released.`
|
|
1230
|
+
: `The phased release of ${version.versionString} is ${phased.state}; it cannot ${input.action}.`);
|
|
1231
|
+
}
|
|
1232
|
+
await port.updatePhasedRelease({ phasedReleaseId: phased.id, state: target });
|
|
1233
|
+
return { status: 'updated', phasedReleaseId: phased.id, state: target };
|
|
1234
|
+
}
|
|
1235
|
+
async function findVersion(input, port) {
|
|
1236
|
+
const app = await port.readApp(input.appId);
|
|
1237
|
+
if (app.bundleId !== input.bundleId) {
|
|
1238
|
+
throw new Error(`App Store Connect app ${input.appId} has bundle ID ${app.bundleId}, not ${input.bundleId} from the deployment profile.`);
|
|
1239
|
+
}
|
|
1240
|
+
const version = (await port.listVersions(input.appId))
|
|
1241
|
+
.find((candidate) => candidate.versionString === input.marketingVersion);
|
|
1242
|
+
if (version === undefined) {
|
|
1243
|
+
throw new Error(`iOS version ${input.marketingVersion} is not on App Store Connect.`);
|
|
1244
|
+
}
|
|
1245
|
+
return version;
|
|
1246
|
+
}
|