@mpgd/cli 0.67.0 → 0.68.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 +13 -13
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isRecord } from './asc-session.js';
|
|
4
|
+
import { assertLocalizedNotes, readLocalizedNotesFile, } from './play-release-options.js';
|
|
5
|
+
import { classifyInternalBuildState } from './testflight-submission.js';
|
|
6
|
+
/** App Store Connect `BetaAppReviewDetail.notes`: "a maximum of 4,000 characters". */
|
|
7
|
+
export const maximumBetaReviewNotesCharacters = 4000;
|
|
8
|
+
/** Waits before each re-read of the group membership after `builds add-groups`. */
|
|
9
|
+
const membershipRetryDelaysMs = [2_000, 5_000, 10_000];
|
|
10
|
+
const groupReferencePattern = /^[^,\s](?:[^,]{0,98}[^,\s])?$/u;
|
|
11
|
+
const environmentNamePattern = /^[A-Z_][A-Z0-9_]*$/u;
|
|
12
|
+
const exportComplianceDetail = 'TestFlight is waiting for the export-compliance answer: answer it in App Store '
|
|
13
|
+
+ 'Connect, or ship ITSAppUsesNonExemptEncryption in Info.plist for later builds.';
|
|
14
|
+
/** Read App Store Connect and print what `applyTestFlightDistribution` would write. */
|
|
15
|
+
export async function planTestFlightDistribution(port, request) {
|
|
16
|
+
return toReport(await planDistribution(port, request), true);
|
|
17
|
+
}
|
|
18
|
+
/** Write the differences the plan found: What to Test, groups, review details, submission. */
|
|
19
|
+
export async function applyTestFlightDistribution(port, request, options = {}) {
|
|
20
|
+
const unresolved = request.betaReview?.demoAccount?.unresolvedEnv ?? [];
|
|
21
|
+
if (unresolved.length > 0) {
|
|
22
|
+
throw new Error(`TestFlight review demo account needs ${unresolved.join(', ')} in the environment.`);
|
|
23
|
+
}
|
|
24
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
25
|
+
const plan = await planDistribution(port, request);
|
|
26
|
+
if (plan.blocked) {
|
|
27
|
+
return toReport(plan, false);
|
|
28
|
+
}
|
|
29
|
+
const written = [];
|
|
30
|
+
const step = async (label, action) => {
|
|
31
|
+
let result;
|
|
32
|
+
try {
|
|
33
|
+
result = await action();
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
const done = written.length === 0 ? 'nothing' : written.join(', ');
|
|
37
|
+
throw new Error(`TestFlight distribution failed at ${label} (already written: ${done}); re-run the same `
|
|
38
|
+
+ `command, which skips what is already done: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
39
|
+
}
|
|
40
|
+
written.push(label);
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
for (const note of plan.whatToTest.filter((entry) => entry.action !== 'unchanged')) {
|
|
44
|
+
await step(`What to Test (${note.locale})`, () => port.putWhatToTest(plan.build.id, note.locale, note.text));
|
|
45
|
+
}
|
|
46
|
+
const toAdd = plan.groups.filter((group) => group.membership === 'add');
|
|
47
|
+
if (toAdd.length > 0) {
|
|
48
|
+
await step('group assignment', async () => {
|
|
49
|
+
await port.addBuildToGroups(plan.build.id, toAdd.map((group) => group.group.id));
|
|
50
|
+
// App Store Connect can list a new membership a moment late: read it a
|
|
51
|
+
// few times before treating the assignment as unconfirmed.
|
|
52
|
+
let missing = toAdd;
|
|
53
|
+
for (let attempt = 0;; attempt += 1) {
|
|
54
|
+
const present = new Set(await port.listBuildGroupIds(request.appStoreAppId, plan.build.id));
|
|
55
|
+
missing = toAdd.filter((group) => !present.has(group.group.id));
|
|
56
|
+
const delay = membershipRetryDelaysMs[attempt];
|
|
57
|
+
if (missing.length === 0 || delay === undefined) {
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
await sleep(delay);
|
|
61
|
+
}
|
|
62
|
+
if (missing.length > 0) {
|
|
63
|
+
throw new Error(`a membership read does not show the build in ${missing.map((group) => group.group.name).join(', ')}`);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
let betaReview = plan.betaReview;
|
|
68
|
+
let detail = plan.detail;
|
|
69
|
+
let submit = plan.submit;
|
|
70
|
+
// The build's TestFlight states as the report shows them: re-read after the
|
|
71
|
+
// writes that can change them.
|
|
72
|
+
let state = plan.state;
|
|
73
|
+
if (submit) {
|
|
74
|
+
// Adding the external group may have changed the review state; submit
|
|
75
|
+
// only a build that still waits for its first submission.
|
|
76
|
+
state = await port.readBuildBetaState(plan.build.id);
|
|
77
|
+
const after = classifyExternalReview(state.externalBuildState, latestSubmission(await port.listBetaReviewSubmissions(plan.build.id)));
|
|
78
|
+
if (after.kind !== 'status' || after.status !== 'not-submitted') {
|
|
79
|
+
submit = false;
|
|
80
|
+
betaReview = after.kind === 'status' ? { status: after.status } : { status: 'action-required' };
|
|
81
|
+
detail = after.kind === 'status' ? detail : after.detail;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (submit) {
|
|
85
|
+
const detailChanges = plan.reviewDetailChanges;
|
|
86
|
+
if (detailChanges !== undefined && plan.reviewDetailId !== undefined) {
|
|
87
|
+
const detailId = plan.reviewDetailId;
|
|
88
|
+
await step('TestFlight review details', () => port.updateBetaReviewDetail(detailId, detailChanges));
|
|
89
|
+
}
|
|
90
|
+
const submission = await step('TestFlight App Review submission', () => port.submitForBetaReview(plan.build.id));
|
|
91
|
+
betaReview = {
|
|
92
|
+
status: classifySubmissionState(submission.betaReviewState) ?? 'waiting-for-review',
|
|
93
|
+
submissionId: submission.id,
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
state = await port.readBuildBetaState(plan.build.id);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// The submission is done; a failed read keeps the state read just before it.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Review details are written only with a submission; when the recheck
|
|
103
|
+
// cancelled it, none were written.
|
|
104
|
+
const { reviewDetailChanges: _unwrittenChanges, reviewDetailPendingFields: _unwrittenPending, ...writtenPlan } = plan;
|
|
105
|
+
return toReport({
|
|
106
|
+
...(submit ? plan : writtenPlan),
|
|
107
|
+
groups: plan.groups.map((group) => group.membership === 'add' ? { ...group, membership: 'added' } : group),
|
|
108
|
+
state,
|
|
109
|
+
betaReview,
|
|
110
|
+
submit,
|
|
111
|
+
...(detail === undefined ? {} : { detail }),
|
|
112
|
+
}, false);
|
|
113
|
+
}
|
|
114
|
+
/** A read-only status of the build's TestFlight groups and App Review, for CI polling. */
|
|
115
|
+
export async function readTestFlightBuildStatus(port, target) {
|
|
116
|
+
assertBuildTarget(target);
|
|
117
|
+
const base = {
|
|
118
|
+
appStoreAppId: target.appStoreAppId,
|
|
119
|
+
marketingVersion: target.marketingVersion,
|
|
120
|
+
buildNumber: target.buildNumber,
|
|
121
|
+
buildIdRecorded: target.expectedBuildId !== undefined,
|
|
122
|
+
};
|
|
123
|
+
await assertAppBundle(port, target);
|
|
124
|
+
const build = await port.findBuild(target.appStoreAppId, target.marketingVersion, target.buildNumber);
|
|
125
|
+
if (build === undefined) {
|
|
126
|
+
return {
|
|
127
|
+
...base,
|
|
128
|
+
groups: [],
|
|
129
|
+
betaReview: 'build-not-ready',
|
|
130
|
+
detail: 'App Store Connect has no build with this version and build number yet.',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
assertExpectedBuild(target, build.id);
|
|
134
|
+
if (build.processingState !== 'VALID') {
|
|
135
|
+
const processing = build.processingState === 'PROCESSING';
|
|
136
|
+
return {
|
|
137
|
+
...base,
|
|
138
|
+
buildId: build.id,
|
|
139
|
+
processingState: build.processingState,
|
|
140
|
+
groups: [],
|
|
141
|
+
betaReview: processing ? 'build-not-ready' : 'build-unusable',
|
|
142
|
+
detail: processing
|
|
143
|
+
? 'App Store Connect is still processing the build; poll again.'
|
|
144
|
+
: `App Store Connect reports ${build.processingState}; upload a new build.`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// One asc call at a time, like every other asc-backed port.
|
|
148
|
+
const state = await port.readBuildBetaState(build.id);
|
|
149
|
+
const groups = await port.listGroups(target.appStoreAppId);
|
|
150
|
+
const memberIds = await port.listBuildGroupIds(target.appStoreAppId, build.id);
|
|
151
|
+
const submissions = await port.listBetaReviewSubmissions(build.id);
|
|
152
|
+
const members = new Set(memberIds);
|
|
153
|
+
const submission = latestSubmission(submissions);
|
|
154
|
+
const review = classifyExternalReview(state.externalBuildState, submission);
|
|
155
|
+
return {
|
|
156
|
+
...base,
|
|
157
|
+
buildId: build.id,
|
|
158
|
+
processingState: build.processingState,
|
|
159
|
+
internalBuildState: state.internalBuildState,
|
|
160
|
+
externalBuildState: state.externalBuildState,
|
|
161
|
+
groups: groups.filter((group) => members.has(group.id))
|
|
162
|
+
.map((group) => ({ id: group.id, name: group.name, external: !group.isInternal })),
|
|
163
|
+
betaReview: review.kind === 'status'
|
|
164
|
+
? review.status
|
|
165
|
+
: review.kind === 'action-required'
|
|
166
|
+
? 'action-required'
|
|
167
|
+
: review.kind === 'not-ready' ? 'build-not-ready' : 'build-unusable',
|
|
168
|
+
...(submission === undefined ? {} : { submission }),
|
|
169
|
+
...(review.kind === 'status' ? {} : { detail: review.detail }),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Read a TestFlight review details file. Contact fields and notes are plain
|
|
174
|
+
* values; the demo account only names environment variables, which are read
|
|
175
|
+
* here so a sign-in never sits in a committed file.
|
|
176
|
+
*
|
|
177
|
+
* {
|
|
178
|
+
* "contactFirstName": "Ada", "contactLastName": "Kim",
|
|
179
|
+
* "contactEmail": "qa@example.com", "contactPhone": "+82 10 0000 0000",
|
|
180
|
+
* "notes": "Guest play; no sign-in needed.",
|
|
181
|
+
* "demoAccount": { "required": false }
|
|
182
|
+
* }
|
|
183
|
+
*
|
|
184
|
+
* With `"required": true`, `nameEnv` and `passwordEnv` name the variables.
|
|
185
|
+
*/
|
|
186
|
+
export function readTestFlightBetaReviewFile(file, environment = process.env, options = {}) {
|
|
187
|
+
const resolved = path.resolve(file);
|
|
188
|
+
if (!existsSync(resolved) || !statSync(resolved).isFile() || statSync(resolved).size > 64 * 1024) {
|
|
189
|
+
throw new Error(`TestFlight review details file is missing or larger than 64 KiB: ${resolved}`);
|
|
190
|
+
}
|
|
191
|
+
let parsed;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(readFileSync(resolved, 'utf8'));
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
throw new Error(`TestFlight review details file is not valid JSON: ${resolved}`);
|
|
197
|
+
}
|
|
198
|
+
return parseTestFlightBetaReview(parsed, environment, options);
|
|
199
|
+
}
|
|
200
|
+
export function parseTestFlightBetaReview(value, environment = process.env, options = {}) {
|
|
201
|
+
const textKeys = [
|
|
202
|
+
'contactFirstName',
|
|
203
|
+
'contactLastName',
|
|
204
|
+
'contactEmail',
|
|
205
|
+
'contactPhone',
|
|
206
|
+
'notes',
|
|
207
|
+
];
|
|
208
|
+
if (!isRecord(value) || Object.keys(value).some((key) => key !== 'demoAccount'
|
|
209
|
+
&& !textKeys.includes(key))) {
|
|
210
|
+
throw new Error(`TestFlight review details accept only ${[...textKeys, 'demoAccount'].join(', ')}; `
|
|
211
|
+
+ 'a demo account names environment variables (nameEnv, passwordEnv).');
|
|
212
|
+
}
|
|
213
|
+
const result = {};
|
|
214
|
+
for (const key of textKeys) {
|
|
215
|
+
const text = value[key];
|
|
216
|
+
if (text === undefined) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
220
|
+
throw new Error(`TestFlight review detail ${key} must be a non-empty string.`);
|
|
221
|
+
}
|
|
222
|
+
result[key] = text.trim();
|
|
223
|
+
}
|
|
224
|
+
if (typeof result.notes === 'string' && [...result.notes].length > maximumBetaReviewNotesCharacters) {
|
|
225
|
+
throw new Error(`TestFlight review notes allow ${maximumBetaReviewNotesCharacters} characters.`);
|
|
226
|
+
}
|
|
227
|
+
if (typeof result.contactEmail === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(result.contactEmail)) {
|
|
228
|
+
throw new Error('TestFlight review detail contactEmail is not an email address.');
|
|
229
|
+
}
|
|
230
|
+
const demo = value.demoAccount;
|
|
231
|
+
if (demo !== undefined) {
|
|
232
|
+
if (!isRecord(demo) || typeof demo.required !== 'boolean'
|
|
233
|
+
|| Object.keys(demo).some((key) => !['required', 'nameEnv', 'passwordEnv'].includes(key))) {
|
|
234
|
+
throw new Error('TestFlight review demoAccount takes required (boolean), nameEnv and passwordEnv: the names of '
|
|
235
|
+
+ 'environment variables, never the sign-in itself.');
|
|
236
|
+
}
|
|
237
|
+
if (!demo.required) {
|
|
238
|
+
if (demo.nameEnv !== undefined || demo.passwordEnv !== undefined) {
|
|
239
|
+
throw new Error('A TestFlight review demo account that is not required takes no nameEnv or passwordEnv.');
|
|
240
|
+
}
|
|
241
|
+
result.demoAccount = { required: false };
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
const unresolved = [];
|
|
245
|
+
const read = (key) => {
|
|
246
|
+
const name = demo[key];
|
|
247
|
+
if (typeof name !== 'string' || !environmentNamePattern.test(name)) {
|
|
248
|
+
throw new Error(`TestFlight review demoAccount.${key} must name an environment variable (A-Z, 0-9, _).`);
|
|
249
|
+
}
|
|
250
|
+
const secret = environment[name];
|
|
251
|
+
if (secret === undefined || secret.trim() === '') {
|
|
252
|
+
if (options.requireValues !== false) {
|
|
253
|
+
throw new Error(`TestFlight review demo account needs ${name} in the environment.`);
|
|
254
|
+
}
|
|
255
|
+
unresolved.push(name);
|
|
256
|
+
return { env: name };
|
|
257
|
+
}
|
|
258
|
+
return { env: name, value: secret.trim() };
|
|
259
|
+
};
|
|
260
|
+
const name = read('nameEnv');
|
|
261
|
+
const password = read('passwordEnv');
|
|
262
|
+
result.demoAccount = {
|
|
263
|
+
required: true,
|
|
264
|
+
nameEnv: name.env,
|
|
265
|
+
passwordEnv: password.env,
|
|
266
|
+
...(name.value === undefined ? {} : { name: name.value }),
|
|
267
|
+
...(password.value === undefined ? {} : { password: password.value }),
|
|
268
|
+
...(unresolved.length === 0 ? {} : { unresolvedEnv: unresolved }),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
/** The "What to Test" file: the release-notes format of `--release-notes`, without Play's length limit. */
|
|
275
|
+
export function readTestFlightWhatToTestFile(file) {
|
|
276
|
+
return readLocalizedNotesFile(file, 'What to Test');
|
|
277
|
+
}
|
|
278
|
+
/** Split a `--groups` value into group IDs or names. */
|
|
279
|
+
export function parseTestFlightGroups(value) {
|
|
280
|
+
const groups = value.split(',').map((entry) => entry.trim()).filter((entry) => entry !== '');
|
|
281
|
+
if (groups.length === 0) {
|
|
282
|
+
throw new Error('--groups needs at least one TestFlight group ID or name.');
|
|
283
|
+
}
|
|
284
|
+
return groups;
|
|
285
|
+
}
|
|
286
|
+
async function planDistribution(port, request) {
|
|
287
|
+
assertBuildTarget(request);
|
|
288
|
+
if (request.expectedBuildId === undefined) {
|
|
289
|
+
throw new Error(`The release state has no App Store Connect build ID for iOS build ${request.marketingVersion} `
|
|
290
|
+
+ `(${request.buildNumber}) yet. Re-run "mpgd deploy submit --target ios" for this release until it `
|
|
291
|
+
+ 'reports testflight-ready, then retry.');
|
|
292
|
+
}
|
|
293
|
+
if (request.groups.length === 0 || request.groups.some((group) => !groupReferencePattern.test(group))) {
|
|
294
|
+
throw new Error('TestFlight distribution needs one or more group IDs or names (no commas).');
|
|
295
|
+
}
|
|
296
|
+
await assertAppBundle(port, request);
|
|
297
|
+
const build = await port.findBuild(request.appStoreAppId, request.marketingVersion, request.buildNumber);
|
|
298
|
+
const label = `${request.marketingVersion} (${request.buildNumber})`;
|
|
299
|
+
if (build === undefined) {
|
|
300
|
+
throw new Error(`App Store Connect has no iOS build ${label} yet. Upload it with "mpgd deploy submit --target ios" `
|
|
301
|
+
+ '(or "deploy run") and retry once it appears.');
|
|
302
|
+
}
|
|
303
|
+
assertExpectedBuild(request, build.id);
|
|
304
|
+
if (build.processingState === 'PROCESSING') {
|
|
305
|
+
throw new Error(`iOS build ${label} is still processing in App Store Connect; retry when processing ends `
|
|
306
|
+
+ '(re-run "mpgd deploy submit --target ios" until it reports testflight-ready).');
|
|
307
|
+
}
|
|
308
|
+
if (build.processingState !== 'VALID') {
|
|
309
|
+
throw new Error(`iOS build ${label} cannot be tested: App Store Connect reports ${build.processingState}.`);
|
|
310
|
+
}
|
|
311
|
+
const groups = resolveGroups(request.groups, await port.listGroups(request.appStoreAppId), request.appStoreAppId);
|
|
312
|
+
const state = await port.readBuildBetaState(build.id);
|
|
313
|
+
const hasExternal = groups.some((group) => !group.isInternal);
|
|
314
|
+
const present = new Set(await port.listBuildGroupIds(request.appStoreAppId, build.id));
|
|
315
|
+
const base = { request, build, state, whatToTest: [], submit: false };
|
|
316
|
+
if (groups.some((group) => group.isInternal && !present.has(group.id))) {
|
|
317
|
+
const internal = classifyInternalBuildState(state.internalBuildState);
|
|
318
|
+
if (internal?.status === 'action-required') {
|
|
319
|
+
return blocked(base, groups, present, internal.detail ?? exportComplianceDetail);
|
|
320
|
+
}
|
|
321
|
+
if (internal !== undefined) {
|
|
322
|
+
throw new Error(`iOS build ${label} is not ready for internal testers (TestFlight ${state.internalBuildState}); `
|
|
323
|
+
+ `${internal.detail ?? 'retry when its TestFlight processing ends.'}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
let review = { kind: 'status', status: 'not-needed' };
|
|
327
|
+
let submission;
|
|
328
|
+
if (hasExternal) {
|
|
329
|
+
submission = latestSubmission(await port.listBetaReviewSubmissions(build.id));
|
|
330
|
+
review = classifyExternalReview(state.externalBuildState, submission);
|
|
331
|
+
if (review.kind === 'action-required') {
|
|
332
|
+
return blocked(base, groups, present, review.detail);
|
|
333
|
+
}
|
|
334
|
+
if (review.kind !== 'status') {
|
|
335
|
+
throw new Error(`iOS build ${label} cannot go to external testers yet: ${review.detail}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const status = review.kind === 'status' ? review.status : 'not-needed';
|
|
339
|
+
const reviewOpen = status === 'waiting-for-review' || status === 'in-review' || status === 'rejected';
|
|
340
|
+
const plannedGroups = groups.map((group) => ({
|
|
341
|
+
group,
|
|
342
|
+
membership: present.has(group.id)
|
|
343
|
+
? 'present'
|
|
344
|
+
: !group.isInternal && reviewOpen
|
|
345
|
+
? 'after-review'
|
|
346
|
+
: 'add',
|
|
347
|
+
}));
|
|
348
|
+
const whatToTest = planWhatToTest(request.whatToTest ?? [], await port.listWhatToTest(build.id), hasExternal);
|
|
349
|
+
const submit = hasExternal && status === 'not-submitted';
|
|
350
|
+
let reviewDetailId;
|
|
351
|
+
let reviewDetailChanges;
|
|
352
|
+
let reviewDetailPendingFields = [];
|
|
353
|
+
if (submit) {
|
|
354
|
+
const detail = await port.readBetaReviewDetail(request.appStoreAppId);
|
|
355
|
+
reviewDetailId = detail.id;
|
|
356
|
+
({ changes: reviewDetailChanges, pendingFields: reviewDetailPendingFields } = planReviewDetail(detail, request.betaReview));
|
|
357
|
+
}
|
|
358
|
+
const waitingGroups = plannedGroups.filter((group) => group.membership === 'after-review');
|
|
359
|
+
return {
|
|
360
|
+
request,
|
|
361
|
+
build,
|
|
362
|
+
state,
|
|
363
|
+
groups: plannedGroups,
|
|
364
|
+
whatToTest,
|
|
365
|
+
...(reviewDetailId === undefined ? {} : { reviewDetailId }),
|
|
366
|
+
...(reviewDetailChanges === undefined ? {} : { reviewDetailChanges }),
|
|
367
|
+
...(reviewDetailPendingFields.length === 0 ? {} : { reviewDetailPendingFields }),
|
|
368
|
+
submit,
|
|
369
|
+
betaReview: { status, ...(submission === undefined ? {} : { submissionId: submission.id }) },
|
|
370
|
+
blocked: false,
|
|
371
|
+
...(waitingGroups.length === 0 ? {} : {
|
|
372
|
+
detail: `App Store Connect adds ${waitingGroups.map((group) => group.group.name).join(', ')} only `
|
|
373
|
+
+ (status === 'rejected'
|
|
374
|
+
? 'after the rejection is resolved in App Store Connect and the build passes review; re-run then.'
|
|
375
|
+
: 'after the open TestFlight App Review ends; re-run then.'),
|
|
376
|
+
}),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function blocked(base, groups, present, detail) {
|
|
380
|
+
return {
|
|
381
|
+
...base,
|
|
382
|
+
groups: groups.map((group) => ({
|
|
383
|
+
group,
|
|
384
|
+
membership: present.has(group.id) ? 'present' : 'blocked',
|
|
385
|
+
})),
|
|
386
|
+
betaReview: { status: 'action-required' },
|
|
387
|
+
blocked: true,
|
|
388
|
+
detail,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
function resolveGroups(requested, groups, appId) {
|
|
392
|
+
const resolved = new Map();
|
|
393
|
+
for (const reference of requested) {
|
|
394
|
+
const byId = groups.find((group) => group.id === reference);
|
|
395
|
+
const byName = groups.filter((group) => group.name === reference);
|
|
396
|
+
if (byId === undefined && byName.length > 1) {
|
|
397
|
+
throw new Error(`More than one TestFlight group of app ${appId} is named "${reference}"; pass its group ID `
|
|
398
|
+
+ `(${byName.map((group) => group.id).join(', ')}).`);
|
|
399
|
+
}
|
|
400
|
+
const group = byId ?? byName[0];
|
|
401
|
+
if (group === undefined) {
|
|
402
|
+
const known = groups.map((entry) => `${entry.name} (${entry.isInternal ? 'internal' : 'external'}, ${entry.id})`);
|
|
403
|
+
throw new Error(`TestFlight group "${reference}" does not exist for app ${appId}. Create it in App Store Connect `
|
|
404
|
+
+ `> TestFlight first (an external group needs an internal group to exist). Existing groups: `
|
|
405
|
+
+ `${known.length === 0 ? 'none' : known.join('; ')}.`);
|
|
406
|
+
}
|
|
407
|
+
resolved.set(group.id, group);
|
|
408
|
+
}
|
|
409
|
+
return [...resolved.values()];
|
|
410
|
+
}
|
|
411
|
+
function planWhatToTest(requested, existing, hasExternal) {
|
|
412
|
+
if (requested.length > 0) {
|
|
413
|
+
assertLocalizedNotes(requested, 'What to Test');
|
|
414
|
+
}
|
|
415
|
+
if (hasExternal && requested.length === 0
|
|
416
|
+
&& !existing.some((entry) => (entry.whatsNew ?? '').trim() !== '')) {
|
|
417
|
+
throw new Error('External testers need the build\'s "What to Test": pass --what-to-test <file> '
|
|
418
|
+
+ '(<en-US>…</en-US> blocks or JSON, the --release-notes format).');
|
|
419
|
+
}
|
|
420
|
+
return requested.map((note) => {
|
|
421
|
+
const current = existing.find((entry) => entry.locale.toLowerCase() === note.language.toLowerCase());
|
|
422
|
+
const text = note.text.trim();
|
|
423
|
+
return {
|
|
424
|
+
locale: current?.locale ?? note.language,
|
|
425
|
+
text,
|
|
426
|
+
action: current === undefined
|
|
427
|
+
? 'create'
|
|
428
|
+
: (current.whatsNew ?? '').trim() === text
|
|
429
|
+
? 'unchanged'
|
|
430
|
+
: 'update',
|
|
431
|
+
};
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
/** The changes the review details need before a submission; throws when required ones stay missing. */
|
|
435
|
+
function planReviewDetail(detail, input) {
|
|
436
|
+
const changes = {};
|
|
437
|
+
const effective = {};
|
|
438
|
+
for (const key of [
|
|
439
|
+
'contactFirstName',
|
|
440
|
+
'contactLastName',
|
|
441
|
+
'contactEmail',
|
|
442
|
+
'contactPhone',
|
|
443
|
+
'notes',
|
|
444
|
+
]) {
|
|
445
|
+
const wanted = input?.[key];
|
|
446
|
+
const current = detail[key]?.trim();
|
|
447
|
+
if (wanted !== undefined && wanted !== current) {
|
|
448
|
+
changes[key] = wanted;
|
|
449
|
+
}
|
|
450
|
+
effective[key] = wanted ?? current;
|
|
451
|
+
}
|
|
452
|
+
const notes = input?.notes;
|
|
453
|
+
if (notes !== undefined && [...notes.trim()].length > maximumBetaReviewNotesCharacters) {
|
|
454
|
+
throw new Error(`TestFlight review notes allow ${maximumBetaReviewNotesCharacters} characters.`);
|
|
455
|
+
}
|
|
456
|
+
const demo = input?.demoAccount;
|
|
457
|
+
const demoRequired = demo?.required ?? detail.demoAccountRequired;
|
|
458
|
+
if (demo !== undefined && demo.required !== detail.demoAccountRequired) {
|
|
459
|
+
changes.demoAccountRequired = demo.required;
|
|
460
|
+
}
|
|
461
|
+
// A dry run without the demo account variables plans their fields as
|
|
462
|
+
// written; apply refuses to run until they are set.
|
|
463
|
+
const unresolved = demo?.unresolvedEnv ?? [];
|
|
464
|
+
const nameUnresolved = demo?.nameEnv !== undefined && unresolved.includes(demo.nameEnv);
|
|
465
|
+
const passwordUnresolved = demo?.passwordEnv !== undefined && unresolved.includes(demo.passwordEnv);
|
|
466
|
+
const pendingFields = [
|
|
467
|
+
...(nameUnresolved ? ['demoAccountName'] : []),
|
|
468
|
+
...(passwordUnresolved ? ['demoAccountPassword'] : []),
|
|
469
|
+
];
|
|
470
|
+
if (demo?.required === true) {
|
|
471
|
+
if (demo.name !== undefined && demo.name !== detail.demoAccountName?.trim()) {
|
|
472
|
+
changes.demoAccountName = demo.name;
|
|
473
|
+
}
|
|
474
|
+
// A stored password cannot be read back, so the one from the environment
|
|
475
|
+
// is always sent with the submission.
|
|
476
|
+
if (demo.password !== undefined) {
|
|
477
|
+
changes.demoAccountPassword = demo.password;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const missing = [
|
|
481
|
+
...['contactFirstName', 'contactLastName', 'contactEmail', 'contactPhone']
|
|
482
|
+
.filter((key) => effective[key] === undefined || effective[key] === ''),
|
|
483
|
+
...(demoRequired && !nameUnresolved && (demo?.name ?? detail.demoAccountName ?? '') === ''
|
|
484
|
+
? ['demoAccountName'] : []),
|
|
485
|
+
...(demoRequired && !passwordUnresolved && demo?.password === undefined && !detail.demoAccountPasswordSet
|
|
486
|
+
? ['demoAccountPassword'] : []),
|
|
487
|
+
];
|
|
488
|
+
if (missing.length > 0) {
|
|
489
|
+
throw new Error(`TestFlight App Review needs review details that App Store Connect does not have: ${missing.join(', ')}. `
|
|
490
|
+
+ 'Add them to the --beta-review file (a demo account by nameEnv/passwordEnv environment variable '
|
|
491
|
+
+ 'names) or fill App Store Connect > TestFlight > Test Information, then retry.');
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
changes: Object.keys(changes).length === 0
|
|
495
|
+
? undefined
|
|
496
|
+
: changes,
|
|
497
|
+
pendingFields,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* App Store Connect `ExternalBetaState` and the build's
|
|
502
|
+
* `BetaAppReviewSubmission.betaReviewState` (`BetaReviewState`).
|
|
503
|
+
*/
|
|
504
|
+
function classifyExternalReview(externalBuildState, submission) {
|
|
505
|
+
const submitted = submission === undefined
|
|
506
|
+
? undefined
|
|
507
|
+
: classifySubmissionState(submission.betaReviewState);
|
|
508
|
+
if (submission !== undefined && submitted === undefined) {
|
|
509
|
+
// Never submit again over a submission whose state is not understood.
|
|
510
|
+
return {
|
|
511
|
+
kind: 'action-required',
|
|
512
|
+
detail: `TestFlight App Review submission ${submission.id} reports an unknown state `
|
|
513
|
+
+ `${submission.betaReviewState}; inspect it in App Store Connect (App Review > TestFlight) `
|
|
514
|
+
+ 'before running this again.',
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
switch (externalBuildState) {
|
|
518
|
+
case 'WAITING_FOR_BETA_REVIEW':
|
|
519
|
+
return { kind: 'status', status: 'waiting-for-review' };
|
|
520
|
+
case 'IN_BETA_REVIEW':
|
|
521
|
+
return { kind: 'status', status: 'in-review' };
|
|
522
|
+
case 'BETA_REJECTED':
|
|
523
|
+
return { kind: 'status', status: 'rejected' };
|
|
524
|
+
case 'BETA_APPROVED':
|
|
525
|
+
return { kind: 'status', status: 'approved' };
|
|
526
|
+
case 'READY_FOR_BETA_TESTING':
|
|
527
|
+
// Not in Apple's current ExternalBetaState list; asc 5.5.0 still accepts
|
|
528
|
+
// it as a value App Store Connect returned before the rename.
|
|
529
|
+
case 'READY_FOR_TESTING':
|
|
530
|
+
case 'IN_BETA_TESTING':
|
|
531
|
+
// Testable by external testers; without a submission of its own, the
|
|
532
|
+
// build did not need one.
|
|
533
|
+
return { kind: 'status', status: submitted ?? 'not-needed' };
|
|
534
|
+
case 'READY_FOR_BETA_SUBMISSION':
|
|
535
|
+
// A submission App Store Connect has not reflected in the build state yet.
|
|
536
|
+
return { kind: 'status', status: submitted ?? 'not-submitted' };
|
|
537
|
+
case 'MISSING_EXPORT_COMPLIANCE':
|
|
538
|
+
return { kind: 'action-required', detail: exportComplianceDetail };
|
|
539
|
+
case 'IN_EXPORT_COMPLIANCE_REVIEW':
|
|
540
|
+
return {
|
|
541
|
+
kind: 'action-required',
|
|
542
|
+
detail: 'Apple is reviewing the export-compliance documentation.',
|
|
543
|
+
};
|
|
544
|
+
case 'PROCESSING':
|
|
545
|
+
return {
|
|
546
|
+
kind: 'not-ready',
|
|
547
|
+
detail: 'TestFlight is still processing the build for external testing; retry later.',
|
|
548
|
+
};
|
|
549
|
+
// Not in Apple's current ExternalBetaState list; asc 5.5.0 still
|
|
550
|
+
// recognizes it and recommends re-checking, so it is not terminal.
|
|
551
|
+
case 'NOT_READY_FOR_TESTING':
|
|
552
|
+
return {
|
|
553
|
+
kind: 'not-ready',
|
|
554
|
+
detail: 'TestFlight reports NOT_READY_FOR_TESTING for external testing; retry later.',
|
|
555
|
+
};
|
|
556
|
+
case 'NOT_APPLICABLE':
|
|
557
|
+
return {
|
|
558
|
+
kind: 'unusable',
|
|
559
|
+
detail: 'App Store Connect reports NOT_APPLICABLE (a TestFlight Internal Only upload); upload a build eligible for App Store distribution.',
|
|
560
|
+
};
|
|
561
|
+
case 'PROCESSING_EXCEPTION':
|
|
562
|
+
case 'EXPIRED':
|
|
563
|
+
return { kind: 'unusable', detail: `TestFlight reports ${externalBuildState}.` };
|
|
564
|
+
default:
|
|
565
|
+
return {
|
|
566
|
+
kind: 'unusable',
|
|
567
|
+
detail: `TestFlight reports an unknown external state ${externalBuildState}.`,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
function classifySubmissionState(state) {
|
|
572
|
+
switch (state) {
|
|
573
|
+
case 'WAITING_FOR_REVIEW':
|
|
574
|
+
return 'waiting-for-review';
|
|
575
|
+
case 'IN_REVIEW':
|
|
576
|
+
return 'in-review';
|
|
577
|
+
case 'REJECTED':
|
|
578
|
+
return 'rejected';
|
|
579
|
+
case 'APPROVED':
|
|
580
|
+
return 'approved';
|
|
581
|
+
default:
|
|
582
|
+
return undefined;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function latestSubmission(submissions) {
|
|
586
|
+
return [...submissions].sort((left, right) => (right.submittedDate ?? '').localeCompare(left.submittedDate ?? ''))[0];
|
|
587
|
+
}
|
|
588
|
+
function toReport(plan, dryRun) {
|
|
589
|
+
return {
|
|
590
|
+
dryRun,
|
|
591
|
+
appStoreAppId: plan.request.appStoreAppId,
|
|
592
|
+
marketingVersion: plan.request.marketingVersion,
|
|
593
|
+
buildNumber: plan.request.buildNumber,
|
|
594
|
+
buildId: plan.build.id,
|
|
595
|
+
internalBuildState: plan.state.internalBuildState,
|
|
596
|
+
externalBuildState: plan.state.externalBuildState,
|
|
597
|
+
groups: plan.groups.map((entry) => ({
|
|
598
|
+
id: entry.group.id,
|
|
599
|
+
name: entry.group.name,
|
|
600
|
+
external: !entry.group.isInternal,
|
|
601
|
+
membership: entry.membership,
|
|
602
|
+
})),
|
|
603
|
+
whatToTest: plan.whatToTest.map((note) => ({ locale: note.locale, action: note.action })),
|
|
604
|
+
reviewDetailFields: [
|
|
605
|
+
...Object.keys(plan.reviewDetailChanges ?? {}),
|
|
606
|
+
...(plan.reviewDetailPendingFields ?? []),
|
|
607
|
+
],
|
|
608
|
+
...(unresolvedDemoEnv(plan.request).length === 0 ? {} : {
|
|
609
|
+
pendingInputs: [`demo account values: missing ${unresolvedDemoEnv(plan.request).join(', ')}`],
|
|
610
|
+
}),
|
|
611
|
+
betaReview: {
|
|
612
|
+
status: plan.betaReview.status,
|
|
613
|
+
submit: plan.submit,
|
|
614
|
+
...(plan.betaReview.submissionId === undefined ? {} : { submissionId: plan.betaReview.submissionId }),
|
|
615
|
+
},
|
|
616
|
+
...(plan.detail === undefined ? {} : { detail: plan.detail }),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
async function assertAppBundle(port, target) {
|
|
620
|
+
if (await port.readAppBundleId(target.appStoreAppId) !== target.bundleId) {
|
|
621
|
+
throw new Error('App Store Connect app ID does not match the recorded iOS bundle ID.');
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
function assertExpectedBuild(target, buildId) {
|
|
625
|
+
if (target.expectedBuildId !== undefined && target.expectedBuildId !== buildId) {
|
|
626
|
+
throw new Error('The App Store Connect build differs from the build ID the release state recorded.');
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function assertBuildTarget(target) {
|
|
630
|
+
if (!/^\d+$/u.test(target.appStoreAppId) || !/^\d+(?:\.\d+){0,2}$/u.test(target.marketingVersion)
|
|
631
|
+
|| !/^[1-9]\d*$/u.test(target.buildNumber) || target.bundleId.trim() === '') {
|
|
632
|
+
throw new Error('TestFlight distribution needs a numeric app ID and the recorded version and build number.');
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
function unresolvedDemoEnv(request) {
|
|
636
|
+
return request.betaReview?.demoAccount?.unresolvedEnv ?? [];
|
|
637
|
+
}
|
|
638
|
+
function defaultSleep(milliseconds) {
|
|
639
|
+
return new Promise((resolve) => {
|
|
640
|
+
setTimeout(resolve, milliseconds);
|
|
641
|
+
});
|
|
642
|
+
}
|
|
@@ -53,6 +53,17 @@ export declare function classifyInternalBuildState(state: string): {
|
|
|
53
53
|
readonly status: Extract<IosTestFlightStatus, 'processing' | 'action-required' | 'failed' | 'unknown'>;
|
|
54
54
|
readonly detail?: string;
|
|
55
55
|
} | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* asc 5.5.0 prints `testflight groups list --build-id` as its own membership
|
|
58
|
+
* result, not as App Store Connect resources:
|
|
59
|
+
* `{ buildId, appId, complete, groups: [{ id, name, type, membership }] }`
|
|
60
|
+
* (`internal/cli/testflight/build_group_membership.go`). An incomplete lookup
|
|
61
|
+
* is never read as "not a member".
|
|
62
|
+
*/
|
|
63
|
+
export declare function readBuildGroupMembership(response: unknown, buildId: string): readonly {
|
|
64
|
+
readonly id: string;
|
|
65
|
+
readonly type: string;
|
|
66
|
+
}[];
|
|
56
67
|
export declare function isolatedAscEnvironment(input: Pick<IosTestFlightSubmissionInput, 'apiKeyId' | 'apiIssuerId' | 'apiPrivateKeyBase64'>, directory: string, source?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
57
68
|
/** The package consumes one reviewed asc pin; it does not trust PATH or version text. */
|
|
58
69
|
export declare function verifyPinnedAscBinary(binary: string, pinFile?: string): Promise<void>;
|