// Update or first release? The owner's standing rule (docs/bypass.md, "Updates are agent steps"): a new version of an // app that is ALREADY on the store ships end to end without an owner gate (submit for review, promote to production, // roll out, release an approved version) with --yes, also in a pipeline. The gate stays for the first-ever release of an // app (and, in their own commands, removal from sale and live price changes). // The kind comes from the store, never from the config: // Google Play the production track holds a completed / inProgress / halted release (the app was published) // App Store the platform has a version that is or was on sale (READY_FOR_SALE, READY_FOR_DISTRIBUTION, // REPLACED_WITH_NEW_VERSION) // A first release with --yes (nobody to answer a prompt) is refused with exit 3 `OWNER_STEP` unless the owner approved // it: `--first-release` after the owner's yes in chat, or bypass mode (docs/bypass.md). In a terminal without --yes the // normal confirmation prompt is the owner's approval. import { bypassOn } from './bypass'; import type { Ctx } from './ctx'; import { declined } from './ctx'; import { log } from './log'; import { SHIP_EXIT, ShipStepError, shipRun } from './ship-step'; export type ReleaseKind = 'update' | 'first'; export interface KindAnswer { kind: ReleaseKind; why: string } /** Play: the production track's releases say whether the app was ever published. Pure. */ export function playReleaseKind(production: { status?: string; versionCodes?: string[] }[] | undefined): KindAnswer { const live = (production ?? []).filter(r => ['completed', 'inProgress', 'halted'].includes(String(r.status))); if (live.length) return { kind: 'update', why: `the production track holds ${live.map(r => `${r.status} ${(r.versionCodes ?? []).join(',')}`).join('; ')}` }; const drafts = (production ?? []).length; return { kind: 'first', why: drafts ? 'the production track has only draft releases: the app was never published' : 'the production track is empty: the app was never published' }; } /** App Store version states that prove the app was on sale. */ export const APPLE_LIVE_STATES = ['READY_FOR_SALE', 'READY_FOR_DISTRIBUTION', 'REPLACED_WITH_NEW_VERSION'] as const; /** App Store: the versions of one platform say whether the app was ever on sale. Pure. */ export function appleReleaseKind(versions: { versionString?: string; state: string }[]): KindAnswer { const live = versions.filter(v => (APPLE_LIVE_STATES as readonly string[]).includes(v.state)); if (live.length) return { kind: 'update', why: `${live.map(v => `${v.versionString ?? '?'} ${v.state}`).join(', ')} on this platform` }; return { kind: 'first', why: 'no version of this app was ever on sale on this platform' }; } export interface GateInput { kind: ReleaseKind; yes: boolean; bypass: boolean; approved: boolean } /** Whether the step may run. Updates always; a first release only with a person answering a prompt (no --yes), the * owner's `--first-release`, or bypass mode. Pure. */ export function firstReleaseAllowed(g: GateInput): boolean { return g.kind === 'update' || !g.yes || g.approved || g.bypass; } export const firstReleaseMessage = (store: 'play' | 'apple', what: string): string => `first release of this app (${store === 'play' ? 'Google Play' : 'App Store'}): ${what} is the owner's step, approved once. Updates of an app that is already live need no approval. ` + 'After the owner\'s yes: re-run with --first-release (or turn on bypass mode: storebound settings bypass on)'; /** * Applies the policy to a real run. Returns true when the step may go on. A refusal is exit 3: error.code OWNER_STEP * under the ship-step contract (with the console deep link), the usual "declined" exit 3 otherwise. */ export function enforceFirstRelease(ctx: Ctx, k: KindAnswer, o: { store: 'play' | 'apple'; what: string; consoleUrl?: string }): boolean { const a = ctx.args; const bypass = bypassOn(); // the desktop's owner action: the owner pressed the button of a native dialog that showed the dry run (cli/lib/ctx.ts confirm) const approved = a.has('first-release') || (process.env.STOREBOUND_SERVE === '1' && process.env.STOREBOUND_OWNER_CONFIRMED === '1'); if (firstReleaseAllowed({ kind: k.kind, yes: a.has('yes'), bypass, approved })) { if (k.kind === 'update') log('release', `update of a live app (${k.why}): no owner step needed`); else log('release', `first release (${k.why}): ${approved ? 'approved by the owner' : bypass ? 'bypass mode' : 'confirmation prompt'}`); return true; } const msg = `${firstReleaseMessage(o.store, o.what)} (${k.why})`; if (shipRun()) throw new ShipStepError(msg, SHIP_EXIT.owner, 'OWNER_STEP', o.consoleUrl); declined(msg); return false; }