From 452b610a1f1cc7f0e55ea16f412f6af990dea323 Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Tue, 10 Nov 2026 00:02:55 +0200 Subject: [PATCH 1/5] feat(release): attach a build to the editable version without submitting `launch release ios --attach-only --build ` attaches the build to the editable App Store version and stops before review. A re-run with the same build sends no writes. Fixes #492 --- src/apple/ascClient.test.ts | 31 ++++++++++++ src/apple/ascClient.ts | 44 ++++++++-------- src/cli/commands/release.ts | 5 ++ src/core/release/appStoreRelease.test.ts | 51 +++++++++++++++++++ src/core/release/appStoreRelease.ts | 46 +++++++++++++++-- src/core/release/releaseCommand.test.ts | 52 +++++++++++++++++++ src/core/release/releaseCommand.ts | 64 ++++++++++++++++++++++-- src/core/releaseTrain/builder.ts | 1 + src/core/types/appleCatalog.ts | 2 + 9 files changed, 266 insertions(+), 30 deletions(-) diff --git a/src/apple/ascClient.test.ts b/src/apple/ascClient.test.ts index ec33f7c9..cffcebea 100644 --- a/src/apple/ascClient.test.ts +++ b/src/apple/ascClient.test.ts @@ -3362,3 +3362,34 @@ describe('findBundleId - exact identifier, not substring (#291)', () => { expect(await client.findBundleId('com.example.sampleapp')).toBeNull(); }); }); + +describe('AppStoreConnectClient - app store versions', () => { + it('reads the attached build id from the build relationship, and asks Apple for it', 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' } } }, + }, + { + id: 'v-empty', + attributes: { versionString: '1.1.0', appStoreState: 'READY_FOR_SALE' }, + relationships: { build: { data: null } }, + }, + ], + }), + ), + ); + const versions = await client.listAppStoreVersions('app1', 'IOS'); + const [requestUrl] = expectArrayElement(fetchMock.mock.calls, 0, 'mock call'); + expect(String(requestUrl)).toContain( + 'fields[appStoreVersions]=versionString,appStoreState,releaseType,build', + ); + expect(expectArrayElement(versions, 0, 'version').attachedBuildId).toBe('b-42'); + expect(expectArrayElement(versions, 1, 'version').attachedBuildId).toBeUndefined(); + }); +}); diff --git a/src/apple/ascClient.ts b/src/apple/ascClient.ts index 7654461f..a47a1c19 100644 --- a/src/apple/ascClient.ts +++ b/src/apple/ascClient.ts @@ -382,11 +382,17 @@ type ResourceSingle = { * One page of a paginated collection: the page's `data` plus Apple's `links.next` - an absolute URL * to the following page, present only while more pages remain. Consumed by {@link AppStoreConnectClient.requestAll}. */ +/** + * One App Store Connect resource. `relationships` holds a linked id only when the request named that + * relationship in `fields`; Apple then returns its `data` linkage. + */ +type AppleResource = { + id: string; + attributes: Attributes; + relationships?: Record; +}; type PagedList = { - data: { - id: string; - attributes: A; - }[]; + data: AppleResource[]; links?: { next?: string; }; @@ -891,16 +897,8 @@ 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< - { - id: string; - attributes: A; - }[] - > { - const all: { - id: string; - attributes: A; - }[] = []; + private async requestAll(path: string): Promise[]> { + const all: AppleResource[] = []; 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 @@ -4043,7 +4041,7 @@ export class AppStoreConnectClient { appStoreState?: string; releaseType?: string; }>( - `/apps/${appId}/appStoreVersions?filter[platform]=${platform}&limit=200&fields[appStoreVersions]=versionString,appStoreState,releaseType`, + `/apps/${appId}/appStoreVersions?filter[platform]=${platform}&limit=200&fields[appStoreVersions]=versionString,appStoreState,releaseType,build`, ); return appleResources.map((entry) => toVersionResource(entry)); } @@ -4974,14 +4972,11 @@ const toBuildResources = ({ data: builds, included = [] }: BuildPage): BuildReso builds.map((entry) => toBuildResource(entry, included)); /** Map a raw version row onto an {@link AppStoreVersionResource}, keeping a known versionString fallback. */ const toVersionResource = ( - entry: { - id: string; - attributes: { - versionString?: string; - appStoreState?: string; - releaseType?: string; - }; - }, + entry: AppleResource<{ + versionString?: string; + appStoreState?: string; + releaseType?: string; + }>, fallbackVersion = '', ): AppStoreVersionResource => { let versionString = entry.attributes.versionString; @@ -4995,6 +4990,9 @@ const toVersionResource = ( }; if (entry.attributes.releaseType !== undefined) Object.assign(version, { releaseType: entry.attributes.releaseType }); + const attachedBuildId = entry.relationships?.build?.data?.id; + const isBuildAttached = attachedBuildId !== undefined; + if (isBuildAttached) Object.assign(version, { attachedBuildId }); return version; }; /** Map a raw phased-release row onto a {@link PhasedReleaseResource}. */ diff --git a/src/cli/commands/release.ts b/src/cli/commands/release.ts index f01a28e3..356e52e7 100644 --- a/src/cli/commands/release.ts +++ b/src/cli/commands/release.ts @@ -39,6 +39,11 @@ export const registerReleaseCommand = (program: Command): void => { .option('--manual', 'iOS only - hold the approved build for manual release', false) .option('--scheduled ', 'iOS only - schedule the go-live at an ISO-8601 instant') .option('--phased', "iOS only - opt into Apple's 7-day phased rollout", false) + .option( + '--attach-only', + 'iOS only - attach the --build to the editable App Store version without submitting for review', + false, + ) .option('--dry-run', 'print the release plan (touches nothing) and exit', false) .option( '--create-app', diff --git a/src/core/release/appStoreRelease.test.ts b/src/core/release/appStoreRelease.test.ts index 9d07aad6..e99c5352 100644 --- a/src/core/release/appStoreRelease.test.ts +++ b/src/core/release/appStoreRelease.test.ts @@ -80,6 +80,7 @@ const releaseInput = (overrides: Partial = {}): ReleaseInput => ({ whatsNew: { 'en-US': 'Bug fixes.' }, build: VALID_BUILD, dryRun: false, + attachOnly: false, ...overrides, }); describe('nextReleaseAction - the appStoreState transition table', () => { @@ -473,6 +474,56 @@ describe('releaseApp - submit an update over the API', () => { }); }); +describe('releaseApp - attach only (no review submission)', () => { + const EDITABLE_VERSION = { + id: 'v-edit', + versionString: '1.2.0', + appStoreState: 'PREPARE_FOR_SUBMISSION', + }; + + it('attaches the build to the editable version and never submits it', async () => { + const api = makeApi({ + listAppStoreVersions: vi.fn(() => Effect.succeed([LIVE_VERSION, EDITABLE_VERSION])), + }); + const report = await runRelease(api, releaseInput({ attachOnly: true })); + expect(api.selectBuildForVersion).toHaveBeenCalledWith('v-edit', 'b-1'); + expect(api.createReviewSubmission).not.toHaveBeenCalled(); + expect(api.addReviewSubmissionItem).not.toHaveBeenCalled(); + expect(api.submitReviewSubmission).not.toHaveBeenCalled(); + expect(report).toMatchObject({ + versionId: 'v-edit', + appStoreState: 'PREPARE_FOR_SUBMISSION', + submitted: false, + alreadyInReview: false, + }); + }); + + it('sends no write when the same build is already attached', async () => { + const api = makeApi({ + listAppStoreVersions: vi.fn(() => + Effect.succeed([{ ...EDITABLE_VERSION, attachedBuildId: 'b-1' }]), + ), + }); + const report = await runRelease(api, releaseInput({ attachOnly: true })); + expect(api.selectBuildForVersion).not.toHaveBeenCalled(); + expect(api.updateAppStoreVersion).not.toHaveBeenCalled(); + expect(api.updateAppStoreVersionLocalization).not.toHaveBeenCalled(); + expect(report).toMatchObject({ submitted: false, alreadyInReview: false }); + expect(report.actions).toEqual([expect.objectContaining({ status: 'skipped' })]); + }); + + it('swaps an attached build for the new one while the version is editable', async () => { + const api = makeApi({ + listAppStoreVersions: vi.fn(() => + Effect.succeed([{ ...EDITABLE_VERSION, attachedBuildId: 'b-old' }]), + ), + }); + await runRelease(api, releaseInput({ attachOnly: true })); + expect(api.selectBuildForVersion).toHaveBeenCalledWith('v-edit', 'b-1'); + expect(api.submitReviewSubmission).not.toHaveBeenCalled(); + }); +}); + describe('pickCurrentVersion', () => { it('prefers an in-flight version over a live one', () => { const versions: AppStoreVersionResource[] = [ diff --git a/src/core/release/appStoreRelease.ts b/src/core/release/appStoreRelease.ts index 1364d213..71626abd 100644 --- a/src/core/release/appStoreRelease.ts +++ b/src/core/release/appStoreRelease.ts @@ -242,6 +242,8 @@ export type ReleaseInput = { whatsNew: Record; build: BuildResource | null; dryRun: boolean; + /** Attach the build to the editable version and stop before review submission. */ + attachOnly: boolean; }; /** Where one release step ended: planned (dry-run), or applied / skipped / failed after a real run. */ @@ -261,6 +263,7 @@ export type ReleaseReport = { versionId: string; versionString: string; appStoreState: string; + /** False for attach-only runs, which stop before review, and for dry runs. */ submitted: boolean; alreadyInReview: boolean; actions: ReleaseAction[]; @@ -279,6 +282,8 @@ type ReleaseContext = { type SelectedReleaseVersion = Readonly<{ versionId: string; versionString: string; + appStoreState: string; + attachedBuildId?: string; appReleasedBefore: boolean; idempotentState?: string; }>; @@ -375,7 +380,8 @@ const checkBuildCanJoinVersion = ( /** * Drive one App Store version to "submitted for review", idempotently. Reuses, retargets, or creates * the editable version; attaches build; declares export compliance; writes notes; sets release type / - * phased rollout; submits via Apple's review-submission model. With `releaseInput.dryRun` records + * phased rollout; submits via Apple's review-submission model. With `releaseInput.attachOnly` it stops + * before that submit, leaving the version editable. With `releaseInput.dryRun` records * planned steps and performs no writes. Fails only on preconditions the user must fix (no app record, * build not VALID/expired, build's marketing version already live, version already live); every other * step is captured per-action so one failure never aborts the rest. @@ -405,6 +411,7 @@ export const releaseApp = ( const storeBuild = releaseInput.build; const hasStoreBuild = storeBuild != null; + const isAttachOnly = releaseInput.attachOnly; if (hasStoreBuild) { yield* requireValidStoreBuild(storeBuild); } @@ -447,6 +454,25 @@ export const releaseApp = ( actions: releaseContext.actions, }; } + + const isBuildAlreadyAttached = + hasStoreBuild && selectedVersion.attachedBuildId === storeBuild.id; + const isAttachNoOp = isAttachOnly && isBuildAlreadyAttached; + if (isAttachNoOp) { + releaseContext.actions.push({ + description: `build already attached to version ${selectedVersion.versionString} - nothing to change`, + status: 'skipped', + }); + return { + bundleId: releaseInput.bundleId, + versionId, + versionString: selectedVersion.versionString, + appStoreState: selectedVersion.appStoreState, + submitted: false, + alreadyInReview: false, + actions: releaseContext.actions, + }; + } if (hasStoreBuild) { yield* performReleaseAction(releaseContext, `attach build ${storeBuild.version}`, () => appleReleaseApi.selectBuildForVersion(versionId, storeBuild.id), @@ -480,13 +506,18 @@ export const releaseApp = ( appleReleaseApi.updateAppStoreVersion(versionId, versionUpdate), ); yield* applyPhasedRelease(releaseContext, versionId, releaseInput.phasedRelease); - yield* submitForReview(releaseContext, appId, versionId, releaseInput.platform); + + const isSubmitting = !isAttachOnly; + if (isSubmitting) { + yield* submitForReview(releaseContext, appId, versionId, releaseInput.platform); + } + return { bundleId: releaseInput.bundleId, versionId, versionString: selectedVersion.versionString, - appStoreState: 'WAITING_FOR_REVIEW', - submitted: !releaseInput.dryRun, + appStoreState: isAttachOnly ? selectedVersion.appStoreState : 'WAITING_FOR_REVIEW', + submitted: !isAttachOnly && !releaseInput.dryRun, alreadyInReview: false, actions: releaseContext.actions, }; @@ -601,6 +632,8 @@ const selectReleaseVersion = ( return { versionId: matchingVersion.id, versionString: matchingVersion.versionString, + appStoreState: matchingVersion.appStoreState, + attachedBuildId: matchingVersion.attachedBuildId, appReleasedBefore, idempotentState: matchingVersion.appStoreState, }; @@ -608,6 +641,8 @@ const selectReleaseVersion = ( return { versionId: matchingVersion.id, versionString: matchingVersion.versionString, + appStoreState: matchingVersion.appStoreState, + attachedBuildId: matchingVersion.attachedBuildId, appReleasedBefore, }; } @@ -626,6 +661,8 @@ const selectReleaseVersion = ( return { versionId: editableVersion.id, versionString: releaseInput.versionString, + appStoreState: editableVersion.appStoreState, + attachedBuildId: editableVersion.attachedBuildId, appReleasedBefore, }; } @@ -652,6 +689,7 @@ const selectReleaseVersion = ( return { versionId, versionString: releaseInput.versionString, + appStoreState: 'PREPARE_FOR_SUBMISSION', appReleasedBefore, }; }); diff --git a/src/core/release/releaseCommand.test.ts b/src/core/release/releaseCommand.test.ts index 06bcc39f..44bb963d 100644 --- a/src/core/release/releaseCommand.test.ts +++ b/src/core/release/releaseCommand.test.ts @@ -5,11 +5,13 @@ import type { BuildResource } from '../types/appleCatalog.js'; import type { ReleaseAction, ReleaseReport } from './appStoreRelease.js'; import { countFailedReleaseActions, + describePromotionQuestion, formatReleaseActionLine, isVerifiedStoreBuild, makeReleaseCommandFailure, newestValidBuild, parseAppStoreBuildSelector, + pickReceiptTitle, androidReleasePlanLines, resolveAndroidRollout, resolveVersionString, @@ -188,3 +190,53 @@ describe('makeReleaseCommandFailure', () => { expect(failure.message).toBe('network down'); }); }); + +describe('pickReceiptTitle', () => { + const attachedReport: ReleaseReport = { + bundleId: 'com.acme.app', + versionId: 'v-edit', + versionString: '1.2.0', + appStoreState: 'PREPARE_FOR_SUBMISSION', + submitted: false, + alreadyInReview: false, + actions: [], + }; + const submittedReport: ReleaseReport = { ...attachedReport, submitted: true }; + + it('titles an attach-only receipt as attached, not submitted for review', () => { + expect(pickReceiptTitle(attachedReport, '42', 0)).toBe( + 'Build 42 attached - not submitted for review', + ); + }); + + it('keeps the submitted title for a normal release', () => { + expect(pickReceiptTitle(submittedReport, '42', 0)).toBe('Submitted for App Store review'); + }); + + it('counts failed steps in an attach-only receipt title', () => { + expect(pickReceiptTitle(attachedReport, '42', 2)).toBe( + 'Attached with 2 failed step(s) - see below', + ); + }); +}); + +describe('describePromotionQuestion', () => { + const promotion = { + appName: 'Oly', + versionString: '6.11.78', + buildLabel: '42', + attachOnly: false, + }; + + it('asks to submit the build for App Store review by default', () => { + expect(describePromotionQuestion(promotion)).toBe( + 'Submit Oly 6.11.78 (build 42) for App Store review?', + ); + }); + + it('says the build is only attached when attach-only is set', () => { + expect(describePromotionQuestion({ ...promotion, attachOnly: true })).toBe( + 'Attach build 42 to Oly 6.11.78 without submitting for review?', + ); + }); +}); diff --git a/src/core/release/releaseCommand.ts b/src/core/release/releaseCommand.ts index f3f1ae15..0e98b9a8 100644 --- a/src/core/release/releaseCommand.ts +++ b/src/core/release/releaseCommand.ts @@ -61,6 +61,7 @@ export type ReleaseCommandOptions = Readonly<{ manual?: boolean; scheduled?: string; phased?: boolean; + attachOnly?: boolean; dryRun?: boolean; createApp?: boolean; yes: boolean; @@ -286,6 +287,24 @@ const renderReleasePlan = ( ); }; +/** Receipt title: an attach-only run says the build is attached, because nothing was submitted. */ +export const pickReceiptTitle = ( + releaseReport: ReleaseReport, + buildLabel: string, + failureCount: number, +): string => { + const isAttachReceipt = !releaseReport.submitted; + const hasFailedSteps = failureCount > 0; + const isAttachedWithFailures = isAttachReceipt && hasFailedSteps; + if (isAttachedWithFailures) return `Attached with ${failureCount} failed step(s) - see below`; + + if (isAttachReceipt) return `Build ${buildLabel} attached - not submitted for review`; + + if (hasFailedSteps) return `Submitted with ${failureCount} failed step(s) - see below`; + + return 'Submitted for App Store review'; +}; + const renderReleaseReport = ( releaseReport: ReleaseReport, appName: string, @@ -304,12 +323,14 @@ const renderReleaseReport = ( const receiptLines = releaseReport.actions.map((releaseAction) => formatReleaseActionLine(releaseAction, 'receipt'), ); - let receiptTitle = 'Submitted for App Store review'; - if (failureCount > 0) receiptTitle = `Submitted with ${failureCount} failed step(s) - see below`; + const receiptTitle = pickReceiptTitle(releaseReport, buildLabel, failureCount); + const isAttachReceipt = !releaseReport.submitted; + const stateLines = isAttachReceipt ? [`App Store state: ${releaseReport.appStoreState}`] : []; return writeLog( 'render release report', logger.box(receiptTitle, [ `${appName} ${releaseReport.versionString} (build ${buildLabel})`, + ...stateLines, ...receiptLines, ]), ).pipe(Effect.as(failureCount)); @@ -434,6 +455,21 @@ const resolveBuildSource = ( }).pipe(Effect.mapError((cause) => releaseFailure('select release build', cause))); }); +/** The confirmation question for promoting a build; attach-only says it will not submit. */ +export const describePromotionQuestion = ( + promotion: Readonly<{ + appName: string; + versionString: string; + buildLabel: string; + attachOnly: boolean; + }>, +): string => { + const isAttachOnly = promotion.attachOnly; + if (isAttachOnly) + return `Attach build ${promotion.buildLabel} to ${promotion.appName} ${promotion.versionString} without submitting for review?`; + return `Submit ${promotion.appName} ${promotion.versionString} (build ${promotion.buildLabel}) for App Store review?`; +}; + const resolveIosBuild = ( ascClient: AscReleaseApi, appId: string, @@ -467,7 +503,12 @@ const resolveIosBuild = ( ); const confirmed = yield* confirmOrCancel( commandService, - `Submit ${appDescriptor.name} ${versionString} (build ${buildSource.storeBuild.version}) for App Store review?`, + describePromotionQuestion({ + appName: appDescriptor.name, + versionString, + buildLabel: buildSource.storeBuild.version, + attachOnly: commandOptions.attachOnly === true, + }), commandOptions, ); if (!confirmed) return null; @@ -649,11 +690,24 @@ const releaseAppleBuild = ( phasedRelease, usesNonExemptEncryption: launchConfig.release?.usesNonExemptEncryption === true, whatsNew, + attachOnly: commandOptions.attachOnly === true, }; if (releaseTypeSettings.earliestReleaseDate !== undefined) releaseInputCommon.earliestReleaseDate = releaseTypeSettings.earliestReleaseDate; if (launchConfig.release?.copyright !== undefined) releaseInputCommon.copyright = launchConfig.release.copyright; + const isAttachOnlyWithoutBuild = + commandOptions.attachOnly === true && commandOptions.build === undefined; + if (isAttachOnlyWithoutBuild) { + return yield* Effect.fail( + releaseFailure( + 'select App Store build', + 'attach-only', + '--attach-only needs an existing build: pass --build or --build latest.', + ), + ); + } + if (commandOptions.dryRun === true) { let storeBuild: BuildResource | null = null; if (commandOptions.build !== undefined) @@ -759,6 +813,10 @@ const releaseAppleBuild = ( yield* completeCommand(1, failureMessage); return; } + + const isAttachOnly = releaseInput.attachOnly; + if (isAttachOnly) return; + yield* notify(launchConfig, notifyEvent).pipe( Effect.mapError((cause) => releaseFailure('send release notification', cause)), ); diff --git a/src/core/releaseTrain/builder.ts b/src/core/releaseTrain/builder.ts index 8f3f61bf..75c9c13d 100644 --- a/src/core/releaseTrain/builder.ts +++ b/src/core/releaseTrain/builder.ts @@ -291,6 +291,7 @@ export const createTrainRuntime = ( whatsNew: releaseNotes, build: storeBuild, dryRun: false, + attachOnly: false, }; if (releaseTypeSettings.earliestReleaseDate !== undefined) { releaseInput.earliestReleaseDate = releaseTypeSettings.earliestReleaseDate; diff --git a/src/core/types/appleCatalog.ts b/src/core/types/appleCatalog.ts index be427670..32b9b8f6 100644 --- a/src/core/types/appleCatalog.ts +++ b/src/core/types/appleCatalog.ts @@ -453,6 +453,8 @@ export type AppStoreVersionResource = Readonly<{ versionString: string; appStoreState: string; releaseType?: string; + /** Id of the build attached to this version; absent when no build is attached. */ + attachedBuildId?: string; }>; /** One locale's editable version copy. Launch only ever writes `whatsNew` (the release notes). */ export type AppStoreVersionLocalizationResource = Readonly<{ From 4acf0d9bd8e7405caf4250371868f7939422c3ed Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Tue, 10 Nov 2026 00:04:01 +0200 Subject: [PATCH 2/5] fix(release): type the build linkage and the optional attached build for the build check --- src/apple/ascClient.ts | 6 +++--- src/core/release/appStoreRelease.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/apple/ascClient.ts b/src/apple/ascClient.ts index a47a1c19..6e6915c9 100644 --- a/src/apple/ascClient.ts +++ b/src/apple/ascClient.ts @@ -383,13 +383,13 @@ type ResourceSingle = { * to the following page, present only while more pages remain. Consumed by {@link AppStoreConnectClient.requestAll}. */ /** - * One App Store Connect resource. `relationships` holds a linked id only when the request named that - * relationship in `fields`; Apple then returns its `data` linkage. + * One App Store Connect resource. `relationships` holds the `build` linkage only when the request named + * `build` in `fields`; Apple then returns its `data` linkage (null when no build is attached). */ type AppleResource = { id: string; attributes: Attributes; - relationships?: Record; + relationships?: { build?: { data?: { id: string } | null } }; }; type PagedList = { data: AppleResource[]; diff --git a/src/core/release/appStoreRelease.ts b/src/core/release/appStoreRelease.ts index 71626abd..3fe6a342 100644 --- a/src/core/release/appStoreRelease.ts +++ b/src/core/release/appStoreRelease.ts @@ -283,7 +283,7 @@ type SelectedReleaseVersion = Readonly<{ versionId: string; versionString: string; appStoreState: string; - attachedBuildId?: string; + attachedBuildId?: string | undefined; appReleasedBefore: boolean; idempotentState?: string; }>; From ab6e1aa9f7073e08839bf999420efd021a705e59 Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Tue, 10 Nov 2026 01:01:05 +0200 Subject: [PATCH 3/5] fix(release): reject --attach-only on Android and test attach-only through the command --- src/apple/ascClient.ts | 18 +- src/core/release/appStoreRelease.test.ts | 112 ++++------- src/core/release/releaseCommand.test.ts | 243 +++++++++++++++++++---- src/core/release/releaseCommand.ts | 45 +++-- src/testkit/ascReleaseApi.testkit.ts | 54 +++++ 5 files changed, 331 insertions(+), 141 deletions(-) create mode 100644 src/testkit/ascReleaseApi.testkit.ts diff --git a/src/apple/ascClient.ts b/src/apple/ascClient.ts index 6e6915c9..c212c935 100644 --- a/src/apple/ascClient.ts +++ b/src/apple/ascClient.ts @@ -378,10 +378,6 @@ type ResourceSingle = { attributes: A; }; }; -/** - * One page of a paginated collection: the page's `data` plus Apple's `links.next` - an absolute URL - * to the following page, present only while more pages remain. Consumed by {@link AppStoreConnectClient.requestAll}. - */ /** * One App Store Connect resource. `relationships` holds the `build` linkage only when the request named * `build` in `fields`; Apple then returns its `data` linkage (null when no build is attached). @@ -391,6 +387,10 @@ type AppleResource = { attributes: Attributes; relationships?: { build?: { data?: { id: string } | null } }; }; +/** + * One page of a paginated collection: the page's `data` plus Apple's `links.next` - an absolute URL + * to the following page, present only while more pages remain. Consumed by {@link AppStoreConnectClient.requestAll}. + */ type PagedList = { data: AppleResource[]; links?: { @@ -4983,16 +4983,16 @@ const toVersionResource = ( if (versionString === undefined) versionString = fallbackVersion; let appStoreState = entry.attributes.appStoreState; if (appStoreState === undefined) appStoreState = ''; + const releaseType = entry.attributes.releaseType; + const attachedBuildId = entry.relationships?.build?.data?.id; + const isBuildAttached = attachedBuildId !== undefined; const version: AppStoreVersionResource = { id: entry.id, versionString, appStoreState, + ...(releaseType !== undefined && { releaseType }), + ...(isBuildAttached && { attachedBuildId }), }; - if (entry.attributes.releaseType !== undefined) - Object.assign(version, { releaseType: entry.attributes.releaseType }); - const attachedBuildId = entry.relationships?.build?.data?.id; - const isBuildAttached = attachedBuildId !== undefined; - if (isBuildAttached) Object.assign(version, { attachedBuildId }); return version; }; /** Map a raw phased-release row onto a {@link PhasedReleaseResource}. */ diff --git a/src/core/release/appStoreRelease.test.ts b/src/core/release/appStoreRelease.test.ts index e99c5352..395667ca 100644 --- a/src/core/release/appStoreRelease.test.ts +++ b/src/core/release/appStoreRelease.test.ts @@ -1,5 +1,6 @@ import { Effect } from 'effect'; import { describe, expect, it, vi } from 'vitest'; +import { LIVE_VERSION, makeReleaseApiFake } from '@testkit/ascReleaseApi.testkit.js'; import type { AppStoreVersionResource, BuildResource } from '../types/appleCatalog.js'; import { classifyVerdict, @@ -13,53 +14,6 @@ import { type ReleaseInput, } from './appStoreRelease.js'; -const LIVE_VERSION = { id: 'v-live', versionString: '1.1.0', appStoreState: 'READY_FOR_SALE' }; - -/** Fully-stubbed {@link AscReleaseApi}. Reads default to an app with one live version; writes resolve to a created resource. */ -const makeApi = (methodOverrides: Partial = {}): AscReleaseApi => { - const baseApi: AscReleaseApi = { - getAppId: vi.fn(() => Effect.succeed('app1')), - getLatestMarketingVersion: vi.fn(() => Effect.succeed(null)), - listBuilds: vi.fn(() => Effect.succeed([])), - findBuild: vi.fn(() => Effect.succeed({ id: 'b-1', usesNonExemptEncryption: null })), - findBuildByVersion: vi.fn(() => Effect.succeed(null)), - setBuildUsesNonExemptEncryption: vi.fn(() => Effect.void), - listAppStoreVersions: vi.fn(() => Effect.succeed([LIVE_VERSION])), - createAppStoreVersion: vi.fn().mockImplementation( - ( - _appId: string, - createInput: { - versionString: string; - }, - ) => - Effect.succeed({ - id: 'v-new', - versionString: createInput.versionString, - appStoreState: 'PREPARE_FOR_SUBMISSION', - }), - ), - updateAppStoreVersion: vi.fn(() => Effect.void), - selectBuildForVersion: vi.fn(() => Effect.void), - listAppStoreVersionLocalizations: vi.fn(() => Effect.succeed([])), - createAppStoreVersionLocalization: vi - .fn() - .mockReturnValue(Effect.succeed({ id: 'loc-new', locale: 'en-US' })), - updateAppStoreVersionLocalization: vi.fn(() => Effect.void), - getPhasedRelease: vi.fn(() => Effect.succeed(null)), - createPhasedRelease: vi.fn(() => - Effect.succeed({ id: 'ph-new', phasedReleaseState: 'ACTIVE' }), - ), - deletePhasedRelease: vi.fn(() => Effect.void), - listReviewSubmissions: vi.fn(() => Effect.succeed([])), - createReviewSubmission: vi.fn(() => Effect.succeed({ id: 'rs1', state: 'READY_FOR_REVIEW' })), - addReviewSubmissionItem: vi.fn(() => Effect.void), - submitReviewSubmission: vi.fn(() => Effect.void), - getReviewSubmission: vi.fn(() => Effect.succeed({ id: 'rs1', state: 'WAITING_FOR_REVIEW' })), - createAppStoreVersionReleaseRequest: vi.fn(() => Effect.void), - }; - return { ...baseApi, ...methodOverrides }; -}; - const runRelease = (appleReleaseApi: AscReleaseApi, releaseInput: ReleaseInput) => Effect.runPromise(releaseApp(appleReleaseApi, releaseInput)); @@ -123,7 +77,7 @@ describe('classifyVerdict - the --watch / exit-code contract', () => { }); describe('releaseApp - submit an update over the API', () => { it('creates the version, attaches the build, declares compliance, writes notes, and submits', async () => { - const api = makeApi(); + const api = makeReleaseApiFake(); const report = await runRelease(api, releaseInput()); expect(api.createAppStoreVersion).toHaveBeenCalledWith( 'app1', @@ -151,7 +105,7 @@ describe('releaseApp - submit an update over the API', () => { }); }); it("updates an existing localization's notes instead of creating a duplicate", async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ listAppStoreVersions: vi .fn() .mockReturnValue( @@ -169,7 +123,7 @@ describe('releaseApp - submit an update over the API', () => { expect(api.createAppStoreVersionLocalization).not.toHaveBeenCalled(); }); it("skips release notes on the app's first version (Apple has no What's New there)", async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ listAppStoreVersions: vi .fn() .mockReturnValue( @@ -192,7 +146,7 @@ describe('releaseApp - submit an update over the API', () => { expect(api.submitReviewSubmission).toHaveBeenCalledWith('rs1'); }); it('sends release.copyright in the version PATCH with the release type', async () => { - const api = makeApi(); + const api = makeReleaseApiFake(); const report = await runRelease(api, releaseInput({ copyright: '2026 Acme Inc.' })); expect(api.updateAppStoreVersion).toHaveBeenCalledWith('v-new', { releaseType: 'AFTER_APPROVAL', @@ -203,7 +157,7 @@ describe('releaseApp - submit an update over the API', () => { ); }); it('retargets the open editable version when its version string differs (one editable allowed at a time)', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ listAppStoreVersions: vi .fn() .mockReturnValue( @@ -217,7 +171,7 @@ describe('releaseApp - submit an update over the API', () => { expect(api.createAppStoreVersion).not.toHaveBeenCalled(); }); it('is an idempotent no-op when the version is already in review', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ listAppStoreVersions: vi .fn() .mockReturnValue( @@ -235,7 +189,7 @@ describe('releaseApp - submit an update over the API', () => { expect(api.submitReviewSubmission).not.toHaveBeenCalled(); }); it('skips export-compliance writes when the build already has the desired answer', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ findBuild: vi.fn(() => Effect.succeed({ id: 'b-1', usesNonExemptEncryption: false })), }); const report = await runRelease(api, releaseInput()); @@ -253,7 +207,7 @@ describe('releaseApp - submit an update over the API', () => { new Error('PATCH /builds/b-1 failed (409): You cannot update when the value is already set.'), { status: 409 }, ); - const api = makeApi({ + const api = makeReleaseApiFake({ // Pre-check misses (null), then the PATCH races into Apple's already-set 409. findBuild: vi.fn(() => Effect.succeed({ id: 'b-1', usesNonExemptEncryption: null })), setBuildUsesNonExemptEncryption: vi.fn(() => Effect.fail(alreadySetError)), @@ -271,12 +225,12 @@ describe('releaseApp - submit an update over the API', () => { expect(api.submitReviewSubmission).toHaveBeenCalled(); }); it('enables a phased release when opted in', async () => { - const api = makeApi(); + const api = makeReleaseApiFake(); await runRelease(api, releaseInput({ phasedRelease: true })); expect(api.createPhasedRelease).toHaveBeenCalledWith('v-new'); }); it('errors when the exact version is already live (you must bump)', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ listAppStoreVersions: vi .fn() .mockReturnValue( @@ -288,7 +242,7 @@ describe('releaseApp - submit an update over the API', () => { ); }); it('refuses a build whose marketing version is already live, before any App Store write', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ listAppStoreVersions: vi.fn().mockReturnValue( Effect.succeed([ { id: 'v-live', versionString: '1.1.0', appStoreState: 'READY_FOR_SALE' }, @@ -308,7 +262,7 @@ describe('releaseApp - submit an update over the API', () => { expect(api.createAppStoreVersion).not.toHaveBeenCalled(); }); it('attaches a build whose marketing version is not live yet, to its editable version', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ listAppStoreVersions: vi.fn().mockReturnValue( Effect.succeed([ { id: 'v-live', versionString: '1.1.0', appStoreState: 'READY_FOR_SALE' }, @@ -323,7 +277,7 @@ describe('releaseApp - submit an update over the API', () => { expect(api.selectBuildForVersion).toHaveBeenCalledWith('v-next', 'b-1'); }); it('errors without changing anything when the exact version is in an unknown state', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ listAppStoreVersions: vi .fn() .mockReturnValue( @@ -334,13 +288,13 @@ describe('releaseApp - submit an update over the API', () => { expect(api.updateAppStoreVersion).not.toHaveBeenCalled(); }); it('errors with the portal checklist when the app has no App Store Connect record', async () => { - const api = makeApi({ getAppId: vi.fn(() => Effect.succeed(null)) }); + const api = makeReleaseApiFake({ getAppId: vi.fn(() => Effect.succeed(null)) }); await expect(runRelease(api, releaseInput())).rejects.toThrow( /No App Store Connect app record.*Apple has no API/s, ); }); it("refuses a build that hasn't finished processing", async () => { - const api = makeApi(); + const api = makeReleaseApiFake(); const processing: BuildResource = { id: 'b-2', version: '43', @@ -353,7 +307,7 @@ describe('releaseApp - submit an update over the API', () => { expect(api.selectBuildForVersion).not.toHaveBeenCalled(); }); it('plans every step in a dry-run without performing any write', async () => { - const api = makeApi(); + const api = makeReleaseApiFake(); const report = await runRelease(api, releaseInput({ dryRun: true })); for (const write of [ api.createAppStoreVersion, @@ -374,7 +328,7 @@ describe('releaseApp - submit an update over the API', () => { ); }); it('captures a failed step and keeps going (one failure no longer aborts the rest)', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ listAppStoreVersions: vi .fn() .mockReturnValue( @@ -392,7 +346,7 @@ describe('releaseApp - submit an update over the API', () => { }); it('is an idempotent no-op when the version is pending developer release', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ listAppStoreVersions: vi.fn().mockReturnValue( Effect.succeed([ { @@ -414,7 +368,7 @@ describe('releaseApp - submit an update over the API', () => { it('passes earliestReleaseDate when creating and updating the version', async () => { const earliestReleaseDate = '2026-09-01T12:00:00.000Z'; - const api = makeApi(); + const api = makeReleaseApiFake(); await runRelease(api, releaseInput({ earliestReleaseDate, releaseType: 'SCHEDULED' })); expect(api.createAppStoreVersion).toHaveBeenCalledWith( 'app1', @@ -434,7 +388,7 @@ describe('releaseApp - submit an update over the API', () => { }); it('disables an existing phased release when opted out', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ listAppStoreVersions: vi .fn() .mockReturnValue( @@ -450,7 +404,7 @@ describe('releaseApp - submit an update over the API', () => { }); it('refuses an expired build before attaching it', async () => { - const api = makeApi(); + const api = makeReleaseApiFake(); const expiredBuild: BuildResource = { id: 'b-2', version: '43', @@ -462,7 +416,7 @@ describe('releaseApp - submit an update over the API', () => { }); it('reuses an open READY_FOR_REVIEW submission instead of creating another', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ listReviewSubmissions: vi.fn(() => Effect.succeed([{ id: 'rs-open', state: 'READY_FOR_REVIEW' }]), ), @@ -482,7 +436,7 @@ describe('releaseApp - attach only (no review submission)', () => { }; it('attaches the build to the editable version and never submits it', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ listAppStoreVersions: vi.fn(() => Effect.succeed([LIVE_VERSION, EDITABLE_VERSION])), }); const report = await runRelease(api, releaseInput({ attachOnly: true })); @@ -499,7 +453,7 @@ describe('releaseApp - attach only (no review submission)', () => { }); it('sends no write when the same build is already attached', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ listAppStoreVersions: vi.fn(() => Effect.succeed([{ ...EDITABLE_VERSION, attachedBuildId: 'b-1' }]), ), @@ -513,7 +467,7 @@ describe('releaseApp - attach only (no review submission)', () => { }); it('swaps an attached build for the new one while the version is editable', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ listAppStoreVersions: vi.fn(() => Effect.succeed([{ ...EDITABLE_VERSION, attachedBuildId: 'b-old' }]), ), @@ -547,7 +501,7 @@ describe('pickCurrentVersion', () => { describe('readReleaseStatus', () => { it('classifies the current version and latest build', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ listAppStoreVersions: vi.fn(() => Effect.succeed([{ id: 'v2', versionString: '1.1.0', appStoreState: 'IN_REVIEW' }]), ), @@ -569,7 +523,7 @@ describe('readReleaseStatus', () => { }); it('returns an empty preparing verdict when the app has no versions yet', async () => { - const api = makeApi({ listAppStoreVersions: vi.fn(() => Effect.succeed([])) }); + const api = makeReleaseApiFake({ listAppStoreVersions: vi.fn(() => Effect.succeed([])) }); const releaseStatus = await Effect.runPromise( readReleaseStatus(api, 'com.acme.app', IOS_PLATFORM), ); @@ -585,7 +539,9 @@ describe('waitForValidBuild', () => { const noSleep = (): Effect.Effect => Effect.void; it('returns the build once it reaches VALID', async () => { - const api = makeApi({ findBuildByVersion: vi.fn(() => Effect.succeed(VALID_BUILD)) }); + const api = makeReleaseApiFake({ + findBuildByVersion: vi.fn(() => Effect.succeed(VALID_BUILD)), + }); await expect( Effect.runPromise(waitForValidBuild(api, 'app1', 42, { sleep: noSleep })), ).resolves.toEqual(VALID_BUILD); @@ -603,7 +559,7 @@ describe('waitForValidBuild', () => { }), ) .mockReturnValueOnce(Effect.succeed(VALID_BUILD)); - const api = makeApi({ findBuildByVersion }); + const api = makeReleaseApiFake({ findBuildByVersion }); const sleepBetweenPolls = vi.fn(() => Effect.void); const storeBuild = await Effect.runPromise( waitForValidBuild(api, 'app1', 42, { sleep: sleepBetweenPolls, intervalMs: 1 }), @@ -613,7 +569,7 @@ describe('waitForValidBuild', () => { }); it('throws on INVALID processing', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ findBuildByVersion: vi.fn().mockReturnValue( Effect.succeed({ id: 'b', @@ -629,7 +585,7 @@ describe('waitForValidBuild', () => { }); it('throws on timeout', async () => { - const api = makeApi({ + const api = makeReleaseApiFake({ findBuildByVersion: vi.fn(() => Effect.succeed({ id: 'b', diff --git a/src/core/release/releaseCommand.test.ts b/src/core/release/releaseCommand.test.ts index 44bb963d..db8fb2b5 100644 --- a/src/core/release/releaseCommand.test.ts +++ b/src/core/release/releaseCommand.test.ts @@ -1,23 +1,39 @@ -import { Effect } from 'effect'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { NodeHttpClient } from '@effect/platform-node'; +import { Cause, Effect, Exit, Layer } from 'effect'; import { describe, expect, it, vi } from 'vitest'; +import { makeReleaseApiFake } from '@testkit/ascReleaseApi.testkit.js'; +import { + createTemporaryHomeDirectory, + makeLaunchHomeTest, + removeTemporaryHomeDirectory, + writeLaunchHomeFile, +} from '@testkit/launchHome.testkit.js'; +import { makeFakeAscClient } from '@testkit/storeClients.testkit.js'; import type { AppDescriptor } from '../types/app.js'; import type { BuildResource } from '../types/appleCatalog.js'; -import type { ReleaseAction, ReleaseReport } from './appStoreRelease.js'; +import { AppleStoreClientService } from '../services/appleStoreClient.js'; +import { makeLaunchEnvironmentTest } from '../services/environment.js'; +import { makeLaunchLoggerTest } from '../services/logger.js'; +import { makeLaunchPromptTest } from '../services/prompt.js'; +import { makeLaunchSecretStoreTest } from '../services/secretStore.js'; +import type { AscReleaseApi, ReleaseAction, ReleaseReport } from './appStoreRelease.js'; import { + androidReleasePlanLines, countFailedReleaseActions, - describePromotionQuestion, formatReleaseActionLine, isVerifiedStoreBuild, makeReleaseCommandFailure, newestValidBuild, parseAppStoreBuildSelector, - pickReceiptTitle, - androidReleasePlanLines, + releaseCommandProgram, + ReleaseCommandService, + type ReleaseCommandOptions, resolveAndroidRollout, resolveVersionString, shouldNudgeRelease, } from './releaseCommand.js'; - const storeBuild = (overrides: Partial = {}): BuildResource => ({ id: 'b-1', version: '42', @@ -191,52 +207,199 @@ describe('makeReleaseCommandFailure', () => { }); }); -describe('pickReceiptTitle', () => { - const attachedReport: ReleaseReport = { - bundleId: 'com.acme.app', - versionId: 'v-edit', - versionString: '1.2.0', - appStoreState: 'PREPARE_FOR_SUBMISSION', - submitted: false, - alreadyInReview: false, - actions: [], - }; - const submittedReport: ReleaseReport = { ...attachedReport, submitted: true }; +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 EDITABLE_VERSION = { + id: 'v-edit', + versionString: '1.2.0', + appStoreState: 'PREPARE_FOR_SUBMISSION', +}; - it('titles an attach-only receipt as attached, not submitted for review', () => { - expect(pickReceiptTitle(attachedReport, '42', 0)).toBe( - 'Build 42 attached - not submitted for review', - ); +interface CommandRun { + homeDirectory: string; + platformArgument: string; + commandOptions: ReleaseCommandOptions; + releaseApi: AscReleaseApi; +} + +interface CommandOutcome { + exit: Exit.Exit; + logLines: string[]; + questions: string[]; +} + +const commandOptionsFor = ( + overrides: Partial = {}, +): ReleaseCommandOptions => ({ + env: [], + includeLocal: false, + printEnv: false, + profile: 'production', + explain: false, + wait: false, + yes: false, + json: false, + ...overrides, +}); + +const makeAttachApiFake = (methodOverrides: Partial = {}): AscReleaseApi => + makeReleaseApiFake({ + listAppStoreVersions: vi.fn(() => Effect.succeed([EDITABLE_VERSION])), + listBuilds: vi.fn(() => Effect.succeed([storeBuild()])), + findBuild: vi.fn(() => Effect.succeed({ id: 'b-1', usesNonExemptEncryption: false })), + findBuildByVersion: vi.fn(() => Effect.succeed(storeBuild())), + ...methodOverrides, + }); + +const runInTemporaryHome = async ( + runWithHome: (homeDirectory: string) => Promise, +): Promise => { + const homeDirectory = createTemporaryHomeDirectory(); + + try { + const workDirectory = join(homeDirectory, 'work'); + mkdirSync(workDirectory, { recursive: true }); + writeFileSync(join(workDirectory, 'app.json'), APP_JSON_TEXT); + writeLaunchHomeFile(homeDirectory, 'accounts.json', ACCOUNTS_FILE_TEXT); + + return await runWithHome(homeDirectory); + } finally { + removeTemporaryHomeDirectory(homeDirectory); + } +}; + +const runReleaseCommand = async (commandRun: CommandRun): Promise => { + const logLines: string[] = []; + const questions: string[] = []; + const appleStoreClientTest = Layer.succeed(AppleStoreClientService, { + createClient: () => Effect.succeed(commandRun.releaseApi), + createEffectClient: () => Effect.succeed(makeFakeAscClient([])), + createReleaseAttributesClient: () => Effect.succeed(commandRun.releaseApi), }); + const releaseCommandTest = Layer.succeed(ReleaseCommandService, { + terminalIsInteractive: true, + confirmRelease: (message) => + Effect.sync(() => { + questions.push(message); + return true; + }), + cancelRelease: () => Effect.void, + }); + const secretStoreTest = makeLaunchSecretStoreTest(new Map([[`asc-p8:${ASC_KEY_ID}`, P8_SECRET]])); + + const releaseProgram = releaseCommandProgram( + commandRun.platformArgument, + commandRun.commandOptions, + ).pipe( + Effect.provide(makeLaunchHomeTest(commandRun.homeDirectory)), + Effect.provide(NodeHttpClient.layer), + Effect.provide(makeLaunchEnvironmentTest({})), + Effect.provide(secretStoreTest), + Effect.provide(makeLaunchPromptTest()), + Effect.provide(makeLaunchLoggerTest(logLines)), + Effect.provide(appleStoreClientTest), + Effect.provide(releaseCommandTest), + ); + + const exit = await Effect.runPromiseExit(releaseProgram); + + return { exit, logLines, questions }; +}; + +const failedRunText = (exit: Exit.Exit): string => { + const didRunFail = Exit.isFailure(exit); + if (!didRunFail) return ''; - it('keeps the submitted title for a normal release', () => { - expect(pickReceiptTitle(submittedReport, '42', 0)).toBe('Submitted for App Store review'); + return Cause.pretty(exit.cause); +}; + +describe('releaseCommandProgram - attach-only', () => { + it('fails on Android before any App Store call', async () => { + const releaseApi = makeReleaseApiFake(); + + const outcome = await runInTemporaryHome((homeDirectory) => + runReleaseCommand({ + homeDirectory, + platformArgument: 'android', + commandOptions: commandOptionsFor({ attachOnly: true }), + releaseApi, + }), + ); + + expect(Exit.isFailure(outcome.exit)).toBe(true); + expect(failedRunText(outcome.exit)).toContain('--attach-only is iOS only'); + expect(releaseApi.getAppId).not.toHaveBeenCalled(); }); - it('counts failed steps in an attach-only receipt title', () => { - expect(pickReceiptTitle(attachedReport, '42', 2)).toBe( - 'Attached with 2 failed step(s) - see below', + it('attaches the build to the editable version and never submits it', async () => { + const releaseApi = makeAttachApiFake(); + + const outcome = await runInTemporaryHome((homeDirectory) => + runReleaseCommand({ + homeDirectory, + platformArgument: 'ios', + commandOptions: commandOptionsFor({ build: '42', attachOnly: true }), + releaseApi, + }), ); + + expect(Exit.isSuccess(outcome.exit)).toBe(true); + expect(outcome.logLines.join('\n')).toContain('Build 42 attached - not submitted for review'); + expect(outcome.questions).toContainEqual( + expect.stringMatching(/^Attach build 42 to .+ without submitting for review\?$/), + ); + expect(releaseApi.createReviewSubmission).not.toHaveBeenCalled(); }); -}); -describe('describePromotionQuestion', () => { - const promotion = { - appName: 'Oly', - versionString: '6.11.78', - buildLabel: '42', - attachOnly: false, - }; + it('titles the receipt with the failed step count when an attach step fails', async () => { + const releaseApi = makeAttachApiFake({ + selectBuildForVersion: vi.fn(() => Effect.fail(new Error('Apple rejected the build'))), + }); - it('asks to submit the build for App Store review by default', () => { - expect(describePromotionQuestion(promotion)).toBe( - 'Submit Oly 6.11.78 (build 42) for App Store review?', + const outcome = await runInTemporaryHome((homeDirectory) => + runReleaseCommand({ + homeDirectory, + platformArgument: 'ios', + commandOptions: commandOptionsFor({ build: '42', attachOnly: true }), + releaseApi, + }), ); + + expect(outcome.logLines.join('\n')).toContain('Attached with 1 failed step(s) - see below'); + expect(Exit.isFailure(outcome.exit)).toBe(true); + expect(releaseApi.createReviewSubmission).not.toHaveBeenCalled(); }); - it('says the build is only attached when attach-only is set', () => { - expect(describePromotionQuestion({ ...promotion, attachOnly: true })).toBe( - 'Attach build 42 to Oly 6.11.78 without submitting for review?', + it('asks to submit and submits for review when attach-only is off', async () => { + const releaseApi = makeAttachApiFake(); + + const outcome = await runInTemporaryHome((homeDirectory) => + runReleaseCommand({ + homeDirectory, + platformArgument: 'ios', + commandOptions: commandOptionsFor({ build: '42' }), + releaseApi, + }), + ); + + expect(outcome.logLines.join('\n')).toContain('Submitted for App Store review'); + expect(outcome.questions).toContainEqual( + expect.stringMatching(/^Submit .+ \(build 42\) for App Store review\?$/), ); + expect(releaseApi.createReviewSubmission).toHaveBeenCalled(); }); }); diff --git a/src/core/release/releaseCommand.ts b/src/core/release/releaseCommand.ts index 0e98b9a8..19d0057e 100644 --- a/src/core/release/releaseCommand.ts +++ b/src/core/release/releaseCommand.ts @@ -288,29 +288,34 @@ const renderReleasePlan = ( }; /** Receipt title: an attach-only run says the build is attached, because nothing was submitted. */ -export const pickReceiptTitle = ( - releaseReport: ReleaseReport, +const pickReceiptTitle = ( + isAttachOnly: boolean, buildLabel: string, failureCount: number, ): string => { - const isAttachReceipt = !releaseReport.submitted; const hasFailedSteps = failureCount > 0; - const isAttachedWithFailures = isAttachReceipt && hasFailedSteps; + const isAttachedWithFailures = isAttachOnly && hasFailedSteps; if (isAttachedWithFailures) return `Attached with ${failureCount} failed step(s) - see below`; - if (isAttachReceipt) return `Build ${buildLabel} attached - not submitted for review`; + if (isAttachOnly) return `Build ${buildLabel} attached - not submitted for review`; if (hasFailedSteps) return `Submitted with ${failureCount} failed step(s) - see below`; return 'Submitted for App Store review'; }; +interface ReleaseReceipt { + appName: string; + buildLabel: string; + isAttachOnly: boolean; +} + const renderReleaseReport = ( releaseReport: ReleaseReport, - appName: string, - buildLabel: string, + receipt: ReleaseReceipt, logger: Logger, ): Effect.Effect => { + const { appName, buildLabel, isAttachOnly } = receipt; if (releaseReport.alreadyInReview) { return writeLog( 'render release report', @@ -323,9 +328,8 @@ const renderReleaseReport = ( const receiptLines = releaseReport.actions.map((releaseAction) => formatReleaseActionLine(releaseAction, 'receipt'), ); - const receiptTitle = pickReceiptTitle(releaseReport, buildLabel, failureCount); - const isAttachReceipt = !releaseReport.submitted; - const stateLines = isAttachReceipt ? [`App Store state: ${releaseReport.appStoreState}`] : []; + const receiptTitle = pickReceiptTitle(isAttachOnly, buildLabel, failureCount); + const stateLines = isAttachOnly ? [`App Store state: ${releaseReport.appStoreState}`] : []; return writeLog( 'render release report', logger.box(receiptTitle, [ @@ -456,7 +460,7 @@ const resolveBuildSource = ( }); /** The confirmation question for promoting a build; attach-only says it will not submit. */ -export const describePromotionQuestion = ( +const describePromotionQuestion = ( promotion: Readonly<{ appName: string; versionString: string; @@ -795,10 +799,14 @@ const releaseAppleBuild = ( ), ), ); + const isAttachOnly = releaseInput.attachOnly; const failureCount = yield* renderReleaseReport( releaseReport, - appDescriptor.name, - selectedBuild.storeBuild.version, + { + appName: appDescriptor.name, + buildLabel: selectedBuild.storeBuild.version, + isAttachOnly, + }, logger, ); if (failureCount > 0) { @@ -814,7 +822,6 @@ const releaseAppleBuild = ( return; } - const isAttachOnly = releaseInput.attachOnly; if (isAttachOnly) return; yield* notify(launchConfig, notifyEvent).pipe( @@ -974,6 +981,16 @@ export const releaseCommandProgram = ( const platform = yield* parsePlatform(platformArgument).pipe( Effect.mapError((cause) => releaseFailure('parse release platform', cause, cause.message)), ); + const isAttachOnlyOnAndroid = commandOptions.attachOnly === true && !isApplePlatform(platform); + if (isAttachOnlyOnAndroid) { + return yield* Effect.fail( + releaseFailure( + 'select release platform', + 'attach-only', + '--attach-only is iOS only; Google Play has no attach-without-submit step.', + ), + ); + } const loadedConfiguration = yield* loadConfig().pipe( Effect.mapError((cause) => releaseFailure('load Launch configuration', cause)), ); diff --git a/src/testkit/ascReleaseApi.testkit.ts b/src/testkit/ascReleaseApi.testkit.ts new file mode 100644 index 00000000..b3b82971 --- /dev/null +++ b/src/testkit/ascReleaseApi.testkit.ts @@ -0,0 +1,54 @@ +import { Effect } from 'effect'; +import { vi } from 'vitest'; +import type { AscReleaseApi } from '../core/release/appStoreRelease.js'; + +export const LIVE_VERSION = { + id: 'v-live', + versionString: '1.1.0', + appStoreState: 'READY_FOR_SALE', +}; + +/** Fully-stubbed AscReleaseApi. Reads default to an app with one live version; writes resolve to a created resource. */ +export const makeReleaseApiFake = (methodOverrides: Partial = {}): AscReleaseApi => { + const baseApi: AscReleaseApi = { + getAppId: vi.fn(() => Effect.succeed('app1')), + getLatestMarketingVersion: vi.fn(() => Effect.succeed(null)), + listBuilds: vi.fn(() => Effect.succeed([])), + findBuild: vi.fn(() => Effect.succeed({ id: 'b-1', usesNonExemptEncryption: null })), + findBuildByVersion: vi.fn(() => Effect.succeed(null)), + setBuildUsesNonExemptEncryption: vi.fn(() => Effect.void), + listAppStoreVersions: vi.fn(() => Effect.succeed([LIVE_VERSION])), + createAppStoreVersion: vi.fn().mockImplementation( + ( + _appId: string, + createInput: { + versionString: string; + }, + ) => + Effect.succeed({ + id: 'v-new', + versionString: createInput.versionString, + appStoreState: 'PREPARE_FOR_SUBMISSION', + }), + ), + updateAppStoreVersion: vi.fn(() => Effect.void), + selectBuildForVersion: vi.fn(() => Effect.void), + listAppStoreVersionLocalizations: vi.fn(() => Effect.succeed([])), + createAppStoreVersionLocalization: vi + .fn() + .mockReturnValue(Effect.succeed({ id: 'loc-new', locale: 'en-US' })), + updateAppStoreVersionLocalization: vi.fn(() => Effect.void), + getPhasedRelease: vi.fn(() => Effect.succeed(null)), + createPhasedRelease: vi.fn(() => + Effect.succeed({ id: 'ph-new', phasedReleaseState: 'ACTIVE' }), + ), + deletePhasedRelease: vi.fn(() => Effect.void), + listReviewSubmissions: vi.fn(() => Effect.succeed([])), + createReviewSubmission: vi.fn(() => Effect.succeed({ id: 'rs1', state: 'READY_FOR_REVIEW' })), + addReviewSubmissionItem: vi.fn(() => Effect.void), + submitReviewSubmission: vi.fn(() => Effect.void), + getReviewSubmission: vi.fn(() => Effect.succeed({ id: 'rs1', state: 'WAITING_FOR_REVIEW' })), + createAppStoreVersionReleaseRequest: vi.fn(() => Effect.void), + }; + return { ...baseApi, ...methodOverrides }; +}; From da824d1212e100ead50ae2854cba0aebf1b0a604 Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Tue, 10 Nov 2026 01:01:24 +0200 Subject: [PATCH 4/5] docs: regenerate after rebase on main --- README.de.md | 2 +- README.es.md | 2 +- README.fr.md | 2 +- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.pt-BR.md | 2 +- README.ru.md | 2 +- README.zh-CN.md | 2 +- llms-full.txt | 2 +- llms.txt | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.de.md b/README.de.md index 6326000e..9f5e9133 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 - 2909 tests passing + 2917 tests passing

diff --git a/README.es.md b/README.es.md index f77874f5..8f8e7c42 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 - 2909 tests passing + 2917 tests passing

diff --git a/README.fr.md b/README.fr.md index 52fa8318..d3ee4d6c 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 - 2909 tests passing + 2917 tests passing

diff --git a/README.ja.md b/README.ja.md index 593d07c9..4f7b16b1 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 - 2909 tests passing + 2917 tests passing

diff --git a/README.ko.md b/README.ko.md index fddce7fb..852d54ae 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 - 2909 tests passing + 2917 tests passing

diff --git a/README.md b/README.md index 56a55cd6..96424ef0 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 - 2909 tests passing + 2917 tests passing

diff --git a/README.pt-BR.md b/README.pt-BR.md index b76e6499..5f4d23df 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 - 2909 tests passing + 2917 tests passing

diff --git a/README.ru.md b/README.ru.md index a61d26dd..6b47bf49 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 - 2909 tests passing + 2917 tests passing

diff --git a/README.zh-CN.md b/README.zh-CN.md index 7b2e776f..c0daaf59 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 - 2909 tests passing + 2917 tests passing

diff --git a/llms-full.txt b/llms-full.txt index fdb9674e..e0c61b3b 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, 2909 tests): +All 65 `launch` commands (205 store-API operations underneath, 2917 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 diff --git a/llms.txt b/llms.txt index 0cce729c..9aa27f8a 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, 2909 tests): +All 65 `launch` commands (205 store-API operations underneath, 2917 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 From f99d62c375ebf6d97938421f67bb473296097b3b Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Tue, 10 Nov 2026 01:02:17 +0200 Subject: [PATCH 5/5] fix(release): type the release attributes client fake in the command test --- src/core/release/releaseCommand.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/release/releaseCommand.test.ts b/src/core/release/releaseCommand.test.ts index db8fb2b5..7d2f6535 100644 --- a/src/core/release/releaseCommand.test.ts +++ b/src/core/release/releaseCommand.test.ts @@ -288,7 +288,7 @@ const runReleaseCommand = async (commandRun: CommandRun): Promise Effect.succeed(commandRun.releaseApi), createEffectClient: () => Effect.succeed(makeFakeAscClient([])), - createReleaseAttributesClient: () => Effect.succeed(commandRun.releaseApi), + createReleaseAttributesClient: () => Effect.succeed(makeFakeAscClient([])), }); const releaseCommandTest = Layer.succeed(ReleaseCommandService, { terminalIsInteractive: true,