// Console plans for the Play Console pages that have no API and no scripted fill (`storebound console plan
`): // Store settings (the App category: the Play Developer API's edits.details has no category field, so `play listing` // leaves it "Not selected") and the App content declarations next to Content rating and Target audience: Privacy // policy, Ads, Sign in details (App access), Advertising ID, Government apps, Financial features, Health apps, plus the // App content overview that lists every card in order. Pure: answers come from the config only (AGENTS.md rule 5: // anything it does not prove is ask-owner, collected in the one batch at the start). // Verified on a real Play Console by an agent with a browser tool (2026-10-12): // /developers//app//store-settings: the first Edit opens App category; Category is a dropdown whose // entries are [role=option] with the exact category name; Save stores it. // /developers//app//app-content/overview: each card has a button aria-label="Start declaration". // Card pages: app-content/privacy-policy, ads-declaration, testing-credentials (Sign in details), ad-id-declaration, // government-apps, finance (2 steps; the last has "My app doesn't provide any financial features"), health (2 steps; // the last step's "Other" box was the no-health-features answer on that run), target-audience-content, // content-rating-overview > "Start questionnaire" > content-rating-iarc-questionnaire. // The Data safety card's slug was not recorded (modeled); storebound answers Data safety through the API anyway. import type { ShipConfig } from './config'; import { PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES, playCategoryOf, profileOf } from './config'; import type { PlanFormId, PlanOnlyFormId } from './console-answers'; import { NEVER_PRESS, playAppList } from './console-forms'; import type { ConsolePlan, PlanAnswer, PlanIds, PlanStatus, PlanStep } from './console-plan'; const PLAY = 'https://play.google.com/console'; /** Play Console's material radios and checkboxes (verified 2026-10-12 on Create app, App content and the IARC * questionnaire): what every Play plan tells the agent. */ export const PLAY_MATERIAL_FACT = 'Play Console radios and checkboxes are / : computer-tool clicks and setting the native input (form_input) can leave the Angular model unchanged. Click the inner [role=radio] / [role=checkbox] (or the itself) with a JS el.click(), then read aria-checked (or input.checked). Their labels sit in sibling nodes, not in an aria-label: walk up from the control to the nearest parent whose innerText is not empty and match by "starts with" (verified 2026-10-12).'; /** One App content card on /app-content/overview, in the page's order. */ export interface AppContentCard { /** The card's name; its button is aria-label="Start declaration". */ name: string; /** The page under /app-content/. */ slug: string; /** The plan that fills it, or null when an API command covers it. */ form: PlanFormId | null; /** How storebound covers it. */ via: string; /** The slug was read on a real console (2026-10-12). */ verified: boolean; } export const APP_CONTENT_CARDS: AppContentCard[] = [ { name: 'Privacy policy', slug: 'privacy-policy', form: 'play-privacy-policy', via: 'console plan play-privacy-policy', verified: true }, { name: 'Ads', slug: 'ads-declaration', form: 'play-ads', via: 'console plan play-ads', verified: true }, { name: 'Sign in details', slug: 'testing-credentials', form: 'play-sign-in-details', via: 'console plan play-sign-in-details', verified: true }, { name: 'Content ratings', slug: 'content-rating-overview', form: 'play-content-rating', via: 'console plan play-content-rating (Start questionnaire > content-rating-iarc-questionnaire)', verified: true }, { name: 'Target audience and content', slug: 'target-audience-content', form: 'play-target-audience', via: 'console plan play-target-audience', verified: true }, { name: 'Data safety', slug: 'data-privacy-security', form: null, via: 'storebound play data-safety (API; no console form to fill; the page has Export to CSV / Import from CSV)', verified: true }, { name: 'Advertising ID', slug: 'ad-id-declaration', form: 'play-advertising-id', via: 'console plan play-advertising-id', verified: true }, { name: 'Government apps', slug: 'government-apps', form: 'play-government-apps', via: 'console plan play-government-apps', verified: true }, { name: 'Financial features', slug: 'finance', form: 'play-financial-features', via: 'console plan play-financial-features', verified: true }, { name: 'Health apps', slug: 'health', form: 'play-health-apps', via: 'console plan play-health-apps', verified: true }, ]; /** The aria-label of a card's start button on the overview. */ export const startLabel = (card: string): string => `Start ${card} declaration`; const devOf = (c: ShipConfig, p: PlanIds) => p.playDeveloperId ?? c.play?.developerId ?? null; const appOf = (c: ShipConfig, p: PlanIds) => p.playAppId ?? c.play?.appId ?? null; /** /console/u/0/developers//app//. */ export const playAppPage = (dev: string, app: string, path: string): string => `${PLAY}/u/0/developers/${encodeURIComponent(dev)}/app/${encodeURIComponent(app)}/${path}`; function start(config: ShipConfig, p: PlanIds, path: string, reach: string[]): ConsolePlan['start'] { const dev = devOf(config, p), app = appOf(config, p); if (dev && app) return { url: playAppPage(dev, app, path), deepLink: true, reach: [] }; return { url: dev ? playAppList(PLAY, dev) : PLAY, deepLink: false, reach: [...(dev ? [] : ['Pick the developer account if asked (set play.developerId for a deep link)']), `the app (${config.play?.packageName ?? config.app.name}) in the app list (set play.appId, the digits after /app/, for a deep link to /${path})`, ...reach], }; } 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 { title: string; start: ConsolePlan['start']; steps: PlanStep[]; answers: PlanAnswer[]; final: string; neverPress: string[]; allowed: ConsolePlan['buttons']['allowed']; uiFacts: string[]; verify: ConsolePlan['verify']; guidance: string[]; } const PAGE_TEXT = 'document.querySelector("main")?.innerText ?? document.body.innerText'; const pageVerify = (form: PlanFormId, expect: string[]): ConsolePlan['verify'] => ({ command: `storebound console verify ${form} --from-text -`, source: 'page-text', capture: PAGE_TEXT, expect }); const ans = (id: string, question: string, value: string | boolean | null, status: PlanStatus, source: string, extra: Partial = {}): PlanAnswer => ({ id, question, value: status === 'ask-owner' || status === 'agreement' ? null : value, text: status === 'agreement' ? 'the owner ticks it (an agreement)' : value === null || status === 'ask-owner' ? '(you decide)' : value === true ? 'Yes' : value === false ? 'No' : value, status, source, ...extra, }); const yesNo = (v: boolean, yes: string, no: string) => (v ? yes : no); function steps(list: (Omit | false | undefined)[]): PlanStep[] { return list.filter((s): s is Omit => !!s).map((s, i) => ({ n: i + 1, ...s })); } /** A declaration's Save: stores the answer, which Google reviews: the owner's (or the agent's after a clear yes in chat). */ const DECLARATION_SAVE = 'Stop before Save: show the owner the filled card and let them press Save (or press it after the owner\'s clear yes in chat; one yes can cover the cards listed by `console plan play-app-content`). Google reviews the declaration with the next submission.'; const declarationButtons = (where: string, extra: ConsolePlan['buttons']['allowed'] = []) => ({ final: 'Save', neverPress: ['Save', 'Send changes for review', ...NEVER_PRESS], allowed: [{ button: 'Start declaration / Manage', class: 'navigation', where: 'App content overview: opens the card' }, ...extra, { button: 'Back', class: 'navigation', where }], }); // ---- Store settings: the App category (no API) ---- function storeSettings(config: ShipConfig, p: PlanIds): Part { const prof = config.app.profile, game = profileOf(config) === 'game'; const type = prof ? ans('appType', 'App or game', game ? 'Game' : 'App', 'certain', 'app.profile', { control: 'radio', action: 'pick', label: game ? 'Game' : 'App', where: 'App category (after the first Edit)' }) : ans('appType', 'App or game', null, 'ask-owner', 'app.profile', { note: 'Set app.profile (game | app | subscription-app).', control: 'radio', where: 'App category' }); const cat = playCategoryOf(config); const list = (game ? PLAY_GAME_CATEGORIES : PLAY_APP_CATEGORIES).join(', '); const category = cat ? ans('category', 'App category', cat.category, cat.source === 'play.category' ? 'certain' : 'owner-check', cat.source, { control: 'select', action: 'choose', label: cat.category, where: 'App category > Category dropdown: the [role=option] with exactly this text', ...(cat.source === 'play.category' ? {} : { note: `Mapped from the App Store category; set play.category to make it explicit (one of ${list}).` }) }) : ans('category', 'App category', null, 'ask-owner', 'play.category', { control: 'select', action: 'choose', where: 'App category > Category dropdown', note: `Set play.category to one of: ${list}.` }); return { title: 'Play Console: Store settings (App category)', start: start(config, p, 'store-settings', ['Grow users > Store presence > Store settings']), steps: steps([ { text: 'Open Store settings of the app (/app//store-settings; deep link when play.appId is known).' }, { text: 'Press the FIRST "Edit" on the page (the App category section at the top; the other Edits are contact details and tags).', click: ['Edit'] }, { text: 'App or game: pick the material radio (JS click on the inner [role=radio]); the category list depends on it.', answers: ['appType'] }, { text: 'Category: open the dropdown, then click the [role=option] whose text is exactly the category of the sheet (e.g. "Strategy"). Read the dropdown back: it must show that name, not "Select a category".', answers: ['category'] }, { text: 'Press Save (stores the setting; on an app that is not published nothing is public). Read the section back: App category shows the type and the category, no "Not selected".', click: ['Save'] }, { text: 'Verify: read the page text and pipe it to `storebound console verify play-store-settings --from-text -`.' }, ]), answers: [type, category], final: 'Send changes for review', neverPress: ['Send changes for review', ...NEVER_PRESS], allowed: [ { button: 'Edit (the first one: App category)', class: 'navigation', where: 'Store settings: opens the App category section' }, { button: 'Save', class: 'draft-save', where: 'Store settings: stores the category. On a published app the change goes out with Google\'s review (with managed publishing: Publishing overview > Send changes for review, the owner\'s)' }, ], uiFacts: [ 'Verified 2026-10-12: `play listing` does not set the App category (the Play API\'s edits.details has no category field), so a new app shows "Not selected" until it is set here.', 'URL: /console/u/0/developers//app//store-settings. The first Edit on the page opens App category; Category is a dropdown whose entries are [role=option] elements with the exact category name.', 'Contact details (email, website, phone) on the same page come from the API (`storebound play listing`): leave them.', PLAY_MATERIAL_FACT, ], verify: pageVerify('play-store-settings', ['The App category section shows the app type and the category of the sheet; no "Not selected".']), guidance: ['Open: Grow users > Store presence > Store settings > App category > Edit'], }; } // ---- App content declarations ---- function privacyPolicy(config: ShipConfig, p: PlanIds): Part { const url = config.app.privacyPolicyUrl; return { title: 'Play Console: Privacy policy (App content)', start: start(config, p, 'app-content/privacy-policy', appContentReach('Privacy policy')), steps: steps([ { text: 'Open App content > Privacy policy (/app-content/privacy-policy).' }, { text: 'Type the privacy policy URL into the Privacy policy URL field (replace what is there).', answers: ['url'] }, { text: DECLARATION_SAVE }, { text: 'Verify: read the page text and pipe it to `storebound console verify play-privacy-policy --from-text -`.' }, ]), answers: [url ? ans('url', 'Privacy policy URL', url, 'certain', 'app.privacyPolicyUrl', { control: 'text', action: 'type', label: 'Privacy policy URL', where: 'Privacy policy' }) : ans('url', 'Privacy policy URL', null, 'ask-owner', 'app.privacyPolicyUrl', { note: 'Set app.privacyPolicyUrl (privacy-policy skill).' })], ...declarationButtons('Privacy policy'), uiFacts: ['Verified 2026-10-12: the card\'s page is /app-content/privacy-policy (one URL field).', 'The URL must be public (no PDF, no sign-in): `storebound validate` checks it.', PLAY_MATERIAL_FACT], verify: pageVerify('play-privacy-policy', ['The field shows app.privacyPolicyUrl.']), guidance: ['Open: Policy and programs > App content > Privacy policy'], }; } /** The ads answer (the same one the target audience plan uses). */ function adsValue(config: ShipConfig): { v: boolean; src: string } | null { const ta = config.console?.targetAudience; if (ta?.containsAds !== undefined) return { v: ta.containsAds, src: 'console.targetAudience.containsAds' }; if (config.ads?.appAdsTxt?.length) return { v: true, src: 'ads.appAdsTxt' }; return null; } function adsDeclaration(config: ShipConfig, p: PlanIds): Part { const a = adsValue(config); const yes = 'Yes, my app contains ads', no = 'No, my app does not contain ads'; return { title: 'Play Console: Ads (App content)', start: start(config, p, 'app-content/ads-declaration', appContentReach('Ads')), steps: steps([ { text: 'Open App content > Ads (/app-content/ads-declaration).' }, { text: 'Pick the radio (JS click on the inner [role=radio]); read aria-checked back.', answers: ['ads'] }, { text: DECLARATION_SAVE }, { text: 'Verify: read the page text and pipe it to `storebound console verify play-ads --from-text -`.' }, ]), answers: [a ? ans('ads', 'Does your app contain ads?', a.v, 'certain', a.src, { control: 'radio', action: 'pick', label: yesNo(a.v, yes, no), where: 'Ads' }) : ans('ads', 'Does your app contain ads?', null, 'ask-owner', 'console.targetAudience.containsAds', { control: 'radio', where: 'Ads', note: 'Any ad SDK (banner, rewarded, house ads of other apps) is Yes; set console.targetAudience.containsAds.' })], ...declarationButtons('Ads'), uiFacts: ['Verified 2026-10-12: /app-content/ads-declaration.', 'Rewarded ads count as ads.', PLAY_MATERIAL_FACT], verify: pageVerify('play-ads', ['The selected radio matches the sheet.']), guidance: ['Open: Policy and programs > App content > Ads'], }; } function signInDetails(config: ShipConfig, p: PlanIds): Part { const ra = config.reviewAccess; const all = 'All functionality in my app is available without any access restrictions', some = 'All or some functionality in my app is restricted'; const out: PlanAnswer[] = [ra ? ans('access', 'Is any functionality restricted (sign-in, membership, location)?', ra.signInRequired, 'certain', 'reviewAccess.signInRequired', { control: 'radio', action: 'pick', label: ra.signInRequired ? some : all, where: 'Sign in details (App access)' }) : ans('access', 'Is any functionality restricted (sign-in, membership, location)?', null, 'ask-owner', 'reviewAccess.signInRequired', { control: 'radio', where: 'Sign in details', note: 'Set reviewAccess { signInRequired } (review-access skill).' })]; if (ra?.signInRequired) { out.push(ra.instructions ? ans('instructions', 'Instructions for the reviewer', ra.instructions, 'certain', 'reviewAccess.instructions', { control: 'text', action: 'type', label: 'Any other information required to access your app', where: 'Add instructions' }) : ans('instructions', 'Instructions for the reviewer', null, 'ask-owner', 'reviewAccess.instructions', { note: 'Set reviewAccess.instructions (how to sign in and reach the main features).' })); out.push(ans('credentials', 'Username and password of the demo account', null, 'ask-owner', '.storebound/credentials.json reviewDemoAccount', { control: 'text', where: 'Add instructions: Username, Password', note: `The owner types them from .storebound/credentials.json (reviewDemoAccount: ${p.demoAccount === undefined ? 'not checked' : p.demoAccount ? 'present' : 'MISSING, add it first'}). storebound never prints them; an agent never types a password.` })); } return { title: 'Play Console: Sign in details (App access)', start: start(config, p, 'app-content/testing-credentials', appContentReach('Sign in details')), steps: steps([ { text: 'Open App content > Sign in details (/app-content/testing-credentials).' }, { text: 'Pick whether any functionality is restricted (material radio: JS click on the inner [role=radio]).', answers: ['access'] }, ra?.signInRequired && { text: 'Restricted: press "Add instructions", type the instructions; leave Username and Password to the owner (the demo account in .storebound/credentials.json).', click: ['Add instructions'], answers: ['instructions', 'credentials'] }, { text: DECLARATION_SAVE }, { text: 'Verify: read the page text and pipe it to `storebound console verify play-sign-in-details --from-text -`.' }, ]), answers: out, ...declarationButtons('Sign in details', [{ button: 'Add instructions', class: 'navigation', where: 'Sign in details: opens the instructions dialog' }]), uiFacts: ['Verified 2026-10-12: the Sign in details card (App access) is /app-content/testing-credentials.', 'Credentials are never in the config or in chat: the owner types them from .storebound/credentials.json.', PLAY_MATERIAL_FACT], verify: pageVerify('play-sign-in-details', ['The selected radio matches reviewAccess.signInRequired; with a sign-in, one set of instructions is listed.']), guidance: ['Open: Policy and programs > App content > Sign in details (App access)'], }; } /** Play's advertising ID purposes (the Data safety purpose names). */ const AD_ID_PURPOSES: Record = { functionality: 'App functionality', analytics: 'Analytics', communications: 'Developer communications', advertising: 'Advertising or marketing', fraud: 'Fraud prevention, security, and compliance', personalization: 'Personalization', account: 'Account management', }; function advertisingId(config: ShipConfig, p: PlanIds): Part { const ac = config.console?.appContent, dev = config.dataSafety?.data?.PSL_DEVICE_ID; const uses: { v: boolean; src: string } | null = ac?.advertisingId !== undefined ? { v: ac.advertisingId, src: 'console.appContent.advertisingId' } : config.ads?.appAdsTxt?.length ? { v: true, src: 'ads.appAdsTxt (ad SDKs read the advertising ID)' } : dev ? { v: true, src: 'dataSafety.data.PSL_DEVICE_ID' } : null; const out: PlanAnswer[] = [uses ? ans('uses', 'Does your app use advertising ID?', uses.v, 'certain', uses.src, { control: 'radio', action: 'pick', label: uses.v ? 'Yes' : 'No', where: 'Advertising ID' }) : ans('uses', 'Does your app use advertising ID?', null, 'ask-owner', 'console.appContent.advertisingId', { control: 'radio', where: 'Advertising ID', note: 'Yes when the app or any SDK (ads, analytics, attribution) reads it; set console.appContent.advertisingId.' })]; if (uses?.v) { if (dev) { const purposes = new Set([...dev.collect, ...(dev.shared ? dev.share ?? [] : [])]); for (const [k, label] of Object.entries(AD_ID_PURPOSES)) out.push(ans(`purpose:${k}`, `Advertising ID purpose: ${label}`, purposes.has(k as never), 'certain', 'dataSafety.data.PSL_DEVICE_ID', { control: 'checkbox', action: purposes.has(k as never) ? 'tick' : 'untick', label, where: 'Advertising ID: why it is used' })); } else out.push(ans('purposes', 'Advertising ID purposes', null, 'ask-owner', 'dataSafety.data.PSL_DEVICE_ID', { control: 'checkbox', where: 'Advertising ID', note: 'Add dataSafety.data.PSL_DEVICE_ID with its purposes (store-declarations skill): the same purposes answer this card.' })); } return { title: 'Play Console: Advertising ID (App content)', start: start(config, p, 'app-content/ad-id-declaration', appContentReach('Advertising ID')), steps: steps([ { text: 'Open App content > Advertising ID (/app-content/ad-id-declaration).' }, { text: 'Pick Yes or No (material radio: JS click on the inner [role=radio]).', answers: ['uses'] }, uses?.v && { text: 'Tick exactly the purposes of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back).', answers: out.filter(a => a.id.startsWith('purpose')).map(a => a.id) }, { text: DECLARATION_SAVE }, { text: 'Verify: read the page text and pipe it to `storebound console verify play-advertising-id --from-text -`.' }, ]), answers: out, ...declarationButtons('Advertising ID'), uiFacts: ['Verified 2026-10-12: /app-content/ad-id-declaration.', 'Yes needs the com.google.android.gms.permission.AD_ID permission in the merged manifest (ad SDKs usually add it); `storebound scan` reports the SDKs.', PLAY_MATERIAL_FACT], verify: pageVerify('play-advertising-id', ['The selected radio and the ticked purposes match the sheet.']), guidance: ['Open: Policy and programs > App content > Advertising ID'], }; } function government(config: ShipConfig, p: PlanIds): Part { const v = config.console?.appContent?.government; return { title: 'Play Console: Government apps (App content)', start: start(config, p, 'app-content/government-apps', appContentReach('Government apps')), steps: steps([ { text: 'Open App content > Government apps (/app-content/government-apps).' }, { text: 'Pick whether the app is developed by or on behalf of a government (material radio).', answers: ['government'] }, { text: DECLARATION_SAVE }, { text: 'Verify: read the page text and pipe it to `storebound console verify play-government-apps --from-text -`.' }, ]), answers: [v !== undefined ? ans('government', 'Is the app developed by or on behalf of a government?', v, 'certain', 'console.appContent.government', { control: 'radio', action: 'pick', label: v ? 'Yes' : 'No', where: 'Government apps' }) : ans('government', 'Is the app developed by or on behalf of a government?', null, 'ask-owner', 'console.appContent.government', { control: 'radio', where: 'Government apps', note: 'Set console.appContent.government (true only for a government body or its contractor).' })], ...declarationButtons('Government apps'), uiFacts: ['Verified 2026-10-12: /app-content/government-apps.', PLAY_MATERIAL_FACT], verify: pageVerify('play-government-apps', ['The selected answer matches the sheet.']), guidance: ['Open: Policy and programs > App content > Government apps'], }; } /** Financial features / Health apps: two steps, the features on the last one (or the none box). */ function featureCard(config: ShipConfig, p: PlanIds, kind: 'finance' | 'health'): Part { const fin = kind === 'finance'; const form: PlanOnlyFormId = fin ? 'play-financial-features' : 'play-health-apps'; const name = fin ? 'Financial features' : 'Health apps'; const key = fin ? 'financialFeatures' : 'healthFeatures'; const v = config.console?.appContent?.[key]; const none = fin ? 'My app doesn\'t provide any financial features' : 'Other'; const out: PlanAnswer[] = v === false ? [ans('none', fin ? 'No financial features' : 'No health features', true, 'certain', `console.appContent.${key}`, { control: 'checkbox', action: 'tick', label: none, curlyQuotes: false, where: `${name}, last step`, ...(fin ? {} : { note: 'On the verified run (2026-10-12) the no-health-features answer was the last step\'s "Other" box; when the page offers an explicit none option, tick that instead.' }) })] : Array.isArray(v) ? v.map(l => ans(`feature:${l}`, `${name}: ${l}`, true, 'certain', `console.appContent.${key}`, { control: 'checkbox', action: 'tick', label: l, where: `${name}, last step` })) : [ans('features', `${name}: which features does the app provide?`, null, 'ask-owner', `console.appContent.${key}`, { control: 'checkbox', where: `${name}, last step`, note: `Set console.appContent.${key}: false for none, or the console's labels to tick.` })]; return { title: `Play Console: ${name} (App content)`, start: start(config, p, `app-content/${kind}`, appContentReach(name)), steps: steps([ { text: `Open App content > ${name} (/app-content/${kind}).` }, { text: 'Step 1: read it and press Next.', click: ['Next'] }, { text: `Last step: tick exactly the boxes of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back)${fin ? '; no financial features: "My app doesn\'t provide any financial features"' : '; no health features: "Other" (as on the verified run) or the none option the page offers'}. Then Next if the page shows one.`, click: ['Next'], answers: out.map(a => a.id) }, { text: DECLARATION_SAVE }, { text: `Verify: read the page text and pipe it to \`storebound console verify ${form} --from-text -\`.` }, ]), answers: out, ...declarationButtons(name, [{ button: 'Next', class: 'navigation', where: `${name}: between the two steps` }]), uiFacts: [`Verified 2026-10-12: /app-content/${kind}, two steps; the last step holds ${fin ? '"My app doesn\'t provide any financial features"' : 'the health feature boxes, "Other" among them'}; then Next and Save.`, PLAY_MATERIAL_FACT], verify: pageVerify(form, ['The ticked boxes match the sheet.']), guidance: [`Open: Policy and programs > App content > ${name}`], }; } // ---- the first production release (after `play release --production draft`) ---- /** The managed publishing warning (Play's default is off: approved means live). */ export function managedPublishingNote(config: ShipConfig): string { return config.play?.managedPublishing === true ? 'Managed publishing is on (play.managedPublishing): after Google approves, the owner publishes the changes in Publishing overview.' : `Managed publishing is ${config.play?.managedPublishing === false ? 'off' : 'off by default (play.managedPublishing not set)'}: the release goes live automatically once Google approves it. To control the moment, the owner turns on Publishing overview > Managed publishing first (and sets play.managedPublishing: true).`; } const regionName = (code: string): string => { try { return new Intl.DisplayNames(['en'], { type: 'region' }).of(code) ?? code; } catch { return code; } }; function productionCountries(config: ShipConfig, p: PlanIds): Part { const list = config.play?.countries; const answers: PlanAnswer[] = list?.length ? list.map(c => ans(`country:${c}`, `Country / region: ${regionName(c)} (${c})`, true, 'certain', 'play.countries', { control: 'checkbox', action: 'tick', label: regionName(c), where: 'Edit countries / regions: the row of that country' })) : [ans('all', 'All countries / regions', true, 'certain', 'default (play.countries not set)', { control: 'checkbox', action: 'tick', label: 'Select all rows', where: 'Edit countries / regions: the mat-checkbox aria-label="Select all rows"', note: 'Set play.countries (ISO codes) to limit them.' })]; return { title: 'Play Console: production countries / regions', start: start(config, p, 'tracks/production', ['Test and release > Production']), steps: steps([ { text: 'Open the production track (/app//tracks/production) after `storebound play release --production draft` made the draft release.' }, { text: 'Open the "Countries / regions" tab and press "Edit countries / regions".', click: ['Countries / regions', 'Edit countries / regions'] }, { text: list?.length ? 'Tick exactly the rows of play.countries (material checkboxes: JS click on the inner [role=checkbox], read aria-checked back); untick the rest.' : 'Tick the mat-checkbox aria-label="Select all rows" (every country / region).', answers: answers.map(a => a.id) }, { text: 'Press Save. A "Go to Publishing overview?" dialog follows: it only navigates; go on with `storebound console plan play-production-release`.', click: ['Save'] }, { text: 'Verify: read the tab\'s text and pipe it to `storebound console verify play-production-countries --from-text -` (or run `storebound validate --play`: it reads the production track\'s countries through the API).' }, ]), answers, final: 'Send changes for review', neverPress: ['Send changes for review', ...NEVER_PRESS], allowed: [ { button: 'Countries / regions (tab), Edit countries / regions', class: 'navigation', where: 'production track: opens the country list' }, { button: 'Save', class: 'draft-save', where: 'country list: stores the countries of the draft production release; nothing is public before the owner sends the changes for review' }, { button: 'Go to Publishing overview / its dismiss button', class: 'navigation', where: 'the dialog after Save: navigation only' }, ], uiFacts: [ 'Verified 2026-10-12: the API\'s edits.countryAvailability is read-only, so the countries are a console step. tracks/production > Countries / regions tab > Edit countries / regions > a mat-checkbox aria-label="Select all rows" (or one per row) > Save > a "Go to Publishing overview?" dialog.', 'A production track with 0 countries cannot reach anyone: `storebound validate` reports it.', PLAY_MATERIAL_FACT, ], verify: pageVerify('play-production-countries', ['The tab lists the countries / regions of the sheet (all of them by default); not 0.']), guidance: ['Open: Test and release > Production > Countries / regions > Edit countries / regions'], }; } /** The dialog Publishing overview shows when changes are submitted while a review is running (seen 2026-10-12). */ export const RESTART_DIALOG = 'Do you want to restart your review?'; function productionRelease(config: ShipConfig, p: PlanIds): Part { const mp = managedPublishingNote(config); return { title: 'Play Console: send the first production release for review', start: start(config, p, 'tracks/production', ['Test and release > Production']), steps: steps([ { text: 'Before: the production countries are set (`storebound console plan play-production-countries`) and every App content card is done (`console plan play-app-content`).' }, { text: 'Production track > Releases tab > "Edit release" on the draft that `play release --production draft` made.', click: ['Releases', 'Edit release'] }, { text: 'Press Next: the review page lists errors and warnings. A missing deobfuscation file is only a warning. Any error: stop and fix it (it names the page).', click: ['Next'] }, { text: 'Press Save (stores the release; nothing is sent yet).', click: ['Save'] }, { text: 'Open Publishing overview (left menu). It lists "Changes not yet sent for review" and a "Submit N changes for review" button.' }, { text: `Stop. The owner presses "Submit N changes for review" and then "Send changes for review" in the "Send N changes for review?" dialog (a real mouse click: a JS .click() did nothing on it). ${mp}`, answers: ['managedPublishing'] }, { text: `While a review is already running, "Submit N changes for review" opens "${RESTART_DIALOG}" instead, with "Restart review": that sends the new changes and starts the running review over. It is the owner's button as well (tell the owner a review is running and what restarting means); never the agent's. Cancel closes the dialog and nothing changes.` }, { text: 'Verify: read Publishing overview as text and pipe it to `storebound console verify play-production-release --from-text -`; `storebound play status` shows the production release.' }, ]), answers: [ans('managedPublishing', 'Managed publishing on?', config.play?.managedPublishing ?? false, config.play?.managedPublishing === undefined ? 'owner-check' : 'certain', config.play?.managedPublishing === undefined ? 'Play default (off)' : 'play.managedPublishing', { note: mp, where: 'Publishing overview > Managed publishing (only read; the owner switches it)' })], final: 'Send changes for review', neverPress: ['Submit N changes for review', 'Send changes for review', 'Restart review', ...NEVER_PRESS], allowed: [ { button: 'Releases (tab), Edit release, Next', class: 'navigation', where: 'production track: opens the draft release and its review page' }, { button: 'Save', class: 'draft-save', where: 'the release review page: stores the draft release; nothing is sent' }, { button: 'Cancel', class: 'navigation', where: `the "${RESTART_DIALOG}" dialog: closes it; the running review goes on unchanged` }, ], uiFacts: [ 'Verified 2026-10-12 on a first production release: Releases tab > Edit release > Next (review page; a missing deobfuscation file is only a warning) > Save; then Publishing overview > "Submit N changes for review" > dialog "Send N changes for review?" > "Send changes for review" (the owner\'s final button; a JS .click() did nothing on it, it needs a real click).', `Seen 2026-10-12: pressing "Submit N changes for review" while a review is running opens the dialog "${RESTART_DIALOG}" with "Restart review" (the owner\'s final button: the running review starts over with the new changes) and Cancel. Known dialog; agents never press Restart review.`, mp, PLAY_MATERIAL_FACT, ], verify: pageVerify('play-production-release', ['Publishing overview no longer lists changes not yet sent for review (they are "In review").']), guidance: ['Open: Test and release > Production > Releases > Edit release; then Publishing overview'], }; } export function playOnlyPart(form: Exclude, config: ShipConfig, p: PlanIds): Part { switch (form) { case 'play-production-countries': return productionCountries(config, p); case 'play-production-release': return productionRelease(config, p); case 'play-store-settings': return storeSettings(config, p); case 'play-privacy-policy': return privacyPolicy(config, p); case 'play-ads': return adsDeclaration(config, p); case 'play-sign-in-details': return signInDetails(config, p); case 'play-advertising-id': return advertisingId(config, p); 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'); } } /** The overview's start (the App content page itself). */ export const appContentStart = (config: ShipConfig, p: PlanIds): ConsolePlan['start'] => start(config, p, 'app-content/overview', ['Policy and programs > App content']); /** Capture for `console verify play-app-content`: the cards still to start (their aria-labels) and the page text. */ export const APP_CONTENT_CAPTURE = 'JSON.stringify({ start: [...document.querySelectorAll(\'[aria-label^="Start "][aria-label$=" declaration"]\')].map(b => b.getAttribute("aria-label")), text: (document.querySelector("main") ?? document.body).innerText })'; export type { Part as PlayPart };