From f5da7c9f7a1207825d366185f725da1802fdb7c0 Mon Sep 17 00:00:00 2001 From: CRT Date: Mon, 2 Nov 2026 18:16:25 +0700 Subject: [PATCH] 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 --- CHANGELOG.md | 6 ++ cli/api/wrapped.gen.ts | 6 +- cli/apple/build-mas.ts | 52 ++++++++++++--- cli/apple/build.ts | 8 +-- cli/apple/next-build.ts | 22 +++++-- cli/apple/screenshots.ts | 6 +- cli/apple/upload.ts | 2 +- cli/flow.ts | 16 ++++- cli/lib/config.ts | 4 ++ cli/lib/embedded.ts | 4 +- cli/lib/flow.ts | 43 ++++++++++++- cli/lib/signing.ts | 6 +- cli/validate.ts | 36 ++++++----- docs/api-coverage.md | 6 +- skills/release-flows/SKILL.md | 9 ++- templates/workflows/beta.yml | 14 +++- templates/workflows/release.yml | 20 ++++-- tests/flow-dry.test.ts | 100 +++++++++++++++++++++++++++++ tests/next-build-platforms.test.ts | 73 +++++++++++++++++++++ tests/validate-macos.test.ts | 52 +++++++++++++++ tests/validate-play-edit.test.ts | 91 ++++++++++++++++++++++++++ 21 files changed, 519 insertions(+), 57 deletions(-) create mode 100644 tests/flow-dry.test.ts create mode 100644 tests/next-build-platforms.test.ts create mode 100644 tests/validate-macos.test.ts create mode 100644 tests/validate-play-edit.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 267112c..ab04470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/cli/api/wrapped.gen.ts b/cli/api/wrapped.gen.ts index 87c30a2..0fced3e 100644 --- a/cli/api/wrapped.gen.ts +++ b/cli/api/wrapped.gen.ts @@ -133,7 +133,7 @@ export const WRAPPED: { apple: Record; play: Record; play: Record [--path-free] [--target universal-apple-darwin] // [--version x.y.z] [--build-number N] [--profile ] [--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 { +/** 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 { 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 { 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 { const team = teamId ?? ''; // 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 { 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 ?? '', 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) : 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 { 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 { { 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 }); diff --git a/cli/apple/build.ts b/cli/apple/build.ts index caefc02..18093db 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -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 { 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) { diff --git a/cli/apple/next-build.ts b/cli/apple/next-build.ts index 5c4a560..530b28f 100644 --- a/cli/apple/next-build.ts +++ b/cli/apple/next-build.ts @@ -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): 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 { 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); } diff --git a/cli/apple/screenshots.ts b/cli/apple/screenshots.ts index fbb353f..bc7bfce 100644 --- a/cli/apple/screenshots.ts +++ b/cli/apple/screenshots.ts @@ -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 diff --git a/cli/apple/upload.ts b/cli/apple/upload.ts index 28ccd4a..a31574f 100644 --- a/cli/apple/upload.ts +++ b/cli/apple/upload.ts @@ -284,7 +284,7 @@ export async function run(ctx: Ctx): Promise { 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 diff --git a/cli/flow.ts b/cli/flow.ts index 63387e1..11151b3 100644 --- a/cli/flow.ts +++ b/cli/flow.ts @@ -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 { const subParams = expand(flow, all, 'steps').params; const vars: Record = { ...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}=`).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; @@ -196,12 +202,20 @@ async function runFlow(args: Args, root: string): Promise { 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) { diff --git a/cli/lib/config.ts b/cli/lib/config.ts index aea46a9..446ee91 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -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): string[] { if (cfg.build !== undefined && (typeof cfg.build !== 'object' || cfg.build === null)) e.push('build: { prebuild?: "" }'); 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/'); diff --git a/cli/lib/embedded.ts b/cli/lib/embedded.ts index 6429370..e11a6ba 100644 --- a/cli/lib/embedded.ts +++ b/cli/lib/embedded.ts @@ -31,8 +31,8 @@ export const TEMPLATE_TEXT: Record = { "web/appstore.svg": "App Store", "web/googleplay.svg": "Google Play", "web/landing.html": "\n\n\n\n\n{{APP_NAME}}\n\n\n\n\n\n
\n

{{APP_NAME}}

\n \n
\n\n\n\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 --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= 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= or add a group with internal: true to apple.testflight.groups, then storebound apple testflight distribute --build ${BUILD} --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 --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= 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 --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. */ diff --git a/cli/lib/flow.ts b/cli/lib/flow.ts index 238ade6..1993cbd 100644 --- a/cli/lib/flow.ts +++ b/cli/lib/flow.ts @@ -205,7 +205,7 @@ export function validateFlows(flows: Flow[], o: { commands?: Set } = {}) 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 } = {}) // ---- 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[] { 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, env: Record = {}): 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; uploaded: Set } + +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 (exact, or the first id under a sub-flow prefix). */ export function startIndex(steps: FlowStep[], from: string | undefined): number { if (!from) return 0; diff --git a/cli/lib/signing.ts b/cli/lib/signing.ts index 9b28e45..55623be 100644 --- a/cli/lib/signing.ts +++ b/cli/lib/signing.ts @@ -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 }; } diff --git a/cli/validate.ts b/cli/validate.ts index 539f453..b95f7a2 100644 --- a/cli/validate.ts +++ b/cli/validate.ts @@ -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 { 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 { 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 { 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 = {}; 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 { 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 { 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) { diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 6ffd6c3..e5db8ff 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -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) | | diff --git a/skills/release-flows/SKILL.md b/skills/release-flows/SKILL.md index 266e326..b5dfb0a 100644 --- a/skills/release-flows/SKILL.md +++ b/skills/release-flows/SKILL.md @@ -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 | ${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=` +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). diff --git a/templates/workflows/beta.yml b/templates/workflows/beta.yml index 6049b2e..2dfe6f1 100644 --- a/templates/workflows/beta.yml +++ b/templates/workflows/beta.yml @@ -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= 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= or add a group with internal: true to apple.testflight.groups, then storebound apple testflight distribute --build ${BUILD} --group " - id: play-build if: play run: play build --version-name ${VERSION} diff --git a/templates/workflows/release.yml b/templates/workflows/release.yml index f73ecca..13d9d79 100644 --- a/templates/workflows/release.yml +++ b/templates/workflows/release.yml @@ -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= 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 --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" diff --git a/tests/flow-dry.test.ts b/tests/flow-dry.test.ts new file mode 100644 index 0000000..7dd5ccc --- /dev/null +++ b/tests/flow-dry.test.ts @@ -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) { + const f = load(name); + const pending: DryPending = { built: new Set(), uploaded: new Set() }; + const out: Record = {}; + 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) => 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='); + }); +}); diff --git a/tests/next-build-platforms.test.ts b/tests/next-build-platforms.test.ts new file mode 100644 index 0000000..7b4fa28 --- /dev/null +++ b/tests/next-build-platforms.test.ts @@ -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 = { + '/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'); + }); +}); diff --git a/tests/validate-macos.test.ts b/tests/validate-macos.test.ts new file mode 100644 index 0000000..baae419 --- /dev/null +++ b/tests/validate-macos.test.ts @@ -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[0], shots: Record): 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'); + } + }); +}); diff --git a/tests/validate-play-edit.test.ts b/tests/validate-play-edit.test.ts new file mode 100644 index 0000000..fb21da1 --- /dev/null +++ b/tests/validate-play-edit.test.ts @@ -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([]); + }); +});