diff --git a/.changeset/bright-release-lifecycle.md b/.changeset/bright-release-lifecycle.md new file mode 100644 index 0000000..c566534 --- /dev/null +++ b/.changeset/bright-release-lifecycle.md @@ -0,0 +1,10 @@ +--- +'@ankhorage/deploy': minor +--- + +Add the complete provider-neutral release lifecycle: canonical `deploy/release.json` authoring, +cross-target planning, Google Play and App Store rollout execution, read-back verification, +safe retry/resume, supported lifecycle controls, immutable release history, and intentional +public release APIs. + +The project entrypoint now owns inspection, planning, execution, resume, and supported lifecycle controls while provider adapters remain internal. diff --git a/README.md b/README.md index 4ad8281..09468bb 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # @ankhorage/deploy -![license: MIT](././paradox/badges/license.svg) ![npm: v0.7.0](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg) +![license: MIT](././paradox/badges/license.svg) ![npm: v0.8.0](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg) Declarative deployment engine for Expo apps across web, iOS, and Android. @@ -17,5 +17,9 @@ Declarative deployment engine for Expo apps across web, iOS, and Android. - [Export graph](././paradox/diagrams/export-graph.mmd) - [createDeploymentPlan sequence](././paradox/diagrams/sequences/create-deployment-plan.mmd) - [createProjectMonetizationPlan sequence](././paradox/diagrams/sequences/create-project-monetization-plan.mmd) +- [createProjectReleaseHistoryRecord sequence](././paradox/diagrams/sequences/create-project-release-history-record.mmd) +- [createReleaseCurrentRevision sequence](././paradox/diagrams/sequences/create-release-current-revision.mmd) +- [createReleaseRevision sequence](././paradox/diagrams/sequences/create-release-revision.mmd) +- [listReleaseLifecycleControls sequence](././paradox/diagrams/sequences/list-release-lifecycle-controls.mmd) - [readProjectDeploymentConfig sequence](././paradox/diagrams/sequences/read-project-deployment-config.mmd) - [resolveDeployProject sequence](././paradox/diagrams/sequences/resolve-deploy-project.mmd) diff --git a/src/domain/release/ReleaseControlExecutionResult.ts b/src/domain/release/ReleaseControlExecutionResult.ts new file mode 100644 index 0000000..77215dd --- /dev/null +++ b/src/domain/release/ReleaseControlExecutionResult.ts @@ -0,0 +1,4 @@ +export type ReleaseControlExecutionResult = + | { readonly status: 'completed'; readonly mutationAttempted: boolean } + | { readonly status: 'blocked'; readonly mutationAttempted: false; readonly code: string } + | { readonly status: 'failed'; readonly mutationAttempted: boolean; readonly code: string }; diff --git a/src/domain/release/ReleaseDesiredState.ts b/src/domain/release/ReleaseDesiredState.ts new file mode 100644 index 0000000..8f28372 --- /dev/null +++ b/src/domain/release/ReleaseDesiredState.ts @@ -0,0 +1,11 @@ +import type { ReleaseNote } from './ReleaseNote'; +import type { ReleaseRollout } from './ReleaseRollout'; +import type { ReleaseTarget } from './ReleaseTarget'; + +export interface ReleaseDesiredState { + readonly version: string; + readonly targets: readonly ReleaseTarget[]; + readonly notes: readonly ReleaseNote[]; + readonly rollout: ReleaseRollout; + readonly revision: string; +} diff --git a/src/domain/release/ReleaseDiagnostic.ts b/src/domain/release/ReleaseDiagnostic.ts new file mode 100644 index 0000000..979a605 --- /dev/null +++ b/src/domain/release/ReleaseDiagnostic.ts @@ -0,0 +1,8 @@ +import type { ReleaseTarget } from './ReleaseTarget'; + +export interface ReleaseDiagnostic { + readonly severity: 'warning' | 'error'; + readonly code: string; + readonly message: string; + readonly target?: ReleaseTarget | 'release'; +} diff --git a/src/domain/release/ReleaseExecutionState.ts b/src/domain/release/ReleaseExecutionState.ts new file mode 100644 index 0000000..9c56fc7 --- /dev/null +++ b/src/domain/release/ReleaseExecutionState.ts @@ -0,0 +1,6 @@ +import type { ReleaseExecutionStep } from './ReleaseExecutionStep'; + +export interface ReleaseExecutionState { + readonly releaseRevision: string; + readonly steps: readonly ReleaseExecutionStep[]; +} diff --git a/src/domain/release/ReleaseExecutionStep.ts b/src/domain/release/ReleaseExecutionStep.ts new file mode 100644 index 0000000..eeebc03 --- /dev/null +++ b/src/domain/release/ReleaseExecutionStep.ts @@ -0,0 +1,10 @@ +import type { ReleasePlanStep } from './ReleasePlanStep'; + +type ReleaseExecutionStepStatus = 'pending' | 'completed' | 'failed' | 'waiting' | 'cancelled'; + +export interface ReleaseExecutionStep { + readonly step: ReleasePlanStep; + readonly status: ReleaseExecutionStepStatus; + readonly attempts: number; + readonly code?: string; +} diff --git a/src/domain/release/ReleaseLifecycleControl.ts b/src/domain/release/ReleaseLifecycleControl.ts new file mode 100644 index 0000000..e8c62cc --- /dev/null +++ b/src/domain/release/ReleaseLifecycleControl.ts @@ -0,0 +1,6 @@ +export type ReleaseLifecycleControl = + | { readonly target: 'android'; readonly action: 'halt' | 'resume' } + | { + readonly target: 'ios'; + readonly action: 'pause-phased' | 'resume-phased' | 'cancel-phased' | 'cancel-review'; + }; diff --git a/src/domain/release/ReleaseNote.ts b/src/domain/release/ReleaseNote.ts new file mode 100644 index 0000000..1d51567 --- /dev/null +++ b/src/domain/release/ReleaseNote.ts @@ -0,0 +1,4 @@ +export interface ReleaseNote { + readonly locale: string; + readonly text: string; +} diff --git a/src/domain/release/ReleaseObservedAndroidState.ts b/src/domain/release/ReleaseObservedAndroidState.ts new file mode 100644 index 0000000..e601164 --- /dev/null +++ b/src/domain/release/ReleaseObservedAndroidState.ts @@ -0,0 +1,11 @@ +import type { ReleaseNote } from './ReleaseNote'; + +export interface ReleaseObservedAndroidState { + readonly target: 'android'; + readonly version: string | null; + readonly artifactRevision: string | null; + readonly versionCodes: readonly string[]; + readonly releaseNotes: readonly ReleaseNote[]; + readonly rolloutStatus: 'missing' | 'draft' | 'inProgress' | 'halted' | 'completed'; + readonly userFraction?: string; +} diff --git a/src/domain/release/ReleaseObservedIosState.ts b/src/domain/release/ReleaseObservedIosState.ts new file mode 100644 index 0000000..882a26b --- /dev/null +++ b/src/domain/release/ReleaseObservedIosState.ts @@ -0,0 +1,13 @@ +import type { ReleaseNote } from './ReleaseNote'; + +export interface ReleaseObservedIosState { + readonly target: 'ios'; + readonly version: string | null; + readonly artifactRevision: string | null; + readonly buildNumber: string | null; + readonly releaseNotes: readonly ReleaseNote[]; + readonly appVersionState?: string; + readonly releaseType?: string; + readonly reviewState?: string; + readonly phasedReleaseState: 'INACTIVE' | 'ACTIVE' | 'PAUSED' | 'COMPLETE' | null; +} diff --git a/src/domain/release/ReleaseObservedState.ts b/src/domain/release/ReleaseObservedState.ts new file mode 100644 index 0000000..fb60338 --- /dev/null +++ b/src/domain/release/ReleaseObservedState.ts @@ -0,0 +1,5 @@ +import type { ReleaseObservedTargetState } from './ReleaseObservedTargetState'; + +export interface ReleaseObservedState { + readonly targets: readonly ReleaseObservedTargetState[]; +} diff --git a/src/domain/release/ReleaseObservedTargetState.ts b/src/domain/release/ReleaseObservedTargetState.ts new file mode 100644 index 0000000..f974753 --- /dev/null +++ b/src/domain/release/ReleaseObservedTargetState.ts @@ -0,0 +1,6 @@ +import type { ReleaseObservedAndroidState } from './ReleaseObservedAndroidState'; +import type { ReleaseObservedIosState } from './ReleaseObservedIosState'; +import type { ReleaseObservedWebState } from './ReleaseObservedWebState'; + +export type ReleaseObservedTargetState = + ReleaseObservedWebState | ReleaseObservedAndroidState | ReleaseObservedIosState; diff --git a/src/domain/release/ReleaseObservedWebState.ts b/src/domain/release/ReleaseObservedWebState.ts new file mode 100644 index 0000000..575ec7a --- /dev/null +++ b/src/domain/release/ReleaseObservedWebState.ts @@ -0,0 +1,5 @@ +export interface ReleaseObservedWebState { + readonly target: 'web'; + readonly version: string | null; + readonly artifactRevision: string | null; +} diff --git a/src/domain/release/ReleasePlan.ts b/src/domain/release/ReleasePlan.ts new file mode 100644 index 0000000..6cfc86d --- /dev/null +++ b/src/domain/release/ReleasePlan.ts @@ -0,0 +1,11 @@ +import type { ReleaseDiagnostic } from './ReleaseDiagnostic'; +import type { ReleasePlanStatus } from './ReleasePlanStatus'; +import type { ReleasePlanStep } from './ReleasePlanStep'; + +export interface ReleasePlan { + readonly status: ReleasePlanStatus; + readonly desiredRevision: string; + readonly currentRevision: string; + readonly steps: readonly ReleasePlanStep[]; + readonly diagnostics: readonly ReleaseDiagnostic[]; +} diff --git a/src/domain/release/ReleasePlanStatus.ts b/src/domain/release/ReleasePlanStatus.ts new file mode 100644 index 0000000..26b3db0 --- /dev/null +++ b/src/domain/release/ReleasePlanStatus.ts @@ -0,0 +1 @@ +export type ReleasePlanStatus = 'no-change' | 'changes' | 'waiting' | 'blocked'; diff --git a/src/domain/release/ReleasePlanStep.ts b/src/domain/release/ReleasePlanStep.ts new file mode 100644 index 0000000..b7ab99d --- /dev/null +++ b/src/domain/release/ReleasePlanStep.ts @@ -0,0 +1,12 @@ +import type { ReleaseStepOperation } from './ReleaseStepOperation'; +import type { ReleaseStepRetry } from './ReleaseStepRetry'; +import type { ReleaseTarget } from './ReleaseTarget'; + +export interface ReleasePlanStep { + readonly id: string; + readonly target: ReleaseTarget | 'release'; + readonly operation: ReleaseStepOperation; + readonly dependsOn: readonly string[]; + readonly retry: ReleaseStepRetry; + readonly irreversible: boolean; +} diff --git a/src/domain/release/ReleaseReconcileResult.ts b/src/domain/release/ReleaseReconcileResult.ts new file mode 100644 index 0000000..577e33a --- /dev/null +++ b/src/domain/release/ReleaseReconcileResult.ts @@ -0,0 +1,10 @@ +import type { ReleasePlan } from './ReleasePlan'; + +export interface ReleaseReconcileResult { + readonly status: 'completed' | 'waiting' | 'blocked' | 'failed' | 'drifted'; + readonly plan: ReleasePlan; + readonly currentRevision: string; + readonly executedStepIds: readonly string[]; + readonly attemptedStepId?: string; + readonly code?: string; +} diff --git a/src/domain/release/ReleaseRollout.ts b/src/domain/release/ReleaseRollout.ts new file mode 100644 index 0000000..bd63d10 --- /dev/null +++ b/src/domain/release/ReleaseRollout.ts @@ -0,0 +1,7 @@ +import type { ReleaseTargetRollout } from './ReleaseTargetRollout'; + +export interface ReleaseRollout { + readonly web?: ReleaseTargetRollout; + readonly android?: ReleaseTargetRollout; + readonly ios?: ReleaseTargetRollout; +} diff --git a/src/domain/release/ReleaseRolloutMode.ts b/src/domain/release/ReleaseRolloutMode.ts new file mode 100644 index 0000000..51cc24a --- /dev/null +++ b/src/domain/release/ReleaseRolloutMode.ts @@ -0,0 +1 @@ +export type ReleaseRolloutMode = 'immediate' | 'staged'; diff --git a/src/domain/release/ReleaseStepOperation.ts b/src/domain/release/ReleaseStepOperation.ts new file mode 100644 index 0000000..37e4179 --- /dev/null +++ b/src/domain/release/ReleaseStepOperation.ts @@ -0,0 +1,10 @@ +export type ReleaseStepOperation = + | 'prepare' + | 'build' + | 'publish' + | 'sync-notes' + | 'submit-review' + | 'release' + | 'rollout' + | 'verify' + | 'record'; diff --git a/src/domain/release/ReleaseStepRetry.ts b/src/domain/release/ReleaseStepRetry.ts new file mode 100644 index 0000000..78440e9 --- /dev/null +++ b/src/domain/release/ReleaseStepRetry.ts @@ -0,0 +1 @@ +export type ReleaseStepRetry = 'safe' | 'reinspect' | 'never'; diff --git a/src/domain/release/ReleaseTarget.ts b/src/domain/release/ReleaseTarget.ts new file mode 100644 index 0000000..253fe21 --- /dev/null +++ b/src/domain/release/ReleaseTarget.ts @@ -0,0 +1 @@ +export type ReleaseTarget = 'web' | 'android' | 'ios'; diff --git a/src/domain/release/ReleaseTargetPlanContribution.ts b/src/domain/release/ReleaseTargetPlanContribution.ts new file mode 100644 index 0000000..4e34fcc --- /dev/null +++ b/src/domain/release/ReleaseTargetPlanContribution.ts @@ -0,0 +1,10 @@ +import type { ReleaseDiagnostic } from './ReleaseDiagnostic'; +import type { ReleasePlanStep } from './ReleasePlanStep'; + +export interface ReleaseTargetPlanContribution { + readonly steps: readonly ReleasePlanStep[]; + readonly diagnostics: readonly ReleaseDiagnostic[]; + readonly waiting: boolean; + readonly complete: boolean; + readonly terminalStepId?: string; +} diff --git a/src/domain/release/ReleaseTargetRollout.ts b/src/domain/release/ReleaseTargetRollout.ts new file mode 100644 index 0000000..92c3bfc --- /dev/null +++ b/src/domain/release/ReleaseTargetRollout.ts @@ -0,0 +1,6 @@ +import type { ReleaseRolloutMode } from './ReleaseRolloutMode'; + +export interface ReleaseTargetRollout { + readonly mode: ReleaseRolloutMode; + readonly initialFraction?: string; +} diff --git a/src/domain/release/areReleaseNotesSatisfied.ts b/src/domain/release/areReleaseNotesSatisfied.ts new file mode 100644 index 0000000..7e9d830 --- /dev/null +++ b/src/domain/release/areReleaseNotesSatisfied.ts @@ -0,0 +1,10 @@ +import type { ReleaseNote } from './ReleaseNote'; + +export function areReleaseNotesSatisfied( + desired: readonly ReleaseNote[], + current: readonly ReleaseNote[], +): boolean { + return desired.every((note) => + current.some((candidate) => candidate.locale === note.locale && candidate.text === note.text), + ); +} diff --git a/src/domain/release/createAndroidReleaseSteps.ts b/src/domain/release/createAndroidReleaseSteps.ts new file mode 100644 index 0000000..3663900 --- /dev/null +++ b/src/domain/release/createAndroidReleaseSteps.ts @@ -0,0 +1,113 @@ +import { areReleaseNotesSatisfied } from './areReleaseNotesSatisfied'; +import { normalizeReleaseFraction } from './normalizeReleaseFraction'; +import type { ReleaseDesiredState } from './ReleaseDesiredState'; +import type { ReleaseObservedAndroidState } from './ReleaseObservedAndroidState'; +import type { ReleasePlanStep } from './ReleasePlanStep'; +import type { ReleaseTargetPlanContribution } from './ReleaseTargetPlanContribution'; +import type { ReleaseTargetRollout } from './ReleaseTargetRollout'; + +export function createAndroidReleaseSteps( + desired: ReleaseDesiredState, + current: ReleaseObservedAndroidState, +): ReleaseTargetPlanContribution { + if (current.rolloutStatus === 'halted') return haltedContribution(); + const rollout = desired.rollout.android ?? { mode: 'immediate' }; + const artifactReady = isArtifactReady(desired, current); + const notesMatch = areReleaseNotesSatisfied(desired.notes, current.releaseNotes); + if (artifactReady && notesMatch && rolloutSatisfied(rollout, current)) { + return { steps: [], diagnostics: [], waiting: false, complete: true }; + } + const steps = createMutableSteps(desired, current, rollout, artifactReady, notesMatch); + const verify = verificationStep(steps); + return { + steps: [...steps, verify], + diagnostics: [], + waiting: false, + complete: false, + terminalStepId: verify.id, + }; +} + +function createMutableSteps( + desired: ReleaseDesiredState, + current: ReleaseObservedAndroidState, + rollout: ReleaseTargetRollout, + artifactReady: boolean, + notesMatch: boolean, +): ReleasePlanStep[] { + const steps: ReleasePlanStep[] = []; + if (!artifactReady) steps.push(step('android:publish', 'publish', [], 'reinspect')); + if (!notesMatch) { + steps.push(step('android:sync-notes', 'sync-notes', lastDependency(steps), 'reinspect')); + } + if (needsRolloutMutation(rollout, current, artifactReady)) { + const operation = rollout.mode === 'staged' ? 'rollout' : 'release'; + steps.push(step(`android:${operation}`, operation, lastDependency(steps), 'reinspect')); + } + return steps; +} + +function rolloutSatisfied( + rollout: ReleaseTargetRollout, + current: ReleaseObservedAndroidState, +): boolean { + if (current.rolloutStatus === 'completed') return true; + if (rollout.mode !== 'staged' || current.rolloutStatus !== 'inProgress') return false; + return ( + normalizeReleaseFraction(current.userFraction) === + normalizeReleaseFraction(rollout.initialFraction) + ); +} + +function needsRolloutMutation( + rollout: ReleaseTargetRollout, + current: ReleaseObservedAndroidState, + artifactReady: boolean, +): boolean { + return !artifactReady || !rolloutSatisfied(rollout, current); +} + +function isArtifactReady( + desired: ReleaseDesiredState, + current: ReleaseObservedAndroidState, +): boolean { + return ( + current.version === desired.version && + current.artifactRevision !== null && + current.versionCodes.length > 0 + ); +} + +function verificationStep(steps: readonly ReleasePlanStep[]): ReleasePlanStep { + return step('android:verify', 'verify', lastDependency(steps), 'safe'); +} + +function step( + id: string, + operation: ReleasePlanStep['operation'], + dependsOn: readonly string[], + retry: ReleasePlanStep['retry'], +): ReleasePlanStep { + return { id, target: 'android', operation, dependsOn, retry, irreversible: false }; +} + +function lastDependency(steps: readonly ReleasePlanStep[]): readonly string[] { + const last = steps.at(-1); + return last === undefined ? [] : [last.id]; +} + +function haltedContribution(): ReleaseTargetPlanContribution { + return { + steps: [], + diagnostics: [ + { + severity: 'warning', + code: 'ANDROID_ROLLOUT_HALTED', + message: 'The Google Play staged rollout is halted and will not be resumed implicitly.', + target: 'android', + }, + ], + waiting: true, + complete: false, + }; +} diff --git a/src/domain/release/createIosReleaseSteps.ts b/src/domain/release/createIosReleaseSteps.ts new file mode 100644 index 0000000..a9928f3 --- /dev/null +++ b/src/domain/release/createIosReleaseSteps.ts @@ -0,0 +1,255 @@ +import { areReleaseNotesSatisfied } from './areReleaseNotesSatisfied'; +import type { ReleaseDesiredState } from './ReleaseDesiredState'; +import type { ReleaseDiagnostic } from './ReleaseDiagnostic'; +import type { ReleaseObservedIosState } from './ReleaseObservedIosState'; +import type { ReleasePlanStep } from './ReleasePlanStep'; +import type { ReleaseTargetPlanContribution } from './ReleaseTargetPlanContribution'; + +const REVIEW_WAITING = new Set([ + 'WAITING_FOR_REVIEW', + 'IN_REVIEW', + 'WAITING_FOR_EXPORT_COMPLIANCE', +]); +const RELEASED = new Set(['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE']); +const REJECTED = new Set(['REJECTED', 'METADATA_REJECTED', 'DEVELOPER_REJECTED']); + +export function createIosReleaseSteps( + desired: ReleaseDesiredState, + current: ReleaseObservedIosState, +): ReleaseTargetPlanContribution { + if (isRejected(current)) + return blocked('IOS_RELEASE_REJECTED', 'The iOS release needs manual correction.'); + const lockedConflict = lockedStateConflict(desired, current); + if (lockedConflict !== null) return blocked(lockedConflict.code, lockedConflict.message); + const rolloutConflict = immediateRolloutConflict(desired, current); + if (rolloutConflict !== null) return blocked(rolloutConflict.code, rolloutConflict.message); + + const steps = createMutableMetadataSteps(desired, current); + const rolloutResult = appendRolloutStep(desired, current, steps); + if (rolloutResult !== null) return rolloutResult; + if (isReleased(current)) return releasedContribution(steps); + if (isWaitingForReview(current)) return waitingContribution(steps); + if (isReadyForRelease(current)) return releaseContribution(steps); + if (current.reviewState === 'COMPLETE') return processingContribution(steps); + return submitReviewContribution(steps); +} + +function createMutableMetadataSteps( + desired: ReleaseDesiredState, + current: ReleaseObservedIosState, +): ReleasePlanStep[] { + const steps: ReleasePlanStep[] = []; + if (!isArtifactReady(desired, current)) { + steps.push(step('ios:publish', 'publish', [], 'reinspect', false)); + } + if (!areReleaseNotesSatisfied(desired.notes, current.releaseNotes)) { + steps.push(step('ios:sync-notes', 'sync-notes', lastDependency(steps), 'reinspect', false)); + } + return steps; +} + +function appendRolloutStep( + desired: ReleaseDesiredState, + current: ReleaseObservedIosState, + steps: ReleasePlanStep[], +): ReleaseTargetPlanContribution | null { + const mode = desired.rollout.ios?.mode ?? 'immediate'; + if (mode === 'staged') return appendStagedRollout(current, steps); + return appendImmediateRollout(current, steps); +} + +function appendStagedRollout( + current: ReleaseObservedIosState, + steps: ReleasePlanStep[], +): ReleaseTargetPlanContribution | null { + if (current.phasedReleaseState === 'PAUSED') { + return waiting( + steps, + 'IOS_PHASED_RELEASE_PAUSED', + 'The App Store phased release is paused and will not be resumed implicitly.', + ); + } + if (current.phasedReleaseState !== null) return null; + if (isReleased(current)) { + return blocked( + 'IOS_PHASED_RELEASE_NOT_CONFIGURED', + 'Phased release was not configured before the iOS version was released.', + ); + } + steps.push(step('ios:rollout', 'rollout', lastDependency(steps), 'reinspect', false)); + return null; +} + +function appendImmediateRollout( + current: ReleaseObservedIosState, + steps: ReleasePlanStep[], +): ReleaseTargetPlanContribution | null { + if (current.phasedReleaseState === null || current.phasedReleaseState === 'COMPLETE') return null; + if (current.phasedReleaseState === 'INACTIVE') { + return blocked( + 'IOS_PHASED_RELEASE_CONFLICT', + 'A planned phased release conflicts with immediate iOS release intent.', + ); + } + if (!isReleased(current)) return null; + steps.push(step('ios:rollout', 'rollout', lastDependency(steps), 'reinspect', true)); + return null; +} + +function lockedStateConflict( + desired: ReleaseDesiredState, + current: ReleaseObservedIosState, +): ReleaseDiagnostic | null { + const locked = isWaitingForReview(current) || isReadyForRelease(current) || isReleased(current); + if (!locked) return null; + if (!isArtifactReady(desired, current)) { + return diagnostic( + 'IOS_RELEASE_ARTIFACT_DRIFT_LOCKED', + 'The iOS review/release lifecycle is locked to a different artifact.', + ); + } + if (!areReleaseNotesSatisfied(desired.notes, current.releaseNotes)) { + return diagnostic( + 'IOS_RELEASE_NOTES_DRIFT_LOCKED', + 'The iOS review/release lifecycle is locked with different release notes.', + ); + } + return null; +} + +function immediateRolloutConflict( + desired: ReleaseDesiredState, + current: ReleaseObservedIosState, +): ReleaseDiagnostic | null { + const mode = desired.rollout.ios?.mode ?? 'immediate'; + if (mode !== 'immediate' || current.phasedReleaseState !== 'INACTIVE') return null; + return diagnostic( + 'IOS_PHASED_RELEASE_CONFLICT', + 'A planned phased release conflicts with immediate iOS release intent.', + ); +} + +function releasedContribution(steps: readonly ReleasePlanStep[]): ReleaseTargetPlanContribution { + if (steps.length === 0) return { steps: [], diagnostics: [], waiting: false, complete: true }; + return verifiedContribution(steps); +} + +function releaseContribution(steps: readonly ReleasePlanStep[]): ReleaseTargetPlanContribution { + const release = step('ios:release', 'release', lastDependency(steps), 'never', true); + return verifiedContribution([...steps, release]); +} + +function submitReviewContribution( + steps: readonly ReleasePlanStep[], +): ReleaseTargetPlanContribution { + const submit = step( + 'ios:submit-review', + 'submit-review', + lastDependency(steps), + 'reinspect', + false, + ); + return waiting( + [...steps, submit], + 'IOS_REVIEW_PENDING', + 'The iOS release will wait for App Review after submission.', + ); +} + +function verifiedContribution(steps: readonly ReleasePlanStep[]): ReleaseTargetPlanContribution { + const verify = step('ios:verify', 'verify', lastDependency(steps), 'safe', false); + return { + steps: [...steps, verify], + diagnostics: [], + waiting: false, + complete: false, + terminalStepId: verify.id, + }; +} + +function waitingContribution(steps: readonly ReleasePlanStep[]): ReleaseTargetPlanContribution { + return waiting( + steps, + 'IOS_REVIEW_PENDING', + 'The iOS release is waiting for the current App Review lifecycle.', + ); +} + +function processingContribution(steps: readonly ReleasePlanStep[]): ReleaseTargetPlanContribution { + return waiting( + steps, + 'IOS_RELEASE_PROCESSING', + 'App Review is complete but the iOS version is not yet ready for release.', + ); +} + +function waiting( + steps: readonly ReleasePlanStep[], + code: string, + message: string, +): ReleaseTargetPlanContribution { + return { + steps, + diagnostics: [{ severity: 'warning', code, message, target: 'ios' }], + waiting: true, + complete: false, + }; +} + +function blocked(code: string, message: string): ReleaseTargetPlanContribution { + return { + steps: [], + diagnostics: [{ severity: 'error', code, message, target: 'ios' }], + waiting: false, + complete: false, + }; +} + +function diagnostic(code: string, message: string): ReleaseDiagnostic { + return { severity: 'error', code, message, target: 'ios' }; +} + +function step( + id: string, + operation: ReleasePlanStep['operation'], + dependsOn: readonly string[], + retry: ReleasePlanStep['retry'], + irreversible: boolean, +): ReleasePlanStep { + return { id, target: 'ios', operation, dependsOn, retry, irreversible }; +} + +function lastDependency(steps: readonly ReleasePlanStep[]): readonly string[] { + const last = steps.at(-1); + return last === undefined ? [] : [last.id]; +} + +function isArtifactReady(desired: ReleaseDesiredState, current: ReleaseObservedIosState): boolean { + return ( + current.version === desired.version && + current.artifactRevision !== null && + current.buildNumber !== null + ); +} + +function isWaitingForReview(current: ReleaseObservedIosState): boolean { + return ( + (current.appVersionState !== undefined && REVIEW_WAITING.has(current.appVersionState)) || + (current.reviewState !== undefined && REVIEW_WAITING.has(current.reviewState)) + ); +} + +function isReadyForRelease(current: ReleaseObservedIosState): boolean { + return current.appVersionState === 'PENDING_DEVELOPER_RELEASE'; +} + +function isReleased(current: ReleaseObservedIosState): boolean { + return current.appVersionState !== undefined && RELEASED.has(current.appVersionState); +} + +function isRejected(current: ReleaseObservedIosState): boolean { + return ( + (current.appVersionState !== undefined && REJECTED.has(current.appVersionState)) || + (current.reviewState !== undefined && REJECTED.has(current.reviewState)) + ); +} diff --git a/src/domain/release/createReleaseCurrentRevision.ts b/src/domain/release/createReleaseCurrentRevision.ts new file mode 100644 index 0000000..1ffdc96 --- /dev/null +++ b/src/domain/release/createReleaseCurrentRevision.ts @@ -0,0 +1,71 @@ +import { createHash } from 'node:crypto'; + +import { normalizeReleaseFraction } from './normalizeReleaseFraction'; +import type { ReleaseNote } from './ReleaseNote'; +import type { ReleaseObservedState } from './ReleaseObservedState'; +import type { ReleaseObservedTargetState } from './ReleaseObservedTargetState'; +import type { ReleaseTarget } from './ReleaseTarget'; + +export function createReleaseCurrentRevision( + state: ReleaseObservedState, + selectedTargets: readonly ReleaseTarget[], +): string { + const targets = state.targets + .filter((target) => selectedTargets.includes(target.target)) + .map(canonicalTarget) + .sort((left, right) => left.target.localeCompare(right.target)); + return createHash('sha256').update(JSON.stringify(targets)).digest('hex'); +} + +function canonicalTarget(state: ReleaseObservedTargetState): CanonicalReleaseTarget { + switch (state.target) { + case 'web': + return { + target: state.target, + version: state.version, + artifactRevision: state.artifactRevision, + }; + case 'android': + return canonicalAndroid(state); + case 'ios': + return canonicalIos(state); + } +} + +function canonicalAndroid( + state: Extract, +): CanonicalReleaseTarget { + return { + target: state.target, + version: state.version, + artifactRevision: state.artifactRevision, + versionCodes: state.versionCodes.slice().sort(), + releaseNotes: canonicalNotes(state.releaseNotes), + rolloutStatus: state.rolloutStatus, + userFraction: normalizeReleaseFraction(state.userFraction) ?? null, + }; +} + +function canonicalIos( + state: Extract, +): CanonicalReleaseTarget { + return { + target: state.target, + version: state.version, + artifactRevision: state.artifactRevision, + buildNumber: state.buildNumber, + releaseNotes: canonicalNotes(state.releaseNotes), + appVersionState: state.appVersionState ?? null, + releaseType: state.releaseType ?? null, + reviewState: state.reviewState ?? null, + phasedReleaseState: state.phasedReleaseState, + }; +} + +function canonicalNotes(notes: readonly ReleaseNote[]): readonly ReleaseNote[] { + return notes.slice().sort((left, right) => left.locale.localeCompare(right.locale)); +} + +type CanonicalReleaseTarget = Readonly> & { + readonly target: ReleaseTarget; +}; diff --git a/src/domain/release/createReleaseExecutionState.ts b/src/domain/release/createReleaseExecutionState.ts new file mode 100644 index 0000000..6f0e39b --- /dev/null +++ b/src/domain/release/createReleaseExecutionState.ts @@ -0,0 +1,12 @@ +import type { ReleaseExecutionState } from './ReleaseExecutionState'; +import type { ReleasePlanStep } from './ReleasePlanStep'; + +export function createReleaseExecutionState( + releaseRevision: string, + steps: readonly ReleasePlanStep[], +): ReleaseExecutionState { + return { + releaseRevision, + steps: steps.map((step) => ({ step, status: 'pending', attempts: 0 })), + }; +} diff --git a/src/domain/release/createReleaseExecutionStateFromResult.ts b/src/domain/release/createReleaseExecutionStateFromResult.ts new file mode 100644 index 0000000..f51a881 --- /dev/null +++ b/src/domain/release/createReleaseExecutionStateFromResult.ts @@ -0,0 +1,33 @@ +import type { ReleaseExecutionState } from './ReleaseExecutionState'; +import type { ReleaseExecutionStep } from './ReleaseExecutionStep'; +import type { ReleasePlan } from './ReleasePlan'; +import type { ReleaseReconcileResult } from './ReleaseReconcileResult'; + +export function createReleaseExecutionStateFromResult(options: { + readonly releaseRevision: string; + readonly initialPlan: ReleasePlan; + readonly result: ReleaseReconcileResult; +}): ReleaseExecutionState { + return { + releaseRevision: options.releaseRevision, + steps: options.initialPlan.steps.map((step) => createStep(step, options.result)), + }; +} + +function createStep( + step: ReleasePlan['steps'][number], + result: ReleaseReconcileResult, +): ReleaseExecutionStep { + if (result.executedStepIds.includes(step.id)) { + return { step, status: 'completed', attempts: 1 }; + } + if (result.attemptedStepId === step.id && result.status === 'failed') { + return { + step, + status: 'failed', + attempts: 1, + ...(result.code === undefined ? {} : { code: result.code }), + }; + } + return { step, status: 'pending', attempts: 0 }; +} diff --git a/src/domain/release/createReleasePlan.ts b/src/domain/release/createReleasePlan.ts new file mode 100644 index 0000000..bc1abc6 --- /dev/null +++ b/src/domain/release/createReleasePlan.ts @@ -0,0 +1,127 @@ +import { createAndroidReleaseSteps } from './createAndroidReleaseSteps'; +import { createIosReleaseSteps } from './createIosReleaseSteps'; +import { createReleaseCurrentRevision } from './createReleaseCurrentRevision'; +import { createWebReleaseSteps } from './createWebReleaseSteps'; +import type { ReleaseDesiredState } from './ReleaseDesiredState'; +import type { ReleaseDiagnostic } from './ReleaseDiagnostic'; +import type { ReleaseObservedState } from './ReleaseObservedState'; +import type { ReleaseObservedTargetState } from './ReleaseObservedTargetState'; +import type { ReleasePlan } from './ReleasePlan'; +import type { ReleasePlanStep } from './ReleasePlanStep'; +import type { ReleaseTarget } from './ReleaseTarget'; +import type { ReleaseTargetPlanContribution } from './ReleaseTargetPlanContribution'; + +export function createReleasePlan( + desired: ReleaseDesiredState, + current: ReleaseObservedState, +): ReleasePlan { + const diagnostics: ReleaseDiagnostic[] = []; + const contributions: ReleaseTargetPlanContribution[] = []; + for (const target of sortTargets(desired.targets)) { + const observed = findTarget(current, target); + if (observed === null) { + diagnostics.push(missingTargetDiagnostic(target)); + continue; + } + contributions.push(createContribution(desired, observed)); + } + diagnostics.push(...contributions.flatMap((item) => item.diagnostics)); + return finalizePlan(desired, current, contributions, diagnostics); +} + +function finalizePlan( + desired: ReleaseDesiredState, + current: ReleaseObservedState, + contributions: readonly ReleaseTargetPlanContribution[], + diagnostics: readonly ReleaseDiagnostic[], +): ReleasePlan { + const currentRevision = createReleaseCurrentRevision(current, desired.targets); + if (diagnostics.some((diagnostic) => diagnostic.severity === 'error')) { + return basePlan(desired, currentRevision, 'blocked', [], diagnostics); + } + const steps = contributions.flatMap((contribution) => contribution.steps); + const waiting = contributions.some((contribution) => contribution.waiting); + if (steps.length === 0) { + return basePlan(desired, currentRevision, waiting ? 'waiting' : 'no-change', [], diagnostics); + } + const finalSteps = waiting ? steps : [...steps, recordStep(contributions)]; + return basePlan(desired, currentRevision, 'changes', finalSteps, diagnostics); +} + +function basePlan( + desired: ReleaseDesiredState, + currentRevision: string, + status: ReleasePlan['status'], + steps: readonly ReleasePlanStep[], + diagnostics: readonly ReleaseDiagnostic[], +): ReleasePlan { + return { + status, + desiredRevision: desired.revision, + currentRevision, + steps, + diagnostics, + }; +} + +function createContribution( + desired: ReleaseDesiredState, + observed: ReleaseObservedTargetState, +): ReleaseTargetPlanContribution { + switch (observed.target) { + case 'web': + return createWebReleaseSteps(desired, observed); + case 'android': + return createAndroidReleaseSteps(desired, observed); + case 'ios': + return createIosReleaseSteps(desired, observed); + } +} + +function findTarget( + current: ReleaseObservedState, + target: ReleaseTarget, +): ReleaseObservedTargetState | null { + const matches = current.targets.filter((candidate) => candidate.target === target); + return matches.length === 1 ? (matches[0] ?? null) : null; +} + +function missingTargetDiagnostic(target: ReleaseTarget): ReleaseDiagnostic { + return { + severity: 'error', + code: 'RELEASE_TARGET_STATE_INVALID', + message: 'Exactly one observed state is required for each selected release target.', + target, + }; +} + +function recordStep(contributions: readonly ReleaseTargetPlanContribution[]): ReleasePlanStep { + const dependsOn = contributions + .flatMap((contribution) => + contribution.terminalStepId === undefined ? [] : [contribution.terminalStepId], + ) + .sort(); + return { + id: 'release:record', + target: 'release', + operation: 'record', + dependsOn, + retry: 'safe', + irreversible: true, + }; +} + +function sortTargets(targets: readonly ReleaseTarget[]): readonly ReleaseTarget[] { + return targets.slice().sort((left, right) => targetRank(left) - targetRank(right)); +} + +function targetRank(target: ReleaseTarget): number { + switch (target) { + case 'web': + return 0; + case 'android': + return 1; + case 'ios': + return 2; + } +} diff --git a/src/domain/release/createReleaseRevision.ts b/src/domain/release/createReleaseRevision.ts new file mode 100644 index 0000000..a4b5796 --- /dev/null +++ b/src/domain/release/createReleaseRevision.ts @@ -0,0 +1,20 @@ +import { createHash } from 'node:crypto'; + +import type { ReleaseNote } from './ReleaseNote'; +import type { ReleaseRollout } from './ReleaseRollout'; +import type { ReleaseTarget } from './ReleaseTarget'; + +export function createReleaseRevision(options: { + readonly version: string; + readonly targets: readonly ReleaseTarget[]; + readonly notes: readonly ReleaseNote[]; + readonly rollout: ReleaseRollout; +}): string { + const canonical = { + version: options.version, + targets: options.targets.slice().sort(), + notes: options.notes.slice().sort((a, b) => a.locale.localeCompare(b.locale)), + rollout: options.rollout, + }; + return createHash('sha256').update(JSON.stringify(canonical)).digest('hex'); +} diff --git a/src/domain/release/createWebReleaseSteps.ts b/src/domain/release/createWebReleaseSteps.ts new file mode 100644 index 0000000..5dd26c7 --- /dev/null +++ b/src/domain/release/createWebReleaseSteps.ts @@ -0,0 +1,36 @@ +import type { ReleaseDesiredState } from './ReleaseDesiredState'; +import type { ReleaseObservedWebState } from './ReleaseObservedWebState'; +import type { ReleasePlanStep } from './ReleasePlanStep'; +import type { ReleaseTargetPlanContribution } from './ReleaseTargetPlanContribution'; + +export function createWebReleaseSteps( + desired: ReleaseDesiredState, + current: ReleaseObservedWebState, +): ReleaseTargetPlanContribution { + if (current.version === desired.version && current.artifactRevision !== null) { + return { steps: [], diagnostics: [], waiting: false, complete: true }; + } + const publish: ReleasePlanStep = { + id: 'web:publish', + target: 'web', + operation: 'publish', + dependsOn: [], + retry: 'reinspect', + irreversible: false, + }; + const verify: ReleasePlanStep = { + id: 'web:verify', + target: 'web', + operation: 'verify', + dependsOn: [publish.id], + retry: 'safe', + irreversible: false, + }; + return { + steps: [publish, verify], + diagnostics: [], + waiting: false, + complete: false, + terminalStepId: verify.id, + }; +} diff --git a/src/domain/release/isReleaseLifecycleControlReadbackSatisfied.ts b/src/domain/release/isReleaseLifecycleControlReadbackSatisfied.ts new file mode 100644 index 0000000..7a4ef09 --- /dev/null +++ b/src/domain/release/isReleaseLifecycleControlReadbackSatisfied.ts @@ -0,0 +1,16 @@ +import { isReleaseLifecycleControlSatisfied } from './isReleaseLifecycleControlSatisfied'; +import type { ReleaseLifecycleControl } from './ReleaseLifecycleControl'; +import type { ReleaseObservedTargetState } from './ReleaseObservedTargetState'; + +export function isReleaseLifecycleControlReadbackSatisfied( + control: ReleaseLifecycleControl, + observed: ReleaseObservedTargetState, +): boolean { + if (isReleaseLifecycleControlSatisfied(control, observed)) return true; + return ( + control.target === 'ios' && + control.action === 'cancel-review' && + observed.target === 'ios' && + observed.reviewState === 'COMPLETE' + ); +} diff --git a/src/domain/release/isReleaseLifecycleControlSatisfied.ts b/src/domain/release/isReleaseLifecycleControlSatisfied.ts new file mode 100644 index 0000000..0e5c6dc --- /dev/null +++ b/src/domain/release/isReleaseLifecycleControlSatisfied.ts @@ -0,0 +1,30 @@ +import type { ReleaseLifecycleControl } from './ReleaseLifecycleControl'; +import type { ReleaseObservedTargetState } from './ReleaseObservedTargetState'; + +export function isReleaseLifecycleControlSatisfied( + control: ReleaseLifecycleControl, + observed: ReleaseObservedTargetState, +): boolean { + if (control.target !== observed.target) return false; + if (observed.target === 'android') return androidSatisfied(control, observed.rolloutStatus); + return iosSatisfied(control, observed); +} + +function androidSatisfied( + control: ReleaseLifecycleControl, + status: Extract['rolloutStatus'], +): boolean { + if (control.target !== 'android') return false; + return control.action === 'halt' ? status === 'halted' : status === 'inProgress'; +} + +function iosSatisfied( + control: ReleaseLifecycleControl, + observed: Extract, +): boolean { + if (control.target !== 'ios') return false; + if (control.action === 'pause-phased') return observed.phasedReleaseState === 'PAUSED'; + if (control.action === 'resume-phased') return observed.phasedReleaseState === 'ACTIVE'; + if (control.action === 'cancel-phased') return observed.phasedReleaseState === null; + return observed.reviewState === 'CANCELING'; +} diff --git a/src/domain/release/isReleaseStepResumable.ts b/src/domain/release/isReleaseStepResumable.ts new file mode 100644 index 0000000..64f517b --- /dev/null +++ b/src/domain/release/isReleaseStepResumable.ts @@ -0,0 +1,6 @@ +import type { ReleaseExecutionStep } from './ReleaseExecutionStep'; + +export function isReleaseStepResumable(step: ReleaseExecutionStep): boolean { + if (step.status === 'completed' || step.status === 'cancelled') return false; + return step.step.retry !== 'never'; +} diff --git a/src/domain/release/listReleaseLifecycleControls.ts b/src/domain/release/listReleaseLifecycleControls.ts new file mode 100644 index 0000000..b6348fe --- /dev/null +++ b/src/domain/release/listReleaseLifecycleControls.ts @@ -0,0 +1,37 @@ +import type { ReleaseLifecycleControl } from './ReleaseLifecycleControl'; +import type { ReleaseObservedTargetState } from './ReleaseObservedTargetState'; + +export function listReleaseLifecycleControls( + observed: ReleaseObservedTargetState, +): readonly ReleaseLifecycleControl[] { + if (observed.target === 'android') return androidControls(observed.rolloutStatus); + if (observed.target === 'ios') return iosControls(observed); + return []; +} + +function androidControls( + status: Extract['rolloutStatus'], +): readonly ReleaseLifecycleControl[] { + if (status === 'inProgress') return [{ target: 'android', action: 'halt' }]; + if (status === 'halted') return [{ target: 'android', action: 'resume' }]; + return []; +} + +function iosControls( + observed: Extract, +): readonly ReleaseLifecycleControl[] { + const controls: ReleaseLifecycleControl[] = []; + if (observed.phasedReleaseState === 'ACTIVE') { + controls.push({ target: 'ios', action: 'pause-phased' }); + } + if (observed.phasedReleaseState === 'PAUSED') { + controls.push({ target: 'ios', action: 'resume-phased' }); + } + if (observed.phasedReleaseState === 'INACTIVE') { + controls.push({ target: 'ios', action: 'cancel-phased' }); + } + if (observed.reviewState === 'IN_REVIEW') { + controls.push({ target: 'ios', action: 'cancel-review' }); + } + return controls; +} diff --git a/src/domain/release/normalizeReleaseFraction.ts b/src/domain/release/normalizeReleaseFraction.ts new file mode 100644 index 0000000..bd800d3 --- /dev/null +++ b/src/domain/release/normalizeReleaseFraction.ts @@ -0,0 +1,5 @@ +export function normalizeReleaseFraction(value: string | undefined): string | undefined { + if (value === undefined || !/^0\.\d+$/.test(value)) return value; + const digits = value.slice(2).replace(/0+$/, ''); + return digits.length === 0 ? '0' : `0.${digits}`; +} diff --git a/src/domain/release/releaseCurrentRevision.test.ts b/src/domain/release/releaseCurrentRevision.test.ts new file mode 100644 index 0000000..9d7f994 --- /dev/null +++ b/src/domain/release/releaseCurrentRevision.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from 'bun:test'; + +import { createReleaseCurrentRevision } from './createReleaseCurrentRevision'; +import type { ReleaseObservedState } from './ReleaseObservedState'; + +test('release current revision is deterministic and ignores unselected targets', () => { + const first: ReleaseObservedState = { + targets: [ + { + target: 'ios', + version: '2.1.0', + artifactRevision: 'ios-artifact', + buildNumber: '42', + releaseNotes: [{ locale: 'de-CH', text: 'Neu' }], + appVersionState: 'PENDING_DEVELOPER_RELEASE', + releaseType: 'MANUAL', + reviewState: 'COMPLETE', + phasedReleaseState: 'INACTIVE', + }, + { + target: 'android', + version: '2.1.0', + artifactRevision: 'android-artifact', + versionCodes: ['42'], + releaseNotes: [{ locale: 'de-CH', text: 'Neu' }], + rolloutStatus: 'inProgress', + userFraction: '0.1000', + }, + ], + }; + const android = first.targets.find((target) => target.target === 'android'); + const ios = first.targets.find((target) => target.target === 'ios'); + if (android?.target !== 'android' || ios?.target !== 'ios') { + throw new Error('Expected mobile release fixtures.'); + } + const second: ReleaseObservedState = { + targets: [ + { ...android, userFraction: '0.1' }, + ios, + { target: 'web', version: '9.9.9', artifactRevision: 'ignored' }, + ], + }; + expect(createReleaseCurrentRevision(first, ['android', 'ios'])).toBe( + createReleaseCurrentRevision(second, ['ios', 'android']), + ); +}); diff --git a/src/domain/release/releaseDomain.test.ts b/src/domain/release/releaseDomain.test.ts new file mode 100644 index 0000000..4b6b3ae --- /dev/null +++ b/src/domain/release/releaseDomain.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from 'bun:test'; + +import { createReleaseExecutionState } from './createReleaseExecutionState'; +import { createReleaseRevision } from './createReleaseRevision'; +import { isReleaseStepResumable } from './isReleaseStepResumable'; +import type { ReleasePlanStep } from './ReleasePlanStep'; + +test('release revision is deterministic across target and note ordering', () => { + const first = createReleaseRevision({ + version: '2.1.0', + targets: ['ios', 'android'], + notes: [ + { locale: 'de-CH', text: 'Neu' }, + { locale: 'en-US', text: 'New' }, + ], + rollout: { + android: { mode: 'staged', initialFraction: '0.1' }, + ios: { mode: 'staged' }, + }, + }); + const second = createReleaseRevision({ + version: '2.1.0', + targets: ['android', 'ios'], + notes: [ + { locale: 'en-US', text: 'New' }, + { locale: 'de-CH', text: 'Neu' }, + ], + rollout: { + android: { mode: 'staged', initialFraction: '0.1' }, + ios: { mode: 'staged' }, + }, + }); + expect(first).toBe(second); +}); + +test('release execution state exposes retry and irreversibility before execution', () => { + const steps: ReleasePlanStep[] = [ + { + id: 'ios:release', + target: 'ios', + operation: 'release', + dependsOn: ['ios:verify'], + retry: 'never', + irreversible: true, + }, + { + id: 'android:verify', + target: 'android', + operation: 'verify', + dependsOn: [], + retry: 'safe', + irreversible: false, + }, + ]; + const state = createReleaseExecutionState('revision', steps); + const [iosRelease, androidVerify] = state.steps; + if (iosRelease === undefined || androidVerify === undefined) { + throw new Error('Expected release execution fixtures.'); + } + expect(state.steps.map((step) => step.status)).toEqual(['pending', 'pending']); + expect(isReleaseStepResumable(iosRelease)).toBe(false); + expect(isReleaseStepResumable(androidVerify)).toBe(true); +}); diff --git a/src/domain/release/releaseLifecycleControls.test.ts b/src/domain/release/releaseLifecycleControls.test.ts new file mode 100644 index 0000000..c970466 --- /dev/null +++ b/src/domain/release/releaseLifecycleControls.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from 'bun:test'; + +import { listReleaseLifecycleControls } from './listReleaseLifecycleControls'; +import type { ReleaseObservedAndroidState } from './ReleaseObservedAndroidState'; +import type { ReleaseObservedIosState } from './ReleaseObservedIosState'; + +test('Android lifecycle controls expose only safe staged-rollout transitions', () => { + expect(listReleaseLifecycleControls(androidState('inProgress'))).toEqual([ + { target: 'android', action: 'halt' }, + ]); + expect(listReleaseLifecycleControls(androidState('halted'))).toEqual([ + { target: 'android', action: 'resume' }, + ]); + expect(listReleaseLifecycleControls(androidState('completed'))).toEqual([]); +}); + +test('iOS lifecycle controls preserve provider-specific semantics', () => { + expect(listReleaseLifecycleControls(iosState({ phasedReleaseState: 'ACTIVE' }))).toEqual([ + { target: 'ios', action: 'pause-phased' }, + ]); + expect(listReleaseLifecycleControls(iosState({ phasedReleaseState: 'PAUSED' }))).toEqual([ + { target: 'ios', action: 'resume-phased' }, + ]); + expect(listReleaseLifecycleControls(iosState({ phasedReleaseState: 'INACTIVE' }))).toEqual([ + { target: 'ios', action: 'cancel-phased' }, + ]); + expect(listReleaseLifecycleControls(iosState({ reviewState: 'IN_REVIEW' }))).toEqual([ + { target: 'ios', action: 'cancel-review' }, + ]); +}); + +function androidState( + rolloutStatus: ReleaseObservedAndroidState['rolloutStatus'], +): ReleaseObservedAndroidState { + return { + target: 'android', + version: '2.1.0', + artifactRevision: 'artifact', + versionCodes: ['42'], + releaseNotes: [], + rolloutStatus, + ...(rolloutStatus === 'inProgress' || rolloutStatus === 'halted' + ? { userFraction: '0.1' } + : {}), + }; +} + +function iosState(overrides: Partial): ReleaseObservedIosState { + return { + target: 'ios', + version: '2.1.0', + artifactRevision: 'artifact', + buildNumber: '42', + releaseNotes: [], + phasedReleaseState: null, + ...overrides, + }; +} diff --git a/src/domain/release/releasePlanAndroid.test.ts b/src/domain/release/releasePlanAndroid.test.ts new file mode 100644 index 0000000..9d37daf --- /dev/null +++ b/src/domain/release/releasePlanAndroid.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from 'bun:test'; + +import { createReleasePlan } from './createReleasePlan'; +import type { ReleaseDesiredState } from './ReleaseDesiredState'; +import type { ReleaseObservedAndroidState } from './ReleaseObservedAndroidState'; + +const desired: ReleaseDesiredState = { + version: '2.1.0', + targets: ['android'], + notes: [{ locale: 'de-CH', text: 'Neu' }], + rollout: { android: { mode: 'staged', initialFraction: '0.1' } }, + revision: 'desired-revision', +}; + +test('Android-only release emits no iOS or Web steps', () => { + const plan = createReleasePlan(desired, androidState('0.25')); + expect(plan.status).toBe('changes'); + expect(plan.steps.every((step) => step.target === 'android' || step.target === 'release')).toBe( + true, + ); +}); + +test('matching Android staged fraction requires no change', () => { + const plan = createReleasePlan(desired, androidState('0.1000')); + expect(plan.status).toBe('no-change'); + expect(plan.steps).toEqual([]); +}); + +test('Android staged fraction drift plans rollout without floating-point comparison', () => { + const plan = createReleasePlan(desired, androidState('0.25')); + expect(plan.steps.map((step) => step.id)).toEqual([ + 'android:rollout', + 'android:verify', + 'release:record', + ]); +}); + +test('halted Android rollout remains waiting instead of resuming implicitly', () => { + const current = androidState('0.1'); + const [android] = current.targets; + if (android?.target !== 'android') throw new Error('Expected Android fixture.'); + const plan = createReleasePlan(desired, { + targets: [{ ...android, rolloutStatus: 'halted' }], + }); + expect(plan.status).toBe('waiting'); + expect(plan.steps).toEqual([]); + expect(plan.diagnostics[0]?.code).toBe('ANDROID_ROLLOUT_HALTED'); +}); + +function androidState(userFraction: string) { + const android: ReleaseObservedAndroidState = { + target: 'android', + version: '2.1.0', + artifactRevision: 'android-artifact', + versionCodes: ['42'], + releaseNotes: [{ locale: 'de-CH', text: 'Neu' }], + rolloutStatus: 'inProgress', + userFraction, + }; + return { targets: [android] }; +} diff --git a/src/domain/release/releasePlanCrossTarget.test.ts b/src/domain/release/releasePlanCrossTarget.test.ts new file mode 100644 index 0000000..5962ccd --- /dev/null +++ b/src/domain/release/releasePlanCrossTarget.test.ts @@ -0,0 +1,113 @@ +import { expect, test } from 'bun:test'; + +import { createReleasePlan } from './createReleasePlan'; +import type { ReleaseDesiredState } from './ReleaseDesiredState'; +import type { ReleaseObservedState } from './ReleaseObservedState'; + +const desired: ReleaseDesiredState = { + version: '2.1.0', + targets: ['web', 'android', 'ios'], + notes: [{ locale: 'en-US', text: 'New' }], + rollout: { + web: { mode: 'immediate' }, + android: { mode: 'immediate' }, + ios: { mode: 'immediate' }, + }, + revision: 'desired-revision', +}; + +test('fully completed multi-target release is no-change', () => { + const plan = createReleasePlan(desired, completedState()); + expect(plan.status).toBe('no-change'); + expect(plan.steps).toEqual([]); +}); + +test('completed Android is not rewritten while iOS still needs release', () => { + const current = completedState(); + const ios = current.targets.find((target) => target.target === 'ios'); + if (ios?.target !== 'ios') throw new Error('Expected iOS fixture.'); + const plan = createReleasePlan(desired, { + targets: [ + ...current.targets.filter((target) => target.target !== 'ios'), + { ...ios, appVersionState: 'PENDING_DEVELOPER_RELEASE' }, + ], + }); + expect(plan.steps.some((step) => step.target === 'android')).toBe(false); + expect(plan.steps.some((step) => step.id === 'ios:release')).toBe(true); +}); + +test('final record depends on every changed target verification step', () => { + const plan = createReleasePlan(desired, { + targets: [ + { target: 'web', version: null, artifactRevision: null }, + { + target: 'android', + version: null, + artifactRevision: null, + versionCodes: [], + releaseNotes: [], + rolloutStatus: 'missing', + }, + { + target: 'ios', + version: '2.1.0', + artifactRevision: 'ios-artifact', + buildNumber: '42', + releaseNotes: [{ locale: 'en-US', text: 'New' }], + appVersionState: 'PENDING_DEVELOPER_RELEASE', + releaseType: 'MANUAL', + reviewState: 'COMPLETE', + phasedReleaseState: null, + }, + ], + }); + const record = plan.steps.find((step) => step.id === 'release:record'); + expect(record?.dependsOn).toEqual(['android:verify', 'ios:verify', 'web:verify']); +}); + +test('independent Android changes can proceed while iOS waits for review', () => { + const current = completedState(); + const android = current.targets.find((target) => target.target === 'android'); + const ios = current.targets.find((target) => target.target === 'ios'); + const web = current.targets.find((target) => target.target === 'web'); + if (android?.target !== 'android' || ios?.target !== 'ios' || web?.target !== 'web') { + throw new Error('Expected release fixtures.'); + } + const plan = createReleasePlan(desired, { + targets: [ + web, + { ...android, rolloutStatus: 'draft' }, + { ...ios, appVersionState: 'WAITING_FOR_REVIEW', reviewState: 'WAITING_FOR_REVIEW' }, + ], + }); + expect(plan.status).toBe('changes'); + expect(plan.steps.some((step) => step.target === 'android')).toBe(true); + expect(plan.steps.some((step) => step.id === 'release:record')).toBe(false); +}); + +function completedState(): ReleaseObservedState { + return { + targets: [ + { target: 'web', version: '2.1.0', artifactRevision: 'web-artifact' }, + { + target: 'android', + version: '2.1.0', + artifactRevision: 'android-artifact', + versionCodes: ['42'], + releaseNotes: [{ locale: 'en-US', text: 'New' }], + rolloutStatus: 'completed', + }, + { + target: 'ios', + version: '2.1.0', + artifactRevision: 'ios-artifact', + buildNumber: '42', + releaseNotes: [{ locale: 'en-US', text: 'New' }], + appVersionState: 'READY_FOR_DISTRIBUTION', + releaseType: 'MANUAL', + reviewState: 'COMPLETE', + phasedReleaseState: null, + }, + ], + }; +} diff --git a/src/domain/release/releasePlanIos.test.ts b/src/domain/release/releasePlanIos.test.ts new file mode 100644 index 0000000..b380e5b --- /dev/null +++ b/src/domain/release/releasePlanIos.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from 'bun:test'; + +import { createReleasePlan } from './createReleasePlan'; +import type { ReleaseDesiredState } from './ReleaseDesiredState'; +import type { ReleaseObservedIosState } from './ReleaseObservedIosState'; + +const immediate: ReleaseDesiredState = { + version: '2.1.0', + targets: ['ios'], + notes: [{ locale: 'en-US', text: 'New' }], + rollout: { ios: { mode: 'immediate' } }, + revision: 'desired-immediate', +}; + +test('iOS waiting for review does not duplicate submission or release', () => { + const plan = createReleasePlan(immediate, { + targets: [ + iosState({ appVersionState: 'WAITING_FOR_REVIEW', reviewState: 'WAITING_FOR_REVIEW' }), + ], + }); + expect(plan.status).toBe('waiting'); + expect(plan.steps).toEqual([]); + expect(plan.diagnostics[0]?.code).toBe('IOS_REVIEW_PENDING'); +}); + +test('manual iOS release request is explicit and irreversible', () => { + const plan = createReleasePlan(immediate, { + targets: [iosState({ appVersionState: 'PENDING_DEVELOPER_RELEASE', reviewState: 'COMPLETE' })], + }); + const release = plan.steps.find((step) => step.id === 'ios:release'); + expect(release?.irreversible).toBe(true); + expect(release?.retry).toBe('never'); +}); + +test('staged iOS config is planned before review and never invents a percentage', () => { + const staged: ReleaseDesiredState = { + ...immediate, + rollout: { ios: { mode: 'staged' } }, + revision: 'desired-staged', + }; + const plan = createReleasePlan(staged, { + targets: [ + iosState({ + artifactRevision: null, + buildNumber: null, + appVersionState: 'PREPARE_FOR_SUBMISSION', + phasedReleaseState: null, + }), + ], + }); + expect(plan.steps.map((step) => step.id)).toEqual([ + 'ios:publish', + 'ios:rollout', + 'ios:submit-review', + ]); + expect(plan.status).toBe('changes'); + expect(JSON.stringify(plan)).not.toContain('initialFraction'); +}); + +test('paused App Store phased release remains waiting', () => { + const staged: ReleaseDesiredState = { + ...immediate, + rollout: { ios: { mode: 'staged' } }, + revision: 'desired-staged', + }; + const plan = createReleasePlan(staged, { + targets: [ + iosState({ + appVersionState: 'READY_FOR_DISTRIBUTION', + reviewState: 'COMPLETE', + phasedReleaseState: 'PAUSED', + }), + ], + }); + expect(plan.status).toBe('waiting'); + expect(plan.steps).toEqual([]); + expect(plan.diagnostics[0]?.code).toBe('IOS_PHASED_RELEASE_PAUSED'); +}); + +function iosState(overrides: Partial = {}): ReleaseObservedIosState { + return { + target: 'ios', + version: '2.1.0', + artifactRevision: 'ios-artifact', + buildNumber: '42', + releaseNotes: [{ locale: 'en-US', text: 'New' }], + appVersionState: 'PREPARE_FOR_SUBMISSION', + releaseType: 'MANUAL', + phasedReleaseState: null, + ...overrides, + }; +} diff --git a/src/domain/release/releasePlanNotes.test.ts b/src/domain/release/releasePlanNotes.test.ts new file mode 100644 index 0000000..aa5f9ae --- /dev/null +++ b/src/domain/release/releasePlanNotes.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from 'bun:test'; + +import { createReleasePlan } from './createReleasePlan'; +import type { ReleaseDesiredState } from './ReleaseDesiredState'; + +test('provider-only release-note locales do not cause destructive drift', () => { + const desired: ReleaseDesiredState = { + version: '2.1.0', + targets: ['android'], + notes: [{ locale: 'en-US', text: 'New' }], + rollout: { android: { mode: 'immediate' } }, + revision: 'desired', + }; + const plan = createReleasePlan(desired, { + targets: [ + { + target: 'android', + version: '2.1.0', + artifactRevision: 'artifact', + versionCodes: ['42'], + releaseNotes: [ + { locale: 'de-CH', text: 'Provider-only note' }, + { locale: 'en-US', text: 'New' }, + ], + rolloutStatus: 'completed', + }, + ], + }); + expect(plan.status).toBe('no-change'); + expect(plan.steps).toEqual([]); +}); diff --git a/src/engine/release/ReleaseMutationResult.ts b/src/engine/release/ReleaseMutationResult.ts new file mode 100644 index 0000000..0411689 --- /dev/null +++ b/src/engine/release/ReleaseMutationResult.ts @@ -0,0 +1,4 @@ +export type ReleaseMutationResult = + | { readonly status: 'completed' } + | { readonly status: 'blocked'; readonly code: string } + | { readonly status: 'failed'; readonly code: string }; diff --git a/src/engine/release/executeReleaseLifecycleControl.test.ts b/src/engine/release/executeReleaseLifecycleControl.test.ts new file mode 100644 index 0000000..bcbfc09 --- /dev/null +++ b/src/engine/release/executeReleaseLifecycleControl.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from 'bun:test'; + +import type { ReleaseObservedAndroidState } from '../../domain/release/ReleaseObservedAndroidState'; +import { executeReleaseLifecycleControl } from './executeReleaseLifecycleControl'; + +test('release control performs one write and verifies fresh read-back', async () => { + let current = androidState('inProgress'); + let mutations = 0; + const result = await executeReleaseLifecycleControl({ + control: { target: 'android', action: 'halt' }, + inspect: () => Promise.resolve(current), + mutate: () => { + mutations += 1; + current = androidState('halted'); + return Promise.resolve({ status: 'completed' }); + }, + }); + expect(result).toEqual({ status: 'completed', mutationAttempted: true }); + expect(mutations).toBe(1); +}); + +test('release control makes no write when fresh state already satisfies the request', async () => { + let mutations = 0; + const result = await executeReleaseLifecycleControl({ + control: { target: 'android', action: 'halt' }, + inspect: () => Promise.resolve(androidState('halted')), + mutate: () => { + mutations += 1; + return Promise.resolve({ status: 'completed' }); + }, + }); + expect(result).toEqual({ status: 'completed', mutationAttempted: false }); + expect(mutations).toBe(0); +}); + +test('release control fails when mutation is not visible on read-back', async () => { + const result = await executeReleaseLifecycleControl({ + control: { target: 'android', action: 'halt' }, + inspect: () => Promise.resolve(androidState('inProgress')), + mutate: () => Promise.resolve({ status: 'completed' }), + }); + expect(result).toEqual({ + status: 'failed', + mutationAttempted: true, + code: 'RELEASE_CONTROL_READBACK_VERIFICATION_FAILED', + }); +}); + +function androidState( + rolloutStatus: ReleaseObservedAndroidState['rolloutStatus'], +): ReleaseObservedAndroidState { + return { + target: 'android', + version: '2.1.0', + artifactRevision: 'artifact', + versionCodes: ['42'], + releaseNotes: [], + rolloutStatus, + userFraction: '0.1', + }; +} diff --git a/src/engine/release/executeReleaseLifecycleControl.ts b/src/engine/release/executeReleaseLifecycleControl.ts new file mode 100644 index 0000000..f001c0f --- /dev/null +++ b/src/engine/release/executeReleaseLifecycleControl.ts @@ -0,0 +1,65 @@ +import { isReleaseLifecycleControlReadbackSatisfied } from '../../domain/release/isReleaseLifecycleControlReadbackSatisfied'; +import { isReleaseLifecycleControlSatisfied } from '../../domain/release/isReleaseLifecycleControlSatisfied'; +import { listReleaseLifecycleControls } from '../../domain/release/listReleaseLifecycleControls'; +import type { ReleaseControlExecutionResult } from '../../domain/release/ReleaseControlExecutionResult'; +import type { ReleaseLifecycleControl } from '../../domain/release/ReleaseLifecycleControl'; +import type { ReleaseObservedTargetState } from '../../domain/release/ReleaseObservedTargetState'; +import type { ReleaseMutationResult } from './ReleaseMutationResult'; + +type InspectTarget = () => Promise; +type MutateControl = ( + control: ReleaseLifecycleControl, + current: ReleaseObservedTargetState, +) => Promise; + +type FailedReleaseMutation = Exclude; + +export async function executeReleaseLifecycleControl(options: { + readonly control: ReleaseLifecycleControl; + readonly inspect: InspectTarget; + readonly mutate: MutateControl; +}): Promise { + const before = await options.inspect(); + if (before === null) return failed(false, 'RELEASE_CONTROL_INSPECTION_FAILED'); + if (before.target !== options.control.target) return blocked('RELEASE_CONTROL_TARGET_MISMATCH'); + if (isReleaseLifecycleControlSatisfied(options.control, before)) { + return { status: 'completed', mutationAttempted: false }; + } + if (!isAvailable(options.control, before)) return blocked('RELEASE_CONTROL_UNAVAILABLE'); + const mutation = await options.mutate(options.control, before); + if (mutation.status !== 'completed') return mutationFailure(mutation); + return verifyReadback(options); +} + +async function verifyReadback( + options: Parameters[0], +): Promise { + const after = await options.inspect(); + if (after === null) return failed(true, 'RELEASE_CONTROL_READBACK_FAILED'); + if (!isReleaseLifecycleControlReadbackSatisfied(options.control, after)) { + return failed(true, 'RELEASE_CONTROL_READBACK_VERIFICATION_FAILED'); + } + return { status: 'completed', mutationAttempted: true }; +} + +function isAvailable( + control: ReleaseLifecycleControl, + observed: ReleaseObservedTargetState, +): boolean { + return listReleaseLifecycleControls(observed).some( + (candidate) => candidate.target === control.target && candidate.action === control.action, + ); +} + +function mutationFailure(mutation: FailedReleaseMutation): ReleaseControlExecutionResult { + if (mutation.status === 'blocked') return blocked(mutation.code); + return failed(true, mutation.code); +} + +function blocked(code: string): ReleaseControlExecutionResult { + return { status: 'blocked', mutationAttempted: false, code }; +} + +function failed(mutationAttempted: boolean, code: string): ReleaseControlExecutionResult { + return { status: 'failed', mutationAttempted, code }; +} diff --git a/src/engine/release/executeReleasePlan.test.ts b/src/engine/release/executeReleasePlan.test.ts new file mode 100644 index 0000000..130a13e --- /dev/null +++ b/src/engine/release/executeReleasePlan.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from 'bun:test'; + +import { createReleasePlan } from '../../domain/release/createReleasePlan'; +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import type { ReleaseObservedState } from '../../domain/release/ReleaseObservedState'; +import { executeReleasePlan } from './executeReleasePlan'; + +const desired: ReleaseDesiredState = { + version: '2.1.0', + targets: ['android'], + notes: [{ locale: 'en-US', text: 'New' }], + rollout: { android: { mode: 'staged', initialFraction: '0.1' } }, + revision: 'desired', +}; + +test('release execution refuses stale preflight state before mutation', async () => { + const initial = androidState('0.25'); + const stale = androidState('0.5'); + let mutations = 0; + const result = await executeReleasePlan({ + desired, + plan: createReleasePlan(desired, initial), + inspect: () => Promise.resolve(stale), + mutate: () => { + mutations += 1; + return Promise.resolve({ status: 'completed' }); + }, + }); + expect(result.status).toBe('drifted'); + expect(result.code).toBe('RELEASE_STATE_DRIFTED'); + expect(mutations).toBe(0); +}); + +test('release execution verifies read-back before completing', async () => { + let current = androidState('0.25'); + const result = await executeReleasePlan({ + desired, + plan: createReleasePlan(desired, current), + inspect: () => Promise.resolve(current), + mutate: () => { + current = androidState('0.1'); + return Promise.resolve({ status: 'completed' }); + }, + }); + expect(result.status).toBe('completed'); + expect(result.executedStepIds).toEqual(['android:rollout']); +}); + +test('release execution fails when provider mutation is not visible on read-back', async () => { + const current = androidState('0.25'); + const result = await executeReleasePlan({ + desired, + plan: createReleasePlan(desired, current), + inspect: () => Promise.resolve(current), + mutate: () => Promise.resolve({ status: 'completed' }), + }); + expect(result.status).toBe('failed'); + expect(result.code).toBe('RELEASE_READBACK_VERIFICATION_FAILED'); +}); + +function androidState(userFraction: string): ReleaseObservedState { + return { + targets: [ + { + target: 'android', + version: '2.1.0', + artifactRevision: 'artifact', + versionCodes: ['42'], + releaseNotes: [{ locale: 'en-US', text: 'New' }], + rolloutStatus: 'inProgress', + userFraction, + }, + ], + }; +} diff --git a/src/engine/release/executeReleasePlan.ts b/src/engine/release/executeReleasePlan.ts new file mode 100644 index 0000000..86980d6 --- /dev/null +++ b/src/engine/release/executeReleasePlan.ts @@ -0,0 +1,153 @@ +import { createReleaseCurrentRevision } from '../../domain/release/createReleaseCurrentRevision'; +import { createReleasePlan } from '../../domain/release/createReleasePlan'; +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import type { ReleaseObservedState } from '../../domain/release/ReleaseObservedState'; +import type { ReleasePlan } from '../../domain/release/ReleasePlan'; +import type { ReleasePlanStep } from '../../domain/release/ReleasePlanStep'; +import type { ReleaseReconcileResult } from '../../domain/release/ReleaseReconcileResult'; +import type { ReleaseMutationResult } from './ReleaseMutationResult'; + +const MAX_MUTATIONS = 32; + +type InspectRelease = () => Promise; +type MutateRelease = ( + step: ReleasePlanStep, + current: ReleaseObservedState, +) => Promise; + +export async function executeReleasePlan(options: { + readonly desired: ReleaseDesiredState; + readonly plan: ReleasePlan; + readonly inspect: InspectRelease; + readonly mutate: MutateRelease; +}): Promise { + let expectedRevision = options.plan.currentRevision; + const executed: string[] = []; + for (let index = 0; index < MAX_MUTATIONS; index += 1) { + const current = await options.inspect(); + if (current === null) { + return failed(options.plan, expectedRevision, executed, 'RELEASE_INSPECTION_FAILED'); + } + const revision = createReleaseCurrentRevision(current, options.desired.targets); + if (revision !== expectedRevision) return drifted(options, revision, executed, current); + const plan = createReleasePlan(options.desired, current); + const terminal = terminalResult(plan, revision, executed); + if (terminal !== null) return terminal; + const step = firstMutationStep(plan.steps); + if (step === null) return failed(plan, revision, executed, 'RELEASE_EXECUTABLE_STEP_REQUIRED'); + const mutation = await options.mutate(step, current); + const mutationFailure = mutationResult(plan, revision, executed, step, mutation); + if (mutationFailure !== null) return mutationFailure; + const readback = await verifyReadback(options, step, revision, executed); + if (readback.result !== null) return readback.result; + expectedRevision = readback.revision; + executed.push(step.id); + } + const current = await options.inspect(); + const plan = current === null ? options.plan : createReleasePlan(options.desired, current); + return failed(plan, expectedRevision, executed, 'RELEASE_MUTATION_LIMIT_EXCEEDED'); +} + +function terminalResult( + plan: ReleasePlan, + revision: string, + executed: readonly string[], +): ReleaseReconcileResult | null { + if (plan.status === 'no-change') return result('completed', plan, revision, executed); + if (plan.status === 'waiting') return result('waiting', plan, revision, executed); + if (plan.status === 'blocked') return result('blocked', plan, revision, executed); + return null; +} + +function firstMutationStep(steps: readonly ReleasePlanStep[]): ReleasePlanStep | null { + return steps.find((step) => step.operation !== 'verify' && step.operation !== 'record') ?? null; +} + +function mutationResult( + plan: ReleasePlan, + revision: string, + executed: readonly string[], + step: ReleasePlanStep, + mutation: ReleaseMutationResult, +): ReleaseReconcileResult | null { + if (mutation.status === 'completed') return null; + return { + status: mutation.status, + plan, + currentRevision: revision, + executedStepIds: executed, + ...(mutation.status === 'failed' ? { attemptedStepId: step.id } : {}), + code: mutation.code, + }; +} + +async function verifyReadback( + options: Parameters[0], + step: ReleasePlanStep, + previousRevision: string, + executed: readonly string[], +): Promise<{ readonly revision: string; readonly result: ReleaseReconcileResult | null }> { + const current = await options.inspect(); + if (current === null) { + return { + revision: previousRevision, + result: failed(options.plan, previousRevision, executed, 'RELEASE_READBACK_FAILED'), + }; + } + const revision = createReleaseCurrentRevision(current, options.desired.targets); + const plan = createReleasePlan(options.desired, current); + if (containsMutationStep(plan, step.id)) { + return { + revision, + result: failed(plan, revision, executed, 'RELEASE_READBACK_VERIFICATION_FAILED', step.id), + }; + } + return { revision, result: null }; +} + +function containsMutationStep(plan: ReleasePlan, stepId: string): boolean { + return plan.steps.some( + (step) => step.id === stepId && step.operation !== 'verify' && step.operation !== 'record', + ); +} + +function drifted( + options: Parameters[0], + revision: string, + executed: readonly string[], + current: ReleaseObservedState, +): ReleaseReconcileResult { + return { + status: 'drifted', + plan: createReleasePlan(options.desired, current), + currentRevision: revision, + executedStepIds: executed, + code: 'RELEASE_STATE_DRIFTED', + }; +} + +function failed( + plan: ReleasePlan, + revision: string, + executed: readonly string[], + code: string, + attemptedStepId?: string, +): ReleaseReconcileResult { + return { + status: 'failed', + plan, + currentRevision: revision, + executedStepIds: executed, + ...(attemptedStepId === undefined ? {} : { attemptedStepId }), + code, + }; +} + +function result( + status: 'completed' | 'waiting' | 'blocked', + plan: ReleasePlan, + revision: string, + executed: readonly string[], +): ReleaseReconcileResult { + return { status, plan, currentRevision: revision, executedStepIds: executed }; +} diff --git a/src/engine/release/resumeReleaseExecution.test.ts b/src/engine/release/resumeReleaseExecution.test.ts new file mode 100644 index 0000000..5828f58 --- /dev/null +++ b/src/engine/release/resumeReleaseExecution.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from 'bun:test'; + +import { createReleasePlan } from '../../domain/release/createReleasePlan'; +import { createReleaseRevision } from '../../domain/release/createReleaseRevision'; +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import type { ReleaseObservedState } from '../../domain/release/ReleaseObservedState'; +import { createProjectReleaseHistoryRecord } from '../../project/releaseHistory/createProjectReleaseHistoryRecord'; +import { resumeReleaseExecution } from './resumeReleaseExecution'; + +test('resume starts from fresh inspection and blocks unresolved never-retry step', async () => { + const desired = desiredState(); + const current = iosPendingRelease(); + const initialPlan = createReleasePlan(desired, current); + const previous = createProjectReleaseHistoryRecord({ + executionId: 'attempt-1', + recordedAt: '2026-08-14T00:00:00.000Z', + desired, + initialPlan, + result: { + status: 'failed', + plan: initialPlan, + currentRevision: initialPlan.currentRevision, + executedStepIds: [], + attemptedStepId: 'ios:release', + code: 'APP_STORE_RELEASE_REQUEST_FAILED', + }, + }); + let mutations = 0; + const result = await resumeReleaseExecution({ + desired, + previous, + inspect: () => Promise.resolve(current), + mutate: () => { + mutations += 1; + return Promise.resolve({ status: 'completed' }); + }, + }); + expect(result.status).toBe('blocked'); + expect(result.code).toBe('RELEASE_STEP_NON_RESUMABLE'); + expect(mutations).toBe(0); +}); + +test('resume proceeds when fresh inspection proves irreversible step completed', async () => { + const desired = desiredState(); + const before = iosPendingRelease(); + const initialPlan = createReleasePlan(desired, before); + const previous = createProjectReleaseHistoryRecord({ + executionId: 'attempt-1', + recordedAt: '2026-08-14T00:00:00.000Z', + desired, + initialPlan, + result: { + status: 'failed', + plan: initialPlan, + currentRevision: initialPlan.currentRevision, + executedStepIds: [], + attemptedStepId: 'ios:release', + code: 'RELEASE_READBACK_VERIFICATION_FAILED', + }, + }); + const after = iosReleased(); + const result = await resumeReleaseExecution({ + desired, + previous, + inspect: () => Promise.resolve(after), + mutate: () => Promise.resolve({ status: 'completed' }), + }); + expect(result.status).toBe('completed'); + expect(result.executedStepIds).toEqual([]); +}); + +function desiredState(): ReleaseDesiredState { + const base = { + version: '2.1.0', + targets: ['ios'] as const, + notes: [{ locale: 'en-US', text: 'New' }], + rollout: { ios: { mode: 'immediate' as const } }, + }; + return { ...base, revision: createReleaseRevision(base) }; +} + +function iosPendingRelease(): ReleaseObservedState { + return { + targets: [ + { + target: 'ios', + version: '2.1.0', + artifactRevision: 'ios-artifact', + buildNumber: '42', + releaseNotes: [{ locale: 'en-US', text: 'New' }], + appVersionState: 'PENDING_DEVELOPER_RELEASE', + releaseType: 'MANUAL', + reviewState: 'COMPLETE', + phasedReleaseState: null, + }, + ], + }; +} + +function iosReleased(): ReleaseObservedState { + const [ios] = iosPendingRelease().targets; + if (ios?.target !== 'ios') throw new Error('Expected iOS fixture.'); + return { targets: [{ ...ios, appVersionState: 'READY_FOR_DISTRIBUTION' }] }; +} diff --git a/src/engine/release/resumeReleaseExecution.ts b/src/engine/release/resumeReleaseExecution.ts new file mode 100644 index 0000000..ff5e0d2 --- /dev/null +++ b/src/engine/release/resumeReleaseExecution.ts @@ -0,0 +1,68 @@ +import { createReleasePlan } from '../../domain/release/createReleasePlan'; +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import type { ReleaseObservedState } from '../../domain/release/ReleaseObservedState'; +import type { ReleasePlanStep } from '../../domain/release/ReleasePlanStep'; +import type { ReleaseReconcileResult } from '../../domain/release/ReleaseReconcileResult'; +import type { ProjectReleaseHistoryRecord } from '../../project/releaseHistory/ProjectReleaseHistoryRecord'; +import { executeReleasePlan } from './executeReleasePlan'; +import type { ReleaseMutationResult } from './ReleaseMutationResult'; + +type InspectRelease = () => Promise; +type MutateRelease = ( + step: ReleasePlanStep, + current: ReleaseObservedState, +) => Promise; + +export async function resumeReleaseExecution(options: { + readonly desired: ReleaseDesiredState; + readonly previous: ProjectReleaseHistoryRecord; + readonly inspect: InspectRelease; + readonly mutate: MutateRelease; +}): Promise { + const current = await options.inspect(); + if (current === null) return inspectionFailed(options.previous); + const plan = createReleasePlan(options.desired, current); + if (options.previous.desired.revision !== options.desired.revision) { + return blocked(plan, 'RELEASE_RESUME_REVISION_MISMATCH'); + } + if (hasUnsafeNeverRetry(options.previous, plan.steps)) { + return blocked(plan, 'RELEASE_STEP_NON_RESUMABLE'); + } + return executeReleasePlan({ + desired: options.desired, + plan, + inspect: options.inspect, + mutate: options.mutate, + }); +} + +function hasUnsafeNeverRetry( + previous: ProjectReleaseHistoryRecord, + currentSteps: readonly ReleasePlanStep[], +): boolean { + const attempted = previous.result.attemptedStepId; + if (attempted === undefined) return false; + const prior = previous.result.plan.steps.find((step) => step.id === attempted); + if (prior?.retry !== 'never') return false; + return currentSteps.some((step) => step.id === attempted); +} + +function blocked(plan: ReturnType, code: string): ReleaseReconcileResult { + return { + status: 'blocked', + plan, + currentRevision: plan.currentRevision, + executedStepIds: [], + code, + }; +} + +function inspectionFailed(previous: ProjectReleaseHistoryRecord): ReleaseReconcileResult { + return { + status: 'failed', + plan: previous.result.plan, + currentRevision: previous.result.currentRevision, + executedStepIds: [], + code: 'RELEASE_INSPECTION_FAILED', + }; +} diff --git a/src/index.test.ts b/src/index.test.ts index 4e183b3..7ceb96c 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -11,7 +11,16 @@ test('package entrypoint exposes the canonical deployment domain', () => { 'DEPLOYMENT_STEP_OPERATIONS', 'createDeploymentChanges', 'createDeploymentPlan', + 'createReleaseCurrentRevision', + 'createReleaseExecutionState', + 'createReleasePlan', + 'createReleaseRevision', 'executeDeploymentPlan', + 'executeReleaseLifecycleControl', + 'executeReleasePlan', 'inspectDeploymentProviderSetup', + 'isReleaseStepResumable', + 'listReleaseLifecycleControls', + 'resumeReleaseExecution', ]); }); diff --git a/src/index.ts b/src/index.ts index 6fed730..c7a4ed3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,9 +44,41 @@ export type { MonetizationSubscriptionPeriod, } from './domain/monetization/MonetizationSubscription'; export type { MonetizationTargetState } from './domain/monetization/MonetizationTargetState'; +export { createReleaseCurrentRevision } from './domain/release/createReleaseCurrentRevision'; +export { createReleaseExecutionState } from './domain/release/createReleaseExecutionState'; +export { createReleasePlan } from './domain/release/createReleasePlan'; +export { createReleaseRevision } from './domain/release/createReleaseRevision'; +export { isReleaseStepResumable } from './domain/release/isReleaseStepResumable'; +export { listReleaseLifecycleControls } from './domain/release/listReleaseLifecycleControls'; +export type { ReleaseControlExecutionResult } from './domain/release/ReleaseControlExecutionResult'; +export type { ReleaseDesiredState } from './domain/release/ReleaseDesiredState'; +export type { ReleaseDiagnostic } from './domain/release/ReleaseDiagnostic'; +export type { ReleaseExecutionState } from './domain/release/ReleaseExecutionState'; +export type { ReleaseExecutionStep } from './domain/release/ReleaseExecutionStep'; +export type { ReleaseLifecycleControl } from './domain/release/ReleaseLifecycleControl'; +export type { ReleaseNote } from './domain/release/ReleaseNote'; +export type { ReleaseObservedAndroidState } from './domain/release/ReleaseObservedAndroidState'; +export type { ReleaseObservedIosState } from './domain/release/ReleaseObservedIosState'; +export type { ReleaseObservedState } from './domain/release/ReleaseObservedState'; +export type { ReleaseObservedTargetState } from './domain/release/ReleaseObservedTargetState'; +export type { ReleaseObservedWebState } from './domain/release/ReleaseObservedWebState'; +export type { ReleasePlan } from './domain/release/ReleasePlan'; +export type { ReleasePlanStatus } from './domain/release/ReleasePlanStatus'; +export type { ReleasePlanStep } from './domain/release/ReleasePlanStep'; +export type { ReleaseReconcileResult } from './domain/release/ReleaseReconcileResult'; +export type { ReleaseRollout } from './domain/release/ReleaseRollout'; +export type { ReleaseRolloutMode } from './domain/release/ReleaseRolloutMode'; +export type { ReleaseStepOperation } from './domain/release/ReleaseStepOperation'; +export type { ReleaseStepRetry } from './domain/release/ReleaseStepRetry'; +export type { ReleaseTarget } from './domain/release/ReleaseTarget'; +export type { ReleaseTargetRollout } from './domain/release/ReleaseTargetRollout'; export * from './domain/WebDeploymentPublication'; export * from './domain/WebDeploymentPublishIntent'; export * from './engine/createDeploymentChanges'; export * from './engine/createDeploymentPlan'; export * from './engine/executeDeploymentPlan'; export * from './engine/inspectDeploymentProviderSetup'; +export { executeReleaseLifecycleControl } from './engine/release/executeReleaseLifecycleControl'; +export { executeReleasePlan } from './engine/release/executeReleasePlan'; +export type { ReleaseMutationResult } from './engine/release/ReleaseMutationResult'; +export { resumeReleaseExecution } from './engine/release/resumeReleaseExecution'; diff --git a/src/project/index.ts b/src/project/index.ts index 799623f..ae55827 100644 --- a/src/project/index.ts +++ b/src/project/index.ts @@ -48,6 +48,29 @@ export type { ProjectMonetizationTargets } from './monetization/ProjectMonetizat export { readProjectMonetization } from './monetization/readProjectMonetization'; export type { ProjectDeploymentPaths } from './ProjectDeploymentPaths'; export { readProjectDeploymentConfig } from './readProjectDeploymentConfig'; +export { createProjectReleasePlan } from './release/createProjectReleasePlan'; +export { executeProjectRelease } from './release/executeProjectRelease'; +export { executeProjectReleaseControl } from './release/executeProjectReleaseControl'; +export type { ExecuteProjectReleaseControlOptions } from './release/ExecuteProjectReleaseControlOptions'; +export type { ExecuteProjectReleaseOptions } from './release/ExecuteProjectReleaseOptions'; +export { inspectProjectRelease } from './release/inspectProjectRelease'; +export type { InspectProjectReleaseOptions } from './release/InspectProjectReleaseOptions'; +export type { ProjectReleaseAccess } from './release/ProjectReleaseAccess'; +export type { ProjectReleaseAndroidContext } from './release/ProjectReleaseAndroidContext'; +export type { ProjectReleaseExecution } from './release/ProjectReleaseExecution'; +export type { ProjectReleaseExecutionResult } from './release/ProjectReleaseExecutionResult'; +export type { ProjectReleaseInspection } from './release/ProjectReleaseInspection'; +export type { ProjectReleaseInspectionResult } from './release/ProjectReleaseInspectionResult'; +export type { ProjectReleaseIosContext } from './release/ProjectReleaseIosContext'; +export type { ProjectReleaseWebContext } from './release/ProjectReleaseWebContext'; +export { readProjectRelease } from './release/readProjectRelease'; +export { resumeProjectRelease } from './release/resumeProjectRelease'; +export type { ResumeProjectReleaseOptions } from './release/ResumeProjectReleaseOptions'; +export { createProjectReleaseHistoryRecord } from './releaseHistory/createProjectReleaseHistoryRecord'; +export { listProjectReleaseHistory } from './releaseHistory/listProjectReleaseHistory'; +export type { ProjectReleaseHistoryRecord } from './releaseHistory/ProjectReleaseHistoryRecord'; +export { readProjectReleaseHistory } from './releaseHistory/readProjectReleaseHistory'; +export { recordProjectReleaseHistory } from './releaseHistory/recordProjectReleaseHistory'; export type { ResolvedDeployProject } from './ResolvedDeployProject'; export { resolveDeployProject } from './resolveDeployProject'; export { resolveProjectDeploymentPaths } from './resolveProjectDeploymentPaths'; diff --git a/src/project/release/ExecuteProjectReleaseControlOptions.ts b/src/project/release/ExecuteProjectReleaseControlOptions.ts new file mode 100644 index 0000000..dbceb3f --- /dev/null +++ b/src/project/release/ExecuteProjectReleaseControlOptions.ts @@ -0,0 +1,7 @@ +import type { ReleaseLifecycleControl } from '../../domain/release/ReleaseLifecycleControl'; +import type { ProjectReleaseAccess } from './ProjectReleaseAccess'; + +export interface ExecuteProjectReleaseControlOptions extends ProjectReleaseAccess { + readonly projectRoot: string; + readonly control: ReleaseLifecycleControl; +} diff --git a/src/project/release/ExecuteProjectReleaseOptions.ts b/src/project/release/ExecuteProjectReleaseOptions.ts new file mode 100644 index 0000000..c34963a --- /dev/null +++ b/src/project/release/ExecuteProjectReleaseOptions.ts @@ -0,0 +1,9 @@ +import type { ReleasePlan } from '../../domain/release/ReleasePlan'; +import type { ProjectReleaseAccess } from './ProjectReleaseAccess'; +import type { ProjectReleaseInspection } from './ProjectReleaseInspection'; + +export interface ExecuteProjectReleaseOptions extends ProjectReleaseAccess { + readonly inspection: ProjectReleaseInspection; + readonly plan: ReleasePlan; + readonly executionId: string; +} diff --git a/src/project/release/InspectProjectReleaseOptions.ts b/src/project/release/InspectProjectReleaseOptions.ts new file mode 100644 index 0000000..fd41a1f --- /dev/null +++ b/src/project/release/InspectProjectReleaseOptions.ts @@ -0,0 +1,5 @@ +import type { ProjectReleaseAccess } from './ProjectReleaseAccess'; + +export interface InspectProjectReleaseOptions extends ProjectReleaseAccess { + readonly projectRoot: string; +} diff --git a/src/project/release/ProjectReleaseAccess.ts b/src/project/release/ProjectReleaseAccess.ts new file mode 100644 index 0000000..2dcbc2e --- /dev/null +++ b/src/project/release/ProjectReleaseAccess.ts @@ -0,0 +1,13 @@ +import type { DeploymentCredentialReference } from '../../domain/DeploymentCredentialReference'; +import type { DeploymentSecretResolver } from '../../domain/DeploymentSecretResolver'; +import type { ProjectReleaseAndroidContext } from './ProjectReleaseAndroidContext'; +import type { ProjectReleaseIosContext } from './ProjectReleaseIosContext'; +import type { ProjectReleaseWebContext } from './ProjectReleaseWebContext'; + +export interface ProjectReleaseAccess { + readonly credentials?: readonly DeploymentCredentialReference[]; + readonly resolveSecret?: DeploymentSecretResolver; + readonly android?: ProjectReleaseAndroidContext; + readonly ios?: ProjectReleaseIosContext; + readonly web?: ProjectReleaseWebContext; +} diff --git a/src/project/release/ProjectReleaseAndroidContext.ts b/src/project/release/ProjectReleaseAndroidContext.ts new file mode 100644 index 0000000..1e7b7af --- /dev/null +++ b/src/project/release/ProjectReleaseAndroidContext.ts @@ -0,0 +1,6 @@ +import type { AndroidDeploymentTrack } from '../../domain/AndroidDeploymentIntent'; + +export interface ProjectReleaseAndroidContext { + readonly track: AndroidDeploymentTrack; + readonly buildProfile?: string; +} diff --git a/src/project/release/ProjectReleaseExecution.ts b/src/project/release/ProjectReleaseExecution.ts new file mode 100644 index 0000000..8744f15 --- /dev/null +++ b/src/project/release/ProjectReleaseExecution.ts @@ -0,0 +1,8 @@ +import type { DeploymentFailure } from '../../domain/DeploymentFailure'; +import type { ReleaseReconcileResult } from '../../domain/release/ReleaseReconcileResult'; + +export interface ProjectReleaseExecution { + readonly result: ReleaseReconcileResult; + readonly historyRecorded: boolean; + readonly historyFailure?: DeploymentFailure; +} diff --git a/src/project/release/ProjectReleaseExecutionResult.ts b/src/project/release/ProjectReleaseExecutionResult.ts new file mode 100644 index 0000000..c1b616e --- /dev/null +++ b/src/project/release/ProjectReleaseExecutionResult.ts @@ -0,0 +1,6 @@ +import type { DeploymentFailure } from '../../domain/DeploymentFailure'; +import type { ProjectReleaseExecution } from './ProjectReleaseExecution'; + +export type ProjectReleaseExecutionResult = + | { readonly ok: true; readonly execution: ProjectReleaseExecution } + | { readonly ok: false; readonly failure: DeploymentFailure }; diff --git a/src/project/release/ProjectReleaseInspection.ts b/src/project/release/ProjectReleaseInspection.ts new file mode 100644 index 0000000..b6665f9 --- /dev/null +++ b/src/project/release/ProjectReleaseInspection.ts @@ -0,0 +1,11 @@ +import type { DeploymentRequiredAction } from '../../domain/DeploymentRequiredAction'; +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import type { ReleaseObservedState } from '../../domain/release/ReleaseObservedState'; + +export interface ProjectReleaseInspection { + readonly projectRoot: string; + readonly desired: ReleaseDesiredState; + readonly observed: ReleaseObservedState; + readonly currentRevision: string; + readonly actions: readonly DeploymentRequiredAction[]; +} diff --git a/src/project/release/ProjectReleaseInspectionResult.ts b/src/project/release/ProjectReleaseInspectionResult.ts new file mode 100644 index 0000000..6694a96 --- /dev/null +++ b/src/project/release/ProjectReleaseInspectionResult.ts @@ -0,0 +1,6 @@ +import type { DeploymentFailure } from '../../domain/DeploymentFailure'; +import type { ProjectReleaseInspection } from './ProjectReleaseInspection'; + +export type ProjectReleaseInspectionResult = + | { readonly ok: true; readonly inspection: ProjectReleaseInspection } + | { readonly ok: false; readonly failure: DeploymentFailure }; diff --git a/src/project/release/ProjectReleaseIosContext.ts b/src/project/release/ProjectReleaseIosContext.ts new file mode 100644 index 0000000..417f702 --- /dev/null +++ b/src/project/release/ProjectReleaseIosContext.ts @@ -0,0 +1,3 @@ +export interface ProjectReleaseIosContext { + readonly buildProfile?: string; +} diff --git a/src/project/release/ProjectReleaseMutationContext.ts b/src/project/release/ProjectReleaseMutationContext.ts new file mode 100644 index 0000000..e528bed --- /dev/null +++ b/src/project/release/ProjectReleaseMutationContext.ts @@ -0,0 +1,10 @@ +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import type { ProjectReleaseTargets } from './ProjectReleaseTargets'; +import type { ResolvedProjectReleaseAccess } from './ResolvedProjectReleaseAccess'; + +export interface ProjectReleaseMutationContext { + readonly projectRoot: string; + readonly desired: ReleaseDesiredState; + readonly targets: ProjectReleaseTargets; + readonly access: ResolvedProjectReleaseAccess; +} diff --git a/src/project/release/ProjectReleaseObservedResult.ts b/src/project/release/ProjectReleaseObservedResult.ts new file mode 100644 index 0000000..a679d42 --- /dev/null +++ b/src/project/release/ProjectReleaseObservedResult.ts @@ -0,0 +1,11 @@ +import type { DeploymentFailure } from '../../domain/DeploymentFailure'; +import type { DeploymentRequiredAction } from '../../domain/DeploymentRequiredAction'; +import type { ReleaseObservedState } from '../../domain/release/ReleaseObservedState'; + +export type ProjectReleaseObservedResult = + | { + readonly ok: true; + readonly observed: ReleaseObservedState; + readonly actions: readonly DeploymentRequiredAction[]; + } + | { readonly ok: false; readonly failure: DeploymentFailure }; diff --git a/src/project/release/ProjectReleasePublishTargetOptions.ts b/src/project/release/ProjectReleasePublishTargetOptions.ts new file mode 100644 index 0000000..244318e --- /dev/null +++ b/src/project/release/ProjectReleasePublishTargetOptions.ts @@ -0,0 +1,10 @@ +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import type { ProjectReleaseTargets } from './ProjectReleaseTargets'; +import type { ResolvedProjectReleaseAccess } from './ResolvedProjectReleaseAccess'; + +export interface ProjectReleasePublishTargetOptions { + readonly projectRoot: string; + readonly desired: ReleaseDesiredState; + readonly targets: ProjectReleaseTargets; + readonly access: ResolvedProjectReleaseAccess; +} diff --git a/src/project/release/ProjectReleaseRuntime.ts b/src/project/release/ProjectReleaseRuntime.ts new file mode 100644 index 0000000..0cdd92b --- /dev/null +++ b/src/project/release/ProjectReleaseRuntime.ts @@ -0,0 +1,26 @@ +import type { AppStoreConnectTokenFactory } from '../../providers/appStoreConnect/AppStoreConnectTokenFactory'; +import type { AppStoreConnectTransport } from '../../providers/appStoreConnect/AppStoreConnectTransport'; +import type { executeAppStoreReleaseControl } from '../../providers/appStoreConnect/executeAppStoreReleaseControl'; +import type { executeAppStoreReleaseMutation } from '../../providers/appStoreConnect/executeAppStoreReleaseMutation'; +import type { executeGooglePlayReleaseControl } from '../../providers/googlePlay/executeGooglePlayReleaseControl'; +import type { executeGooglePlayReleaseMutation } from '../../providers/googlePlay/executeGooglePlayReleaseMutation'; +import type { GooglePlayTokenFactory } from '../../providers/googlePlay/GooglePlayTokenFactory'; +import type { GooglePlayTransport } from '../../providers/googlePlay/GooglePlayTransport'; +import type { publishProjectReleaseAndroid } from './publishProjectReleaseAndroid'; +import type { publishProjectReleaseIos } from './publishProjectReleaseIos'; +import type { publishProjectReleaseWeb } from './publishProjectReleaseWeb'; + +export interface ProjectReleaseRuntime { + readonly createGooglePlayToken: GooglePlayTokenFactory; + readonly requestGooglePlay: GooglePlayTransport; + readonly createAppStoreConnectToken: AppStoreConnectTokenFactory; + readonly requestAppStoreConnect: AppStoreConnectTransport; + readonly executeGooglePlayMutation: typeof executeGooglePlayReleaseMutation; + readonly executeAppStoreMutation: typeof executeAppStoreReleaseMutation; + readonly executeGooglePlayControl: typeof executeGooglePlayReleaseControl; + readonly executeAppStoreControl: typeof executeAppStoreReleaseControl; + readonly publishWeb: typeof publishProjectReleaseWeb; + readonly publishAndroid: typeof publishProjectReleaseAndroid; + readonly publishIos: typeof publishProjectReleaseIos; + readonly now: () => Date; +} diff --git a/src/project/release/ProjectReleaseTargetInspection.ts b/src/project/release/ProjectReleaseTargetInspection.ts new file mode 100644 index 0000000..4cf4c12 --- /dev/null +++ b/src/project/release/ProjectReleaseTargetInspection.ts @@ -0,0 +1,11 @@ +import type { DeploymentFailure } from '../../domain/DeploymentFailure'; +import type { DeploymentRequiredAction } from '../../domain/DeploymentRequiredAction'; +import type { ReleaseObservedTargetState } from '../../domain/release/ReleaseObservedTargetState'; + +export type ProjectReleaseTargetInspection = + | { + readonly ok: true; + readonly state: ReleaseObservedTargetState; + readonly actions: readonly DeploymentRequiredAction[]; + } + | { readonly ok: false; readonly failure: DeploymentFailure }; diff --git a/src/project/release/ProjectReleaseTargets.ts b/src/project/release/ProjectReleaseTargets.ts new file mode 100644 index 0000000..8d7994d --- /dev/null +++ b/src/project/release/ProjectReleaseTargets.ts @@ -0,0 +1,15 @@ +import type { AndroidDeploymentTrack } from '../../domain/AndroidDeploymentIntent'; +import type { ProjectReleaseWebContext } from './ProjectReleaseWebContext'; + +export interface ProjectReleaseTargets { + readonly web?: ProjectReleaseWebContext; + readonly android?: { + readonly packageName: string; + readonly track: AndroidDeploymentTrack; + readonly buildProfile?: string; + }; + readonly ios?: { + readonly bundleIdentifier: string; + readonly buildProfile?: string; + }; +} diff --git a/src/project/release/ProjectReleaseWebContext.ts b/src/project/release/ProjectReleaseWebContext.ts new file mode 100644 index 0000000..a4e8082 --- /dev/null +++ b/src/project/release/ProjectReleaseWebContext.ts @@ -0,0 +1,4 @@ +export interface ProjectReleaseWebContext { + readonly alias?: string; + readonly environment?: string; +} diff --git a/src/project/release/ResolvedProjectReleaseAccess.ts b/src/project/release/ResolvedProjectReleaseAccess.ts new file mode 100644 index 0000000..0df3b33 --- /dev/null +++ b/src/project/release/ResolvedProjectReleaseAccess.ts @@ -0,0 +1,13 @@ +import type { DeploymentCredentialReference } from '../../domain/DeploymentCredentialReference'; +import type { DeploymentSecretResolver } from '../../domain/DeploymentSecretResolver'; +import type { ProjectReleaseAndroidContext } from './ProjectReleaseAndroidContext'; +import type { ProjectReleaseIosContext } from './ProjectReleaseIosContext'; +import type { ProjectReleaseWebContext } from './ProjectReleaseWebContext'; + +export interface ResolvedProjectReleaseAccess { + readonly credentials: readonly DeploymentCredentialReference[]; + readonly resolveSecret: DeploymentSecretResolver; + readonly android?: ProjectReleaseAndroidContext; + readonly ios?: ProjectReleaseIosContext; + readonly web?: ProjectReleaseWebContext; +} diff --git a/src/project/release/ResumeProjectReleaseOptions.ts b/src/project/release/ResumeProjectReleaseOptions.ts new file mode 100644 index 0000000..61ada87 --- /dev/null +++ b/src/project/release/ResumeProjectReleaseOptions.ts @@ -0,0 +1,7 @@ +import type { ProjectReleaseAccess } from './ProjectReleaseAccess'; + +export interface ResumeProjectReleaseOptions extends ProjectReleaseAccess { + readonly projectRoot: string; + readonly previousExecutionId: string; + readonly executionId: string; +} diff --git a/src/project/release/areReleasePlansEqual.ts b/src/project/release/areReleasePlansEqual.ts new file mode 100644 index 0000000..5f9b129 --- /dev/null +++ b/src/project/release/areReleasePlansEqual.ts @@ -0,0 +1,5 @@ +import type { ReleasePlan } from '../../domain/release/ReleasePlan'; + +export function areReleasePlansEqual(left: ReleasePlan, right: ReleasePlan): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} diff --git a/src/project/release/createProjectReleasePlan.ts b/src/project/release/createProjectReleasePlan.ts new file mode 100644 index 0000000..4a243bf --- /dev/null +++ b/src/project/release/createProjectReleasePlan.ts @@ -0,0 +1,7 @@ +import { createReleasePlan } from '../../domain/release/createReleasePlan'; +import type { ReleasePlan } from '../../domain/release/ReleasePlan'; +import type { ProjectReleaseInspection } from './ProjectReleaseInspection'; + +export function createProjectReleasePlan(inspection: ProjectReleaseInspection): ReleasePlan { + return createReleasePlan(inspection.desired, inspection.observed); +} diff --git a/src/project/release/defaultProjectReleaseRuntime.ts b/src/project/release/defaultProjectReleaseRuntime.ts new file mode 100644 index 0000000..13b37e9 --- /dev/null +++ b/src/project/release/defaultProjectReleaseRuntime.ts @@ -0,0 +1,27 @@ +import { createAppStoreConnectToken } from '../../providers/appStoreConnect/AppStoreConnectTokenFactory'; +import { fetchAppStoreConnect } from '../../providers/appStoreConnect/AppStoreConnectTransport'; +import { executeAppStoreReleaseControl } from '../../providers/appStoreConnect/executeAppStoreReleaseControl'; +import { executeAppStoreReleaseMutation } from '../../providers/appStoreConnect/executeAppStoreReleaseMutation'; +import { executeGooglePlayReleaseControl } from '../../providers/googlePlay/executeGooglePlayReleaseControl'; +import { executeGooglePlayReleaseMutation } from '../../providers/googlePlay/executeGooglePlayReleaseMutation'; +import { createGooglePlayAccessToken } from '../../providers/googlePlay/GooglePlayTokenFactory'; +import { fetchGooglePlay } from '../../providers/googlePlay/GooglePlayTransport'; +import type { ProjectReleaseRuntime } from './ProjectReleaseRuntime'; +import { publishProjectReleaseAndroid } from './publishProjectReleaseAndroid'; +import { publishProjectReleaseIos } from './publishProjectReleaseIos'; +import { publishProjectReleaseWeb } from './publishProjectReleaseWeb'; + +export const defaultProjectReleaseRuntime: ProjectReleaseRuntime = { + createGooglePlayToken: createGooglePlayAccessToken, + requestGooglePlay: fetchGooglePlay, + createAppStoreConnectToken, + requestAppStoreConnect: fetchAppStoreConnect, + executeGooglePlayMutation: executeGooglePlayReleaseMutation, + executeAppStoreMutation: executeAppStoreReleaseMutation, + executeGooglePlayControl: executeGooglePlayReleaseControl, + executeAppStoreControl: executeAppStoreReleaseControl, + publishWeb: publishProjectReleaseWeb, + publishAndroid: publishProjectReleaseAndroid, + publishIos: publishProjectReleaseIos, + now: () => new Date(), +}; diff --git a/src/project/release/executeProjectRelease.ts b/src/project/release/executeProjectRelease.ts new file mode 100644 index 0000000..e400625 --- /dev/null +++ b/src/project/release/executeProjectRelease.ts @@ -0,0 +1,10 @@ +import { defaultProjectReleaseRuntime } from './defaultProjectReleaseRuntime'; +import type { ExecuteProjectReleaseOptions } from './ExecuteProjectReleaseOptions'; +import { executeProjectReleaseWithRuntime } from './executeProjectReleaseWithRuntime'; +import type { ProjectReleaseExecutionResult } from './ProjectReleaseExecutionResult'; + +export function executeProjectRelease( + options: ExecuteProjectReleaseOptions, +): Promise { + return executeProjectReleaseWithRuntime(options, defaultProjectReleaseRuntime); +} diff --git a/src/project/release/executeProjectReleaseAndroidMutation.ts b/src/project/release/executeProjectReleaseAndroidMutation.ts new file mode 100644 index 0000000..e5a5985 --- /dev/null +++ b/src/project/release/executeProjectReleaseAndroidMutation.ts @@ -0,0 +1,48 @@ +import type { ReleasePlanStep } from '../../domain/release/ReleasePlanStep'; +import type { ReleaseMutationResult } from '../../engine/release/ReleaseMutationResult'; +import { inspectGooglePlayReleaseState } from '../../providers/googlePlay/inspectGooglePlayReleaseState'; +import type { ProjectReleaseMutationContext } from './ProjectReleaseMutationContext'; +import type { ProjectReleaseRuntime } from './ProjectReleaseRuntime'; +import { readProjectReleaseAndroidArtifact } from './readProjectReleaseAndroidArtifact'; + +export async function executeProjectReleaseAndroidMutation(options: { + readonly step: ReleasePlanStep; + readonly context: ProjectReleaseMutationContext; + readonly runtime: ProjectReleaseRuntime; +}): Promise { + const target = options.context.targets.android; + if (target === undefined) return failed('PROJECT_RELEASE_ANDROID_TARGET_REQUIRED'); + const inspected = await inspectGooglePlayReleaseState({ + packageName: target.packageName, + track: target.track, + credentials: options.context.access.credentials, + resolveSecret: options.context.access.resolveSecret, + createToken: options.runtime.createGooglePlayToken, + request: options.runtime.requestGooglePlay, + }); + if (inspected.status === 'action-required') { + return { status: 'blocked', code: inspected.action.code }; + } + if (inspected.status === 'failed') return failed(inspected.failure.code); + const publication = await readProjectReleaseAndroidArtifact({ + projectRoot: options.context.projectRoot, + target, + snapshot: inspected.state, + }); + if (publication === null) return failed('PROJECT_RELEASE_ANDROID_ARTIFACT_REQUIRED'); + return options.runtime.executeGooglePlayMutation({ + step: options.step, + desired: options.context.desired, + packageName: target.packageName, + track: target.track, + versionCode: String(publication.versionCode), + credentials: options.context.access.credentials, + resolveSecret: options.context.access.resolveSecret, + createToken: options.runtime.createGooglePlayToken, + request: options.runtime.requestGooglePlay, + }); +} + +function failed(code: string): ReleaseMutationResult { + return { status: 'failed', code }; +} diff --git a/src/project/release/executeProjectReleaseControl.ts b/src/project/release/executeProjectReleaseControl.ts new file mode 100644 index 0000000..1efb1a0 --- /dev/null +++ b/src/project/release/executeProjectReleaseControl.ts @@ -0,0 +1,10 @@ +import type { ReleaseControlExecutionResult } from '../../domain/release/ReleaseControlExecutionResult'; +import { defaultProjectReleaseRuntime } from './defaultProjectReleaseRuntime'; +import type { ExecuteProjectReleaseControlOptions } from './ExecuteProjectReleaseControlOptions'; +import { executeProjectReleaseControlWithRuntime } from './executeProjectReleaseControlWithRuntime'; + +export function executeProjectReleaseControl( + options: ExecuteProjectReleaseControlOptions, +): Promise { + return executeProjectReleaseControlWithRuntime(options, defaultProjectReleaseRuntime); +} diff --git a/src/project/release/executeProjectReleaseControlMutation.ts b/src/project/release/executeProjectReleaseControlMutation.ts new file mode 100644 index 0000000..6face56 --- /dev/null +++ b/src/project/release/executeProjectReleaseControlMutation.ts @@ -0,0 +1,103 @@ +import type { ReleaseLifecycleControl } from '../../domain/release/ReleaseLifecycleControl'; +import type { ReleaseMutationResult } from '../../engine/release/ReleaseMutationResult'; +import { inspectAppStoreReleaseState } from '../../providers/appStoreConnect/inspectAppStoreReleaseState'; +import { inspectGooglePlayReleaseState } from '../../providers/googlePlay/inspectGooglePlayReleaseState'; +import type { ProjectReleaseAccess } from './ProjectReleaseAccess'; +import type { ProjectReleaseRuntime } from './ProjectReleaseRuntime'; +import { readProjectReleaseAndroidArtifact } from './readProjectReleaseAndroidArtifact'; +import { resolveProjectReleaseMutationContext } from './resolveProjectReleaseMutationContext'; + +export async function executeProjectReleaseControlMutation(options: { + readonly control: ReleaseLifecycleControl; + readonly projectRoot: string; + readonly expectedRevision: string; + readonly access: ProjectReleaseAccess; + readonly runtime: ProjectReleaseRuntime; +}): Promise { + const resolved = await resolveProjectReleaseMutationContext(options); + if (!resolved.ok) return resolved.mutation; + if (options.control.target === 'android') { + return executeAndroidControl(options, resolved.context, options.control); + } + return executeIosControl(options, resolved.context, options.control); +} + +async function executeAndroidControl( + options: Parameters[0], + context: Extract< + Awaited>, + { ok: true } + >['context'], + + control: Extract, +): Promise { + const target = context.targets.android; + if (target === undefined) return failed('PROJECT_RELEASE_ANDROID_TARGET_REQUIRED'); + const inspected = await inspectGooglePlayReleaseState({ + packageName: target.packageName, + track: target.track, + credentials: context.access.credentials, + resolveSecret: context.access.resolveSecret, + createToken: options.runtime.createGooglePlayToken, + request: options.runtime.requestGooglePlay, + }); + if (inspected.status === 'action-required') return blocked(inspected.action.code); + if (inspected.status === 'failed') return failed(inspected.failure.code); + const publication = await readProjectReleaseAndroidArtifact({ + projectRoot: context.projectRoot, + target, + snapshot: inspected.state, + }); + if (publication === null) return failed('PROJECT_RELEASE_ANDROID_ARTIFACT_REQUIRED'); + return options.runtime.executeGooglePlayControl({ + control, + packageName: target.packageName, + track: target.track, + versionCode: String(publication.versionCode), + credentials: context.access.credentials, + resolveSecret: context.access.resolveSecret, + createToken: options.runtime.createGooglePlayToken, + request: options.runtime.requestGooglePlay, + }); +} + +async function executeIosControl( + options: Parameters[0], + context: Extract< + Awaited>, + { ok: true } + >['context'], + + control: Extract, +): Promise { + const target = context.targets.ios; + if (target === undefined) return failed('PROJECT_RELEASE_IOS_TARGET_REQUIRED'); + const inspected = await inspectAppStoreReleaseState({ + bundleIdentifier: target.bundleIdentifier, + version: context.desired.version, + credentials: context.access.credentials, + resolveSecret: context.access.resolveSecret, + createToken: options.runtime.createAppStoreConnectToken, + request: options.runtime.requestAppStoreConnect, + now: options.runtime.now(), + }); + if (inspected.status === 'action-required') return blocked(inspected.action.code); + if (inspected.status === 'failed') return failed(inspected.failure.code); + return options.runtime.executeAppStoreControl({ + control, + snapshot: inspected.state, + credentials: context.access.credentials, + resolveSecret: context.access.resolveSecret, + createToken: options.runtime.createAppStoreConnectToken, + request: options.runtime.requestAppStoreConnect, + now: options.runtime.now(), + }); +} + +function blocked(code: string): ReleaseMutationResult { + return { status: 'blocked', code }; +} + +function failed(code: string): ReleaseMutationResult { + return { status: 'failed', code }; +} diff --git a/src/project/release/executeProjectReleaseControlWithRuntime.ts b/src/project/release/executeProjectReleaseControlWithRuntime.ts new file mode 100644 index 0000000..afeac07 --- /dev/null +++ b/src/project/release/executeProjectReleaseControlWithRuntime.ts @@ -0,0 +1,51 @@ +import type { ReleaseControlExecutionResult } from '../../domain/release/ReleaseControlExecutionResult'; +import { executeReleaseLifecycleControl } from '../../engine/release/executeReleaseLifecycleControl'; +import { executeProjectReleaseControlMutation } from './executeProjectReleaseControlMutation'; +import type { ExecuteProjectReleaseControlOptions } from './ExecuteProjectReleaseControlOptions'; +import { findProjectReleaseTargetState } from './findProjectReleaseTargetState'; +import { inspectProjectReleaseWithRuntime } from './inspectProjectReleaseWithRuntime'; +import type { ProjectReleaseRuntime } from './ProjectReleaseRuntime'; + +export async function executeProjectReleaseControlWithRuntime( + options: ExecuteProjectReleaseControlOptions, + runtime: ProjectReleaseRuntime, +): Promise { + const initial = await inspectProjectReleaseWithRuntime(options, runtime); + if (!initial.ok) return failed(initial.failure.code); + const current = findProjectReleaseTargetState( + initial.inspection.observed, + options.control.target, + ); + if (current === null) return blocked('PROJECT_RELEASE_CONTROL_TARGET_NOT_SELECTED'); + const expectedRevision = initial.inspection.desired.revision; + return executeReleaseLifecycleControl({ + control: options.control, + inspect: () => inspectTarget(options, expectedRevision, runtime), + mutate: () => + executeProjectReleaseControlMutation({ + control: options.control, + projectRoot: initial.inspection.projectRoot, + expectedRevision, + access: options, + runtime, + }), + }); +} + +async function inspectTarget( + options: ExecuteProjectReleaseControlOptions, + expectedRevision: string, + runtime: ProjectReleaseRuntime, +) { + const inspected = await inspectProjectReleaseWithRuntime(options, runtime); + if (!inspected.ok || inspected.inspection.desired.revision !== expectedRevision) return null; + return findProjectReleaseTargetState(inspected.inspection.observed, options.control.target); +} + +function blocked(code: string): ReleaseControlExecutionResult { + return { status: 'blocked', mutationAttempted: false, code }; +} + +function failed(code: string): ReleaseControlExecutionResult { + return { status: 'failed', mutationAttempted: false, code }; +} diff --git a/src/project/release/executeProjectReleaseIosMutation.ts b/src/project/release/executeProjectReleaseIosMutation.ts new file mode 100644 index 0000000..a3c026a --- /dev/null +++ b/src/project/release/executeProjectReleaseIosMutation.ts @@ -0,0 +1,41 @@ +import type { ReleasePlanStep } from '../../domain/release/ReleasePlanStep'; +import type { ReleaseMutationResult } from '../../engine/release/ReleaseMutationResult'; +import { inspectAppStoreReleaseState } from '../../providers/appStoreConnect/inspectAppStoreReleaseState'; +import type { ProjectReleaseMutationContext } from './ProjectReleaseMutationContext'; +import type { ProjectReleaseRuntime } from './ProjectReleaseRuntime'; + +export async function executeProjectReleaseIosMutation(options: { + readonly step: ReleasePlanStep; + readonly context: ProjectReleaseMutationContext; + readonly runtime: ProjectReleaseRuntime; +}): Promise { + const target = options.context.targets.ios; + if (target === undefined) return failed('PROJECT_RELEASE_IOS_TARGET_REQUIRED'); + const inspected = await inspectAppStoreReleaseState({ + bundleIdentifier: target.bundleIdentifier, + version: options.context.desired.version, + credentials: options.context.access.credentials, + resolveSecret: options.context.access.resolveSecret, + createToken: options.runtime.createAppStoreConnectToken, + request: options.runtime.requestAppStoreConnect, + now: options.runtime.now(), + }); + if (inspected.status === 'action-required') { + return { status: 'blocked', code: inspected.action.code }; + } + if (inspected.status === 'failed') return failed(inspected.failure.code); + return options.runtime.executeAppStoreMutation({ + step: options.step, + desired: options.context.desired, + snapshot: inspected.state, + credentials: options.context.access.credentials, + resolveSecret: options.context.access.resolveSecret, + createToken: options.runtime.createAppStoreConnectToken, + request: options.runtime.requestAppStoreConnect, + now: options.runtime.now(), + }); +} + +function failed(code: string): ReleaseMutationResult { + return { status: 'failed', code }; +} diff --git a/src/project/release/executeProjectReleaseMutation.ts b/src/project/release/executeProjectReleaseMutation.ts new file mode 100644 index 0000000..556bf35 --- /dev/null +++ b/src/project/release/executeProjectReleaseMutation.ts @@ -0,0 +1,41 @@ +import type { ReleasePlanStep } from '../../domain/release/ReleasePlanStep'; +import type { ReleaseMutationResult } from '../../engine/release/ReleaseMutationResult'; +import { executeProjectReleaseAndroidMutation } from './executeProjectReleaseAndroidMutation'; +import { executeProjectReleaseIosMutation } from './executeProjectReleaseIosMutation'; +import type { ProjectReleaseAccess } from './ProjectReleaseAccess'; +import type { ProjectReleaseRuntime } from './ProjectReleaseRuntime'; +import { publishProjectReleaseTarget } from './publishProjectReleaseTarget'; +import { resolveProjectReleaseMutationContext } from './resolveProjectReleaseMutationContext'; + +export async function executeProjectReleaseMutation(options: { + readonly step: ReleasePlanStep; + readonly projectRoot: string; + readonly expectedRevision: string; + readonly access: ProjectReleaseAccess; + readonly runtime: ProjectReleaseRuntime; +}): Promise { + const resolved = await resolveProjectReleaseMutationContext(options); + if (!resolved.ok) return resolved.mutation; + if (options.step.operation === 'publish') { + return publishProjectReleaseTarget({ + step: options.step, + context: resolved.context, + runtime: options.runtime, + }); + } + if (options.step.target === 'android') { + return executeProjectReleaseAndroidMutation({ + step: options.step, + context: resolved.context, + runtime: options.runtime, + }); + } + if (options.step.target === 'ios') { + return executeProjectReleaseIosMutation({ + step: options.step, + context: resolved.context, + runtime: options.runtime, + }); + } + return { status: 'failed', code: 'PROJECT_RELEASE_STEP_UNSUPPORTED' }; +} diff --git a/src/project/release/executeProjectReleaseWithRuntime.ts b/src/project/release/executeProjectReleaseWithRuntime.ts new file mode 100644 index 0000000..26057f1 --- /dev/null +++ b/src/project/release/executeProjectReleaseWithRuntime.ts @@ -0,0 +1,81 @@ +import type { ReleaseReconcileResult } from '../../domain/release/ReleaseReconcileResult'; +import { executeReleasePlan } from '../../engine/release/executeReleasePlan'; +import { areReleasePlansEqual } from './areReleasePlansEqual'; +import { createProjectReleasePlan } from './createProjectReleasePlan'; +import { executeProjectReleaseMutation } from './executeProjectReleaseMutation'; +import type { ExecuteProjectReleaseOptions } from './ExecuteProjectReleaseOptions'; +import { inspectExpectedProjectReleaseObserved } from './inspectExpectedProjectReleaseObserved'; +import type { ProjectReleaseExecutionResult } from './ProjectReleaseExecutionResult'; +import type { ProjectReleaseRuntime } from './ProjectReleaseRuntime'; +import { recordProjectReleaseExecution } from './recordProjectReleaseExecution'; + +export async function executeProjectReleaseWithRuntime( + options: ExecuteProjectReleaseOptions, + runtime: ProjectReleaseRuntime, +): Promise { + try { + const expected = createProjectReleasePlan(options.inspection); + const result = areReleasePlansEqual(expected, options.plan) + ? await executePlannedRelease(options, runtime) + : blocked(expected, 'PROJECT_RELEASE_PLAN_MISMATCH'); + const execution = await recordProjectReleaseExecution({ + projectRoot: options.inspection.projectRoot, + executionId: options.executionId, + recordedAt: runtime.now().toISOString(), + desired: options.inspection.desired, + initialPlan: expected, + result, + }); + return { ok: true, execution }; + } catch { + return failed(); + } +} + +function executePlannedRelease( + options: ExecuteProjectReleaseOptions, + runtime: ProjectReleaseRuntime, +): Promise { + return executeReleasePlan({ + desired: options.inspection.desired, + plan: options.plan, + inspect: () => + inspectExpectedProjectReleaseObserved({ + projectRoot: options.inspection.projectRoot, + expectedRevision: options.inspection.desired.revision, + access: options, + runtime, + }), + mutate: (step) => + executeProjectReleaseMutation({ + step, + projectRoot: options.inspection.projectRoot, + expectedRevision: options.inspection.desired.revision, + access: options, + runtime, + }), + }); +} + +function blocked( + plan: ReturnType, + code: string, +): ReleaseReconcileResult { + return { + status: 'blocked', + plan, + currentRevision: plan.currentRevision, + executedStepIds: [], + code, + }; +} + +function failed(): ProjectReleaseExecutionResult { + return { + ok: false, + failure: { + code: 'PROJECT_RELEASE_EXECUTION_FAILED', + message: 'Project release execution failed.', + }, + }; +} diff --git a/src/project/release/findProjectReleaseTargetState.ts b/src/project/release/findProjectReleaseTargetState.ts new file mode 100644 index 0000000..377c2bb --- /dev/null +++ b/src/project/release/findProjectReleaseTargetState.ts @@ -0,0 +1,11 @@ +import type { ReleaseObservedState } from '../../domain/release/ReleaseObservedState'; +import type { ReleaseObservedTargetState } from '../../domain/release/ReleaseObservedTargetState'; +import type { ReleaseTarget } from '../../domain/release/ReleaseTarget'; + +export function findProjectReleaseTargetState( + observed: ReleaseObservedState, + target: ReleaseTarget, +): ReleaseObservedTargetState | null { + const matches = observed.targets.filter((state) => state.target === target); + return matches.length === 1 ? (matches[0] ?? null) : null; +} diff --git a/src/project/release/inspectExpectedProjectReleaseObserved.ts b/src/project/release/inspectExpectedProjectReleaseObserved.ts new file mode 100644 index 0000000..1af321d --- /dev/null +++ b/src/project/release/inspectExpectedProjectReleaseObserved.ts @@ -0,0 +1,20 @@ +import type { ReleaseObservedState } from '../../domain/release/ReleaseObservedState'; +import { inspectProjectReleaseWithRuntime } from './inspectProjectReleaseWithRuntime'; +import type { ProjectReleaseAccess } from './ProjectReleaseAccess'; +import type { ProjectReleaseRuntime } from './ProjectReleaseRuntime'; + +export async function inspectExpectedProjectReleaseObserved(options: { + readonly projectRoot: string; + readonly expectedRevision: string; + readonly access: ProjectReleaseAccess; + readonly runtime: ProjectReleaseRuntime; +}): Promise { + const inspected = await inspectProjectReleaseWithRuntime( + { projectRoot: options.projectRoot, ...options.access }, + options.runtime, + ); + if (!inspected.ok || inspected.inspection.desired.revision !== options.expectedRevision) { + return null; + } + return inspected.inspection.observed; +} diff --git a/src/project/release/inspectProjectRelease.ts b/src/project/release/inspectProjectRelease.ts new file mode 100644 index 0000000..7e10fea --- /dev/null +++ b/src/project/release/inspectProjectRelease.ts @@ -0,0 +1,10 @@ +import { defaultProjectReleaseRuntime } from './defaultProjectReleaseRuntime'; +import type { InspectProjectReleaseOptions } from './InspectProjectReleaseOptions'; +import { inspectProjectReleaseWithRuntime } from './inspectProjectReleaseWithRuntime'; +import type { ProjectReleaseInspectionResult } from './ProjectReleaseInspectionResult'; + +export function inspectProjectRelease( + options: InspectProjectReleaseOptions, +): Promise { + return inspectProjectReleaseWithRuntime(options, defaultProjectReleaseRuntime); +} diff --git a/src/project/release/inspectProjectReleaseAndroid.ts b/src/project/release/inspectProjectReleaseAndroid.ts new file mode 100644 index 0000000..b481f31 --- /dev/null +++ b/src/project/release/inspectProjectReleaseAndroid.ts @@ -0,0 +1,54 @@ +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import { inspectGooglePlayReleaseState } from '../../providers/googlePlay/inspectGooglePlayReleaseState'; +import { normalizeGooglePlayReleaseObservation } from '../../providers/googlePlay/normalizeGooglePlayReleaseObservation'; +import type { ProjectReleaseRuntime } from './ProjectReleaseRuntime'; +import type { ProjectReleaseTargetInspection } from './ProjectReleaseTargetInspection'; +import type { ProjectReleaseTargets } from './ProjectReleaseTargets'; +import { readProjectReleaseAndroidArtifact } from './readProjectReleaseAndroidArtifact'; +import type { ResolvedProjectReleaseAccess } from './ResolvedProjectReleaseAccess'; + +export async function inspectProjectReleaseAndroid(options: { + readonly projectRoot: string; + readonly desired: ReleaseDesiredState; + readonly target: NonNullable; + readonly access: ResolvedProjectReleaseAccess; + readonly runtime: ProjectReleaseRuntime; +}): Promise { + const inspected = await inspectGooglePlayReleaseState({ + packageName: options.target.packageName, + track: options.target.track, + credentials: options.access.credentials, + resolveSecret: options.access.resolveSecret, + createToken: options.runtime.createGooglePlayToken, + request: options.runtime.requestGooglePlay, + }); + if (inspected.status === 'failed') return { ok: false, failure: inspected.failure }; + if (inspected.status === 'action-required') { + return { ok: true, state: missingState(), actions: [inspected.action] }; + } + const publication = await readProjectReleaseAndroidArtifact({ + projectRoot: options.projectRoot, + target: options.target, + snapshot: inspected.state, + }); + return { + ok: true, + state: normalizeGooglePlayReleaseObservation({ + desiredVersion: options.desired.version, + publication, + snapshot: inspected.state, + }), + actions: [], + }; +} + +function missingState(): Extract['state'] { + return { + target: 'android', + version: null, + artifactRevision: null, + versionCodes: [], + releaseNotes: [], + rolloutStatus: 'missing', + }; +} diff --git a/src/project/release/inspectProjectReleaseIos.ts b/src/project/release/inspectProjectReleaseIos.ts new file mode 100644 index 0000000..a37a7e3 --- /dev/null +++ b/src/project/release/inspectProjectReleaseIos.ts @@ -0,0 +1,90 @@ +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import type { AppStoreReleaseSnapshot } from '../../providers/appStoreConnect/AppStoreReleaseSnapshot'; +import { inspectAppStoreConnectIos } from '../../providers/appStoreConnect/inspectAppStoreConnectIos'; +import { inspectAppStoreReleaseState } from '../../providers/appStoreConnect/inspectAppStoreReleaseState'; +import { normalizeAppStoreReleaseObservation } from '../../providers/appStoreConnect/normalizeAppStoreReleaseObservation'; +import type { ProjectReleaseRuntime } from './ProjectReleaseRuntime'; +import type { ProjectReleaseTargetInspection } from './ProjectReleaseTargetInspection'; +import type { ProjectReleaseTargets } from './ProjectReleaseTargets'; +import { readProjectReleaseIosArtifact } from './readProjectReleaseIosArtifact'; +import type { ResolvedProjectReleaseAccess } from './ResolvedProjectReleaseAccess'; + +export async function inspectProjectReleaseIos(options: { + readonly projectRoot: string; + readonly desired: ReleaseDesiredState; + readonly target: NonNullable; + readonly access: ResolvedProjectReleaseAccess; + readonly runtime: ProjectReleaseRuntime; +}): Promise { + const release = await inspectRelease(options); + if (release.status === 'failed') return { ok: false, failure: release.failure }; + if (release.status === 'action-required') { + return { ok: true, state: missingState(), actions: [release.action] }; + } + return inspectArtifact(options, release.state); +} + +async function inspectArtifact( + options: Parameters[0], + snapshot: AppStoreReleaseSnapshot, +): Promise { + const appStore = await inspectAppStoreConnectIos({ + bundleIdentifier: options.target.bundleIdentifier, + version: options.desired.version, + credentials: options.access.credentials, + resolveSecret: options.access.resolveSecret, + createToken: options.runtime.createAppStoreConnectToken, + request: options.runtime.requestAppStoreConnect, + now: options.runtime.now(), + }); + if (appStore.status === 'failed') return { ok: false, failure: appStore.failure }; + if (appStore.status === 'action-required') { + return success(snapshot, null, [appStore.action]); + } + const publication = await readProjectReleaseIosArtifact({ + projectRoot: options.projectRoot, + version: options.desired.version, + target: options.target, + appStoreState: appStore.state, + }); + return success(snapshot, publication, []); +} + +function inspectRelease(options: Parameters[0]) { + return inspectAppStoreReleaseState({ + bundleIdentifier: options.target.bundleIdentifier, + version: options.desired.version, + credentials: options.access.credentials, + resolveSecret: options.access.resolveSecret, + createToken: options.runtime.createAppStoreConnectToken, + request: options.runtime.requestAppStoreConnect, + now: options.runtime.now(), + }); +} + +function success( + snapshot: AppStoreReleaseSnapshot, + publication: Parameters[0]['publication'], + actions: Extract['actions'], +): ProjectReleaseTargetInspection { + return { + ok: true, + state: normalizeAppStoreReleaseObservation({ + publication, + publicationVerified: publication !== null, + snapshot, + }), + actions, + }; +} + +function missingState(): Extract['state'] { + return { + target: 'ios', + version: null, + artifactRevision: null, + buildNumber: null, + releaseNotes: [], + phasedReleaseState: null, + }; +} diff --git a/src/project/release/inspectProjectReleaseObserved.ts b/src/project/release/inspectProjectReleaseObserved.ts new file mode 100644 index 0000000..e60ce38 --- /dev/null +++ b/src/project/release/inspectProjectReleaseObserved.ts @@ -0,0 +1,46 @@ +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import type { ReleaseObservedTargetState } from '../../domain/release/ReleaseObservedTargetState'; +import { inspectProjectReleaseAndroid } from './inspectProjectReleaseAndroid'; +import { inspectProjectReleaseIos } from './inspectProjectReleaseIos'; +import { inspectProjectReleaseWeb } from './inspectProjectReleaseWeb'; +import type { ProjectReleaseObservedResult } from './ProjectReleaseObservedResult'; +import type { ProjectReleaseRuntime } from './ProjectReleaseRuntime'; +import type { ProjectReleaseTargets } from './ProjectReleaseTargets'; +import type { ResolvedProjectReleaseAccess } from './ResolvedProjectReleaseAccess'; + +export async function inspectProjectReleaseObserved(options: { + readonly projectRoot: string; + readonly desired: ReleaseDesiredState; + readonly targets: ProjectReleaseTargets; + readonly access: ResolvedProjectReleaseAccess; + readonly runtime: ProjectReleaseRuntime; +}): Promise { + const states: ReleaseObservedTargetState[] = []; + const actions: Extract['actions'][number][] = + []; + if (options.targets.web !== undefined) { + const result = await inspectProjectReleaseWeb(options); + if (!result.ok) return result; + states.push(result.state); + actions.push(...result.actions); + } + if (options.targets.android !== undefined) { + const result = await inspectProjectReleaseAndroid({ + ...options, + target: options.targets.android, + }); + if (!result.ok) return result; + states.push(result.state); + actions.push(...result.actions); + } + if (options.targets.ios !== undefined) { + const result = await inspectProjectReleaseIos({ + ...options, + target: options.targets.ios, + }); + if (!result.ok) return result; + states.push(result.state); + actions.push(...result.actions); + } + return { ok: true, observed: { targets: states }, actions }; +} diff --git a/src/project/release/inspectProjectReleaseWeb.ts b/src/project/release/inspectProjectReleaseWeb.ts new file mode 100644 index 0000000..5b7d0e4 --- /dev/null +++ b/src/project/release/inspectProjectReleaseWeb.ts @@ -0,0 +1,20 @@ +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import { createWebReleaseObservation } from '../../providers/release/createWebReleaseObservation'; +import { readCurrentProjectWebProductionDeployment } from '../web/readCurrentProjectWebProductionDeployment'; +import type { ProjectReleaseTargetInspection } from './ProjectReleaseTargetInspection'; + +export async function inspectProjectReleaseWeb(options: { + readonly projectRoot: string; + readonly desired: ReleaseDesiredState; +}): Promise { + const current = await readCurrentProjectWebProductionDeployment(options.projectRoot); + const revision = current.targets.web?.revision; + return { + ok: true, + state: createWebReleaseObservation( + options.desired.version, + revision === undefined ? null : { revision }, + ), + actions: [], + }; +} diff --git a/src/project/release/inspectProjectReleaseWithRuntime.ts b/src/project/release/inspectProjectReleaseWithRuntime.ts new file mode 100644 index 0000000..3634752 --- /dev/null +++ b/src/project/release/inspectProjectReleaseWithRuntime.ts @@ -0,0 +1,52 @@ +import { createReleaseCurrentRevision } from '../../domain/release/createReleaseCurrentRevision'; +import { resolveDeployProject } from '../resolveDeployProject'; +import { inspectProjectReleaseObserved } from './inspectProjectReleaseObserved'; +import type { InspectProjectReleaseOptions } from './InspectProjectReleaseOptions'; +import type { ProjectReleaseInspectionResult } from './ProjectReleaseInspectionResult'; +import type { ProjectReleaseRuntime } from './ProjectReleaseRuntime'; +import { readProjectRelease } from './readProjectRelease'; +import { resolveProjectReleaseAccess } from './resolveProjectReleaseAccess'; +import { resolveProjectReleaseTargets } from './resolveProjectReleaseTargets'; + +export async function inspectProjectReleaseWithRuntime( + options: InspectProjectReleaseOptions, + runtime: ProjectReleaseRuntime, +): Promise { + try { + const project = await resolveDeployProject({ projectRoot: options.projectRoot }); + const desired = await readProjectRelease({ projectRoot: project.projectRoot }); + const access = resolveProjectReleaseAccess(options); + const targets = resolveProjectReleaseTargets(project.deploy, desired, access); + if (!targets.ok) return targets; + const observed = await inspectProjectReleaseObserved({ + projectRoot: project.projectRoot, + desired, + targets: targets.targets, + access, + runtime, + }); + if (!observed.ok) return observed; + return { + ok: true, + inspection: { + projectRoot: project.projectRoot, + desired, + observed: observed.observed, + currentRevision: createReleaseCurrentRevision(observed.observed, desired.targets), + actions: observed.actions, + }, + }; + } catch { + return failure(); + } +} + +function failure(): ProjectReleaseInspectionResult { + return { + ok: false, + failure: { + code: 'PROJECT_RELEASE_INSPECTION_FAILED', + message: 'Project release inspection failed.', + }, + }; +} diff --git a/src/project/release/mapDeploymentExecutionToReleaseMutation.ts b/src/project/release/mapDeploymentExecutionToReleaseMutation.ts new file mode 100644 index 0000000..0680659 --- /dev/null +++ b/src/project/release/mapDeploymentExecutionToReleaseMutation.ts @@ -0,0 +1,15 @@ +import type { DeploymentExecutionResult } from '../../domain/DeploymentExecutionResult'; +import type { ReleaseMutationResult } from '../../engine/release/ReleaseMutationResult'; + +export function mapDeploymentExecutionToReleaseMutation( + execution: DeploymentExecutionResult, +): ReleaseMutationResult { + if (execution.status === 'completed') return { status: 'completed' }; + if (execution.status === 'action-required') { + return { status: 'blocked', code: execution.action.code }; + } + if (execution.status === 'failed') { + return { status: 'failed', code: execution.failure.code }; + } + return { status: 'blocked', code: 'PROJECT_RELEASE_DEPLOYMENT_BLOCKED' }; +} diff --git a/src/project/release/parseProjectRelease.ts b/src/project/release/parseProjectRelease.ts new file mode 100644 index 0000000..839dfa2 --- /dev/null +++ b/src/project/release/parseProjectRelease.ts @@ -0,0 +1,124 @@ +import type { ReleaseNote } from '../../domain/release/ReleaseNote'; +import type { ReleaseRollout } from '../../domain/release/ReleaseRollout'; +import type { ReleaseTarget } from '../../domain/release/ReleaseTarget'; +import type { ReleaseTargetRollout } from '../../domain/release/ReleaseTargetRollout'; +import { hasOnlyKeys } from '../io/hasOnlyKeys'; +import { isRecord } from '../io/isRecord'; + +const ROOT_KEYS = new Set(['version', 'targets', 'notes', 'rollout']); +const NOTE_KEYS = new Set(['locale', 'text']); +const ROLLOUT_KEYS = new Set(['web', 'android', 'ios']); +const TARGET_ROLLOUT_KEYS = new Set(['mode', 'initialFraction']); +const TARGETS = new Set(['web', 'android', 'ios']); + +export interface ParsedProjectRelease { + readonly version: string; + readonly targets: readonly ReleaseTarget[]; + readonly notes: readonly ReleaseNote[]; + readonly rollout: ReleaseRollout; +} + +export function parseProjectRelease(value: unknown): ParsedProjectRelease { + if (!isRecord(value) || !hasOnlyKeys(value, ROOT_KEYS)) throw invalid(); + return { + version: parseVersion(value.version), + targets: parseTargets(value.targets), + notes: parseNotes(value.notes), + rollout: parseRollout(value.rollout), + }; +} + +function parseVersion(value: unknown): string { + if (typeof value !== 'string' || !/^\d+(\.\d+){0,2}$/.test(value)) throw invalid(); + return value; +} + +function parseTargets(value: unknown): readonly ReleaseTarget[] { + if (!Array.isArray(value) || value.length === 0) throw invalid(); + const targets = value.map(parseTarget).sort(); + if (new Set(targets).size !== targets.length) throw invalid(); + return targets; +} + +function parseTarget(value: unknown): ReleaseTarget { + if (typeof value !== 'string' || !TARGETS.has(value as ReleaseTarget)) throw invalid(); + return value as ReleaseTarget; +} + +function parseNotes(value: unknown): readonly ReleaseNote[] { + if (!Array.isArray(value)) throw invalid(); + const notes = value.map(parseNote).sort((a, b) => a.locale.localeCompare(b.locale)); + if (new Set(notes.map((note) => note.locale)).size !== notes.length) throw invalid(); + return notes; +} + +function parseNote(value: unknown): ReleaseNote { + if (!isRecord(value) || !hasOnlyKeys(value, NOTE_KEYS)) throw invalid(); + if (typeof value.text !== 'string' || value.text.trim().length === 0) throw invalid(); + return { locale: normalizeLocale(value.locale), text: value.text }; +} + +function parseRollout(value: unknown): ReleaseRollout { + if (!isRecord(value) || !hasOnlyKeys(value, ROLLOUT_KEYS)) throw invalid(); + return { + ...(value.web === undefined ? {} : { web: parseWebRollout(value.web) }), + ...(value.android === undefined ? {} : { android: parseAndroidRollout(value.android) }), + ...(value.ios === undefined ? {} : { ios: parseIosRollout(value.ios) }), + }; +} + +function parseWebRollout(value: unknown): ReleaseTargetRollout { + const rollout = parseTargetRollout(value); + if (rollout.mode !== 'immediate' || rollout.initialFraction !== undefined) throw invalid(); + return rollout; +} + +function parseAndroidRollout(value: unknown): ReleaseTargetRollout { + const rollout = parseTargetRollout(value); + if (rollout.mode === 'staged') { + if (rollout.initialFraction === undefined) throw invalid(); + return { mode: 'staged', initialFraction: normalizeFraction(rollout.initialFraction) }; + } + if (rollout.initialFraction !== undefined) throw invalid(); + return { mode: 'immediate' }; +} + +function parseIosRollout(value: unknown): ReleaseTargetRollout { + const rollout = parseTargetRollout(value); + if (rollout.initialFraction !== undefined) throw invalid(); + return { mode: rollout.mode }; +} + +function parseTargetRollout(value: unknown): ReleaseTargetRollout { + if (!isRecord(value) || !hasOnlyKeys(value, TARGET_ROLLOUT_KEYS)) throw invalid(); + if (value.mode !== 'immediate' && value.mode !== 'staged') throw invalid(); + if (value.initialFraction !== undefined && typeof value.initialFraction !== 'string') { + throw invalid(); + } + return { + mode: value.mode, + ...(value.initialFraction === undefined ? {} : { initialFraction: value.initialFraction }), + }; +} + +function normalizeFraction(value: string): string { + if (!/^0\.\d{1,6}$/.test(value)) throw invalid(); + const digits = value.slice(2).replace(/0+$/, ''); + if (digits.length === 0) throw invalid(); + return `0.${digits}`; +} + +function normalizeLocale(value: unknown): string { + if (typeof value !== 'string' || value.trim().length === 0) throw invalid(); + try { + const locales = Intl.getCanonicalLocales(value); + if (locales.length !== 1 || locales[0] === undefined) throw invalid(); + return locales[0]; + } catch { + throw invalid(); + } +} + +function invalid(): Error { + return new Error('RELEASE_INVALID'); +} diff --git a/src/project/release/projectReleaseOwner.test.ts b/src/project/release/projectReleaseOwner.test.ts new file mode 100644 index 0000000..bffa934 --- /dev/null +++ b/src/project/release/projectReleaseOwner.test.ts @@ -0,0 +1,134 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { expect, test } from 'bun:test'; + +import { createTempProject, createTestManifest } from '../manifestTestSupport.test'; +import { listProjectReleaseHistory } from '../releaseHistory/listProjectReleaseHistory'; +import { createProjectWebDeploymentPlan } from '../web/createProjectWebDeploymentPlan'; +import type { ProjectWebDeploymentInspection } from '../web/ProjectWebDeploymentInspection'; +import { recordProjectWebDeployment } from '../web/recordProjectWebDeployment'; +import { createProjectReleasePlan } from './createProjectReleasePlan'; +import { defaultProjectReleaseRuntime } from './defaultProjectReleaseRuntime'; +import { executeProjectReleaseWithRuntime } from './executeProjectReleaseWithRuntime'; +import { inspectProjectReleaseWithRuntime } from './inspectProjectReleaseWithRuntime'; + +test('project release owner inspects no-change web release and records execution', async () => { + const projectRoot = await createWebProject(); + try { + await writeRelease(projectRoot, ['web']); + await recordWebDeployment(projectRoot); + const inspected = await inspectProjectReleaseWithRuntime( + { projectRoot }, + defaultProjectReleaseRuntime, + ); + expect(inspected.ok).toBe(true); + if (!inspected.ok) return; + const plan = createProjectReleasePlan(inspected.inspection); + expect(plan.status).toBe('no-change'); + const executed = await executeProjectReleaseWithRuntime( + { inspection: inspected.inspection, plan, executionId: 'release-owner-1' }, + defaultProjectReleaseRuntime, + ); + expect(executed.ok).toBe(true); + if (!executed.ok) return; + expect(executed.execution.result.status).toBe('completed'); + expect(executed.execution.historyRecorded).toBe(true); + expect((await listProjectReleaseHistory({ projectRoot })).length).toBe(1); + } finally { + await fs.rm(projectRoot, { recursive: true, force: true }); + } +}); + +test('project release owner does not treat a Web preview as a production release', async () => { + const projectRoot = await createWebProject(); + try { + await writeRelease(projectRoot, ['web']); + await recordWebDeployment(projectRoot, false); + const inspected = await inspectProjectReleaseWithRuntime( + { projectRoot }, + defaultProjectReleaseRuntime, + ); + expect(inspected.ok).toBe(true); + if (!inspected.ok) return; + const plan = createProjectReleasePlan(inspected.inspection); + expect(plan.status).toBe('changes'); + expect(plan.steps.some((step) => step.id === 'web:publish')).toBe(true); + } finally { + await fs.rm(projectRoot, { recursive: true, force: true }); + } +}); + +test('project release owner requires explicit Android track context', async () => { + const projectRoot = await createAndroidProject(); + try { + await writeRelease(projectRoot, ['android']); + const inspected = await inspectProjectReleaseWithRuntime( + { projectRoot }, + defaultProjectReleaseRuntime, + ); + expect(inspected.ok).toBe(false); + if (inspected.ok) return; + expect(inspected.failure.code).toBe('PROJECT_RELEASE_ANDROID_TRACK_REQUIRED'); + } finally { + await fs.rm(projectRoot, { recursive: true, force: true }); + } +}); + +function createWebProject(): Promise { + return createTempProject(createTestManifest({ targets: { web: { enabled: true } } })); +} + +function createAndroidProject(): Promise { + return createTempProject( + createTestManifest({ + targets: { android: { enabled: true, package: 'com.example.app' } }, + }), + ); +} + +async function writeRelease( + projectRoot: string, + targets: readonly ('web' | 'android')[], +): Promise { + const deployRoot = path.join(projectRoot, 'deploy'); + await fs.mkdir(deployRoot, { recursive: true }); + await fs.writeFile( + path.join(deployRoot, 'release.json'), + `${JSON.stringify({ + version: '2.1.0', + targets, + notes: [], + rollout: targets.includes('web') + ? { web: { mode: 'immediate' } } + : { android: { mode: 'immediate' } }, + })}\n`, + ); +} + +async function recordWebDeployment(projectRoot: string, production = true): Promise { + const inspection: ProjectWebDeploymentInspection = { + projectRoot, + desired: { targets: { web: { enabled: true } } }, + current: { targets: {} }, + desiredRevision: 'a'.repeat(64), + setup: null, + }; + const plan = createProjectWebDeploymentPlan(inspection); + const result = await recordProjectWebDeployment({ + inspection, + plan, + execution: { status: 'completed', records: [] }, + verification: { ok: true }, + publication: { + target: 'web', + revision: 'a'.repeat(64), + provider: 'eas', + deploymentId: 'web-test', + url: 'https://example.test', + production, + }, + recordedAt: '2026-08-14T03:30:00.000Z', + }); + expect(result.recorded).toBe(true); +} diff --git a/src/project/release/publishProjectReleaseAndroid.ts b/src/project/release/publishProjectReleaseAndroid.ts new file mode 100644 index 0000000..020d636 --- /dev/null +++ b/src/project/release/publishProjectReleaseAndroid.ts @@ -0,0 +1,39 @@ +import type { ReleaseMutationResult } from '../../engine/release/ReleaseMutationResult'; +import { createProjectAndroidDeploymentPlan } from '../android/createProjectAndroidDeploymentPlan'; +import { executeProjectAndroidDeployment } from '../android/executeProjectAndroidDeployment'; +import { inspectProjectAndroidDeployment } from '../android/inspectProjectAndroidDeployment'; +import { mapDeploymentExecutionToReleaseMutation } from './mapDeploymentExecutionToReleaseMutation'; +import type { ProjectReleasePublishTargetOptions } from './ProjectReleasePublishTargetOptions'; + +export async function publishProjectReleaseAndroid( + options: ProjectReleasePublishTargetOptions, +): Promise { + const context = options.targets.android; + if (context === undefined) return failed('PROJECT_RELEASE_ANDROID_TARGET_REQUIRED'); + if (context.buildProfile === undefined || context.buildProfile.trim().length === 0) { + return { status: 'blocked', code: 'PROJECT_RELEASE_ANDROID_BUILD_PROFILE_REQUIRED' }; + } + const inspected = await inspectProjectAndroidDeployment({ + projectRoot: options.projectRoot, + intent: { + buildProfile: context.buildProfile, + track: context.track, + releaseStatus: 'draft', + }, + credentials: options.access.credentials, + resolveSecret: options.access.resolveSecret, + }); + if (!inspected.ok) return failed(inspected.failure.code); + const plan = createProjectAndroidDeploymentPlan(inspected.inspection); + const execution = await executeProjectAndroidDeployment({ + inspection: inspected.inspection, + plan, + credentials: options.access.credentials, + resolveSecret: options.access.resolveSecret, + }); + return mapDeploymentExecutionToReleaseMutation(execution.execution); +} + +function failed(code: string): ReleaseMutationResult { + return { status: 'failed', code }; +} diff --git a/src/project/release/publishProjectReleaseIos.ts b/src/project/release/publishProjectReleaseIos.ts new file mode 100644 index 0000000..2bf133b --- /dev/null +++ b/src/project/release/publishProjectReleaseIos.ts @@ -0,0 +1,35 @@ +import type { ReleaseMutationResult } from '../../engine/release/ReleaseMutationResult'; +import { createProjectIosDeploymentPlan } from '../ios/createProjectIosDeploymentPlan'; +import { executeProjectIosDeployment } from '../ios/executeProjectIosDeployment'; +import { inspectProjectIosDeployment } from '../ios/inspectProjectIosDeployment'; +import { mapDeploymentExecutionToReleaseMutation } from './mapDeploymentExecutionToReleaseMutation'; +import type { ProjectReleasePublishTargetOptions } from './ProjectReleasePublishTargetOptions'; + +export async function publishProjectReleaseIos( + options: ProjectReleasePublishTargetOptions, +): Promise { + const context = options.targets.ios; + if (context === undefined) return failed('PROJECT_RELEASE_IOS_TARGET_REQUIRED'); + if (context.buildProfile === undefined || context.buildProfile.trim().length === 0) { + return { status: 'blocked', code: 'PROJECT_RELEASE_IOS_BUILD_PROFILE_REQUIRED' }; + } + const inspected = await inspectProjectIosDeployment({ + projectRoot: options.projectRoot, + intent: { buildProfile: context.buildProfile, version: options.desired.version }, + credentials: options.access.credentials, + resolveSecret: options.access.resolveSecret, + }); + if (!inspected.ok) return failed(inspected.failure.code); + const plan = createProjectIosDeploymentPlan(inspected.inspection); + const execution = await executeProjectIosDeployment({ + inspection: inspected.inspection, + plan, + credentials: options.access.credentials, + resolveSecret: options.access.resolveSecret, + }); + return mapDeploymentExecutionToReleaseMutation(execution.execution); +} + +function failed(code: string): ReleaseMutationResult { + return { status: 'failed', code }; +} diff --git a/src/project/release/publishProjectReleaseTarget.ts b/src/project/release/publishProjectReleaseTarget.ts new file mode 100644 index 0000000..e937c61 --- /dev/null +++ b/src/project/release/publishProjectReleaseTarget.ts @@ -0,0 +1,21 @@ +import type { ReleasePlanStep } from '../../domain/release/ReleasePlanStep'; +import type { ReleaseMutationResult } from '../../engine/release/ReleaseMutationResult'; +import type { ProjectReleaseMutationContext } from './ProjectReleaseMutationContext'; +import type { ProjectReleaseRuntime } from './ProjectReleaseRuntime'; + +export function publishProjectReleaseTarget(options: { + readonly step: ReleasePlanStep; + readonly context: ProjectReleaseMutationContext; + readonly runtime: ProjectReleaseRuntime; +}): Promise { + const publish = { + projectRoot: options.context.projectRoot, + desired: options.context.desired, + targets: options.context.targets, + access: options.context.access, + }; + if (options.step.target === 'web') return options.runtime.publishWeb(publish); + if (options.step.target === 'android') return options.runtime.publishAndroid(publish); + if (options.step.target === 'ios') return options.runtime.publishIos(publish); + return Promise.resolve({ status: 'failed', code: 'PROJECT_RELEASE_PUBLISH_TARGET_INVALID' }); +} diff --git a/src/project/release/publishProjectReleaseWeb.ts b/src/project/release/publishProjectReleaseWeb.ts new file mode 100644 index 0000000..415dd0b --- /dev/null +++ b/src/project/release/publishProjectReleaseWeb.ts @@ -0,0 +1,46 @@ +import type { WebDeploymentPublishIntent } from '../../domain/WebDeploymentPublishIntent'; +import type { ReleaseMutationResult } from '../../engine/release/ReleaseMutationResult'; +import { createProjectWebDeploymentPlan } from '../web/createProjectWebDeploymentPlan'; +import { executeProjectWebDeployment } from '../web/executeProjectWebDeployment'; +import { inspectProjectWebDeployment } from '../web/inspectProjectWebDeployment'; +import { readCurrentProjectWebProductionDeployment } from '../web/readCurrentProjectWebProductionDeployment'; +import { mapDeploymentExecutionToReleaseMutation } from './mapDeploymentExecutionToReleaseMutation'; +import type { ProjectReleasePublishTargetOptions } from './ProjectReleasePublishTargetOptions'; + +export async function publishProjectReleaseWeb( + options: ProjectReleasePublishTargetOptions, +): Promise { + const context = options.targets.web; + if (context === undefined) return failed('PROJECT_RELEASE_WEB_TARGET_REQUIRED'); + const inspected = await inspectProjectWebDeployment({ + projectRoot: options.projectRoot, + credentials: options.access.credentials, + resolveSecret: options.access.resolveSecret, + }); + if (!inspected.ok) return failed(inspected.failure.code); + const current = await readCurrentProjectWebProductionDeployment(inspected.inspection.projectRoot); + const inspection = { ...inspected.inspection, current }; + const plan = createProjectWebDeploymentPlan(inspection); + const execution = await executeProjectWebDeployment({ + inspection, + plan, + intent: createIntent(context), + credentials: options.access.credentials, + resolveSecret: options.access.resolveSecret, + }); + return mapDeploymentExecutionToReleaseMutation(execution.execution); +} + +function createIntent( + context: NonNullable, +): WebDeploymentPublishIntent { + return { + mode: 'production', + ...(context.alias === undefined ? {} : { alias: context.alias }), + ...(context.environment === undefined ? {} : { environment: context.environment }), + }; +} + +function failed(code: string): ReleaseMutationResult { + return { status: 'failed', code }; +} diff --git a/src/project/release/readProjectRelease.test.ts b/src/project/release/readProjectRelease.test.ts new file mode 100644 index 0000000..d7f58b6 --- /dev/null +++ b/src/project/release/readProjectRelease.test.ts @@ -0,0 +1,70 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { expect, test } from 'bun:test'; + +import { createTempProject } from '../manifestTestSupport.test'; +import { readProjectRelease } from './readProjectRelease'; + +test('reads and canonicalizes release authoring deterministically', async () => { + const projectRoot = await createTempProject(); + try { + await writeRelease(projectRoot, { + version: '2.1.0', + targets: ['ios', 'android'], + notes: [ + { locale: 'de-ch', text: 'Neu' }, + { locale: 'en-us', text: 'New' }, + ], + rollout: { + android: { mode: 'staged', initialFraction: '0.1000' }, + ios: { mode: 'staged' }, + }, + }); + const release = await readProjectRelease({ projectRoot }); + expect(release.targets).toEqual(['android', 'ios']); + expect(release.notes.map((note) => note.locale)).toEqual(['de-CH', 'en-US']); + expect(release.rollout.android).toEqual({ mode: 'staged', initialFraction: '0.1' }); + expect(release.rollout.ios).toEqual({ mode: 'staged' }); + expect(release.revision).toHaveLength(64); + } finally { + await fs.rm(projectRoot, { recursive: true, force: true }); + } +}); + +test('rejects target-specific rollout shapes that cannot be represented safely', async () => { + const projectRoot = await createTempProject(); + try { + await writeRelease(projectRoot, { + version: '2.1.0', + targets: ['ios'], + notes: [], + rollout: { ios: { mode: 'staged', initialFraction: '0.1' } }, + }); + expect(readProjectRelease({ projectRoot })).rejects.toThrow('RELEASE_INVALID'); + } finally { + await fs.rm(projectRoot, { recursive: true, force: true }); + } +}); + +test('rejects unknown release fields and duplicate targets', async () => { + const projectRoot = await createTempProject(); + try { + await writeRelease(projectRoot, { + version: '2.1.0', + targets: ['android', 'android'], + notes: [], + rollout: {}, + providerId: 'leak', + }); + expect(readProjectRelease({ projectRoot })).rejects.toThrow('RELEASE_INVALID'); + } finally { + await fs.rm(projectRoot, { recursive: true, force: true }); + } +}); + +async function writeRelease(projectRoot: string, value: unknown): Promise { + const directory = path.join(projectRoot, 'deploy'); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile(path.join(directory, 'release.json'), `${JSON.stringify(value, null, 2)}\n`); +} diff --git a/src/project/release/readProjectRelease.ts b/src/project/release/readProjectRelease.ts new file mode 100644 index 0000000..ab3407f --- /dev/null +++ b/src/project/release/readProjectRelease.ts @@ -0,0 +1,16 @@ +import { createReleaseRevision } from '../../domain/release/createReleaseRevision'; +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import { readJsonFile } from '../io/readJsonFile'; +import { resolveProjectDeploymentPaths } from '../resolveProjectDeploymentPaths'; +import { parseProjectRelease } from './parseProjectRelease'; + +export async function readProjectRelease(options: { + readonly projectRoot: string; +}): Promise { + const { releasePath } = resolveProjectDeploymentPaths(options.projectRoot); + const parsed = parseProjectRelease(await readJsonFile(releasePath, 'Deploy release')); + return { + ...parsed, + revision: createReleaseRevision(parsed), + }; +} diff --git a/src/project/release/readProjectReleaseAndroidArtifact.ts b/src/project/release/readProjectReleaseAndroidArtifact.ts new file mode 100644 index 0000000..e6b9c5d --- /dev/null +++ b/src/project/release/readProjectReleaseAndroidArtifact.ts @@ -0,0 +1,57 @@ +import type { AndroidDeploymentPublication } from '../../domain/AndroidDeploymentPublication'; +import type { GooglePlayReleaseSnapshot } from '../../providers/googlePlay/GooglePlayReleaseSnapshot'; +import { listProjectDeploymentHistory } from '../history/listProjectDeploymentHistory'; +import type { ProjectDeploymentHistoryRecord } from '../history/ProjectDeploymentHistoryRecord'; +import type { ProjectReleaseTargets } from './ProjectReleaseTargets'; + +type AndroidArtifact = Pick; + +interface AndroidEvidence { + readonly revision: string; + readonly versionCode: number; + readonly packageName: string; + readonly recordedAt: string; +} + +export async function readProjectReleaseAndroidArtifact(options: { + readonly projectRoot: string; + readonly target: NonNullable; + readonly snapshot: GooglePlayReleaseSnapshot; +}): Promise { + const history = await listProjectDeploymentHistory({ projectRoot: options.projectRoot }); + const [evidence] = history + .map(parseEvidence) + .filter((item): item is AndroidEvidence => item !== null) + .filter((item) => item.packageName === options.target.packageName) + .filter((item) => appearsInSnapshot(item.versionCode, options.snapshot)) + .sort((left, right) => right.recordedAt.localeCompare(left.recordedAt)); + return evidence === undefined + ? null + : { + revision: evidence.revision, + versionCode: evidence.versionCode, + track: options.target.track, + }; +} + +function parseEvidence(record: ProjectDeploymentHistoryRecord): AndroidEvidence | null { + if (record.execution.status !== 'completed' || record.verification?.ok !== true) return null; + const match = /^android-(\d+)-([a-f\d]{64})$/i.exec(record.deploymentId); + if (match === null) return null; + const [, versionCodeValue, revision] = match; + const versionCode = Number(versionCodeValue); + if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || revision === undefined) return null; + const desired = record.plan.changes.find((change) => change.target === 'android')?.desired; + if (desired?.target !== 'android') return null; + return { + revision, + versionCode, + packageName: desired.package, + recordedAt: record.recordedAt, + }; +} + +function appearsInSnapshot(versionCode: number, snapshot: GooglePlayReleaseSnapshot): boolean { + const expected = String(versionCode); + return snapshot.releases.some((release) => release.versionCodes.includes(expected)); +} diff --git a/src/project/release/readProjectReleaseIosArtifact.ts b/src/project/release/readProjectReleaseIosArtifact.ts new file mode 100644 index 0000000..5bcec54 --- /dev/null +++ b/src/project/release/readProjectReleaseIosArtifact.ts @@ -0,0 +1,55 @@ +import type { IosDeploymentPublication } from '../../domain/IosDeploymentPublication'; +import type { AppStoreConnectIosState } from '../../providers/appStoreConnect/AppStoreConnectIosState'; +import { listProjectDeploymentHistory } from '../history/listProjectDeploymentHistory'; +import type { ProjectDeploymentHistoryRecord } from '../history/ProjectDeploymentHistoryRecord'; +import type { ProjectReleaseTargets } from './ProjectReleaseTargets'; + +type IosArtifact = Pick; + +interface IosEvidence { + readonly revision: string; + readonly buildNumber: string; + readonly bundleIdentifier: string; + readonly recordedAt: string; +} + +export async function readProjectReleaseIosArtifact(options: { + readonly projectRoot: string; + readonly version: string; + readonly target: NonNullable; + readonly appStoreState: AppStoreConnectIosState; +}): Promise { + const build = options.appStoreState.version?.build; + if (build === null || build === undefined) return null; + if (build.processingState !== undefined && build.processingState !== 'VALID') return null; + const history = await listProjectDeploymentHistory({ projectRoot: options.projectRoot }); + const [evidence] = history + .map(parseEvidence) + .filter((item): item is IosEvidence => item !== null) + .filter((item) => item.bundleIdentifier === options.target.bundleIdentifier) + .filter((item) => item.buildNumber === build.buildNumber) + .sort((left, right) => right.recordedAt.localeCompare(left.recordedAt)); + return evidence === undefined + ? null + : { + revision: evidence.revision, + version: options.version, + buildNumber: evidence.buildNumber, + }; +} + +function parseEvidence(record: ProjectDeploymentHistoryRecord): IosEvidence | null { + if (record.execution.status !== 'completed' || record.verification?.ok !== true) return null; + const match = /^ios-(.+)-([a-f\d]{64})$/i.exec(record.deploymentId); + if (match === null) return null; + const [, buildNumber, revision] = match; + if (buildNumber === undefined || buildNumber.length === 0 || revision === undefined) return null; + const desired = record.plan.changes.find((change) => change.target === 'ios')?.desired; + if (desired?.target !== 'ios') return null; + return { + revision, + buildNumber, + bundleIdentifier: desired.bundleIdentifier, + recordedAt: record.recordedAt, + }; +} diff --git a/src/project/release/recordProjectReleaseExecution.ts b/src/project/release/recordProjectReleaseExecution.ts new file mode 100644 index 0000000..15aa33c --- /dev/null +++ b/src/project/release/recordProjectReleaseExecution.ts @@ -0,0 +1,41 @@ +import type { DeploymentFailure } from '../../domain/DeploymentFailure'; +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import type { ReleasePlan } from '../../domain/release/ReleasePlan'; +import type { ReleaseReconcileResult } from '../../domain/release/ReleaseReconcileResult'; +import { createProjectReleaseHistoryRecord } from '../releaseHistory/createProjectReleaseHistoryRecord'; +import { recordProjectReleaseHistory } from '../releaseHistory/recordProjectReleaseHistory'; +import type { ProjectReleaseExecution } from './ProjectReleaseExecution'; + +export async function recordProjectReleaseExecution(options: { + readonly projectRoot: string; + readonly executionId: string; + readonly recordedAt: string; + readonly desired: ReleaseDesiredState; + readonly initialPlan: ReleasePlan; + readonly result: ReleaseReconcileResult; +}): Promise { + try { + const record = createProjectReleaseHistoryRecord({ + executionId: options.executionId, + recordedAt: options.recordedAt, + desired: options.desired, + initialPlan: options.initialPlan, + result: options.result, + }); + await recordProjectReleaseHistory({ projectRoot: options.projectRoot, record }); + return { result: options.result, historyRecorded: true }; + } catch { + return { + result: options.result, + historyRecorded: false, + historyFailure: historyFailure(), + }; + } +} + +function historyFailure(): DeploymentFailure { + return { + code: 'PROJECT_RELEASE_HISTORY_RECORD_FAILED', + message: 'Project release execution history could not be recorded.', + }; +} diff --git a/src/project/release/resolveProjectReleaseAccess.ts b/src/project/release/resolveProjectReleaseAccess.ts new file mode 100644 index 0000000..96b94b0 --- /dev/null +++ b/src/project/release/resolveProjectReleaseAccess.ts @@ -0,0 +1,14 @@ +import type { ProjectReleaseAccess } from './ProjectReleaseAccess'; +import type { ResolvedProjectReleaseAccess } from './ResolvedProjectReleaseAccess'; + +export function resolveProjectReleaseAccess( + access: ProjectReleaseAccess, +): ResolvedProjectReleaseAccess { + return { + credentials: access.credentials ?? [], + resolveSecret: access.resolveSecret ?? (() => Promise.resolve(null)), + ...(access.android === undefined ? {} : { android: access.android }), + ...(access.ios === undefined ? {} : { ios: access.ios }), + ...(access.web === undefined ? {} : { web: access.web }), + }; +} diff --git a/src/project/release/resolveProjectReleaseMutationContext.ts b/src/project/release/resolveProjectReleaseMutationContext.ts new file mode 100644 index 0000000..8b4ee36 --- /dev/null +++ b/src/project/release/resolveProjectReleaseMutationContext.ts @@ -0,0 +1,47 @@ +import type { ReleaseMutationResult } from '../../engine/release/ReleaseMutationResult'; +import { resolveDeployProject } from '../resolveDeployProject'; +import type { ProjectReleaseAccess } from './ProjectReleaseAccess'; +import type { ProjectReleaseMutationContext } from './ProjectReleaseMutationContext'; +import { readProjectRelease } from './readProjectRelease'; +import { resolveProjectReleaseAccess } from './resolveProjectReleaseAccess'; +import { resolveProjectReleaseTargets } from './resolveProjectReleaseTargets'; + +type MutationContextResult = + | { readonly ok: true; readonly context: ProjectReleaseMutationContext } + | { readonly ok: false; readonly mutation: ReleaseMutationResult }; + +export async function resolveProjectReleaseMutationContext(options: { + readonly projectRoot: string; + readonly expectedRevision: string; + readonly access: ProjectReleaseAccess; +}): Promise { + try { + const project = await resolveDeployProject({ projectRoot: options.projectRoot }); + const desired = await readProjectRelease({ projectRoot: project.projectRoot }); + if (desired.revision !== options.expectedRevision) { + return blocked('PROJECT_RELEASE_DESIRED_STATE_DRIFTED'); + } + const access = resolveProjectReleaseAccess(options.access); + const targets = resolveProjectReleaseTargets(project.deploy, desired, access); + if (!targets.ok) return failed(targets.failure.code); + return { + ok: true, + context: { + projectRoot: project.projectRoot, + desired, + targets: targets.targets, + access, + }, + }; + } catch { + return failed('PROJECT_RELEASE_MUTATION_CONTEXT_FAILED'); + } +} + +function blocked(code: string): MutationContextResult { + return { ok: false, mutation: { status: 'blocked', code } }; +} + +function failed(code: string): MutationContextResult { + return { ok: false, mutation: { status: 'failed', code } }; +} diff --git a/src/project/release/resolveProjectReleaseTargets.ts b/src/project/release/resolveProjectReleaseTargets.ts new file mode 100644 index 0000000..a5f1997 --- /dev/null +++ b/src/project/release/resolveProjectReleaseTargets.ts @@ -0,0 +1,106 @@ +import type { AppDeployManifest } from '@ankhorage/contracts/deploy'; + +import type { DeploymentFailure } from '../../domain/DeploymentFailure'; +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import { normalizeProjectAndroidDesired } from '../android/normalizeProjectAndroidDesired'; +import { normalizeProjectIosDesired } from '../ios/normalizeProjectIosDesired'; +import { normalizeProjectWebDesired } from '../web/normalizeProjectWebDesired'; +import type { ProjectReleaseTargets } from './ProjectReleaseTargets'; +import type { ResolvedProjectReleaseAccess } from './ResolvedProjectReleaseAccess'; + +type ProjectReleaseTargetsResult = + | { readonly ok: true; readonly targets: ProjectReleaseTargets } + | { readonly ok: false; readonly failure: DeploymentFailure }; + +export function resolveProjectReleaseTargets( + deploy: AppDeployManifest | null, + desired: ReleaseDesiredState, + access: ResolvedProjectReleaseAccess, +): ProjectReleaseTargetsResult { + const web = normalizeProjectWebDesired(deploy); + if (!web.ok) return web; + const android = normalizeProjectAndroidDesired(deploy); + if (!android.ok) return android; + const ios = normalizeProjectIosDesired(deploy); + if (!ios.ok) return ios; + const webTarget = resolveWebTarget(desired, web.enabled, access); + if (!webTarget.ok) return webTarget; + const androidTarget = resolveAndroidTarget(desired, android, access); + if (!androidTarget.ok) return androidTarget; + const iosTarget = resolveIosTarget(desired, ios, access); + if (!iosTarget.ok) return iosTarget; + return { + ok: true, + targets: { + ...webTarget.value, + ...androidTarget.value, + ...iosTarget.value, + }, + }; +} + +function resolveWebTarget( + desired: ReleaseDesiredState, + enabled: boolean, + access: ResolvedProjectReleaseAccess, +): TargetPart { + if (!desired.targets.includes('web')) return success({}); + if (!enabled) + return failure('PROJECT_RELEASE_WEB_TARGET_DISABLED', 'Web release target is disabled.'); + return success({ web: access.web ?? {} }); +} + +function resolveAndroidTarget( + desired: ReleaseDesiredState, + android: ReturnType, + access: ResolvedProjectReleaseAccess, +): TargetPart { + if (!desired.targets.includes('android')) return success({}); + if (!android.ok || !android.enabled || android.packageName === undefined) { + return failure( + 'PROJECT_RELEASE_ANDROID_TARGET_INVALID', + 'Android release target is unavailable.', + ); + } + if (access.android === undefined) { + return failure('PROJECT_RELEASE_ANDROID_TRACK_REQUIRED', 'Android release track is required.'); + } + return success({ + android: { + packageName: android.packageName, + track: access.android.track, + ...(access.android.buildProfile === undefined + ? {} + : { buildProfile: access.android.buildProfile }), + }, + }); +} + +function resolveIosTarget( + desired: ReleaseDesiredState, + ios: ReturnType, + access: ResolvedProjectReleaseAccess, +): TargetPart { + if (!desired.targets.includes('ios')) return success({}); + if (!ios.ok || !ios.enabled || ios.bundleIdentifier === undefined) { + return failure('PROJECT_RELEASE_IOS_TARGET_INVALID', 'iOS release target is unavailable.'); + } + return success({ + ios: { + bundleIdentifier: ios.bundleIdentifier, + ...(access.ios?.buildProfile === undefined ? {} : { buildProfile: access.ios.buildProfile }), + }, + }); +} + +type TargetPart = + | { readonly ok: true; readonly value: Partial } + | { readonly ok: false; readonly failure: DeploymentFailure }; + +function success(value: Partial): TargetPart { + return { ok: true, value }; +} + +function failure(code: string, message: string): TargetPart { + return { ok: false, failure: { code, message } }; +} diff --git a/src/project/release/resumeProjectRelease.ts b/src/project/release/resumeProjectRelease.ts new file mode 100644 index 0000000..e5b15dc --- /dev/null +++ b/src/project/release/resumeProjectRelease.ts @@ -0,0 +1,10 @@ +import { defaultProjectReleaseRuntime } from './defaultProjectReleaseRuntime'; +import type { ProjectReleaseExecutionResult } from './ProjectReleaseExecutionResult'; +import type { ResumeProjectReleaseOptions } from './ResumeProjectReleaseOptions'; +import { resumeProjectReleaseWithRuntime } from './resumeProjectReleaseWithRuntime'; + +export function resumeProjectRelease( + options: ResumeProjectReleaseOptions, +): Promise { + return resumeProjectReleaseWithRuntime(options, defaultProjectReleaseRuntime); +} diff --git a/src/project/release/resumeProjectReleaseWithRuntime.ts b/src/project/release/resumeProjectReleaseWithRuntime.ts new file mode 100644 index 0000000..badfc9a --- /dev/null +++ b/src/project/release/resumeProjectReleaseWithRuntime.ts @@ -0,0 +1,76 @@ +import { resumeReleaseExecution } from '../../engine/release/resumeReleaseExecution'; +import { readProjectReleaseHistory } from '../releaseHistory/readProjectReleaseHistory'; +import { createProjectReleasePlan } from './createProjectReleasePlan'; +import { executeProjectReleaseMutation } from './executeProjectReleaseMutation'; +import { inspectExpectedProjectReleaseObserved } from './inspectExpectedProjectReleaseObserved'; +import { inspectProjectReleaseWithRuntime } from './inspectProjectReleaseWithRuntime'; +import type { ProjectReleaseExecutionResult } from './ProjectReleaseExecutionResult'; +import type { ProjectReleaseRuntime } from './ProjectReleaseRuntime'; +import { recordProjectReleaseExecution } from './recordProjectReleaseExecution'; +import type { ResumeProjectReleaseOptions } from './ResumeProjectReleaseOptions'; + +export async function resumeProjectReleaseWithRuntime( + options: ResumeProjectReleaseOptions, + runtime: ProjectReleaseRuntime, +): Promise { + try { + const previous = await readProjectReleaseHistory({ + projectRoot: options.projectRoot, + executionId: options.previousExecutionId, + }); + if (previous === null) return missingHistory(); + const inspected = await inspectProjectReleaseWithRuntime(options, runtime); + if (!inspected.ok) return inspected; + const plan = createProjectReleasePlan(inspected.inspection); + const result = await resumeReleaseExecution({ + desired: inspected.inspection.desired, + previous, + inspect: () => + inspectExpectedProjectReleaseObserved({ + projectRoot: inspected.inspection.projectRoot, + expectedRevision: inspected.inspection.desired.revision, + access: options, + runtime, + }), + mutate: (step) => + executeProjectReleaseMutation({ + step, + projectRoot: inspected.inspection.projectRoot, + expectedRevision: inspected.inspection.desired.revision, + access: options, + runtime, + }), + }); + const execution = await recordProjectReleaseExecution({ + projectRoot: inspected.inspection.projectRoot, + executionId: options.executionId, + recordedAt: runtime.now().toISOString(), + desired: inspected.inspection.desired, + initialPlan: plan, + result, + }); + return { ok: true, execution }; + } catch { + return failed(); + } +} + +function missingHistory(): ProjectReleaseExecutionResult { + return { + ok: false, + failure: { + code: 'PROJECT_RELEASE_RESUME_HISTORY_REQUIRED', + message: 'Previous release execution history is required for resume.', + }, + }; +} + +function failed(): ProjectReleaseExecutionResult { + return { + ok: false, + failure: { + code: 'PROJECT_RELEASE_RESUME_FAILED', + message: 'Project release resume failed.', + }, + }; +} diff --git a/src/project/releaseHistory/ProjectReleaseHistoryRecord.ts b/src/project/releaseHistory/ProjectReleaseHistoryRecord.ts new file mode 100644 index 0000000..c1102bf --- /dev/null +++ b/src/project/releaseHistory/ProjectReleaseHistoryRecord.ts @@ -0,0 +1,14 @@ +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import type { ReleaseExecutionState } from '../../domain/release/ReleaseExecutionState'; +import type { ReleasePlan } from '../../domain/release/ReleasePlan'; +import type { ReleaseReconcileResult } from '../../domain/release/ReleaseReconcileResult'; + +export interface ProjectReleaseHistoryRecord { + readonly schemaVersion: 1; + readonly executionId: string; + readonly recordedAt: string; + readonly desired: ReleaseDesiredState; + readonly initialPlan: ReleasePlan; + readonly result: ReleaseReconcileResult; + readonly execution: ReleaseExecutionState; +} diff --git a/src/project/releaseHistory/createProjectReleaseHistoryRecord.ts b/src/project/releaseHistory/createProjectReleaseHistoryRecord.ts new file mode 100644 index 0000000..6dc960f --- /dev/null +++ b/src/project/releaseHistory/createProjectReleaseHistoryRecord.ts @@ -0,0 +1,30 @@ +import { createReleaseExecutionStateFromResult } from '../../domain/release/createReleaseExecutionStateFromResult'; +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import type { ReleasePlan } from '../../domain/release/ReleasePlan'; +import type { ReleaseReconcileResult } from '../../domain/release/ReleaseReconcileResult'; +import type { ProjectReleaseHistoryRecord } from './ProjectReleaseHistoryRecord'; + +export function createProjectReleaseHistoryRecord(options: { + readonly executionId: string; + readonly recordedAt: string; + readonly desired: ReleaseDesiredState; + readonly initialPlan: ReleasePlan; + readonly result: ReleaseReconcileResult; +}): ProjectReleaseHistoryRecord { + if (options.initialPlan.desiredRevision !== options.desired.revision) { + throw new Error('Release history initial plan revision does not match desired release.'); + } + return { + schemaVersion: 1, + executionId: options.executionId, + recordedAt: options.recordedAt, + desired: options.desired, + initialPlan: options.initialPlan, + result: options.result, + execution: createReleaseExecutionStateFromResult({ + releaseRevision: options.desired.revision, + initialPlan: options.initialPlan, + result: options.result, + }), + }; +} diff --git a/src/project/releaseHistory/listProjectReleaseHistory.ts b/src/project/releaseHistory/listProjectReleaseHistory.ts new file mode 100644 index 0000000..4375cda --- /dev/null +++ b/src/project/releaseHistory/listProjectReleaseHistory.ts @@ -0,0 +1,45 @@ +import { type Dirent, promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { isMissingPathError } from '../io/isMissingPathError'; +import { resolveDeployProject } from '../resolveDeployProject'; +import type { ProjectReleaseHistoryRecord } from './ProjectReleaseHistoryRecord'; +import { readProjectReleaseHistory } from './readProjectReleaseHistory'; + +export async function listProjectReleaseHistory(options: { + readonly projectRoot: string; +}): Promise { + const project = await resolveDeployProject({ projectRoot: options.projectRoot }); + const releasesRoot = path.join(project.paths.historyRoot, 'releases'); + const entries = await readEntries(releasesRoot); + const records = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => + readProjectReleaseHistory({ + projectRoot: project.projectRoot, + executionId: entry.name, + }), + ), + ); + return records + .filter((record): record is ProjectReleaseHistoryRecord => record !== null) + .sort(compareRecords); +} + +function compareRecords( + left: ProjectReleaseHistoryRecord, + right: ProjectReleaseHistoryRecord, +): number { + const timestamp = left.recordedAt.localeCompare(right.recordedAt); + return timestamp !== 0 ? timestamp : left.executionId.localeCompare(right.executionId); +} + +async function readEntries(root: string): Promise { + try { + return await fs.readdir(root, { withFileTypes: true }); + } catch (error) { + if (isMissingPathError(error)) return []; + throw error; + } +} diff --git a/src/project/releaseHistory/parseProjectReleaseHistoryRecord.ts b/src/project/releaseHistory/parseProjectReleaseHistoryRecord.ts new file mode 100644 index 0000000..b8dd361 --- /dev/null +++ b/src/project/releaseHistory/parseProjectReleaseHistoryRecord.ts @@ -0,0 +1,61 @@ +import { isIsoTimestamp } from '../history/validation/isIsoTimestamp'; +import { hasOnlyKeys } from '../io/hasOnlyKeys'; +import { isRecord } from '../io/isRecord'; +import { parseReleaseHistoryDesired } from './parseReleaseHistoryDesired'; +import { parseReleaseHistoryExecution } from './parseReleaseHistoryExecution'; +import { parseReleaseHistoryPlan } from './parseReleaseHistoryPlan'; +import { parseReleaseHistoryResult } from './parseReleaseHistoryResult'; +import type { ProjectReleaseHistoryRecord } from './ProjectReleaseHistoryRecord'; + +const KEYS = new Set([ + 'schemaVersion', + 'executionId', + 'recordedAt', + 'desired', + 'initialPlan', + 'result', + 'execution', +]); + +export function parseProjectReleaseHistoryRecord(value: unknown): ProjectReleaseHistoryRecord { + if (!isRecord(value) || !hasOnlyKeys(value, KEYS)) throw invalid(); + if (value.schemaVersion !== 1 || !isNonEmptyString(value.executionId)) throw invalid(); + if (!isIsoTimestamp(value.recordedAt)) throw invalid(); + const desired = parseReleaseHistoryDesired(value.desired); + const initialPlan = parseReleaseHistoryPlan(value.initialPlan); + const result = parseReleaseHistoryResult(value.result); + const execution = parseReleaseHistoryExecution(value.execution); + validateConsistency(desired.revision, initialPlan, result, execution); + return { + schemaVersion: 1, + executionId: value.executionId, + recordedAt: value.recordedAt, + desired, + initialPlan, + result, + execution, + }; +} + +function validateConsistency( + revision: string, + initialPlan: ProjectReleaseHistoryRecord['initialPlan'], + result: ProjectReleaseHistoryRecord['result'], + execution: ProjectReleaseHistoryRecord['execution'], +): void { + if (initialPlan.desiredRevision !== revision || result.plan.desiredRevision !== revision) { + throw invalid(); + } + if (execution.releaseRevision !== revision) throw invalid(); + const plannedIds = initialPlan.steps.map((step) => step.id); + const executionIds = execution.steps.map((item) => item.step.id); + if (JSON.stringify(plannedIds) !== JSON.stringify(executionIds)) throw invalid(); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function invalid(): Error { + return new Error('Release history record has an invalid canonical shape.'); +} diff --git a/src/project/releaseHistory/parseReleaseHistoryDesired.ts b/src/project/releaseHistory/parseReleaseHistoryDesired.ts new file mode 100644 index 0000000..3acf37f --- /dev/null +++ b/src/project/releaseHistory/parseReleaseHistoryDesired.ts @@ -0,0 +1,25 @@ +import { createReleaseRevision } from '../../domain/release/createReleaseRevision'; +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import { hasOnlyKeys } from '../io/hasOnlyKeys'; +import { isRecord } from '../io/isRecord'; +import { parseProjectRelease } from '../release/parseProjectRelease'; + +const KEYS = new Set(['version', 'targets', 'notes', 'rollout', 'revision']); + +export function parseReleaseHistoryDesired(value: unknown): ReleaseDesiredState { + if (!isRecord(value) || !hasOnlyKeys(value, KEYS)) throw invalid(); + const parsed = parseProjectRelease({ + version: value.version, + targets: value.targets, + notes: value.notes, + rollout: value.rollout, + }); + if (typeof value.revision !== 'string') throw invalid(); + const revision = createReleaseRevision(parsed); + if (value.revision !== revision) throw invalid(); + return { ...parsed, revision }; +} + +function invalid(): Error { + return new Error('Release history has an invalid desired release.'); +} diff --git a/src/project/releaseHistory/parseReleaseHistoryExecution.ts b/src/project/releaseHistory/parseReleaseHistoryExecution.ts new file mode 100644 index 0000000..99b907d --- /dev/null +++ b/src/project/releaseHistory/parseReleaseHistoryExecution.ts @@ -0,0 +1,55 @@ +import type { ReleaseExecutionState } from '../../domain/release/ReleaseExecutionState'; +import type { ReleaseExecutionStep } from '../../domain/release/ReleaseExecutionStep'; +import { hasOnlyKeys } from '../io/hasOnlyKeys'; +import { isRecord } from '../io/isRecord'; +import { parseReleaseHistoryPlanStep } from './parseReleaseHistoryPlanStep'; + +const ROOT_KEYS = new Set(['releaseRevision', 'steps']); +const STEP_KEYS = new Set(['step', 'status', 'attempts', 'code']); +const STATUSES = new Set([ + 'pending', + 'completed', + 'failed', + 'waiting', + 'cancelled', +]); + +export function parseReleaseHistoryExecution(value: unknown): ReleaseExecutionState { + if (!isRecord(value) || !hasOnlyKeys(value, ROOT_KEYS)) throw invalid(); + if (!isNonEmptyString(value.releaseRevision) || !Array.isArray(value.steps)) throw invalid(); + return { + releaseRevision: value.releaseRevision, + steps: value.steps.map(parseExecutionStep), + }; +} + +function parseExecutionStep(value: unknown): ReleaseExecutionStep { + if (!isRecord(value) || !hasOnlyKeys(value, STEP_KEYS)) throw invalid(); + if (!isStatus(value.status) || !Number.isInteger(value.attempts)) throw invalid(); + if (typeof value.attempts !== 'number' || value.attempts < 0) throw invalid(); + const code = parseOptionalString(value.code); + return { + step: parseReleaseHistoryPlanStep(value.step), + status: value.status, + attempts: value.attempts, + ...(code === undefined ? {} : { code }), + }; +} + +function parseOptionalString(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (!isNonEmptyString(value)) throw invalid(); + return value; +} + +function isStatus(value: unknown): value is ReleaseExecutionStep['status'] { + return typeof value === 'string' && STATUSES.has(value as ReleaseExecutionStep['status']); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function invalid(): Error { + return new Error('Release history has an invalid execution state.'); +} diff --git a/src/project/releaseHistory/parseReleaseHistoryPlan.ts b/src/project/releaseHistory/parseReleaseHistoryPlan.ts new file mode 100644 index 0000000..a595fdb --- /dev/null +++ b/src/project/releaseHistory/parseReleaseHistoryPlan.ts @@ -0,0 +1,56 @@ +import type { ReleaseDiagnostic } from '../../domain/release/ReleaseDiagnostic'; +import type { ReleasePlan } from '../../domain/release/ReleasePlan'; +import type { ReleasePlanStatus } from '../../domain/release/ReleasePlanStatus'; +import { hasOnlyKeys } from '../io/hasOnlyKeys'; +import { isRecord } from '../io/isRecord'; +import { parseReleaseHistoryPlanStep } from './parseReleaseHistoryPlanStep'; + +const KEYS = new Set(['status', 'desiredRevision', 'currentRevision', 'steps', 'diagnostics']); +const DIAGNOSTIC_KEYS = new Set(['severity', 'code', 'message', 'target']); +const STATUSES = new Set(['no-change', 'changes', 'waiting', 'blocked']); +const TARGETS = new Set(['web', 'android', 'ios', 'release']); + +export function parseReleaseHistoryPlan(value: unknown): ReleasePlan { + if (!isRecord(value) || !hasOnlyKeys(value, KEYS)) throw invalid(); + if (!isStatus(value.status)) throw invalid(); + if (!isNonEmptyString(value.desiredRevision) || !isNonEmptyString(value.currentRevision)) { + throw invalid(); + } + if (!Array.isArray(value.steps) || !Array.isArray(value.diagnostics)) throw invalid(); + return { + status: value.status, + desiredRevision: value.desiredRevision, + currentRevision: value.currentRevision, + steps: value.steps.map(parseReleaseHistoryPlanStep), + diagnostics: value.diagnostics.map(parseDiagnostic), + }; +} + +function parseDiagnostic(value: unknown): ReleaseDiagnostic { + if (!isRecord(value) || !hasOnlyKeys(value, DIAGNOSTIC_KEYS)) throw invalid(); + if (value.severity !== 'warning' && value.severity !== 'error') throw invalid(); + if (!isNonEmptyString(value.code) || !isNonEmptyString(value.message)) throw invalid(); + if (value.target !== undefined && !isTarget(value.target)) throw invalid(); + return { + severity: value.severity, + code: value.code, + message: value.message, + ...(value.target === undefined ? {} : { target: value.target }), + }; +} + +function isStatus(value: unknown): value is ReleasePlanStatus { + return typeof value === 'string' && STATUSES.has(value as ReleasePlanStatus); +} + +function isTarget(value: unknown): value is ReleaseDiagnostic['target'] { + return typeof value === 'string' && TARGETS.has(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function invalid(): Error { + return new Error('Release history has an invalid plan.'); +} diff --git a/src/project/releaseHistory/parseReleaseHistoryPlanStep.ts b/src/project/releaseHistory/parseReleaseHistoryPlanStep.ts new file mode 100644 index 0000000..a11ed92 --- /dev/null +++ b/src/project/releaseHistory/parseReleaseHistoryPlanStep.ts @@ -0,0 +1,62 @@ +import type { ReleasePlanStep } from '../../domain/release/ReleasePlanStep'; +import type { ReleaseStepOperation } from '../../domain/release/ReleaseStepOperation'; +import type { ReleaseStepRetry } from '../../domain/release/ReleaseStepRetry'; +import { hasOnlyKeys } from '../io/hasOnlyKeys'; +import { isRecord } from '../io/isRecord'; + +const KEYS = new Set(['id', 'target', 'operation', 'dependsOn', 'retry', 'irreversible']); +const TARGETS = new Set(['web', 'android', 'ios', 'release']); +const OPERATIONS = new Set([ + 'prepare', + 'build', + 'publish', + 'sync-notes', + 'submit-review', + 'release', + 'rollout', + 'verify', + 'record', +]); +const RETRIES = new Set(['safe', 'reinspect', 'never']); + +export function parseReleaseHistoryPlanStep(value: unknown): ReleasePlanStep { + if (!isRecord(value) || !hasOnlyKeys(value, KEYS)) throw invalid(); + if (!isNonEmptyString(value.id) || !isTarget(value.target)) throw invalid(); + if (!isOperation(value.operation) || !isRetry(value.retry)) throw invalid(); + if (typeof value.irreversible !== 'boolean') throw invalid(); + const dependsOn = parseDependencies(value.dependsOn); + return { + id: value.id, + target: value.target, + operation: value.operation, + dependsOn, + retry: value.retry, + irreversible: value.irreversible, + }; +} + +function parseDependencies(value: unknown): readonly string[] { + if (!Array.isArray(value) || !value.every(isNonEmptyString)) throw invalid(); + if (new Set(value).size !== value.length) throw invalid(); + return value.slice(); +} + +function isTarget(value: unknown): value is ReleasePlanStep['target'] { + return typeof value === 'string' && TARGETS.has(value); +} + +function isOperation(value: unknown): value is ReleaseStepOperation { + return typeof value === 'string' && OPERATIONS.has(value as ReleaseStepOperation); +} + +function isRetry(value: unknown): value is ReleaseStepRetry { + return typeof value === 'string' && RETRIES.has(value as ReleaseStepRetry); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function invalid(): Error { + return new Error('Release history has an invalid plan step.'); +} diff --git a/src/project/releaseHistory/parseReleaseHistoryResult.ts b/src/project/releaseHistory/parseReleaseHistoryResult.ts new file mode 100644 index 0000000..ddca923 --- /dev/null +++ b/src/project/releaseHistory/parseReleaseHistoryResult.ts @@ -0,0 +1,62 @@ +import type { ReleaseReconcileResult } from '../../domain/release/ReleaseReconcileResult'; +import { hasOnlyKeys } from '../io/hasOnlyKeys'; +import { isRecord } from '../io/isRecord'; +import { parseReleaseHistoryPlan } from './parseReleaseHistoryPlan'; + +const KEYS = new Set([ + 'status', + 'plan', + 'currentRevision', + 'executedStepIds', + 'attemptedStepId', + 'code', +]); +const STATUSES = new Set([ + 'completed', + 'waiting', + 'blocked', + 'failed', + 'drifted', +]); + +export function parseReleaseHistoryResult(value: unknown): ReleaseReconcileResult { + if (!isRecord(value) || !hasOnlyKeys(value, KEYS)) throw invalid(); + if (!isStatus(value.status) || !isNonEmptyString(value.currentRevision)) throw invalid(); + const plan = parseReleaseHistoryPlan(value.plan); + if (plan.currentRevision !== value.currentRevision) throw invalid(); + const executedStepIds = parseStepIds(value.executedStepIds); + const attemptedStepId = parseOptionalString(value.attemptedStepId); + const code = parseOptionalString(value.code); + return { + status: value.status, + plan, + currentRevision: value.currentRevision, + executedStepIds, + ...(attemptedStepId === undefined ? {} : { attemptedStepId }), + ...(code === undefined ? {} : { code }), + }; +} + +function parseStepIds(value: unknown): readonly string[] { + if (!Array.isArray(value) || !value.every(isNonEmptyString)) throw invalid(); + if (new Set(value).size !== value.length) throw invalid(); + return value.slice(); +} + +function parseOptionalString(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (!isNonEmptyString(value)) throw invalid(); + return value; +} + +function isStatus(value: unknown): value is ReleaseReconcileResult['status'] { + return typeof value === 'string' && STATUSES.has(value as ReleaseReconcileResult['status']); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function invalid(): Error { + return new Error('Release history has an invalid execution result.'); +} diff --git a/src/project/releaseHistory/readProjectReleaseHistory.ts b/src/project/releaseHistory/readProjectReleaseHistory.ts new file mode 100644 index 0000000..95066d4 --- /dev/null +++ b/src/project/releaseHistory/readProjectReleaseHistory.ts @@ -0,0 +1,34 @@ +import path from 'node:path'; + +import { assertSafeSegment } from '../io/assertSafeSegment'; +import { isMissingPathError } from '../io/isMissingPathError'; +import { readJsonFile } from '../io/readJsonFile'; +import { resolveDeployProject } from '../resolveDeployProject'; +import { parseProjectReleaseHistoryRecord } from './parseProjectReleaseHistoryRecord'; +import type { ProjectReleaseHistoryRecord } from './ProjectReleaseHistoryRecord'; + +export async function readProjectReleaseHistory(options: { + readonly projectRoot: string; + readonly executionId: string; +}): Promise { + assertSafeSegment(options.executionId, 'release execution id'); + const project = await resolveDeployProject({ projectRoot: options.projectRoot }); + const recordPath = path.join( + project.paths.historyRoot, + 'releases', + options.executionId, + 'release.json', + ); + let parsed: unknown; + try { + parsed = await readJsonFile(recordPath, 'Release history record'); + } catch (error) { + if (isMissingPathError(error)) return null; + throw error; + } + const record = parseProjectReleaseHistoryRecord(parsed); + if (record.executionId !== options.executionId) { + throw new Error(`Release history id does not match its directory: ${options.executionId}`); + } + return record; +} diff --git a/src/project/releaseHistory/recordProjectReleaseHistory.ts b/src/project/releaseHistory/recordProjectReleaseHistory.ts new file mode 100644 index 0000000..b479317 --- /dev/null +++ b/src/project/releaseHistory/recordProjectReleaseHistory.ts @@ -0,0 +1,45 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { assertContainedWritePath } from '../io/assertContainedPath'; +import { assertSafeSegment } from '../io/assertSafeSegment'; +import { atomicWriteJson } from '../io/atomicWriteJson'; +import { resolveDeployProject } from '../resolveDeployProject'; +import { parseProjectReleaseHistoryRecord } from './parseProjectReleaseHistoryRecord'; +import type { ProjectReleaseHistoryRecord } from './ProjectReleaseHistoryRecord'; + +export async function recordProjectReleaseHistory(options: { + readonly projectRoot: string; + readonly record: ProjectReleaseHistoryRecord; +}): Promise { + const record = parseProjectReleaseHistoryRecord(options.record); + assertSafeSegment(record.executionId, 'release execution id'); + const project = await resolveDeployProject({ projectRoot: options.projectRoot }); + const releasesRoot = path.join(project.paths.historyRoot, 'releases'); + const recordRoot = path.join(releasesRoot, record.executionId); + const recordPath = path.join(recordRoot, 'release.json'); + await assertContainedWritePath(project.projectRoot, recordPath); + await fs.mkdir(releasesRoot, { recursive: true }); + await createRecordDirectory(recordRoot, record.executionId); + try { + await atomicWriteJson({ + projectRoot: project.projectRoot, + filePath: recordPath, + value: record, + }); + } catch (error) { + await fs.rm(recordRoot, { recursive: true, force: true }); + throw error; + } +} + +async function createRecordDirectory(recordRoot: string, executionId: string): Promise { + try { + await fs.mkdir(recordRoot); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'EEXIST') { + throw new Error(`Release history already exists: ${executionId}`, { cause: error }); + } + throw error; + } +} diff --git a/src/project/releaseHistoryWrite.test.ts b/src/project/releaseHistoryWrite.test.ts new file mode 100644 index 0000000..4a7f5d4 --- /dev/null +++ b/src/project/releaseHistoryWrite.test.ts @@ -0,0 +1,99 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { expect, test } from 'bun:test'; + +import { createReleasePlan } from '../domain/release/createReleasePlan'; +import { createReleaseRevision } from '../domain/release/createReleaseRevision'; +import type { ReleaseDesiredState } from '../domain/release/ReleaseDesiredState'; +import type { ReleaseReconcileResult } from '../domain/release/ReleaseReconcileResult'; +import { expectRejects } from './expectRejects.test'; +import { createTempProject } from './manifestTestSupport.test'; +import { createProjectReleaseHistoryRecord } from './releaseHistory/createProjectReleaseHistoryRecord'; +import { listProjectReleaseHistory } from './releaseHistory/listProjectReleaseHistory'; +import { recordProjectReleaseHistory } from './releaseHistory/recordProjectReleaseHistory'; +import { resolveProjectDeploymentPaths } from './resolveProjectDeploymentPaths'; + +test('records release history immutably and deterministically', async () => { + const projectRoot = await createTempProject(); + try { + await recordProjectReleaseHistory({ + projectRoot, + record: record('002', '2026-08-14T00:02:00.000Z'), + }); + await recordProjectReleaseHistory({ + projectRoot, + record: record('001', '2026-08-14T00:01:00.000Z'), + }); + const records = await listProjectReleaseHistory({ projectRoot }); + expect(records.map((item) => item.executionId)).toEqual(['001', '002']); + } finally { + await fs.rm(projectRoot, { recursive: true, force: true }); + } +}); + +test('duplicate release history write preserves original bytes', async () => { + const projectRoot = await createTempProject(); + const paths = resolveProjectDeploymentPaths(projectRoot); + try { + const value = record('same-id', '2026-08-14T00:00:00.000Z'); + await recordProjectReleaseHistory({ projectRoot, record: value }); + const recordPath = path.join(paths.historyRoot, 'releases', 'same-id', 'release.json'); + const before = await fs.readFile(recordPath, 'utf8'); + await expectRejects( + recordProjectReleaseHistory({ projectRoot, record: value }), + 'already exists', + ); + expect(await fs.readFile(recordPath, 'utf8')).toBe(before); + } finally { + await fs.rm(projectRoot, { recursive: true, force: true }); + } +}); + +test('release history writer rejects secret-like extra payload fields', async () => { + const projectRoot = await createTempProject(); + try { + const value = { ...record('no-secret', '2026-08-14T00:00:00.000Z'), token: 'SECRET' }; + await expectRejects( + recordProjectReleaseHistory({ projectRoot, record: value }), + 'invalid canonical shape', + ); + expect(await listProjectReleaseHistory({ projectRoot })).toEqual([]); + } finally { + await fs.rm(projectRoot, { recursive: true, force: true }); + } +}); + +function record(executionId: string, recordedAt: string) { + const desired = desiredState(); + const initialPlan = createReleasePlan(desired, completedState()); + const result: ReleaseReconcileResult = { + status: 'completed', + plan: initialPlan, + currentRevision: initialPlan.currentRevision, + executedStepIds: [], + }; + return createProjectReleaseHistoryRecord({ + executionId, + recordedAt, + desired, + initialPlan, + result, + }); +} + +function desiredState(): ReleaseDesiredState { + const base = { + version: '2.1.0', + targets: ['web'] as const, + notes: [], + rollout: { web: { mode: 'immediate' as const } }, + }; + return { ...base, revision: createReleaseRevision(base) }; +} + +function completedState() { + return { + targets: [{ target: 'web' as const, version: '2.1.0', artifactRevision: 'web-revision' }], + }; +} diff --git a/src/project/releaseProjectExports.test.ts b/src/project/releaseProjectExports.test.ts new file mode 100644 index 0000000..c90b3f5 --- /dev/null +++ b/src/project/releaseProjectExports.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from 'bun:test'; + +import * as project from './index.js'; + +test('project entrypoint exposes release authoring and immutable history ownership', () => { + expect(typeof project.readProjectRelease).toBe('function'); + expect(typeof project.inspectProjectRelease).toBe('function'); + expect(typeof project.createProjectReleasePlan).toBe('function'); + expect(typeof project.executeProjectRelease).toBe('function'); + expect(typeof project.resumeProjectRelease).toBe('function'); + expect(typeof project.executeProjectReleaseControl).toBe('function'); + expect(typeof project.createProjectReleaseHistoryRecord).toBe('function'); + expect(typeof project.recordProjectReleaseHistory).toBe('function'); + expect(typeof project.readProjectReleaseHistory).toBe('function'); + expect(typeof project.listProjectReleaseHistory).toBe('function'); +}); diff --git a/src/project/web/executeProjectWebDeployment.ts b/src/project/web/executeProjectWebDeployment.ts index ec3b7bf..2b2a47a 100644 --- a/src/project/web/executeProjectWebDeployment.ts +++ b/src/project/web/executeProjectWebDeployment.ts @@ -96,6 +96,7 @@ async function finalizeExecution( plan: options.plan, execution, verification: state.verification, + publication: state.publication, recordedAt: runtime.now().toISOString(), }); return history.recorded diff --git a/src/project/web/readCurrentProjectWebProductionDeployment.ts b/src/project/web/readCurrentProjectWebProductionDeployment.ts new file mode 100644 index 0000000..b130d76 --- /dev/null +++ b/src/project/web/readCurrentProjectWebProductionDeployment.ts @@ -0,0 +1,36 @@ +import type { + DeploymentCurrentState, + DeploymentObservedWebTarget, +} from '../../domain/DeploymentCurrentState'; +import { listProjectDeploymentHistory } from '../history/listProjectDeploymentHistory'; +import type { ProjectDeploymentHistoryRecord } from '../history/ProjectDeploymentHistoryRecord'; + +export async function readCurrentProjectWebProductionDeployment( + projectRoot: string, +): Promise { + const history = await listProjectDeploymentHistory({ projectRoot }); + const [record] = [...history] + .filter(isSuccessfulProductionRecord) + .sort((left, right) => right.recordedAt.localeCompare(left.recordedAt)); + const web = record === undefined ? null : observedWebTarget(record); + return { targets: web === null ? {} : { web } }; +} + +function isSuccessfulProductionRecord(record: ProjectDeploymentHistoryRecord): boolean { + return ( + record.deploymentId.startsWith('web-production-') && + record.execution.status === 'completed' && + record.verification?.ok === true + ); +} + +function observedWebTarget( + record: ProjectDeploymentHistoryRecord, +): DeploymentObservedWebTarget | null { + const desired = record.plan.changes.find((change) => change.target === 'web')?.desired; + if (desired?.target !== 'web' || desired.revision === undefined) return null; + return { + revision: desired.revision, + ...(desired.providers === undefined ? {} : { providers: desired.providers }), + }; +} diff --git a/src/project/web/recordProjectWebDeployment.ts b/src/project/web/recordProjectWebDeployment.ts index 7598a23..4ba7f97 100644 --- a/src/project/web/recordProjectWebDeployment.ts +++ b/src/project/web/recordProjectWebDeployment.ts @@ -2,6 +2,7 @@ import type { DeploymentExecutionResult } from '../../domain/DeploymentExecution import type { DeploymentFailure } from '../../domain/DeploymentFailure'; import type { DeploymentPlan } from '../../domain/DeploymentPlan'; import type { DeploymentVerificationResult } from '../../domain/DeploymentVerificationResult'; +import type { WebDeploymentPublication } from '../../domain/WebDeploymentPublication'; import { PROJECT_DEPLOYMENT_HISTORY_SCHEMA_VERSION } from '../history/historySchemaVersion'; import { recordProjectDeploymentHistory } from '../history/recordProjectDeploymentHistory'; import type { ProjectWebDeploymentInspection } from './ProjectWebDeploymentInspection'; @@ -14,6 +15,7 @@ export async function recordProjectWebDeployment(options: { readonly plan: DeploymentPlan; readonly execution: DeploymentExecutionResult; readonly verification: DeploymentVerificationResult; + readonly publication: WebDeploymentPublication; readonly recordedAt: string; }): Promise { const revision = options.inspection.desiredRevision; @@ -23,7 +25,7 @@ export async function recordProjectWebDeployment(options: { projectRoot: options.inspection.projectRoot, record: { schemaVersion: PROJECT_DEPLOYMENT_HISTORY_SCHEMA_VERSION, - deploymentId: `web-${revision}`, + deploymentId: webDeploymentId(options.publication, revision), recordedAt: options.recordedAt, desired: options.inspection.desired, plan: options.plan, @@ -47,3 +49,8 @@ function historyFailure(code: string): RecordProjectWebHistoryResult { }, }; } + +function webDeploymentId(publication: WebDeploymentPublication, revision: string): string { + const scope = publication.production ? 'production' : 'preview'; + return `web-${scope}-${revision}`; +} diff --git a/src/providers/appStoreConnect/AppStoreReleaseLocalizationResource.ts b/src/providers/appStoreConnect/AppStoreReleaseLocalizationResource.ts new file mode 100644 index 0000000..bdb2218 --- /dev/null +++ b/src/providers/appStoreConnect/AppStoreReleaseLocalizationResource.ts @@ -0,0 +1,5 @@ +export interface AppStoreReleaseLocalizationResource { + readonly resourceId: string; + readonly locale: string; + readonly whatsNew: string | null; +} diff --git a/src/providers/appStoreConnect/AppStoreReleaseSnapshot.ts b/src/providers/appStoreConnect/AppStoreReleaseSnapshot.ts new file mode 100644 index 0000000..da97f2e --- /dev/null +++ b/src/providers/appStoreConnect/AppStoreReleaseSnapshot.ts @@ -0,0 +1,22 @@ +import type { ReleaseNote } from '../../domain/release/ReleaseNote'; + +export interface AppStoreReleaseSnapshot { + readonly appId: string; + readonly version: string; + readonly versionId: string | null; + readonly appVersionState?: string; + readonly releaseType?: string; + readonly releaseNotes: readonly ReleaseNote[]; + readonly reviewSubmission: { + readonly id: string; + readonly state: string; + readonly submittedDate?: string; + } | null; + readonly phasedRelease: { + readonly id: string; + readonly state: 'INACTIVE' | 'ACTIVE' | 'PAUSED' | 'COMPLETE'; + readonly startDate?: string; + readonly currentDayNumber?: number; + readonly totalPauseDuration?: number; + } | null; +} diff --git a/src/providers/appStoreConnect/appStoreReleaseControl.test.ts b/src/providers/appStoreConnect/appStoreReleaseControl.test.ts new file mode 100644 index 0000000..9a6469d --- /dev/null +++ b/src/providers/appStoreConnect/appStoreReleaseControl.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from 'bun:test'; + +import type { AppStoreConnectRequest, AppStoreConnectTransport } from './AppStoreConnectTransport'; +import type { AppStoreReleaseSnapshot } from './AppStoreReleaseSnapshot'; +import { executeAppStoreReleaseControl } from './executeAppStoreReleaseControl'; + +test('App Store pause and resume use phased-release state PATCH', async () => { + const pause = await execute('pause-phased', snapshot('ACTIVE', null)); + expect(pause.result.status).toBe('completed'); + expect(lastBody(pause.requests)).toEqual(phasedBody('phase-1', 'PAUSED')); + + const resume = await execute('resume-phased', snapshot('PAUSED', null)); + expect(resume.result.status).toBe('completed'); + expect(lastBody(resume.requests)).toEqual(phasedBody('phase-1', 'ACTIVE')); +}); + +test('App Store cancellation uses only supported provider resources', async () => { + const phased = await execute('cancel-phased', snapshot('INACTIVE', null)); + expect(lastRequest(phased.requests)).toEqual({ + method: 'DELETE', + url: 'https://api.appstoreconnect.apple.com/v1/appStoreVersionPhasedReleases/phase-1', + }); + + const review = await execute('cancel-review', snapshot(null, 'IN_REVIEW')); + expect(lastBody(review.requests)).toEqual({ + data: { + type: 'reviewSubmissions', + id: 'review-1', + attributes: { canceled: true }, + }, + }); +}); + +async function execute( + action: 'pause-phased' | 'resume-phased' | 'cancel-phased' | 'cancel-review', + releaseSnapshot: AppStoreReleaseSnapshot, +) { + const requests: AppStoreConnectRequest[] = []; + const result = await executeAppStoreReleaseControl({ + control: { target: 'ios', action }, + snapshot: releaseSnapshot, + credentials: [{ provider: 'app-store-connect', id: 'apple', kind: 'api-key' }], + resolveSecret: () => + Promise.resolve(JSON.stringify({ keyId: 'KEY', issuerId: 'ISSUER', privateKey: 'PRIVATE' })), + createToken: () => Promise.resolve('token'), + request: transport(requests), + now: new Date('2026-08-14T00:00:00Z'), + }); + return { result, requests }; +} + +function snapshot( + phasedState: 'INACTIVE' | 'ACTIVE' | 'PAUSED' | null, + reviewState: string | null, +): AppStoreReleaseSnapshot { + return { + appId: 'app-1', + version: '2.1.0', + versionId: 'version-1', + releaseNotes: [], + reviewSubmission: reviewState === null ? null : { id: 'review-1', state: reviewState }, + phasedRelease: phasedState === null ? null : { id: 'phase-1', state: phasedState }, + }; +} + +function phasedBody(id: string, state: 'ACTIVE' | 'PAUSED'): unknown { + return { + data: { + type: 'appStoreVersionPhasedReleases', + id, + attributes: { phasedReleaseState: state }, + }, + }; +} + +function lastRequest( + requests: readonly AppStoreConnectRequest[], +): Pick | null { + const last = requests.at(-1); + return last === undefined ? null : { method: last.method, url: last.url }; +} + +function lastBody(requests: readonly AppStoreConnectRequest[]): unknown { + const last = requests.at(-1); + return last?.body === undefined ? null : (JSON.parse(last.body) as unknown); +} + +function transport(requests: AppStoreConnectRequest[]): AppStoreConnectTransport { + return (request) => { + requests.push(request); + return Promise.resolve({ status: request.method === 'DELETE' ? 204 : 200, body: '{}' }); + }; +} diff --git a/src/providers/appStoreConnect/appStoreReleaseExecution.test.ts b/src/providers/appStoreConnect/appStoreReleaseExecution.test.ts new file mode 100644 index 0000000..31ef1f8 --- /dev/null +++ b/src/providers/appStoreConnect/appStoreReleaseExecution.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from 'bun:test'; + +import type { AppStoreConnectRequest, AppStoreConnectTransport } from './AppStoreConnectTransport'; +import { executeAppStoreReleaseMutation } from './executeAppStoreReleaseMutation'; + +test('immediate iOS intent completes an active phased release explicitly', async () => { + const requests: AppStoreConnectRequest[] = []; + const result = await executeAppStoreReleaseMutation({ + step: { + id: 'ios:rollout', + target: 'ios', + operation: 'rollout', + dependsOn: [], + retry: 'reinspect', + irreversible: true, + }, + desired: { + version: '2.1.0', + targets: ['ios'], + notes: [], + rollout: { ios: { mode: 'immediate' } }, + revision: 'desired', + }, + snapshot: { + appId: 'app-1', + version: '2.1.0', + versionId: 'version-1', + releaseNotes: [], + reviewSubmission: null, + phasedRelease: { id: 'phase-1', state: 'ACTIVE' }, + }, + credentials: [{ provider: 'app-store-connect', id: 'apple', kind: 'api-key' }], + resolveSecret: () => + Promise.resolve(JSON.stringify({ keyId: 'KEY', issuerId: 'ISSUER', privateKey: 'PRIVATE' })), + createToken: () => Promise.resolve('token'), + request: transport(requests), + now: new Date('2026-08-14T00:00:00Z'), + }); + expect(result.status).toBe('completed'); + const patch = requests.find((request) => request.method === 'PATCH'); + expect(patch?.url).toBe( + 'https://api.appstoreconnect.apple.com/v1/appStoreVersionPhasedReleases/phase-1', + ); + expect(parseBody(patch?.body)).toEqual({ + data: { + type: 'appStoreVersionPhasedReleases', + id: 'phase-1', + attributes: { phasedReleaseState: 'COMPLETE' }, + }, + }); +}); + +function transport(requests: AppStoreConnectRequest[]): AppStoreConnectTransport { + return (request) => { + requests.push(request); + return Promise.resolve({ status: 200, body: '{}' }); + }; +} + +function parseBody(body: string | undefined): unknown { + return body === undefined ? null : (JSON.parse(body) as unknown); +} diff --git a/src/providers/appStoreConnect/appStoreReleaseInspection.test.ts b/src/providers/appStoreConnect/appStoreReleaseInspection.test.ts new file mode 100644 index 0000000..850fd5e --- /dev/null +++ b/src/providers/appStoreConnect/appStoreReleaseInspection.test.ts @@ -0,0 +1,157 @@ +import { expect, test } from 'bun:test'; + +import type { AppStoreConnectRequest, AppStoreConnectTransport } from './AppStoreConnectTransport'; +import { inspectAppStoreReleaseState } from './inspectAppStoreReleaseState'; + +const ACCESS = { + bundleIdentifier: 'com.example.app', + version: '2.1.0', + credentials: [{ provider: 'app-store-connect', id: 'apple', kind: 'api-key' }], + resolveSecret: () => + Promise.resolve( + JSON.stringify({ + keyId: 'KEY', + issuerId: 'ISSUER', + privateKey: 'PRIVATE_KEY_SENTINEL', + }), + ), + createToken: () => Promise.resolve('apple-token'), + now: new Date('2026-08-13T12:00:00Z'), +}; + +test('App Store release inspection normalizes notes, review and phased release', async () => { + const requests: AppStoreConnectRequest[] = []; + const result = await inspectAppStoreReleaseState({ + ...ACCESS, + request: createTransport(requests), + }); + expect(result.status).toBe('completed'); + if (result.status !== 'completed') return; + expect(result.state.appVersionState).toBe('WAITING_FOR_REVIEW'); + expect(result.state.releaseNotes).toEqual([{ locale: 'de-CH', text: 'Neu' }]); + expect(result.state.reviewSubmission).toEqual({ + id: 'review-1', + state: 'WAITING_FOR_REVIEW', + submittedDate: '2026-08-13T10:00:00Z', + }); + expect(result.state.phasedRelease).toEqual({ + id: 'phase-1', + state: 'ACTIVE', + currentDayNumber: 3, + }); + expect(requests.every((request) => request.method === 'GET')).toBe(true); + expect(JSON.stringify(result)).not.toContain('PRIVATE_KEY_SENTINEL'); +}); + +test('App Store release inspection stops safely when the version does not exist', async () => { + const requests: AppStoreConnectRequest[] = []; + const result = await inspectAppStoreReleaseState({ + ...ACCESS, + request: createMissingVersionTransport(requests), + }); + expect(result.status).toBe('completed'); + if (result.status !== 'completed') return; + expect(result.state.versionId).toBeNull(); + expect(result.state.reviewSubmission).toBeNull(); + expect(result.state.phasedRelease).toBeNull(); + expect(requests).toHaveLength(2); +}); + +function createTransport(requests: AppStoreConnectRequest[]): AppStoreConnectTransport { + return (request) => { + requests.push(request); + return Promise.resolve(appStoreResponse(request)); + }; +} + +function createMissingVersionTransport( + requests: AppStoreConnectRequest[], +): AppStoreConnectTransport { + return (request) => { + requests.push(request); + if (request.url.includes('/apps?')) return Promise.resolve(appResponse()); + return Promise.resolve(response(200, { data: [] })); + }; +} + +function appStoreResponse(request: AppStoreConnectRequest): { status: number; body: string } { + if (request.url.includes('/apps?')) return appResponse(); + if (request.url.includes('/appStoreVersions?')) return versionResponse(); + if (request.url.includes('/appStoreVersionLocalizations?')) { + return response(200, { + data: [ + { + type: 'appStoreVersionLocalizations', + id: 'loc-1', + attributes: { locale: 'de-ch', whatsNew: 'Neu' }, + }, + ], + }); + } + if (request.url.includes('/reviewSubmissions?')) return reviewResponse(); + if (request.url.includes('/appStoreVersionPhasedRelease?')) return phasedResponse(); + return response(404, {}); +} + +function appResponse(): { status: number; body: string } { + return response(200, { + data: [ + { + type: 'apps', + id: 'app-1', + attributes: { bundleId: 'com.example.app' }, + }, + ], + }); +} + +function versionResponse(): { status: number; body: string } { + return response(200, { + data: [ + { + type: 'appStoreVersions', + id: 'version-1', + attributes: { + platform: 'IOS', + versionString: '2.1.0', + appVersionState: 'WAITING_FOR_REVIEW', + releaseType: 'MANUAL', + }, + }, + ], + }); +} + +function reviewResponse(): { status: number; body: string } { + return response(200, { + data: [ + { + type: 'reviewSubmissions', + id: 'review-1', + attributes: { + state: 'WAITING_FOR_REVIEW', + submittedDate: '2026-08-13T10:00:00Z', + }, + relationships: { + appStoreVersionForReview: { + data: { type: 'appStoreVersions', id: 'version-1' }, + }, + }, + }, + ], + }); +} + +function phasedResponse(): { status: number; body: string } { + return response(200, { + data: { + type: 'appStoreVersionPhasedReleases', + id: 'phase-1', + attributes: { phasedReleaseState: 'ACTIVE', currentDayNumber: 3 }, + }, + }); +} + +function response(status: number, body: unknown): { status: number; body: string } { + return { status, body: JSON.stringify(body) }; +} diff --git a/src/providers/appStoreConnect/appStoreReleaseMutation.test.ts b/src/providers/appStoreConnect/appStoreReleaseMutation.test.ts new file mode 100644 index 0000000..eca3ba7 --- /dev/null +++ b/src/providers/appStoreConnect/appStoreReleaseMutation.test.ts @@ -0,0 +1,138 @@ +import { expect, test } from 'bun:test'; + +import type { AppStoreConnectRequest, AppStoreConnectTransport } from './AppStoreConnectTransport'; +import { createAppStorePhasedRelease } from './createAppStorePhasedRelease'; +import { createAppStoreReviewSubmission } from './createAppStoreReviewSubmission'; +import { createAppStoreReviewSubmissionItem } from './createAppStoreReviewSubmissionItem'; +import { createAppStoreVersionReleaseRequest } from './createAppStoreVersionReleaseRequest'; +import { submitAppStoreReviewSubmission } from './submitAppStoreReviewSubmission'; +import { syncAppStoreReleaseNotes } from './syncAppStoreReleaseNotes'; + +test('App Store release-note sync updates desired locales and preserves provider-only locales', async () => { + const requests: AppStoreConnectRequest[] = []; + const result = await syncAppStoreReleaseNotes({ + versionId: 'version-1', + notes: [ + { locale: 'de-CH', text: 'Neu' }, + { locale: 'en-US', text: 'New' }, + ], + token: 'TOKEN_SENTINEL', + request: noteTransport(requests), + }); + expect(result).toBe(true); + const writes = requests.filter((request) => request.method !== 'GET'); + expect(writes.map((request) => request.method)).toEqual(['PATCH', 'POST']); + expect(writes.some((request) => request.body?.includes('fr-FR'))).toBe(false); + expect(JSON.stringify(writes.map((request) => request.body))).not.toContain('TOKEN_SENTINEL'); +}); + +test('App Store review submission uses current review-submission resources', async () => { + const requests: AppStoreConnectRequest[] = []; + const request = successTransport(requests); + const reviewSubmissionId = await createAppStoreReviewSubmission({ + appId: 'app-1', + token: 'token', + request, + }); + expect(reviewSubmissionId).toBe('review-1'); + if (reviewSubmissionId === null) return; + expect( + await createAppStoreReviewSubmissionItem({ + reviewSubmissionId, + versionId: 'version-1', + token: 'token', + request, + }), + ).toBe(true); + expect( + await submitAppStoreReviewSubmission({ + reviewSubmissionId, + token: 'token', + request, + }), + ).toBe(true); + expect(requests.map((item) => `${item.method} ${item.url}`)).toEqual([ + 'POST https://api.appstoreconnect.apple.com/v1/reviewSubmissions', + 'POST https://api.appstoreconnect.apple.com/v1/reviewSubmissionItems', + 'PATCH https://api.appstoreconnect.apple.com/v1/reviewSubmissions/review-1', + ]); + const itemBody = parseBody(requests[1]?.body); + expect(itemBody).toEqual({ + data: { + type: 'reviewSubmissionItems', + relationships: { + reviewSubmission: { + data: { type: 'reviewSubmissions', id: 'review-1' }, + }, + appStoreVersion: { + data: { type: 'appStoreVersions', id: 'version-1' }, + }, + }, + }, + }); +}); + +test('App Store phased and manual release requests use separate resources', async () => { + const requests: AppStoreConnectRequest[] = []; + const request = successTransport(requests); + expect( + await createAppStorePhasedRelease({ + versionId: 'version-1', + token: 'token', + request, + }), + ).toBe(true); + expect( + await createAppStoreVersionReleaseRequest({ + versionId: 'version-1', + token: 'token', + request, + }), + ).toBe(true); + expect(requests.map((item) => `${item.method} ${item.url}`)).toEqual([ + 'POST https://api.appstoreconnect.apple.com/v1/appStoreVersionPhasedReleases', + 'POST https://api.appstoreconnect.apple.com/v1/appStoreVersionReleaseRequests', + ]); +}); + +function noteTransport(requests: AppStoreConnectRequest[]): AppStoreConnectTransport { + return (request) => { + requests.push(request); + if (request.method === 'GET') { + return Promise.resolve( + response(200, { + data: [localization('de-1', 'de-CH', 'Alt'), localization('fr-1', 'fr-FR', 'Conserver')], + }), + ); + } + return Promise.resolve(response(request.method === 'POST' ? 201 : 200, {})); + }; +} + +function successTransport(requests: AppStoreConnectRequest[]): AppStoreConnectTransport { + return (request) => { + requests.push(request); + if (request.url.endsWith('/reviewSubmissions') && request.method === 'POST') { + return Promise.resolve( + response(201, { data: { type: 'reviewSubmissions', id: 'review-1' } }), + ); + } + return Promise.resolve(response(request.method === 'POST' ? 201 : 200, {})); + }; +} + +function localization(id: string, locale: string, whatsNew: string): unknown { + return { + type: 'appStoreVersionLocalizations', + id, + attributes: { locale, whatsNew }, + }; +} + +function parseBody(body: string | undefined): unknown { + return body === undefined ? null : (JSON.parse(body) as unknown); +} + +function response(status: number, body: unknown): { status: number; body: string } { + return { status, body: JSON.stringify(body) }; +} diff --git a/src/providers/appStoreConnect/appStoreReleaseUrls.ts b/src/providers/appStoreConnect/appStoreReleaseUrls.ts new file mode 100644 index 0000000..ecf8f2a --- /dev/null +++ b/src/providers/appStoreConnect/appStoreReleaseUrls.ts @@ -0,0 +1,69 @@ +const BASE = 'https://api.appstoreconnect.apple.com/v1'; + +export function appStoreReleaseVersionsUrl(appId: string): string { + const query = new URLSearchParams({ + 'filter[platform]': 'IOS', + 'fields[appStoreVersions]': 'platform,versionString,appVersionState,releaseType', + limit: '200', + }); + return `${BASE}/apps/${encodeURIComponent(appId)}/appStoreVersions?${query.toString()}`; +} + +export function appStoreReleaseNotesUrl(versionId: string): string { + const query = new URLSearchParams({ + 'fields[appStoreVersionLocalizations]': 'locale,whatsNew', + limit: '200', + }); + return `${BASE}/appStoreVersions/${encodeURIComponent(versionId)}/appStoreVersionLocalizations?${query.toString()}`; +} + +export function appStoreReviewSubmissionsUrl(appId: string): string { + const query = new URLSearchParams({ + 'filter[platform]': 'IOS', + 'fields[reviewSubmissions]': 'platform,submittedDate,state,appStoreVersionForReview', + 'fields[appStoreVersions]': 'versionString,appVersionState', + include: 'appStoreVersionForReview', + limit: '200', + }); + return `${BASE}/apps/${encodeURIComponent(appId)}/reviewSubmissions?${query.toString()}`; +} + +export function appStorePhasedReleaseUrl(versionId: string): string { + const query = new URLSearchParams({ + 'fields[appStoreVersionPhasedReleases]': + 'phasedReleaseState,startDate,totalPauseDuration,currentDayNumber', + }); + return `${BASE}/appStoreVersions/${encodeURIComponent(versionId)}/appStoreVersionPhasedRelease?${query.toString()}`; +} + +export function appStoreVersionLocalizationsUrl(): string { + return `${BASE}/appStoreVersionLocalizations`; +} + +export function appStoreVersionLocalizationUrl(localizationId: string): string { + return `${BASE}/appStoreVersionLocalizations/${encodeURIComponent(localizationId)}`; +} + +export function appStoreReviewSubmissionsCollectionUrl(): string { + return `${BASE}/reviewSubmissions`; +} + +export function appStoreReviewSubmissionUrl(reviewSubmissionId: string): string { + return `${BASE}/reviewSubmissions/${encodeURIComponent(reviewSubmissionId)}`; +} + +export function appStoreReviewSubmissionItemsUrl(): string { + return `${BASE}/reviewSubmissionItems`; +} + +export function appStorePhasedReleasesUrl(): string { + return `${BASE}/appStoreVersionPhasedReleases`; +} + +export function appStoreVersionReleaseRequestsUrl(): string { + return `${BASE}/appStoreVersionReleaseRequests`; +} + +export function appStorePhasedReleaseMutationUrl(phasedReleaseId: string): string { + return `${BASE}/appStoreVersionPhasedReleases/${encodeURIComponent(phasedReleaseId)}`; +} diff --git a/src/providers/appStoreConnect/cancelAppStoreReviewSubmission.ts b/src/providers/appStoreConnect/cancelAppStoreReviewSubmission.ts new file mode 100644 index 0000000..88560f6 --- /dev/null +++ b/src/providers/appStoreConnect/cancelAppStoreReviewSubmission.ts @@ -0,0 +1,22 @@ +import type { AppStoreConnectTransport } from './AppStoreConnectTransport'; +import { appStoreReviewSubmissionUrl } from './appStoreReleaseUrls'; + +export async function cancelAppStoreReviewSubmission(options: { + readonly reviewSubmissionId: string; + readonly token: string; + readonly request: AppStoreConnectTransport; +}): Promise { + const response = await options.request({ + method: 'PATCH', + url: appStoreReviewSubmissionUrl(options.reviewSubmissionId), + token: options.token, + body: JSON.stringify({ + data: { + type: 'reviewSubmissions', + id: options.reviewSubmissionId, + attributes: { canceled: true }, + }, + }), + }); + return response.status >= 200 && response.status < 300; +} diff --git a/src/providers/appStoreConnect/completeAppStorePhasedRelease.ts b/src/providers/appStoreConnect/completeAppStorePhasedRelease.ts new file mode 100644 index 0000000..2ea4f71 --- /dev/null +++ b/src/providers/appStoreConnect/completeAppStorePhasedRelease.ts @@ -0,0 +1,22 @@ +import type { AppStoreConnectTransport } from './AppStoreConnectTransport'; +import { appStorePhasedReleaseMutationUrl } from './appStoreReleaseUrls'; + +export async function completeAppStorePhasedRelease(options: { + readonly phasedReleaseId: string; + readonly token: string; + readonly request: AppStoreConnectTransport; +}): Promise { + const response = await options.request({ + method: 'PATCH', + url: appStorePhasedReleaseMutationUrl(options.phasedReleaseId), + token: options.token, + body: JSON.stringify({ + data: { + type: 'appStoreVersionPhasedReleases', + id: options.phasedReleaseId, + attributes: { phasedReleaseState: 'COMPLETE' }, + }, + }), + }); + return response.status >= 200 && response.status < 300; +} diff --git a/src/providers/appStoreConnect/createAppStorePhasedRelease.ts b/src/providers/appStoreConnect/createAppStorePhasedRelease.ts new file mode 100644 index 0000000..fe638a3 --- /dev/null +++ b/src/providers/appStoreConnect/createAppStorePhasedRelease.ts @@ -0,0 +1,26 @@ +import type { AppStoreConnectTransport } from './AppStoreConnectTransport'; +import { appStorePhasedReleasesUrl } from './appStoreReleaseUrls'; + +export async function createAppStorePhasedRelease(options: { + readonly versionId: string; + readonly token: string; + readonly request: AppStoreConnectTransport; +}): Promise { + const response = await options.request({ + method: 'POST', + url: appStorePhasedReleasesUrl(), + token: options.token, + body: JSON.stringify({ + data: { + type: 'appStoreVersionPhasedReleases', + attributes: { phasedReleaseState: 'INACTIVE' }, + relationships: { + appStoreVersion: { + data: { type: 'appStoreVersions', id: options.versionId }, + }, + }, + }, + }), + }); + return response.status >= 200 && response.status < 300; +} diff --git a/src/providers/appStoreConnect/createAppStoreReviewSubmission.ts b/src/providers/appStoreConnect/createAppStoreReviewSubmission.ts new file mode 100644 index 0000000..6c46da9 --- /dev/null +++ b/src/providers/appStoreConnect/createAppStoreReviewSubmission.ts @@ -0,0 +1,42 @@ +import type { AppStoreConnectTransport } from './AppStoreConnectTransport'; +import { appStoreReviewSubmissionsCollectionUrl } from './appStoreReleaseUrls'; + +export async function createAppStoreReviewSubmission(options: { + readonly appId: string; + readonly token: string; + readonly request: AppStoreConnectTransport; +}): Promise { + const response = await options.request({ + method: 'POST', + url: appStoreReviewSubmissionsCollectionUrl(), + token: options.token, + body: JSON.stringify({ + data: { + type: 'reviewSubmissions', + relationships: { + app: { data: { type: 'apps', id: options.appId } }, + }, + }, + }), + }); + if (response.status < 200 || response.status >= 300) return null; + return parseCreatedId(response.body, 'reviewSubmissions'); +} + +function parseCreatedId(body: string, type: string): string | null { + try { + const value: unknown = JSON.parse(body); + if (!isRecord(value) || !isRecord(value.data)) return null; + return value.data.type === type && isNonEmptyString(value.data.id) ? value.data.id : null; + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} diff --git a/src/providers/appStoreConnect/createAppStoreReviewSubmissionItem.ts b/src/providers/appStoreConnect/createAppStoreReviewSubmissionItem.ts new file mode 100644 index 0000000..421c258 --- /dev/null +++ b/src/providers/appStoreConnect/createAppStoreReviewSubmissionItem.ts @@ -0,0 +1,29 @@ +import type { AppStoreConnectTransport } from './AppStoreConnectTransport'; +import { appStoreReviewSubmissionItemsUrl } from './appStoreReleaseUrls'; + +export async function createAppStoreReviewSubmissionItem(options: { + readonly reviewSubmissionId: string; + readonly versionId: string; + readonly token: string; + readonly request: AppStoreConnectTransport; +}): Promise { + const response = await options.request({ + method: 'POST', + url: appStoreReviewSubmissionItemsUrl(), + token: options.token, + body: JSON.stringify({ + data: { + type: 'reviewSubmissionItems', + relationships: { + reviewSubmission: { + data: { type: 'reviewSubmissions', id: options.reviewSubmissionId }, + }, + appStoreVersion: { + data: { type: 'appStoreVersions', id: options.versionId }, + }, + }, + }, + }), + }); + return response.status >= 200 && response.status < 300; +} diff --git a/src/providers/appStoreConnect/createAppStoreVersionReleaseRequest.ts b/src/providers/appStoreConnect/createAppStoreVersionReleaseRequest.ts new file mode 100644 index 0000000..a21dbed --- /dev/null +++ b/src/providers/appStoreConnect/createAppStoreVersionReleaseRequest.ts @@ -0,0 +1,25 @@ +import type { AppStoreConnectTransport } from './AppStoreConnectTransport'; +import { appStoreVersionReleaseRequestsUrl } from './appStoreReleaseUrls'; + +export async function createAppStoreVersionReleaseRequest(options: { + readonly versionId: string; + readonly token: string; + readonly request: AppStoreConnectTransport; +}): Promise { + const response = await options.request({ + method: 'POST', + url: appStoreVersionReleaseRequestsUrl(), + token: options.token, + body: JSON.stringify({ + data: { + type: 'appStoreVersionReleaseRequests', + relationships: { + appStoreVersion: { + data: { type: 'appStoreVersions', id: options.versionId }, + }, + }, + }, + }), + }); + return response.status >= 200 && response.status < 300; +} diff --git a/src/providers/appStoreConnect/deleteAppStorePhasedRelease.ts b/src/providers/appStoreConnect/deleteAppStorePhasedRelease.ts new file mode 100644 index 0000000..5042a17 --- /dev/null +++ b/src/providers/appStoreConnect/deleteAppStorePhasedRelease.ts @@ -0,0 +1,15 @@ +import type { AppStoreConnectTransport } from './AppStoreConnectTransport'; +import { appStorePhasedReleaseMutationUrl } from './appStoreReleaseUrls'; + +export async function deleteAppStorePhasedRelease(options: { + readonly phasedReleaseId: string; + readonly token: string; + readonly request: AppStoreConnectTransport; +}): Promise { + const response = await options.request({ + method: 'DELETE', + url: appStorePhasedReleaseMutationUrl(options.phasedReleaseId), + token: options.token, + }); + return response.status >= 200 && response.status < 300; +} diff --git a/src/providers/appStoreConnect/executeAppStoreReleaseControl.ts b/src/providers/appStoreConnect/executeAppStoreReleaseControl.ts new file mode 100644 index 0000000..3fd703a --- /dev/null +++ b/src/providers/appStoreConnect/executeAppStoreReleaseControl.ts @@ -0,0 +1,92 @@ +import type { DeploymentCredentialReference } from '../../domain/DeploymentCredentialReference'; +import type { DeploymentSecretResolver } from '../../domain/DeploymentSecretResolver'; +import type { ReleaseLifecycleControl } from '../../domain/release/ReleaseLifecycleControl'; +import type { ReleaseMutationResult } from '../../engine/release/ReleaseMutationResult'; +import type { AppStoreConnectTokenFactory } from './AppStoreConnectTokenFactory'; +import type { AppStoreConnectTransport } from './AppStoreConnectTransport'; +import type { AppStoreReleaseSnapshot } from './AppStoreReleaseSnapshot'; +import { cancelAppStoreReviewSubmission } from './cancelAppStoreReviewSubmission'; +import { deleteAppStorePhasedRelease } from './deleteAppStorePhasedRelease'; +import { resolveAppStoreConnectToken } from './resolveAppStoreConnectToken'; +import { updateAppStorePhasedReleaseState } from './updateAppStorePhasedReleaseState'; + +type IosControl = Extract; + +export async function executeAppStoreReleaseControl(options: { + readonly control: IosControl; + readonly snapshot: AppStoreReleaseSnapshot; + readonly credentials: readonly DeploymentCredentialReference[]; + readonly resolveSecret: DeploymentSecretResolver; + readonly createToken: AppStoreConnectTokenFactory; + readonly request: AppStoreConnectTransport; + readonly now: Date; +}): Promise { + if (!controlAvailable(options.control, options.snapshot)) { + return failed('APP_STORE_RELEASE_CONTROL_UNAVAILABLE'); + } + const access = await resolveAppStoreConnectToken(options); + if (!access.ok) return { status: 'blocked', code: access.action.code }; + return executeWithToken(options, access.token); +} + +function executeWithToken( + options: Parameters[0], + token: string, +): Promise { + const phased = options.snapshot.phasedRelease; + if (options.control.action === 'pause-phased' && phased !== null) { + return booleanResult( + updateAppStorePhasedReleaseState({ + phasedReleaseId: phased.id, + state: 'PAUSED', + token, + request: options.request, + }), + ); + } + if (options.control.action === 'resume-phased' && phased !== null) { + return booleanResult( + updateAppStorePhasedReleaseState({ + phasedReleaseId: phased.id, + state: 'ACTIVE', + token, + request: options.request, + }), + ); + } + if (options.control.action === 'cancel-phased' && phased !== null) { + return booleanResult( + deleteAppStorePhasedRelease({ + phasedReleaseId: phased.id, + token, + request: options.request, + }), + ); + } + const review = options.snapshot.reviewSubmission; + if (options.control.action === 'cancel-review' && review !== null) { + return booleanResult( + cancelAppStoreReviewSubmission({ + reviewSubmissionId: review.id, + token, + request: options.request, + }), + ); + } + return Promise.resolve(failed('APP_STORE_RELEASE_CONTROL_UNAVAILABLE')); +} + +function controlAvailable(control: IosControl, snapshot: AppStoreReleaseSnapshot): boolean { + if (control.action === 'pause-phased') return snapshot.phasedRelease?.state === 'ACTIVE'; + if (control.action === 'resume-phased') return snapshot.phasedRelease?.state === 'PAUSED'; + if (control.action === 'cancel-phased') return snapshot.phasedRelease?.state === 'INACTIVE'; + return snapshot.reviewSubmission?.state === 'IN_REVIEW'; +} + +async function booleanResult(operation: Promise): Promise { + return (await operation) ? { status: 'completed' } : failed('APP_STORE_RELEASE_CONTROL_FAILED'); +} + +function failed(code: string): ReleaseMutationResult { + return { status: 'failed', code }; +} diff --git a/src/providers/appStoreConnect/executeAppStoreReleaseMutation.ts b/src/providers/appStoreConnect/executeAppStoreReleaseMutation.ts new file mode 100644 index 0000000..cf1b82e --- /dev/null +++ b/src/providers/appStoreConnect/executeAppStoreReleaseMutation.ts @@ -0,0 +1,142 @@ +import type { DeploymentCredentialReference } from '../../domain/DeploymentCredentialReference'; +import type { DeploymentSecretResolver } from '../../domain/DeploymentSecretResolver'; +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import type { ReleasePlanStep } from '../../domain/release/ReleasePlanStep'; +import type { ReleaseMutationResult } from '../../engine/release/ReleaseMutationResult'; +import type { AppStoreConnectTokenFactory } from './AppStoreConnectTokenFactory'; +import type { AppStoreConnectTransport } from './AppStoreConnectTransport'; +import type { AppStoreReleaseSnapshot } from './AppStoreReleaseSnapshot'; +import { completeAppStorePhasedRelease } from './completeAppStorePhasedRelease'; +import { createAppStorePhasedRelease } from './createAppStorePhasedRelease'; +import { createAppStoreReviewSubmission } from './createAppStoreReviewSubmission'; +import { createAppStoreReviewSubmissionItem } from './createAppStoreReviewSubmissionItem'; +import { createAppStoreVersionReleaseRequest } from './createAppStoreVersionReleaseRequest'; +import { resolveAppStoreConnectToken } from './resolveAppStoreConnectToken'; +import { submitAppStoreReviewSubmission } from './submitAppStoreReviewSubmission'; +import { syncAppStoreReleaseNotes } from './syncAppStoreReleaseNotes'; + +export async function executeAppStoreReleaseMutation(options: { + readonly step: ReleasePlanStep; + readonly desired: ReleaseDesiredState; + readonly snapshot: AppStoreReleaseSnapshot; + readonly credentials: readonly DeploymentCredentialReference[]; + readonly resolveSecret: DeploymentSecretResolver; + readonly createToken: AppStoreConnectTokenFactory; + readonly request: AppStoreConnectTransport; + readonly now: Date; +}): Promise { + if (options.step.target !== 'ios') return failed('APP_STORE_RELEASE_STEP_UNSUPPORTED'); + if (options.snapshot.versionId === null) return failed('APP_STORE_RELEASE_VERSION_REQUIRED'); + const access = await resolveAppStoreConnectToken(options); + if (!access.ok) return blocked(access.action.code); + return executeWithToken(options, access.token, options.snapshot.versionId); +} + +function executeWithToken( + options: Parameters[0], + token: string, + versionId: string, +): Promise { + switch (options.step.operation) { + case 'sync-notes': + return noteResult(options, token, versionId); + case 'rollout': + return executeRollout(options, token, versionId); + case 'submit-review': + return executeReviewSubmission(options, token, versionId); + case 'release': + return releaseResult(options, token, versionId); + default: + return Promise.resolve(failed('APP_STORE_RELEASE_STEP_UNSUPPORTED')); + } +} + +function noteResult( + options: Parameters[0], + token: string, + versionId: string, +): Promise { + return booleanResult( + syncAppStoreReleaseNotes({ + versionId, + notes: options.desired.notes, + token, + request: options.request, + }), + 'APP_STORE_RELEASE_NOTES_UPDATE_FAILED', + ); +} + +function releaseResult( + options: Parameters[0], + token: string, + versionId: string, +): Promise { + return booleanResult( + createAppStoreVersionReleaseRequest({ versionId, token, request: options.request }), + 'APP_STORE_RELEASE_REQUEST_FAILED', + ); +} + +async function executeRollout( + options: Parameters[0], + token: string, + versionId: string, +): Promise { + const mode = options.desired.rollout.ios?.mode ?? 'immediate'; + if (mode === 'staged') { + return booleanResult( + createAppStorePhasedRelease({ versionId, token, request: options.request }), + 'APP_STORE_PHASED_RELEASE_CREATE_FAILED', + ); + } + const phased = options.snapshot.phasedRelease; + if (phased === null) return failed('APP_STORE_PHASED_RELEASE_REQUIRED'); + return booleanResult( + completeAppStorePhasedRelease({ + phasedReleaseId: phased.id, + token, + request: options.request, + }), + 'APP_STORE_PHASED_RELEASE_COMPLETE_FAILED', + ); +} + +async function executeReviewSubmission( + options: Parameters[0], + token: string, + versionId: string, +): Promise { + const reviewSubmissionId = await createAppStoreReviewSubmission({ + appId: options.snapshot.appId, + token, + request: options.request, + }); + if (reviewSubmissionId === null) return failed('APP_STORE_REVIEW_SUBMISSION_CREATE_FAILED'); + const item = await createAppStoreReviewSubmissionItem({ + reviewSubmissionId, + versionId, + token, + request: options.request, + }); + if (!item) return failed('APP_STORE_REVIEW_ITEM_CREATE_FAILED'); + return booleanResult( + submitAppStoreReviewSubmission({ reviewSubmissionId, token, request: options.request }), + 'APP_STORE_REVIEW_SUBMIT_FAILED', + ); +} + +async function booleanResult( + operation: Promise, + code: string, +): Promise { + return (await operation) ? { status: 'completed' } : failed(code); +} + +function blocked(code: string): ReleaseMutationResult { + return { status: 'blocked', code }; +} + +function failed(code: string): ReleaseMutationResult { + return { status: 'failed', code }; +} diff --git a/src/providers/appStoreConnect/inspectAppStoreReleaseState.ts b/src/providers/appStoreConnect/inspectAppStoreReleaseState.ts new file mode 100644 index 0000000..c22f1e7 --- /dev/null +++ b/src/providers/appStoreConnect/inspectAppStoreReleaseState.ts @@ -0,0 +1,211 @@ +import type { DeploymentCredentialReference } from '../../domain/DeploymentCredentialReference'; +import type { DeploymentFailure } from '../../domain/DeploymentFailure'; +import type { DeploymentRequiredAction } from '../../domain/DeploymentRequiredAction'; +import type { DeploymentSecretResolver } from '../../domain/DeploymentSecretResolver'; +import { trackAppStoreConnectRequests } from './AppStoreConnectRequestTracker'; +import type { AppStoreConnectTokenFactory } from './AppStoreConnectTokenFactory'; +import type { AppStoreConnectTransport } from './AppStoreConnectTransport'; +import { appStoreConnectAppsUrl } from './appStoreConnectUrls'; +import type { AppStoreReleaseSnapshot } from './AppStoreReleaseSnapshot'; +import { + appStorePhasedReleaseUrl, + appStoreReleaseNotesUrl, + appStoreReleaseVersionsUrl, + appStoreReviewSubmissionsUrl, +} from './appStoreReleaseUrls'; +import { parseAppStoreAppId } from './parseAppStoreAppId'; +import { parseAppStorePhasedRelease } from './parseAppStorePhasedRelease'; +import { parseAppStoreReleaseNotes } from './parseAppStoreReleaseNotes'; +import { parseAppStoreReleaseVersion } from './parseAppStoreReleaseVersion'; +import { parseAppStoreReviewSubmission } from './parseAppStoreReviewSubmission'; +import { readAppStoreReleaseJson } from './readAppStoreReleaseJson'; +import { resolveAppStoreConnectToken } from './resolveAppStoreConnectToken'; + +type AppStoreReleaseInspectionResult = + | { readonly status: 'completed'; readonly state: AppStoreReleaseSnapshot } + | { readonly status: 'action-required'; readonly action: DeploymentRequiredAction } + | { readonly status: 'failed'; readonly failure: DeploymentFailure }; + +export async function inspectAppStoreReleaseState(options: { + readonly bundleIdentifier: string; + readonly version: string; + readonly credentials: readonly DeploymentCredentialReference[]; + readonly resolveSecret: DeploymentSecretResolver; + readonly createToken: AppStoreConnectTokenFactory; + readonly request: AppStoreConnectTransport; + readonly now: Date; +}): Promise { + const access = await resolveAppStoreConnectToken(options); + if (!access.ok) return { status: 'action-required', action: access.action }; + const tracker = trackAppStoreConnectRequests(options.request); + const appId = await readAppId(options, access.token, tracker.request); + if (appId === false) return providerFailure(tracker.blockingStatus()); + if (appId === null) return appRequired(); + const version = await readVersion(options, appId, access.token, tracker.request); + if (version === 'failed') return providerFailure(tracker.blockingStatus()); + if (version === null) return emptySnapshot(appId, options.version); + return readVersionDetails(options, appId, version, access.token, tracker.request); +} + +async function readAppId( + options: Parameters[0], + token: string, + request: AppStoreConnectTransport, +): Promise { + const result = await readAppStoreReleaseJson({ + url: appStoreConnectAppsUrl(options.bundleIdentifier), + token, + request, + }); + if (result === null || result.status < 200 || result.status >= 300) return false; + return parseAppStoreAppId(result.value, options.bundleIdentifier); +} + +async function readVersion( + options: Parameters[0], + appId: string, + token: string, + request: AppStoreConnectTransport, +): Promise< + | { readonly versionId: string; readonly appVersionState: string; readonly releaseType?: string } + | null + | 'failed' +> { + const result = await readAppStoreReleaseJson({ + url: appStoreReleaseVersionsUrl(appId), + token, + request, + }); + if (result === null || result.status < 200 || result.status >= 300) return 'failed'; + const parsed = parseAppStoreReleaseVersion(result.value, options.version); + return parsed === undefined ? 'failed' : parsed; +} + +async function readVersionDetails( + options: Parameters[0], + appId: string, + version: { + readonly versionId: string; + readonly appVersionState: string; + readonly releaseType?: string; + }, + token: string, + request: AppStoreConnectTransport, +): Promise { + const notes = await readNotes(version.versionId, token, request); + if (notes === null) return failed('APP_STORE_RELEASE_RESPONSE_INVALID'); + const review = await readReview(appId, version.versionId, token, request); + if (review === undefined) return failed('APP_STORE_RELEASE_RESPONSE_INVALID'); + const phased = await readPhased(version.versionId, token, request); + if (phased === undefined) return failed('APP_STORE_RELEASE_RESPONSE_INVALID'); + return { + status: 'completed', + state: { + appId, + version: options.version, + versionId: version.versionId, + appVersionState: version.appVersionState, + ...(version.releaseType === undefined ? {} : { releaseType: version.releaseType }), + releaseNotes: notes, + reviewSubmission: review, + phasedRelease: phased, + }, + }; +} + +async function readNotes( + versionId: string, + token: string, + request: AppStoreConnectTransport, +): Promise { + const result = await readAppStoreReleaseJson({ + url: appStoreReleaseNotesUrl(versionId), + token, + request, + }); + if (result === null || result.status < 200 || result.status >= 300) return null; + return parseAppStoreReleaseNotes(result.value); +} + +async function readReview( + appId: string, + versionId: string, + token: string, + request: AppStoreConnectTransport, +): Promise { + const result = await readAppStoreReleaseJson({ + url: appStoreReviewSubmissionsUrl(appId), + token, + request, + }); + if (result === null || result.status < 200 || result.status >= 300) return undefined; + return parseAppStoreReviewSubmission(result.value, versionId); +} + +async function readPhased( + versionId: string, + token: string, + request: AppStoreConnectTransport, +): Promise { + const result = await readAppStoreReleaseJson({ + url: appStorePhasedReleaseUrl(versionId), + token, + request, + }); + if (result === null) return undefined; + if (result.status === 404) return null; + if (result.status < 200 || result.status >= 300) return undefined; + return parseAppStorePhasedRelease(result.value); +} + +function emptySnapshot(appId: string, version: string): AppStoreReleaseInspectionResult { + return { + status: 'completed', + state: { + appId, + version, + versionId: null, + releaseNotes: [], + reviewSubmission: null, + phasedRelease: null, + }, + }; +} + +function providerFailure(status: 401 | 403 | null): AppStoreReleaseInspectionResult { + if (status === 401) return action('authentication', 'APP_STORE_CONNECT_AUTHENTICATION_REQUIRED'); + if (status === 403) return action('manual-action', 'APP_STORE_CONNECT_PERMISSION_REQUIRED'); + return failed('APP_STORE_RELEASE_INSPECTION_FAILED'); +} + +function appRequired(): AppStoreReleaseInspectionResult { + return action('manual-action', 'APP_STORE_APP_REQUIRED'); +} + +function action( + type: 'authentication' | 'manual-action', + code: string, +): AppStoreReleaseInspectionResult { + return { + status: 'action-required', + action: { + type, + provider: 'app-store-connect', + target: 'ios', + code, + message: 'App Store release inspection requires provider action.', + }, + }; +} + +function failed(code: string): AppStoreReleaseInspectionResult { + return { + status: 'failed', + failure: { + code, + message: 'App Store release state could not be inspected.', + target: 'ios', + provider: 'app-store-connect', + }, + }; +} diff --git a/src/providers/appStoreConnect/normalizeAppStoreReleaseObservation.ts b/src/providers/appStoreConnect/normalizeAppStoreReleaseObservation.ts new file mode 100644 index 0000000..b862c24 --- /dev/null +++ b/src/providers/appStoreConnect/normalizeAppStoreReleaseObservation.ts @@ -0,0 +1,39 @@ +import type { IosDeploymentPublication } from '../../domain/IosDeploymentPublication'; +import type { ReleaseObservedIosState } from '../../domain/release/ReleaseObservedIosState'; +import type { AppStoreReleaseSnapshot } from './AppStoreReleaseSnapshot'; + +type IosReleasePublication = + IosDeploymentPublication | Pick; + +export function normalizeAppStoreReleaseObservation(options: { + readonly publication: IosReleasePublication | null; + readonly publicationVerified: boolean; + readonly snapshot: AppStoreReleaseSnapshot; +}): ReleaseObservedIosState { + const publication = matchingPublication(options); + return { + target: 'ios', + version: options.snapshot.versionId === null ? null : options.snapshot.version, + artifactRevision: publication?.revision ?? null, + buildNumber: publication?.buildNumber ?? null, + releaseNotes: options.snapshot.releaseNotes, + ...(options.snapshot.appVersionState === undefined + ? {} + : { appVersionState: options.snapshot.appVersionState }), + ...(options.snapshot.releaseType === undefined + ? {} + : { releaseType: options.snapshot.releaseType }), + ...(options.snapshot.reviewSubmission === null + ? {} + : { reviewState: options.snapshot.reviewSubmission.state }), + phasedReleaseState: options.snapshot.phasedRelease?.state ?? null, + }; +} + +function matchingPublication( + options: Parameters[0], +): IosReleasePublication | null { + if (!options.publicationVerified || options.publication === null) return null; + if (options.snapshot.versionId === null) return null; + return options.publication.version === options.snapshot.version ? options.publication : null; +} diff --git a/src/providers/appStoreConnect/parseAppStorePhasedRelease.ts b/src/providers/appStoreConnect/parseAppStorePhasedRelease.ts new file mode 100644 index 0000000..424ee61 --- /dev/null +++ b/src/providers/appStoreConnect/parseAppStorePhasedRelease.ts @@ -0,0 +1,51 @@ +import type { AppStoreReleaseSnapshot } from './AppStoreReleaseSnapshot'; + +const STATES = new Set(['INACTIVE', 'ACTIVE', 'PAUSED', 'COMPLETE']); + +export function parseAppStorePhasedRelease( + value: unknown, +): AppStoreReleaseSnapshot['phasedRelease'] | undefined { + if (!isRecord(value) || !isRecord(value.data)) return undefined; + const { data } = value; + if (data.type !== 'appStoreVersionPhasedReleases' || !isNonEmptyString(data.id)) { + return undefined; + } + if (!isRecord(data.attributes) || !isState(data.attributes.phasedReleaseState)) { + return undefined; + } + if (!validOptionalNumber(data.attributes.currentDayNumber)) return undefined; + if (!validOptionalNumber(data.attributes.totalPauseDuration)) return undefined; + return { + id: data.id, + state: data.attributes.phasedReleaseState, + ...(isNonEmptyString(data.attributes.startDate) + ? { startDate: data.attributes.startDate } + : {}), + ...(typeof data.attributes.currentDayNumber === 'number' + ? { currentDayNumber: data.attributes.currentDayNumber } + : {}), + ...(typeof data.attributes.totalPauseDuration === 'number' + ? { totalPauseDuration: data.attributes.totalPauseDuration } + : {}), + }; +} + +function isState( + value: unknown, +): value is NonNullable['state'] { + return typeof value === 'string' && STATES.has(value); +} + +function validOptionalNumber(value: unknown): boolean { + return ( + value === undefined || value === null || (typeof value === 'number' && Number.isFinite(value)) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/src/providers/appStoreConnect/parseAppStoreReleaseLocalizations.ts b/src/providers/appStoreConnect/parseAppStoreReleaseLocalizations.ts new file mode 100644 index 0000000..744cbd3 --- /dev/null +++ b/src/providers/appStoreConnect/parseAppStoreReleaseLocalizations.ts @@ -0,0 +1,41 @@ +import type { AppStoreReleaseLocalizationResource } from './AppStoreReleaseLocalizationResource'; + +export function parseAppStoreReleaseLocalizations( + value: unknown, +): readonly AppStoreReleaseLocalizationResource[] | null { + if (!isRecord(value) || !Array.isArray(value.data)) return null; + const result: AppStoreReleaseLocalizationResource[] = []; + for (const item of value.data as unknown[]) { + const localization = parseLocalization(item); + if (localization === null) return null; + result.push(localization); + } + return result.sort((left, right) => left.locale.localeCompare(right.locale)); +} + +function parseLocalization(value: unknown): AppStoreReleaseLocalizationResource | null { + if (!isRecord(value) || value.type !== 'appStoreVersionLocalizations') return null; + if (!isNonEmptyString(value.id) || !isRecord(value.attributes)) return null; + const locale = normalizeLocale(value.attributes.locale); + if (locale === null) return null; + const { whatsNew } = value.attributes; + if (whatsNew !== null && whatsNew !== undefined && typeof whatsNew !== 'string') return null; + return { resourceId: value.id, locale, whatsNew: whatsNew ?? null }; +} + +function normalizeLocale(value: unknown): string | null { + if (typeof value !== 'string' || value.length === 0) return null; + try { + return Intl.getCanonicalLocales(value)[0] ?? null; + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} diff --git a/src/providers/appStoreConnect/parseAppStoreReleaseNotes.ts b/src/providers/appStoreConnect/parseAppStoreReleaseNotes.ts new file mode 100644 index 0000000..8fa334f --- /dev/null +++ b/src/providers/appStoreConnect/parseAppStoreReleaseNotes.ts @@ -0,0 +1,38 @@ +import type { ReleaseNote } from '../../domain/release/ReleaseNote'; + +export function parseAppStoreReleaseNotes(value: unknown): readonly ReleaseNote[] | null { + if (!isRecord(value) || !Array.isArray(value.data)) return null; + const notes: ReleaseNote[] = []; + for (const item of value.data as unknown[]) { + const note = parseNote(item); + if (note === null) return null; + if (note !== undefined) notes.push(note); + } + notes.sort((left, right) => left.locale.localeCompare(right.locale)); + if (new Set(notes.map((note) => note.locale)).size !== notes.length) return null; + return notes; +} + +function parseNote(value: unknown): ReleaseNote | null | undefined { + if (!isRecord(value) || value.type !== 'appStoreVersionLocalizations') return null; + if (!isRecord(value.attributes)) return null; + const locale = normalizeLocale(value.attributes.locale); + if (locale === null) return null; + const { whatsNew } = value.attributes; + if (whatsNew === null || whatsNew === undefined || whatsNew === '') return undefined; + return typeof whatsNew === 'string' ? { locale, text: whatsNew } : null; +} + +function normalizeLocale(value: unknown): string | null { + if (typeof value !== 'string' || value.trim().length === 0) return null; + try { + const locales = Intl.getCanonicalLocales(value); + return locales.length === 1 ? (locales[0] ?? null) : null; + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/providers/appStoreConnect/parseAppStoreReleaseVersion.ts b/src/providers/appStoreConnect/parseAppStoreReleaseVersion.ts new file mode 100644 index 0000000..451eccd --- /dev/null +++ b/src/providers/appStoreConnect/parseAppStoreReleaseVersion.ts @@ -0,0 +1,44 @@ +export function parseAppStoreReleaseVersion( + value: unknown, + expectedVersion: string, +): + | { + readonly versionId: string; + readonly appVersionState: string; + readonly releaseType?: string; + } + | null + | undefined { + if (!isRecord(value) || !Array.isArray(value.data)) return undefined; + let match: Record | null = null; + for (const candidate of value.data as unknown[]) { + if (!matches(candidate, expectedVersion)) continue; + if (match !== null || !isRecord(candidate)) return undefined; + match = candidate; + } + if (match === null) return null; + if (!isNonEmptyString(match.id) || !isRecord(match.attributes)) return undefined; + if (!isNonEmptyString(match.attributes.appVersionState)) return undefined; + return { + versionId: match.id, + appVersionState: match.attributes.appVersionState, + ...(isNonEmptyString(match.attributes.releaseType) + ? { releaseType: match.attributes.releaseType } + : {}), + }; +} + +function matches(value: unknown, expectedVersion: string): boolean { + if (!isRecord(value) || value.type !== 'appStoreVersions' || !isRecord(value.attributes)) { + return false; + } + return value.attributes.platform === 'IOS' && value.attributes.versionString === expectedVersion; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/src/providers/appStoreConnect/parseAppStoreReviewSubmission.ts b/src/providers/appStoreConnect/parseAppStoreReviewSubmission.ts new file mode 100644 index 0000000..f10e437 --- /dev/null +++ b/src/providers/appStoreConnect/parseAppStoreReviewSubmission.ts @@ -0,0 +1,60 @@ +import type { AppStoreReleaseSnapshot } from './AppStoreReleaseSnapshot'; + +const COMPLETE = 'COMPLETE'; + +export function parseAppStoreReviewSubmission( + value: unknown, + versionId: string, +): AppStoreReleaseSnapshot['reviewSubmission'] | undefined { + if (!isRecord(value) || !Array.isArray(value.data)) return undefined; + const matches = value.data + .map((item) => parseSubmission(item, versionId)) + .filter((item): item is NonNullable => item !== null); + const active = matches.filter((item) => item.state !== COMPLETE); + if (active.length > 1) return undefined; + if (active.length === 1) return active[0] ?? undefined; + const completed = matches.filter((item) => item.state === COMPLETE).sort(compareSubmissions); + return completed[0] ?? null; +} + +function parseSubmission( + value: unknown, + versionId: string, +): NonNullable | null { + if (!isRecord(value) || value.type !== 'reviewSubmissions' || !isNonEmptyString(value.id)) { + return null; + } + if (!isRecord(value.attributes) || !isNonEmptyString(value.attributes.state)) return null; + if (!referencesVersion(value, versionId)) return null; + return { + id: value.id, + state: value.attributes.state, + ...(isNonEmptyString(value.attributes.submittedDate) + ? { submittedDate: value.attributes.submittedDate } + : {}), + }; +} + +function referencesVersion(value: Record, versionId: string): boolean { + if (!isRecord(value.relationships) || !isRecord(value.relationships.appStoreVersionForReview)) { + return false; + } + const { data } = value.relationships.appStoreVersionForReview; + return isRecord(data) && data.type === 'appStoreVersions' && data.id === versionId; +} + +function compareSubmissions( + left: NonNullable, + right: NonNullable, +): number { + const date = (right.submittedDate ?? '').localeCompare(left.submittedDate ?? ''); + return date !== 0 ? date : left.id.localeCompare(right.id); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/src/providers/appStoreConnect/readAppStoreReleaseJson.ts b/src/providers/appStoreConnect/readAppStoreReleaseJson.ts new file mode 100644 index 0000000..c711339 --- /dev/null +++ b/src/providers/appStoreConnect/readAppStoreReleaseJson.ts @@ -0,0 +1,21 @@ +import type { AppStoreConnectTransport } from './AppStoreConnectTransport'; + +export async function readAppStoreReleaseJson(options: { + readonly url: string; + readonly token: string; + readonly request: AppStoreConnectTransport; +}): Promise<{ readonly status: number; readonly value: unknown } | null> { + try { + const response = await options.request({ method: 'GET', ...options }); + if (response.status < 200 || response.status >= 300) { + return { status: response.status, value: null }; + } + try { + return { status: response.status, value: JSON.parse(response.body) as unknown }; + } catch { + return null; + } + } catch { + return null; + } +} diff --git a/src/providers/appStoreConnect/readAppStoreReleaseLocalizations.ts b/src/providers/appStoreConnect/readAppStoreReleaseLocalizations.ts new file mode 100644 index 0000000..880a377 --- /dev/null +++ b/src/providers/appStoreConnect/readAppStoreReleaseLocalizations.ts @@ -0,0 +1,22 @@ +import type { AppStoreConnectTransport } from './AppStoreConnectTransport'; +import type { AppStoreReleaseLocalizationResource } from './AppStoreReleaseLocalizationResource'; +import { appStoreReleaseNotesUrl } from './appStoreReleaseUrls'; +import { parseAppStoreReleaseLocalizations } from './parseAppStoreReleaseLocalizations'; + +export async function readAppStoreReleaseLocalizations(options: { + readonly versionId: string; + readonly token: string; + readonly request: AppStoreConnectTransport; +}): Promise { + const response = await options.request({ + method: 'GET', + url: appStoreReleaseNotesUrl(options.versionId), + token: options.token, + }); + if (response.status < 200 || response.status >= 300) return null; + try { + return parseAppStoreReleaseLocalizations(JSON.parse(response.body) as unknown); + } catch { + return null; + } +} diff --git a/src/providers/appStoreConnect/submitAppStoreReviewSubmission.ts b/src/providers/appStoreConnect/submitAppStoreReviewSubmission.ts new file mode 100644 index 0000000..0331709 --- /dev/null +++ b/src/providers/appStoreConnect/submitAppStoreReviewSubmission.ts @@ -0,0 +1,22 @@ +import type { AppStoreConnectTransport } from './AppStoreConnectTransport'; +import { appStoreReviewSubmissionUrl } from './appStoreReleaseUrls'; + +export async function submitAppStoreReviewSubmission(options: { + readonly reviewSubmissionId: string; + readonly token: string; + readonly request: AppStoreConnectTransport; +}): Promise { + const response = await options.request({ + method: 'PATCH', + url: appStoreReviewSubmissionUrl(options.reviewSubmissionId), + token: options.token, + body: JSON.stringify({ + data: { + type: 'reviewSubmissions', + id: options.reviewSubmissionId, + attributes: { submitted: true }, + }, + }), + }); + return response.status >= 200 && response.status < 300; +} diff --git a/src/providers/appStoreConnect/syncAppStoreReleaseNotes.ts b/src/providers/appStoreConnect/syncAppStoreReleaseNotes.ts new file mode 100644 index 0000000..b1f91c5 --- /dev/null +++ b/src/providers/appStoreConnect/syncAppStoreReleaseNotes.ts @@ -0,0 +1,74 @@ +import type { ReleaseNote } from '../../domain/release/ReleaseNote'; +import type { AppStoreConnectTransport } from './AppStoreConnectTransport'; +import type { AppStoreReleaseLocalizationResource } from './AppStoreReleaseLocalizationResource'; +import { + appStoreVersionLocalizationsUrl, + appStoreVersionLocalizationUrl, +} from './appStoreReleaseUrls'; +import { readAppStoreReleaseLocalizations } from './readAppStoreReleaseLocalizations'; + +export async function syncAppStoreReleaseNotes(options: { + readonly versionId: string; + readonly notes: readonly ReleaseNote[]; + readonly token: string; + readonly request: AppStoreConnectTransport; +}): Promise { + const current = await readAppStoreReleaseLocalizations(options); + if (current === null) return false; + for (const note of options.notes) { + const existing = current.find((item) => item.locale === note.locale); + if (existing === undefined) { + if (!(await createNote(options, note))) return false; + } else if (existing.whatsNew !== note.text) { + if (!(await updateNote(options, existing, note))) return false; + } + } + return true; +} + +async function createNote( + options: Parameters[0], + note: ReleaseNote, +): Promise { + const response = await options.request({ + method: 'POST', + url: appStoreVersionLocalizationsUrl(), + token: options.token, + body: JSON.stringify({ + data: { + type: 'appStoreVersionLocalizations', + attributes: { locale: note.locale, whatsNew: note.text }, + relationships: { + appStoreVersion: { + data: { type: 'appStoreVersions', id: options.versionId }, + }, + }, + }, + }), + }); + return ok(response.status); +} + +async function updateNote( + options: Parameters[0], + existing: AppStoreReleaseLocalizationResource, + note: ReleaseNote, +): Promise { + const response = await options.request({ + method: 'PATCH', + url: appStoreVersionLocalizationUrl(existing.resourceId), + token: options.token, + body: JSON.stringify({ + data: { + type: 'appStoreVersionLocalizations', + id: existing.resourceId, + attributes: { whatsNew: note.text }, + }, + }), + }); + return ok(response.status); +} + +function ok(status: number): boolean { + return status >= 200 && status < 300; +} diff --git a/src/providers/appStoreConnect/updateAppStorePhasedReleaseState.ts b/src/providers/appStoreConnect/updateAppStorePhasedReleaseState.ts new file mode 100644 index 0000000..6ef3457 --- /dev/null +++ b/src/providers/appStoreConnect/updateAppStorePhasedReleaseState.ts @@ -0,0 +1,23 @@ +import type { AppStoreConnectTransport } from './AppStoreConnectTransport'; +import { appStorePhasedReleaseMutationUrl } from './appStoreReleaseUrls'; + +export async function updateAppStorePhasedReleaseState(options: { + readonly phasedReleaseId: string; + readonly state: 'ACTIVE' | 'PAUSED'; + readonly token: string; + readonly request: AppStoreConnectTransport; +}): Promise { + const response = await options.request({ + method: 'PATCH', + url: appStorePhasedReleaseMutationUrl(options.phasedReleaseId), + token: options.token, + body: JSON.stringify({ + data: { + type: 'appStoreVersionPhasedReleases', + id: options.phasedReleaseId, + attributes: { phasedReleaseState: options.state }, + }, + }), + }); + return response.status >= 200 && response.status < 300; +} diff --git a/src/providers/googlePlay/GooglePlayEditableRelease.ts b/src/providers/googlePlay/GooglePlayEditableRelease.ts new file mode 100644 index 0000000..b37ec2d --- /dev/null +++ b/src/providers/googlePlay/GooglePlayEditableRelease.ts @@ -0,0 +1,9 @@ +import type { ReleaseNote } from '../../domain/release/ReleaseNote'; + +export interface GooglePlayEditableRelease { + readonly raw: Readonly>; + readonly versionCodes: readonly string[]; + readonly releaseNotes: readonly ReleaseNote[]; + readonly status: 'draft' | 'inProgress' | 'halted' | 'completed'; + readonly userFraction?: string; +} diff --git a/src/providers/googlePlay/GooglePlayEditableTrack.ts b/src/providers/googlePlay/GooglePlayEditableTrack.ts new file mode 100644 index 0000000..6f9c6e2 --- /dev/null +++ b/src/providers/googlePlay/GooglePlayEditableTrack.ts @@ -0,0 +1,7 @@ +import type { AndroidDeploymentTrack } from '../../domain/AndroidDeploymentIntent'; +import type { GooglePlayEditableRelease } from './GooglePlayEditableRelease'; + +export interface GooglePlayEditableTrack { + readonly track: AndroidDeploymentTrack; + readonly releases: readonly GooglePlayEditableRelease[]; +} diff --git a/src/providers/googlePlay/GooglePlayReleaseSnapshot.ts b/src/providers/googlePlay/GooglePlayReleaseSnapshot.ts new file mode 100644 index 0000000..4e2c579 --- /dev/null +++ b/src/providers/googlePlay/GooglePlayReleaseSnapshot.ts @@ -0,0 +1,14 @@ +import type { AndroidDeploymentTrack } from '../../domain/AndroidDeploymentIntent'; +import type { ReleaseNote } from '../../domain/release/ReleaseNote'; +import type { GooglePlayTrackState } from './GooglePlayTrackState'; + +export interface GooglePlayReleaseSnapshot { + readonly track: AndroidDeploymentTrack; + readonly summary: GooglePlayTrackState; + readonly releases: readonly { + readonly status: 'draft' | 'inProgress' | 'halted' | 'completed'; + readonly versionCodes: readonly string[]; + readonly releaseNotes: readonly ReleaseNote[]; + readonly userFraction?: string; + }[]; +} diff --git a/src/providers/googlePlay/createGooglePlayTrackControlBody.ts b/src/providers/googlePlay/createGooglePlayTrackControlBody.ts new file mode 100644 index 0000000..ce5845f --- /dev/null +++ b/src/providers/googlePlay/createGooglePlayTrackControlBody.ts @@ -0,0 +1,39 @@ +import type { ReleaseLifecycleControl } from '../../domain/release/ReleaseLifecycleControl'; +import type { GooglePlayEditableRelease } from './GooglePlayEditableRelease'; +import type { GooglePlayEditableTrack } from './GooglePlayEditableTrack'; + +type AndroidControl = Extract; + +export function createGooglePlayTrackControlBody(options: { + readonly current: GooglePlayEditableTrack; + readonly targetVersionCode: string; + readonly control: AndroidControl; +}): Readonly> | null { + const matches = options.current.releases.filter((release) => + release.versionCodes.includes(options.targetVersionCode), + ); + if (matches.length !== 1) return null; + const [target] = matches; + if (target === undefined || !canTransition(target, options.control)) return null; + const releases = options.current.releases.map((release) => + release === target ? controlledRelease(target, options.control) : release.raw, + ); + return { track: options.current.track, releases }; +} + +function canTransition(release: GooglePlayEditableRelease, control: AndroidControl): boolean { + if (release.userFraction === undefined) return false; + return control.action === 'halt' ? release.status === 'inProgress' : release.status === 'halted'; +} + +function controlledRelease( + current: GooglePlayEditableRelease, + control: AndroidControl, +): Readonly> { + const { status: _status, userFraction: _userFraction, ...preserved } = current.raw; + return { + ...preserved, + status: control.action === 'halt' ? 'halted' : 'inProgress', + userFraction: Number(current.userFraction), + }; +} diff --git a/src/providers/googlePlay/createGooglePlayTrackUpdateBody.ts b/src/providers/googlePlay/createGooglePlayTrackUpdateBody.ts new file mode 100644 index 0000000..715f536 --- /dev/null +++ b/src/providers/googlePlay/createGooglePlayTrackUpdateBody.ts @@ -0,0 +1,81 @@ +import type { ReleaseNote } from '../../domain/release/ReleaseNote'; +import type { ReleaseTargetRollout } from '../../domain/release/ReleaseTargetRollout'; +import type { GooglePlayEditableRelease } from './GooglePlayEditableRelease'; +import type { GooglePlayEditableTrack } from './GooglePlayEditableTrack'; +import { mergeGooglePlayReleaseNotes } from './mergeGooglePlayReleaseNotes'; + +export function createGooglePlayTrackUpdateBody(options: { + readonly current: GooglePlayEditableTrack; + readonly targetVersionCode: string; + readonly releaseNotes?: readonly ReleaseNote[]; + readonly rollout?: ReleaseTargetRollout; +}): Readonly> | null { + const matches = options.current.releases.filter((release) => + release.versionCodes.includes(options.targetVersionCode), + ); + if (matches.length !== 1) return null; + const [target] = matches; + if (target === undefined) return null; + const releases = options.current.releases.map((release) => + release === target ? createTargetRelease(target, options) : release.raw, + ); + return { track: options.current.track, releases }; +} + +function createTargetRelease( + current: GooglePlayEditableRelease, + options: Parameters[0], +): Readonly> { + const { + versionCodes: _versionCodes, + releaseNotes: _releaseNotes, + status: _status, + userFraction: _userFraction, + ...preserved + } = current.raw; + const base = { + ...preserved, + versionCodes: current.versionCodes, + releaseNotes: desiredNotes(current, options.releaseNotes), + status: desiredStatus(current, options.rollout), + }; + return withFraction(base, current, options.rollout); +} + +function desiredNotes( + current: GooglePlayEditableRelease, + releaseNotes: readonly ReleaseNote[] | undefined, +): unknown { + return releaseNotes === undefined + ? current.raw.releaseNotes + : mergeGooglePlayReleaseNotes(releaseNotes, current.releaseNotes); +} + +function desiredStatus( + current: GooglePlayEditableRelease, + rollout: ReleaseTargetRollout | undefined, +): GooglePlayEditableRelease['status'] { + if (rollout === undefined) return current.status; + return rollout.mode === 'staged' ? 'inProgress' : 'completed'; +} + +function withFraction( + base: Readonly>, + current: GooglePlayEditableRelease, + rollout: ReleaseTargetRollout | undefined, +): Readonly> { + if (rollout === undefined) { + return current.userFraction === undefined + ? base + : { ...base, userFraction: Number(current.userFraction) }; + } + if (rollout.mode !== 'staged') return base; + const userFraction = toProviderFraction(rollout.initialFraction); + return userFraction === null ? base : { ...base, userFraction }; +} + +function toProviderFraction(value: string | undefined): number | null { + if (value === undefined || !/^0\.\d+$/.test(value)) return null; + const fraction = Number(value); + return Number.isFinite(fraction) && fraction > 0 && fraction < 1 ? fraction : null; +} diff --git a/src/providers/googlePlay/executeGooglePlayReleaseControl.ts b/src/providers/googlePlay/executeGooglePlayReleaseControl.ts new file mode 100644 index 0000000..430b5bb --- /dev/null +++ b/src/providers/googlePlay/executeGooglePlayReleaseControl.ts @@ -0,0 +1,50 @@ +import type { AndroidDeploymentTrack } from '../../domain/AndroidDeploymentIntent'; +import type { DeploymentCredentialReference } from '../../domain/DeploymentCredentialReference'; +import type { DeploymentSecretResolver } from '../../domain/DeploymentSecretResolver'; +import type { ReleaseLifecycleControl } from '../../domain/release/ReleaseLifecycleControl'; +import type { ReleaseMutationResult } from '../../engine/release/ReleaseMutationResult'; +import { commitGooglePlayEdit } from './commitGooglePlayEdit'; +import type { GooglePlayTokenFactory } from './GooglePlayTokenFactory'; +import type { GooglePlayTransport } from './GooglePlayTransport'; +import { insertGooglePlayEdit } from './insertGooglePlayEdit'; +import { resolveGooglePlayAccessToken } from './resolveGooglePlayAccessToken'; +import { updateGooglePlayReleaseControlInEdit } from './updateGooglePlayReleaseControlInEdit'; + +type AndroidControl = Extract; + +export async function executeGooglePlayReleaseControl(options: { + readonly control: AndroidControl; + readonly packageName: string; + readonly track: AndroidDeploymentTrack; + readonly versionCode: string; + readonly credentials: readonly DeploymentCredentialReference[]; + readonly resolveSecret: DeploymentSecretResolver; + readonly createToken: GooglePlayTokenFactory; + readonly request: GooglePlayTransport; +}): Promise { + const access = await resolveGooglePlayAccessToken(options); + if (!access.ok) return { status: 'blocked', code: access.action.code }; + const shared = { + packageName: options.packageName, + token: access.token, + request: options.request, + }; + const editId = await insertGooglePlayEdit(shared); + if (editId === null) return failed('GOOGLE_PLAY_CONTROL_EDIT_CREATE_FAILED'); + const updated = await updateGooglePlayReleaseControlInEdit({ + packageName: options.packageName, + editId, + track: options.track, + targetVersionCode: options.versionCode, + control: options.control, + token: access.token, + request: options.request, + }); + if (!updated) return failed('GOOGLE_PLAY_RELEASE_CONTROL_FAILED'); + const committed = await commitGooglePlayEdit({ ...shared, editId }); + return committed ? { status: 'completed' } : failed('GOOGLE_PLAY_CONTROL_COMMIT_FAILED'); +} + +function failed(code: string): ReleaseMutationResult { + return { status: 'failed', code }; +} diff --git a/src/providers/googlePlay/executeGooglePlayReleaseMutation.ts b/src/providers/googlePlay/executeGooglePlayReleaseMutation.ts new file mode 100644 index 0000000..afbc506 --- /dev/null +++ b/src/providers/googlePlay/executeGooglePlayReleaseMutation.ts @@ -0,0 +1,78 @@ +import type { AndroidDeploymentTrack } from '../../domain/AndroidDeploymentIntent'; +import type { DeploymentCredentialReference } from '../../domain/DeploymentCredentialReference'; +import type { DeploymentSecretResolver } from '../../domain/DeploymentSecretResolver'; +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import type { ReleasePlanStep } from '../../domain/release/ReleasePlanStep'; +import type { ReleaseMutationResult } from '../../engine/release/ReleaseMutationResult'; +import { commitGooglePlayEdit } from './commitGooglePlayEdit'; +import type { GooglePlayTokenFactory } from './GooglePlayTokenFactory'; +import type { GooglePlayTransport } from './GooglePlayTransport'; +import { insertGooglePlayEdit } from './insertGooglePlayEdit'; +import { resolveGooglePlayAccessToken } from './resolveGooglePlayAccessToken'; +import { updateGooglePlayReleaseInEdit } from './updateGooglePlayReleaseInEdit'; + +export async function executeGooglePlayReleaseMutation(options: { + readonly step: ReleasePlanStep; + readonly desired: ReleaseDesiredState; + readonly packageName: string; + readonly track: AndroidDeploymentTrack; + readonly versionCode: string; + readonly credentials: readonly DeploymentCredentialReference[]; + readonly resolveSecret: DeploymentSecretResolver; + readonly createToken: GooglePlayTokenFactory; + readonly request: GooglePlayTransport; +}): Promise { + if (!isSupported(options.step)) return failed('GOOGLE_PLAY_RELEASE_STEP_UNSUPPORTED'); + const access = await resolveGooglePlayAccessToken(options); + if (!access.ok) return blocked(access.action.code); + const shared = { + packageName: options.packageName, + token: access.token, + request: options.request, + }; + const editId = await insertGooglePlayEdit(shared); + if (editId === null) return failed('GOOGLE_PLAY_EDIT_CREATE_FAILED'); + const updated = await updateStep(options, access.token, editId); + if (!updated) return failed('GOOGLE_PLAY_RELEASE_UPDATE_FAILED'); + const committed = await commitGooglePlayEdit({ ...shared, editId }); + return committed ? { status: 'completed' } : failed('GOOGLE_PLAY_EDIT_COMMIT_FAILED'); +} + +function updateStep( + options: Parameters[0], + token: string, + editId: string, +): Promise { + const notes = options.step.operation === 'sync-notes' ? options.desired.notes : undefined; + const rollout = + options.step.operation === 'release' || options.step.operation === 'rollout' + ? options.desired.rollout.android + : undefined; + return updateGooglePlayReleaseInEdit({ + packageName: options.packageName, + editId, + track: options.track, + targetVersionCode: options.versionCode, + ...(notes === undefined ? {} : { releaseNotes: notes }), + ...(rollout === undefined ? {} : { rollout }), + token, + request: options.request, + }); +} + +function isSupported(step: ReleasePlanStep): boolean { + return ( + step.target === 'android' && + (step.operation === 'sync-notes' || + step.operation === 'release' || + step.operation === 'rollout') + ); +} + +function blocked(code: string): ReleaseMutationResult { + return { status: 'blocked', code }; +} + +function failed(code: string): ReleaseMutationResult { + return { status: 'failed', code }; +} diff --git a/src/providers/googlePlay/googlePlayReleaseControl.test.ts b/src/providers/googlePlay/googlePlayReleaseControl.test.ts new file mode 100644 index 0000000..a43858e --- /dev/null +++ b/src/providers/googlePlay/googlePlayReleaseControl.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from 'bun:test'; + +import { executeGooglePlayReleaseControl } from './executeGooglePlayReleaseControl'; +import type { GooglePlayRequest, GooglePlayTransport } from './GooglePlayTransport'; + +test('Google Play halt preserves staged fraction and commits the complete track', async () => { + const requests: GooglePlayRequest[] = []; + const result = await executeGooglePlayReleaseControl(options(requests, 'halt')); + expect(result.status).toBe('completed'); + expect(requests.map(requestLabel)).toEqual([ + 'POST /edits', + 'GET /edits/edit-1/tracks/production', + 'PUT /edits/edit-1/tracks/production', + 'POST /edits/edit-1:commit', + ]); + expect(parsePutBody(requests)).toEqual(expectedHaltedTrack()); +}); + +test('Google Play resume preserves the staged fraction', async () => { + const requests: GooglePlayRequest[] = []; + const result = await executeGooglePlayReleaseControl(options(requests, 'resume')); + expect(result.status).toBe('completed'); + const body = parsePutBody(requests); + expect(JSON.stringify(body)).toContain('"status":"inProgress"'); + expect(JSON.stringify(body)).toContain('"userFraction":0.25'); +}); + +function options(requests: GooglePlayRequest[], action: 'halt' | 'resume') { + return { + control: { target: 'android', action } as const, + packageName: 'com.example.app', + track: 'production' as const, + versionCode: '42', + credentials: [{ provider: 'google-play', id: 'play', kind: 'service-account' }] as const, + resolveSecret: () => + Promise.resolve( + JSON.stringify({ + type: 'service_account', + client_email: 'robot@example.test', + private_key: 'PRIVATE_KEY_SENTINEL', + }), + ), + createToken: () => Promise.resolve('token'), + request: transport(requests, action === 'halt' ? 'inProgress' : 'halted'), + }; +} + +function transport( + requests: GooglePlayRequest[], + status: 'inProgress' | 'halted', +): GooglePlayTransport { + return (request) => { + requests.push(request); + if (request.method === 'POST' && request.url.endsWith('/edits')) { + return Promise.resolve(response(200, { id: 'edit-1' })); + } + if (request.method === 'GET') return Promise.resolve(response(200, currentTrack(status))); + return Promise.resolve(response(200, {})); + }; +} + +function currentTrack(status: 'inProgress' | 'halted'): unknown { + return { + track: 'production', + releases: [ + { + name: '2.1.0', + versionCodes: ['42'], + releaseNotes: [{ language: 'de-CH', text: 'Neu' }], + status, + userFraction: 0.25, + futureProviderField: { keep: true }, + }, + { + name: 'legacy', + versionCodes: ['41'], + releaseNotes: [], + status: 'completed', + }, + ], + }; +} + +function expectedHaltedTrack(): unknown { + return { + track: 'production', + releases: [ + { + name: '2.1.0', + versionCodes: ['42'], + releaseNotes: [{ language: 'de-CH', text: 'Neu' }], + futureProviderField: { keep: true }, + status: 'halted', + userFraction: 0.25, + }, + { + name: 'legacy', + versionCodes: ['41'], + releaseNotes: [], + status: 'completed', + }, + ], + }; +} + +function parsePutBody(requests: readonly GooglePlayRequest[]): unknown { + const put = requests.find((request) => request.method === 'PUT'); + return typeof put?.body === 'string' ? (JSON.parse(put.body) as unknown) : null; +} + +function requestLabel(request: GooglePlayRequest): string { + const marker = '/applications/com.example.app'; + return `${request.method} ${request.url.split(marker)[1] ?? request.url}`; +} + +function response(status: number, body: unknown): { status: number; body: string } { + return { status, body: JSON.stringify(body) }; +} diff --git a/src/providers/googlePlay/googlePlayReleaseExecution.test.ts b/src/providers/googlePlay/googlePlayReleaseExecution.test.ts new file mode 100644 index 0000000..7b328c4 --- /dev/null +++ b/src/providers/googlePlay/googlePlayReleaseExecution.test.ts @@ -0,0 +1,138 @@ +import { expect, test } from 'bun:test'; + +import { executeGooglePlayReleaseMutation } from './executeGooglePlayReleaseMutation'; +import type { GooglePlayRequest, GooglePlayTransport } from './GooglePlayTransport'; + +test('Google Play release-note mutation preserves rollout state and commits the edit', async () => { + const requests: GooglePlayRequest[] = []; + const result = await executeGooglePlayReleaseMutation(createOptions(requests)); + + expect(result.status).toBe('completed'); + expect(requests.map(requestLabel)).toEqual(expectedRequestFlow()); + expect(parsePutBody(requests)).toEqual(expectedPutBody()); + expect(serializedBodies(requests)).not.toContain('PRIVATE_KEY_SENTINEL'); +}); + +function createOptions(requests: GooglePlayRequest[]) { + return { + step: { + id: 'android:sync-notes', + target: 'android', + operation: 'sync-notes', + dependsOn: [], + retry: 'reinspect', + irreversible: false, + } as const, + desired: { + version: '2.1.0', + targets: ['android'], + notes: [{ locale: 'de-CH', text: 'Neu' }], + rollout: { android: { mode: 'staged', initialFraction: '0.1' } }, + revision: 'desired', + } as const, + packageName: 'com.example.app', + track: 'production' as const, + versionCode: '42', + credentials: [{ provider: 'google-play', id: 'play', kind: 'service-account' }] as const, + resolveSecret: () => + Promise.resolve( + JSON.stringify({ + type: 'service_account', + client_email: 'robot@example.test', + private_key: 'PRIVATE_KEY_SENTINEL', + }), + ), + createToken: () => Promise.resolve('google-token'), + request: createTransport(requests), + }; +} + +function expectedRequestFlow(): string[] { + return [ + 'POST /edits', + 'GET /edits/edit-1/tracks/production', + 'PUT /edits/edit-1/tracks/production', + 'POST /edits/edit-1:commit', + ]; +} + +function expectedPutBody(): unknown { + return { + track: 'production', + releases: [ + { + name: '2.1.0', + versionCodes: ['42'], + releaseNotes: [ + { language: 'de-CH', text: 'Neu' }, + { language: 'fr-FR', text: 'Conserver' }, + ], + status: 'inProgress', + userFraction: 0.25, + }, + ], + }; +} + +function parsePutBody(requests: readonly GooglePlayRequest[]): unknown { + const put = requests.find((request) => request.method === 'PUT'); + return parseBody(put?.body); +} + +function serializedBodies(requests: readonly GooglePlayRequest[]): string { + return JSON.stringify(requests.map((request) => request.body)); +} + +function createTransport(requests: GooglePlayRequest[]): GooglePlayTransport { + return (request) => { + requests.push(request); + return Promise.resolve(responseFor(request)); + }; +} + +function responseFor(request: GooglePlayRequest): { status: number; body: string } { + if (request.method === 'POST' && request.url.endsWith('/edits')) { + return response(200, { id: 'edit-1' }); + } + if (request.method === 'GET' && request.url.endsWith('/edits/edit-1/tracks/production')) { + return response(200, currentTrack()); + } + if (request.method === 'PUT' && request.url.endsWith('/edits/edit-1/tracks/production')) { + return response(200, {}); + } + if (request.method === 'POST' && request.url.endsWith('/edits/edit-1:commit')) { + return response(200, {}); + } + return response(404, {}); +} + +function currentTrack(): unknown { + return { + track: 'production', + releases: [ + { + name: '2.1.0', + versionCodes: ['42'], + releaseNotes: [ + { language: 'de-CH', text: 'Alt' }, + { language: 'fr-FR', text: 'Conserver' }, + ], + status: 'inProgress', + userFraction: 0.25, + }, + ], + }; +} + +function requestLabel(request: GooglePlayRequest): string { + const marker = '/applications/com.example.app'; + return `${request.method} ${request.url.split(marker)[1] ?? request.url}`; +} + +function parseBody(body: GooglePlayRequest['body']): unknown { + return typeof body === 'string' ? (JSON.parse(body) as unknown) : null; +} + +function response(status: number, body: unknown): { status: number; body: string } { + return { status, body: JSON.stringify(body) }; +} diff --git a/src/providers/googlePlay/googlePlayReleaseInspection.test.ts b/src/providers/googlePlay/googlePlayReleaseInspection.test.ts new file mode 100644 index 0000000..456b18d --- /dev/null +++ b/src/providers/googlePlay/googlePlayReleaseInspection.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from 'bun:test'; + +import type { GooglePlayRequest, GooglePlayTransport } from './GooglePlayTransport'; +import { inspectGooglePlayReleaseState } from './inspectGooglePlayReleaseState'; + +const ACCESS = { + packageName: 'com.example.app', + track: 'production' as const, + credentials: [{ provider: 'google-play', id: 'play', kind: 'service-account' }], + resolveSecret: () => + Promise.resolve( + JSON.stringify({ + type: 'service_account', + client_email: 'robot@example.test', + private_key: 'PRIVATE_KEY_SENTINEL', + }), + ), + createToken: () => Promise.resolve('google-token'), +}; + +test('Google Play release inspection combines review lifecycle and configured rollout', async () => { + const requests: GooglePlayRequest[] = []; + const result = await inspectGooglePlayReleaseState({ + ...ACCESS, + request: createTransport(requests), + }); + expect(result.status).toBe('completed'); + if (result.status !== 'completed') return; + expect(result.state.summary.releases[0]?.lifecycle).toBe('RELEASE_LIFECYCLE_STATE_PUBLISHED'); + expect(result.state.releases).toEqual([ + { + status: 'inProgress', + versionCodes: ['42'], + releaseNotes: [{ locale: 'de-CH', text: 'Neu' }], + userFraction: '0.1', + }, + ]); + expect(requests.filter((request) => request.method === 'POST')).toHaveLength(1); + expect(requests.some((request) => request.method === 'PUT')).toBe(false); + expect(JSON.stringify(result)).not.toContain('PRIVATE_KEY_SENTINEL'); +}); + +function createTransport(requests: GooglePlayRequest[]): GooglePlayTransport { + return (request) => { + requests.push(request); + return Promise.resolve(responseFor(request)); + }; +} + +function responseFor(request: GooglePlayRequest): { status: number; body: string } { + if (request.method === 'GET' && request.url.endsWith('/tracks/production/releases')) { + return response(200, { + releases: [ + { + releaseLifecycleState: 'RELEASE_LIFECYCLE_STATE_PUBLISHED', + activeArtifacts: [{ versionCode: 42 }], + }, + ], + }); + } + if (request.method === 'POST' && request.url.endsWith('/edits')) { + return response(200, { id: 'edit-1' }); + } + if (request.method === 'GET' && request.url.endsWith('/edits/edit-1/tracks/production')) { + return response(200, configuredTrack()); + } + return response(404, {}); +} + +function configuredTrack(): unknown { + return { + track: 'production', + releases: [ + { + versionCodes: ['42'], + releaseNotes: [{ language: 'de-ch', text: 'Neu' }], + status: 'inProgress', + userFraction: 0.1, + }, + ], + }; +} + +function response(status: number, body: unknown): { status: number; body: string } { + return { status, body: JSON.stringify(body) }; +} diff --git a/src/providers/googlePlay/googlePlayReleaseMutation.test.ts b/src/providers/googlePlay/googlePlayReleaseMutation.test.ts new file mode 100644 index 0000000..585e201 --- /dev/null +++ b/src/providers/googlePlay/googlePlayReleaseMutation.test.ts @@ -0,0 +1,112 @@ +import { expect, test } from 'bun:test'; + +import type { GooglePlayRequest, GooglePlayTransport } from './GooglePlayTransport'; +import { updateGooglePlayReleaseInEdit } from './updateGooglePlayReleaseInEdit'; + +test('Google Play release update preserves unrelated provider-managed release state', async () => { + const requests: GooglePlayRequest[] = []; + const result = await updateGooglePlayReleaseInEdit({ + packageName: 'com.example.app', + editId: 'edit-1', + track: 'production', + targetVersionCode: '42', + releaseNotes: [{ locale: 'de-CH', text: 'Neu' }], + rollout: { mode: 'staged', initialFraction: '0.1' }, + token: 'TOKEN_SENTINEL', + request: transport(requests), + }); + expect(result).toBe(true); + const put = requests.find((request) => request.method === 'PUT'); + expect(put).toBeDefined(); + const body = parseBody(put?.body); + expect(body).toEqual(expectedTrackBody()); + expect(JSON.stringify(body)).not.toContain('TOKEN_SENTINEL'); +}); + +test('Google Play release update refuses an ambiguous target release', async () => { + const requests: GooglePlayRequest[] = []; + const result = await updateGooglePlayReleaseInEdit({ + packageName: 'com.example.app', + editId: 'edit-1', + track: 'production', + targetVersionCode: '999', + releaseNotes: [], + rollout: { mode: 'immediate' }, + token: 'token', + request: transport(requests), + }); + expect(result).toBe(false); + expect(requests.some((request) => request.method === 'PUT')).toBe(false); +}); + +function transport(requests: GooglePlayRequest[]): GooglePlayTransport { + return (request) => { + requests.push(request); + return Promise.resolve( + request.method === 'GET' ? response(200, currentTrack()) : response(200, {}), + ); + }; +} + +function currentTrack(): unknown { + return { + track: 'production', + releases: [ + { + name: '2.1.0', + versionCodes: ['41', '42'], + releaseNotes: [ + { language: 'de-CH', text: 'Alt' }, + { language: 'fr-FR', text: 'Conserver' }, + ], + status: 'inProgress', + userFraction: 0.25, + countryTargeting: { countries: ['CH'], includeRestOfWorld: false }, + inAppUpdatePriority: 3, + futureProviderField: { keep: true }, + }, + { + name: 'legacy', + versionCodes: ['40'], + status: 'completed', + releaseNotes: [{ language: 'en-US', text: 'Legacy' }], + }, + ], + }; +} + +function expectedTrackBody(): unknown { + return { + track: 'production', + releases: [ + { + name: '2.1.0', + countryTargeting: { countries: ['CH'], includeRestOfWorld: false }, + inAppUpdatePriority: 3, + futureProviderField: { keep: true }, + versionCodes: ['41', '42'], + releaseNotes: [ + { language: 'de-CH', text: 'Neu' }, + { language: 'fr-FR', text: 'Conserver' }, + ], + status: 'inProgress', + userFraction: 0.1, + }, + { + name: 'legacy', + versionCodes: ['40'], + status: 'completed', + releaseNotes: [{ language: 'en-US', text: 'Legacy' }], + }, + ], + }; +} + +function parseBody(body: GooglePlayRequest['body']): unknown { + if (typeof body !== 'string') return null; + return JSON.parse(body) as unknown; +} + +function response(status: number, body: unknown): { status: number; body: string } { + return { status, body: JSON.stringify(body) }; +} diff --git a/src/providers/googlePlay/googlePlayReleaseUrls.ts b/src/providers/googlePlay/googlePlayReleaseUrls.ts new file mode 100644 index 0000000..747c871 --- /dev/null +++ b/src/providers/googlePlay/googlePlayReleaseUrls.ts @@ -0,0 +1,12 @@ +import type { AndroidDeploymentTrack } from '../../domain/AndroidDeploymentIntent'; + +const API = 'https://androidpublisher.googleapis.com/androidpublisher/v3'; + +export function googlePlayTrackReleasesUrl( + packageName: string, + track: AndroidDeploymentTrack, +): string { + const app = encodeURIComponent(packageName); + const encodedTrack = encodeURIComponent(track); + return `${API}/applications/${app}/tracks/${encodedTrack}/releases`; +} diff --git a/src/providers/googlePlay/inspectGooglePlayReleaseState.ts b/src/providers/googlePlay/inspectGooglePlayReleaseState.ts new file mode 100644 index 0000000..14128cc --- /dev/null +++ b/src/providers/googlePlay/inspectGooglePlayReleaseState.ts @@ -0,0 +1,153 @@ +import type { AndroidDeploymentTrack } from '../../domain/AndroidDeploymentIntent'; +import type { DeploymentCredentialReference } from '../../domain/DeploymentCredentialReference'; +import type { DeploymentFailure } from '../../domain/DeploymentFailure'; +import type { DeploymentRequiredAction } from '../../domain/DeploymentRequiredAction'; +import type { DeploymentSecretResolver } from '../../domain/DeploymentSecretResolver'; +import type { GooglePlayReleaseSnapshot } from './GooglePlayReleaseSnapshot'; +import { googlePlayTrackReleasesUrl } from './googlePlayReleaseUrls'; +import { trackGooglePlayRequests } from './GooglePlayRequestTracker'; +import type { GooglePlayTokenFactory } from './GooglePlayTokenFactory'; +import { parseGooglePlayTrackState } from './GooglePlayTrackState'; +import type { GooglePlayTransport } from './GooglePlayTransport'; +import { googlePlayTrackEditUrl } from './googlePlayUrls'; +import { insertGooglePlayEdit } from './insertGooglePlayEdit'; +import { parseGooglePlayConfiguredTrack } from './parseGooglePlayConfiguredTrack'; +import { resolveGooglePlayAccessToken } from './resolveGooglePlayAccessToken'; + +type GooglePlayReleaseInspectionResult = + | { readonly status: 'completed'; readonly state: GooglePlayReleaseSnapshot } + | { readonly status: 'action-required'; readonly action: DeploymentRequiredAction } + | { readonly status: 'failed'; readonly failure: DeploymentFailure }; + +export async function inspectGooglePlayReleaseState(options: { + readonly packageName: string; + readonly track: AndroidDeploymentTrack; + readonly credentials: readonly DeploymentCredentialReference[]; + readonly resolveSecret: DeploymentSecretResolver; + readonly createToken: GooglePlayTokenFactory; + readonly request: GooglePlayTransport; +}): Promise { + const access = await resolveGooglePlayAccessToken(options); + if (!access.ok) return { status: 'action-required', action: access.action }; + const tracker = trackGooglePlayRequests(options.request); + const summary = await readSummary(options, access.token, tracker.request); + if (!summary.ok) return providerResult(summary.status, tracker.blockingStatus()); + const editId = await insertGooglePlayEdit({ + packageName: options.packageName, + token: access.token, + request: tracker.request, + }); + if (editId === null) return providerResult(null, tracker.blockingStatus()); + return readConfigured(options, access.token, editId, summary.state, tracker.request); +} + +async function readSummary( + options: Parameters[0], + token: string, + request: GooglePlayTransport, +): Promise< + | { readonly ok: true; readonly state: GooglePlayReleaseSnapshot['summary'] } + | { readonly ok: false; readonly status: number | null } +> { + try { + const response = await request({ + method: 'GET', + url: googlePlayTrackReleasesUrl(options.packageName, options.track), + token, + }); + if (response.status < 200 || response.status >= 300) { + return { ok: false, status: response.status }; + } + const state = parseSummary(response.body, options.track); + return state === null ? { ok: false, status: null } : { ok: true, state }; + } catch { + return { ok: false, status: null }; + } +} + +async function readConfigured( + options: Parameters[0], + token: string, + editId: string, + summary: GooglePlayReleaseSnapshot['summary'], + request: GooglePlayTransport, +): Promise { + try { + const response = await request({ + method: 'GET', + url: googlePlayTrackEditUrl(options.packageName, editId, options.track), + token, + }); + if (response.status < 200 || response.status >= 300) { + return providerResult(response.status, null); + } + const releases = parseConfigured(response.body, options.track); + return releases === null + ? failed('GOOGLE_PLAY_RELEASE_RESPONSE_INVALID') + : { status: 'completed', state: { track: options.track, summary, releases } }; + } catch { + return failed('GOOGLE_PLAY_RELEASE_INSPECTION_FAILED'); + } +} + +function parseSummary( + body: string, + track: AndroidDeploymentTrack, +): GooglePlayReleaseSnapshot['summary'] | null { + try { + return parseGooglePlayTrackState(JSON.parse(body) as unknown, track); + } catch { + return null; + } +} + +function parseConfigured( + body: string, + track: AndroidDeploymentTrack, +): GooglePlayReleaseSnapshot['releases'] | null { + try { + return parseGooglePlayConfiguredTrack(JSON.parse(body) as unknown, track); + } catch { + return null; + } +} + +function providerResult( + status: number | null, + blocking: 401 | 403 | null, +): GooglePlayReleaseInspectionResult { + if (blocking === 401 || status === 401) + return action('authentication', 'GOOGLE_PLAY_AUTHENTICATION_REQUIRED'); + if (blocking === 403 || status === 403) + return action('manual-action', 'GOOGLE_PLAY_PERMISSION_REQUIRED'); + if (status === 404) return action('manual-action', 'GOOGLE_PLAY_APP_BOOTSTRAP_REQUIRED'); + return failed('GOOGLE_PLAY_RELEASE_INSPECTION_FAILED'); +} + +function action( + type: 'authentication' | 'manual-action', + code: string, +): GooglePlayReleaseInspectionResult { + return { + status: 'action-required', + action: { + type, + provider: 'google-play', + target: 'android', + code, + message: 'Google Play release inspection requires provider action.', + }, + }; +} + +function failed(code: string): GooglePlayReleaseInspectionResult { + return { + status: 'failed', + failure: { + code, + message: 'Google Play release state could not be inspected.', + target: 'android', + provider: 'google-play', + }, + }; +} diff --git a/src/providers/googlePlay/mergeGooglePlayReleaseNotes.ts b/src/providers/googlePlay/mergeGooglePlayReleaseNotes.ts new file mode 100644 index 0000000..38675c0 --- /dev/null +++ b/src/providers/googlePlay/mergeGooglePlayReleaseNotes.ts @@ -0,0 +1,13 @@ +import type { ReleaseNote } from '../../domain/release/ReleaseNote'; + +export function mergeGooglePlayReleaseNotes( + desired: readonly ReleaseNote[], + current: readonly ReleaseNote[], +): readonly { readonly language: string; readonly text: string }[] { + const notes = new Map(); + for (const note of current) notes.set(note.locale, note.text); + for (const note of desired) notes.set(note.locale, note.text); + return [...notes.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([language, text]) => ({ language, text })); +} diff --git a/src/providers/googlePlay/normalizeGooglePlayReleaseObservation.ts b/src/providers/googlePlay/normalizeGooglePlayReleaseObservation.ts new file mode 100644 index 0000000..024cb4d --- /dev/null +++ b/src/providers/googlePlay/normalizeGooglePlayReleaseObservation.ts @@ -0,0 +1,43 @@ +import type { AndroidDeploymentPublication } from '../../domain/AndroidDeploymentPublication'; +import type { ReleaseObservedAndroidState } from '../../domain/release/ReleaseObservedAndroidState'; +import type { GooglePlayReleaseSnapshot } from './GooglePlayReleaseSnapshot'; + +type AndroidReleasePublication = + | AndroidDeploymentPublication + | Pick; + +export function normalizeGooglePlayReleaseObservation(options: { + readonly desiredVersion: string; + readonly publication: AndroidReleasePublication | null; + readonly snapshot: GooglePlayReleaseSnapshot; +}): ReleaseObservedAndroidState { + const { publication } = options; + if (publication?.track !== options.snapshot.track) return missing(); + const code = String(publication.versionCode); + const matches = options.snapshot.releases.filter((release) => + release.versionCodes.includes(code), + ); + if (matches.length !== 1) return missing(); + const [release] = matches; + if (release === undefined) return missing(); + return { + target: 'android', + version: options.desiredVersion, + artifactRevision: publication.revision, + versionCodes: release.versionCodes, + releaseNotes: release.releaseNotes, + rolloutStatus: release.status, + ...(release.userFraction === undefined ? {} : { userFraction: release.userFraction }), + }; +} + +function missing(): ReleaseObservedAndroidState { + return { + target: 'android', + version: null, + artifactRevision: null, + versionCodes: [], + releaseNotes: [], + rolloutStatus: 'missing', + }; +} diff --git a/src/providers/googlePlay/parseGooglePlayConfiguredTrack.ts b/src/providers/googlePlay/parseGooglePlayConfiguredTrack.ts new file mode 100644 index 0000000..9e9d034 --- /dev/null +++ b/src/providers/googlePlay/parseGooglePlayConfiguredTrack.ts @@ -0,0 +1,105 @@ +import type { AndroidDeploymentTrack } from '../../domain/AndroidDeploymentIntent'; +import type { ReleaseNote } from '../../domain/release/ReleaseNote'; +import type { GooglePlayReleaseSnapshot } from './GooglePlayReleaseSnapshot'; + +const STATUSES = new Set(['draft', 'inProgress', 'halted', 'completed']); + +export function parseGooglePlayConfiguredTrack( + value: unknown, + expectedTrack: AndroidDeploymentTrack, +): GooglePlayReleaseSnapshot['releases'] | null { + if (!isRecord(value) || value.track !== expectedTrack) return null; + if (value.releases === undefined) return []; + if (!Array.isArray(value.releases)) return null; + const releases = value.releases.map(parseRelease); + if (releases.some((release) => release === null)) return null; + return releases + .filter((release): release is NonNullable => release !== null) + .sort(compareReleases); +} + +function parseRelease(value: unknown): GooglePlayReleaseSnapshot['releases'][number] | null { + if (!isRecord(value) || !isStatus(value.status)) return null; + const versionCodes = parseVersionCodes(value.versionCodes); + const releaseNotes = parseNotes(value.releaseNotes); + if (versionCodes === null || releaseNotes === null) return null; + const userFraction = parseFraction(value.userFraction, value.status); + if (userFraction === null) return null; + return { + status: value.status, + versionCodes, + releaseNotes, + ...(userFraction === undefined ? {} : { userFraction }), + }; +} + +function parseVersionCodes(value: unknown): readonly string[] | null { + if (!Array.isArray(value)) return null; + const codes: string[] = []; + for (const code of value as unknown[]) { + if (typeof code !== 'string' || !/^[1-9]\d*$/.test(code)) return null; + codes.push(code); + } + return codes.sort((left, right) => left.localeCompare(right)); +} + +function parseNotes(value: unknown): readonly ReleaseNote[] | null { + if (value === undefined) return []; + if (!Array.isArray(value)) return null; + const notes: ReleaseNote[] = []; + for (const item of value as unknown[]) { + const note = parseNote(item); + if (note === null) return null; + notes.push(note); + } + notes.sort((left, right) => left.locale.localeCompare(right.locale)); + if (new Set(notes.map((note) => note.locale)).size !== notes.length) return null; + return notes; +} + +function parseNote(value: unknown): ReleaseNote | null { + if (!isRecord(value) || typeof value.text !== 'string') return null; + const locale = normalizeLocale(value.language); + if (locale === null) return null; + return { locale, text: value.text }; +} + +function parseFraction( + value: unknown, + status: GooglePlayReleaseSnapshot['releases'][number]['status'], +): string | undefined | null { + if (status !== 'inProgress' && status !== 'halted') { + return value === undefined ? undefined : null; + } + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + const text = String(value); + return /^0\.\d+$/.test(text) && text !== '0.0' ? text : null; +} + +function compareReleases( + left: GooglePlayReleaseSnapshot['releases'][number], + right: GooglePlayReleaseSnapshot['releases'][number], +): number { + const versions = left.versionCodes.join(',').localeCompare(right.versionCodes.join(',')); + return versions !== 0 ? versions : left.status.localeCompare(right.status); +} + +function isStatus( + value: unknown, +): value is GooglePlayReleaseSnapshot['releases'][number]['status'] { + return typeof value === 'string' && STATUSES.has(value); +} + +function normalizeLocale(value: unknown): string | null { + if (typeof value !== 'string' || value.trim().length === 0) return null; + try { + const locales = Intl.getCanonicalLocales(value); + return locales.length === 1 ? (locales[0] ?? null) : null; + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/providers/googlePlay/parseGooglePlayEditableTrack.ts b/src/providers/googlePlay/parseGooglePlayEditableTrack.ts new file mode 100644 index 0000000..c096e99 --- /dev/null +++ b/src/providers/googlePlay/parseGooglePlayEditableTrack.ts @@ -0,0 +1,94 @@ +import type { AndroidDeploymentTrack } from '../../domain/AndroidDeploymentIntent'; +import type { ReleaseNote } from '../../domain/release/ReleaseNote'; +import type { GooglePlayEditableRelease } from './GooglePlayEditableRelease'; +import type { GooglePlayEditableTrack } from './GooglePlayEditableTrack'; + +const STATUSES = new Set(['draft', 'inProgress', 'halted', 'completed']); + +export function parseGooglePlayEditableTrack( + value: unknown, + expectedTrack: AndroidDeploymentTrack, +): GooglePlayEditableTrack | null { + if (!isRecord(value) || value.track !== expectedTrack || !Array.isArray(value.releases)) { + return null; + } + const releases: GooglePlayEditableRelease[] = []; + for (const item of value.releases as unknown[]) { + const release = parseRelease(item); + if (release === null) return null; + releases.push(release); + } + return { track: expectedTrack, releases }; +} + +function parseRelease(value: unknown): GooglePlayEditableRelease | null { + if (!isRecord(value) || !isStatus(value.status)) return null; + const versionCodes = parseVersionCodes(value.versionCodes); + const releaseNotes = parseReleaseNotes(value.releaseNotes); + if (versionCodes === null || releaseNotes === null) return null; + const userFraction = parseUserFraction(value.userFraction, value.status); + if (userFraction === null) return null; + return { + raw: value, + versionCodes, + releaseNotes, + status: value.status, + ...(userFraction === undefined ? {} : { userFraction }), + }; +} + +function parseVersionCodes(value: unknown): readonly string[] | null { + if (!Array.isArray(value)) return null; + const result: string[] = []; + for (const code of value as unknown[]) { + if (typeof code !== 'string' || !/^[1-9]\d*$/.test(code)) return null; + result.push(code); + } + return result; +} + +function parseReleaseNotes(value: unknown): readonly ReleaseNote[] | null { + if (value === undefined) return []; + if (!Array.isArray(value)) return null; + const result: ReleaseNote[] = []; + for (const item of value as unknown[]) { + const note = parseReleaseNote(item); + if (note === null) return null; + result.push(note); + } + return result; +} + +function parseReleaseNote(value: unknown): ReleaseNote | null { + if (!isRecord(value) || typeof value.text !== 'string') return null; + const locale = normalizeLocale(value.language); + return locale === null ? null : { locale, text: value.text }; +} + +function parseUserFraction( + value: unknown, + status: GooglePlayEditableRelease['status'], +): string | undefined | null { + if (status !== 'inProgress' && status !== 'halted') { + return value === undefined ? undefined : null; + } + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value >= 1) return null; + return String(value); +} + +function isStatus(value: unknown): value is GooglePlayEditableRelease['status'] { + return typeof value === 'string' && STATUSES.has(value); +} + +function normalizeLocale(value: unknown): string | null { + if (typeof value !== 'string' || value.length === 0) return null; + try { + return Intl.getCanonicalLocales(value)[0] ?? null; + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/providers/googlePlay/updateGooglePlayReleaseControlInEdit.ts b/src/providers/googlePlay/updateGooglePlayReleaseControlInEdit.ts new file mode 100644 index 0000000..f72bb46 --- /dev/null +++ b/src/providers/googlePlay/updateGooglePlayReleaseControlInEdit.ts @@ -0,0 +1,49 @@ +import type { AndroidDeploymentTrack } from '../../domain/AndroidDeploymentIntent'; +import type { ReleaseLifecycleControl } from '../../domain/release/ReleaseLifecycleControl'; +import { createGooglePlayTrackControlBody } from './createGooglePlayTrackControlBody'; +import type { GooglePlayTransport } from './GooglePlayTransport'; +import { googlePlayTrackEditUrl } from './googlePlayUrls'; +import { parseGooglePlayEditableTrack } from './parseGooglePlayEditableTrack'; + +type AndroidControl = Extract; + +export async function updateGooglePlayReleaseControlInEdit(options: { + readonly packageName: string; + readonly editId: string; + readonly track: AndroidDeploymentTrack; + readonly targetVersionCode: string; + readonly control: AndroidControl; + readonly token: string; + readonly request: GooglePlayTransport; +}): Promise { + const url = googlePlayTrackEditUrl(options.packageName, options.editId, options.track); + const current = await readTrack(options, url); + if (current === null) return false; + const body = createGooglePlayTrackControlBody({ + current, + targetVersionCode: options.targetVersionCode, + control: options.control, + }); + if (body === null) return false; + const response = await options.request({ + method: 'PUT', + url, + token: options.token, + contentType: 'application/json', + body: JSON.stringify(body), + }); + return response.status >= 200 && response.status < 300; +} + +async function readTrack( + options: Parameters[0], + url: string, +) { + const response = await options.request({ method: 'GET', url, token: options.token }); + if (response.status < 200 || response.status >= 300) return null; + try { + return parseGooglePlayEditableTrack(JSON.parse(response.body) as unknown, options.track); + } catch { + return null; + } +} diff --git a/src/providers/googlePlay/updateGooglePlayReleaseInEdit.ts b/src/providers/googlePlay/updateGooglePlayReleaseInEdit.ts new file mode 100644 index 0000000..cb8ae54 --- /dev/null +++ b/src/providers/googlePlay/updateGooglePlayReleaseInEdit.ts @@ -0,0 +1,50 @@ +import type { AndroidDeploymentTrack } from '../../domain/AndroidDeploymentIntent'; +import type { ReleaseNote } from '../../domain/release/ReleaseNote'; +import type { ReleaseTargetRollout } from '../../domain/release/ReleaseTargetRollout'; +import { createGooglePlayTrackUpdateBody } from './createGooglePlayTrackUpdateBody'; +import type { GooglePlayTransport } from './GooglePlayTransport'; +import { googlePlayTrackEditUrl } from './googlePlayUrls'; +import { parseGooglePlayEditableTrack } from './parseGooglePlayEditableTrack'; + +export async function updateGooglePlayReleaseInEdit(options: { + readonly packageName: string; + readonly editId: string; + readonly track: AndroidDeploymentTrack; + readonly targetVersionCode: string; + readonly releaseNotes?: readonly ReleaseNote[]; + readonly rollout?: ReleaseTargetRollout; + readonly token: string; + readonly request: GooglePlayTransport; +}): Promise { + const url = googlePlayTrackEditUrl(options.packageName, options.editId, options.track); + const current = await readTrack(options, url); + if (current === null) return false; + const body = createGooglePlayTrackUpdateBody({ + current, + targetVersionCode: options.targetVersionCode, + ...(options.releaseNotes === undefined ? {} : { releaseNotes: options.releaseNotes }), + ...(options.rollout === undefined ? {} : { rollout: options.rollout }), + }); + if (body === null) return false; + const response = await options.request({ + method: 'PUT', + url, + token: options.token, + contentType: 'application/json', + body: JSON.stringify(body), + }); + return response.status >= 200 && response.status < 300; +} + +async function readTrack( + options: Parameters[0], + url: string, +) { + const response = await options.request({ method: 'GET', url, token: options.token }); + if (response.status < 200 || response.status >= 300) return null; + try { + return parseGooglePlayEditableTrack(JSON.parse(response.body) as unknown, options.track); + } catch { + return null; + } +} diff --git a/src/providers/release/createWebReleaseObservation.ts b/src/providers/release/createWebReleaseObservation.ts new file mode 100644 index 0000000..a6dfad6 --- /dev/null +++ b/src/providers/release/createWebReleaseObservation.ts @@ -0,0 +1,15 @@ +import type { ReleaseObservedWebState } from '../../domain/release/ReleaseObservedWebState'; +import type { WebDeploymentPublication } from '../../domain/WebDeploymentPublication'; + +type WebReleasePublication = WebDeploymentPublication | Pick; + +export function createWebReleaseObservation( + desiredVersion: string, + publication: WebReleasePublication | null, +): ReleaseObservedWebState { + return { + target: 'web', + version: publication === null ? null : desiredVersion, + artifactRevision: publication?.revision ?? null, + }; +} diff --git a/src/providers/release/releaseObservationNormalization.test.ts b/src/providers/release/releaseObservationNormalization.test.ts new file mode 100644 index 0000000..b11c971 --- /dev/null +++ b/src/providers/release/releaseObservationNormalization.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from 'bun:test'; + +import { normalizeAppStoreReleaseObservation } from '../appStoreConnect/normalizeAppStoreReleaseObservation'; +import { normalizeGooglePlayReleaseObservation } from '../googlePlay/normalizeGooglePlayReleaseObservation'; +import { createWebReleaseObservation } from './createWebReleaseObservation'; + +test('Google Play release snapshot normalizes without provider ids', () => { + const observed = normalizeGooglePlayReleaseObservation({ + desiredVersion: '2.1.0', + publication: { + target: 'android', + revision: 'rev', + buildProvider: 'eas', + publishProvider: 'google-play', + buildId: 'build', + versionCode: 42, + track: 'production', + releaseStatus: 'draft', + }, + snapshot: { + track: 'production', + summary: { track: 'production', releases: [] }, + releases: [ + { + status: 'inProgress', + versionCodes: ['42'], + releaseNotes: [{ locale: 'en-US', text: 'New' }], + userFraction: '0.1', + }, + ], + }, + }); + expect(observed.artifactRevision).toBe('rev'); + expect(observed.userFraction).toBe('0.1'); + expect(JSON.stringify(observed)).not.toContain('build'); +}); + +test('App Store artifact identity is included only after publication verification', () => { + const snapshot = { + appId: 'provider-app-id', + version: '2.1.0', + versionId: 'provider-version-id', + appVersionState: 'WAITING_FOR_REVIEW', + releaseNotes: [{ locale: 'en-US', text: 'New' }], + reviewSubmission: { id: 'provider-review-id', state: 'WAITING_FOR_REVIEW' }, + phasedRelease: null, + } as const; + const publication = { + target: 'ios', + revision: 'ios-rev', + buildProvider: 'eas', + publishProvider: 'app-store-connect', + buildId: 'build-id', + version: '2.1.0', + buildNumber: '42', + } as const; + expect( + normalizeAppStoreReleaseObservation({ + publication, + publicationVerified: false, + snapshot, + }).artifactRevision, + ).toBeNull(); + const observed = normalizeAppStoreReleaseObservation({ + publication, + publicationVerified: true, + snapshot, + }); + expect(observed.artifactRevision).toBe('ios-rev'); + expect(JSON.stringify(observed)).not.toContain('provider-version-id'); + expect(JSON.stringify(observed)).not.toContain('provider-review-id'); +}); + +test('Web publication normalizes without provider deployment identity', () => { + const observed = createWebReleaseObservation('2.1.0', { + target: 'web', + revision: 'web-rev', + provider: 'vercel', + deploymentId: 'provider-deployment-id', + url: 'https://example.test', + production: true, + }); + expect(observed).toEqual({ + target: 'web', + version: '2.1.0', + artifactRevision: 'web-rev', + }); +}); diff --git a/src/releaseExports.test.ts b/src/releaseExports.test.ts new file mode 100644 index 0000000..19918de --- /dev/null +++ b/src/releaseExports.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from 'bun:test'; + +import type { + ReleaseDesiredState, + ReleaseLifecycleControl, + ReleaseMutationResult, + ReleaseObservedState, + ReleasePlan, + ReleaseReconcileResult, +} from './index.js'; +import * as deploy from './index.js'; + +test('root entrypoint exposes the provider-neutral release lifecycle', () => { + expect(typeof deploy.createReleaseRevision).toBe('function'); + expect(typeof deploy.createReleaseCurrentRevision).toBe('function'); + expect(typeof deploy.createReleasePlan).toBe('function'); + expect(typeof deploy.createReleaseExecutionState).toBe('function'); + expect(typeof deploy.executeReleasePlan).toBe('function'); + expect(typeof deploy.resumeReleaseExecution).toBe('function'); + expect(typeof deploy.listReleaseLifecycleControls).toBe('function'); + expect(typeof deploy.executeReleaseLifecycleControl).toBe('function'); + expect(typeof deploy.isReleaseStepResumable).toBe('function'); +}); + +test('release public types compose without provider-specific contracts', () => { + const desired = desiredState(); + const observed: ReleaseObservedState = { + targets: [{ target: 'web', version: '2.1.0', artifactRevision: 'revision' }], + }; + const plan: ReleasePlan = deploy.createReleasePlan(desired, observed); + const control: ReleaseLifecycleControl = { target: 'android', action: 'halt' }; + const mutation: ReleaseMutationResult = { status: 'completed' }; + const result: ReleaseReconcileResult = { + status: 'completed', + plan, + currentRevision: plan.currentRevision, + executedStepIds: [], + }; + expect([control.action, mutation.status, result.status]).toEqual([ + 'halt', + 'completed', + 'completed', + ]); +}); + +function desiredState(): ReleaseDesiredState { + const base = { + version: '2.1.0', + targets: ['web'] as const, + notes: [], + rollout: { web: { mode: 'immediate' as const } }, + }; + return { ...base, revision: deploy.createReleaseRevision(base) }; +}