@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
|
@@ -142,14 +142,25 @@ export function assertPlayUserFraction(fraction) {
|
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
144
|
export function readPlayReleaseNotesFile(file) {
|
|
145
|
+
const notes = readLocalizedNotesFile(file);
|
|
146
|
+
assertPlayReleaseNoteLengths(notes);
|
|
147
|
+
return notes;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* A per-language notes file in the release-notes format below, without a
|
|
151
|
+
* store's length limit; `label` names the notes in errors. TestFlight's
|
|
152
|
+
* "What to Test" (`mpgd deploy testflight distribute --what-to-test`) reads
|
|
153
|
+
* the same file format as Google Play release notes.
|
|
154
|
+
*/
|
|
155
|
+
export function readLocalizedNotesFile(file, label = 'Release notes') {
|
|
145
156
|
const resolved = path.resolve(file);
|
|
146
157
|
if (!existsSync(resolved) || !statSync(resolved).isFile()) {
|
|
147
|
-
throw new Error(
|
|
158
|
+
throw new Error(`${label} file is missing: ${resolved}`);
|
|
148
159
|
}
|
|
149
160
|
if (statSync(resolved).size > maximumReleaseNotesFileBytes) {
|
|
150
|
-
throw new Error(
|
|
161
|
+
throw new Error(`${label} file is larger than 256 KiB.`);
|
|
151
162
|
}
|
|
152
|
-
return
|
|
163
|
+
return parseLocalizedNotes(readFileSync(resolved, 'utf8'), label);
|
|
153
164
|
}
|
|
154
165
|
/**
|
|
155
166
|
* Accepts the Play Console's own language-tag format, one block per language:
|
|
@@ -161,10 +172,16 @@ export function readPlayReleaseNotesFile(file) {
|
|
|
161
172
|
* or JSON: `{ "en-US": "Bug fixes." }` or `[{ "language": "en-US", "text": "Bug fixes." }]`.
|
|
162
173
|
*/
|
|
163
174
|
export function parsePlayReleaseNotes(source) {
|
|
175
|
+
const notes = parseLocalizedNotes(source);
|
|
176
|
+
assertPlayReleaseNoteLengths(notes);
|
|
177
|
+
return notes;
|
|
178
|
+
}
|
|
179
|
+
/** `parsePlayReleaseNotes` without Google Play's length limit. */
|
|
180
|
+
export function parseLocalizedNotes(source, label = 'Release notes') {
|
|
164
181
|
const content = source.replace(/^/u, '');
|
|
165
182
|
const trimmed = content.trim();
|
|
166
183
|
if (trimmed === '') {
|
|
167
|
-
throw new Error(
|
|
184
|
+
throw new Error(`${label} file is empty.`);
|
|
168
185
|
}
|
|
169
186
|
let notes;
|
|
170
187
|
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
@@ -173,7 +190,7 @@ export function parsePlayReleaseNotes(source) {
|
|
|
173
190
|
parsed = JSON.parse(trimmed);
|
|
174
191
|
}
|
|
175
192
|
catch {
|
|
176
|
-
throw new Error(
|
|
193
|
+
throw new Error(`${label} JSON is malformed.`);
|
|
177
194
|
}
|
|
178
195
|
notes = Array.isArray(parsed)
|
|
179
196
|
? parsed.map((entry) => {
|
|
@@ -181,7 +198,7 @@ export function parsePlayReleaseNotes(source) {
|
|
|
181
198
|
|| typeof entry.language !== 'string'
|
|
182
199
|
|| typeof entry.text !== 'string'
|
|
183
200
|
|| Object.keys(entry).some((key) => key !== 'language' && key !== 'text')) {
|
|
184
|
-
throw new Error(
|
|
201
|
+
throw new Error(`${label} JSON array entries need only string language and text.`);
|
|
185
202
|
}
|
|
186
203
|
const { language, text } = entry;
|
|
187
204
|
return { language, text: text.trim() };
|
|
@@ -189,7 +206,7 @@ export function parsePlayReleaseNotes(source) {
|
|
|
189
206
|
: typeof parsed === 'object' && parsed !== null
|
|
190
207
|
? Object.entries(parsed).map(([language, text]) => {
|
|
191
208
|
if (typeof text !== 'string') {
|
|
192
|
-
throw new Error(
|
|
209
|
+
throw new Error(`${label} for ${language} must be a string.`);
|
|
193
210
|
}
|
|
194
211
|
return { language, text: text.trim() };
|
|
195
212
|
})
|
|
@@ -201,35 +218,44 @@ export function parsePlayReleaseNotes(source) {
|
|
|
201
218
|
let consumed = 0;
|
|
202
219
|
for (const match of content.matchAll(block)) {
|
|
203
220
|
if (content.slice(consumed, match.index).trim() !== '') {
|
|
204
|
-
throw new Error(
|
|
221
|
+
throw new Error(`${label} text must sit between <language> and </language> tags.`);
|
|
205
222
|
}
|
|
206
223
|
notes.push({ language: match[1] ?? '', text: (match[2] ?? '').trim() });
|
|
207
224
|
consumed = match.index + match[0].length;
|
|
208
225
|
}
|
|
209
226
|
if (content.slice(consumed).trim() !== '') {
|
|
210
|
-
throw new Error(
|
|
227
|
+
throw new Error(`${label} text must sit between <language> and </language> tags.`);
|
|
211
228
|
}
|
|
212
229
|
}
|
|
213
|
-
|
|
230
|
+
assertLocalizedNotes(notes, label);
|
|
214
231
|
return notes;
|
|
215
232
|
}
|
|
216
233
|
export function assertPlayReleaseNotes(notes) {
|
|
234
|
+
assertLocalizedNotes(notes, 'Release notes');
|
|
235
|
+
assertPlayReleaseNoteLengths(notes);
|
|
236
|
+
}
|
|
237
|
+
/** One non-empty text per BCP-47 language, each language once. */
|
|
238
|
+
export function assertLocalizedNotes(notes, label) {
|
|
217
239
|
if (notes.length === 0) {
|
|
218
|
-
throw new Error(
|
|
240
|
+
throw new Error(`${label} need at least one language.`);
|
|
219
241
|
}
|
|
220
242
|
const seen = new Set();
|
|
221
243
|
for (const note of notes) {
|
|
222
244
|
if (!languageTagPattern.test(note.language)) {
|
|
223
|
-
throw new Error(
|
|
245
|
+
throw new Error(`${label} language is not a BCP-47 tag: ${note.language}`);
|
|
224
246
|
}
|
|
225
247
|
const key = note.language.toLowerCase();
|
|
226
248
|
if (seen.has(key)) {
|
|
227
|
-
throw new Error(
|
|
249
|
+
throw new Error(`${label} repeat the language ${note.language}.`);
|
|
228
250
|
}
|
|
229
251
|
seen.add(key);
|
|
230
252
|
if (note.text.trim() === '') {
|
|
231
|
-
throw new Error(
|
|
253
|
+
throw new Error(`${label} for ${note.language} are empty.`);
|
|
232
254
|
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function assertPlayReleaseNoteLengths(notes) {
|
|
258
|
+
for (const note of notes) {
|
|
233
259
|
const length = [...note.text].length;
|
|
234
260
|
if (length > maximumPlayReleaseNoteCharacters) {
|
|
235
261
|
throw new Error(`Release notes for ${note.language} are ${length} characters; Google Play allows `
|
|
@@ -98,8 +98,9 @@ async function planWithLocal(input, appStore, local, port) {
|
|
|
98
98
|
// App Store Connect takes what's new only once a version has been released.
|
|
99
99
|
const first = version !== undefined && !versions.some((candidate) => releasedVersionStates.has(String(candidate.state)) || releasedVersionStates.has(String(candidate.appStoreState)));
|
|
100
100
|
if (version === undefined) {
|
|
101
|
-
manual.push('No iOS version is editable (PREPARE_FOR_SUBMISSION or rejected): create the next version
|
|
102
|
-
+ 'Connect, then plan again. The sync never creates or
|
|
101
|
+
manual.push('No iOS version is editable (PREPARE_FOR_SUBMISSION or rejected): create the next version with '
|
|
102
|
+
+ '"mpgd store release plan" and apply (or in App Store Connect), then plan again. The sync never creates or '
|
|
103
|
+
+ 'submits a version.');
|
|
103
104
|
}
|
|
104
105
|
const versionLocalizations = version === undefined
|
|
105
106
|
? []
|
|
@@ -139,8 +140,9 @@ async function planWithLocal(input, appStore, local, port) {
|
|
|
139
140
|
const remoteOnlyLocales = [...new Set([...appInfoLocalizations, ...versionLocalizations].map((entry) => entry.locale))]
|
|
140
141
|
.filter((locale) => !named.has(locale.toLowerCase()))
|
|
141
142
|
.sort();
|
|
142
|
-
manual.push('App Privacy answers, the age rating,
|
|
143
|
-
+ 'settings and submitting for review
|
|
143
|
+
manual.push('App Privacy answers, the age rating, pricing and availability stay in App Store Connect; creating the '
|
|
144
|
+
+ 'version, its build, App Review information, release settings and submitting for review are '
|
|
145
|
+
+ '"mpgd store release".');
|
|
144
146
|
const imageRemovals = planned.reduce((sum, locale) => sum + locale.screenshots.reduce((count, set) => count + (set.action === 'replace' ? set.deleteIds.length : 0), 0), 0);
|
|
145
147
|
const blocked = planned.reduce((sum, locale) => sum + [locale.appInfo.action, locale.version.action].filter((action) => action === 'blocked').length
|
|
146
148
|
+ locale.screenshots.filter((set) => set.blocked === true).length, 0);
|
|
@@ -87,9 +87,9 @@ export async function planAppStoreProducts(input, port) {
|
|
|
87
87
|
.filter((productId) => !named.has(productId))
|
|
88
88
|
.sort();
|
|
89
89
|
if (products.some((product) => product.action !== 'unchanged' || product.state !== 'APPROVED')) {
|
|
90
|
-
manual.push('Submit the in-app purchases for App Review
|
|
91
|
-
+
|
|
92
|
-
+ '
|
|
90
|
+
manual.push('Submit the in-app purchases for App Review with "mpgd store release": it adds every purchase that is '
|
|
91
|
+
+ "READY_TO_SUBMIT to the app version's review submission (the first consumable and the first "
|
|
92
|
+
+ 'non-consumable must each go with a new app version). The sync never submits.');
|
|
93
93
|
}
|
|
94
94
|
return {
|
|
95
95
|
schemaVersion: 1,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type AscCommandRunner, type AscCredential } from './asc-session.js';
|
|
2
|
+
import type { TestFlightDistributionPort } from './testflight-distribution-port.js';
|
|
3
|
+
/**
|
|
4
|
+
* The TestFlight distribution port on the pinned `asc` 5.5.0 CLI. Commands
|
|
5
|
+
* (5.5.0 source, `internal/cli/...`):
|
|
6
|
+
*
|
|
7
|
+
* - `apps view`, `builds list` (`builds/builds_commands.go`)
|
|
8
|
+
* - `testflight groups list --app` (App Store Connect `BetaGroup` resources) and
|
|
9
|
+
* `testflight groups list --build-id`, which prints asc's own membership
|
|
10
|
+
* result `{ buildId, appId, complete, groups: [{ id, type }] }`
|
|
11
|
+
* (`testflight/build_group_membership.go`)
|
|
12
|
+
* - `builds build-beta-detail view` (`BuildBetaDetail`: `internalBuildState`,
|
|
13
|
+
* `externalBuildState`)
|
|
14
|
+
* - `builds add-groups --build-id --group` without `--submit`
|
|
15
|
+
* (`builds/builds.go`); asc refuses an external group while the build waits
|
|
16
|
+
* for or failed TestFlight App Review
|
|
17
|
+
* - `builds test-notes list|create` (`builds/build_test_notes.go`): the
|
|
18
|
+
* build's `BetaBuildLocalization` "What to Test"; create updates the locale
|
|
19
|
+
* when it exists
|
|
20
|
+
* - `testflight review view|edit` (`testflight/testflight_review.go`): the app's
|
|
21
|
+
* `BetaAppReviewDetail`; asc prints a stored demo account password as
|
|
22
|
+
* `(redacted)`
|
|
23
|
+
* - `testflight review submissions list` and `testflight review submit
|
|
24
|
+
* --confirm`: the build's `BetaAppReviewSubmission`
|
|
25
|
+
*/
|
|
26
|
+
/** Copy and verify the pinned binary; `close` removes it. */
|
|
27
|
+
export declare function openAscTestFlightDistributionSession(credential: AscCredential, options?: {
|
|
28
|
+
readonly signal?: AbortSignal;
|
|
29
|
+
readonly secretValues?: readonly string[];
|
|
30
|
+
}): Promise<{
|
|
31
|
+
readonly port: TestFlightDistributionPort;
|
|
32
|
+
close(): void;
|
|
33
|
+
}>;
|
|
34
|
+
/** The port over any runner; the tests drive it with recorded asc output. */
|
|
35
|
+
export declare function createAscTestFlightDistributionPort(run: AscCommandRunner): TestFlightDistributionPort;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { ascListTimeoutMs, ascLookupTimeoutMs, isRecord, list, openAscSession, optionalString, requiredString, single, } from './asc-session.js';
|
|
2
|
+
import { readBuildGroupMembership } from './testflight-submission.js';
|
|
3
|
+
/**
|
|
4
|
+
* The TestFlight distribution port on the pinned `asc` 5.5.0 CLI. Commands
|
|
5
|
+
* (5.5.0 source, `internal/cli/...`):
|
|
6
|
+
*
|
|
7
|
+
* - `apps view`, `builds list` (`builds/builds_commands.go`)
|
|
8
|
+
* - `testflight groups list --app` (App Store Connect `BetaGroup` resources) and
|
|
9
|
+
* `testflight groups list --build-id`, which prints asc's own membership
|
|
10
|
+
* result `{ buildId, appId, complete, groups: [{ id, type }] }`
|
|
11
|
+
* (`testflight/build_group_membership.go`)
|
|
12
|
+
* - `builds build-beta-detail view` (`BuildBetaDetail`: `internalBuildState`,
|
|
13
|
+
* `externalBuildState`)
|
|
14
|
+
* - `builds add-groups --build-id --group` without `--submit`
|
|
15
|
+
* (`builds/builds.go`); asc refuses an external group while the build waits
|
|
16
|
+
* for or failed TestFlight App Review
|
|
17
|
+
* - `builds test-notes list|create` (`builds/build_test_notes.go`): the
|
|
18
|
+
* build's `BetaBuildLocalization` "What to Test"; create updates the locale
|
|
19
|
+
* when it exists
|
|
20
|
+
* - `testflight review view|edit` (`testflight/testflight_review.go`): the app's
|
|
21
|
+
* `BetaAppReviewDetail`; asc prints a stored demo account password as
|
|
22
|
+
* `(redacted)`
|
|
23
|
+
* - `testflight review submissions list` and `testflight review submit
|
|
24
|
+
* --confirm`: the build's `BetaAppReviewSubmission`
|
|
25
|
+
*/
|
|
26
|
+
/** Copy and verify the pinned binary; `close` removes it. */
|
|
27
|
+
export async function openAscTestFlightDistributionSession(credential, options = {}) {
|
|
28
|
+
const session = await openAscSession(credential, options.signal, options.secretValues ?? []);
|
|
29
|
+
return { port: createAscTestFlightDistributionPort(session.run), close: () => session.close() };
|
|
30
|
+
}
|
|
31
|
+
/** The port over any runner; the tests drive it with recorded asc output. */
|
|
32
|
+
export function createAscTestFlightDistributionPort(run) {
|
|
33
|
+
return {
|
|
34
|
+
async readAppBundleId(appId) {
|
|
35
|
+
const app = single(await run(['apps', 'view', '--id', appId], ascLookupTimeoutMs), 'apps view');
|
|
36
|
+
const bundleId = app.attributes.bundleId;
|
|
37
|
+
if (app.id !== appId || typeof bundleId !== 'string') {
|
|
38
|
+
throw new Error('asc apps view returned no bundle ID for the app.');
|
|
39
|
+
}
|
|
40
|
+
return bundleId;
|
|
41
|
+
},
|
|
42
|
+
async findBuild(appId, marketingVersion, buildNumber) {
|
|
43
|
+
const builds = list(await run([
|
|
44
|
+
'builds', 'list',
|
|
45
|
+
'--app', appId,
|
|
46
|
+
'--version', marketingVersion,
|
|
47
|
+
'--build-number', buildNumber,
|
|
48
|
+
'--platform', 'IOS',
|
|
49
|
+
'--paginate',
|
|
50
|
+
], ascListTimeoutMs), 'builds list').filter((build) => build.attributes.version === buildNumber);
|
|
51
|
+
if (builds.length > 1) {
|
|
52
|
+
throw new Error('App Store Connect returned more than one build for this version and build number.');
|
|
53
|
+
}
|
|
54
|
+
const build = builds[0];
|
|
55
|
+
return build === undefined ? undefined : {
|
|
56
|
+
id: build.id,
|
|
57
|
+
processingState: requiredString(build.attributes.processingState, 'build processing state'),
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
async listGroups(appId) {
|
|
61
|
+
return list(await run(['testflight', 'groups', 'list', '--app', appId, '--paginate'], ascListTimeoutMs), 'testflight groups list').map((group) => {
|
|
62
|
+
if (typeof group.attributes.isInternalGroup !== 'boolean') {
|
|
63
|
+
throw new Error('asc testflight groups list returned a group without isInternalGroup.');
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
id: group.id,
|
|
67
|
+
name: requiredString(group.attributes.name, 'TestFlight group name'),
|
|
68
|
+
isInternal: group.attributes.isInternalGroup,
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
async readBuildBetaState(buildId) {
|
|
73
|
+
const detail = single(await run(['builds', 'build-beta-detail', 'view', '--build-id', buildId], ascLookupTimeoutMs), 'builds build-beta-detail view');
|
|
74
|
+
return {
|
|
75
|
+
internalBuildState: requiredString(detail.attributes.internalBuildState, 'internal build state'),
|
|
76
|
+
externalBuildState: requiredString(detail.attributes.externalBuildState, 'external build state'),
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
async listBuildGroupIds(appId, buildId) {
|
|
80
|
+
const result = await run(['testflight', 'groups', 'list', '--build-id', buildId, '--app', appId], ascListTimeoutMs);
|
|
81
|
+
return readBuildGroupMembership(result, buildId).map((group) => group.id);
|
|
82
|
+
},
|
|
83
|
+
async addBuildToGroups(buildId, groupIds) {
|
|
84
|
+
const result = await run(['builds', 'add-groups', '--build-id', buildId, '--group', groupIds.join(',')], ascLookupTimeoutMs);
|
|
85
|
+
if (!isRecord(result) || result.action !== 'added' || result.buildId !== buildId
|
|
86
|
+
|| !Array.isArray(result.groupIds)
|
|
87
|
+
|| result.groupIds.some((id) => typeof id !== 'string' || !groupIds.includes(id))) {
|
|
88
|
+
throw new Error('asc builds add-groups returned an unexpected result.');
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
async listWhatToTest(buildId) {
|
|
92
|
+
return list(await run(['builds', 'test-notes', 'list', '--build-id', buildId, '--paginate'], ascListTimeoutMs), 'builds test-notes list').map((localization) => ({
|
|
93
|
+
id: localization.id,
|
|
94
|
+
locale: requiredString(localization.attributes.locale, 'What to Test locale'),
|
|
95
|
+
...optionalString('whatsNew', localization.attributes.whatsNew),
|
|
96
|
+
}));
|
|
97
|
+
},
|
|
98
|
+
async putWhatToTest(buildId, locale, text) {
|
|
99
|
+
single(await run([
|
|
100
|
+
'builds', 'test-notes', 'create',
|
|
101
|
+
'--build-id', buildId,
|
|
102
|
+
'--locale', locale,
|
|
103
|
+
'--whats-new', text,
|
|
104
|
+
], ascLookupTimeoutMs), 'builds test-notes create');
|
|
105
|
+
},
|
|
106
|
+
async readBetaReviewDetail(appId) {
|
|
107
|
+
const details = list(await run(['testflight', 'review', 'view', '--app', appId], ascLookupTimeoutMs), 'testflight review view');
|
|
108
|
+
const detail = details[0];
|
|
109
|
+
if (detail === undefined || details.length > 1) {
|
|
110
|
+
throw new Error("asc testflight review view did not return the app's one review detail.");
|
|
111
|
+
}
|
|
112
|
+
const attributes = detail.attributes;
|
|
113
|
+
return {
|
|
114
|
+
id: detail.id,
|
|
115
|
+
...optionalString('contactFirstName', attributes.contactFirstName),
|
|
116
|
+
...optionalString('contactLastName', attributes.contactLastName),
|
|
117
|
+
...optionalString('contactEmail', attributes.contactEmail),
|
|
118
|
+
...optionalString('contactPhone', attributes.contactPhone),
|
|
119
|
+
...optionalString('notes', attributes.notes),
|
|
120
|
+
...optionalString('demoAccountName', attributes.demoAccountName),
|
|
121
|
+
demoAccountPasswordSet: typeof attributes.demoAccountPassword === 'string'
|
|
122
|
+
&& attributes.demoAccountPassword !== '',
|
|
123
|
+
// asc omits a false demoAccountRequired from its JSON.
|
|
124
|
+
demoAccountRequired: attributes.demoAccountRequired === true,
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
async updateBetaReviewDetail(detailId, changes) {
|
|
128
|
+
single(await run(['testflight', 'review', 'edit', '--id', detailId, ...reviewDetailFlags(changes)], ascLookupTimeoutMs), 'testflight review edit');
|
|
129
|
+
},
|
|
130
|
+
async listBetaReviewSubmissions(buildId) {
|
|
131
|
+
return list(await run(['testflight', 'review', 'submissions', 'list', '--build-id', buildId, '--paginate'], ascLookupTimeoutMs), 'testflight review submissions list').map(readSubmission);
|
|
132
|
+
},
|
|
133
|
+
async submitForBetaReview(buildId) {
|
|
134
|
+
return readSubmission(single(await run(['testflight', 'review', 'submit', '--build-id', buildId, '--confirm'], ascLookupTimeoutMs), 'testflight review submit'));
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function readSubmission(resource) {
|
|
139
|
+
return {
|
|
140
|
+
id: resource.id,
|
|
141
|
+
betaReviewState: requiredString(resource.attributes.betaReviewState, 'beta review state'),
|
|
142
|
+
...optionalString('submittedDate', resource.attributes.submittedDate),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function reviewDetailFlags(changes) {
|
|
146
|
+
const flags = [];
|
|
147
|
+
const text = [
|
|
148
|
+
['--contact-first-name', changes.contactFirstName],
|
|
149
|
+
['--contact-last-name', changes.contactLastName],
|
|
150
|
+
['--contact-email', changes.contactEmail],
|
|
151
|
+
['--contact-phone', changes.contactPhone],
|
|
152
|
+
['--notes', changes.notes],
|
|
153
|
+
['--demo-account-name', changes.demoAccountName],
|
|
154
|
+
// asc 5.5.0 takes the password only as a flag value; the session redacts
|
|
155
|
+
// it from asc's output.
|
|
156
|
+
['--demo-account-password', changes.demoAccountPassword],
|
|
157
|
+
];
|
|
158
|
+
for (const [flag, value] of text) {
|
|
159
|
+
if (value !== undefined) {
|
|
160
|
+
flags.push(flag, value);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (changes.demoAccountRequired !== undefined) {
|
|
164
|
+
flags.push(`--demo-account-required=${String(changes.demoAccountRequired)}`);
|
|
165
|
+
}
|
|
166
|
+
if (flags.length === 0) {
|
|
167
|
+
throw new Error('A TestFlight review detail update needs at least one change.');
|
|
168
|
+
}
|
|
169
|
+
return flags;
|
|
170
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The App Store Connect operations TestFlight distribution needs, as data the
|
|
3
|
+
* planner can compare. `testflight-distribution-asc.ts` implements it on the
|
|
4
|
+
* pinned `asc` 5.5.0; the tests use an in-memory fake.
|
|
5
|
+
*/
|
|
6
|
+
export interface TestFlightBuild {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
/** App Store Connect `Build.processingState`: PROCESSING, FAILED, INVALID or VALID. */
|
|
9
|
+
readonly processingState: string;
|
|
10
|
+
}
|
|
11
|
+
export interface TestFlightGroup {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly isInternal: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** App Store Connect `BuildBetaDetail` states (`InternalBetaState`, `ExternalBetaState`). */
|
|
17
|
+
export interface TestFlightBuildBetaState {
|
|
18
|
+
readonly internalBuildState: string;
|
|
19
|
+
readonly externalBuildState: string;
|
|
20
|
+
}
|
|
21
|
+
/** One `BetaBuildLocalization`: the build's "What to Test" text in one locale. */
|
|
22
|
+
export interface TestFlightWhatToTest {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly locale: string;
|
|
25
|
+
readonly whatsNew?: string;
|
|
26
|
+
}
|
|
27
|
+
/** The app's `BetaAppReviewDetail`; asc never prints the demo account password. */
|
|
28
|
+
export interface TestFlightBetaReviewDetail {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly contactFirstName?: string;
|
|
31
|
+
readonly contactLastName?: string;
|
|
32
|
+
readonly contactEmail?: string;
|
|
33
|
+
readonly contactPhone?: string;
|
|
34
|
+
readonly notes?: string;
|
|
35
|
+
readonly demoAccountName?: string;
|
|
36
|
+
/** asc 5.5.0 replaces a stored password with a placeholder; only its presence is known. */
|
|
37
|
+
readonly demoAccountPasswordSet: boolean;
|
|
38
|
+
readonly demoAccountRequired: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface TestFlightBetaReviewDetailChanges {
|
|
41
|
+
readonly contactFirstName?: string;
|
|
42
|
+
readonly contactLastName?: string;
|
|
43
|
+
readonly contactEmail?: string;
|
|
44
|
+
readonly contactPhone?: string;
|
|
45
|
+
readonly notes?: string;
|
|
46
|
+
readonly demoAccountName?: string;
|
|
47
|
+
readonly demoAccountPassword?: string;
|
|
48
|
+
readonly demoAccountRequired?: boolean;
|
|
49
|
+
}
|
|
50
|
+
/** A `BetaAppReviewSubmission`; `betaReviewState` is a `BetaReviewState`. */
|
|
51
|
+
export interface TestFlightBetaReviewSubmission {
|
|
52
|
+
readonly id: string;
|
|
53
|
+
readonly betaReviewState: string;
|
|
54
|
+
readonly submittedDate?: string;
|
|
55
|
+
}
|
|
56
|
+
export interface TestFlightDistributionPort {
|
|
57
|
+
readAppBundleId(appId: string): Promise<string>;
|
|
58
|
+
/** The build with this marketing version and build number, if App Store Connect has it. */
|
|
59
|
+
findBuild(appId: string, marketingVersion: string, buildNumber: string): Promise<TestFlightBuild | undefined>;
|
|
60
|
+
listGroups(appId: string): Promise<readonly TestFlightGroup[]>;
|
|
61
|
+
readBuildBetaState(buildId: string): Promise<TestFlightBuildBetaState>;
|
|
62
|
+
/** IDs of the groups that contain the build; throws when the lookup is incomplete. */
|
|
63
|
+
listBuildGroupIds(appId: string, buildId: string): Promise<readonly string[]>;
|
|
64
|
+
/** Adds the build to these groups; never submits it for review. */
|
|
65
|
+
addBuildToGroups(buildId: string, groupIds: readonly string[]): Promise<void>;
|
|
66
|
+
listWhatToTest(buildId: string): Promise<readonly TestFlightWhatToTest[]>;
|
|
67
|
+
/** Creates or updates the "What to Test" text of one locale. */
|
|
68
|
+
putWhatToTest(buildId: string, locale: string, text: string): Promise<void>;
|
|
69
|
+
readBetaReviewDetail(appId: string): Promise<TestFlightBetaReviewDetail>;
|
|
70
|
+
updateBetaReviewDetail(detailId: string, changes: TestFlightBetaReviewDetailChanges): Promise<void>;
|
|
71
|
+
listBetaReviewSubmissions(buildId: string): Promise<readonly TestFlightBetaReviewSubmission[]>;
|
|
72
|
+
submitForBetaReview(buildId: string): Promise<TestFlightBetaReviewSubmission>;
|
|
73
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { type PlayReleaseNote } from './play-release-options.js';
|
|
2
|
+
import type { TestFlightBetaReviewSubmission, TestFlightDistributionPort } from './testflight-distribution-port.js';
|
|
3
|
+
/**
|
|
4
|
+
* TestFlight distribution of an already uploaded iOS build beyond the
|
|
5
|
+
* internal group of `mpgd deploy submit`: more internal or external groups,
|
|
6
|
+
* the build's "What to Test" per locale, the app's TestFlight review details
|
|
7
|
+
* and, for an external group, the TestFlight App Review submission.
|
|
8
|
+
*
|
|
9
|
+
* Every run reads App Store Connect first and writes only what differs, so a
|
|
10
|
+
* re-run never adds a group twice or submits a build again.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The build's TestFlight App Review state for external testers:
|
|
14
|
+
*
|
|
15
|
+
* - `not-needed`: no external group was asked for, or App Store Connect lets
|
|
16
|
+
* external testers have the build without a review submission (Apple: "the
|
|
17
|
+
* first build you submit requires a full review, but later builds for the
|
|
18
|
+
* same version might not")
|
|
19
|
+
* - `not-submitted`: the build can be submitted (dry run, or a status read)
|
|
20
|
+
* - `waiting-for-review`, `in-review`, `approved`, `rejected`
|
|
21
|
+
* - `action-required`: TestFlight waits for the export-compliance answer
|
|
22
|
+
*/
|
|
23
|
+
export type TestFlightBetaReviewStatus = 'not-needed' | 'not-submitted' | 'waiting-for-review' | 'in-review' | 'approved' | 'rejected' | 'action-required';
|
|
24
|
+
export interface TestFlightBuildTarget {
|
|
25
|
+
/** Numeric App Store Connect app ID. */
|
|
26
|
+
readonly appStoreAppId: string;
|
|
27
|
+
readonly bundleId: string;
|
|
28
|
+
readonly marketingVersion: string;
|
|
29
|
+
readonly buildNumber: string;
|
|
30
|
+
/**
|
|
31
|
+
* The build ID the release state recorded once the submission found the
|
|
32
|
+
* build. `distribute` requires it; `status` matches by version and build
|
|
33
|
+
* number without it and says so (`buildIdRecorded: false`).
|
|
34
|
+
*/
|
|
35
|
+
readonly expectedBuildId?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface TestFlightBetaReviewInput {
|
|
38
|
+
readonly contactFirstName?: string;
|
|
39
|
+
readonly contactLastName?: string;
|
|
40
|
+
readonly contactEmail?: string;
|
|
41
|
+
readonly contactPhone?: string;
|
|
42
|
+
readonly notes?: string;
|
|
43
|
+
readonly demoAccount?: {
|
|
44
|
+
readonly required: boolean;
|
|
45
|
+
/** Read from the environment variable the review file names. */
|
|
46
|
+
readonly name?: string;
|
|
47
|
+
/** Read from the environment variable the review file names; never printed. */
|
|
48
|
+
readonly password?: string;
|
|
49
|
+
readonly nameEnv?: string;
|
|
50
|
+
readonly passwordEnv?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Variables a dry run found unset (`requireValues: false`). A plan lists
|
|
53
|
+
* them; `applyTestFlightDistribution` refuses them before any call.
|
|
54
|
+
*/
|
|
55
|
+
readonly unresolvedEnv?: readonly string[];
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export interface TestFlightBetaReviewReadOptions {
|
|
59
|
+
/** False for a dry run: unset demo account variables are reported, not refused. Default true. */
|
|
60
|
+
readonly requireValues?: boolean;
|
|
61
|
+
}
|
|
62
|
+
export interface TestFlightDistributionOptions {
|
|
63
|
+
/** Waits between membership reads after adding groups; tests pass a no-op. */
|
|
64
|
+
readonly sleep?: (milliseconds: number) => Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
export interface TestFlightDistributionRequest extends TestFlightBuildTarget {
|
|
67
|
+
/** Group IDs or exact group names; internal and external groups may be mixed. */
|
|
68
|
+
readonly groups: readonly string[];
|
|
69
|
+
readonly whatToTest?: readonly PlayReleaseNote[];
|
|
70
|
+
readonly betaReview?: TestFlightBetaReviewInput;
|
|
71
|
+
}
|
|
72
|
+
export type TestFlightGroupMembership = 'present' | 'add' | 'added' | 'after-review' | 'blocked';
|
|
73
|
+
export interface TestFlightDistributionReport {
|
|
74
|
+
readonly dryRun: boolean;
|
|
75
|
+
readonly appStoreAppId: string;
|
|
76
|
+
readonly marketingVersion: string;
|
|
77
|
+
readonly buildNumber: string;
|
|
78
|
+
readonly buildId: string;
|
|
79
|
+
readonly internalBuildState: string;
|
|
80
|
+
readonly externalBuildState: string;
|
|
81
|
+
readonly groups: readonly {
|
|
82
|
+
readonly id: string;
|
|
83
|
+
readonly name: string;
|
|
84
|
+
readonly external: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* `after-review`: App Store Connect accepts the group once the open review
|
|
87
|
+
* ends. `blocked`: not added until the `action-required` step is done.
|
|
88
|
+
*/
|
|
89
|
+
readonly membership: TestFlightGroupMembership;
|
|
90
|
+
}[];
|
|
91
|
+
readonly whatToTest: readonly {
|
|
92
|
+
readonly locale: string;
|
|
93
|
+
readonly action: 'unchanged' | 'create' | 'update';
|
|
94
|
+
}[];
|
|
95
|
+
/** Names of the review detail fields this run writes (or would write); never their values. */
|
|
96
|
+
readonly reviewDetailFields: readonly string[];
|
|
97
|
+
/** Inputs a dry run could not resolve, e.g. `demo account values: missing NAME`; apply needs them. */
|
|
98
|
+
readonly pendingInputs?: readonly string[];
|
|
99
|
+
readonly betaReview: {
|
|
100
|
+
readonly status: TestFlightBetaReviewStatus;
|
|
101
|
+
/** True when this run submits (dry run: would submit) the build. */
|
|
102
|
+
readonly submit: boolean;
|
|
103
|
+
readonly submissionId?: string;
|
|
104
|
+
};
|
|
105
|
+
readonly detail?: string;
|
|
106
|
+
}
|
|
107
|
+
export interface TestFlightStatusReport {
|
|
108
|
+
readonly appStoreAppId: string;
|
|
109
|
+
readonly marketingVersion: string;
|
|
110
|
+
readonly buildNumber: string;
|
|
111
|
+
readonly buildId?: string;
|
|
112
|
+
/**
|
|
113
|
+
* False when the release state has no build ID yet, so the build was
|
|
114
|
+
* matched by app, version and build number only.
|
|
115
|
+
*/
|
|
116
|
+
readonly buildIdRecorded: boolean;
|
|
117
|
+
readonly processingState?: string;
|
|
118
|
+
readonly internalBuildState?: string;
|
|
119
|
+
readonly externalBuildState?: string;
|
|
120
|
+
/** Groups that contain the build. */
|
|
121
|
+
readonly groups: readonly {
|
|
122
|
+
readonly id: string;
|
|
123
|
+
readonly name: string;
|
|
124
|
+
readonly external: boolean;
|
|
125
|
+
}[];
|
|
126
|
+
/**
|
|
127
|
+
* `build-not-ready`: not uploaded or still processing; poll again.
|
|
128
|
+
* `build-unusable`: failed processing, expired or internal-only; it never
|
|
129
|
+
* becomes testable, so stop polling.
|
|
130
|
+
*/
|
|
131
|
+
readonly betaReview: TestFlightBetaReviewStatus | 'build-not-ready' | 'build-unusable';
|
|
132
|
+
readonly submission?: TestFlightBetaReviewSubmission;
|
|
133
|
+
readonly detail?: string;
|
|
134
|
+
}
|
|
135
|
+
/** App Store Connect `BetaAppReviewDetail.notes`: "a maximum of 4,000 characters". */
|
|
136
|
+
export declare const maximumBetaReviewNotesCharacters = 4000;
|
|
137
|
+
/** Read App Store Connect and print what `applyTestFlightDistribution` would write. */
|
|
138
|
+
export declare function planTestFlightDistribution(port: TestFlightDistributionPort, request: TestFlightDistributionRequest): Promise<TestFlightDistributionReport>;
|
|
139
|
+
/** Write the differences the plan found: What to Test, groups, review details, submission. */
|
|
140
|
+
export declare function applyTestFlightDistribution(port: TestFlightDistributionPort, request: TestFlightDistributionRequest, options?: TestFlightDistributionOptions): Promise<TestFlightDistributionReport>;
|
|
141
|
+
/** A read-only status of the build's TestFlight groups and App Review, for CI polling. */
|
|
142
|
+
export declare function readTestFlightBuildStatus(port: TestFlightDistributionPort, target: TestFlightBuildTarget): Promise<TestFlightStatusReport>;
|
|
143
|
+
/**
|
|
144
|
+
* Read a TestFlight review details file. Contact fields and notes are plain
|
|
145
|
+
* values; the demo account only names environment variables, which are read
|
|
146
|
+
* here so a sign-in never sits in a committed file.
|
|
147
|
+
*
|
|
148
|
+
* {
|
|
149
|
+
* "contactFirstName": "Ada", "contactLastName": "Kim",
|
|
150
|
+
* "contactEmail": "qa@example.com", "contactPhone": "+82 10 0000 0000",
|
|
151
|
+
* "notes": "Guest play; no sign-in needed.",
|
|
152
|
+
* "demoAccount": { "required": false }
|
|
153
|
+
* }
|
|
154
|
+
*
|
|
155
|
+
* With `"required": true`, `nameEnv` and `passwordEnv` name the variables.
|
|
156
|
+
*/
|
|
157
|
+
export declare function readTestFlightBetaReviewFile(file: string, environment?: NodeJS.ProcessEnv, options?: TestFlightBetaReviewReadOptions): TestFlightBetaReviewInput;
|
|
158
|
+
export declare function parseTestFlightBetaReview(value: unknown, environment?: NodeJS.ProcessEnv, options?: TestFlightBetaReviewReadOptions): TestFlightBetaReviewInput;
|
|
159
|
+
/** The "What to Test" file: the release-notes format of `--release-notes`, without Play's length limit. */
|
|
160
|
+
export declare function readTestFlightWhatToTestFile(file: string): PlayReleaseNote[];
|
|
161
|
+
/** Split a `--groups` value into group IDs or names. */
|
|
162
|
+
export declare function parseTestFlightGroups(value: string): string[];
|