Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<p align="center">
<img src="https://img.shields.io/badge/store%20API-205%20endpoints-8957e5?logo=apple&logoColor=white" alt="205 App Store Connect &amp; Google Play API operations" />
<img src="https://img.shields.io/badge/CRUD-full%20lifecycle-1f6feb" alt="Full create / read / update / delete coverage across the store APIs" />
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2917%20passing-3fb950?logo=vitest&logoColor=white" alt="2917 tests passing" /></a>
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2934%20passing-3fb950?logo=vitest&logoColor=white" alt="2934 tests passing" /></a>
</p>

<!-- stats-badges:end -->
Expand Down
2 changes: 1 addition & 1 deletion README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<p align="center">
<img src="https://img.shields.io/badge/store%20API-205%20endpoints-8957e5?logo=apple&logoColor=white" alt="205 App Store Connect &amp; Google Play API operations" />
<img src="https://img.shields.io/badge/CRUD-full%20lifecycle-1f6feb" alt="Full create / read / update / delete coverage across the store APIs" />
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2917%20passing-3fb950?logo=vitest&logoColor=white" alt="2917 tests passing" /></a>
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2934%20passing-3fb950?logo=vitest&logoColor=white" alt="2934 tests passing" /></a>
</p>

<!-- stats-badges:end -->
Expand Down
2 changes: 1 addition & 1 deletion README.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<p align="center">
<img src="https://img.shields.io/badge/store%20API-205%20endpoints-8957e5?logo=apple&logoColor=white" alt="205 App Store Connect &amp; Google Play API operations" />
<img src="https://img.shields.io/badge/CRUD-full%20lifecycle-1f6feb" alt="Full create / read / update / delete coverage across the store APIs" />
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2917%20passing-3fb950?logo=vitest&logoColor=white" alt="2917 tests passing" /></a>
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2934%20passing-3fb950?logo=vitest&logoColor=white" alt="2934 tests passing" /></a>
</p>

<!-- stats-badges:end -->
Expand Down
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<p align="center">
<img src="https://img.shields.io/badge/store%20API-205%20endpoints-8957e5?logo=apple&logoColor=white" alt="205 App Store Connect &amp; Google Play API operations" />
<img src="https://img.shields.io/badge/CRUD-full%20lifecycle-1f6feb" alt="Full create / read / update / delete coverage across the store APIs" />
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2917%20passing-3fb950?logo=vitest&logoColor=white" alt="2917 tests passing" /></a>
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2934%20passing-3fb950?logo=vitest&logoColor=white" alt="2934 tests passing" /></a>
</p>

<!-- stats-badges:end -->
Expand Down
2 changes: 1 addition & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<p align="center">
<img src="https://img.shields.io/badge/store%20API-205%20endpoints-8957e5?logo=apple&logoColor=white" alt="205 App Store Connect &amp; Google Play API operations" />
<img src="https://img.shields.io/badge/CRUD-full%20lifecycle-1f6feb" alt="Full create / read / update / delete coverage across the store APIs" />
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2917%20passing-3fb950?logo=vitest&logoColor=white" alt="2917 tests passing" /></a>
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2934%20passing-3fb950?logo=vitest&logoColor=white" alt="2934 tests passing" /></a>
</p>

<!-- stats-badges:end -->
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<p align="center">
<img src="https://img.shields.io/badge/store%20API-205%20endpoints-8957e5?logo=apple&logoColor=white" alt="205 App Store Connect &amp; Google Play API operations" />
<img src="https://img.shields.io/badge/CRUD-full%20lifecycle-1f6feb" alt="Full create / read / update / delete coverage across the store APIs" />
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2917%20passing-3fb950?logo=vitest&logoColor=white" alt="2917 tests passing" /></a>
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2934%20passing-3fb950?logo=vitest&logoColor=white" alt="2934 tests passing" /></a>
</p>

<!-- stats-badges:end -->
Expand Down
2 changes: 1 addition & 1 deletion README.pt-BR.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<p align="center">
<img src="https://img.shields.io/badge/store%20API-205%20endpoints-8957e5?logo=apple&logoColor=white" alt="205 App Store Connect &amp; Google Play API operations" />
<img src="https://img.shields.io/badge/CRUD-full%20lifecycle-1f6feb" alt="Full create / read / update / delete coverage across the store APIs" />
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2917%20passing-3fb950?logo=vitest&logoColor=white" alt="2917 tests passing" /></a>
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2934%20passing-3fb950?logo=vitest&logoColor=white" alt="2934 tests passing" /></a>
</p>

<!-- stats-badges:end -->
Expand Down
2 changes: 1 addition & 1 deletion README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<p align="center">
<img src="https://img.shields.io/badge/store%20API-205%20endpoints-8957e5?logo=apple&logoColor=white" alt="205 App Store Connect &amp; Google Play API operations" />
<img src="https://img.shields.io/badge/CRUD-full%20lifecycle-1f6feb" alt="Full create / read / update / delete coverage across the store APIs" />
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2917%20passing-3fb950?logo=vitest&logoColor=white" alt="2917 tests passing" /></a>
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2934%20passing-3fb950?logo=vitest&logoColor=white" alt="2934 tests passing" /></a>
</p>

<!-- stats-badges:end -->
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<p align="center">
<img src="https://img.shields.io/badge/store%20API-205%20endpoints-8957e5?logo=apple&logoColor=white" alt="205 App Store Connect &amp; Google Play API operations" />
<img src="https://img.shields.io/badge/CRUD-full%20lifecycle-1f6feb" alt="Full create / read / update / delete coverage across the store APIs" />
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2917%20passing-3fb950?logo=vitest&logoColor=white" alt="2917 tests passing" /></a>
<a href="https://github.com/YosefHayim/launch-store/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/tests-2934%20passing-3fb950?logo=vitest&logoColor=white" alt="2934 tests passing" /></a>
</p>

<!-- stats-badges:end -->
Expand Down
4 changes: 2 additions & 2 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <platform>` - run the full pipeline and upload to the testing track (--no-submit to build only)
- `launch release <platform>` - 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
Expand Down
4 changes: 2 additions & 2 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <platform>` - run the full pipeline and upload to the testing track (--no-submit to build only)
- `launch release <platform>` - 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
Expand Down
93 changes: 92 additions & 1 deletion src/apple/ascClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
});
});
57 changes: 43 additions & 14 deletions src/apple/ascClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<A>(path: string): Promise<AppleResource<A>[]> {
const all: AppleResource<A>[] = [];
const pages = await this.requestPages<PagedList<A>>(path);
return pages.flatMap((page) => page.data);
}
private async requestPages<Page extends { links?: { next?: string } }>(
path: string,
): Promise<Page[]> {
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<A> = await this.request<PagedList<A>>('GET', next);
all.push(...page.data);
const page: Page = await this.request<Page>('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
Expand Down Expand Up @@ -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<BuildResource | null> {
async findBuildByVersion(
appId: string,
buildNumber: number,
marketingVersion?: string,
): Promise<BuildResource | null> {
const versionFilter = createMarketingVersionFilter(marketingVersion);
const buildPage = await this.request<BuildPage>(
'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;
Expand Down Expand Up @@ -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<AppStoreVersionResource[]> {
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<AppStoreVersionPage>(
`/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(
Expand Down Expand Up @@ -4978,6 +4999,7 @@ const toVersionResource = (
releaseType?: string;
}>,
fallbackVersion = '',
includedBuilds: readonly BuildPageIncluded[] = [],
): AppStoreVersionResource => {
let versionString = entry.attributes.versionString;
if (versionString === undefined) versionString = fallbackVersion;
Expand All @@ -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;
};
Expand Down
Loading
Loading