validate: macOS versions need Mac screenshots, Play reads survive an ended edit; macOS build numbers across platforms; Mac build prebuild + apple.macAppCheck; signing wording off the Mac; dry flows plan uploads

This commit is contained in:
CRTkafa 2026-11-02 18:16:25 +07:00
parent b1f1b206d3
commit f5da7c9f7a
21 changed files with 519 additions and 57 deletions

View file

@ -133,7 +133,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'builds_betaBuildLocalizations_getToManyRelated': 'apple/testflight',
'builds_buildBetaDetail_getToOneRelated': 'apple/testflight',
'builds_diagnosticSignatures_getToManyRelated': 'health',
'builds_getCollection': 'apple/build apple/build-mas apple/encryption apple/listing apple/next-build apple/resubmit apple/status apple/testflight apple/upload art health lib/store-apps triage',
'builds_getCollection': 'apple/encryption apple/listing apple/next-build apple/resubmit apple/status apple/testflight apple/upload art health lib/store-apps triage',
'builds_getInstance': 'apple/device-families',
'builds_updateInstance': 'apple/encryption lib/build-ops',
'bundleIdCapabilities_createInstance': 'lib/signing',
@ -245,13 +245,13 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'androidpublisher.edits.bundles.upload': 'play/release play/upload',
'androidpublisher.edits.commit': 'play/client',
'androidpublisher.edits.countryavailability.get': 'validate',
'androidpublisher.edits.delete': 'apps onboard play/client validate',
'androidpublisher.edits.delete': 'apps onboard play/client',
'androidpublisher.edits.details.get': 'apps onboard play/listing play/status validate',
'androidpublisher.edits.details.update': 'play/listing',
'androidpublisher.edits.images.deleteall': 'play/images',
'androidpublisher.edits.images.list': 'apps art play/images play/listing validate',
'androidpublisher.edits.images.upload': 'play/images play/listing',
'androidpublisher.edits.insert': 'apps onboard play/client validate',
'androidpublisher.edits.insert': 'apps onboard play/client',
'androidpublisher.edits.listings.get': 'onboard',
'androidpublisher.edits.listings.list': 'art listing-store play/listing play/status validate',
'androidpublisher.edits.listings.patch': 'listing-store',

View file

@ -1,7 +1,7 @@
// Mac App Store build of a Tauri app: `tauri build --bundles app`, signed for the Mac App Store, then productbuild to a .pkg.
// storebound apple build --platform macos --tauri <src-tauri> [--path-free] [--target universal-apple-darwin]
// [--version x.y.z] [--build-number N] [--profile <file.provisionprofile>] [--out build/macos] [--unsigned] [--dry] [--json]
// Steps: Tauri project -> version / build number (next free one on App Store Connect, platform MAC_OS) -> signing
// Steps: Tauri project -> version / build number (next free one on App Store Connect, every platform) -> signing
// identities from the keychain ("Apple Distribution" or "3rd Party Mac Developer Application"; "Mac Installer
// Distribution" or "3rd Party Mac Developer Installer") -> Mac App Store provisioning profile (--profile, installed, or
// from the API) -> entitlements (app sandbox + application / team identifier, the project's own keys kept) -> a
@ -19,10 +19,13 @@ import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import { parseMobileprovision, parsePlist, type PlistValue } from '../lib/plist';
import { nextBuildNumber } from '../lib/release';
import { appNextBuild } from './next-build';
import { parseIdentities, profileDirs, readProfiles, type ApiProfile } from '../lib/signing';
import { sshBuildRefusal } from '../lib/keychain';
import { remoteBuildSession, showCmd } from '../lib/xcode';
import { prebuildFor, prebuildsDone, runPrebuild } from '../lib/web-assets';
import { preCheckCommand } from '../lib/pre-check';
import { pump } from '../lib/runner';
import {
appProblems, entitlementKeysOf, entitlementsXml, findBuiltApp, findTauri, leakNeedles, masEntitlements, type MasProfile, masSteps, pathFreeEnv, pickMasIdentities,
pickMasProfiles, pkgProblems, parsePkgSignature, readAppInfo, scanPathLeaks, tauriOverlay, categoryId,
@ -41,6 +44,16 @@ const run1 = (argv: string[], timeout = 120_000): { ok: boolean; out: string } =
};
/** The Tauri CLI: the project's node_modules/.bin/tauri (walking up), `cargo tauri`, else bunx. */
/** One sh -c command in root, its output streamed; the exit code and the last lines. */
async function runCheck(cmd: string, root: string): Promise<{ ok: boolean; code: number; tail: string }> {
const p = rt.spawn(['sh', '-c', cmd], { cwd: root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: process.env });
const tail: string[] = [];
const each = (line: string, kind: 'stdout' | 'stderr'): void => { tail.push(line); if (tail.length > 10) tail.shift(); (kind === 'stderr' ? console.error : console.log)(` ${line}`); };
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
const code = await p.exited;
return { ok: code === 0, code, tail: tail.join('\n') };
}
export function tauriCommand(cwd: string, exists: (p: string) => boolean = existsSync, which: (c: string) => string | null = rt.which): string[] {
for (let d = cwd, prev = ''; d !== prev; prev = d, d = dirname(d)) { const p = join(d, 'node_modules', '.bin', 'tauri'); if (exists(p)) return [p]; }
if (which('cargo-tauri')) return ['cargo', 'tauri'];
@ -72,7 +85,13 @@ async function fetchMasProfile(c: Asc, bundleId: string, dest: string): Promise<
return dest;
}
export async function run(ctx: Ctx): Promise<void> {
/** The dry-run line about signing identities: on a machine that is not a Mac, say so instead of "none found". */
export function identityLine(o: { platform: string; appIdentity: string | null; installerIdentity: string | null; profile: string | null }): string {
if (o.platform !== 'darwin') return `this is not a build machine: Mac App Store builds run on a Mac (the signing identities live in its keychain), so none were looked up here; profile: ${o.profile ?? '(none)'}`;
return `app identity: ${o.appIdentity ?? '(none found)'}; installer identity: ${o.installerIdentity ?? '(none found)'}; profile: ${o.profile ?? '(none)'}`;
}
export async function run(ctx: Ctx, platform: string = process.platform): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
@ -92,9 +111,10 @@ export async function run(ctx: Ctx): Promise<void> {
try {
client = await ascClient(root, bundleId, config.apple?.appId);
if (!build) {
const builds = await client.all(`/v1/builds?filter[app]=${await client.app()}&filter[preReleaseVersion.platform]=MAC_OS&limit=200&fields[builds]=version`);
build = nextBuildNumber(builds.map(b => String(b.attributes.version)));
log(TAG, `${builds.length} macOS builds on App Store Connect: next build number ${build} (CFBundleVersion must increase)`);
// every platform of the app record, not only macOS: CFBundleVersion must be unique across all its builds
const found = await appNextBuild(client);
build = found.next;
log(TAG, `${found.builds} builds (all platforms) on App Store Connect: next build number ${build} (CFBundleVersion must increase)`);
}
if (!teamId) teamId = (await findBundleId(client, bundleId))?.attributes?.seedId ?? null;
} catch (e) {
@ -107,7 +127,7 @@ export async function run(ctx: Ctx): Promise<void> {
const team = teamId ?? '<TEAM_ID>';
// identities and profile (a real signed run needs them; a dry run shows placeholders)
const mac = process.platform === 'darwin';
const mac = platform === 'darwin';
let appIdentity: string | null = null, installerIdentity: string | null = null;
if (!unsigned && mac) {
const picked = pickMasIdentities(parseIdentities(run1(['security', 'find-identity', '-v']).out), teamId);
@ -142,18 +162,26 @@ export async function run(ctx: Ctx): Promise<void> {
const overlay = tauriOverlay({ appIdentity, entitlementsFile: entFile, profile, version, build: buildNo, unsigned, minOs: tauri.minOs, category });
const input = { tauri, tauriCmd, target, out, configFile, entitlementsFile: entFile, profile, appIdentity, installerIdentity: installerIdentity ?? '<Mac Installer Distribution identity>', version, build: buildNo, unsigned, pathFree, pkg };
const steps = masSteps(input);
// the same hooks as apple build: build.prebuild / apple.prebuild first (a project computes its build-time env there),
// apple.macAppCheck on the built .app before productbuild (e.g. a project's own check that a value was stamped in)
const prebuild = prebuildFor(config, 'apple') ?? null;
const prebuildSkip = !prebuild ? undefined : a.has('no-prebuild') ? '--no-prebuild' : prebuildsDone().has(prebuild) ? 'already ran in this flow run' : undefined;
const appCheck = config.apple?.macAppCheck?.trim() || null;
const existingEnt = tauri.entitlements && existsSync(tauri.entitlements) ? (parsePlist(readFileSync(tauri.entitlements)) as Record<string, PlistValue>) : undefined;
const ent = masEntitlements({ teamId: team, bundleId, existing: existingEnt });
const plan = { platform: 'MAC_OS', tauri: relative(root, tauri.confPath), target, version, build: buildNo, teamId: teamId, unsigned, pathFree, appIdentity, installerIdentity,
profile: profile ? basenameOf(profile) : null, entitlements: Object.keys(ent), tauriOverlay: overlay, env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, k === 'RUSTFLAGS' ? v.replaceAll(home, '~') : v])),
prebuild, ...(prebuildSkip ? { prebuildSkip } : {}), appCheck,
steps: [showCmd(steps.tauri, null), showCmd(steps.codesignVerify, null), showCmd(steps.productbuild, null), showCmd(steps.pkgCheck, null)], out: relative(root, out) || '.', pkg: relative(root, pkg) };
if (dry) {
if (json) { printJson(plan); return; }
log(TAG, `Tauri ${tauri.productName} (${bundleId}) ${version} (${buildNo}) for ${target}, ${unsigned ? 'UNSIGNED' : 'Mac App Store signing'}${pathFree ? ', path-free' : ''}`);
log(TAG, `app identity: ${appIdentity ?? '(none found)'}; installer identity: ${installerIdentity ?? '(none found)'}; profile: ${plan.profile ?? '(none)'}`);
log(TAG, identityLine({ platform, appIdentity, installerIdentity, profile: plan.profile }));
log(TAG, `entitlements: ${plan.entitlements.join(', ')}`);
if (pathFree) log(TAG, `RUSTFLAGS: ${plan.env.RUSTFLAGS}`);
if (prebuild) log(TAG, prebuildSkip ? `prebuild skipped (${prebuildSkip}): ${prebuild}` : `would run the prebuild: ${prebuild}`);
for (const s of plan.steps) log(TAG, `would run: ${s}`);
if (appCheck) log(TAG, `would check the built .app: ${appCheck}`);
done(TAG, 'macOS build planned (dry run)');
return;
}
@ -164,6 +192,7 @@ export async function run(ctx: Ctx): Promise<void> {
mkdirSync(out, { recursive: true });
writeFileSync(entFile, entitlementsXml(ent));
writeFileSync(configFile, `${JSON.stringify(overlay, null, 2)}\n`);
if (prebuild && !prebuildSkip) await runPrebuild(prebuild, root, TAG);
shipPhase('build', `tauri build ${target}, build ${buildNo}`);
await runLogged(steps.tauri, tauri.cwd, join(out, 'tauri-build.log'), showCmd(steps.tauri, null), env);
const appDir = findBuiltApp(tauri.dir, root, target, tauri.productName);
@ -179,6 +208,13 @@ export async function run(ctx: Ctx): Promise<void> {
{ bundleId, version, build: buildNo, signed: !unsigned, pathFree });
for (const w of checks.warnings) warn(TAG, w);
if (checks.errors.length) throw new Error(`${basenameOf(appDir)} was built, but the Mac App Store would refuse it:\n${checks.errors.map(x => ` ${x}`).join('\n')}`);
if (appCheck) {
const cmd = preCheckCommand(appCheck, appDir);
log(TAG, `apple.macAppCheck: $ ${cmd}`);
const r = await runCheck(cmd, root);
if (!r.ok) throw new Error(`apple.macAppCheck failed (exit ${r.code}) on ${basenameOf(appDir)}; nothing packaged:\n${r.tail}`);
log(TAG, 'apple.macAppCheck: ok');
}
shipPhase('sign', 'productbuild');
rmSync(exportPath, { recursive: true, force: true });
mkdirSync(exportPath, { recursive: true });

View file

@ -34,7 +34,7 @@ import { done, log, printJson, warn } from '../lib/log';
import { derivedDataFor, archiveScanDirs, leakNeedles, leakProblem, outsideHome, pathFreeSettings, scanForPaths } from '../lib/path-free';
import { pump } from '../lib/runner';
import { parseMobileprovision, toXmlPlist } from '../lib/plist';
import { nextBuildNumber } from '../lib/release';
import { appNextBuild } from './next-build';
import { exportOptions, profileDirs } from '../lib/signing';
import { type FamilyVerdict, familyVerdicts, projectFamilies } from '../lib/device-families';
import { liveDeviceFamilies } from './device-families';
@ -177,9 +177,9 @@ export async function run(ctx: Ctx): Promise<void> {
try {
const c = await ascClient(root, bundleId, config.apple?.appId);
if (!build) {
const builds = await c.all(`/v1/builds?filter[app]=${await c.app()}&limit=200&fields[builds]=version`);
build = nextBuildNumber(builds.map(b => String(b.attributes.version)));
log(TAG, `${builds.length} builds on App Store Connect: next build number ${build}`);
const found = await appNextBuild(c);
build = found.next;
log(TAG, `${found.builds} builds on App Store Connect: next build number ${build}`);
}
if (!teamId) teamId = (await findBundleId(c, bundleId))?.attributes?.seedId ?? null;
} catch (e) {

View file

@ -7,15 +7,25 @@ import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { printJson } from '../lib/log';
import { nextBuildNumber } from '../lib/release';
import { ascClient } from './asc';
import { ascClient, type Asc } from './asc';
/**
* The one place that computes the next CFBundleVersion: the highest build of the app over EVERY platform (iOS, macOS,
* tvOS, visionOS), every processing state and every page, plus one. CFBundleVersion must be unique across the app
* record's builds, so a platform filter (a macOS build once got a number an uploaded macOS build already had) or a
* missed page gives a number the store refuses. `apple next-build`, `apple build` and `apple build --platform macos`
* (also their dry runs) all call this, so they agree.
*/
export async function appNextBuild(c: Pick<Asc, 'all' | 'app'>): Promise<{ next: string; builds: number }> {
const builds = await c.all(`/v1/builds?filter[app]=${await c.app()}&limit=200&fields[builds]=version`);
return { next: nextBuildNumber(builds.map(b => String(b.attributes.version))), builds: builds.length };
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app();
const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version`);
const next = nextBuildNumber(builds.map(b => String(b.attributes.version)));
if (ctx.args.has('json')) { printJson({ next, builds: builds.length }); return; }
console.error(`[apple] ${builds.length} builds on App Store Connect; next build number:`);
const { next, builds } = await appNextBuild(c);
if (ctx.args.has('json')) { printJson({ next, builds }); return; }
console.error(`[apple] ${builds} builds on App Store Connect; next build number:`);
console.log(next);
}

View file

@ -52,8 +52,10 @@ export function skippedDuoSets(apple: { iphoneDuo?: boolean; appId?: string } |
/** Display types `validate` requires on every locale: apple.screenshotTypes, else the classes App Store Connect's
* "App Previews and Screenshots" tab lists (October 2026): iPhone with Dynamic Island (medium display), filled by
* scaling a 6.9" or 6.5" set down (ACCEPTED_INSTEAD), plus iPad 13" display with ipad. iPhone Duo is optional. */
export const requiredDisplayTypes = (apple: { ipad?: boolean; screenshotTypes?: string[] } | undefined): string[] =>
apple?.screenshotTypes ?? ['APP_IPHONE_61', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])];
export const requiredDisplayTypes = (apple: { ipad?: boolean; mac?: boolean; screenshotTypes?: string[] } | undefined, platform: string = 'IOS'): string[] =>
platform === 'MAC_OS' ? (apple?.mac ? Object.values(MAC_DISPLAY) : []) // a macOS version takes the Mac set only (apple.screenshotTypes is the iOS list)
: platform !== 'IOS' ? []
: apple?.screenshotTypes ?? ['APP_IPHONE_61', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])];
/** Makes one screenshot set (of a version localization, a custom product page localization or a Product Page
* Optimization treatment localization) hold exactly `files`, in order: left alone when the MD5s match, else the

View file

@ -284,7 +284,7 @@ export async function run(ctx: Ctx): Promise<void> {
const editable = await c.editableVersion(platform).catch(() => null);
const trainWhy = ipaVersionProblem(info.version, editable?.attributes?.versionString as string | undefined, 'the editable App Store version');
if (trainWhy) { if (a.has('allow-version-mismatch')) warn(TAG, trainWhy); else throw new Error(`${relative(root, ipa)}: not uploaded, ${trainWhy}`); }
const builds = await c.all(`/v1/builds?filter[app]=${appId}${isPkg ? '&filter[preReleaseVersion.platform]=MAC_OS' : ''}&limit=200&fields[builds]=version,processingState`);
const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version,processingState`);
const numbers = builds.map(b => String(b.attributes.version));
const next = nextBuildNumber(numbers);
// the same IPA uploaded by an earlier run: nothing to send again, only the wait

View file

@ -20,7 +20,7 @@ import { dirname, join, relative } from 'node:path';
import type { Args } from './lib/args';
import { CONFIG_NAMES, loadConfig, type ShipConfig } from './lib/config';
import {
type CondCtx, expand, type Flow, flowsOfFile, kindOf, type Mode, parseParams, planStep, type PlannedStep, resumePoint, type RunState, startIndex,
type CondCtx, type DryPending, expand, type Flow, flowsOfFile, kindOf, missingParams, type Mode, noteDry, parseParams, planStep, plannedForDryInput, type PlannedStep, resumePoint, type RunState, startIndex,
type StepResult, validateFlows,
} from './lib/flow';
import { DECLINED_MARK } from './lib/ctx';
@ -170,6 +170,12 @@ async function runFlow(args: Args, root: string): Promise<void> {
const subParams = expand(flow, all, 'steps').params;
const vars: Record<string, string> = { ...subParams, ...flow.params, ...(from ? last?.vars ?? {} : {}), ...params };
const c = await condCtx(root);
vars['config.testflight.internal'] = (c.config?.apple?.testflight?.groups ?? []).filter(g => g.internal).map(g => g.name).join(',');
if (!args.has('resume')) {
const missing = missingParams(vars);
if (missing.length) throw new Error(`${name} needs ${missing.map(k => `${k}=<value>`).join(' ')} (storebound flow run ${name} ${missing.map(k => `${k}=...`).join(' ')})`);
}
const pending: DryPending = { built: new Set(), uploaded: new Set() };
const prebuilt = prebuildsDone();
const state: RunState = { flow: name, mode, startedAt: new Date().toISOString(), vars, results: [], ok: true };
const env = process.env as Record<string, string | undefined>;
@ -196,12 +202,20 @@ async function runFlow(args: Args, root: string): Promise<void> {
state.results.push(r); save();
continue;
}
const planned = plannedForDryInput(p, mode, pending);
if (planned) {
r.status = 'planned'; r.detail = planned;
if (!json) log(TAG, `${label}: ${describe(p)} (${planned})`);
state.results.push(r); save();
continue;
}
if (!json) log(TAG, `${label}: ${describe(p)}${p.dryAdded ? ' (dry run)' : ''}`);
const t = Date.now();
const x = await exec(p, root, json, prebuilt.size ? { [PREBUILD_DONE_ENV]: [...prebuilt].join('\n') } : {});
r.exitCode = x.code; r.ms = Date.now() - t;
if (x.code === 0) {
r.status = 'ok';
noteDry(p, pending);
const pre = p.argv ? prebuildRan(p.argv, c.config) : null;
if (pre) prebuilt.add(pre);
if (p.capture) {

View file

@ -716,6 +716,9 @@ export interface ShipConfig {
build?: AppleBuildConfig;
/** Shell command `apple build` runs first in the project root; overrides build.prebuild for the App Store. */
prebuild?: string;
/** `apple build --platform macos`: shell command run on the built .app before productbuild ({file} = the .app path);
* a nonzero exit stops the build, nothing is packaged. E.g. a project's own check that a build-time value is in the binary. */
macAppCheck?: string;
/** Promoted in-app purchases on the product page, in order (`storebound apple promoted`; at most 20). */
promoted?: PromotedConfig;
/** Custom product pages (`storebound apple cpp`; at most 70). */
@ -1019,6 +1022,7 @@ export function validateExtras(cfg: Partial<ShipConfig>): string[] {
if (cfg.build !== undefined && (typeof cfg.build !== 'object' || cfg.build === null)) e.push('build: { prebuild?: "<shell command>" }');
if (badCmd(cfg.build?.prebuild)) e.push('build.prebuild: a shell command run in the project root before apple build / play build, e.g. "npm run build && npx cap sync"');
if (badCmd(cfg.apple?.prebuild)) e.push('apple.prebuild: a shell command (overrides build.prebuild for apple build)');
if (badCmd(cfg.apple?.macAppCheck)) e.push('apple.macAppCheck: a shell command run on the built Mac .app ({file} = its path), e.g. "node tools/check-app.mjs {file}"');
if (badCmd(cfg.play?.prebuild)) e.push('play.prebuild: a shell command (overrides build.prebuild for play build)');
if (cfg.apple?.teamId !== undefined &&!/^[A-Z0-9]{10}$/.test(cfg.apple.teamId)) e.push('apple.teamId: the 10-character Team ID (Membership details)');
if (cfg.play?.developerId !== undefined && !/^\d{1,25}$/.test(cfg.play.developerId)) e.push('play.developerId: the digits of play.google.com/console/u/0/developers/<id>');

View file

@ -31,8 +31,8 @@ export const TEMPLATE_TEXT: Record<string, string> = {
"web/appstore.svg": "<svg role=\"img\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\"><title>App Store</title><path d=\"M8.8086 14.9194l6.1107-11.0368c.0837-.1513.1682-.302.2437-.4584.0685-.142.1267-.2854.1646-.4403.0803-.3259.0588-.6656-.066-.9767-.1238-.3095-.3417-.5678-.6201-.7355a1.4175 1.4175 0 0 0-.921-.1924c-.3207.043-.6135.1935-.8443.4288-.1094.1118-.1996.2361-.2832.369-.092.1463-.175.2979-.259.4492l-.3864.6979-.3865-.6979c-.0837-.1515-.1667-.303-.2587-.4492-.0837-.1329-.1739-.2572-.2835-.369-.2305-.2353-.5233-.3857-.844-.429a1.4181 1.4181 0 0 0-.921.1926c-.2784.1677-.4964.426-.6203.7355-.1246.311-.1461.6508-.066.9767.038.155.0962.2984.1648.4403.0753.1564.1598.307.2437.4584l1.248 2.2543-4.8625 8.7825H2.0295c-.1676 0-.3351-.0007-.5026.0092-.1522.009-.3004.0284-.448.0714-.3108.0906-.5822.2798-.7783.548-.195.2665-.3006.5929-.3006.9279 0 .3352.1057.6612.3006.9277.196.2683.4675.4575.7782.548.1477.043.296.0623.4481.0715.1675.01.335.009.5026.009h13.0974c.0171-.0357.059-.1294.1-.2697.415-1.4151-.6156-2.843-2.0347-2.843zM3.113 18.5418l-.7922 1.5008c-.0818.1553-.1644.31-.2384.4705-.067.1458-.124.293-.1611.452-.0785.3346-.0576.6834.0645 1.0029.1212.3175.3346.583.607.7549.2727.172.5891.2416.9013.1975.3139-.044.6005-.1986.8263-.4402.1072-.1148.1954-.2424.2772-.3787.0902-.1503.1714-.3059.2535-.4612L6 19.4636c-.0896-.149-.9473-1.4704-2.887-.9218m20.5861-3.0056a1.4707 1.4707 0 0 0-.779-.5407c-.1476-.0425-.2961-.0616-.4483-.0705-.1678-.0099-.3352-.0091-.503-.0091H18.648l-4.3891-7.817c-.6655.7005-.9632 1.485-1.0773 2.1976-.1655 1.0333.0367 2.0934.546 3.0004l5.2741 9.3933c.084.1494.167.299.2591.4435.0837.131.1739.2537.2836.364.231.2323.5238.3809.8449.4232.3192.0424.643-.0244.9217-.1899.2784-.1653.4968-.4204.621-.7257.1246-.3072.146-.6425.0658-.9641-.0381-.1529-.0962-.2945-.165-.4346-.0753-.1543-.1598-.303-.2438-.4524l-1.216-2.1662h1.596c.1677 0 .3351.0009.5029-.009.1522-.009.3007-.028.4483-.0705a1.4707 1.4707 0 0 0 .779-.5407A1.5386 1.5386 0 0 0 24 16.452a1.539 1.539 0 0 0-.3009-.9158Z\"/></svg>",
"web/googleplay.svg": "<svg role=\"img\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\"><title>Google Play</title><path d=\"M22.018 13.298l-3.919 2.218-3.515-3.493 3.543-3.521 3.891 2.202a1.49 1.49 0 0 1 0 2.594zM1.337.924a1.486 1.486 0 0 0-.112.568v21.017c0 .217.045.419.124.6l11.155-11.087L1.337.924zm12.207 10.065l3.258-3.238L3.45.195a1.466 1.466 0 0 0-.946-.179l11.04 10.973zm0 2.067l-11 10.933c.298.036.612-.016.906-.183l13.324-7.54-3.23-3.21z\"/></svg>",
"web/landing.html": "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>{{APP_NAME}}</title>\n<!--{{SMART_BANNER}}-->\n<!--\n Minimal landing page written by `storebound web init --landing` from templates/web/landing.html. Deploy the whole\n output folder to the domain root: index.html, appstore.svg, googleplay.svg and .well-known/. No external requests.\n The store marks are the simple-icons files (CC0 data), shown as a mask in the text colour; they only name the store.\n-->\n<style>\n :root { color-scheme: light dark; --fg: #1b1c20; --bg: #ffffff; --muted: #5d6170; --line: #d5d8e0; --card: #f4f5f8; --accent: #1d4ed8; }\n @media (prefers-color-scheme: dark) { :root { --fg: #eceef3; --bg: #15161a; --muted: #a3a8b6; --line: #343744; --card: #1f2127; --accent: #8fb0ff; } }\n * { box-sizing: border-box; }\n body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; background: var(--bg); color: var(--fg); font: 16px/1.6 system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif; }\n main { flex: 1; width: 100%; max-width: 640px; margin: 0 auto; padding: 64px 16px 32px; text-align: center; }\n h1 { font-size: clamp(2rem, 7vw, 3rem); line-height: 1.15; margin: 0 0 32px; overflow-wrap: anywhere; }\n .stores { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; margin: 0; padding: 0; list-style: none; }\n .store { display: inline-flex; align-items: center; gap: 10px; min-height: 48px; padding: 10px 18px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); color: var(--fg); font-weight: 600; text-decoration: none; }\n .store:hover { border-color: var(--fg); }\n .store:focus-visible, footer a:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }\n .mark { display: inline-block; flex: none; width: 22px; height: 22px; background-color: currentColor; -webkit-mask: none center / contain no-repeat; mask: none center / contain no-repeat; }\n .mark-appstore { -webkit-mask-image: url(appstore.svg); mask-image: url(appstore.svg); }\n .mark-googleplay { -webkit-mask-image: url(googleplay.svg); mask-image: url(googleplay.svg); }\n footer { width: 100%; max-width: 640px; margin: 0 auto; padding: 24px 16px 32px; border-top: 1px solid var(--line); color: var(--muted); font-size: .875rem; text-align: center; }\n footer nav { display: flex; flex-wrap: wrap; gap: 8px 20px; justify-content: center; margin-bottom: 12px; }\n footer a { color: var(--accent); }\n footer p { margin: 0; }\n</style>\n</head>\n<body>\n<main>\n <h1>{{APP_NAME}}</h1>\n <ul class=\"stores\" aria-label=\"Get the app\">\n <!--apple--><li><a class=\"store\" href=\"{{APP_STORE_URL}}\"><span class=\"mark mark-appstore\" aria-hidden=\"true\"></span>App Store</a></li><!--/apple-->\n <!--play--><li><a class=\"store\" href=\"{{PLAY_URL}}\"><span class=\"mark mark-googleplay\" aria-hidden=\"true\"></span>Google Play</a></li><!--/play-->\n </ul>\n</main>\n<footer>\n <nav aria-label=\"Legal and support\">\n <a href=\"{{PRIVACY_URL}}\">Privacy Policy</a>\n <!--terms--><a href=\"{{TERMS_URL}}\">Terms of Use</a><!--/terms-->\n <a href=\"{{SUPPORT_URL}}\">Support</a>\n </nav>\n <p>App Store is a trademark of Apple Inc.; Google Play is a trademark of Google LLC.</p>\n</footer>\n</body>\n</html>\n",
"workflows/beta.yml": "# storebound flow \"beta\" for a game: a playtest build on TestFlight and Play internal testing.\n# storebound flow run beta VERSION=1.2.0 [--apply]\ndescription: Next game build to TestFlight and Play internal testing\nparams:\n VERSION: \"1.0.0\"\n GROUP: \"Playtesters\"\nsteps:\n - id: check\n run: check\n - id: ads\n if: exists ios\n run: ads scan --ios ios\n continueOnError: true\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait --build ${BUILD} --allow-existing\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: distribute\n if: apple\n run: apple testflight distribute --build ${BUILD} --group ${GROUP}\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track internal\n - id: external\n owner: \"External playtesters: storebound apple testflight distribute --build ${BUILD} --group <external group> --submit-review (Beta App Review is your call)\"\n",
"workflows/release.yml": "# storebound flow \"release\" for a game: one version on both stores, up to your Submit click.\n# storebound flow run release VERSION=1.2.0 reads run, writes run as dry runs (nothing changes)\n# storebound flow run release VERSION=1.2.0 --apply writes run for real; each command still asks you first\n# storebound flow run release --resume --apply continue after fixing a failed step\n# Steps are storebound commands (no \"storebound\" prefix). `if: apple` / `if: play` skip a store the config does not\n# have; `owner:` steps are never run, they list what you click. `sh:` runs your own build command (only with --apply).\ndescription: Build, upload and check one game version on both stores, then your Submit click\nparams:\n VERSION: \"1.0.0\"\nsteps:\n - id: shots\n if: exists store/renders\n run: shots check\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait --build ${BUILD} --allow-existing\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track internal\n - id: whats-new\n if: exists store/whats-new\n run: whats-new apply\n - id: listing\n if: apple\n run: apple listing --version ${VERSION} --build ${BUILD}\n - id: validate\n run: validate\n - id: scan\n run: scan\n - id: plan\n run: release plan ${VERSION}\n - id: submit\n owner: \"App Store Connect: Submit for Review (version ${VERSION}, build ${BUILD}). Play Console: promote the internal release and Send changes for review\"\n",
"workflows/beta.yml": "# storebound flow \"beta\" for a game: a playtest build on TestFlight and Play internal testing.\n# storebound flow run beta VERSION=1.2.0 [--apply]\n# VERSION is required (\"?\"). GROUP empty = the internal group of apple.testflight.groups (the group `internal: true`);\n# GROUP=<name> picks a TestFlight group that exists (or is listed in apple.testflight.groups).\ndescription: Next game build to TestFlight and Play internal testing\nparams:\n VERSION: \"?\"\n GROUP: \"\"\nsteps:\n - id: check\n run: check\n - id: ads\n if: exists ios\n run: ads scan --ios ios\n continueOnError: true\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait --build ${BUILD} --allow-existing\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: distribute\n if: apple && ${GROUP}\n run: apple testflight distribute --build ${BUILD} --group ${GROUP}\n - id: distribute-internal\n if: apple && !${GROUP} && ${config.testflight.internal}\n run: apple testflight distribute --build ${BUILD} --group ${config.testflight.internal}\n - id: no-group\n if: apple && !${GROUP} && !${config.testflight.internal}\n owner: \"No TestFlight group: pass GROUP=<name> or add a group with internal: true to apple.testflight.groups, then storebound apple testflight distribute --build ${BUILD} --group <name>\"\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track internal\n - id: external\n owner: \"External playtesters: storebound apple testflight distribute --build ${BUILD} --group <external group> --submit-review (Beta App Review is your call)\"\n",
"workflows/release.yml": "# storebound flow \"release\" for a game: one version on both stores, up to your Submit click.\n# storebound flow run release VERSION=1.2.0 reads run, writes run as dry runs (nothing changes)\n# storebound flow run release VERSION=1.2.0 --apply writes run for real; each command still asks you first\n# storebound flow run release --resume --apply continue after fixing a failed step\n# Steps are storebound commands (no \"storebound\" prefix). `if: apple` / `if: play` skip a store the config does not\n# have; `if: ${PARAM}` turns an optional step on by a param (MAC_TAURI=<src-tauri> adds the macOS build, PLAY_TRACK=beta\n# uploads to Play open testing); VERSION is required; `owner:` steps are never run, they list what you click. `sh:` runs your own build command (only with --apply).\ndescription: Build, upload and check one game version on both stores, then your Submit click\nparams:\n VERSION: \"?\"\n MAC_TAURI: \"\"\n PLAY_TRACK: \"internal\"\nsteps:\n - id: shots\n if: exists store/renders\n run: shots check\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait --build ${BUILD} --allow-existing\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: mac-build\n if: apple && ${MAC_TAURI}\n run: apple build --platform macos --tauri ${MAC_TAURI} --version ${VERSION} --build-number ${BUILD}\n - id: mac-upload\n if: apple && ${MAC_TAURI}\n run: apple upload --platform macos --no-wait --allow-existing\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track ${PLAY_TRACK}\n - id: promote\n if: play && ${PLAY_TRACK} != production\n owner: \"Play Console: promote the ${PLAY_TRACK} release to production (storebound play release --version-code <code> --production completed once you agree)\"\n - id: whats-new\n if: exists store/whats-new\n run: whats-new apply\n - id: listing\n if: apple\n run: apple listing --version ${VERSION} --build ${BUILD}\n - id: validate\n run: validate\n - id: scan\n run: scan\n - id: plan\n run: release plan ${VERSION}\n - id: submit\n owner: \"App Store Connect: Submit for Review (version ${VERSION}, build ${BUILD}). Play Console: promote the ${PLAY_TRACK} release and Send changes for review\"\n",
};
/** The pages shots render / video load (cli/shots/*.html), by file name. */

View file

@ -205,7 +205,7 @@ export function validateFlows(flows: Flow[], o: { commands?: Set<string> } = {})
if (ids.has(s.id)) e.push(`${at}: id used twice`);
ids.add(s.id);
for (const t of [s.run, s.sh, s.owner, s.if].filter((x): x is string => typeof x === 'string'))
for (const m of t.matchAll(/\$\{([^}]*)\}/g)) if (!vars.has(m[1]!) && !/^env\.[A-Z0-9_]+$/.test(m[1]!)) e.push(`${at}: \${${m[1]}} is not a param or an earlier capture`);
for (const m of t.matchAll(/\$\{([^}]*)\}/g)) if (!vars.has(m[1]!) && !/^env\.[A-Z0-9_]+$/.test(m[1]!) && !CONFIG_VARS.includes(m[1]!)) e.push(`${at}: \${${m[1]}} is not a param or an earlier capture`);
if (s.capture) vars.add(s.capture);
if (s.run !== undefined) {
let argv: string[] = [];
@ -230,6 +230,14 @@ export function validateFlows(flows: Flow[], o: { commands?: Set<string> } = {})
// ---- variables, conditions ----
/** Variables the runner fills from the config (empty when the config has none), usable in `run` and `if`. */
export const CONFIG_VARS = ['config.testflight.internal'];
/** The required-param marker: `VERSION: "?"` in params means the run needs VERSION=... (no silent default). */
export const REQUIRED = '?';
/** Params still at the required marker (not given on the command line). Pure. */
export function missingParams(vars: Record<string, string>): string[] { return Object.keys(vars).filter(k => vars[k] === REQUIRED); }
/** ${VAR} and ${env.NAME} substituted; unknown ones stay as written (the plan shows them). */
export function substitute(text: string, vars: Record<string, string>, env: Record<string, string | undefined> = {}): string {
return text.replace(/\$\{([^}]*)\}/g, (m, k: string) => (k.startsWith('env.') ? env[k.slice(4)] ?? m : vars[k] ?? m));
@ -306,6 +314,39 @@ export function planStep(s: FlowStep, part: PlannedStep['part'], mode: Mode, var
return { ...base, kind, argv: addDry ? [...argv, '--dry'] : addYes ? [...argv, '--yes'] : argv, dryAdded: addDry, ...(addYes ? { yesAdded: true } : {}), ...(mode === 'plan' ? { skip: 'plan only' } : {}) };
}
const PRODUCERS = ['apple build', 'play build'];
const CONSUMERS = ['apple upload', 'play upload'];
const AFTER_UPLOAD = ['apple testflight wait', 'apple testflight distribute'];
/** Stores whose artifact (IPA / AAB / PKG) or uploaded build only exists after a real earlier step ("apple", "apple:mac", "play"). */
export interface DryPending { built: Set<string>; uploaded: Set<string> }
const storeKey = (argv: string[]): string => {
const i = argv.indexOf('--platform');
const mac = (i >= 0 && /^(macos|mac)$/i.test(argv[i + 1] ?? '')) || argv.includes('--tauri');
return `${argv[0]}${mac ? ':mac' : ''}`;
};
/** Records a step that ran as a dry run: a dry build leaves no IPA / AAB. Pure. */
export function noteDry(p: PlannedStep, pending: DryPending): void {
if (!p.argv || !p.dryAdded || p.skip) return;
if (PRODUCERS.includes(words(p.argv).slice(0, 2).join(' '))) pending.built.add(storeKey(p.argv));
}
/** Why a step is only planned in a dry run: its input (the IPA / AAB, or the uploaded build) would come from an earlier
* step that did not really run. null = run it (always null with --apply: a real upload with no IPA still fails). Pure. */
export function plannedForDryInput(p: PlannedStep, mode: Mode, pending: DryPending): string | null {
if (mode === 'apply' || !p.argv || p.skip) return null;
const w = words(p.argv);
const k2 = w.slice(0, 2).join(' ');
if (CONSUMERS.includes(k2) && !p.argv.some(a => /\.(ipa|aab|pkg)$/.test(a)) && pending.built.has(storeKey(p.argv))) {
pending.uploaded.add(storeKey(p.argv));
return `planned: would upload the ${k2 === 'play upload' ? 'AAB' : storeKey(p.argv).endsWith(':mac') ? 'PKG' : 'IPA'} the ${w[0]} build step writes (dry run: nothing built)`;
}
if (AFTER_UPLOAD.includes(w.slice(0, 3).join(' ')) && pending.uploaded.has('apple')) return 'planned: the build is not on App Store Connect in a dry run';
return null;
}
/** Index of the step to start from: --from <id> (exact, or the first id under a sub-flow prefix). */
export function startIndex(steps: FlowStep[], from: string | undefined): number {
if (!from) return 0;

View file

@ -113,7 +113,7 @@ export interface CertChoice { cert: ApiCert | null; why: string; usable: ApiCert
/** A certificate is usable when it is of the wanted type, active, valid for 30 more days and this machine holds its
* private key (a key file in .storebound/signing or a code signing identity in the keychain). Newest first. */
export function pickCertificate(certs: ApiCert[], type: CertType, hasKey: (c: ApiCert) => boolean, now = Date.now()): CertChoice {
export function pickCertificate(certs: ApiCert[], type: CertType, hasKey: (c: ApiCert) => boolean, now = Date.now(), platform: string = process.platform): CertChoice {
const ofType = certs.filter(c => c.attributes.certificateType === type && c.attributes.activated !== false);
const valid = ofType.filter(c => Date.parse(c.attributes.expirationDate ?? '') > now);
const expiring = valid.filter(c => Date.parse(c.attributes.expirationDate!) - now < 30 * 86_400_000);
@ -121,7 +121,9 @@ export function pickCertificate(certs: ApiCert[], type: CertType, hasKey: (c: Ap
const withoutKey = valid.filter(c => !hasKey(c));
const cert = usable[0] ?? null;
const why = cert ? `using ${cert.attributes.name ?? cert.attributes.displayName ?? type} (expires ${cert.attributes.expirationDate?.slice(0, 10)})`
: withoutKey.length ? `${withoutKey.length} ${type} certificate(s) on the team, but this machine has none of their private keys: a new one is needed (or import the .p12 from the Mac that made it)`
: withoutKey.length ? `${withoutKey.length} ${type} certificate(s) on the team, but this machine has none of their private keys: ${platform === 'darwin'
? 'a new one is needed (or import the .p12 from the Mac that made it)'
: 'this machine cannot build or sign for Apple. The keys live on the Mac that made the certificates: build there (e.g. over ssh to that Mac). Do not create another certificate, Apple caps them per team'}`
: `no valid ${type} certificate`;
return { cert, why, usable, withoutKey, expiring };
}

View file

@ -34,7 +34,7 @@ import { rendersCheck } from './shots/check';
import { resolvedTablets } from './shots/report';
import { runScan } from './lib/scan';
import type { ScanFinding } from './lib/scan/rules';
import { playClient } from './play/client';
import { EDIT_GONE, playClient } from './play/client';
import { listOneTimeProducts } from './play/products';
import { creativeNote } from './shots/creative';
import { creativeWaiting, localCreative } from './apple/creative-assets';
@ -117,7 +117,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
const every = await allVersions(c, appId);
const editableOf = (x: J) => APPLE_EDITABLE.has(stateOf(x));
const withEditable = [...new Set(every.filter(editableOf).map(platformOf))];
const platform: AscPlatform = parsePlatform(ctx.args.get('platform')) ?? (withEditable.length === 1 ? withEditable[0]! : withEditable.includes('IOS') || !every.length ? 'IOS' : platformOf(every[0]));
const platform: AscPlatform = parsePlatform(ctx.args.get('platform')) ?? (withEditable.length === 1 ? withEditable[0]! : withEditable.includes('IOS') || !every.length || every.some(x => platformOf(x) === 'IOS') ? 'IOS' : platformOf(every[0]));
const versions = await c.all(versionsPath(appId, platform, 10));
const v: J = (await c.editableVersion(platform)) ?? versions[0] ?? null;
const otherPlatformVersions = every.filter(x => platformOf(x) !== platform && editableOf(x)).map(x => ({ versionString: String(x.attributes.versionString), state: stateOf(x), platform: platformOf(x) }));
@ -186,7 +186,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
deviceFamilies: platform === 'IOS' ? await deviceFamilyVerdicts(ctx, c, appId) : undefined,
build, locales, reviewDetail, ageRating,
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),
requiredDisplayTypes: requiredDisplayTypes(config.apple),
requiredDisplayTypes: requiredDisplayTypes(config.apple, platform),
iphoneDuo: !!config.apple?.iphoneDuo, play: !!config.play,
macVersion: every.some(x => platformOf(x) === 'MAC_OS'), mac: !!config.apple?.mac,
priceSchedule: !!sched?.included?.some((x: J) => x.type === 'appPrices'),
@ -202,23 +202,25 @@ export async function collectPlay(ctx: Ctx): Promise<PlaySnapshot> {
const { config, root } = ctx.loaded;
const play = await playClient(root, config.play!.packageName);
const tablets = resolvedTablets(config, ctx.loaded.resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'));
const e = await play.call<{ id: string }>('POST', `${play.API}/edits`, {});
log('validate', `play: read-only edit ${e.id} (deleted afterwards)`);
try {
const details = await play.call<{ defaultLanguage?: string; contactWebsite?: string }>('GET', `${play.API}/edits/${e.id}/details`);
const raw = (await play.call<{ listings?: { language: string; title?: string; shortDescription?: string; fullDescription?: string }[] }>('GET', `${play.API}/edits/${e.id}/listings`)).listings ?? [];
// play.read: one edit under the machine lock, deleted only after every read inside has finished, and opened anew once
// when Play invalidates it meanwhile ("This Edit has been deleted"). A bare POST /edits here was neither locked nor
// retried, so another storebound process (or the Console) ending the edit failed the screenshot reads with a false blocker.
return play.read(async editId => {
log('validate', `play: read-only edit ${editId} (deleted afterwards)`);
const details = await play.call<{ defaultLanguage?: string; contactWebsite?: string }>('GET', `${play.API}/edits/${editId}/details`);
const raw = (await play.call<{ listings?: { language: string; title?: string; shortDescription?: string; fullDescription?: string }[] }>('GET', `${play.API}/edits/${editId}/listings`)).listings ?? [];
const listings: PlaySnapshot['listings'] = [];
for (const l of raw) {
const images: Record<string, number> = {};
for (const type of Object.keys(playRequiredImages(tablets)))
images[type] = ((await play.call<{ images?: unknown[] }>('GET', `${play.API}/edits/${e.id}/listings/${l.language}/${type}`)).images ?? []).length;
images[type] = ((await play.call<{ images?: unknown[] }>('GET', `${play.API}/edits/${editId}/listings/${l.language}/${type}`)).images ?? []).length;
listings.push({ ...l, images });
}
const tracks = ((await play.call<{ tracks?: { track: string; releases?: { status: string; versionCodes?: string[]; userFraction?: number }[] }[] }>('GET', `${play.API}/edits/${e.id}/tracks`)).tracks ?? [])
const tracks = ((await play.call<{ tracks?: { track: string; releases?: { status: string; versionCodes?: string[]; userFraction?: number }[] }[] }>('GET', `${play.API}/edits/${editId}/tracks`)).tracks ?? [])
.map(t => ({ track: t.track, releases: (t.releases ?? []).map(r => ({ status: r.status, versionCodes: r.versionCodes ?? [], userFraction: r.userFraction })) }));
const products = (await listOneTimeProducts(play)).map(p => ({ productId: p.productId, states: (p.purchaseOptions ?? []).map(o => o.state ?? '') }));
// the production track's countries (read-only in the API: set in the console, `console plan play-production-countries`)
const productionCountries = await play.call<{ countries?: unknown[]; restOfWorld?: boolean }>('GET', `${play.API}/edits/${e.id}/countryAvailability/production`)
const productionCountries = await play.call<{ countries?: unknown[]; restOfWorld?: boolean }>('GET', `${play.API}/edits/${editId}/countryAvailability/production`)
.then(r => (r.restOfWorld ? -1 : (r.countries ?? []).length)).catch(() => null);
const website = details.contactWebsite ?? null;
const appAdsLines = config.ads?.appAdsTxt ?? [];
@ -245,9 +247,7 @@ export async function collectPlay(ctx: Ctx): Promise<PlaySnapshot> {
expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'play')).map(p => p.id),
privacyUrl: await checkUrl(config.app.privacyPolicyUrl), website, appAds, appAdsLines, adsLikely, category: playCategoryOf(config), productionCountries,
};
} finally {
await play.call('DELETE', `${play.API}/edits/${e.id}`).catch(() => {});
}
});
}
/** Scan findings as validate findings: blocker stays blocker, high / medium become warnings; low and owner-accepted
@ -279,7 +279,13 @@ export async function run(ctx: Ctx): Promise<void> {
const access = await brokerFindings(ctx.loaded).catch(e => [{ store: 'play' as const, level: 'warn' as const, code: 'broker-unavailable', msg: (e as Error).message }]);
findings.push(...access);
try { findings.push(...playRules({ ...(await collectPlay(ctx)), renders: local?.play })); }
catch (e) { findings.push({ store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 600) }, ...rendersFindings('play', local?.play)); }
catch (e) {
// a Play edit that vanished under the reads is a tool problem (the retry inside play.read failed too), never a store blocker
const gone = EDIT_GONE.test((e as Error).message);
findings.push(gone
? { store: 'play', level: 'warn', code: 'edit-gone', msg: `Play validate could not finish its read-only edit (${(e as Error).message.slice(0, 300)}): a tool problem, not a store finding`, fix: 'storebound validate --play (run it again; close other storebound runs and Console saves first)' }
: { store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 600) }, ...rendersFindings('play', local?.play));
}
} else if (a.has('play')) warn('validate', 'no play section in the config');
// Game Center / Play Games Services: entitlement, APP_ID resource, store state vs the config
if (config.games || config.apple) {