console plan play-games-services: agent-first PGS setup (games project, Android credential with the app-signing SHA-1, web OAuth client, owner's Publish) with absolute Play Console and Google Cloud links, and console verify from the page text

This commit is contained in:
CRTkafa 2026-10-21 05:02:16 +07:00
parent ca7289b173
commit 5a5d7fd4e7
8 changed files with 104 additions and 7 deletions

View file

@ -28,7 +28,8 @@ export type FormId = typeof FORM_IDS[number];
/** Forms with a console plan only (the agent drives them; no scripted fill): Play Store settings (the App category the
* API cannot set), the App content overview and its declarations without a fill form (cli/lib/console-play-plans.ts). */
export const PLAN_ONLY_FORM_IDS = ['play-store-settings', 'play-app-content', 'play-privacy-policy', 'play-ads', 'play-sign-in-details',
'play-advertising-id', 'play-government-apps', 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release'] as const;
'play-advertising-id', 'play-government-apps', 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release',
'play-games-services'] as const;
export type PlanOnlyFormId = typeof PLAN_ONLY_FORM_IDS[number];
/** Every form `console plan` / `console verify` know. */
export const PLAN_FORM_IDS = [...FORM_IDS, ...PLAN_ONLY_FORM_IDS] as const;

View file

@ -0,0 +1,90 @@
// `storebound console plan play-games-services`: the Play Games Services setup has no API (the games project, its
// credentials, publishing). Only the leaderboards and achievements do (`storebound play games`). Agent-first like the
// other Play plans: the agent drives Play Console and Google Cloud console with its own browser tool, stops before
// Publish (the owner's button), then `console verify play-games-services --from-text -`. Pure.
// Modeled on Google's help ("Set up Play Games Services", "Create a credential", "Publish your game's changes"),
// not yet verified on a real console: the page paths and labels are where to start, the agent adapts.
// App signing SHA-1: the Play Developer API has no read of the app signing certificate SHA-1 (generatedapks.list only
// gives certificateSha256Hash), so the plan points at Play Console > App integrity > Play app signing.
import type { ShipConfig } from './config';
import { consoleUrl, PLAY_CONSOLE } from './console-links';
import { NEVER_PRESS, PLAY_NEVER_PRESS } from './console-forms';
import type { PlanAnswer, PlanIds } from './console-plan';
import { type Part, PLAY_MATERIAL_FACT } from './console-play-plans';
import type { VerifyRow } from './console-verify';
import { onStore } from './game-services';
export const GOOGLE_CLOUD_CREDENTIALS = 'https://console.cloud.google.com/apis/credentials';
export const GOOGLE_CLOUD_CONSENT = 'https://console.cloud.google.com/apis/credentials/consent';
export const PGS_PUBLISH = 'Publish';
const ids = (c: ShipConfig, p: PlanIds) => ({ playDeveloperId: p.playDeveloperId ?? c.play?.developerId, playAppId: p.playAppId ?? c.play?.appId });
export function playGamesPart(config: ShipConfig, p: PlanIds): Part {
const pkg = config.play?.packageName ?? '(play.packageName)';
const i = ids(config, p);
const exact = !!(i.playDeveloperId && i.playAppId);
const setup = consoleUrl('play.games-services', i), signing = consoleUrl('play.app-signing', i);
const g = config.games;
const pid = g?.playGames?.appId;
const ach = (g?.achievements ?? []).filter(a => onStore(a, 'play')).length;
const lb = (g?.leaderboards ?? []).filter(l => onStore(l, 'play')).length;
const a = (id: string, question: string, value: string | null, extra: Partial<PlanAnswer> = {}): PlanAnswer =>
({ id, question, value, text: value ?? '(you decide)', status: value === null ? 'ask-owner' : 'certain', source: 'storebound.config.ts', ...extra });
const answers: PlanAnswer[] = [
a('project', 'Games project', pid ? `the existing project ${pid}` : 'a new one ("No, my game doesn\'t use Google APIs")', { where: 'Setup: the first question', control: 'radio', action: 'pick' }),
a('displayName', 'Display name', config.play?.name ?? config.app.name, { where: 'Properties', control: 'text', action: 'type' }),
a('package', 'Android credential: package name', pkg, { where: 'Credentials > Add credential > Android', control: 'select', action: 'choose' }),
a('sha1', 'OAuth client SHA-1', 'the App signing key certificate SHA-1 from Play app signing (not the upload key)', { where: `Google Cloud > Credentials > OAuth client ID (Android): ${signing}`, control: 'text', action: 'type' }),
a('webClient', 'Web OAuth client (server auth codes)', null, { note: 'Only when a game server exchanges server auth codes (requestServerSideAccess). Ask the owner; with no server, skip it.', where: 'Credentials > Add credential > Game server' }),
];
return {
title: 'Play Console + Google Cloud: Play Games Services setup (games project, credentials, publish)',
start: { url: setup, deepLink: exact, reach: exact ? [] : ['Pick the app (set play.developerId and play.appId for a deep link)', 'Grow users > Play Games Services > Setup and management > Configuration'] },
steps: [
{ n: 1, text: `Open ${setup}. No games project yet: answer "No, my game doesn't use Google APIs" and press Create (a draft project; nothing is public). With games.playGames.appId set, check the page shows that project id.`, click: ['Create'], answers: ['project'] },
{ n: 2, text: 'Properties: Edit properties > display name (and the description / graphics the owner gave) > Save as draft.', click: ['Edit properties', 'Save as draft'], answers: ['displayName'] },
{ n: 3, text: `Google Cloud OAuth consent screen (once per Cloud project): ${GOOGLE_CLOUD_CONSENT}. If it is not configured, set it up as External with the app name and the support email; never press "Publish app" there without the owner.` },
{ n: 4, text: `App signing SHA-1: open ${signing} and copy "SHA-1 certificate fingerprint" of the App signing key certificate (Play re-signs installs with it). The upload key's SHA-1 is only for builds you install yourself (an optional second credential).` },
{ 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: 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 -\`.` },
],
answers,
final: PGS_PUBLISH,
neverPress: [...new Set([PGS_PUBLISH, 'Publish changes', 'Publish app', 'Delete', 'Unlink', ...PLAY_NEVER_PRESS, ...NEVER_PRESS])],
allowed: [
{ button: 'Create', class: 'navigation', where: 'Setup: creates the draft games project (not public)' },
{ button: 'Edit properties / Add credential / Create credentials', class: 'navigation', where: 'Play Console and Google Cloud console' },
{ button: 'Save as draft / Save changes', class: 'draft-save', where: 'Play Games Services pages: drafts until the owner publishes' },
{ button: 'Create (OAuth client ID)', class: 'draft-save', where: `${GOOGLE_CLOUD_CREDENTIALS}: an OAuth client (private to the Cloud project)` },
],
uiFacts: [
'Modeled on Google\'s Play Games Services help (not verified on a real console yet): Grow users > Play Games Services > Setup and management > Configuration; Credentials and Properties are sections of that page; Review and publish has the Publish button.',
'The Android credential needs the SHA-1 of the key that signs what players install: with Play app signing that is the App signing key certificate (Test and release > App integrity > Play app signing), not the upload key. The Play Developer API gives only SHA-256 hashes (generatedapks.list), so the SHA-1 is read from that page.',
'A games project links one Google Cloud project; linking a different one later is not possible from the page: check the project id before Create.',
PLAY_MATERIAL_FACT,
`Absolute pages: ${PLAY_CONSOLE} (Play Console), ${GOOGLE_CLOUD_CREDENTIALS} (OAuth clients), ${GOOGLE_CLOUD_CONSENT} (consent screen).`,
],
verify: { command: 'storebound console verify play-games-services --from-text -', source: 'page-text', capture: 'document.querySelector("main")?.innerText ?? document.body.innerText',
expect: ['The Configuration page shows the project id of games.playGames.appId', `An Android credential for ${pkg}`, 'The status is ready to publish or published'] },
guidance: ['Open: Grow users > Play Games Services > Setup and management > Configuration', `Then: ${GOOGLE_CLOUD_CREDENTIALS} for the OAuth clients`],
};
}
/** Page text of the Configuration page vs the config. */
export function verifyPlayGames(config: ShipConfig, text: string): VerifyRow[] {
const rows: VerifyRow[] = [];
const pid = config.games?.playGames?.appId;
const pkg = config.play?.packageName;
if (pid) rows.push({ item: 'project id', expected: pid, seen: text.includes(pid) ? pid : '(not on the page)', status: text.includes(pid) ? 'match' : 'mismatch' });
else rows.push({ item: 'project id', expected: 'games.playGames.appId', seen: (/\b\d{10,14}\b/.exec(text) ?? ['?'])[0], status: 'ask-owner', note: 'set games.playGames.appId to the project id shown on the page' });
if (pkg) rows.push({ item: 'Android credential', expected: pkg, seen: text.includes(pkg) ? pkg : '(no credential with the package)', status: text.includes(pkg) ? 'match' : 'mismatch' });
const published = /\bPublished\b/i.test(text) && !/not published|unpublished/i.test(text);
const ready = /ready to publish|ready for publishing/i.test(text);
rows.push({ item: 'status', expected: 'ready to publish or published', seen: published ? 'published' : ready ? 'ready to publish' : '(neither)', status: published || ready ? 'match' : 'mismatch', ...(ready && !published ? { note: 'the owner presses Publish' } : {}) });
return rows;
}

View file

@ -64,6 +64,7 @@ export const CONSOLE_PAGES = {
'play.internal-testing': { store: 'play', label: 'Play Console > Test and release > Internal testing', scope: 'app', path: 'tracks/internal-testing' },
'play.publishing': { store: 'play', label: 'Play Console > Publishing overview', scope: 'app', path: 'publishing' },
'play.app-signing': { store: 'play', label: 'Play Console > Test and release > App integrity > Play app signing', scope: 'app', path: 'keymanagement' },
'play.games-services': { store: 'play', label: 'Play Console > Grow users > Play Games Services > Setup and management > Configuration', scope: 'app', path: 'play-games-services/setup' },
'play.bundle-explorer': { store: 'play', label: 'Play Console > Test and release > App bundle explorer', scope: 'app', path: 'bundle-explorer-selector' },
'play.products': { store: 'play', label: 'Play Console > Monetize with Play > Products > In-app products', scope: 'app', path: 'managed-products' },
'play.subscriptions': { store: 'play', label: 'Play Console > Monetize with Play > Products > Subscriptions', scope: 'app', path: 'subscriptions' },

View file

@ -19,6 +19,7 @@ import type { PlanFormId, PlanOnlyFormId } from './console-answers';
import { NEVER_PRESS, PLAY_NEVER_PRESS, playAppList } from './console-forms';
import type { ConsolePlan, PlanAnswer, PlanIds, PlanStatus, PlanStep } from './console-plan';
import { AD_ID_PERMISSION, type ProjectSignals } from './project-signals';
import { playGamesPart } from './console-games-plan';
const PLAY = 'https://play.google.com/console';
@ -73,7 +74,7 @@ function start(config: ShipConfig, p: PlanIds, path: string, reach: string[]): C
const appContentReach = (card: string) => ['Policy and programs > App content (/app-content/overview)', `the "${card}" card: the button aria-label="${startLabel(card)}" (or its Manage / Edit link)`];
interface Part {
export interface Part {
title: string;
start: ConsolePlan['start'];
steps: PlanStep[];
@ -514,6 +515,7 @@ export function playOnlyPart(form: Exclude<PlanOnlyFormId, 'play-app-content'>,
case 'play-government-apps': return government(config, p);
case 'play-financial-features': return featureCard(config, p, 'finance');
case 'play-health-apps': return featureCard(config, p, 'health');
case 'play-games-services': return playGamesPart(config, p);
}
}

View file

@ -11,6 +11,7 @@
// apple-age-rating the API (ageRatingDeclaration)
// play-content-rating, play-target-audience page text (summary pages: a label line, its answer on the same or the
// next line). Modeled, not recorded: rows that cannot be found are "not found", never a match.
import { verifyPlayGames } from './console-games-plan';
import type { ShipConfig } from './config';
import { type Answer, consoleAnswers, type FormId, type PlanFormId, type PlanOnlyFormId, valueText } from './console-answers';
import { APP_CONTENT_CARDS, playOnlyPart, startLabel } from './console-play-plans';
@ -427,6 +428,7 @@ export function verifyText(form: PlanFormId, config: ShipConfig, text: string):
if (form === 'play-app-content') return verifyAppContent(text);
if (form === 'play-production-countries') return verifyProductionCountries(config, text);
if (form === 'play-production-release') return verifyProductionRelease(text);
if (form === 'play-games-services') return result(form, 'page-text', verifyPlayGames(config, text));
if (form === 'play-store-settings' || form === 'play-privacy-policy' || form === 'play-ads' || form === 'play-sign-in-details' || form === 'play-advertising-id'
|| form === 'play-government-apps' || form === 'play-financial-features' || form === 'play-health-apps') return verifyPlayDeclaration(form, config, text);
switch (form) {

View file

@ -19,6 +19,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import type { Language, Platform, ShipConfig } from './config';
import { consoleLink } from './console-links';
export const GC_FORMATS = ['INTEGER', 'DECIMAL_POINT_1_PLACE', 'DECIMAL_POINT_2_PLACE', 'DECIMAL_POINT_3_PLACE', 'ELAPSED_TIME_CENTISECOND',
'ELAPSED_TIME_MINUTE', 'ELAPSED_TIME_SECOND', 'MONEY_POUND_DECIMAL', 'MONEY_POUND', 'MONEY_DOLLAR_DECIMAL', 'MONEY_DOLLAR', 'MONEY_EURO_DECIMAL',
@ -347,8 +348,8 @@ export function pgsFindings(config: Pick<ShipConfig, 'games'>, p: PgsProject, ha
if (usesPlayGames(config) && !p.gradle.length)
out.push({ level: 'warn', id: 'pgs.gradle', msg: 'games.playGames is set but no Gradle file uses com.google.android.gms:play-services-games-v2', fix: 'add implementation "com.google.android.gms:play-services-games-v2:<version>" to the app module' });
if (!p.manifest) out.push({ level: 'error', id: 'pgs.manifest', msg: 'no <meta-data android:name="com.google.android.gms.games.APP_ID"> in AndroidManifest.xml: sign-in fails at runtime', fix: 'add <meta-data android:name="com.google.android.gms.games.APP_ID" android:value="@string/game_services_project_id"/> inside <application>' });
else if (!p.appId) out.push({ level: 'error', id: 'pgs.app-id', msg: `the manifest's APP_ID (${p.manifest.value}) points at no string resource with the project id`, fix: 'add res/values/games-ids.xml from Play Console > Play Games Services > Setup and management > Configuration > Get resources' });
else if (want && p.appId.value !== String(want)) out.push({ level: 'error', id: 'pgs.app-id', msg: `games_APP_ID ${p.appId.value} (${p.appId.file}) differs from games.playGames.appId ${want}`, fix: 'use the resources of the games project linked to this app (Play Console > Play Games Services > Configuration)' });
else if (!p.appId) out.push({ level: 'error', id: 'pgs.app-id', msg: `the manifest's APP_ID (${p.manifest.value}) points at no string resource with the project id`, fix: `add res/values/games-ids.xml from ${consoleLink('play.games-services', '> Get resources')}` });
else if (want && p.appId.value !== String(want)) out.push({ level: 'error', id: 'pgs.app-id', msg: `games_APP_ID ${p.appId.value} (${p.appId.file}) differs from games.playGames.appId ${want}`, fix: `use the resources of the games project linked to this app: ${consoleLink('play.games-services')}` });
else out.push({ level: 'ok', id: 'pgs.app-id', msg: `games_APP_ID ${p.appId.value} (${p.appId.file})${want ? ' matches the config' : ''}` });
if (p.appId && !want) out.push({ level: 'warn', id: 'pgs.config-id', msg: `games.playGames.appId is not set (the project has ${p.appId.value})`, fix: `set games.playGames.appId: "${p.appId.value}"` });
return out;

View file

@ -39,7 +39,7 @@ export const notSentProp = { type: 'boolean', default: false, description: 'Comm
/** Every form `console plan` / `console verify` know (cli/lib/console-answers.ts PLAN_FORM_IDS; a test keeps them equal). */
export const CONSOLE_PLAN_FORMS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience',
'play-store-settings', 'play-app-content', 'play-privacy-policy', 'play-ads', 'play-sign-in-details', 'play-advertising-id', 'play-government-apps',
'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release'];
'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release', 'play-games-services'];
export const TOOLS: ToolDef[] = [
{ name: 'check', title: 'Offline config check', command: ['check'], write: false, network: false,

View file

@ -26,7 +26,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch
const slugs: Record<string, string> = {
'play-store-settings': 'store-settings', 'play-app-content': 'app-content/overview', 'play-privacy-policy': 'app-content/privacy-policy',
'play-ads': 'app-content/ads-declaration', 'play-sign-in-details': 'app-content/testing-credentials', 'play-advertising-id': 'app-content/ad-id-declaration',
'play-government-apps': 'app-content/government-apps', 'play-financial-features': 'app-content/finance', 'play-health-apps': 'app-content/health',
'play-government-apps': 'app-content/government-apps', 'play-financial-features': 'app-content/finance', 'play-health-apps': 'app-content/health', 'play-games-services': 'play-games-services/setup',
'play-production-countries': 'tracks/production', 'play-production-release': 'tracks/production',
};
for (const form of PLAN_ONLY_FORM_IDS) {
@ -45,7 +45,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch
for (const b of ['Publish', 'Submit', 'Send changes for review']) expect(p.buttons.neverPress).toContain(b);
for (const b of p.buttons.allowed) expect(b.class).not.toBe('final-submit');
// an App content card's Save is the agent's (verified 2026-10-13); the owner's button is Send changes for review
if (form.startsWith('play-') && !['play-store-settings', 'play-app-content', 'play-production-countries', 'play-production-release'].includes(form)) {
if (form.startsWith('play-') && !['play-store-settings', 'play-app-content', 'play-production-countries', 'play-production-release', 'play-games-services'].includes(form)) {
expect(p.buttons.final).toBe('Send changes for review');
expect(p.buttons.neverPress).not.toContain('Save');
expect(p.buttons.allowed).toContainEqual(expect.objectContaining({ button: 'Save', class: 'declaration-save' }));