Skip to content
10 changes: 10 additions & 0 deletions .changeset/bright-release-lifecycle.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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)
4 changes: 4 additions & 0 deletions src/domain/release/ReleaseControlExecutionResult.ts
Original file line number Diff line number Diff line change
@@ -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 };
11 changes: 11 additions & 0 deletions src/domain/release/ReleaseDesiredState.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 8 additions & 0 deletions src/domain/release/ReleaseDiagnostic.ts
Original file line number Diff line number Diff line change
@@ -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';
}
6 changes: 6 additions & 0 deletions src/domain/release/ReleaseExecutionState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { ReleaseExecutionStep } from './ReleaseExecutionStep';

export interface ReleaseExecutionState {
readonly releaseRevision: string;
readonly steps: readonly ReleaseExecutionStep[];
}
10 changes: 10 additions & 0 deletions src/domain/release/ReleaseExecutionStep.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions src/domain/release/ReleaseLifecycleControl.ts
Original file line number Diff line number Diff line change
@@ -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';
};
4 changes: 4 additions & 0 deletions src/domain/release/ReleaseNote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface ReleaseNote {
readonly locale: string;
readonly text: string;
}
11 changes: 11 additions & 0 deletions src/domain/release/ReleaseObservedAndroidState.ts
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions src/domain/release/ReleaseObservedIosState.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions src/domain/release/ReleaseObservedState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { ReleaseObservedTargetState } from './ReleaseObservedTargetState';

export interface ReleaseObservedState {
readonly targets: readonly ReleaseObservedTargetState[];
}
6 changes: 6 additions & 0 deletions src/domain/release/ReleaseObservedTargetState.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions src/domain/release/ReleaseObservedWebState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface ReleaseObservedWebState {
readonly target: 'web';
readonly version: string | null;
readonly artifactRevision: string | null;
}
11 changes: 11 additions & 0 deletions src/domain/release/ReleasePlan.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
1 change: 1 addition & 0 deletions src/domain/release/ReleasePlanStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type ReleasePlanStatus = 'no-change' | 'changes' | 'waiting' | 'blocked';
12 changes: 12 additions & 0 deletions src/domain/release/ReleasePlanStep.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 10 additions & 0 deletions src/domain/release/ReleaseReconcileResult.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 7 additions & 0 deletions src/domain/release/ReleaseRollout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { ReleaseTargetRollout } from './ReleaseTargetRollout';

export interface ReleaseRollout {
readonly web?: ReleaseTargetRollout;
readonly android?: ReleaseTargetRollout;
readonly ios?: ReleaseTargetRollout;
}
1 change: 1 addition & 0 deletions src/domain/release/ReleaseRolloutMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type ReleaseRolloutMode = 'immediate' | 'staged';
10 changes: 10 additions & 0 deletions src/domain/release/ReleaseStepOperation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type ReleaseStepOperation =
| 'prepare'
| 'build'
| 'publish'
| 'sync-notes'
| 'submit-review'
| 'release'
| 'rollout'
| 'verify'
| 'record';
1 change: 1 addition & 0 deletions src/domain/release/ReleaseStepRetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type ReleaseStepRetry = 'safe' | 'reinspect' | 'never';
1 change: 1 addition & 0 deletions src/domain/release/ReleaseTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type ReleaseTarget = 'web' | 'android' | 'ios';
10 changes: 10 additions & 0 deletions src/domain/release/ReleaseTargetPlanContribution.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions src/domain/release/ReleaseTargetRollout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { ReleaseRolloutMode } from './ReleaseRolloutMode';

export interface ReleaseTargetRollout {
readonly mode: ReleaseRolloutMode;
readonly initialFraction?: string;
}
10 changes: 10 additions & 0 deletions src/domain/release/areReleaseNotesSatisfied.ts
Original file line number Diff line number Diff line change
@@ -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),
);
}
113 changes: 113 additions & 0 deletions src/domain/release/createAndroidReleaseSteps.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading