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

@ -3,6 +3,12 @@
## Unreleased
- `validate` / `release plan`: a macOS version is checked against Mac screenshots (APP_DESKTOP, with `apple.mac`), not the iPhone / iPad sets; with no editable version, iOS is picked over macOS.
- `validate --play`: reads go through the locked read-only edit and retry once on a fresh edit when Play ends it ("This Edit has been deleted" was shown as a blocker; it is now a warning to re-run at most).
- Build numbers for macOS (`apple build --platform macos`, `apple upload` of a .pkg) come from every build of the app, as `apple next-build` does (a macOS-only list proposed a number an iOS build already had).
- `apple build --platform macos` runs `build.prebuild` / `apple.prebuild` before `tauri build` (`--no-prebuild` skips it), and the new `apple.macAppCheck` command on the built .app before productbuild ({file} = the .app; a nonzero exit stops the build).
- `apple signing status` / the Mac build dry run on Windows or Linux say the keys live on the Mac that made the certificates (build there) instead of suggesting a new certificate.
- Flows: a dry `flow run` plans the upload, TestFlight wait and distribute steps after a dry build instead of failing on the missing IPA; `beta` and `release` need VERSION (no silent 1.0.0); `beta` distributes to the config's internal TestFlight group when GROUP is empty; `release` has optional macOS steps (MAC_TAURI), PLAY_TRACK (e.g. beta = open testing) and a promote owner step.
## 0.8.63 · 2026-11-02
- Desktop: a second start at logon (`--hidden`, e.g. a Startup folder shortcut next to the Run entry) keeps the app in the tray instead of opening its window.

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) {

View file

@ -651,7 +651,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `builds_buildBetaDetail_getToOneRelationship` | GET | `/v1/builds/{id}/relationships/buildBetaDetail` | read | |
| `builds_diagnosticSignatures_getToManyRelated` | GET | `/v1/builds/{id}/diagnosticSignatures` | read | health |
| `builds_diagnosticSignatures_getToManyRelationship` | GET | `/v1/builds/{id}/relationships/diagnosticSignatures` | read | |
| `builds_getCollection` | GET | `/v1/builds` | read | 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` | GET | `/v1/builds` | read | apple/encryption, apple/listing, apple/next-build, apple/resubmit, apple/status, apple/testflight, apple/upload, art, health, lib/store-apps, triage |
| `builds_getInstance` | GET | `/v1/builds/{id}` | read | apple/device-families |
| `builds_icons_getToManyRelated` | GET | `/v1/builds/{id}/icons` | read | |
| `builds_icons_getToManyRelationship` | GET | `/v1/builds/{id}/relationships/icons` | read | |
@ -1565,9 +1565,9 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `androidpublisher.appstorecatalog.recentupdateevents.list` | GET | `androidpublisher/v3/appstorecatalog/{appStorePackageName}/recentUpdateEvents` | read | |
| **androidpublisher.edits** (5) | | | | |
| `androidpublisher.edits.commit` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}:commit` | owner only (typed confirm) | play/client |
| `androidpublisher.edits.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}` | owner only (typed confirm) | apps, onboard, play/client, validate |
| `androidpublisher.edits.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}` | owner only (typed confirm) | apps, onboard, play/client |
| `androidpublisher.edits.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}` | read | |
| `androidpublisher.edits.insert` | POST | `androidpublisher/v3/applications/{packageName}/edits` | write (dry by default) | apps, onboard, play/client, validate |
| `androidpublisher.edits.insert` | POST | `androidpublisher/v3/applications/{packageName}/edits` | write (dry by default) | apps, onboard, play/client |
| `androidpublisher.edits.validate` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}:validate` | write (dry by default) | play/client |
| **androidpublisher.edits.apks** (3) | | | | |
| `androidpublisher.edits.apks.addexternallyhosted` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}/apks/externallyHosted` | write (dry by default) | |

View file

@ -18,7 +18,7 @@ shell after the dry run).
```yaml
description: Build, upload and check one version on both stores
params:
VERSION: "1.0.0" # flow run release VERSION=1.2.0 overrides
VERSION: "?" # "?" = required: flow run release VERSION=1.2.0 (no silent default; --resume reuses the last run's)
steps:
- id: next-build
if: apple # apple | play | exists <path> | ${VAR} | !${VAR} | ${A} == x; && joins
@ -36,6 +36,10 @@ before: [] # hooks: before / after the steps, onError when a
after: []
onError: []
```
`${config.testflight.internal}` is filled by the runner with the internal groups of `apple.testflight.groups` (empty when
none); the beta template uses it when `GROUP` is empty. The release template has optional steps by param: `MAC_TAURI=<src-tauri>`
adds the macOS build + upload, `PLAY_TRACK=beta` uploads to Play open testing (default `internal`); its `promote` step is an
owner step listing the Play promotion.
`continueOnError: true` lets a step fail without stopping the flow.
A web build before the native builds (Capacitor: `npm run build && npx cap sync`) belongs in `build.prebuild` in the
config rather than an `sh:` step: `apple build` and `play build` run it themselves, and within one `flow run` it runs
@ -44,7 +48,8 @@ once (the second build step prints "prebuild skipped (already ran in this flow r
## Modes
- `flow run release VERSION=1.2.0` (default): read steps (check, validate, scan, release plan, next-build, status ...)
run for real; **every write step runs with `--dry`**; wait steps check once; shell steps are skipped. Nothing
changes. Show this output to the owner.
changes. Show this output to the owner. A dry `apple build` / `play build` writes no IPA / AAB, so the upload after it
(and `apple testflight wait` / `distribute` after that) is shown as "planned" instead of failing; a real run is unchanged.
- `--dry`: the plan only, nothing runs.
- `--apply`: writes and shell steps run for real; each write command still asks its own confirmation in the
terminal. Never add `--yes` to a step (flow validate refuses it).

View file

@ -1,9 +1,11 @@
# storebound flow "beta" for a game: a playtest build on TestFlight and Play internal testing.
# storebound flow run beta VERSION=1.2.0 [--apply]
# VERSION is required ("?"). GROUP empty = the internal group of apple.testflight.groups (the group `internal: true`);
# GROUP=<name> picks a TestFlight group that exists (or is listed in apple.testflight.groups).
description: Next game build to TestFlight and Play internal testing
params:
VERSION: "1.0.0"
GROUP: "Playtesters"
VERSION: "?"
GROUP: ""
steps:
- id: check
run: check
@ -25,8 +27,14 @@ steps:
if: apple
run: apple testflight wait --build ${BUILD} --version ${VERSION}
- id: distribute
if: apple
if: apple && ${GROUP}
run: apple testflight distribute --build ${BUILD} --group ${GROUP}
- id: distribute-internal
if: apple && !${GROUP} && ${config.testflight.internal}
run: apple testflight distribute --build ${BUILD} --group ${config.testflight.internal}
- id: no-group
if: apple && !${GROUP} && !${config.testflight.internal}
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>"
- id: play-build
if: play
run: play build --version-name ${VERSION}

View file

@ -3,10 +3,13 @@
# storebound flow run release VERSION=1.2.0 --apply writes run for real; each command still asks you first
# storebound flow run release --resume --apply continue after fixing a failed step
# Steps are storebound commands (no "storebound" prefix). `if: apple` / `if: play` skip a store the config does not
# have; `owner:` steps are never run, they list what you click. `sh:` runs your own build command (only with --apply).
# have; `if: ${PARAM}` turns an optional step on by a param (MAC_TAURI=<src-tauri> adds the macOS build, PLAY_TRACK=beta
# 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).
description: Build, upload and check one game version on both stores, then your Submit click
params:
VERSION: "1.0.0"
VERSION: "?"
MAC_TAURI: ""
PLAY_TRACK: "internal"
steps:
- id: shots
if: exists store/renders
@ -24,12 +27,21 @@ steps:
- id: wait
if: apple
run: apple testflight wait --build ${BUILD} --version ${VERSION}
- id: mac-build
if: apple && ${MAC_TAURI}
run: apple build --platform macos --tauri ${MAC_TAURI} --version ${VERSION} --build-number ${BUILD}
- id: mac-upload
if: apple && ${MAC_TAURI}
run: apple upload --platform macos --no-wait --allow-existing
- id: play-build
if: play
run: play build --version-name ${VERSION}
- id: play-upload
if: play
run: play upload --track internal
run: play upload --track ${PLAY_TRACK}
- id: promote
if: play && ${PLAY_TRACK} != production
owner: "Play Console: promote the ${PLAY_TRACK} release to production (storebound play release --version-code <code> --production completed once you agree)"
- id: whats-new
if: exists store/whats-new
run: whats-new apply
@ -43,4 +55,4 @@ steps:
- id: plan
run: release plan ${VERSION}
- id: submit
owner: "App Store Connect: Submit for Review (version ${VERSION}, build ${BUILD}). Play Console: promote the internal release and Send changes for review"
owner: "App Store Connect: Submit for Review (version ${VERSION}, build ${BUILD}). Play Console: promote the ${PLAY_TRACK} release and Send changes for review"

100
tests/flow-dry.test.ts Normal file
View file

@ -0,0 +1,100 @@
// storebound flow, dry runs: a step whose input (the IPA / AAB, the uploaded build) only a real earlier step makes is
// planned, not failed; a real run is untouched. The beta template: required VERSION, GROUP empty = the config's internal group.
import { afterAll, describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { type DryPending, evalIf, expand, flowsOfFile, missingParams, noteDry, planStep, plannedForDryInput, substitute, validateFlows } from '../cli/lib/flow';
import { parseYaml } from '../cli/lib/yaml';
import { readFileSync } from 'node:fs';
const ROOT = join(import.meta.dir, '..');
const CLI = join(ROOT, 'cli', 'index.ts');
const tmp = mkdtempSync(join(tmpdir(), 'storebound-flow-dry-'));
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
const sb = (cwd: string, ...args: string[]) => Bun.spawnSync([process.execPath, CLI, ...args], { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
const C = { apple: true, play: true, exists: () => false };
const load = (name: string) => flowsOfFile(`templates/workflows/${name}.yml`, parseYaml(readFileSync(join(ROOT, 'templates', 'workflows', `${name}.yml`), 'utf8')), name).flows[0]!;
/** What the runner decides per step in a mode: 'ran' (storebound is called), 'planned' (dry input missing) or 'skipped'. */
function simulate(name: string, mode: 'dry' | 'apply', vars: Record<string, string>) {
const f = load(name);
const pending: DryPending = { built: new Set(), uploaded: new Set() };
const out: Record<string, { how: string; argv?: string[]; owner?: string }> = {};
for (const s of expand(f, new Map([[f.name, f]]), 'steps').steps) {
const p = planStep(s, 'steps', mode, vars, C);
if (p.skip) { out[s.id] = { how: 'skipped', argv: p.argv, owner: p.owner }; continue; }
const why = plannedForDryInput(p, mode, pending);
if (why) { out[s.id] = { how: 'planned', argv: p.argv }; continue; }
noteDry(p, pending);
out[s.id] = { how: 'ran', argv: p.argv };
}
return out;
}
describe('dry flows plan what a dry earlier step cannot produce', () => {
test('beta: dry build then upload (and wait, distribute) are planned, reads and checks still run', () => {
const r = simulate('beta', 'dry', { VERSION: '1.2.0', GROUP: '', BUILD: '7', 'config.testflight.internal': 'Team' });
expect(r['build']).toMatchObject({ how: 'ran', argv: ['apple', 'build', '--version', '1.2.0', '--build-number', '7', '--dry'] });
expect(r['upload']!.how).toBe('planned');
expect(r['wait']!.how).toBe('planned');
expect(r['distribute-internal']!.how).toBe('planned');
expect(r['play-build']!.how).toBe('ran');
expect(r['play-upload']!.how).toBe('planned');
expect(r['check']!.how).toBe('ran');
});
test('a real run is untouched: upload with no IPA still runs (and fails inside the command)', () => {
const r = simulate('beta', 'apply', { VERSION: '1.2.0', GROUP: '', BUILD: '7', 'config.testflight.internal': 'Team' });
expect(r['upload']).toMatchObject({ how: 'ran', argv: ['apple', 'upload', '--no-wait', '--build', '7', '--allow-existing'] });
expect(r['play-upload']!.how).toBe('ran');
});
test('an upload of a named file, or without a dry build before it, is not planned away', () => {
const pending: DryPending = { built: new Set(['apple']), uploaded: new Set() };
const up = planStep({ id: 'u', run: 'apple upload build/App.ipa' }, 'steps', 'dry', {}, C);
expect(plannedForDryInput(up, 'dry', pending)).toBeNull();
const fresh: DryPending = { built: new Set(), uploaded: new Set() };
expect(plannedForDryInput(planStep({ id: 'u', run: 'apple upload --no-wait' }, 'steps', 'dry', {}, C), 'dry', fresh)).toBeNull();
});
test('release: the macOS pkg is tracked apart from the iOS IPA', () => {
const r = simulate('release', 'dry', { VERSION: '2.0.0', MAC_TAURI: 'src-tauri', PLAY_TRACK: 'beta', BUILD: '9' });
expect(r['build']!.how).toBe('ran');
expect(r['upload']!.how).toBe('planned');
expect(r['mac-build']!.argv).toContain('--dry');
expect(r['mac-upload']!.how).toBe('planned');
expect(r['play-upload']!.argv).toEqual(['play', 'upload', '--track', 'beta', '--dry']);
const off = simulate('release', 'dry', { VERSION: '2.0.0', MAC_TAURI: '', PLAY_TRACK: 'internal', BUILD: '9' });
expect(off['mac-build']!.how).toBe('skipped');
expect(off['mac-upload']!.how).toBe('skipped');
expect(off['promote']!.owner).toContain('internal');
});
});
describe('beta template defaults', () => {
test('VERSION is required, GROUP defaults to empty and falls to the internal group of the config', () => {
const f = load('beta');
expect(f.params['VERSION']).toBe('?');
expect(f.params['GROUP']).toBe('');
expect(missingParams({ ...f.params })).toEqual(['VERSION']);
expect(missingParams({ ...f.params, VERSION: '1.0.0' })).toEqual([]);
const steps = expand(f, new Map([[f.name, f]]), 'steps').steps;
const by = (v: Record<string, string>) => steps.filter(s => s.id.startsWith('distribute') || s.id === 'no-group')
.filter(s => evalIf(substitute(s.if ?? '', v), C)).map(s => s.id);
expect(by({ GROUP: '', 'config.testflight.internal': 'Team' })).toEqual(['distribute-internal']);
expect(by({ GROUP: 'QA', 'config.testflight.internal': 'Team' })).toEqual(['distribute']);
expect(by({ GROUP: '', 'config.testflight.internal': '' })).toEqual(['no-group']);
expect(validateFlows([f, load('release')])).toEqual([]);
});
test('flow run beta without VERSION fails early with a clear message', () => {
const dir = join(tmp, 'req');
mkdirSync(join(dir, 'store', 'workflows'), { recursive: true });
writeFileSync(join(dir, 'store', 'workflows', 'beta.yml'), readFileSync(join(ROOT, 'templates', 'workflows', 'beta.yml'), 'utf8'));
const p = sb(dir, 'flow', 'run', 'beta');
expect(p.exitCode).not.toBe(0);
expect(p.stderr.toString() + p.stdout.toString()).toContain('beta needs VERSION=<value>');
});
});

View file

@ -0,0 +1,73 @@
// One app record, an iOS and a macOS App Store version (pitfall 79): CFBundleVersion must be unique across ALL the app's
// builds, so `apple next-build`, `apple build` and `apple build --platform macos` (also --dry) share one computation.
// Seen live: the macOS dry run proposed a build number an uploaded macOS build already had (it asked only for macOS
// builds), while next-build said the right one. Against the fake App Store Connect.
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import type { ShipConfig } from '../cli/lib/config';
import { appNextBuild, run as nextBuild } from '../cli/apple/next-build';
import { identityLine, run as buildMas } from '../cli/apple/build-mas';
import { ascEnv, BASE, capture, project, restore } from './helpers/store-mock';
import { installStores, type Stores, uninstallStores } from './helpers/fake-stores';
const APP = '6400000101';
const CONFIG: ShipConfig = { ...BASE };
const TAURI = JSON.stringify({ productName: 'Example Notes', identifier: 'com.example.notes', version: '1.2.0' });
let s: Stores;
beforeAll(ascEnv);
afterAll(restore);
beforeEach(() => { s = installStores(); });
afterEach(uninstallStores);
const build = (id: string, version: string, state = 'VALID') => s.asc.put({ type: 'builds', id, attributes: { version, processingState: state }, rels: { app: APP } });
/** iOS builds 202610261700 / ...1730, a macOS build ...1731 and an expired one ...1734 in another state. */
function seed(): void {
build('b-ios-1', '202610261700');
build('b-ios-2', '202610261730');
build('b-mac-1', '202610261731');
build('b-mac-2', '202610261734', 'PROCESSING');
}
describe('next build number over every platform', () => {
test('apple next-build and the macOS dry run agree and sit above the highest build', async () => {
seed();
const p = project(CONFIG, { 'src-tauri/tauri.conf.json': TAURI });
const nb = await capture(() => nextBuild(p.ctx(['--json'])));
expect(JSON.parse(nb.out)).toEqual({ next: '202610261735', builds: 4 });
const dry = await capture(() => buildMas(p.ctx(['--tauri', 'src-tauri', '--unsigned', '--dry', '--json']), 'linux'));
expect(JSON.parse(dry.out.slice(dry.out.indexOf('{')))).toMatchObject({ platform: 'MAC_OS', build: '202610261735' });
});
test('no request narrows the build list to one platform', async () => {
seed();
const p = project(CONFIG, { 'src-tauri/tauri.conf.json': TAURI });
await capture(() => buildMas(p.ctx(['--tauri', 'src-tauri', '--unsigned', '--dry', '--json']), 'linux'));
const reads = s.calls.filter(c => /\/v1\/builds/.test(c.path));
expect(reads.length).toBeGreaterThan(0);
for (const r of reads) expect(decodeURIComponent(r.path)).not.toMatch(/preReleaseVersion|platform|processingState/);
});
test('every page counts', async () => {
const pages: Record<string, unknown> = {
'/p1': { data: [{ attributes: { version: '5' } }], links: { next: '/p2' } },
'/p2': { data: [{ attributes: { version: '41' } }], links: { next: '/p3' } },
'/p3': { data: [{ attributes: { version: '9' } }], links: {} },
};
const seen: string[] = [];
const client = { app: async () => APP, all: async (path: string) => { seen.push(path); const out: unknown[] = []; for (let u: string | undefined = '/p1'; u; u = (pages[u] as { links: { next?: string } }).links.next) out.push(...(pages[u] as { data: unknown[] }).data); return out; } };
expect(await appNextBuild(client as never)).toEqual({ next: '42', builds: 3 });
expect(seen[0]).toContain(`filter[app]=${APP}`);
});
test('an app without builds starts at 1', async () => {
expect((await appNextBuild({ app: async () => APP, all: async () => [] } as never)).next).toBe('1');
});
});
describe('identity line off a Mac', () => {
test('a non-macOS machine is called a non-build machine, not "none found"', () => {
const line = identityLine({ platform: 'win32', appIdentity: null, installerIdentity: null, profile: null });
expect(line).toContain('not a build machine');
expect(line).toContain('Mac');
expect(line).not.toContain('none found');
});
test('on a Mac a missing identity is still reported as missing', () => {
expect(identityLine({ platform: 'darwin', appIdentity: null, installerIdentity: 'Mac Installer', profile: null })).toContain('app identity: (none found); installer identity: Mac Installer');
});
});

View file

@ -0,0 +1,52 @@
// A macOS App Store version is checked against the Mac screenshot set only (APP_DESKTOP), never the iPhone / iPad
// sets: validate / release plan showed 2 false blockers per locale for an app whose editable version was macOS.
// Also: a non-macOS machine without the certificates' keys must not be told to create another certificate.
import { describe, expect, test } from 'bun:test';
import { requiredDisplayTypes } from '../cli/apple/screenshots';
import { pickCertificate, type ApiCert } from '../cli/lib/signing';
import { appleRules, type AppleSnapshot } from '../cli/lib/validate-rules';
const snap = (platform: string, apple: Parameters<typeof requiredDisplayTypes>[0], shots: Record<string, number>): AppleSnapshot => ({
version: { versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION', platform }, build: null, expectedLocales: ['en-US', 'de-DE'],
locales: ['en-US', 'de-DE'].map(locale => ({ locale, name: 'n', subtitle: 's', description: 'd', keywords: 'k', screenshots: shots })),
requiredDisplayTypes: requiredDisplayTypes(apple, platform), mac: !!apple?.mac, macVersion: platform === 'MAC_OS',
reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 },
} as AppleSnapshot);
const shotBlockers = (s: AppleSnapshot) => appleRules(s).filter(x => x.level === 'blocker' && x.code.startsWith('screenshots'));
describe('required screenshot types per platform', () => {
test('macOS: the Mac set with apple.mac, nothing else; apple.screenshotTypes stays the iOS list', () => {
expect(requiredDisplayTypes({ ipad: true, mac: true }, 'MAC_OS')).toEqual(['APP_DESKTOP']);
expect(requiredDisplayTypes({ ipad: true, mac: true, screenshotTypes: ['APP_IPHONE_67'] }, 'MAC_OS')).toEqual(['APP_DESKTOP']);
expect(requiredDisplayTypes({ ipad: true }, 'MAC_OS')).toEqual([]);
expect(requiredDisplayTypes({ ipad: true, mac: true, screenshotTypes: ['APP_IPHONE_67'] })).toEqual(['APP_IPHONE_67']);
expect(requiredDisplayTypes({ ipad: true })).toEqual(['APP_IPHONE_61', 'APP_IPAD_PRO_3GEN_129']);
expect(requiredDisplayTypes({ ipad: true }, 'TV_OS')).toEqual([]);
});
test('a macOS version with Mac screenshots and no iPhone / iPad ones has no screenshot blocker', () => {
expect(shotBlockers(snap('MAC_OS', { ipad: true, mac: true }, { APP_DESKTOP: 3 }))).toEqual([]);
});
test('a macOS version without the Mac set is missing APP_DESKTOP only', () => {
const b = shotBlockers(snap('MAC_OS', { ipad: true, mac: true }, {}));
expect(b.map(x => x.msg)).toEqual(['en-US: no APP_DESKTOP screenshots', 'de-DE: no APP_DESKTOP screenshots']);
});
test('an iOS version still needs the iPhone and iPad sets', () => {
expect(shotBlockers(snap('IOS', { ipad: true, mac: true }, { APP_DESKTOP: 3 })).length).toBe(4);
});
});
describe('certificate without a local key', () => {
const c = (id: string): ApiCert => ({ id, attributes: { certificateType: 'DISTRIBUTION', activated: true, expirationDate: new Date(Date.now() + 400 * 864e5).toISOString() } });
test('macOS: a new one or the .p12 import', () => {
expect(pickCertificate([c('a')], 'DISTRIBUTION', () => false, Date.now(), 'darwin').why).toContain('a new one is needed');
});
test('Windows / Linux: build on the Mac, never a new certificate', () => {
for (const p of ['win32', 'linux']) {
const why = pickCertificate([c('a')], 'DISTRIBUTION', () => false, Date.now(), p).why;
expect(why).toContain('none of their private keys');
expect(why).toContain('cannot build or sign for Apple');
expect(why).toContain('Do not create another certificate');
expect(why).not.toContain('a new one is needed');
}
});
});

View file

@ -0,0 +1,91 @@
// `storebound validate --play` read its screenshots with a bare POST /edits and a DELETE in a finally: not under the
// machine lock and never retried. When Play (or another storebound process) ended that edit meanwhile, the next read
// answered 400 "This Edit has been deleted" and validate printed it as a store blocker ("GET .../phoneScreenshots 400").
// Now the reads run in play.read (one locked edit, deleted only after all reads, redone once with a new edit), and a
// vanished edit that stays gone is a tool warning, never a blocker.
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { generateKeyPairSync } from 'node:crypto';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parseArgs } from '../cli/lib/args';
import type { Loaded, ShipConfig } from '../cli/lib/config';
import { collectPlay, run } from '../cli/validate';
import { installStores, type Stores, uninstallStores } from './helpers/fake-stores';
const PKG = 'com.example.game';
const GONE = { error: { code: 400, status: 'FAILED_PRECONDITION', message: 'This Edit has been deleted.' } };
let s: Stores;
let savedKey: string | undefined;
beforeEach(() => {
savedKey = process.env.PLAY_SERVICE_ACCOUNT;
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
process.env.PLAY_SERVICE_ACCOUNT = JSON.stringify({ client_email: 'sb@example.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' });
s = installStores();
// the fake has no countryAvailability route (the console owns it): a plain 404, which validate reads as "not readable"
s.faults.push({ method: 'GET', match: /countryAvailability/, status: 404, when: 'before', times: 99 });
s.play.live.listings['en-US'] = { language: 'en-US', title: 'Example Game', shortDescription: 'Short.', fullDescription: 'Full.' };
});
afterEach(() => {
uninstallStores();
if (savedKey === undefined) delete process.env.PLAY_SERVICE_ACCOUNT; else process.env.PLAY_SERVICE_ACCOUNT = savedKey;
});
function loaded(): Loaded {
const root = mkdtempSync(join(tmpdir(), 'sb-vpe-'));
const config = { app: { name: 'Example Game', privacyPolicyUrl: '' }, languages: [{ id: 'en', play: 'en-US' }], play: { packageName: PKG } } as unknown as ShipConfig;
return { config, root, path: join(root, 'storebound.config.ts'), resolve: p => join(root, p) };
}
/** Play ends the edit under the reader: the DELETE happens on the store, the read that follows answers 400. */
function endEditOnImageRead(times: number): { hits: () => number } {
const inner = globalThis.fetch;
let left = times, hits = 0;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input instanceof Request ? input.url : input);
const m = /\/edits\/([^/]+)\/listings\/[^/]+\/phoneScreenshots$/.exec(url);
if (m && (init?.method ?? 'GET').toUpperCase() === 'GET' && left > 0) {
left--; hits++;
s.play.edits.delete(m[1]!);
return new Response(JSON.stringify(GONE), { status: 400, headers: { 'content-type': 'application/json' } });
}
return inner(input, init);
}) as typeof fetch;
return { hits: () => hits };
}
describe('validate --play: the read-only edit', () => {
test('an edit ended during the screenshot reads is reopened once; the snapshot is complete and every edit is closed', async () => {
const fault = endEditOnImageRead(1);
const snap = await collectPlay({ loaded: loaded(), args: parseArgs(['--play']) });
expect(fault.hits()).toBe(1);
expect(snap.listings.map(l => l.language)).toEqual(['en-US']);
expect(Object.keys(snap.listings[0]!.images)).toContain('phoneScreenshots');
expect(s.play.edits.size).toBe(0);
const edits = s.calls.filter(c => c.method === 'POST' && /\/edits$/.test(c.path));
expect(edits.length).toBe(2);
});
test('no image read runs after the edit was deleted', async () => {
await collectPlay({ loaded: loaded(), args: parseArgs(['--play']) });
const del = s.calls.findIndex(c => c.method === 'DELETE' && /\/edits\/[^/]+$/.test(c.path));
expect(del).toBeGreaterThan(-1);
expect(s.calls.slice(del + 1).filter(c => /\/edits\/[^/]+\//.test(c.path))).toEqual([]);
});
test('an edit that stays gone is a tool warning, never a store blocker', async () => {
endEditOnImageRead(10);
const out: string[] = [];
const log = console.log;
console.log = (...a: unknown[]) => { out.push(a.join(' ')); };
const prev = process.exitCode;
try { await run({ loaded: loaded(), args: parseArgs(['--play', '--json', '--no-scan', '--no-health', '--no-local']) }); } finally { console.log = log; }
process.exitCode = prev;
const res = JSON.parse(out.join('\n').slice(out.join('\n').indexOf('{'))) as { blockers: number; findings: { level: string; code: string; msg: string }[] };
expect(res.findings.filter(f => f.level === 'blocker' && /Edit has been deleted|phoneScreenshots/.test(f.msg))).toEqual([]);
expect(res.findings).toContainEqual(expect.objectContaining({ level: 'warn', code: 'edit-gone' }));
// (the template-answers blocker of the example config is unrelated: only an api blocker would be the bug)
expect(res.findings.filter(f => f.level === 'blocker' && f.code === 'api')).toEqual([]);
});
});