upload --pre-check/--evidence, shots copy translate, game-center --locale-map, play games API-disabled hint

This commit is contained in:
CRTkafa 2026-10-24 12:10:23 +08:00
parent 29a9683ab7
commit 8ffa27d4f8
24 changed files with 598 additions and 27 deletions

View file

@ -1,5 +1,7 @@
// Game Center through the App Store Connect API, from the `games` config section (cli/lib/game-services.ts).
// storebound apple game-center [status] [--dry] [--yes] [--json]
// storebound apple game-center [status] [--locale-map en=en-GB] [--dry] [--yes] [--json]
// --locale-map (config games.localeMap): config language -> the locale the live Game Center uses; the dry run suggests it
// when Game Center has exactly one variant of a language (en-GB) and the config says another (en-US).
// status: read-only (App ID capability, Game Center detail, the editable version's switch, leaderboards and
// achievements on the store vs the config, the project's entitlement). Default: the upsert. Every write is a
// find-then-create (createOnce: a conflict or an unclear answer reads the store again, never a second create) or a
@ -10,7 +12,7 @@ import { consoleLink } from '../lib/console-links';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { dryPlan } from '../lib/dry';
import { type GcExisting, gcFindings, type ItemPlan, planGameCenter, planRows, usesGameCenter } from '../lib/game-services';
import { type GcExisting, gcFindings, gcLocaleMap, type ItemPlan, liveGcLocales, planGameCenter, planRows, suggestLocaleMap, usesGameCenter } from '../lib/game-services';
import { done, log, warn } from '../lib/log';
import { createOnce } from '../lib/upsert';
import { type Asc, ascClient, type J } from './asc';
@ -61,21 +63,24 @@ export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const a = ctx.args;
const sub = a._[0] ?? 'sync';
if (!['sync', 'status'].includes(sub)) throw new Error('usage: storebound apple game-center [status] [--dry] [--yes] [--json]');
if (!['sync', 'status'].includes(sub)) throw new Error('usage: storebound apple game-center [status] [--locale-map en=en-GB] [--dry] [--yes] [--json]');
if (!usesGameCenter(config)) throw new Error('games.gameCenter is not true in storebound.config.ts (game-services skill): nothing to do');
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
const c = await ascClient(root, bundleId, config.apple?.appId);
const appId = await c.app();
const s = await readGameCenter(c, bundleId);
const g = config.games!;
const plan = planGameCenter(g, config.languages, s);
const localeMap = gcLocaleMap(g, a.many('locale-map'));
const plan = planGameCenter(g, config.languages, s, localeMap);
const suggest = suggestLocaleMap(config.languages, liveGcLocales(s), localeMap);
const project = gcFindings(config, readProjectEntitlements(root));
if (a.has('json')) { console.log(JSON.stringify({ ...stateSummary(s, plan), project, plan }, null, 2)); return; }
if (a.has('json')) { console.log(JSON.stringify({ ...stateSummary(s, plan), project, localeMap, ...(suggest.length ? { suggestLocaleMap: suggest } : {}), plan }, null, 2)); return; }
if (s.capability === false) warn(TAG, `the App ID ${bundleId} has no GAME_CENTER capability: storebound apple signing ensure --dry, then without --dry (games.gameCenter adds it)`);
if (s.capability === null) warn(TAG, `bundle id ${bundleId} not found in Certificates, Identifiers & Profiles: storebound apple bundle`);
for (const f of project) (f.level === 'ok' ? log : warn)(TAG, `${f.msg}${f.fix ? ` (${f.fix})` : ''}`);
for (const x of suggest) warn(TAG, `config language "${x.id}" is ${x.from}, but the live Game Center has only ${x.to} for that language, so ${x.from} would be created next to it. Map it: storebound apple game-center --locale-map ${x.id}=${x.to} (or games: { localeMap: { ${x.id}: '${x.to}' } } in the config)`);
const rows = [
{ item: 'Game Center (app)', action: s.detail ? 'keep' : 'create', detail: 'POST /v1/gameCenterDetails' },
...(s.version ? [{ item: `Game Center (version ${s.version.versionString})`, action: s.version.enabled ? 'keep' : 'create', detail: 'POST /v1/gameCenterAppVersions' }] : []),
@ -122,7 +127,7 @@ export async function run(ctx: Ctx): Promise<void> {
}
// read back: the store must now match the config (lists can lag right after a create: say so, do not fail)
const after = await readGameCenter(c, bundleId);
const left = planGameCenter(g, config.languages, after).filter(p => p.action !== 'keep' || p.locales.some(l => l.action !== 'keep'));
const left = planGameCenter(g, config.languages, after, localeMap).filter(p => p.action !== 'keep' || p.locales.some(l => l.action !== 'keep'));
if (!after.detail || left.length) warn(TAG, `read back: ${!after.detail ? 'no Game Center detail yet; ' : ''}${left.map(p => `${p.kind} ${p.id}`).join(', ') || ''} not listed as written yet (App Store Connect lists lag after a create): run storebound apple game-center status in a minute`);
done(TAG, `Game Center: ${after.leaderboards.length} leaderboard(s), ${after.achievements.length} achievement(s); images and review go with the next version (${consoleLink('asc.version')})`);
}

View file

@ -1,5 +1,5 @@
// Upload an IPA to App Store Connect and wait until Apple has processed the build.
// storebound apple upload [<app.ipa>] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]
// storebound apple upload [<app.ipa>] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check "<cmd with {file}>"] [--evidence <path.json>] [--dry] [--yes] [--json]
// Default IPA: the newest in <apple.build.out or build/ios>/export (and its <version>-<build>/ folders), or with
// --build N the one with that build number. The bundle id, version and build number come from the IPA's Info.plist.
// An IPA whose build number is already on App Store Connect fails with exit 1 (a stale IPA left by a failed
@ -16,6 +16,7 @@
// with `apple testflight wait` (waitForBuild) until processingState is VALID (exit 0), INVALID / FAILED (exit 1) or the
// timeout (exit 7).
// An upload is a write: --dry shows the plan; a real run asks first (--yes in scripts).
import { runPreCheck } from '../lib/pre-check';
import { appleSdkProblem } from '../lib/store-minimums';
import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
import { basename, join, relative } from 'node:path';
@ -251,6 +252,7 @@ export async function run(ctx: Ctx): Promise<void> {
// upload when NSPrivacyTracking and NSPrivacyTrackingDomains disagree (ITMS-91064), so stop before any network call
const privacy = bundleTrackingIssues(ipaManifests(ipa));
if (privacy.length) throw new Error(`${relative(root, ipa)}: not uploaded, Apple would refuse it as Invalid Binary:\n${privacy.map(x => ` ${x}`).join('\n')}\nFix the manifest, rebuild (storebound apple build) and upload the new IPA.`);
const pre = await runPreCheck({ args: a, config, root, resolve, file: ipa, shown: relative(root, ipa), tag: TAG });
const size = statSync(ipa).size;
const dry = a.has('dry');
const json = a.has('json');
@ -294,7 +296,7 @@ export async function run(ctx: Ctx): Promise<void> {
? [describeReq(reqs.upload), describeReq(reqs.file('<upload id>')), `PUT the file parts to the upload URLs Apple returns`, describeReq(reqs.commit('<file id>', '<md5>')), 'GET /v1/buildUploads/<id> until COMPLETE']
: [showCmd(altoolArgs(ipa, { keyPath: '$ASC_P8', keyId: '$ASC_KEY_ID', issuerId: '$ASC_ISSUER_ID' }), null)];
if (wait) plan.push(`GET /v1/builds?filter[version]=${info.build} until processingState is VALID (timeout ${a.str('timeout', '60m')})`);
const summary = { ipa: relative(root, ipa), bundleId: info.bundleId, version: info.version, build: info.build, sizeMB: Math.round(size / 104857.6) / 10, via, plan };
const summary = { ipa: relative(root, ipa), bundleId: info.bundleId, version: info.version, build: info.build, sizeMB: Math.round(size / 104857.6) / 10, via, plan, ...(pre ? { preCheck: pre } : {}) };
if (dry) {
if (json) { printJson(summary); return; }
log(TAG, `${summary.ipa}: ${info.bundleId} ${info.version} (${info.build}), ${summary.sizeMB} MB, build number free`);