@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.
@@ -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(`Release notes file is missing: ${resolved}`);
158
+ throw new Error(`${label} file is missing: ${resolved}`);
148
159
  }
149
160
  if (statSync(resolved).size > maximumReleaseNotesFileBytes) {
150
- throw new Error('Release notes file is larger than 256 KiB.');
161
+ throw new Error(`${label} file is larger than 256 KiB.`);
151
162
  }
152
- return parsePlayReleaseNotes(readFileSync(resolved, 'utf8'));
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('Release notes file is empty.');
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('Release notes JSON is malformed.');
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('Release notes JSON array entries need only string language and text.');
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(`Release notes for ${language} must be a string.`);
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('Release notes text must sit between <language> and </language> tags.');
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('Release notes text must sit between <language> and </language> tags.');
227
+ throw new Error(`${label} text must sit between <language> and </language> tags.`);
211
228
  }
212
229
  }
213
- assertPlayReleaseNotes(notes);
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('Release notes need at least one language.');
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(`Release notes language is not a BCP-47 tag: ${note.language}`);
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(`Release notes repeat the language ${note.language}.`);
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(`Release notes for ${note.language} are empty.`);
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 in App Store '
102
- + 'Connect, then plan again. The sync never creates or submits a version.');
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, App Review notes and demo account, pricing, availability, release '
143
- + 'settings and submitting for review stay in App Store Connect.');
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 in App Store Connect. The first consumable and the '
91
- + 'first non-consumable must each go with a new app version (version page > In-App Purchases); '
92
- + 'the sync never submits.');
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,6 @@
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 {};
@@ -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[];