68 lines
4.7 KiB
TypeScript
68 lines
4.7 KiB
TypeScript
// 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;
|
|
}
|