// Releases: the release timeline (one version on both stores on one track: build -> testing -> review -> live -> // rollout %), then every version read from the stores, then sending for review (Submit.tsx): App Store submit / // resubmit / in-app purchases and the Google Play production release as owner actions, the Play steps without an API // as console plans. Releasing an approved version (manual release) stays in App Store Connect. import { plainError } from '../../../../../cli/lib/google-errors'; import { type CSSProperties, type ReactNode, useState } from 'react'; import type { AppStatus, ReleaseTrain, StoreRelease } from '../../api'; import { laneGap, storesDiffer } from '../../../../../cli/lib/train'; import type { UnifiedApp } from '../../lib/identity'; import { WriteButton } from '../../ui/WriteButton'; import { type Key, t } from '../../i18n'; import { ago, date, share } from '../../lib/format'; import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory } from '../../lib/model'; import { useData, useRead, useWrite } from '../../store'; import { Icon, StoreGlyph } from '../../ui/icons'; import { RolloutBar } from '../../ui/charts'; import { Card, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit'; import { EmptyState, Section, StatusPill, tintStyle, useTint } from '../../ui/ds'; import { AppleSubmitCard, PlayProductionCard } from './Submit'; import { useContextMenu } from '../../ui/ContextMenu'; import { releaseMenu } from '../../ui/app-menu'; import '../../styles/releases.css'; const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' }; function stageCaption(l: Lane, i: number): string { if (i !== l.at || !l.release) return ''; const r = l.release; if (i === 1) return r.store === 'ios' ? 'TestFlight' : (r.track ?? t('stage.testing')); if (i === 2) return r.store === 'android' ? phaseLabel(r).text : t(`phase.${r.phase}` as Key); if (i === 3 && r.phase === 'released') return phaseLabel(r).text; return ''; } /** The text of a lane that does not carry this version: couldn't read, the store's own current version, not there yet, * or (only without any record of the app on that store) "Not on this store". */ function gapText(lane: Lane, version: string, st: AppStatus): { who: string; line: string; kind: string } { const g = laneGap(lane.store, st.release.all ?? [], st.storeErrors[lane.store]); const store = storeName(lane.store); if (g.kind === 'unreadable') return { kind: g.kind, who: t('tl.unknown'), line: t('tl.unreadable', { store }) }; if (g.kind === 'other') return { kind: g.kind, who: g.now.version, line: t('tl.otherVersion', { now: g.now.version, phase: t(`phase.${g.now.phase}` as Key), version }) }; if (g.kind === 'missing') return { kind: g.kind, who: t('tl.notThisVersion'), line: t('tl.noVersion', { version, store }) }; return { kind: g.kind, who: t('tl.notOnStore'), line: t('tl.notOnStore') }; } /** One store's card of a release train: store mark, version (build), the current state as a pill, the five stages * as a stepper, a big rollout ring when it is rolling out, and that store's rollout controls. */ function LaneRow({ lane, version, st, app }: { lane: Lane; version: string; st: AppStatus; app: UnifiedApp }) { const r = lane.release; const gap = lane.at < 0 ? gapText(lane, version, st) : null; const ph = r ? phaseLabel(r) : null; const rolling = lane.at === STAGES.length - 1 && lane.share !== null; const ringTone = lane.tone === 'bad' ? 'bad' : (lane.share ?? 0) >= 1 ? 'ok' : 'accent'; const d = useData(); const menu = useContextMenu(); return (
releaseMenu(d, app, lane.store, r?.version ?? version, r?.build))} className="tl-lane rel-store" data-store={lane.store} data-tone={lane.tone} aria-label={storeName(lane.store)}>
{storeName(lane.store)}
{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}
{ph && {ph.text}}
{gap ?
{gap.line}
: (
    {STAGES.map((s, i) => { const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : 'todo'; const cap = stageCaption(lane, i); return (
  1. {i < lane.at ? : {i + 1}} {t(STAGE_KEY[s])} {cap && {cap}}
  2. ); })}
{rolling && lane.share !== null && (
{lane.share >= 1 ? t('tl.everyone') : `${share(lane.share)}${r?.phased?.day ? ` · ${t('tl.day', { day: r.phased.day })}` : ''}`} {t('v2rel.ofUsers')}
)}
)} {r && app.project && }
); } const SHARES = [1, 2, 5, 10, 20, 50]; /** Rollout controls of one store release: Play staged rollout share / halt / resume / complete, iOS phased release * pause / resume / everyone, or turning the phased release on for an upcoming iOS version. Owner actions: each one is * a dry run first, then a system dialog that names it. */ function RolloutControls({ app, r }: { app: UnifiedApp; r: StoreRelease }) { const w = useWrite(app.project?.id ?? null); const pct = r.fraction !== null ? Math.round(r.fraction * 100) : null; const [share, setShare] = useState(() => String(SHARES.find(s => pct === null || s > pct) ?? 50)); if (!app.project) return null; const v = r.version ?? ''; if (r.store === 'android' && (r.phase === 'rollout' || r.phase === 'halted')) { const track = r.track && ['production', 'beta', 'alpha', 'internal'].includes(r.track) ? r.track : 'production'; const next = SHARES.filter(s => pct === null || s > pct); return (
{t('roll.playNow', { pct: pct ?? 0 })} {r.phase === 'rollout' && next.length > 0 && <> {t('roll.toShare', { pct: share })} } {r.phase === 'rollout' && {t('roll.halt')}} {r.phase === 'halted' && {t('roll.resume')}} {t('roll.complete')}
); } if (r.store === 'ios' && r.phased && (r.phased.state === 'ACTIVE' || r.phased.state === 'PAUSED')) { return (
{t('roll.iosNow', { day: r.phased.day ?? 0 })} {r.phased.state === 'ACTIVE' && {t('roll.pause')}} {r.phased.state === 'PAUSED' && {t('roll.resume')}} {t('roll.everyone')}
); } if (r.store === 'ios' && !r.phased && ['draft', 'review', 'approved'].includes(r.phase) && /^\d+(\.\d+){0,2}$/.test(v)) { return (
{t('roll.phasedOn')}
); } return null; } function Train({ title, train, tf, tone, app, st }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok'; app: UnifiedApp; st: AppStatus }) { const lanes = trainLanes(train, tf); // the stores' current versions differ (normal: they do not always move together): a neutral "Current" header const differ = tone === 'ok' ? storesDiffer(st.release.all ?? []) : null; // "Live" only when a store has it live or rolling out; a Play release whose store page could not be checked is not const label = differ ? t('tl.current') : tone === 'ok' && !lanes.some(l => l.release && ['live', 'rollout', 'halted'].includes(l.release.phase)) ? t('tl.released') : title; const ver = differ ? t('tl.bothVersions', { play: differ.android, ios: differ.ios }) : train.version; const created = train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null; return (
{t('v2rel.version')} {ver}
{label} {created && {created}}
{lanes.map(l => )}
); } export function ReleasesTab({ app }: { app: UnifiedApp }) { const d = useData(); const menu = useContextMenu(); const l = d.liveFor(app); const project = app.project; const hue = useTint(d.iconFor(app), app.name); const tf = useRead(app.target, 'apple.testflight_status', { network: true, title: 'TestFlight', skip: !app.ios && !d.statusFor(app)?.app?.platforms.ios }); const sum = project ? d.statusOf(project)?.summary : null; const submit = project && (sum?.apple || sum?.play) ? (

{t('sub.section')}

{t('releases.ownerNote')}

{sum?.apple && } {sum?.play && }
) : null; const wrap = (children: ReactNode) =>
{children}
; if (!l && d.portfolioBusy === 'live') return wrap(<>{submit}); if (!l) { return wrap( <> {project && !project.playAccount && !project.appleAccount && {t('releases.noAccounts')}} }>{t('releases.notReadText')} {submit} , ); } const history = versionHistory(l.release.all ?? []); const errs = Object.entries(l.storeErrors); return wrap( <> {errs.map(([s, msg]) => { const when = l.staleStores?.[s as 'ios']; return {when ? t('releases.storeStale', { store: storeName(s as 'ios'), when: ago(when), error: plainError(msg) }) : t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: plainError(msg) })}; })} {l.release.next && } {l.release.current && } {!l.release.next && !l.release.current &&
{t('releases.noVersions')} {t('releases.noVersionsText')}
}
{t('v2rel.versions', { count: history.length })} : undefined}> {history.length ? (
{history.map(h => ( [ ...(h.ios ? [{ label: 'App Store', icon: 'phone' as const, submenu: releaseMenu(d, app, 'ios', h.ios.version, h.ios.build) }] : []), ...(h.android ? [{ label: 'Google Play', icon: 'phone' as const, submenu: releaseMenu(d, app, 'android', h.android.version, h.android.build) }] : []), ])}> ))}
{t('col.version')}App Store{t('col.build')}Google Play{t('col.versionCode')}{t('col.track')}
{[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')} {h.ios ? : –} {h.ios?.build ?? '–'} {h.android ? : –} {h.android?.build ?? '–'} {h.android?.track ?? '–'}
) :
{t('releases.noVersions')}
}
{submit} , ); }