140 lines
9.4 KiB
TypeScript
140 lines
9.4 KiB
TypeScript
// Pure pieces of `portfolio.status` (serve.ts): an app's identity across platforms, its release trains (one version
|
|
// across the App Store and Google Play) and the in-app catalog parity between the config and both stores.
|
|
// Inputs are the `apple status --json` / `play status --json` results, read defensively: "unknown, never false".
|
|
import type { AppIdentity, CatalogParity, ProjectSummary, ReleasePhase, ReleaseTrain, StoreRelease } from './desktop-protocol';
|
|
|
|
type J = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
|
|
export function appIdentity(s: ProjectSummary): AppIdentity {
|
|
return {
|
|
id: s.dir,
|
|
name: s.name,
|
|
platforms: {
|
|
ios: s.apple && s.platforms.includes('ios') ? { bundleId: s.apple.bundleId, ascAppId: s.apple.appId } : null,
|
|
android: s.play && s.platforms.includes('android') ? { packageName: s.play.packageName } : null,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Numeric compare of dotted versions ("2.10.0" > "2.9.1"); non-numeric parts compare as 0. */
|
|
export function cmpVersion(a: string, b: string): number {
|
|
const pa = a.split('.').map(x => parseInt(x, 10) || 0), pb = b.split('.').map(x => parseInt(x, 10) || 0);
|
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] ?? 0) - (pb[i] ?? 0); if (d) return d; }
|
|
return 0;
|
|
}
|
|
|
|
/** App Store version states (appStoreState / appVersionState) -> phase. */
|
|
export function applePhase(state: string): ReleasePhase {
|
|
if (/^(READY_FOR_SALE|READY_FOR_DISTRIBUTION)$/.test(state)) return 'live';
|
|
if (/^(PENDING_DEVELOPER_RELEASE|PENDING_APPLE_RELEASE|ACCEPTED)$/.test(state)) return 'approved';
|
|
if (/^(WAITING_FOR_REVIEW|IN_REVIEW|WAITING_FOR_EXPORT_COMPLIANCE|PROCESSING_FOR_DISTRIBUTION|PROCESSING_FOR_APP_STORE)$/.test(state)) return 'review';
|
|
if (/REJECTED$/.test(state)) return 'rejected';
|
|
if (/^(PREPARE_FOR_SUBMISSION|DEVELOPER_ACTION_NEEDED)$/.test(state)) return 'draft';
|
|
return 'other';
|
|
}
|
|
|
|
/** Apple's phased release schedule: share of users with automatic updates on day 1-7. */
|
|
export const PHASED_SCHEDULE = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1] as const;
|
|
|
|
/** Phased release of a version (`apple status --json` phased[versionId]) -> rollout share, or null. */
|
|
export function phasedFraction(p: { state?: string; currentDayNumber?: number | null } | null | undefined): number | null {
|
|
if (!p?.state) return null;
|
|
if (p.state === 'COMPLETE') return 1;
|
|
const d = typeof p.currentDayNumber === 'number' ? Math.min(7, Math.max(1, Math.round(p.currentDayNumber))) : null;
|
|
return d ? PHASED_SCHEDULE[d - 1]! : p.state === 'INACTIVE' ? 0 : null;
|
|
}
|
|
|
|
export function appleReleases(data: unknown): StoreRelease[] {
|
|
const versions = ((data as J)?.versions ?? []) as J[];
|
|
const recent = (((data as J)?.builds ?? []) as J[]);
|
|
const builds = new Map([...recent, ...(((data as J)?.attachedBuilds ?? []) as J[])].map(b => [b.id, b.attributes?.version] as const));
|
|
const phasedById = (((data as J)?.phased ?? {}) as Record<string, J | null>);
|
|
const rows = versions.filter(v => typeof v?.attributes?.versionString === 'string' && (v.attributes.platform ?? 'IOS') === 'IOS').map(v => {
|
|
const state = String(v.attributes.appVersionState ?? v.attributes.appStoreState ?? 'UNKNOWN');
|
|
const buildId = v.relationships?.build?.data?.id;
|
|
let phase = applePhase(state);
|
|
const pr = (typeof v.id === 'string' ? phasedById[v.id] : null) ?? null;
|
|
const prState = typeof pr?.attributes?.phasedReleaseState === 'string' ? String(pr.attributes.phasedReleaseState) : null;
|
|
const day = typeof pr?.attributes?.currentDayNumber === 'number' ? pr.attributes.currentDayNumber as number : null;
|
|
const fraction = phase === 'live' && prState ? phasedFraction({ state: prState, currentDayNumber: day }) : null;
|
|
// live but still rolling out (phased release on, not complete): the same as a Play staged rollout
|
|
if (phase === 'live' && prState === 'ACTIVE') phase = 'rollout';
|
|
if (phase === 'live' && prState === 'PAUSED') phase = 'halted';
|
|
const r: StoreRelease = { store: 'ios', version: v.attributes.versionString as string, build: (buildId && builds.get(buildId)) ? String(builds.get(buildId)) : null, state, phase, fraction, track: null };
|
|
if (prState) r.phased = { state: prState, day };
|
|
if (typeof v.attributes.createdDate === 'string') r.date = v.attributes.createdDate;
|
|
return r;
|
|
});
|
|
return [...rows, ...testflightReleases(recent, ((data as J)?.buildVersions ?? {}) as Record<string, string>, new Set(rows.map(r => r.version)))];
|
|
}
|
|
|
|
/** Marketing versions that have a processed build but no App Store version yet: on the train at "testing" (TestFlight),
|
|
* with the highest build number. Seen live: builds of 0.6.0 in TestFlight while 0.5.0 waited for review. */
|
|
export function testflightReleases(builds: J[], buildVersions: Record<string, string>, versions: Set<string | null>): StoreRelease[] {
|
|
const best = new Map<string, string>();
|
|
for (const b of builds) {
|
|
const v = buildVersions[b?.id];
|
|
const n = b?.attributes?.version;
|
|
if (!v || versions.has(v) || typeof n !== 'string' || b.attributes.processingState !== 'VALID' || b.attributes.expired) continue;
|
|
const cur = best.get(v);
|
|
if (!cur || cmpVersion(n, cur) > 0) best.set(v, n);
|
|
}
|
|
return [...best].map(([version, build]) => ({ store: 'ios' as const, version, build, state: 'TESTFLIGHT', phase: 'testing' as const, fraction: null, track: 'TestFlight' }));
|
|
}
|
|
|
|
/** Leading dotted version of a Play release name ("2.3.0 (45)" -> "2.3.0"). */
|
|
export const versionOfName = (name: unknown): string | null => (typeof name === 'string' ? name.match(/\d+(?:\.\d+){0,3}/)?.[0] ?? null : null);
|
|
|
|
export function playReleases(data: unknown): StoreRelease[] {
|
|
const out: StoreRelease[] = [];
|
|
for (const t of ((data as J)?.tracks ?? []) as J[]) {
|
|
for (const r of (t?.releases ?? []) as J[]) {
|
|
const codes = ((r.versionCodes ?? []) as unknown[]).map(Number).filter(Number.isFinite);
|
|
const production = t.track === 'production';
|
|
const status = String(r.status ?? 'unknown');
|
|
const phase: ReleasePhase = !production ? (status === 'draft' ? 'draft' : 'testing')
|
|
: status === 'completed' ? 'live' : status === 'inProgress' ? 'rollout' : status === 'halted' ? 'halted' : status === 'draft' ? 'draft' : 'other';
|
|
out.push({ store: 'android', version: versionOfName(r.name), build: codes.length ? String(Math.max(...codes)) : null, state: status, phase, fraction: typeof r.userFraction === 'number' ? r.userFraction : null, track: String(t.track) });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const PHASE_RANK: Record<ReleasePhase, number> = { live: 0, rollout: 1, halted: 2, approved: 3, review: 4, rejected: 5, testing: 6, draft: 7, other: 8 };
|
|
const pick = (rs: StoreRelease[]): StoreRelease | null => [...rs].sort((a, b) => PHASE_RANK[a.phase] - PHASE_RANK[b.phase])[0] ?? null;
|
|
|
|
/**
|
|
* Release trains: one version across both stores. current = the highest version that reaches users on a store (live,
|
|
* or rolling out: a staged rollout or an iOS phased release, also when halted / paused); next = the highest version
|
|
* above it on either store (review, testing, draft ...). A store without that version has null in the train.
|
|
*/
|
|
export function releaseTrains(ios: StoreRelease[], android: StoreRelease[]): { current: ReleaseTrain | null; next: ReleaseTrain | null } {
|
|
const all = [...ios, ...android].filter((r): r is StoreRelease & { version: string } => !!r.version);
|
|
const train = (v: string): ReleaseTrain => ({ version: v, ios: pick(all.filter(r => r.store === 'ios' && r.version === v)), android: pick(all.filter(r => r.store === 'android' && r.version === v)) });
|
|
const live = all.filter(r => r.phase === 'live' || r.phase === 'rollout' || r.phase === 'halted').map(r => r.version).sort(cmpVersion).at(-1) ?? null;
|
|
const above = all.filter(r => !live || cmpVersion(r.version, live) > 0).map(r => r.version).sort(cmpVersion).at(-1) ?? null;
|
|
return { current: live ? train(live) : null, next: above ? train(above) : null };
|
|
}
|
|
|
|
/** Product ids on App Store Connect (`apple status --json` iaps) and Google Play (`play status --json` products). */
|
|
export const appleProductIds = (data: unknown): string[] => (((data as J)?.iaps ?? []) as J[]).map(p => p?.attributes?.productId).filter((x): x is string => typeof x === 'string');
|
|
export const playProductIds = (data: unknown): string[] => (((data as J)?.products ?? []) as J[]).map(p => p?.productId ?? p?.sku).filter((x): x is string => typeof x === 'string');
|
|
|
|
/**
|
|
* Catalog parity. Without store data (offline) only the config side is known. With it: ids only on one store,
|
|
* and config ids missing from a store the app is on.
|
|
*/
|
|
export function catalogParity(s: ProjectSummary, apple?: string[], play?: string[]): CatalogParity {
|
|
const live = apple !== undefined || play !== undefined;
|
|
const onlyIos = apple && play ? apple.filter(x => !play.includes(x)) : [];
|
|
const onlyAndroid = apple && play ? play.filter(x => !apple.includes(x)) : [];
|
|
// the status reads list one-time products; subscriptions are compared by `catalog diff` (catalog.diff method)
|
|
const sold = (ids: string[]) => ids.filter(x => s.productIds.includes(x));
|
|
const missingIos = apple ? sold(s.catalog.ios).filter(x => !apple.includes(x)) : [];
|
|
const missingAndroid = play ? sold(s.catalog.android).filter(x => !play.includes(x)) : [];
|
|
return {
|
|
checked: live ? 'live' : 'config', products: s.products, subscriptions: s.subscriptions,
|
|
onlyIos, onlyAndroid, missingIos, missingAndroid,
|
|
ok: !onlyIos.length && !onlyAndroid.length && !missingIos.length && !missingAndroid.length,
|
|
};
|
|
}
|