diff --git a/README.de.md b/README.de.md index 9f5e9133..cbc6e1fe 100644 --- a/README.de.md +++ b/README.de.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2934 tests passing

diff --git a/README.es.md b/README.es.md index 8f8e7c42..656d66f3 100644 --- a/README.es.md +++ b/README.es.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2934 tests passing

diff --git a/README.fr.md b/README.fr.md index d3ee4d6c..cf68e00c 100644 --- a/README.fr.md +++ b/README.fr.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2934 tests passing

diff --git a/README.ja.md b/README.ja.md index 4f7b16b1..83ab2c42 100644 --- a/README.ja.md +++ b/README.ja.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2934 tests passing

diff --git a/README.ko.md b/README.ko.md index 852d54ae..e19d308e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2934 tests passing

diff --git a/README.md b/README.md index 96424ef0..1489a335 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2934 tests passing

diff --git a/README.pt-BR.md b/README.pt-BR.md index 5f4d23df..eab13835 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2934 tests passing

diff --git a/README.ru.md b/README.ru.md index 6b47bf49..5e9845ce 100644 --- a/README.ru.md +++ b/README.ru.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2934 tests passing

diff --git a/README.zh-CN.md b/README.zh-CN.md index c0daaf59..b943dd56 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,7 +20,7 @@

205 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2917 tests passing + 2934 tests passing

diff --git a/llms-full.txt b/llms-full.txt index e0c61b3b..a7d65aeb 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -146,7 +146,7 @@ Each is declared in `launch.config.ts` (or a `*.config.json` sidecar) and reconc ## Commands -All 65 `launch` commands (205 store-API operations underneath, 2917 tests): +All 65 `launch` commands (205 store-API operations underneath, 2934 tests): - `launch init` - scaffold launch.config.ts (and .env.example) into the current repo - `launch adopt` - onboard an app that already ships: import its App Store Connect setup into config @@ -161,7 +161,7 @@ All 65 `launch` commands (205 store-API operations underneath, 2917 tests): - `launch config help [command]` - display help for command - `launch build ` - run the full pipeline and upload to the testing track (--no-submit to build only) - `launch release ` - submit the latest build to the store's PUBLIC production track (with confirmation) -- `launch status` - show each app's App Store version, review, and phased-rollout state +- `launch status` - show each app's App Store version, build, review, and phased-rollout state - `launch open [target]` - deep-link the app's App Store Connect / Play Console page in your browser - `launch dashboard` - serve a local, read-only web UI over your apps, builds, accounts, and secrets - `launch release-train [action] [id]` - coordinate an app's iOS + Android + OTA release as one resumable record diff --git a/llms.txt b/llms.txt index 9aa27f8a..90fbf4d6 100644 --- a/llms.txt +++ b/llms.txt @@ -146,7 +146,7 @@ Each is declared in `launch.config.ts` (or a `*.config.json` sidecar) and reconc ## Commands -All 65 `launch` commands (205 store-API operations underneath, 2917 tests): +All 65 `launch` commands (205 store-API operations underneath, 2934 tests): - `launch init` - scaffold launch.config.ts (and .env.example) into the current repo - `launch adopt` - onboard an app that already ships: import its App Store Connect setup into config @@ -161,7 +161,7 @@ All 65 `launch` commands (205 store-API operations underneath, 2917 tests): - `launch config help [command]` - display help for command - `launch build ` - run the full pipeline and upload to the testing track (--no-submit to build only) - `launch release ` - submit the latest build to the store's PUBLIC production track (with confirmation) -- `launch status` - show each app's App Store version, review, and phased-rollout state +- `launch status` - show each app's App Store version, build, review, and phased-rollout state - `launch open [target]` - deep-link the app's App Store Connect / Play Console page in your browser - `launch dashboard` - serve a local, read-only web UI over your apps, builds, accounts, and secrets - `launch release-train [action] [id]` - coordinate an app's iOS + Android + OTA release as one resumable record diff --git a/src/apple/ascClient.test.ts b/src/apple/ascClient.test.ts index cffcebea..e4945417 100644 --- a/src/apple/ascClient.test.ts +++ b/src/apple/ascClient.test.ts @@ -3084,7 +3084,7 @@ describe('AppStoreConnectClient - App Store release lifecycle', () => { marketingVersion: '6.11.77', }); expect(expectArrayElement(fetchMock.mock.calls, 0, 'mock call')[0]).toContain( - 'filter[version]=591&limit=1&fields[builds]=version,processingState,uploadedDate,expired,preReleaseVersion&include=preReleaseVersion', + 'filter[version]=591&sort=-uploadedDate&limit=1&fields[builds]=version,processingState,uploadedDate,expired,preReleaseVersion&include=preReleaseVersion', ); }); it('declares export compliance with a PATCH to the build', async () => { @@ -3392,4 +3392,95 @@ describe('AppStoreConnectClient - app store versions', () => { expect(expectArrayElement(versions, 0, 'version').attachedBuildId).toBe('b-42'); expect(expectArrayElement(versions, 1, 'version').attachedBuildId).toBeUndefined(); }); + + it('reads the attached build number and processing state from the included build', async () => { + fetchMock.mockResolvedValueOnce( + fakeResponse( + 200, + JSON.stringify({ + data: [ + { + id: 'v-edit', + attributes: { versionString: '1.2.0', appStoreState: 'PREPARE_FOR_SUBMISSION' }, + relationships: { build: { data: { type: 'builds', id: 'b-42' } } }, + }, + ], + included: [ + { type: 'builds', id: 'b-42', attributes: { version: '42', processingState: 'VALID' } }, + ], + }), + ), + ); + const versions = await client.listAppStoreVersions('app1', 'IOS'); + expect(expectArrayElement(versions, 0, 'version')).toMatchObject({ + attachedBuildNumber: '42', + attachedBuildProcessingState: 'VALID', + }); + }); + + it('keeps each attached build number when the versions span two pages', async () => { + const page2Url = + 'https://api.appstoreconnect.apple.com/v1/apps/app1/appStoreVersions?cursor=page-2'; + fetchMock + .mockResolvedValueOnce( + fakeResponse( + 200, + JSON.stringify({ + data: [ + { + id: 'v-1', + attributes: { versionString: '1.2.0', appStoreState: 'IN_REVIEW' }, + relationships: { build: { data: { type: 'builds', id: 'b-1' } } }, + }, + ], + included: [ + { + type: 'builds', + id: 'b-1', + attributes: { version: '42', processingState: 'PROCESSING' }, + }, + ], + links: { next: page2Url }, + }), + ), + ) + .mockResolvedValueOnce( + fakeResponse( + 200, + JSON.stringify({ + data: [ + { + id: 'v-2', + attributes: { versionString: '1.1.0', appStoreState: 'READY_FOR_SALE' }, + relationships: { build: { data: { type: 'builds', id: 'b-2' } } }, + }, + ], + included: [ + { + type: 'builds', + id: 'b-2', + attributes: { version: '41', processingState: 'VALID' }, + }, + ], + }), + ), + ); + + const versions = await client.listAppStoreVersions('app1', 'IOS'); + + expect(versions.map((version) => version.attachedBuildNumber)).toEqual(['42', '41']); + expect(String(expectArrayElement(fetchMock.mock.calls, 1, 'mock call')[0])).toBe(page2Url); + }); + + it('asks Apple for the build of one marketing version, newest upload first', async () => { + fetchMock.mockResolvedValueOnce(fakeResponse(200, JSON.stringify({ data: [] }))); + + const storeBuild = await client.findBuildByVersion('app1', 9, '1.2.0'); + + const [requestUrl] = expectArrayElement(fetchMock.mock.calls, 0, 'mock call'); + expect(String(requestUrl)).toContain( + 'filter[version]=9&filter[preReleaseVersion.version]=1.2.0&sort=-uploadedDate&limit=1', + ); + expect(storeBuild).toBeNull(); + }); }); diff --git a/src/apple/ascClient.ts b/src/apple/ascClient.ts index c212c935..51f36682 100644 --- a/src/apple/ascClient.ts +++ b/src/apple/ascClient.ts @@ -553,13 +553,26 @@ type BetaFeedbackPage = { }; const BUILD_PAGE_QUERY = 'fields[builds]=version,processingState,uploadedDate,expired,preReleaseVersion&include=preReleaseVersion&fields[preReleaseVersions]=version'; +const createMarketingVersionFilter = (marketingVersion: string | undefined): string => { + const isMarketingVersionGiven = marketingVersion !== undefined; + if (!isMarketingVersionGiven) return ''; + return `&filter[preReleaseVersion.version]=${marketingVersion}`; +}; type BuildPageIncluded = { type: string; id: string; attributes?: { version?: string; + processingState?: string; }; }; +type AppStoreVersionPage = PagedList<{ + versionString?: string; + appStoreState?: string; + releaseType?: string; +}> & { + included?: BuildPageIncluded[]; +}; type BuildPage = { data: { id: string; @@ -898,15 +911,21 @@ export class AppStoreConnectClient { // A is the caller-specified attributes shape of the returned rows; no argument infers it, so the param is required. // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters private async requestAll(path: string): Promise[]> { - const all: AppleResource[] = []; + const pages = await this.requestPages>(path); + return pages.flatMap((page) => page.data); + } + private async requestPages( + path: string, + ): Promise { + const pages: Page[] = []; let next: string | undefined = path; while (next) { // biome-ignore lint/performance/noAwaitInLoops: cursor pagination - each page’s `next` link comes from the prior response, so the reads are inherently sequential - const page: PagedList = await this.request>('GET', next); - all.push(...page.data); + const page: Page = await this.request('GET', next); + pages.push(page); next = page.links?.next; } - return all; + return pages; } /** * Run a read that may legitimately find nothing, mapping App Store Connect's `404` to `null` while @@ -3961,10 +3980,15 @@ export class AppStoreConnectClient { return toBuildResources(buildPage); } /** Find one build by its `CFBundleVersion` number (the resource id is needed to attach / PATCH it), or null. */ - async findBuildByVersion(appId: string, buildNumber: number): Promise { + async findBuildByVersion( + appId: string, + buildNumber: number, + marketingVersion?: string, + ): Promise { + const versionFilter = createMarketingVersionFilter(marketingVersion); const buildPage = await this.request( 'GET', - `/builds?filter[app]=${appId}&filter[version]=${buildNumber}&limit=1&${BUILD_PAGE_QUERY}`, + `/builds?filter[app]=${appId}&filter[version]=${buildNumber}${versionFilter}&sort=-uploadedDate&limit=1&${BUILD_PAGE_QUERY}`, ); const [firstBuild] = toBuildResources(buildPage); if (firstBuild === undefined) return null; @@ -4034,16 +4058,13 @@ export class AppStoreConnectClient { relationships: { build: { data: { type: 'builds', id: buildId } } }, }); } - /** List an app's App Store versions for a platform (e.g. `IOS`), across all pages. */ async listAppStoreVersions(appId: string, platform: string): Promise { - const appleResources = await this.requestAll<{ - versionString?: string; - appStoreState?: string; - releaseType?: string; - }>( - `/apps/${appId}/appStoreVersions?filter[platform]=${platform}&limit=200&fields[appStoreVersions]=versionString,appStoreState,releaseType,build`, + const versionPages = await this.requestPages( + `/apps/${appId}/appStoreVersions?filter[platform]=${platform}&limit=200&fields[appStoreVersions]=versionString,appStoreState,releaseType,build&include=build&fields[builds]=version,processingState`, + ); + return versionPages.flatMap(({ data: entries, included = [] }) => + entries.map((versionEntry) => toVersionResource(versionEntry, '', included)), ); - return appleResources.map((entry) => toVersionResource(entry)); } /** Create a new App Store version for a marketing version string on a platform. */ async createAppStoreVersion( @@ -4978,6 +4999,7 @@ const toVersionResource = ( releaseType?: string; }>, fallbackVersion = '', + includedBuilds: readonly BuildPageIncluded[] = [], ): AppStoreVersionResource => { let versionString = entry.attributes.versionString; if (versionString === undefined) versionString = fallbackVersion; @@ -4986,12 +5008,19 @@ const toVersionResource = ( const releaseType = entry.attributes.releaseType; const attachedBuildId = entry.relationships?.build?.data?.id; const isBuildAttached = attachedBuildId !== undefined; + const attachedBuild = includedBuilds.find( + (includedBuild) => includedBuild.type === 'builds' && includedBuild.id === attachedBuildId, + ); + const attachedBuildNumber = attachedBuild?.attributes?.version; + const attachedBuildProcessingState = attachedBuild?.attributes?.processingState; const version: AppStoreVersionResource = { id: entry.id, versionString, appStoreState, ...(releaseType !== undefined && { releaseType }), ...(isBuildAttached && { attachedBuildId }), + ...(attachedBuildNumber !== undefined && { attachedBuildNumber }), + ...(attachedBuildProcessingState !== undefined && { attachedBuildProcessingState }), }; return version; }; diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index 837b540f..2b7454b7 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -1,16 +1,25 @@ import type { Command } from 'commander'; -import { statusCommandProgram, type StatusCommandInput } from '@core/release/statusCommand.js'; +import { type StatusCommandFlags, statusCommandProgram } from '@core/release/statusCommand.js'; import { runCliProgram } from '../runCliProgram.js'; /** Attach the App Store status command. */ export const registerStatusCommand = (program: Command): void => { program .command('status') - .description("show each app's App Store version, review, and phased-rollout state") + .description("show each app's App Store version, build, review, and phased-rollout state") .option('-a, --app ', 'comma-separated app handles (default: all iOS apps)') + .option( + '--account ', + 'iOS only - Apple account label or Key ID (default: ASC_ACCOUNT, then the active account)', + ) .option('--watch', 'poll until the review reaches a terminal verdict', false) + .option('--build ', 'with --watch: wait until this build is VALID, then exit') + .option( + '--builds', + 'list each app recent builds: version, build, processing state, upload time', + ) .option('--json', 'machine-readable output for CI', false) - .action((commandOptions: StatusCommandInput) => - runCliProgram(statusCommandProgram(commandOptions)), + .action((commandFlags: StatusCommandFlags) => + runCliProgram(statusCommandProgram(commandFlags)), ); }; diff --git a/src/core/build/buildHistoryCommand.ts b/src/core/build/buildHistoryCommand.ts index e86f2de2..a3560628 100644 --- a/src/core/build/buildHistoryCommand.ts +++ b/src/core/build/buildHistoryCommand.ts @@ -18,6 +18,7 @@ import { buildLogId, buildLogPath, readBuildLog } from './buildLog.js'; import { sizeSummary, worstDownloadBytes } from './pipelineArtifact.js'; import { mb } from './pipelineProviders.js'; import { hasMessage } from '../services/errorMessage.js'; +import { alignTableColumns, type Column } from '../terminal/table.js'; /** Stable presentation shape emitted by `builds list --json`. */ export type BuildRow = Readonly<{ @@ -185,40 +186,6 @@ export const buildKindLabel = (flags: Readonly<{ clean: boolean; prunedAt?: stri return 'incremental'; }; -/** A table column and its domain-specific cell renderer. */ -type Column = Readonly<{ - header: string; - cell: (tableEntry: TableEntry) => string; -}>; - -/** Render a non-empty collection as a padded text table. */ -const formatTable = ( - columns: readonly Column[], - tableEntries: readonly TableEntry[], -): string => { - const widths = columns.map((column) => - Math.max( - column.header.length, - ...tableEntries.map((tableEntry) => column.cell(tableEntry).length), - ), - ); - const renderCells = (cells: readonly string[]): string => - cells - .map((cell, columnIndex) => { - const width = widths[columnIndex]; - if (width === undefined) return cell; - return cell.padEnd(width); - }) - .join(' ') - .trimEnd(); - return [ - renderCells(columns.map((column) => column.header)), - ...tableEntries.map((tableEntry) => - renderCells(columns.map((column) => column.cell(tableEntry))), - ), - ].join('\n'); -}; - const BUILD_COLUMNS: readonly Column[] = [ { header: 'BUILD', cell: (buildRow) => String(buildRow.buildNumber) }, { header: 'APP', cell: (buildRow) => buildRow.app }, @@ -239,11 +206,11 @@ const PRUNE_COLUMNS: readonly Column[] = [ /** Render build history as a compact table. */ export const formatBuildsTable = (buildRows: readonly BuildRow[]): string => - formatTable(BUILD_COLUMNS, buildRows); + alignTableColumns(BUILD_COLUMNS, buildRows); /** Render the binaries selected by a prune preview. */ export const formatPrunePreview = (prunedArtifacts: readonly PrunedArtifact[]): string => - formatTable(PRUNE_COLUMNS, prunedArtifacts); + alignTableColumns(PRUNE_COLUMNS, prunedArtifacts); /** Render the detail block for one stored build. */ export const formatBuildDetail = (artifact: BuildArtifact): string => { diff --git a/src/core/credentials/accounts.test.ts b/src/core/credentials/accounts.test.ts index 8cdcaf53..c3b602e4 100644 --- a/src/core/credentials/accounts.test.ts +++ b/src/core/credentials/accounts.test.ts @@ -74,6 +74,7 @@ import { getActiveKeyId, listAccounts, loadActiveAscKey, + loadAscKeyForAccount, loadAscKeyById, matchAccount, migrateLegacyAccounts, @@ -282,15 +283,16 @@ describe('registry mutations', () => { expect(await runAccountEffect(getActiveKeyId())).toBe('AAAA1111'); }); }); +const addTwoAccounts = async () => { + await runAccountEffect( + addAccount({ keyId: 'AAAA1111', issuerId: 'issuer-a', label: 'Personal', p8: PEM }), + ); + await runAccountEffect( + addAccount({ keyId: 'BBBB2222', issuerId: 'issuer-b', label: 'Acme', p8: PEM }), + ); +}; + describe('loadActiveAscKey', () => { - const addTwoAccounts = async () => { - await runAccountEffect( - addAccount({ keyId: 'AAAA1111', issuerId: 'issuer-a', label: 'Personal', p8: PEM }), - ); - await runAccountEffect( - addAccount({ keyId: 'BBBB2222', issuerId: 'issuer-b', label: 'Acme', p8: PEM }), - ); - }; it('loads the active account without ASC_ACCOUNT', async () => { await addTwoAccounts(); expect((await loadActiveKeyWith({}))?.keyId).toBe('BBBB2222'); @@ -307,6 +309,53 @@ describe('loadActiveAscKey', () => { ); }); }); +describe('loadAscKeyForAccount', () => { + const loadKeyForAccountWith = ( + accountSelector: string | undefined, + environmentVariables: Readonly>, + ) => + Effect.runPromise( + loadAscKeyForAccount(accountSelector).pipe( + Effect.provide(makeLaunchEnvironmentTest(environmentVariables).pipe(Layer.orDie)), + Effect.provide(NodeContext.layer), + Effect.provide(makeLaunchPathsTest(home.dir, home.dir)), + Effect.provide(makeLaunchSecretStoreTest()), + ), + ); + + it('loads the account the flag names, even when another account is active', async () => { + await addTwoAccounts(); + + const ascKey = await loadKeyForAccountWith('personal', {}); + + expect(ascKey?.keyId).toBe('AAAA1111'); + }); + + it('falls back to the active account when no flag is given', async () => { + await addTwoAccounts(); + + const ascKey = await loadKeyForAccountWith(undefined, {}); + + expect(ascKey?.keyId).toBe('BBBB2222'); + }); + + it('lets the flag win over ASC_ACCOUNT', async () => { + await addTwoAccounts(); + + const ascKey = await loadKeyForAccountWith('AAAA1111', { ASC_ACCOUNT: 'BBBB2222' }); + + expect(ascKey?.keyId).toBe('AAAA1111'); + }); + + it('fails when the flag matches no account', async () => { + await addTwoAccounts(); + + await expect(loadKeyForAccountWith('nobody', {})).rejects.toThrow( + /No Apple account matching.*nobody/, + ); + }); +}); + describe('migrateLegacyAccounts', () => { it("moves a legacy single key into the registry as the active 'default' account", async () => { secrets.store.set('asc-key-id', 'KEYXYZ7890'); diff --git a/src/core/credentials/accounts.ts b/src/core/credentials/accounts.ts index 5fc113c7..676c193f 100644 --- a/src/core/credentials/accounts.ts +++ b/src/core/credentials/accounts.ts @@ -366,6 +366,18 @@ export const loadActiveAscKey = (): Effect.Effect< if (active) return yield* loadAscKeyById(active); return null; }); +export const loadAscKeyForAccount = ( + accountSelector: string | undefined, +): Effect.Effect< + AscKey | null, + unknown, + AccountStorageRequirements | LaunchEnvironmentService | LaunchSecretStoreService +> => + Effect.gen(function* () { + const isAccountSelected = accountSelector !== undefined; + if (isAccountSelected) return yield* loadSelectedAscKey(accountSelector); + return yield* loadActiveAscKey(); + }); /** * The decision tree for which account a build uses, factored out as a pure function so it's unit- * testable without the filesystem: an explicit selector wins (error if it matches nothing), else the diff --git a/src/core/release/appStoreRelease.test.ts b/src/core/release/appStoreRelease.test.ts index 395667ca..be067b3e 100644 --- a/src/core/release/appStoreRelease.test.ts +++ b/src/core/release/appStoreRelease.test.ts @@ -500,12 +500,19 @@ describe('pickCurrentVersion', () => { }); describe('readReleaseStatus', () => { - it('classifies the current version and latest build', async () => { + it('classifies the current version and the build attached to it', async () => { const api = makeReleaseApiFake({ listAppStoreVersions: vi.fn(() => - Effect.succeed([{ id: 'v2', versionString: '1.1.0', appStoreState: 'IN_REVIEW' }]), + Effect.succeed([ + { + id: 'v2', + versionString: '1.1.0', + appStoreState: 'IN_REVIEW', + attachedBuildNumber: '42', + attachedBuildProcessingState: 'VALID', + }, + ]), ), - listBuilds: vi.fn(() => Effect.succeed([VALID_BUILD])), getPhasedRelease: vi.fn(() => Effect.succeed({ id: 'ph-1', phasedReleaseState: 'ACTIVE' })), }); const releaseStatus = await Effect.runPromise( @@ -522,6 +529,20 @@ describe('readReleaseStatus', () => { }); }); + it('reports no build when the current version has none attached', async () => { + const api = makeReleaseApiFake({ + listAppStoreVersions: vi.fn(() => + Effect.succeed([ + { id: 'v2', versionString: '1.1.0', appStoreState: 'PREPARE_FOR_SUBMISSION' }, + ]), + ), + }); + const releaseStatus = await Effect.runPromise( + readReleaseStatus(api, 'com.acme.app', IOS_PLATFORM), + ); + expect(releaseStatus).toMatchObject({ versionString: '1.1.0', buildNumber: null }); + }); + it('returns an empty preparing verdict when the app has no versions yet', async () => { const api = makeReleaseApiFake({ listAppStoreVersions: vi.fn(() => Effect.succeed([])) }); const releaseStatus = await Effect.runPromise( @@ -584,6 +605,24 @@ describe('waitForValidBuild', () => { ).rejects.toThrow(/INVALID/); }); + it('throws on FAILED processing', async () => { + const api = makeReleaseApiFake({ + findBuildByVersion: vi.fn(() => + Effect.succeed({ id: 'b', version: '42', processingState: 'FAILED', expired: false }), + ), + }); + await expect( + Effect.runPromise(waitForValidBuild(api, 'app1', 42, { sleep: noSleep })), + ).rejects.toThrow(/FAILED/); + }); + + it('throws a not-found message when the build never appears before the timeout', async () => { + const api = makeReleaseApiFake({ findBuildByVersion: vi.fn(() => Effect.succeed(null)) }); + await expect( + Effect.runPromise(waitForValidBuild(api, 'app1', 42, { sleep: noSleep, timeoutMs: 0 })), + ).rejects.toThrow(/was not found on App Store Connect/); + }); + it('throws on timeout', async () => { const api = makeReleaseApiFake({ findBuildByVersion: vi.fn(() => @@ -599,4 +638,35 @@ describe('waitForValidBuild', () => { Effect.runPromise(waitForValidBuild(api, 'app1', 42, { sleep: noSleep, timeoutMs: 0 })), ).rejects.toThrow(/still PROCESSING/); }); + + it('waits for the build of the given marketing version, not another version with the same number', async () => { + const findBuildByVersion = vi.fn( + (_appId: string, _buildNumber: number, marketingVersion?: string) => + Effect.succeed( + marketingVersion === '1.3.0' + ? { + id: 'b-130', + version: '42', + marketingVersion: '1.3.0', + processingState: 'VALID', + expired: false, + } + : { + id: 'b-120', + version: '42', + marketingVersion: '1.2.0', + processingState: 'FAILED', + expired: false, + }, + ), + ); + const api = makeReleaseApiFake({ findBuildByVersion }); + + const storeBuild = await Effect.runPromise( + waitForValidBuild(api, 'app1', 42, { sleep: noSleep, marketingVersion: '1.3.0' }), + ); + + expect(findBuildByVersion).toHaveBeenCalledWith('app1', 42, '1.3.0'); + expect(storeBuild).toMatchObject({ id: 'b-130', processingState: 'VALID' }); + }); }); diff --git a/src/core/release/appStoreRelease.ts b/src/core/release/appStoreRelease.ts index 3fe6a342..6aa2735e 100644 --- a/src/core/release/appStoreRelease.ts +++ b/src/core/release/appStoreRelease.ts @@ -27,6 +27,7 @@ export type AscReleaseApi = { findBuildByVersion( appId: string, buildNumber: number, + marketingVersion?: string, ): Effect.Effect; setBuildUsesNonExemptEncryption( buildId: string, @@ -841,11 +842,21 @@ export const readReleaseStatus = ( bundleId: string, platform: string, ): Effect.Effect => + Effect.gen(function* () { + const appId = yield* readAppStoreAppId(appleReleaseApi, bundleId); + return yield* readReleaseStatusForApp(appleReleaseApi, appId, bundleId, platform); + }); + +export const readAppStoreAppId = ( + appleReleaseApi: AscReleaseApi, + bundleId: string, +): Effect.Effect => Effect.gen(function* () { const appId = yield* appleReleaseApi .getAppId(bundleId) .pipe(Effect.mapError((cause) => releaseFailure('find App Store app', cause))); - if (appId === null) { + const isThereNoAppRecord = appId === null; + if (isThereNoAppRecord) { return yield* Effect.fail( releaseFailure( 'find App Store app', @@ -854,11 +865,22 @@ export const readReleaseStatus = ( ), ); } + return appId; + }); + +export const readReleaseStatusForApp = ( + appleReleaseApi: AscReleaseApi, + appId: string, + bundleId: string, + platform: string, +): Effect.Effect => + Effect.gen(function* () { const storeVersions = yield* appleReleaseApi .listAppStoreVersions(appId, platform) .pipe(Effect.mapError((cause) => releaseFailure('list App Store versions', cause))); const currentVersion = pickCurrentVersion(storeVersions); - if (currentVersion === null) { + const isThereNoCurrentVersion = currentVersion === null; + if (isThereNoCurrentVersion) { return { bundleId, versionString: null, @@ -869,22 +891,12 @@ export const readReleaseStatus = ( verdict: classifyVerdict(''), }; } - const [phasedRelease, storeBuilds] = yield* Effect.all( - [appleReleaseApi.getPhasedRelease(currentVersion.id), appleReleaseApi.listBuilds(appId, 1)], - { concurrency: 2 }, - ).pipe(Effect.mapError((cause) => releaseFailure('read App Store release status', cause))); - let latestBuild: BuildResource | null = null; - if (storeBuilds[0] !== undefined) latestBuild = storeBuilds[0]; - let buildNumber: string | null = null; - let buildProcessingState: string | null = null; - if (latestBuild !== null) { - buildNumber = latestBuild.version; - buildProcessingState = latestBuild.processingState; - } - let phasedReleaseState: string | null = null; - if (phasedRelease !== null) { - phasedReleaseState = phasedRelease.phasedReleaseState; - } + const phasedRelease = yield* appleReleaseApi + .getPhasedRelease(currentVersion.id) + .pipe(Effect.mapError((cause) => releaseFailure('read App Store release status', cause))); + const buildNumber = currentVersion.attachedBuildNumber ?? null; + const buildProcessingState = currentVersion.attachedBuildProcessingState ?? null; + const phasedReleaseState = phasedRelease?.phasedReleaseState ?? null; return { bundleId, versionString: currentVersion.versionString, @@ -914,11 +926,13 @@ export type WaitOptions = { intervalMs?: number; timeoutMs?: number; sleep?: (milliseconds: number) => Effect.Effect; - onTick?: (state: string) => void; + onTick?: (processingState: string) => Effect.Effect; + // Picks this version's build when another version reused the same build number. + marketingVersion?: string; }; /** - * Poll a freshly uploaded build until processing reaches `VALID`. Fails on `INVALID` or timeout. + * Poll a freshly uploaded build until processing reaches `VALID`. Fails on `INVALID`, `FAILED` or timeout. * Returns the chosen build so the caller can hand it to {@link releaseApp}. */ export const waitForValidBuild = ( @@ -936,39 +950,51 @@ export const waitForValidBuild = ( Effect.sleep(milliseconds); if (options.sleep !== undefined) sleepBetweenPolls = options.sleep; let waited = 0; + const { onTick, marketingVersion } = options; + const timeoutMinutes = Math.round(timeoutMs / 60_000); for (;;) { const storeBuild = yield* appleReleaseApi - .findBuildByVersion(appId, buildNumber) + .findBuildByVersion(appId, buildNumber, marketingVersion) .pipe(Effect.mapError((cause) => releaseFailure('read App Store build', cause))); - let processingState = 'PROCESSING'; - if (storeBuild !== null) { - const reportedState = storeBuild.processingState; - if (reportedState !== undefined && reportedState !== null) { - processingState = reportedState; - } - } - if (options.onTick !== undefined) { - options.onTick(processingState); + const processingState = storeBuild?.processingState ?? 'PROCESSING'; + const isProgressReported = onTick !== undefined; + if (isProgressReported) yield* onTick(processingState); + + const isBuildValid = storeBuild !== null && processingState === 'VALID'; + if (isBuildValid) return storeBuild; + + const isProcessingRejected = processingState === 'INVALID' || processingState === 'FAILED'; + if (isProcessingRejected) { + return yield* Effect.fail( + releaseFailure( + 'wait for App Store build', + storeBuild, + `Build ${buildNumber} failed App Store Connect processing (${processingState}) - check the email Apple sent.`, + ), + ); } - if (storeBuild !== null && processingState === 'VALID') return storeBuild; - if (processingState === 'INVALID') { + + const isTimedOut = waited >= timeoutMs; + const isBuildMissingAtTimeout = isTimedOut && storeBuild === null; + if (isBuildMissingAtTimeout) { return yield* Effect.fail( releaseFailure( 'wait for App Store build', storeBuild, - `Build ${buildNumber} failed App Store Connect processing (INVALID) - check the email Apple sent.`, + `Build ${buildNumber} was not found on App Store Connect after ${timeoutMinutes} min - check that the upload finished.`, ), ); } - if (waited >= timeoutMs) { + if (isTimedOut) { return yield* Effect.fail( releaseFailure( 'wait for App Store build', storeBuild, - `Build ${buildNumber} is still ${processingState} after ${Math.round(timeoutMs / 60_000)} min. Re-run \`launch release ios --build ${buildNumber}\` once \`launch status\` shows it VALID.`, + `Build ${buildNumber} is still ${processingState} after ${timeoutMinutes} min. Re-run \`launch release ios --build ${buildNumber}\` once \`launch status\` shows it VALID.`, ), ); } + yield* sleepBetweenPolls(intervalMs); waited += intervalMs; } diff --git a/src/core/release/releaseCommand.ts b/src/core/release/releaseCommand.ts index 19d0057e..55a667da 100644 --- a/src/core/release/releaseCommand.ts +++ b/src/core/release/releaseCommand.ts @@ -603,7 +603,9 @@ const resolveIosBuild = ( ); const storeBuild = yield* attemptRead( 'wait for App Store build processing', - waitForValidBuild(ascClient, appId, artifact.buildNumber), + waitForValidBuild(ascClient, appId, artifact.buildNumber, { + marketingVersion: artifact.version, + }), ); return { storeBuild, versionString: artifact.version }; }); diff --git a/src/core/release/statusCommand.test.ts b/src/core/release/statusCommand.test.ts index e7406b18..36c78735 100644 --- a/src/core/release/statusCommand.test.ts +++ b/src/core/release/statusCommand.test.ts @@ -1,159 +1,285 @@ -import { Effect, Schema } from 'effect'; -import { describe, expect, it } from 'vitest'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { NodeHttpClient } from '@effect/platform-node'; -import { createLogger, makeLaunchLoggerTest } from '../services/logger.js'; -import type { AppDescriptor } from '../types/app.js'; -import { classifyVerdict, type ReleaseStatus } from './appStoreRelease.js'; -import type { MutableDeep } from '../types/mutable.js'; +import { Effect, Layer, Schema } from 'effect'; +import { describe, expect, it, vi } from 'vitest'; +import { makeReleaseApiFake } from '@testkit/ascReleaseApi.testkit.js'; +import { makeFakeAscClient } from '@testkit/storeClients.testkit.js'; import { - formatStatusLine, - selectIosApps, - StatusCommandInputSchema, - watchReleaseStatuses, - worstExitCode, -} from './statusCommand.js'; - -const releaseStatus = (overrides: Partial = {}): ReleaseStatus => { - const baseStatus: ReleaseStatus = { - bundleId: 'com.acme.app', - versionString: '1.2.0', - appStoreState: 'IN_REVIEW', - buildNumber: '42', - buildProcessingState: 'VALID', - phasedReleaseState: null, - verdict: classifyVerdict('IN_REVIEW'), - }; - const mergedStatus: ReleaseStatus = { ...baseStatus, ...overrides }; - if (overrides.appStoreState !== undefined && overrides.verdict === undefined) { - let appStoreState = overrides.appStoreState; - if (appStoreState === null) appStoreState = ''; - mergedStatus.verdict = classifyVerdict(appStoreState); + createTemporaryHomeDirectory, + makeLaunchHomeTest, + removeTemporaryHomeDirectory, + writeLaunchHomeFile, +} from '@testkit/launchHome.testkit.js'; +import { AppleStoreClientService } from '../services/appleStoreClient.js'; +import { errorMessage } from '../services/errorMessage.js'; +import { makeLaunchEnvironmentTest } from '../services/environment.js'; +import { makeLaunchLoggerTest } from '../services/logger.js'; +import { makeLaunchSecretStoreTest } from '../services/secretStore.js'; +import { CommandExitSchema, type CommandExit } from '../terminal/commandExit.js'; +import type { AppStoreVersionResource, BuildResource } from '../types/appleCatalog.js'; +import type { AscReleaseApi } from './appStoreRelease.js'; +import { type StatusCommandFlags, statusCommandProgram } from './statusCommand.js'; + +const ASC_KEY_ID = 'KEY123'; +const P8_SECRET = Buffer.from('-----BEGIN PRIVATE KEY-----').toString('base64'); +const ACCOUNTS_FILE_TEXT = JSON.stringify({ + active: ASC_KEY_ID, + accounts: [ + { + keyId: ASC_KEY_ID, + issuerId: 'issuer-1', + label: 'Owner', + addedAt: '2026-11-01T00:00:00.000Z', + }, + ], +}); +const APP_JSON_TEXT = JSON.stringify({ + expo: { name: 'Oly', version: '1.2.0', ios: { bundleIdentifier: 'com.acme.app' } }, +}); +const APP_JSON_WITHOUT_IOS_TEXT = JSON.stringify({ expo: { name: 'Oly', version: '1.2.0' } }); + +const liveVersion = ( + overrides: Partial = {}, +): AppStoreVersionResource => ({ + id: 'v-1', + versionString: '1.2.0', + appStoreState: 'READY_FOR_SALE', + attachedBuildNumber: '42', + attachedBuildProcessingState: 'VALID', + ...overrides, +}); + +const makeBuild = (overrides: Partial = {}): BuildResource => ({ + id: 'b-1', + version: '42', + processingState: 'VALID', + expired: false, + ...overrides, +}); + +type StatusRun = Readonly<{ + exitCode: number; + failureMessage: string | null; + logLines: string[]; +}>; + +const isCommandExitFailure = (failure: unknown): failure is CommandExit => + Schema.is(CommandExitSchema)(failure); + +const outcomeOfFailure = (failure: unknown): Omit => { + if (isCommandExitFailure(failure)) { + return { exitCode: failure.exitCode, failureMessage: failure.message ?? null }; } - return mergedStatus; + return { exitCode: 1, failureMessage: errorMessage(failure) }; }; -const discoveredApp = (appName: string, bundleId?: string): AppDescriptor => { - const appDescriptor: MutableDeep = { - name: appName, - dir: `/repo/${appName}`, - configPath: `/repo/${appName}/app.json`, - }; - if (bundleId !== undefined) appDescriptor.bundleId = bundleId; - return appDescriptor; -}; +const runStatusCommand = async ( + flags: StatusCommandFlags, + appleReleaseApi: AscReleaseApi, + appJsonText = APP_JSON_TEXT, +): Promise => { + const homeDirectory = createTemporaryHomeDirectory(); + const logLines: string[] = []; + + try { + const workDirectory = join(homeDirectory, 'work'); + mkdirSync(workDirectory, { recursive: true }); + writeFileSync(join(workDirectory, 'app.json'), appJsonText); + writeLaunchHomeFile(homeDirectory, 'accounts.json', ACCOUNTS_FILE_TEXT); -describe('StatusCommandInputSchema', () => { - it('defaults to one read with human output', async () => { - const commandInput = await Effect.runPromise( - Schema.decodeUnknown(StatusCommandInputSchema)({}), + const appleStoreClientTest = Layer.succeed(AppleStoreClientService, { + createClient: () => Effect.succeed(appleReleaseApi), + createEffectClient: () => Effect.succeed(makeFakeAscClient([])), + createReleaseAttributesClient: () => Effect.succeed(makeFakeAscClient([])), + }); + const statusProgram = statusCommandProgram(flags).pipe( + Effect.match({ + onSuccess: () => ({ exitCode: 0, failureMessage: null }), + onFailure: outcomeOfFailure, + }), + Effect.provide(makeLaunchHomeTest(homeDirectory)), + Effect.provide(NodeHttpClient.layer), + Effect.provide(makeLaunchEnvironmentTest({})), + Effect.provide(makeLaunchSecretStoreTest(new Map([[`asc-p8:${ASC_KEY_ID}`, P8_SECRET]]))), + Effect.provide(makeLaunchLoggerTest(logLines)), + Effect.provide(appleStoreClientTest), ); - expect(commandInput).toEqual({ watch: false, json: false }); - }); -}); -describe('formatStatusLine', () => { - it('renders version, verdict, build, and phased state', () => { - expect(formatStatusLine(releaseStatus())).toBe('v1.2.0 - In review - build 42'); + const outcome = await Effect.runPromise(statusProgram); + return { ...outcome, logLines }; + } finally { + removeTemporaryHomeDirectory(homeDirectory); + } +}; + +describe('statusCommandProgram - plain status', () => { + it('prints the status line and exits 0 when the version is live', async () => { + const api = makeReleaseApiFake({ + listAppStoreVersions: vi.fn(() => Effect.succeed([liveVersion()])), + }); + + const run = await runStatusCommand({}, api); + + expect(run.exitCode).toBe(0); + expect(run.logLines.join('\n')).toContain('Live on the App Store'); }); - it('annotates a build still processing', () => { - expect(formatStatusLine(releaseStatus({ buildProcessingState: 'PROCESSING' }))).toContain( - 'build 42 (PROCESSING)', - ); + it('annotates an attached build that is still processing', async () => { + const api = makeReleaseApiFake({ + listAppStoreVersions: vi.fn(() => + Effect.succeed([ + liveVersion({ appStoreState: 'IN_REVIEW', attachedBuildProcessingState: 'PROCESSING' }), + ]), + ), + }); + + const run = await runStatusCommand({}, api); + + expect(run.logLines.join('\n')).toContain('build 42 (PROCESSING)'); }); - it('shows the phased-rollout state and a live verdict', () => { - const statusLine = formatStatusLine( - releaseStatus({ appStoreState: 'READY_FOR_SALE', phasedReleaseState: 'ACTIVE' }), - ); - expect(statusLine).toContain('Live on the App Store'); - expect(statusLine).toContain('phased: ACTIVE'); + it('never lists builds without --builds', async () => { + const listBuilds = vi.fn(() => Effect.succeed([makeBuild()])); + const api = makeReleaseApiFake({ + listAppStoreVersions: vi.fn(() => Effect.succeed([liveVersion()])), + listBuilds, + }); + + await runStatusCommand({}, api); + + expect(listBuilds).not.toHaveBeenCalled(); }); - it('handles an app with no App Store version yet', () => { - expect( - formatStatusLine( - releaseStatus({ versionString: null, appStoreState: '', buildNumber: null }), + it('lists each recent build in its own row with --builds, looking up the app id once', async () => { + const api = makeReleaseApiFake({ + listAppStoreVersions: vi.fn(() => Effect.succeed([liveVersion()])), + listBuilds: vi.fn(() => + Effect.succeed([ + makeBuild({ id: 'b-43', version: '43', uploadedDate: '2026-11-03T10:00:00Z' }), + makeBuild({ id: 'b-42', version: '42', uploadedDate: '2026-10-20T10:00:00Z' }), + ]), ), - ).toContain('no App Store version'); + }); + + const run = await runStatusCommand({ builds: true }, api); + + const printedText = run.logLines.join('\n'); + expect(printedText).toContain('2026-11-03 10:00'); + expect(printedText).toContain('2026-10-20 10:00'); + expect(api.getAppId).toHaveBeenCalledTimes(1); }); -}); -describe('worstExitCode - error > rejected > in-progress > ok', () => { - it('picks the worst code in the batch', () => { - expect(worstExitCode([0, 0])).toBe(0); - expect(worstExitCode([0, 3])).toBe(3); - expect(worstExitCode([3, 2])).toBe(2); - expect(worstExitCode([2, 1])).toBe(1); - expect(worstExitCode([0, 1, 3])).toBe(1); - expect(worstExitCode([])).toBe(0); + it('exits 0 with a note when the project has no iOS app', async () => { + const api = makeReleaseApiFake(); + + const run = await runStatusCommand({}, api, APP_JSON_WITHOUT_IOS_TEXT); + + expect(run.exitCode).toBe(0); + expect(run.logLines.join('\n')).toContain('No iOS apps discovered'); + }); + + it('fails with exit 1 on an --app name that is not an iOS app', async () => { + const api = makeReleaseApiFake(); + + const run = await runStatusCommand({ app: 'delta' }, api); + + expect(run.exitCode).toBe(1); + expect(run.failureMessage).toMatch(/Unknown iOS app "delta"/); }); }); -describe('selectIosApps', () => { - const apps = [ - discoveredApp('alpha', 'com.acme.alpha'), - discoveredApp('beta'), - discoveredApp('gamma', 'com.acme.gamma'), - ]; +describe('statusCommandProgram - usage errors', () => { + it('exits 2 when --build comes without --watch', async () => { + const run = await runStatusCommand({ build: '42' }, makeReleaseApiFake()); + + expect(run.exitCode).toBe(2); + expect(run.failureMessage).toMatch(/--build needs --watch/); + }); + + it('exits 2 when --builds comes with --watch', async () => { + const run = await runStatusCommand({ builds: true, watch: true }, makeReleaseApiFake()); - it('returns every app with a bundle id when no selector is given', async () => { - expect(await Effect.runPromise(selectIosApps(apps, undefined))).toEqual([ - { name: 'alpha', bundleId: 'com.acme.alpha' }, - { name: 'gamma', bundleId: 'com.acme.gamma' }, - ]); + expect(run.exitCode).toBe(2); + expect(run.failureMessage).toMatch(/--builds lists builds/); }); - it('narrows to the named apps', async () => { - expect(await Effect.runPromise(selectIosApps(apps, 'gamma'))).toEqual([ - { name: 'gamma', bundleId: 'com.acme.gamma' }, - ]); + it('exits 2 with the usage message for a --build that is not a whole number', async () => { + const run = await runStatusCommand({ build: '4.5', watch: true }, makeReleaseApiFake()); + + expect(run.exitCode).toBe(2); + expect(run.failureMessage).toMatch(/--build takes a whole number above 0/); }); - it('fails on an unknown app name', async () => { - await expect(Effect.runPromise(selectIosApps(apps, 'delta'))).rejects.toThrow( - /Unknown iOS app/, - ); + it('exits 2 for a --build of 0', async () => { + const run = await runStatusCommand({ build: '0', watch: true }, makeReleaseApiFake()); + + expect(run.exitCode).toBe(2); }); }); -describe('watchReleaseStatuses', () => { - it('polls until the verdict is terminal and returns its exit code', async () => { - let readCount = 0; - const terminalWrites: string[] = []; - const exitCode = await Effect.runPromise( - Effect.gen(function* () { - const logger = yield* createLogger(false); - return yield* watchReleaseStatuses( - () => { - readCount += 1; - let appStoreState = 'IN_REVIEW'; - if (readCount > 1) appStoreState = 'READY_FOR_SALE'; - return Effect.succeed([ - { - appName: 'alpha', - releaseStatus: releaseStatus({ appStoreState }), - }, - ]); - }, - logger, - { - credentials: 'local', - storage: 'local', - buildEngine: 'xcode', - submit: 'app-store-connect', - profiles: {}, - }, - () => Effect.void, - ); - }).pipe( - Effect.provide(makeLaunchLoggerTest(terminalWrites)), - Effect.provide(NodeHttpClient.layer), - ), +describe('statusCommandProgram - --watch and --build', () => { + it('exits with the verdict code when the first read is already terminal', async () => { + const api = makeReleaseApiFake({ + listAppStoreVersions: vi.fn(() => Effect.succeed([liveVersion()])), + }); + + const run = await runStatusCommand({ watch: true }, api); + + expect(run.exitCode).toBe(0); + }); + + it('reports the build as ready once App Store Connect reports it VALID', async () => { + const api = makeReleaseApiFake({ + findBuildByVersion: vi.fn(() => Effect.succeed(makeBuild({ processingState: 'VALID' }))), + }); + + const run = await runStatusCommand({ build: '42', watch: true }, api); + + expect(run.exitCode).toBe(0); + expect(run.logLines.join('\n')).toContain('build 42 is VALID - ready to release'); + }); + + it('fails with exit 1 when App Store Connect rejects the build', async () => { + const api = makeReleaseApiFake({ + findBuildByVersion: vi.fn(() => Effect.succeed(makeBuild({ processingState: 'FAILED' }))), + }); + + const run = await runStatusCommand({ build: '42', watch: true }, api); + + expect(run.exitCode).toBe(1); + expect(run.failureMessage).toMatch(/failed App Store Connect processing \(FAILED\)/); + }); + + it('prints ok true with the build under --json when the build is VALID', async () => { + const api = makeReleaseApiFake({ + findBuildByVersion: vi.fn(() => Effect.succeed(makeBuild({ processingState: 'VALID' }))), + }); + + const run = await runStatusCommand({ build: '42', watch: true, json: true }, api); + + expect(run.logLines.join('\n')).toContain('"ok": true'); + }); + + it('prints ok false with the error under --json when the build is rejected', async () => { + const api = makeReleaseApiFake({ + findBuildByVersion: vi.fn(() => Effect.succeed(makeBuild({ processingState: 'INVALID' }))), + }); + + const run = await runStatusCommand({ build: '42', watch: true, json: true }, api); + + expect(run.exitCode).toBe(1); + expect(run.logLines.join('\n')).toContain('"ok": false'); + }); + + it('fails with exit 1 and a clear message when --build has no iOS app to watch', async () => { + const run = await runStatusCommand( + { build: '42', watch: true }, + makeReleaseApiFake(), + APP_JSON_WITHOUT_IOS_TEXT, ); - expect(readCount).toBe(2); - expect(exitCode).toBe(0); - expect(terminalWrites.join('')).toContain('In review'); - expect(terminalWrites.join('')).toContain('Live on the App Store'); + + expect(run.exitCode).toBe(1); + expect(run.failureMessage).toMatch(/No iOS apps discovered/); }); }); diff --git a/src/core/release/statusCommand.ts b/src/core/release/statusCommand.ts index cb0d95ed..a9581deb 100644 --- a/src/core/release/statusCommand.ts +++ b/src/core/release/statusCommand.ts @@ -1,6 +1,6 @@ import { Data, Effect, Schema } from 'effect'; import { loadConfig } from '../config/config.js'; -import { loadActiveAscKey } from '../credentials/accounts.js'; +import { loadAscKeyForAccount } from '../credentials/accounts.js'; import { AppleStoreClientService, type AppleStoreClientService as AppleStoreClientRequirements, @@ -10,29 +10,49 @@ import { createLogger, type Logger } from '../services/logger.js'; import { notify } from '../services/notify.js'; import { LaunchPaths, type LaunchPathsService } from '../services/paths.js'; import { CommandExitSchema, completeCommand, type CommandExit } from '../terminal/commandExit.js'; +import { alignTableColumns, type Column } from '../terminal/table.js'; +import { endWithUsageFailure, makeUsageFailure } from '../terminal/usageFailure.js'; import type { AppDescriptor } from '../types/app.js'; +import type { BuildResource } from '../types/appleCatalog.js'; import type { LaunchConfig } from '../types/config.js'; -import { IOS_PLATFORM, readReleaseStatus, type ReleaseStatus } from './appStoreRelease.js'; +import { + type AscReleaseApi, + IOS_PLATFORM, + readAppStoreAppId, + readReleaseStatusForApp, + type ReleaseStatus, + waitForValidBuild, +} from './appStoreRelease.js'; import { createTransitionTracker, planTransitionNotifications } from './releaseNotify.js'; const WATCH_INTERVAL_MS = 30_000; +const NO_IOS_APPS_MESSAGE = + 'No iOS apps discovered. Add an app with an ios.bundleIdentifier in app.json.'; -export const StatusCommandInputSchema = Schema.Struct({ +const StatusFlagsSchema = Schema.Struct({ app: Schema.optionalWith(Schema.String, { exact: true }), + account: Schema.optionalWith(Schema.String, { exact: true }), watch: Schema.optionalWith(Schema.Boolean, { default: () => false }), + build: Schema.optionalWith(Schema.NumberFromString.pipe(Schema.int(), Schema.positive()), { + exact: true, + }), + builds: Schema.optionalWith(Schema.Boolean, { default: () => false }), json: Schema.optionalWith(Schema.Boolean, { default: () => false }), }); -export type StatusCommandInput = Schema.Schema.Type; +type StatusFlags = Schema.Schema.Type; + +export type StatusCommandFlags = Schema.Schema.Encoded; -/** One discovered iOS app reduced to status-read identity. */ -export type StatusApp = Readonly<{ name: string; bundleId: string }>; +type StatusApp = Readonly<{ name: string; bundleId: string }>; -/** One app and its latest App Store status. */ -export type AppReleaseStatus = Readonly<{ appName: string; releaseStatus: ReleaseStatus }>; +type AppReleaseStatus = Readonly<{ + appName: string; + releaseStatus: ReleaseStatus; + recentBuilds?: readonly BuildResource[]; +}>; -/** Reading or watching App Store status failed. */ -export type StatusCommandFailure = Readonly<{ +type StatusCommandFailure = Readonly<{ readonly _tag: 'StatusCommandFailure'; readonly operation: string; readonly message: string; @@ -48,7 +68,12 @@ const StatusCommandFailureSchema: Schema.Schema = Schema.S cause: Schema.Unknown, }); -type AccountRequirements = Effect.Effect.Context>; +const isStatusCommandFailure = (cause: unknown): cause is StatusCommandFailure => + Schema.is(StatusCommandFailureSchema)(cause); + +const isCommandExit = (cause: unknown): cause is CommandExit => Schema.is(CommandExitSchema)(cause); + +type AccountRequirements = Effect.Effect.Context>; type NotifyRequirements = Effect.Effect.Context>; type StatusCommandRequirements = @@ -58,28 +83,22 @@ type StatusCommandRequirements = | Logger | NotifyRequirements; -/** Normalize one status command failure. */ -const statusFailure = ( - operation: string, - cause: unknown, - explicitMessage?: string, -): StatusCommandFailure => { - let message = errorMessage(cause); - if (explicitMessage !== undefined) message = explicitMessage; - return makeStatusCommandFailure({ operation, message, cause }); -}; +const createStatusFailure = (operation: string, cause: unknown): StatusCommandFailure => + makeStatusCommandFailure({ operation, message: errorMessage(cause), cause }); -/** Select iOS apps from discovery and an optional comma-separated handle list. */ export const selectIosApps = ( discoveredApps: readonly AppDescriptor[], appSelector: string | undefined, ): Effect.Effect => { const iosApps: StatusApp[] = []; for (const discoveredApp of discoveredApps) { - if (discoveredApp.bundleId === undefined) continue; - iosApps.push({ name: discoveredApp.name, bundleId: discoveredApp.bundleId }); + const { bundleId } = discoveredApp; + const isIosApp = bundleId !== undefined; + if (isIosApp) iosApps.push({ name: discoveredApp.name, bundleId }); } - if (appSelector === undefined) return Effect.succeed(iosApps); + + const isAppSelectorMissing = appSelector === undefined; + if (isAppSelectorMissing) return Effect.succeed(iosApps); const requestedNames = appSelector .split(',') .map((appName) => appName.trim()) @@ -88,45 +107,44 @@ export const selectIosApps = ( const selectedApps: StatusApp[] = []; for (const requestedName of requestedNames) { const selectedApp = iosAppsByName.get(requestedName); - if (selectedApp !== undefined) { + const isAppFound = selectedApp !== undefined; + if (isAppFound) { selectedApps.push(selectedApp); continue; } - let availableApps = 'none'; - if (iosApps.length > 0) availableApps = iosApps.map((iosApp) => iosApp.name).join(', '); + const isThereIosApp = iosApps.length > 0; + const availableApps = isThereIosApp ? iosApps.map((iosApp) => iosApp.name).join(', ') : 'none'; return Effect.fail( - statusFailure( + createStatusFailure( 'select iOS apps', - requestedName, - `Unknown iOS app "${requestedName}". iOS apps: ${availableApps}.`, + new Error(`Unknown iOS app "${requestedName}". iOS apps: ${availableApps}.`), ), ); } return Effect.succeed(selectedApps); }; -/** Format one human-readable App Store status line. */ -export const formatStatusLine = (releaseStatus: ReleaseStatus): string => { - const statusParts: string[] = []; - if (releaseStatus.versionString === null) statusParts.push('no App Store version'); - else statusParts.push(`v${releaseStatus.versionString}`); - statusParts.push(releaseStatus.verdict.label); - if (releaseStatus.buildNumber !== null) { - let processingSuffix = ''; - if ( - releaseStatus.buildProcessingState !== null && - releaseStatus.buildProcessingState !== 'VALID' - ) { - processingSuffix = ` (${releaseStatus.buildProcessingState})`; - } - statusParts.push(`build ${releaseStatus.buildNumber}${processingSuffix}`); +const describeAttachedBuild = (releaseStatus: ReleaseStatus): string => { + const isNoBuildAttached = releaseStatus.buildNumber === null; + if (isNoBuildAttached) return 'no build'; + const isBuildStillProcessing = + releaseStatus.buildProcessingState !== null && releaseStatus.buildProcessingState !== 'VALID'; + if (isBuildStillProcessing) { + return `build ${releaseStatus.buildNumber} (${releaseStatus.buildProcessingState})`; } - if (releaseStatus.phasedReleaseState !== null) - statusParts.push(`phased: ${releaseStatus.phasedReleaseState}`); + return `build ${releaseStatus.buildNumber}`; +}; + +const describeReleaseStatus = (releaseStatus: ReleaseStatus): string => { + const isVersionPresent = releaseStatus.versionString !== null; + const versionPart = isVersionPresent ? `v${releaseStatus.versionString}` : 'no App Store version'; + const statusParts = [versionPart, releaseStatus.verdict.label]; + if (isVersionPresent) statusParts.push(describeAttachedBuild(releaseStatus)); + const isPhasedReleaseKnown = releaseStatus.phasedReleaseState !== null; + if (isPhasedReleaseKnown) statusParts.push(`phased: ${releaseStatus.phasedReleaseState}`); return statusParts.join(' - '); }; -/** Rank process exit codes from most severe to least severe. */ const exitCodeRank = (exitCode: number): number => { switch (exitCode) { case 1: @@ -140,8 +158,7 @@ const exitCodeRank = (exitCode: number): number => { } }; -/** Return the most severe exit code in a status batch. */ -export const worstExitCode = (exitCodes: readonly number[]): number => { +const worstExitCode = (exitCodes: readonly number[]): number => { let worstCode = 0; for (const exitCode of exitCodes) { if (exitCodeRank(exitCode) > exitCodeRank(worstCode)) worstCode = exitCode; @@ -149,40 +166,107 @@ export const worstExitCode = (exitCodes: readonly number[]): number => { return worstCode; }; -/** Read every selected app concurrently through the shared Apple transport. */ +const readAppId = (appleClient: AscReleaseApi, selectedApp: StatusApp) => + readAppStoreAppId(appleClient, selectedApp.bundleId).pipe( + Effect.mapError((cause) => + createStatusFailure(`find App Store app for ${selectedApp.name}`, cause), + ), + ); + +const listRecentBuilds = ( + appleClient: AscReleaseApi, + appId: string, + appName: string, +): Effect.Effect => { + const recentBuildCount = 25; + return appleClient + .listBuilds(appId, recentBuildCount) + .pipe(Effect.mapError((cause) => createStatusFailure(`list builds for ${appName}`, cause))); +}; + +const describeUploadTime = (uploadedDate: string | undefined): string => { + const isUploadTimeMissing = uploadedDate === undefined; + if (isUploadTimeMissing) return '-'; + return new Date(uploadedDate).toISOString().slice(0, 16).replace('T', ' '); +}; + +const describeRecentBuilds = (recentBuilds: readonly BuildResource[]): string => { + const isThereNoBuild = recentBuilds.length === 0; + if (isThereNoBuild) return ' no builds uploaded yet'; + const recentBuildColumns: readonly Column[] = [ + { header: 'VERSION', cell: (build) => build.marketingVersion ?? '-' }, + { header: 'BUILD', cell: (build) => build.version }, + { header: 'PROCESSING', cell: (build) => build.processingState }, + { header: 'UPLOADED (UTC)', cell: (build) => describeUploadTime(build.uploadedDate) }, + ]; + return alignTableColumns(recentBuildColumns, recentBuilds); +}; + +const readOneAppStatus = ( + appleClient: AscReleaseApi, + selectedApp: StatusApp, + isBuildListWanted: boolean, +): Effect.Effect => + Effect.gen(function* () { + const appId = yield* readAppId(appleClient, selectedApp); + const releaseStatus = yield* readReleaseStatusForApp( + appleClient, + appId, + selectedApp.bundleId, + IOS_PLATFORM, + ).pipe( + Effect.mapError((cause) => + createStatusFailure(`read App Store status for ${selectedApp.name}`, cause), + ), + ); + + const isBuildListSkipped = !isBuildListWanted; + if (isBuildListSkipped) return { appName: selectedApp.name, releaseStatus }; + const recentBuilds = yield* listRecentBuilds(appleClient, appId, selectedApp.name); + return { appName: selectedApp.name, releaseStatus, recentBuilds }; + }); + const readSelectedStatuses = ( - appleClient: Parameters[0], + appleClient: AscReleaseApi, selectedApps: readonly StatusApp[], + isBuildListWanted: boolean, ): Effect.Effect => Effect.forEach( selectedApps, - (selectedApp) => - readReleaseStatus(appleClient, selectedApp.bundleId, IOS_PLATFORM).pipe( - Effect.mapError((cause) => - statusFailure(`read App Store status for ${selectedApp.name}`, cause), - ), - Effect.map((releaseStatus) => ({ appName: selectedApp.name, releaseStatus })), - ), + (selectedApp) => readOneAppStatus(appleClient, selectedApp, isBuildListWanted), { concurrency: 'unbounded' }, ); -/** Print one human-readable status batch. */ +const printOneAppStatus = ( + logger: Logger, + appStatus: AppReleaseStatus, +): Effect.Effect => + Effect.gen(function* () { + yield* logger.step(appStatus.appName, describeReleaseStatus(appStatus.releaseStatus)); + const isBuildListPresent = appStatus.recentBuilds !== undefined; + if (!isBuildListPresent) return; + yield* logger.line(describeRecentBuilds(appStatus.recentBuilds)); + }); + const printStatusBatch = ( logger: Logger, appStatuses: readonly AppReleaseStatus[], ): Effect.Effect => - Effect.forEach( - appStatuses, - (appStatus) => logger.step(appStatus.appName, formatStatusLine(appStatus.releaseStatus)), - { concurrency: 1, discard: true }, - ); + Effect.forEach(appStatuses, (appStatus) => printOneAppStatus(logger, appStatus), { + concurrency: 1, + discard: true, + }); + +const createStatusJsonRow = (appStatus: AppReleaseStatus) => { + const isBuildListPresent = appStatus.recentBuilds !== undefined; + if (!isBuildListPresent) return appStatus.releaseStatus; + return { ...appStatus.releaseStatus, recentBuilds: appStatus.recentBuilds }; +}; -/** Poll until every selected app reaches a terminal verdict. */ -export const watchReleaseStatuses = ( +const watchReleaseStatuses = ( readStatuses: () => Effect.Effect, logger: Logger, launchConfiguration: LaunchConfig, - sleepBetweenPolls: () => Effect.Effect = () => Effect.sleep(WATCH_INTERVAL_MS), ): Effect.Effect => Effect.gen(function* () { const transitionTracker = createTransitionTracker(); @@ -202,75 +286,206 @@ export const watchReleaseStatuses = ( { concurrency: 1, discard: true }, ); } - const allDone = appStatuses.every((appStatus) => appStatus.releaseStatus.verdict.done); - if (allDone) { + + const isEveryAppDone = appStatuses.every((appStatus) => appStatus.releaseStatus.verdict.done); + if (isEveryAppDone) { return worstExitCode( appStatuses.map((appStatus) => appStatus.releaseStatus.verdict.exitCode), ); } - yield* sleepBetweenPolls(); + yield* Effect.sleep(WATCH_INTERVAL_MS); } }).pipe( Effect.mapError((cause) => { - if (Schema.is(StatusCommandFailureSchema)(cause)) return cause; - return statusFailure('watch App Store status', cause); + if (isStatusCommandFailure(cause)) return cause; + return createStatusFailure('watch App Store status', cause); }), ); -/** Run the schema-decoded App Store status command. */ +type BuildWatchRequest = Readonly<{ + appleClient: AscReleaseApi; + selectedApp: StatusApp; + buildNumber: number; + logger: Logger; + isJson: boolean; +}>; + +const watchBuildProcessing = ( + buildWatchRequest: BuildWatchRequest, +): Effect.Effect => + Effect.gen(function* () { + const { appleClient, selectedApp, buildNumber, logger, isJson } = buildWatchRequest; + const appId = yield* readAppId(appleClient, selectedApp); + + const isProgressShown = !isJson; + const reportProgress = (processingState: string) => { + if (!isProgressShown) return Effect.void; + return logger + .step(selectedApp.name, `build ${buildNumber} is ${processingState}`) + .pipe(Effect.orDie); + }; + const storeBuild = yield* waitForValidBuild(appleClient, appId, buildNumber, { + onTick: reportProgress, + }).pipe( + Effect.mapError((cause) => + createStatusFailure(`wait for build ${buildNumber} of ${selectedApp.name}`, cause), + ), + ); + return storeBuild; + }); + +const reportValidBuild = ( + buildWatchRequest: BuildWatchRequest, + storeBuild: BuildResource, +): Effect.Effect => + Effect.gen(function* () { + const { logger, selectedApp, buildNumber, isJson } = buildWatchRequest; + if (isJson) { + const validBuildReport = { + ok: true, + appName: selectedApp.name, + buildNumber, + processingState: storeBuild.processingState, + }; + yield* logger.line(JSON.stringify(validBuildReport, null, 2)).pipe(Effect.orDie); + return; + } + yield* logger + .step( + selectedApp.name, + `build ${buildNumber} is ${storeBuild.processingState} - ready to release`, + ) + .pipe(Effect.orDie); + }); + +const reportFailedBuildWatch = ( + buildWatchRequest: BuildWatchRequest, + watchFailure: StatusCommandFailure, +): Effect.Effect => + Effect.gen(function* () { + const { logger, selectedApp, buildNumber, isJson } = buildWatchRequest; + const isHumanOutput = !isJson; + if (isHumanOutput) return; + const failedBuildReport = { + ok: false, + appName: selectedApp.name, + buildNumber, + error: watchFailure.message, + }; + yield* logger.line(JSON.stringify(failedBuildReport, null, 2)).pipe(Effect.orDie); + }); + +const findStatusUsageProblem = (statusFlags: StatusFlags, iosAppCount: number): string | null => { + const isBuildWithoutWatch = statusFlags.build !== undefined && !statusFlags.watch; + if (isBuildWithoutWatch) { + return '--build needs --watch: it waits for that build to finish processing.'; + } + const isBuildsWithWatch = statusFlags.builds && statusFlags.watch; + if (isBuildsWithWatch) return '--builds lists builds without waiting: drop --watch to list them.'; + const isBuildForSeveralApps = statusFlags.build !== undefined && iosAppCount > 1; + if (isBuildForSeveralApps) return '--build waits on one build of one app: pass --app .'; + return null; +}; + export const statusCommandProgram = ( - rawCommandInput: unknown, + flagsFromCli: StatusCommandFlags, ): Effect.Effect => Effect.gen(function* () { - const commandInput = yield* Schema.decodeUnknown(StatusCommandInputSchema)(rawCommandInput); + const isJsonRequested = flagsFromCli.json === true; + const statusFlags = yield* Schema.decodeUnknown(StatusFlagsSchema)(flagsFromCli).pipe( + Effect.catchTag('ParseError', () => + endWithUsageFailure( + makeUsageFailure({ message: '--build takes a whole number above 0, such as 42.' }), + isJsonRequested, + ), + ), + ); const launchPaths = yield* LaunchPaths; const logger = yield* createLogger(false); const loadedConfiguration = yield* loadConfig(launchPaths.workingDirectory); - const selectedApps = yield* selectIosApps(loadedConfiguration.apps, commandInput.app); - if (selectedApps.length === 0) { - yield* logger.note( - 'No iOS apps discovered. Add an app with an ios.bundleIdentifier in app.json.', + const selectedApps = yield* selectIosApps(loadedConfiguration.apps, statusFlags.app); + + const usageProblem = findStatusUsageProblem(statusFlags, selectedApps.length); + const isUsageProblemFound = usageProblem !== null; + if (isUsageProblemFound) { + return yield* endWithUsageFailure( + makeUsageFailure({ message: usageProblem }), + statusFlags.json, + ); + } + + const isThereNoSelectedApp = selectedApps.length === 0; + const isBuildWatchWithNoApp = isThereNoSelectedApp && statusFlags.build !== undefined; + if (isBuildWatchWithNoApp) { + return yield* Effect.fail( + createStatusFailure('find iOS app', new Error(NO_IOS_APPS_MESSAGE)), ); + } + if (isThereNoSelectedApp) { + yield* logger.note(NO_IOS_APPS_MESSAGE); return; } - const ascKey = yield* loadActiveAscKey(); - if (ascKey === null) { + + const ascKey = yield* loadAscKeyForAccount(statusFlags.account); + const isThereNoAscKey = ascKey === null; + if (isThereNoAscKey) { return yield* Effect.fail( - statusFailure( - 'load active Apple account', - 'missing-active-account', - 'No active Apple account. Run `launch creds set-key` first.', + createStatusFailure( + 'load Apple account', + new Error( + 'No Apple account key found. Run `launch creds set-key`, or pick one with --account.', + ), ), ); } const appleStoreClients = yield* AppleStoreClientService; const appleClient = yield* appleStoreClients.createClient(ascKey); - const readStatuses = () => readSelectedStatuses(appleClient, selectedApps); - let exitCode: number; - if (commandInput.watch && !commandInput.json) { - exitCode = yield* watchReleaseStatuses(readStatuses, logger, loadedConfiguration.config); - } else { - const appStatuses = yield* readStatuses(); - if (commandInput.json) { - yield* logger.line( - JSON.stringify( - appStatuses.map((appStatus) => appStatus.releaseStatus), - null, - 2, - ), - ); - } else { - yield* printStatusBatch(logger, appStatuses); - } - exitCode = worstExitCode( - appStatuses.map((appStatus) => appStatus.releaseStatus.verdict.exitCode), + + const [onlySelectedApp] = selectedApps; + const watchedBuildNumber = statusFlags.build; + const isBuildWatch = watchedBuildNumber !== undefined && onlySelectedApp !== undefined; + if (isBuildWatch) { + const buildWatchRequest: BuildWatchRequest = { + appleClient, + selectedApp: onlySelectedApp, + buildNumber: watchedBuildNumber, + logger, + isJson: statusFlags.json, + }; + const storeBuild = yield* watchBuildProcessing(buildWatchRequest).pipe( + Effect.tapError((watchFailure) => reportFailedBuildWatch(buildWatchRequest, watchFailure)), + ); + return yield* reportValidBuild(buildWatchRequest, storeBuild); + } + + const isWatchingInTerminal = statusFlags.watch && !statusFlags.json; + if (isWatchingInTerminal) { + const isBuildListWanted = false; + const readStatuses = () => readSelectedStatuses(appleClient, selectedApps, isBuildListWanted); + const watchExitCode = yield* watchReleaseStatuses( + readStatuses, + logger, + loadedConfiguration.config, ); + return yield* completeCommand(watchExitCode); } - yield* completeCommand(exitCode); + + const appStatuses = yield* readSelectedStatuses(appleClient, selectedApps, statusFlags.builds); + const isJsonOutput = statusFlags.json; + if (isJsonOutput) { + yield* logger.line(JSON.stringify(appStatuses.map(createStatusJsonRow), null, 2)); + } + + const isHumanOutput = !isJsonOutput; + if (isHumanOutput) yield* printStatusBatch(logger, appStatuses); + const statusExitCode = worstExitCode( + appStatuses.map((appStatus) => appStatus.releaseStatus.verdict.exitCode), + ); + return yield* completeCommand(statusExitCode); }).pipe( Effect.mapError((cause) => { - if (Schema.is(CommandExitSchema)(cause)) return cause; - if (Schema.is(StatusCommandFailureSchema)(cause)) return cause; - return statusFailure('read App Store status', cause); + if (isCommandExit(cause)) return cause; + if (isStatusCommandFailure(cause)) return cause; + return createStatusFailure('read App Store status', cause); }), ); diff --git a/src/core/terminal/table.ts b/src/core/terminal/table.ts new file mode 100644 index 00000000..a2e46e5d --- /dev/null +++ b/src/core/terminal/table.ts @@ -0,0 +1,32 @@ +export type Column = Readonly<{ + header: string; + cell: (tableEntry: TableEntry) => string; +}>; + +export const alignTableColumns = ( + columns: readonly Column[], + tableEntries: readonly TableEntry[], +): string => { + const widths = columns.map((column) => + Math.max( + column.header.length, + ...tableEntries.map((tableEntry) => column.cell(tableEntry).length), + ), + ); + const joinCellsIntoLine = (cells: readonly string[]): string => + cells + .map((cell, columnIndex) => { + const columnWidth = widths[columnIndex]; + const isColumnWidthMissing = columnWidth === undefined; + if (isColumnWidthMissing) return cell; + return cell.padEnd(columnWidth); + }) + .join(' ') + .trimEnd(); + return [ + joinCellsIntoLine(columns.map((column) => column.header)), + ...tableEntries.map((tableEntry) => + joinCellsIntoLine(columns.map((column) => column.cell(tableEntry))), + ), + ].join('\n'); +}; diff --git a/src/core/types/appleCatalog.ts b/src/core/types/appleCatalog.ts index 32b9b8f6..868bdaf3 100644 --- a/src/core/types/appleCatalog.ts +++ b/src/core/types/appleCatalog.ts @@ -455,6 +455,8 @@ export type AppStoreVersionResource = Readonly<{ releaseType?: string; /** Id of the build attached to this version; absent when no build is attached. */ attachedBuildId?: string; + attachedBuildNumber?: string; + attachedBuildProcessingState?: string; }>; /** One locale's editable version copy. Launch only ever writes `whatsNew` (the release notes). */ export type AppStoreVersionLocalizationResource = Readonly<{