Updates are agent steps: --yes ships an update of a live app (Play production, App Store submit / release) without an owner gate; the first release stays the owner's (exit 3 OWNER_STEP, --first-release); apple release is a ship step
This commit is contained in:
parent
c247b7e54a
commit
ae9d26d3f6
28 changed files with 562 additions and 44 deletions
|
|
@ -617,7 +617,7 @@ ${secs.join('\n\n')}
|
|||
const ACT: Record<string, string> = {
|
||||
app_store_connect_api_key: 'ASC_KEY_ID / ASC_ISSUER_ID / ASC_P8 in .storebound/credentials.json (the .p8 path, never its contents)',
|
||||
upload_to_app_store: '`storebound apple listing`, `apple screenshots`, `apple previews` (dry run first; submitting stays with the owner)', deliver: '`storebound apple listing` / `apple screenshots`',
|
||||
upload_to_play_store: '`storebound play listing`, `play images`, `play release <aab>` (testing tracks; production is the owner\'s)', supply: '`storebound play listing` / `play images` / `play release`',
|
||||
upload_to_play_store: '`storebound play listing`, `play images`, `play release <aab>` (testing tracks; production updates of a published app: --production completed --yes; the first-ever release is the owner\'s)', supply: '`storebound play listing` / `play images` / `play release`',
|
||||
upload_to_testflight: '`storebound apple testflight` (testflight skill)', pilot: '`storebound apple testflight` (testflight skill)',
|
||||
match: 'keep match for signing', sync_code_signing: 'keep match for signing',
|
||||
snapshot: '`storebound shots capture --source ios-sim`', capture_screenshots: '`storebound shots capture --source ios-sim`', capture_ios_screenshots: '`storebound shots capture --source ios-sim`',
|
||||
|
|
|
|||
68
cli/lib/release-kind.ts
Normal file
68
cli/lib/release-kind.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -286,7 +286,7 @@ export function resubmitRows(s: SubmitState, plan: SubmitPlan, build: BuildStep,
|
|||
}
|
||||
for (const it of plan.items) rows.push({ step: 'item', what: it.label, now: it.devRejected ? 'DEVELOPER_REJECTED' : '-', action: addAction(it) });
|
||||
if (ch?.action === 'reuse') for (const d of ch.strayDrafts) rows.push({ step: 'stray draft', what: d.id, now: `${d.state}, ${d.items.length} item(s)`, action: d.items.length ? 'left as it is (named)' : `left as it is: ${EMPTY_DRAFT}` });
|
||||
if (ch?.action !== 'busy') rows.push({ step: 'submit', what: 'PATCH submitted: true', now: '-', action: 'owner step (--yes); retried while Apple says "not ready yet" and no item is left REJECTED' });
|
||||
if (ch?.action !== 'busy') rows.push({ step: 'submit', what: 'PATCH submitted: true', now: '-', action: 'send with --yes (update of an app on sale: no owner step; the first-ever release: the owner approves it once); retried while Apple says "not ready yet" and no item is left REJECTED' });
|
||||
return rows;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,10 +29,11 @@ export const SHIP_STEPS: Record<string, { step: string; store: 'apple' | 'play'
|
|||
'apple upload': { step: 'upload', store: 'apple' },
|
||||
'apple submit': { step: 'submit', store: 'apple' },
|
||||
'apple resubmit': { step: 'resubmit', store: 'apple' },
|
||||
'apple release': { step: 'release', store: 'apple' },
|
||||
};
|
||||
|
||||
/** error.code of an exit 3: what the owner has to do. */
|
||||
export const OWNER_CODES = ['AGREEMENT_PENDING', 'SIGN_IN_REQUIRED', 'COMPLIANCE_ANSWER_MISSING', 'CONSOLE_STEP', 'CONFIRMATION_REQUIRED'] as const;
|
||||
export const OWNER_CODES = ['AGREEMENT_PENDING', 'SIGN_IN_REQUIRED', 'COMPLIANCE_ANSWER_MISSING', 'CONSOLE_STEP', 'CONFIRMATION_REQUIRED', 'OWNER_STEP'] as const;
|
||||
|
||||
export interface ShipErrorInfo { code: string; message: string; consoleUrl?: string }
|
||||
export interface ShipResult {
|
||||
|
|
@ -155,13 +156,14 @@ export function fieldsFromDetail(key: string, d: unknown): Fields {
|
|||
const b = x.build as { number?: string } | undefined;
|
||||
return { version: str(v?.versionString), build: str(b?.number), state: dry ? 'planned' : 'WAITING_FOR_REVIEW' };
|
||||
}
|
||||
case 'apple release': return { version: str(x.version), state: dry ? 'planned' : str(x.state) };
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_URL: Record<string, ConsolePage> = {
|
||||
'play build': 'play.bundle-explorer', 'play upload': 'play.releases', 'play release': 'play.releases',
|
||||
'apple build': 'asc.testflight', 'apple upload': 'asc.testflight', 'apple submit': 'asc.app-review', 'apple resubmit': 'asc.app-review',
|
||||
'apple build': 'asc.testflight', 'apple upload': 'asc.testflight', 'apple submit': 'asc.app-review', 'apple resubmit': 'asc.app-review', 'apple release': 'asc.version',
|
||||
};
|
||||
|
||||
/** The final result: the contract keys, then the command's own --json fields that do not clash with them (so readers
|
||||
|
|
@ -297,6 +299,7 @@ const VERSION_FLAGS: Record<string, { version?: string; build?: string; intBuild
|
|||
'apple upload': { build: 'build' },
|
||||
'apple submit': { version: 'version', build: 'build' },
|
||||
'apple resubmit': { version: 'version', build: 'build' },
|
||||
'apple release': { version: 'version' },
|
||||
};
|
||||
|
||||
/** The argv with the flags of --version-from added (a flag given by hand must agree with the file). */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue