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

@ -768,6 +768,15 @@ export interface ShipConfig {
/** Words `whats-new check` must not flag as internal jargon (a product or game term that collides), any case. */
allowWords?: string[];
};
/** Uploads (`apple upload`, `play upload`). */
upload?: {
/** Shell command run before any byte is sent; `{file}` is the IPA / AAB path. A nonzero exit refuses the upload
* (ship-step error code PRE_CHECK_FAILED). `--pre-check` overrides it. */
preCheck?: string;
/** Where the pre-check evidence JSON (file, sha256, size, command, exit code, stdout tail, time, storebound
* version) is written. `--evidence` overrides it. */
evidence?: string;
};
/** Native builds (`apple build`, `play build`). */
build?: {
/** Shell command run in the project root before the archive / bundle, e.g. "npm run build && npx cap sync" for a

View file

@ -49,7 +49,7 @@ export function playGamesPart(config: ShipConfig, p: PlanIds): Part {
{ n: 5, text: `Credentials > Add credential > Android: pick ${pkg}; it links to an OAuth client in Google Cloud. Create it at ${GOOGLE_CLOUD_CREDENTIALS} > Create credentials > OAuth client ID > Android (package ${pkg}, the SHA-1 of step 4) > Create, then back in Play Console pick that client > Save changes.`, click: ['Add credential', 'Save changes'], answers: ['package', 'sha1'] },
{ n: 6, text: `Only with a game server (ask-owner): Add credential > Game server: create a Web application OAuth client at ${GOOGLE_CLOUD_CREDENTIALS}, pick it, Save changes. Its client id goes into the game's requestServerSideAccess; the client secret stays on the server (never in chat or files here).`, answers: ['webClient'] },
{ n: 7, text: 'Copy the project id (Configuration page, "Project ID" / Get resources): write it to games.playGames.appId, and put the resources XML into android/app/src/main/res/values/games-ids.xml. `storebound check` then compares it with the manifest\'s com.google.android.gms.games.APP_ID.' },
{ n: 8, text: `Leaderboards (${lb}) and achievements (${ach}) come from the config: run \`storebound play games --dry\`, then without --dry (the Configuration API; drafts only). Play needs at least 5 achievements before it publishes the games project.` },
{ n: 8, text: `Leaderboards (${lb}) and achievements (${ach}) come from the config: run \`storebound play games --dry\`, then without --dry (the Configuration API; drafts only). If it answers 403 SERVICE_DISABLED / accessNotConfigured, the Google Play Game Services Publishing API is off in the service account's Cloud project: open https://console.cloud.google.com/apis/library/gamesconfiguration.googleapis.com?project=<project id of the service account key> (the error names the project), press Enable, wait a minute, run it again. Play needs at least 5 achievements before it publishes the games project.` },
{ n: 9, text: 'Testers: Play Games Services > Testers lists who can sign in before publishing (the owner adds them; storebound never invites testers).' },
{ n: 10, text: `Stop before "${PGS_PUBLISH}" (Review and publish): the owner presses it. Then read the page text and pipe it to \`storebound console verify play-games-services --from-text -\`.` },
],

View file

@ -28,7 +28,7 @@ import { homedir, tmpdir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
import * as rt from './rt';
export interface ServiceAccount { client_email: string; private_key: string; token_uri?: string }
export interface ServiceAccount { client_email: string; private_key: string; token_uri?: string; project_id?: string }
export interface AscKey { keyId: string; issuerId: string; pem: string }
export interface ReviewContact { firstName: string; lastName: string; phone: string; email: string }
@ -201,7 +201,7 @@ export function parseServiceAccount(text: string): ServiceAccount {
const j = JSON.parse(text) as Partial<ServiceAccount>;
if (typeof j.client_email !== 'string' || typeof j.private_key !== 'string' || !j.private_key.includes('PRIVATE KEY'))
throw new Error('not a Google service account JSON key (client_email / private_key missing)');
return { client_email: j.client_email, private_key: j.private_key, token_uri: j.token_uri };
return { client_email: j.client_email, private_key: j.private_key, token_uri: j.token_uri, ...(typeof j.project_id === 'string' ? { project_id: j.project_id } : {}) };
}
export function playServiceAccount(root: string): ServiceAccount {

View file

@ -65,6 +65,8 @@ export interface GamesConfig {
gameCenter?: boolean;
/** Play Games Services: the games project id (the digits of games_APP_ID / com.google.android.gms.games.APP_ID). */
playGames?: { appId?: string };
/** Game Center only: config language id (or its apple locale) -> the locale the live Game Center uses, e.g. { en: 'en-GB' } when the app's English is en-GB there but the language says en-US. `storebound apple game-center --locale-map en=en-GB` overrides. */
localeMap?: Record<string, string>;
leaderboards?: GameLeaderboard[];
achievements?: GameAchievement[];
}
@ -78,6 +80,10 @@ export function validateGames(g: GamesConfig | undefined, languages: Language[]
if (g.gameCenter !== undefined && typeof g.gameCenter !== 'boolean') e.push('games.gameCenter: true | false');
if (g.playGames !== undefined && (typeof g.playGames !== 'object' || (g.playGames.appId !== undefined && !/^\d{6,20}$/.test(String(g.playGames.appId)))))
e.push('games.playGames.appId: the digits of the Play Games Services project id (games_APP_ID)');
if (g.localeMap !== undefined) {
if (!g.localeMap || typeof g.localeMap !== 'object' || Array.isArray(g.localeMap)) e.push('games.localeMap: an object { en: "en-GB" } (config language id or locale -> Game Center locale)');
else for (const [k, v] of Object.entries(g.localeMap)) if (typeof v !== 'string' || !/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(v)) e.push(`games.localeMap.${k}: "${String(v)}" is not a locale like en-GB`);
}
const langs = new Set(languages.map(l => l.id));
const seen = new Set<string>();
const checkId = (kind: string, id: unknown) => {
@ -133,7 +139,41 @@ export function changedFields(have: Record<string, unknown> | undefined, want: R
return Object.keys(want).filter(k => JSON.stringify(have?.[k] ?? null) !== JSON.stringify(want[k] ?? null));
}
const appleLocale = (languages: Language[], id: string): string | undefined => languages.find(l => l.id === id)?.apple;
const appleLocale = (languages: Language[], id: string, map: Record<string, string> = {}): string | undefined => {
const apple = languages.find(l => l.id === id)?.apple;
return map[id] ?? (apple ? map[apple] : undefined) ?? apple;
};
/** The effective Game Center locale map: games.localeMap, then --locale-map en=en-GB,tr=tr (flag entries win). */
export function gcLocaleMap(g: GamesConfig | undefined, flag: string[] = []): Record<string, string> {
const out: Record<string, string> = { ...(g?.localeMap ?? {}) };
for (const item of flag.flatMap(x => x.split(',')).map(x => x.trim()).filter(Boolean)) {
const m = /^([A-Za-z0-9_-]+)=([A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*)$/.exec(item);
if (!m) throw new Error(`--locale-map "${item}": use <config language id or locale>=<Game Center locale>, e.g. en=en-GB (several: en=en-GB,pt=pt-PT)`);
out[m[1]!] = m[2]!;
}
return out;
}
/** A suggestion per config language whose Game Center locale is not on the live Game Center while exactly one other
* variant of the same language is (en-US configured, only en-GB live): the map entry that stops the spurious creates. */
export function suggestLocaleMap(languages: Language[], liveLocales: string[], map: Record<string, string> = {}): { id: string; from: string; to: string }[] {
const live = [...new Set(liveLocales)];
if (!live.length) return [];
const out: { id: string; from: string; to: string }[] = [];
for (const l of languages) {
const from = appleLocale(languages, l.id, map);
if (!from || live.includes(from) || map[l.id] || map[l.apple ?? '']) continue;
const base = from.split('-')[0]!.toLowerCase();
const same = live.filter(x => x.split('-')[0]!.toLowerCase() === base);
if (same.length === 1) out.push({ id: l.id, from, to: same[0]! });
}
return out;
}
/** The live locales of a Game Center read (every leaderboard and achievement localization). */
export const liveGcLocales = (have: { leaderboards: GcExisting[]; achievements: GcExisting[] }): string[] =>
[...have.leaderboards, ...have.achievements].flatMap(x => x.localizations.map(l => String(l.attributes.locale ?? '')).filter(Boolean));
export function gcLeaderboardAttrs(l: GameLeaderboard): Record<string, unknown> {
return {
@ -151,7 +191,7 @@ export interface GcExisting { id: string; attributes: Record<string, unknown>; l
/** Game Center plan: matched by vendorIdentifier; create what is missing, PATCH what differs, keep the rest. Archived
* items on the store are left alone (an archived vendor id cannot be reused; the plan names it). */
export function planGameCenter(g: GamesConfig, languages: Language[], have: { leaderboards: GcExisting[]; achievements: GcExisting[] }): ItemPlan[] {
export function planGameCenter(g: GamesConfig, languages: Language[], have: { leaderboards: GcExisting[]; achievements: GcExisting[] }, localeMap: Record<string, string> = {}): ItemPlan[] {
const out: ItemPlan[] = [];
const one = (kind: ItemPlan['kind'], id: string, attrs: Record<string, unknown>, locs: Record<string, Record<string, unknown>>, list: GcExisting[]) => {
const cur = list.find(x => x.attributes.vendorIdentifier === id);
@ -166,7 +206,7 @@ export function planGameCenter(g: GamesConfig, languages: Language[], have: { le
for (const l of (g.leaderboards ?? []).filter(x => onStore(x, 'apple'))) {
const locs: Record<string, Record<string, unknown>> = {};
for (const [k, v] of Object.entries(l.locales)) {
const loc = appleLocale(languages, k);
const loc = appleLocale(languages, k, localeMap);
if (loc) locs[loc] = { name: v.name, ...(v.suffix ? { formatterSuffix: v.suffix } : {}) };
}
one('leaderboard', l.id, gcLeaderboardAttrs(l), locs, have.leaderboards);
@ -174,7 +214,7 @@ export function planGameCenter(g: GamesConfig, languages: Language[], have: { le
for (const a of (g.achievements ?? []).filter(x => onStore(x, 'apple'))) {
const locs: Record<string, Record<string, unknown>> = {};
for (const [k, v] of Object.entries(a.locales)) {
const loc = appleLocale(languages, k);
const loc = appleLocale(languages, k, localeMap);
if (loc) locs[loc] = { name: v.name, beforeEarnedDescription: v.description, afterEarnedDescription: v.earned ?? v.description };
}
one('achievement', a.id, gcAchievementAttrs(a), locs, have.achievements);

View file

@ -19,6 +19,7 @@ export const PLAY_USERS_URL = 'https://play.google.com/console/developers/users-
const TITLES: Record<string, string> = {
'playdeveloperreporting.googleapis.com': 'Google Play Developer Reporting API',
'androidpublisher.googleapis.com': 'Google Play Android Developer API',
'gamesconfiguration.googleapis.com': 'Google Play Game Services Publishing API',
'storage.googleapis.com': 'Cloud Storage API',
'storage-api.googleapis.com': 'Cloud Storage API',
};
@ -26,6 +27,7 @@ const TITLES: Record<string, string> = {
const NEEDS: Record<string, string> = {
'playdeveloperreporting.googleapis.com': 'Crash and ANR data',
'androidpublisher.googleapis.com': 'Reading and updating the app on Google Play',
'gamesconfiguration.googleapis.com': 'Play Games Services leaderboards and achievements (storebound play games)',
'storage.googleapis.com': 'Play statistics (store listing visitors, installs)',
'storage-api.googleapis.com': 'Play statistics (store listing visitors, installs)',
};

72
cli/lib/pre-check.ts Normal file
View file

@ -0,0 +1,72 @@
// The pre-upload check of `apple upload` and `play upload`: a shell command the project owns (a store-readiness gate,
// a policy scan of the binary) runs before any byte is sent.
// --pre-check "<cmd with {file}>" (config: upload.preCheck) {file} is replaced by the quoted IPA / AAB path
// --evidence <path.json> (config: upload.evidence) what ran, written also when the check fails
// A nonzero exit refuses the upload: ShipStepError PRE_CHECK_FAILED (exit 1 under the ship-step contract), nothing
// is sent. The evidence ({file, sha256, size, command, exitCode, stdoutTail, timestamp, storeboundVersion}) goes to
// --evidence and into the --json / --result-file result as `preCheck`.
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { basename, dirname } from 'node:path';
import type { Args } from './args';
import type { ShipConfig } from './config';
import { log } from './log';
import { pump } from './runner';
import * as rt from './rt';
import { SHIP_EXIT, ShipStepError, shipExtra, shipPhase } from './ship-step';
export interface PreCheckEvidence {
file: string; sha256: string; size: number; command: string; exitCode: number;
stdoutTail: string[]; timestamp: string; storeboundVersion: string;
}
/** The command with {file} replaced by the POSIX-quoted path (sh -c); a command without {file} gets none. */
export function preCheckCommand(template: string, file: string): string {
const q = `'${file.replace(/\\/g, '/').replace(/'/g, `'\\''`)}'`;
return template.split('{file}').join(q);
}
function sha256File(path: string): string { return createHash('sha256').update(readFileSync(path)).digest('hex'); }
/**
* Runs the pre-check for `file` when --pre-check / upload.preCheck is set (else returns null). Throws PRE_CHECK_FAILED
* on a nonzero exit, after writing the evidence. `shown` is the file name for the evidence (not the absolute path).
*/
export async function runPreCheck(o: { args: Args; config: ShipConfig; root: string; resolve: (p: string) => string; file: string; shown: string; tag: string }): Promise<PreCheckEvidence | null> {
const flag = o.args.get('pre-check');
if (flag === 'true') throw new Error('--pre-check "<command with {file}>" needs a command');
const template = (flag ?? o.config.upload?.preCheck)?.trim();
const evFlag = o.args.get('evidence');
if (evFlag === 'true') throw new Error('--evidence <path.json> needs a path');
const evPath = evFlag ?? o.config.upload?.evidence;
if (!template) {
if (evFlag) throw new Error('--evidence needs --pre-check (or upload.preCheck in the config)');
return null;
}
const command = preCheckCommand(template, o.file);
shipPhase('pre-check', command);
log(o.tag, `pre-check: $ ${command}`);
const p = rt.spawn(['sh', '-c', command], { cwd: o.root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: process.env });
const tail: string[] = [];
const each = (line: string, kind: 'stdout' | 'stderr'): void => {
if (kind === 'stdout') { tail.push(line); if (tail.length > 20) tail.shift(); }
console.error(` ${line}`);
};
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
const exitCode = Number(await p.exited);
const version = ((await import('../../package.json')) as { version: string }).version;
const ev: PreCheckEvidence = {
file: o.shown.split('\\').join('/'), sha256: sha256File(o.file), size: statSync(o.file).size, command, exitCode,
stdoutTail: tail, timestamp: new Date().toISOString(), storeboundVersion: version,
};
if (evPath) {
const abs = o.resolve(evPath);
mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, JSON.stringify(ev, null, 2) + '\n');
log(o.tag, `pre-check evidence: ${basename(abs)}`);
}
shipExtra({ preCheck: ev });
if (exitCode !== 0) throw new ShipStepError(`pre-check failed (exit ${exitCode}), nothing was uploaded: ${command}${tail.length ? `\n${tail.slice(-5).join('\n')}` : ''}`, SHIP_EXIT.failure, 'PRE_CHECK_FAILED');
log(o.tag, 'pre-check: ok');
return ev;
}

View file

@ -59,7 +59,7 @@ type Fields = Partial<Pick<ShipResult, 'track' | 'version' | 'build' | 'state' |
interface Run {
key: string; step: string; store: 'apple' | 'play';
progress: boolean;
fields: Fields; noop: boolean; warnings: string[]; detail?: unknown; declined?: string;
fields: Fields; extra?: Record<string, unknown>; noop: boolean; warnings: string[]; detail?: unknown; declined?: string;
}
const RUN = Symbol.for('storebound.shipRun');
const g = globalThis as unknown as Record<symbol, Run | null | undefined>;
@ -69,6 +69,8 @@ export const shipRun = (): Run | null => g[RUN] ?? null;
/** What the command did: fields of the final result (later calls overwrite earlier ones). */
export function shipReport(f: Fields): void { const r = shipRun(); if (r) Object.assign(r.fields, Object.fromEntries(Object.entries(f).filter(([, v]) => v !== undefined))); }
/** Extra keys of the final result (e.g. the preCheck evidence), kept also when the step fails. */
export function shipExtra(x: Record<string, unknown>): void { const r = shipRun(); if (r) r.extra = { ...r.extra, ...x }; }
/** Nothing to do (the build is on the store already, the release is there): exit 7 with what is there. */
export function shipNoop(f: Fields = {}): void { const r = shipRun(); if (r) { r.noop = true; shipReport(f); } }
/** A warning for the result's warnings[] (the log line is the command's own). */
@ -178,7 +180,7 @@ export function buildResult(run: Run, exit: ShipExitCode, error?: ShipErrorInfo)
...(f.artifact ? { artifact: f.artifact } : {}),
...(error ? { error } : {}),
};
return { ...out, ...Object.fromEntries(Object.entries(own).filter(([k]) => !(k in out) && !CONTRACT_KEYS.has(k))) } as ShipResult & Record<string, unknown>;
return { ...out, ...(run.extra ?? {}), ...Object.fromEntries(Object.entries(own).filter(([k]) => !(k in out) && !CONTRACT_KEYS.has(k))) } as ShipResult & Record<string, unknown>;
}
const CONTRACT_KEYS = new Set(['ok', 'step', 'store', 'track', 'version', 'build', 'state', 'url', 'warnings', 'artifact', 'error']);

View file

@ -0,0 +1,75 @@
// Screenshot copy translation (`storebound shots copy translate`): fills the languages of shots.copy (store/shots/
// copy.json: language id -> scene id -> {eyebrow, title [line1, line2], sub, badges}) from the source language's copy.
// Pure. Like the listing (lib/listing-translate.ts) the CLI never calls a translation service: the agent translates the
// sections marked "translate", or `--translate "<command>"` runs a command of your own once per caption field. A
// language written by this command is machine-translated until a human read it: the marker lives next to the copy file
// (copy.translations.json), because copy.json itself has no place for comments:
// { "de": { "from": "en", "status": "translate" | "draft" } }
// translate: the source text was copied in, still to translate draft: machine-translated, review then `shots copy reviewed`
// Caption limits (characters, per line / field), so a translation still fits the template without the overflow
// `shots check` would flag: eyebrow 30, each of the two title lines 28, sub 90, a badge 18. A translation over its limit
// is not kept (the source text stays and the language stays "translate").
import type { SceneCopy } from '../shots/lib';
export type CopyStatus = 'translate' | 'draft';
export type CopyMarks = Record<string, { from: string; status: CopyStatus }>;
export const CAPTION_LIMITS = { eyebrow: 30, title: 28, sub: 90, badge: 18 } as const;
export const marksPath = (copyPath: string): string => copyPath.replace(/\.json$/i, '') + '.translations.json';
export interface CopyField { key: string; text: string; limit: number }
/** The translatable strings of a scene, in a fixed order (key: eyebrow, title.0, title.1, sub, badge.<n>.<0|1>). */
export function sceneFields(sc: SceneCopy): CopyField[] {
const out: CopyField[] = [];
if (sc.eyebrow) out.push({ key: 'eyebrow', text: sc.eyebrow, limit: CAPTION_LIMITS.eyebrow });
sc.title.forEach((t, i) => { if (t) out.push({ key: `title.${i}`, text: t, limit: CAPTION_LIMITS.title }); });
if (sc.sub) out.push({ key: 'sub', text: sc.sub, limit: CAPTION_LIMITS.sub });
(sc.badges ?? []).forEach((b, i) => b.forEach((t, j) => { if (t) out.push({ key: `badge.${i}.${j}`, text: t, limit: CAPTION_LIMITS.badge }); }));
return out;
}
/** The scene with the given field texts put in (keys of sceneFields; a missing key keeps the source text). */
export function applyFields(src: SceneCopy, texts: Record<string, string>): SceneCopy {
const t = (key: string, d: string): string => texts[key] ?? d;
const out: SceneCopy = { title: [t('title.0', src.title[0]), t('title.1', src.title[1])] };
if (src.eyebrow !== undefined) out.eyebrow = t('eyebrow', src.eyebrow);
if (src.sub !== undefined) out.sub = t('sub', src.sub);
if (src.badges) out.badges = src.badges.map((b, i) => b.map((x, j) => (x === undefined ? x : t(`badge.${i}.${j}`, x))) as [string, string?]);
return out;
}
/** Characters the way a person counts them (code points). */
export const chars = (s: string): number => [...s].length;
export interface SceneCheck { scene: string; field: string; chars: number; limit: number }
/** Fields of a language's copy over their caption limit. */
export function overLimit(copy: Record<string, SceneCopy> | undefined): SceneCheck[] {
const out: SceneCheck[] = [];
for (const [scene, sc] of Object.entries(copy ?? {})) for (const f of sceneFields(sc)) if (chars(f.text) > f.limit) out.push({ scene, field: f.key, chars: chars(f.text), limit: f.limit });
return out;
}
/** Parses the marks file; anything unreadable counts as no marks. */
export function parseMarks(text: string | null): CopyMarks {
if (!text) return {};
try {
const j = JSON.parse(text) as Record<string, { from?: unknown; status?: unknown }>;
return Object.fromEntries(Object.entries(j).filter(([, v]) => v && (v.status === 'translate' || v.status === 'draft') && typeof v.from === 'string')
.map(([k, v]) => [k, { from: v.from as string, status: v.status as CopyStatus }]));
} catch { return {}; }
}
/** One line per marked language for `storebound check` / `shots copy check`; empty when nothing is marked. */
export function marksNotes(marks: CopyMarks, where: string): string[] {
const todo = Object.entries(marks).filter(([, m]) => m.status === 'translate').map(([k]) => k);
const drafts = Object.entries(marks).filter(([, m]) => m.status === 'draft').map(([k]) => k);
return [
...(todo.length ? [`${where}: not translated yet (status=translate): ${todo.join(', ')} (storebound shots copy translate --to ${todo.join(',')})`] : []),
...(drafts.length ? [`${where}: machine-translated drafts not reviewed: ${drafts.join(', ')} (storebound shots copy reviewed --lang <ids> after reading them)`] : []),
];
}
/** The fix to name in "no copy" problems of validateCopy. */
export const copyFix = (lang: string): string => `storebound shots copy translate --to ${lang}`;