cross-store text: other platforms named in App Store text (2.3.10: Android, Google Play, ...) or Play text (Metadata: iOS, App Store, Game Center, ...) are refused by every text write (apple / play listing, play release / upload notes, listing set, whats-new, storebound edit, desktop editor), blockers in check / validate, scan other-platforms / play-other-platforms; listing.allowPlatformMentions for the owner's functional mentions; whats-new splits [apple] / [play] lines per store; storebound api --yes without a TTY is refused (exit 1, stderr) instead of a silent dry run; pitfalls 77, 78

This commit is contained in:
CRTkafa 2026-10-20 02:57:25 +07:00
parent 3e4015b686
commit edabbec874
32 changed files with 613 additions and 53 deletions

View file

@ -2,6 +2,21 @@
## Unreleased
- **Other platforms in store text are refused (both stores).** One checker (`cli/lib/cross-store.ts`) finds Android,
Google Play (Games), Play Store, Galaxy Store, Amazon Appstore, Steam, Microsoft Store, AppGallery (and localized
forms) in App Store text (Guideline 2.3.10) and iOS, iPhone, iPad, App Store, Apple Arcade, Game Center, TestFlight in
Play text (Metadata policy), per locale, field and line with a fix. `apple listing`, `play listing`, `play release` /
`play upload` release notes, `listing set`, `whats-new check` / `apply`, `storebound edit` and the desktop editor
refuse before anything is sent (dry runs show the hits); `check` and `validate` report blockers; `scan` reports
`other-platforms` (2.3.10) and `play-other-platforms` (Play: Metadata). A functional mention stays only through the
owner's `listing.allowPlatformMentions: [{ store, text, reason }]`.
- **Per-store release notes.** Lines tagged `[apple]` / `[play]` (or a `[play]` line heading a block) in a shared notes
source go only into that store's notes; the draft leaves untagged other-platform lines out of the wrong store and
says so; each store's limit counts its own notes.
- **`storebound api` writes piped or redirected are refused loudly.** `--yes` with stdout not a TTY now exits 1 with
"refused: writes through `storebound api` need an interactive terminal ... Nothing was sent." on stderr instead of
a silent dry run.
## 0.8.13 · 2026-10-18
- **iPhone Duo screenshots.** `apple.iphoneDuo: true` adds the foldable's outer (1398 x 2034) and inner (2007 x 2853)

View file

@ -38,8 +38,8 @@ function StoreColumn({ project, data, store, l }: { project: Project; data: Edit
const lim = (data.limits[store] ?? {}) as Record<string, number>;
const vals = current(e, project, store, l, defs.map(d => d.f));
const issues = store === 'play'
? playLangIssues(l.lang, { title: vals.title || undefined, short: vals.short, full: vals.full, whatsNew: vals.whatsNew }, data.appName)
: appleLocaleIssues(l.lang, Object.fromEntries(Object.entries(vals).filter(([, v]) => v)), data.appleName);
? playLangIssues(l.lang, { title: vals.title || undefined, short: vals.short, full: vals.full, whatsNew: vals.whatsNew }, data.appName, data.allowPlatformMentions)
: appleLocaleIssues(l.lang, Object.fromEntries(Object.entries(vals).filter(([, v]) => v)), data.appleName, data.allowPlatformMentions);
// a new section starts empty: say what is required instead of a wall of errors
const fresh = !l.exists && Object.values(vals).every(v => !v);
return (

View file

@ -145,7 +145,11 @@ export async function run(args: Args): Promise<void> {
tty: stdinIsTTY() && !!process.stdout.isTTY,
agent: process.env.STOREBOUND_SERVE === '1' || process.env.STOREBOUND_MCP === '1',
});
if (decision.action === 'refuse') throw new Error(`${op.id}: ${decision.why}`);
if (decision.action === 'refuse') {
const instead = op.method !== 'GET' ? dedicatedCommand(st, op.id) : null;
if (instead) console.error(`api: dedicated command: ${instead}`);
throw new Error(`${op.id}: ${decision.why}`);
}
if (decision.action === 'dry') {
const instead = op.method !== 'GET' ? dedicatedCommand(st, op.id) : null;
if (instead) console.error(`api: ${op.id} has a dedicated storebound command that runs without a terminal: ${instead}`);

View file

@ -86,6 +86,9 @@ export type Decision =
| { action: 'dry'; why: string }
| { action: 'refuse'; why: string };
/** --yes without a terminal: refused loudly (exit 1, stderr), never a silent dry run (pitfall 77). */
export const NOT_A_TTY = 'refused: writes through `storebound api` need an interactive terminal (stdout is not a TTY: piped or redirected). Nothing was sent. Run it without a pipe or redirect, or use the dedicated storebound command.';
/** What to do with one call. Pure. */
export function decide(g: GateInput): Decision {
if (g.getOnly && g.method.toUpperCase() !== 'GET') return { action: 'refuse', why: 'api get runs GET operations only' };
@ -94,7 +97,7 @@ export function decide(g: GateInput): Decision {
? 'owner-only operation: never through MCP or the desktop app; the owner runs it in a terminal'
: 'writes through `storebound api` are not available to MCP or the desktop app; run it in a terminal' };
if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : 'writes are dry by default: add --yes (in a terminal) to send' };
if (!g.tty) return { action: 'dry', why: 'not an interactive terminal: by design, writes through `storebound api` are sent only from an interactive terminal (--yes alone is not enough)' };
if (!g.tty) return { action: 'refuse', why: NOT_A_TTY };
return g.kind === 'owner' ? { action: 'confirm', typed: true } : { action: 'send' };
}

View file

@ -14,6 +14,7 @@
// listing file: shows the diff, writes only with --write.
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { type AppleCopy, parseAppStoreListing } from '../lib/copy';
import { fieldsMentions, hitLine, type PlatformHit, refuseMentions } from '../lib/cross-store';
import { compactDiff } from '../lib/diff';
import { mergeAppleListing, overLimits } from '../lib/listing-pull';
import { counts, draftReason, draftSections, type ItemPlan, itemLines, planItem, planLocales, planVersion, short, type Skipped, type VersionPlan } from '../lib/listing-plan';
@ -86,6 +87,8 @@ export interface AppleListingPlan {
skipped: Skipped[];
notes: string[];
changes: number;
/** Android, Google Play, ... in the texts this run sends (Guideline 2.3.10): the run is refused (pitfall 77) */
mentions: PlatformHit[];
}
type Asc = Awaited<ReturnType<typeof ascClient>>;
@ -133,7 +136,10 @@ async function planListing(ctx: Ctx, c: Asc, appId: string, version: string): Pr
const changes = (versionPlan.action === 'create' || versionPlan.action === 'update' ? 1 : 0) + (build?.action === 'attach' ? 1 : 0)
+ infoLocs.filter(x => x.action !== 'same').length + (versionLocales ? versionLocales.filter(x => x.action !== 'same').length : Object.keys(wanted).length);
return { version, versionPlan, build, appInfo: { id: info.id, state: String(info.attributes?.state ?? info.attributes?.appStoreState ?? ''), locales: infoLocs },
const allow = config.listing?.allowPlatformMentions;
const mentions = Object.entries(listing).flatMap(([l, f]) => fieldsMentions('apple', l, { name: f.name, subtitle: f.subtitle, promotionalText: f.promotionalText,
keywords: f.keywords, description: f.description, whatsNew: versionPlan.first ? undefined : f.whatsNew }, allow));
return { mentions, version, versionPlan, build, appInfo: { id: info.id, state: String(info.attributes?.state ?? info.attributes?.appStoreState ?? ''), locales: infoLocs },
versionLocales, wanted, skipped, notes, changes };
}
@ -156,6 +162,7 @@ export function listingPlanLines(p: AppleListingPlan): string[] {
}
for (const s of p.skipped) out.push(`Skipped ${s.key}: ${s.reason}`);
for (const n of p.notes) out.push(`Note: ${n}`);
for (const h of p.mentions ?? []) out.push(`REFUSE: ${hitLine(h)}`);
return out;
}
@ -187,7 +194,8 @@ export async function run(ctx: Ctx): Promise<void> {
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app();
const p = await planListing(ctx, c, appId, version);
const refused = [p.versionPlan.action === 'refuse' ? p.versionPlan.reason : '', p.build?.action === 'refuse' ? p.build.reason : ''].filter(Boolean);
const refused = [p.versionPlan.action === 'refuse' ? p.versionPlan.reason : '', p.build?.action === 'refuse' ? p.build.reason : '',
(() => { try { refuseMentions(p.mentions, 'apple listing'); return ''; } catch (e) { return (e as Error).message; } })()].filter(Boolean);
if (a.has('json')) printJson({ dry, ...p, refused });
else for (const line of listingPlanLines(p)) console.log(line);
if (refused.length) { if (dry) { process.exitCode = 1; warn('apple', `dry run: would refuse: ${refused.join('; ')}`); return; } throw new Error(refused.join('; ')); }

View file

@ -32,6 +32,8 @@ import { posix, readPulled } from './lib/art-pull';
import { creativeNote } from './shots/creative';
import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets';
import { iphoneDuoNote } from './shots/spec';
import { hitLine } from './lib/cross-store';
import { listingFileMentions } from './lib/listing-mentions';
/** The newest .ipa in a folder (apple build's export), or null. */
function newestIpa(dir: string): string | null {
@ -92,6 +94,8 @@ export async function run(ctx: Ctx): Promise<void> {
}
return `${Object.keys(copy).length} locales`;
});
// other platforms named in a store's text: App Store 2.3.10 / Play Metadata, a blocker (pitfall 77)
try { for (const h of listingFileMentions(ctx.loaded)) problems.push(`other platform in ${hitLine(h)}`); } catch (e) { warn('check', `platform mentions: ${(e as Error).message}`); }
// listing translation drafts (storebound listing translate): fine to keep working on, not to push unreviewed
for (const f of [config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : '', config.play ? resolve(config.play.listing ?? 'store/listing.md') : ''].filter(Boolean)) {
if (!existsSync(f)) continue;

View file

@ -39,6 +39,7 @@ export function sources(ctx: Ctx): EditSources {
shotsCopy: config.shots?.copy ? file(resolve(config.shots.copy)) : null,
appName: config.app.name,
appleName: appleName(config),
allowPlatformMentions: config.listing?.allowPlatformMentions ?? [],
};
}

View file

@ -700,7 +700,17 @@ export interface ShipConfig {
/** Owner answers for console forms that have no API (`storebound console fill`); only what the rest of the config
* cannot prove. Anything left out stays empty and highlighted in the page. */
console?: ConsoleConfig;
/** Release notes (`storebound whats-new`). */
/** Listing text rules for both stores. */
listing?: {
/** Functional mentions of another platform or store that may stay in a store's text, e.g. a "Continue with
* Google" button in App Store text or "Sign in with Apple" in Play text: `{ store: 'apple' | 'play' | 'both',
* text: 'Sign in with Apple', reason: 'the sign-in button label' }`. A line that contains `text` (any case) keeps
* the terms inside it. Every other mention of Android / Google Play in App Store text (Guideline 2.3.10) or of
* iOS / App Store in Play text (Metadata policy) stops the write. The owner adds entries; storebound never does. */
allowPlatformMentions?: { store: 'apple' | 'play' | 'both'; text: string; reason: string }[];
};
/** Release notes (`storebound whats-new`). Lines tagged `[apple]` or `[play]` (or a `[play]` line heading a block
* until the next blank line) go only into that store's notes. */
whatsNew?: {
/** Player-facing notes file the draft comes from before the git log (a CHANGELOG-like file gives the version's
* section), e.g. "docs/player-notes.md". Without it: docs/RELEASE-<version>.md, then CHANGELOG.md. */

171
cli/lib/cross-store.ts Normal file
View file

@ -0,0 +1,171 @@
// Other platforms and stores named in one store's text (pitfall 77). App Store Guideline 2.3.10 (Accurate Metadata):
// no other mobile platforms in the metadata; a Mac version was rejected for a What's New taken from release notes
// shared with Google Play ("Google Play Games", "Android"). Google Play's Metadata policy is the mirror case
// (misleading references to other platforms or apps). One checker for every surface: the listing pushes, whats-new,
// the store-only listing edit and the desktop editor refuse on a hit; dry runs show it; check / validate / scan
// report it with file, line, field and locale. A functional mention ("Sign in with Apple", "Continue with Google")
// is kept only through `listing.allowPlatformMentions` in the config: the owner's call, the tool never adds one.
// Pure, no Node imports (the desktop window runs it too).
export type CrossStore = 'apple' | 'play';
/** One owner-approved mention: `text` (any case) on a line keeps the terms inside it. */
export interface PlatformAllow { store: CrossStore | 'both'; text: string; reason: string }
export interface PlatformHit {
store: CrossStore;
locale: string;
field: string;
/** line inside the field's text (1-based) */
line: number;
term: string;
/** the whole line, trimmed */
text: string;
/** listing file and its line, when the hit comes from a file */
file?: string;
fileLine?: number;
fix: string;
}
interface Term { t: string; cs?: boolean; cjk?: boolean }
/** Named in App Store text: other mobile platforms and stores (Latin forms match whole words, any case). */
export const APPLE_FORBIDDEN: readonly Term[] = [
{ t: 'Google Play Games' }, { t: 'Google Play Spiele' }, { t: 'Google Play Jeux' }, { t: 'Google Play Juegos' }, { t: 'Google Play Giochi' }, { t: 'Google Play Oyun' },
{ t: 'Google Play' }, { t: 'Play Store' }, { t: 'Play Market' }, { t: 'Play Маркет' }, { t: 'Плей Маркет' }, { t: 'Google Play 游戏', cjk: true }, { t: 'Google Play ゲーム', cjk: true },
{ t: 'Android' }, { t: 'Андроид' }, { t: 'Android TV' }, { t: 'Wear OS' }, { t: 'Chromebook' },
{ t: '安卓', cjk: true }, { t: 'アンドロイド', cjk: true }, { t: '안드로이드', cjk: true }, { t: '구글 플레이', cjk: true }, { t: 'グーグルプレイ', cjk: true }, { t: '谷歌商店', cjk: true },
{ t: 'Galaxy Store' }, { t: 'Amazon Appstore' }, { t: 'Amazon App Store' }, { t: 'AppGallery' }, { t: 'Huawei' }, { t: 'Xiaomi GetApps' },
{ t: 'Steam', cs: true }, { t: 'Microsoft Store' }, { t: 'Windows Store' }, { t: 'Windows Phone' }, { t: 'BlackBerry' },
];
/** Named in Google Play text: Apple platforms and services. */
export const PLAY_FORBIDDEN: readonly Term[] = [
{ t: 'Apple App Store' }, { t: 'App Store', cs: true }, { t: 'AppStore', cs: true }, { t: 'App-Store', cs: true },
{ t: 'iOS', cs: true }, { t: 'iPadOS' }, { t: 'iPhone' }, { t: 'iPad' }, { t: 'macOS' }, { t: 'Apple Watch' }, { t: 'Apple TV' }, { t: 'Vision Pro' },
{ t: 'Apple Arcade' }, { t: 'Game Center', cs: true }, { t: 'TestFlight' },
{ t: 'Sign in with Apple' }, { t: 'iCloud' },
{ t: 'アップストア', cjk: true }, { t: '苹果商店', cjk: true }, { t: '앱스토어', cjk: true }, { t: 'アイフォン', cjk: true }, { t: '아이폰', cjk: true },
];
/** A line tagged for the other store's notes (`[play]` in App Store text, `[apple]` in Play text) is always a hit. */
const OTHER_TAG: Record<CrossStore, RegExp> = { apple: /^\s*(?:[-*•]\s*)?\[(play|android|google)\]/i, play: /^\s*(?:[-*•]\s*)?\[(apple|ios|app ?store)\]/i };
const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const termRe = (x: Term): RegExp => x.cjk ? new RegExp(esc(x.t), 'g')
: new RegExp(`(?<![\\p{L}\\p{N}])${esc(x.t).replace(/ /g, '[\\s\\u00a0-]?')}(?![\\p{L}\\p{N}])`, x.cs ? 'gu' : 'giu');
const COMPILED: Record<CrossStore, { term: string; re: RegExp }[]> = {
apple: [...APPLE_FORBIDDEN].sort((a, b) => b.t.length - a.t.length).map(x => ({ term: x.t, re: termRe(x) })),
play: [...PLAY_FORBIDDEN].sort((a, b) => b.t.length - a.t.length).map(x => ({ term: x.t, re: termRe(x) })),
};
export const STORE_NAME: Record<CrossStore, string> = { apple: 'App Store', play: 'Google Play' };
export const GUIDELINE: Record<CrossStore, string> = { apple: '2.3.10', play: 'Play: Metadata' };
export function fixFor(store: CrossStore, field: string): string {
const other = store === 'apple' ? 'play' : 'apple';
const notes = /whatsnew|release/i.test(field) ? ` or tag the line [${other}] in the shared notes (whats-new keeps it out of ${STORE_NAME[store]})` : '';
return `remove it from the ${STORE_NAME[store]} text${notes}; a functional mention (a sign-in button) goes into listing.allowPlatformMentions in storebound.config.ts, the owner's call`;
}
function allowed(store: CrossStore, line: string, term: string, allow: readonly PlatformAllow[]): boolean {
const l = line.toLowerCase(), t = term.toLowerCase();
return allow.some(a => (a.store === store || a.store === 'both') && !!a.text.trim() && l.includes(a.text.toLowerCase()) && a.text.toLowerCase().includes(t));
}
/** Hits in one field of one locale, line by line. Overlapping terms report the longest ("Google Play Games"). */
export function textMentions(text: string, o: { store: CrossStore; locale: string; field: string; allow?: readonly PlatformAllow[] }): PlatformHit[] {
const out: PlatformHit[] = [];
const allow = o.allow ?? [];
text.split('\n').forEach((raw, i) => {
const line = raw.trim();
if (!line) return;
const spans: [number, number][] = [];
const add = (term: string): void => { out.push({ store: o.store, locale: o.locale, field: o.field, line: i + 1, term, text: line.slice(0, 200), fix: fixFor(o.store, o.field) }); };
const tag = OTHER_TAG[o.store].exec(line);
if (tag) add(`[${tag[1]}]`);
for (const { term, re } of COMPILED[o.store]) {
re.lastIndex = 0;
for (let m = re.exec(line); m; m = re.exec(line)) {
const s = m.index, e = s + m[0].length;
if (spans.some(([a, b]) => s < b && e > a)) continue;
spans.push([s, e]);
if (!allowed(o.store, line, m[0], allow)) add(m[0]);
}
}
});
return out;
}
/** Hits in many fields of one locale: values as `{ field: text }`. */
export function fieldsMentions(store: CrossStore, locale: string, fields: Record<string, string | undefined>, allow?: readonly PlatformAllow[]): PlatformHit[] {
return Object.entries(fields).flatMap(([field, t]) => t ? textMentions(t, { store, locale, field, allow }) : []);
}
/** Hits in a listing file (store/appstore.md or store/listing.md): fenced blocks under `### field` of each `## locale`
* section, with the file line. Play sections take their codes from `<!-- play: ... -->`. */
export function fileMentions(store: CrossStore, path: string, md: string, allow?: readonly PlatformAllow[]): PlatformHit[] {
const out: PlatformHit[] = [];
const lines = md.split('\n');
let locale = '', field = '', fence = -1, buf: string[] = [];
lines.forEach((l, i) => {
if (fence < 0 && /^## /.test(l)) { locale = l.slice(3).split(/[\s·]/)[0]!.trim(); field = ''; return; }
const pc = /^<!--\s*play:\s*([^>]+?)\s*-->$/.exec(l);
if (fence < 0 && pc) { locale = pc[1]!.split(',').map(s => s.trim()).join(','); return; }
if (fence < 0 && /^###\s/.test(l)) { field = l.slice(4).trim(); return; }
if (/^```/.test(l)) {
if (fence < 0) { fence = i; buf = []; return; }
if (field && locale) for (const h of textMentions(buf.join('\n'), { store, locale, field: fieldName(field), allow })) out.push({ ...h, file: path, fileLine: fence + 1 + h.line });
fence = -1; return;
}
if (fence >= 0) buf.push(l);
});
return out;
}
/** "### Short description (max 80)" -> "short", "### whatsNew" -> "whatsNew". */
function fieldName(h: string): string {
if (/^whatsNew\b|what'?s new|release notes/i.test(h)) return 'whatsNew';
if (/\bshort\b/i.test(h)) return 'short';
if (/\bfull\b/i.test(h)) return 'full';
if (/\btitle\b/i.test(h)) return 'title';
return h.split(/\s/)[0]!;
}
/** One line per hit: where, what, and the fix. */
export function hitLine(h: PlatformHit): string {
const at = h.file ? `${h.file}:${h.fileLine} ` : '';
return `${at}${STORE_NAME[h.store]} ${h.locale} ${h.field} line ${h.line}: "${h.term}" in "${h.text}" (${GUIDELINE[h.store]}): ${h.fix}`;
}
/** Throws (nothing is written) when there are hits; `what` names the write. */
export function refuseMentions(hits: readonly PlatformHit[], what: string): void {
if (!hits.length) return;
const stores = [...new Set(hits.map(h => h.store))];
throw new Error(`refused: ${what}: other platforms or stores named in ${stores.map(s => `${STORE_NAME[s]} text (${GUIDELINE[s]})`).join(' and ')}; nothing was sent\n${hits.map(h => ` ${hitLine(h)}`).join('\n')}`);
}
// ---- per-store notes: lines tagged [apple] / [play] in shared release notes ----
const TAG_RE = /^(\s*(?:[-*•]\s*)?)\[(apple|ios|app ?store|play|android|google ?play|google)\]\s*/i;
const tagStore = (t: string): CrossStore => /^(apple|ios|app ?store)$/i.test(t) ? 'apple' : 'play';
/** The notes for one store: untagged lines stay, lines tagged for this store lose the tag, lines tagged for the other
* store go. A heading line that is only a tag (`[play]`) starts a block for that store until a blank line. Pure. */
export function notesForStore(text: string, store: CrossStore): string {
const out: string[] = [];
let block: CrossStore | null = null;
for (const l of text.split('\n')) {
if (!l.trim()) { block = null; out.push(l); continue; }
const only = /^\s*\[(apple|ios|app ?store|play|android|google ?play|google)\]\s*:?\s*$/i.exec(l);
if (only) { block = tagStore(only[1]!); continue; }
if (block) { if (block === store) out.push(l); continue; }
const m = TAG_RE.exec(l);
if (!m) { out.push(l); continue; }
if (tagStore(m[2]!) === store) out.push(m[1]! + l.slice(m[0].length));
}
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
}
/** Whether a text has store tags at all (then it is split before it goes anywhere). */
export const hasStoreTags = (text: string): boolean => text.split('\n').some(l => TAG_RE.test(l) || /^\s*\[(apple|ios|play|android)\]\s*:?\s*$/i.test(l));

View file

@ -446,6 +446,8 @@ export interface EditableResult {
git: { repo: boolean; uncommitted: string[] };
appName: string;
appleName: string;
/** listing.allowPlatformMentions: functional mentions of other platforms the owner allowed (pitfall 77) */
allowPlatformMentions?: { store: 'apple' | 'play' | 'both'; text: string; reason: string }[];
languages: { id: string; play: string[]; apple: string | null; rtl: boolean }[];
play: EditableListing[] | null;
apple: EditableListing[] | null;

View file

@ -72,6 +72,7 @@ export function editableValues(l: Loaded): EditableResult {
git: gitState(root, files),
appName: config.app.name,
appleName: appleName(config),
allowPlatformMentions: config.listing?.allowPlatformMentions ?? [],
languages: config.languages.map(x => ({ id: x.id, play: playCodes(x), apple: x.apple ?? null, rtl: !!x.rtl })),
play: config.play ? play : null,
apple: config.apple ? apple : null,

View file

@ -10,6 +10,7 @@ import { compactDiff } from './diff';
import { setByPath } from './config-path';
import type { AppleField } from './copy';
import { appleSection, isAppleField, isPlayField, playSection, setAppleField, setPlayField } from './listing-edit';
import type { PlatformAllow } from './cross-store';
import { appleLocaleIssues, emailIssue, type FieldIssue, playLangIssues, urlIssue } from './listing-rules';
export type EditOp =
@ -35,6 +36,8 @@ export interface EditSources {
shotsCopy?: { file: string; rel: string; text: string | null } | null;
appName: string;
appleName: string;
/** listing.allowPlatformMentions of the config (pitfall 77) */
allowPlatformMentions?: readonly PlatformAllow[];
}
/** Checks one edit's shape (it comes from the window or a terminal). Throws with the reason. */
@ -171,12 +174,12 @@ export function planEdits(ops: EditOp[], src: EditSources, getPath: (path: strin
// the touched languages as they would be saved, with the checks of `check` / `check --aso` / `scan`
if (src.play) for (const lang of touched.play) {
const s = playSection(texts.get(src.play.file) ?? '', lang);
if (s) for (const x of playLangIssues(lang, s.values, src.appName)) issues.push({ ...x, where: `Google Play ${lang}` });
if (s) for (const x of playLangIssues(lang, s.values, src.appName, src.allowPlatformMentions)) issues.push({ ...x, where: `Google Play ${lang}` });
}
if (src.apple) for (const loc of touched.apple) {
const s = appleSection(texts.get(src.apple.file) ?? '', loc);
if (s && onlyWhatToTest.get(loc)) { const n = [...(s.values.whatToTest ?? '')].length; if (n > 4000) issues.push({ level: 'error', field: 'whatToTest', where: `TestFlight ${loc}`, msg: `What to Test is ${n} characters; TestFlight takes 4000` }); continue; }
if (s) for (const x of appleLocaleIssues(loc, s.values, src.appleName)) issues.push({ ...x, where: `App Store ${loc}` });
if (s) for (const x of appleLocaleIssues(loc, s.values, src.appleName, src.allowPlatformMentions)) issues.push({ ...x, where: `App Store ${loc}` });
}
const files: EditFile[] = [];
for (const [file, after] of texts) {

View file

@ -0,0 +1,18 @@
// Other platforms named in the local listing files of both stores (pitfall 77): the blockers `check` and `validate`
// report. Reads store/appstore.md and store/listing.md (or the config's listing paths).
import { existsSync, readFileSync } from 'node:fs';
import { relative } from 'node:path';
import type { Loaded } from './config';
import { fileMentions, type PlatformHit } from './cross-store';
export function listingFileMentions(loaded: Pick<Loaded, 'config' | 'resolve' | 'root'>, stores: { apple: boolean; play: boolean } = { apple: true, play: true }): PlatformHit[] {
const { config, resolve, root } = loaded;
const allow = config.listing?.allowPlatformMentions;
const out: PlatformHit[] = [];
const files: ['apple' | 'play', string | null][] = [
['apple', stores.apple && config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : null],
['play', stores.play && config.play ? resolve(config.play.listing ?? 'store/listing.md') : null],
];
for (const [store, f] of files) if (f && existsSync(f)) out.push(...fileMentions(store, relative(root, f) || f, readFileSync(f, 'utf8'), allow));
return out;
}

View file

@ -4,6 +4,10 @@
// Pure, no Node imports: the desktop window runs it on every keystroke, `storebound edit` before it writes.
import { auditAppleKeywords, auditPlayListing } from './aso';
import { APPLE_LIMITS, type AppleCopy, len, PLAY_LIMITS } from './copy';
import { fieldsMentions, GUIDELINE, type PlatformAllow, type PlatformHit } from './cross-store';
const mentionIssue = (h: PlatformHit): FieldIssue => ({ level: 'error', field: h.field,
msg: `line ${h.line}: "${h.term}": other platforms or stores in ${h.store === 'apple' ? 'App Store' : 'Google Play'} text get it rejected (${GUIDELINE[h.store]}); ${h.fix}` });
export type IssueLevel = 'error' | 'warn' | 'info';
export interface FieldIssue { level: IssueLevel; field: string; msg: string }
@ -12,8 +16,6 @@ export interface FieldIssue { level: IssueLevel; field: string; msg: string }
export const COMPETITORS = ['instagram', 'tiktok', 'whatsapp', 'facebook', 'snapchat', 'youtube', 'netflix', 'spotify', 'telegram', 'messenger', 'twitter', 'pinterest', 'uber', 'airbnb',
'amazon', 'chatgpt', 'openai', 'minecraft', 'roblox', 'fortnite', 'pubg', 'candy crush', 'clash of clans', 'pokemon', 'pokémon', 'among us', 'subway surfers', 'temple run',
'angry birds', 'tetris', 'wordle', 'duolingo', 'zoom', 'gmail', 'google maps', 'waze', 'shazam', 'tinder', 'notion', 'evernote', 'photoshop', 'canva', 'capcut', 'iphone', 'ipad', 'apple'];
/** Other mobile platforms or stores in App Store text (2.3.10). */
export const OTHER_PLATFORMS = /\b(Android|Google Play|Play Store|Galaxy Store|AppGallery|Huawei|Windows Phone|BlackBerry|Amazon Appstore)\b/;
// Developer markers count only in capitals ("TODO:", "FIXME"): "todo" is a real word and keyword (a to-do app).
export const PLACEHOLDER_MARKERS = /\bTODO\b|\bTBD\b|\bFIXME\b|\bXXX\b/;
export const PLACEHOLDER_TEXT = /lorem ipsum|\bplaceholder\b|your app (name|description) here|coming soon|First paragraph:|Short hook under the name|One or two sentences you can change|İsmin altındaki kısa kanca|Yeni sürüm göndermeden istediğin zaman|\bExample (Game|App|Pro|Studio)\b/i;
@ -31,7 +33,7 @@ export const APPLE_REQUIRED = ['subtitle', 'promotionalText', 'keywords', 'descr
* (apple.name, else app.name). Errors stop a save (the store would refuse it, or the listing file would no longer
* parse); warnings and hints do not.
*/
export function appleLocaleIssues(locale: string, v: AppleCopy, appName: string): FieldIssue[] {
export function appleLocaleIssues(locale: string, v: AppleCopy, appName: string, allow: readonly PlatformAllow[] = []): FieldIssue[] {
const out: FieldIssue[] = [];
for (const f of APPLE_REQUIRED) if (!(v[f] ?? '').trim()) out.push({ level: 'error', field: f, msg: `${locale}: ${f} is required` });
for (const [f, lim] of Object.entries(APPLE_LIMITS) as [keyof typeof APPLE_LIMITS, number][]) {
@ -63,19 +65,19 @@ export function appleLocaleIssues(locale: string, v: AppleCopy, appName: string)
for (const f of ['name', 'subtitle', 'promotionalText', 'keywords', 'description', 'whatsNew'] as const) {
const t = f === 'name' ? (v.name ?? '') : v[f] ?? '';
if (!t) continue;
const o = OTHER_PLATFORMS.exec(t);
if (o) out.push({ level: 'warn', field: f, msg: `"${o[0]}": other platforms or stores in App Store text get it rejected (2.3.10)` });
if (PLACEHOLDER_MARKERS.test(t) || PLACEHOLDER_TEXT.test(t)) out.push({ level: 'warn', field: f, msg: 'placeholder text (template or TODO) is still in it' });
const b = f === 'whatsNew' ? null : BETA_WORDS.exec(t);
if (b && !/\bdemo account\b|\bdemo (video|mode)\b/i.test(t)) out.push({ level: 'warn', field: f, msg: `"${b[0]}": demos, betas and trials belong on TestFlight (2.2)` });
}
// 2.3.10: other platforms or stores (Android, Google Play, ...) stop the save (pitfall 77)
out.push(...fieldsMentions('apple', locale, { ...v, name: v.name ?? '' }, allow).map(mentionIssue));
return out;
}
export interface PlayValues { title?: string; short?: string; full?: string; whatsNew?: string }
/** Issues of one Google Play language (title falls back to app.name). */
export function playLangIssues(lang: string, v: PlayValues, appName: string): FieldIssue[] {
export function playLangIssues(lang: string, v: PlayValues, appName: string, allow: readonly PlatformAllow[] = []): FieldIssue[] {
const out: FieldIssue[] = [];
for (const f of PLAY_REQUIRED) if (!(v[f] ?? '').trim()) out.push({ level: 'error', field: f, msg: `${lang}: the ${f} description is required` });
const title = (v.title ?? '').trim() || appName;
@ -93,6 +95,8 @@ export function playLangIssues(lang: string, v: PlayValues, appName: string): Fi
const t = v[f] ?? '';
if (t && (PLACEHOLDER_MARKERS.test(t) || PLACEHOLDER_TEXT.test(t))) out.push({ level: 'warn', field: f, msg: 'placeholder text (template or TODO) is still in it' });
}
// Play Metadata policy: iOS, App Store, ... in Play text stop the save (pitfall 77)
out.push(...fieldsMentions('play', lang, { ...v }, allow).map(mentionIssue));
return out;
}

View file

@ -56,7 +56,7 @@ import { consoleLink } from '../console-links';
import { PLAY_AGE_GROUPS } from '../config';
import { allSubscriptions } from '../subscriptions';
import { grep, type Hit, type ProjectFacts } from './project';
import type { ScanFinding, ScanInput } from './rules';
import { platformHits, type ScanFinding, type ScanInput } from './rules';
import { versionAtLeast } from './sdks';
const PLAY = 'https://support.google.com/googleplay/android-developer/answer/';
@ -347,6 +347,11 @@ export function playRules(inp: ScanInput, x: PlayCtx): void {
// ---- Play listing metadata ----
if (c?.play || inp.listings.play) {
const listing = inp.listings.play;
// Apple platforms or services named in Play text (Metadata policy; the mirror of App Store 2.3.10, pitfall 77)
const apple = platformHits(inp, 'play');
if (apple.length)
add({ rule: 'play-other-platforms', guideline: 'Play: Metadata', severity: 'high', title: 'Apple platforms or stores (iOS, iPhone, App Store, Game Center, TestFlight) named in the Play listing text', evidence: apple.slice(0, 6),
ref: PLAY_REFS.metadata, skill: 'aso-basics', fix: 'Remove them from store/listing.md (release notes shared with the App Store: tag the line [apple]); a functional mention ("Sign in with Apple") goes into listing.allowPlatformMentions (owner\'s call). Every Play text write refuses these.' });
const titles: Hit[] = fieldBlocks(listing, /\btitle\b/i).map(b => ({ ...b.at, text: b.text.trim() }));
if (!titles.length && c?.app.name) titles.push(...cfg(/\bname\s*:/, c.app.name).map(h => ({ ...h, text: c.app.name })));
const long = titles.filter(t => [...t.text].length > 30);

View file

@ -36,7 +36,8 @@
import { consoleLink, consoleUrl } from '../console-links';
import type { FixAction } from '../fix-actions';
import { templateBlockers } from '../template-answers';
import { BETA_WORDS, competitorRe, OTHER_PLATFORMS, PLACEHOLDER_MARKERS, PLACEHOLDER_TEXT, PRICE_CLAIMS } from '../listing-rules';
import { fileMentions, GUIDELINE, type PlatformHit, textMentions } from '../cross-store';
import { BETA_WORDS, competitorRe, PLACEHOLDER_MARKERS, PLACEHOLDER_TEXT, PRICE_CLAIMS } from '../listing-rules';
import type { ShipConfig } from '../config';
import { allSubscriptions } from '../subscriptions';
import { sdkOfProjectPath } from '../privacy-files';
@ -186,6 +187,17 @@ export function rewardNearReviewApi(sources: { path: string; text: string }[], m
}
/** Text lines (inside fenced blocks of a listing file, or JSON string values) that match `re`. */
/** Other platforms named in one store's listing file (and, for the App Store, the screenshot captions), with
* locale, field and line (pitfall 77). */
export function platformHits(inp: Pick<ScanInput, 'listings' | 'config'>, store: 'apple' | 'play'): Hit[] {
const allow = inp.config?.listing?.allowPlatformMentions;
const file = store === 'apple' ? inp.listings.apple : inp.listings.play;
const hits: PlatformHit[] = file ? fileMentions(store, file.path, file.text, allow) : [];
const cap = store === 'apple' ? inp.listings.captions : undefined;
if (cap) hits.push(...textMentions(cap.text, { store, locale: 'captions', field: 'screenshot captions', allow }).map(h => ({ ...h, file: cap.path, fileLine: h.line })));
return hits.map(h => ({ file: h.file!, line: h.fileLine!, text: `${h.locale} ${h.field}: "${h.term}" in ${h.text.slice(0, 120)}` }));
}
function listingHits(file: { path: string; text: string } | undefined, re: RegExp, o: { fenced?: boolean; field?: RegExp } = {}): Hit[] {
if (!file) return [];
const out: Hit[] = [];
@ -439,10 +451,10 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
// ---- metadata (iOS listing + screenshot captions) ----
if (apple) {
const other = [...listingHits(inp.listings.apple, OTHER_PLATFORMS), ...listingHits(inp.listings.captions, OTHER_PLATFORMS, { fenced: false })];
const other = platformHits(inp, 'apple');
if (other.length)
add({ rule: 'other-platforms', guideline: '2.3.10', store: 'apple', severity: 'high', title: 'Other mobile platforms or stores named in the App Store metadata or screenshot captions', evidence: other.slice(0, 4),
fix: 'Remove "Android", "Google Play" and other platforms or stores from the App Store text and screenshots (Play listing text is separate: store/listing.md).', skill: 'aso-basics' });
add({ rule: 'other-platforms', guideline: GUIDELINE.apple, store: 'apple', severity: 'high', title: 'Other mobile platforms or stores named in the App Store metadata or screenshot captions', evidence: other.slice(0, 6),
fix: 'Remove "Android", "Google Play" and other platforms or stores from the App Store text and screenshots (a What\'s New shared with Play: tag the line [play]); a functional mention goes into listing.allowPlatformMentions (owner\'s call). Every App Store text write refuses these.', skill: 'aso-basics' });
const words = competitorRe();
const kw = [...listingHits(inp.listings.apple, words, { field: /^(keywords|name|subtitle)$/ })];
if (kw.length)

View file

@ -4,6 +4,33 @@ import type { Language } from './config';
import { playCodes } from './config';
import { len, PLAY_LIMITS } from './copy';
import { jargonFindings, jargonMessage } from './jargon';
import { type CrossStore, fieldsMentions, notesForStore, type PlatformAllow, type PlatformHit, textMentions } from './cross-store';
/** One store's draft from shared notes: tagged lines split ([apple] / [play]), and an untagged line that names the
* other side's platform (a "Google Play Games" line for the App Store) left out of this store's draft and reported,
* so a draft never carries it into the wrong store (pitfall 77). Tag the line to make it explicit. Pure. */
export function draftForStore(text: string, store: CrossStore, allow: readonly PlatformAllow[] = []): { text: string; dropped: string[] } {
const dropped: string[] = [];
const kept = notesForStore(text, store).split('\n').filter(l => {
if (!l.trim() || !textMentions(l, { store, locale: '', field: 'whatsNew', allow }).length) return true;
dropped.push(l.trim());
return false;
});
return { text: kept.join('\n').replace(/\n{3,}/g, '\n\n').trim(), dropped };
}
/** Note items for one store (items tagged [apple] / [play] go only there, the tag removed). */
export function itemsForStore(items: NoteItem[], store: CrossStore): NoteItem[] {
return items.flatMap(i => { const t = notesForStore(i.text, store); return t ? [{ ...i, text: t }] : []; });
}
/** Other-platform hits in the per-store notes of one language (the texts `whats-new apply` would write). Pure. */
export function languageNoteMentions(l: Language, text: string, allow: readonly PlatformAllow[] = []): PlatformHit[] {
const out: PlatformHit[] = [];
for (const code of playCodes(l)) out.push(...fieldsMentions('play', code, { whatsNew: notesForStore(text, 'play') }, allow));
if (l.apple) out.push(...fieldsMentions('apple', l.apple, { whatsNew: notesForStore(text, 'apple') }, allow));
return out;
}
export const NOTES_LIMITS = { play: PLAY_LIMITS.whatsNew, apple: 4000 } as const;
/** Roughly what both stores show before "more" on a phone. */
@ -196,8 +223,10 @@ export interface LangCheck { lang: string; chars: number; play?: { limit: number
export function checkLanguageNotes(l: Language, f: NotesFile, sourceText?: string, allow: readonly string[] = []): LangCheck {
const chars = len(f.text);
const c: LangCheck = { lang: l.id, chars, problems: [], warnings: jargonFindings(f.text, allow).map(jargonMessage) };
if (playCodes(l).length) { c.play = { limit: NOTES_LIMITS.play, ok: chars <= NOTES_LIMITS.play }; if (!c.play.ok) c.problems.push(`Google Play: ${chars} > ${NOTES_LIMITS.play} characters`); }
if (l.apple) { c.apple = { limit: NOTES_LIMITS.apple, ok: chars <= NOTES_LIMITS.apple }; if (!c.apple.ok) c.problems.push(`App Store: ${chars} > ${NOTES_LIMITS.apple} characters`); }
// each store counts its own notes: lines tagged for the other store are not sent there
const pn = len(notesForStore(f.text, 'play')), an = len(notesForStore(f.text, 'apple'));
if (playCodes(l).length) { c.play = { limit: NOTES_LIMITS.play, ok: pn <= NOTES_LIMITS.play }; if (!c.play.ok) c.problems.push(`Google Play: ${pn} > ${NOTES_LIMITS.play} characters`); }
if (l.apple) { c.apple = { limit: NOTES_LIMITS.apple, ok: an <= NOTES_LIMITS.apple }; if (!c.apple.ok) c.problems.push(`App Store: ${an} > ${NOTES_LIMITS.apple} characters`); }
if (!f.text) c.problems.push('empty');
if (f.status === 'translate') c.problems.push('not translated yet (status=translate)');
else if (sourceText !== undefined && f.status !== 'source' && f.text === sourceText.trim()) c.problems.push('identical to the source language: translate it');
@ -206,16 +235,17 @@ export function checkLanguageNotes(l: Language, f: NotesFile, sourceText?: strin
return c;
}
/** Writes one language's notes into the listing files (every Play code of the language, its App Store locale). Pure. */
/** Writes one language's notes into the listing files (every Play code of the language, its App Store locale); lines
* tagged [apple] / [play] go only into that store's notes. Pure. */
export function applyLanguageNotes(md: { play?: string; apple?: string }, l: Language, text: string): { play?: string; apple?: string; changed: string[] } {
const out: { play?: string; apple?: string; changed: string[] } = { ...md, changed: [] };
if (md.play !== undefined) for (const code of playCodes(l)) {
const r = setPlayWhatsNew(out.play!, code, text);
const r = setPlayWhatsNew(out.play!, code, notesForStore(text, 'play'));
out.play = r.md;
out.changed.push(...r.changed.map(x => `play:${x}`));
}
if (md.apple !== undefined && l.apple) {
const r = setAppleWhatsNew(out.apple!, l.apple, text);
const r = setAppleWhatsNew(out.apple!, l.apple, notesForStore(text, 'apple'));
out.apple = r.md;
out.changed.push(...r.changed.map(x => `apple:${x}`));
}

View file

@ -15,6 +15,7 @@
// from the config of an app folder).
import { appleLocaleIssues, type FieldIssue, playLangIssues } from './lib/listing-rules';
import type { AppleCopy } from './lib/copy';
import type { PlatformAllow } from './lib/cross-store';
import type { Ctx } from './lib/ctx';
import { confirm, need } from './lib/ctx';
import { done, log, printJson, warn } from './lib/log';
@ -39,7 +40,7 @@ export interface FieldPlan { field: string; from: string | null; to: string }
export interface SetPlan { store: 'play' | 'apple'; lang: string; create: boolean; changes: FieldPlan[]; same: string[]; issues: FieldIssue[]; refused: string | null; target?: string }
/** Old -> new for the given fields of one language (an empty and a missing value are the same). Pure. */
export function setPlan(store: 'play' | 'apple', lang: string, current: Record<string, string> | null, want: Record<string, string>, o: { appName: string; editable?: boolean }): SetPlan {
export function setPlan(store: 'play' | 'apple', lang: string, current: Record<string, string> | null, want: Record<string, string>, o: { appName: string; editable?: boolean; allow?: readonly PlatformAllow[] }): SetPlan {
const changes: FieldPlan[] = [];
const same: string[] = [];
for (const [f, v] of Object.entries(want)) {
@ -50,8 +51,8 @@ export function setPlan(store: 'play' | 'apple', lang: string, current: Record<s
// only the fields this run sends are judged (a store value it leaves alone is not this run's problem)
const sent = new Set(Object.keys(want));
const all = store === 'play'
? playLangIssues(lang, { title: merged.title || undefined, short: merged.short ?? '', full: merged.full ?? '' }, o.appName)
: appleLocaleIssues(lang, merged as AppleCopy, o.appName);
? playLangIssues(lang, { title: merged.title || undefined, short: merged.short ?? '', full: merged.full ?? '' }, o.appName, o.allow)
: appleLocaleIssues(lang, merged as AppleCopy, o.appName, o.allow);
const issues = all.filter(i => sent.has(i.field) && !(i.level === 'error' && /is required$/.test(i.msg) && !(want[i.field] !== undefined && !want[i.field]!.trim())));
let refused: string | null = null;
if (store === 'apple' && o.editable === false && changes.some(c => c.field !== 'promotionalText' && (APPLE_VERSION_FIELDS as readonly string[]).includes(c.field)))
@ -138,7 +139,7 @@ async function set(ctx: Ctx): Promise<void> {
const playName = config.play?.name ?? config.app.name;
const now = await playLive(ctx);
const cur = now?.find(l => l.language === lang) ?? null;
const p = setPlan('play', lang, cur ? { title: cur.title, short: cur.short, full: cur.full } : null, want, { appName: playName });
const p = setPlan('play', lang, cur ? { title: cur.title, short: cur.short, full: cur.full } : null, want, { appName: playName, allow: config.listing?.allowPlatformMentions });
show(ctx, p);
const errors = p.issues.filter(i => i.level === 'error').length;
if (a.has('json')) printJson({ ...p, dry, written: false });
@ -167,7 +168,7 @@ async function set(ctx: Ctx): Promise<void> {
promotionalText: vl?.attributes.promotionalText ?? '', whatsNew: vl?.attributes.whatsNew ?? '',
} : null;
const appName = config.apple?.name ?? config.app.name;
const p = { ...setPlan('apple', lang, cur, want, { appName, editable: r.editable }), target: r.version ? `version ${r.version.attributes.versionString}, ${r.version.attributes.appVersionState ?? r.version.attributes.appStoreState}` : 'no version' };
const p = { ...setPlan('apple', lang, cur, want, { appName, editable: r.editable, allow: config.listing?.allowPlatformMentions }), target: r.version ? `version ${r.version.attributes.versionString}, ${r.version.attributes.appVersionState ?? r.version.attributes.appStoreState}` : 'no version' };
show(ctx, p);
const errors = p.issues.filter(i => i.level === 'error').length;
if (a.has('json')) printJson({ ...p, dry, written: false });

View file

@ -12,6 +12,7 @@
// storebound play listing pull [--write] [--json] live listings -> diff -> (with --write) the Play listing file
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { parsePlayListing } from '../lib/copy';
import { fieldsMentions, refuseMentions } from '../lib/cross-store';
import { compactDiff } from '../lib/diff';
import { mergePlayListing, overLimits } from '../lib/listing-pull';
import { counts, draftReason, draftSections, type ItemPlan, itemLines, planItem, type Skipped } from '../lib/listing-plan';
@ -21,6 +22,12 @@ import { done, log, printJson, step, warn } from '../lib/log';
import { playClient } from './client';
import { stdinIsTTY } from '../lib/tty';
/** Play release notes that name iOS, iPhone, App Store, ... are refused before any edit, dry runs included (pitfall 77). */
export function refusePlayNotes(ctx: Ctx, notes: { language: string; text: string }[], what: string): void {
const allow = ctx.loaded.config.listing?.allowPlatformMentions;
refuseMentions(notes.flatMap(n => fieldsMentions('play', n.language, { whatsNew: n.text }, allow)), what);
}
export function readPlayCopy(ctx: Ctx) {
const { config, resolve } = ctx.loaded;
const file = resolve(config.play?.listing ?? 'store/listing.md');
@ -125,6 +132,8 @@ export async function run(ctx: Ctx): Promise<void> {
skipped.push({ key: c.lang, reason: draftReason(st) });
return false;
});
// Play Metadata policy: iOS / App Store / Game Center in Play text stop the run, dry runs included (pitfall 77)
refuseMentions(copy.flatMap(c => fieldsMentions('play', c.lang, { title: c.title, short: c.short, full: c.full }, config.listing?.allowPlatformMentions)), `play listing${a.has('dry') ? ' (dry run)' : ''}`);
const details = { defaultLanguage: config.play?.defaultLanguage ?? 'en-US', contactEmail: config.app.contactEmail, contactWebsite: config.app.website };
if (!all.some(c => c.lang === details.defaultLanguage) && !a.has('only')) throw new Error(`listing has no section for the default language ${details.defaultLanguage}`);
if (!a.has('json')) log('play', `${copy.length} listings: ${copy.map(c => c.lang).join(', ')}${config.play?.video ? ` · promo video ${config.play.video}` : ''}`);

View file

@ -14,7 +14,7 @@ import { done, log, warn } from '../lib/log';
import { aabInfo } from '../lib/gradle';
import { playClient } from './client';
import { usedVersionCodes } from './next-version-code';
import { readPlayCopy } from './listing';
import { readPlayCopy, refusePlayNotes } from './listing';
const TRACKS = ['internal', 'alpha', 'beta', 'production'];
@ -45,9 +45,10 @@ export async function run(ctx: Ctx): Promise<void> {
for (const t of tracks) if (!STATUSES.includes(t.status)) throw new Error(`--${t.track}: status must be one of ${STATUSES.join(', ')}`);
const fraction = a.get('fraction');
const live = tracks.find(t => t.track === 'production' && t.status !== 'draft');
const notes = readPlayCopy(ctx).filter(c => c.whatsNew).map(c => ({ language: c.lang, text: c.whatsNew }));
refusePlayNotes(ctx, notes, `play release notes${a.has('dry') ? ' (dry run)' : ''}`);
if (live && !a.has('dry') && !(await confirm(ctx, `Release to PRODUCTION as "${live.status}" (public once Google approves)?`))) return;
const notes = readPlayCopy(ctx).filter(c => c.whatsNew).map(c => ({ language: c.lang, text: c.whatsNew }));
const play = await playClient(root, pkg);
try {
await play.edit(async (id, ctl) => {

View file

@ -13,7 +13,7 @@ import { aabInfo } from '../lib/gradle';
import { done, log, printJson } from '../lib/log';
import { nextVersionCode } from '../lib/release';
import { playClient } from './client';
import { readPlayCopy } from './listing';
import { readPlayCopy, refusePlayNotes } from './listing';
import { usedVersionCodes } from './next-version-code';
const TAG = 'upload';
@ -43,6 +43,7 @@ export async function run(ctx: Ctx): Promise<void> {
const dry = a.has('dry');
const json = a.has('json');
const notes = readPlayCopy(ctx).filter(c => c.whatsNew).map(c => ({ language: c.lang, text: c.whatsNew }));
refusePlayNotes(ctx, notes, `play upload release notes${dry ? ' (dry run)' : ''}`);
const play = await playClient(root, pkg);
const { used, current } = await play.read(async id => ({
used: await usedVersionCodes(play, id),

View file

@ -34,6 +34,8 @@ import type { ScanFinding } from './lib/scan/rules';
import { playClient } from './play/client';
import { listOneTimeProducts } from './play/products';
import { creativeNote } from './shots/creative';
import { fixFor, GUIDELINE, hitLine } from './lib/cross-store';
import { listingFileMentions } from './lib/listing-mentions';
/** Profile facts for the checks: config + local credentials + public URL checks (no store API). */
export async function collectProfile(loaded: Loaded): Promise<ProfileFacts> {
@ -204,6 +206,11 @@ export async function run(ctx: Ctx): Promise<void> {
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, 400) }, ...rendersFindings('play', local?.play)); }
} else if (a.has('play')) warn('validate', 'no play section in the config');
// other platforms named in a store's listing text (App Store 2.3.10, Play Metadata): blockers (pitfall 77)
try {
for (const h of listingFileMentions(ctx.loaded, { apple: (both || a.has('apple')) && !!config.apple, play: (both || a.has('play')) && !!config.play }))
findings.push({ store: h.store, level: 'blocker', code: 'other-platforms', msg: `${GUIDELINE[h.store]}: ${hitLine(h)}`, fix: fixFor(h.store, h.field) });
} catch (e) { warn('validate', `platform mentions: ${(e as Error).message}`); }
// template compliance answers: the scan reports them; without the scan, here
if (a.has('no-scan')) for (const msg of templateBlockers(config)) findings.push({ store: config.apple ? 'apple' : 'play', level: 'blocker', code: 'template-answers', msg });
if (!a.has('no-scan')) {

View file

@ -17,6 +17,10 @@
// the translation. Existing translated files are kept unless --force.
// storebound whats-new check [--dir store/whats-new] [--json] limits per store (Play 500, App Store 4000), untranslated files
// storebound whats-new apply [--dir store/whats-new] [--dry] [--json] checked files -> both listing files
// Per store: a line tagged `[apple]` or `[play]` (also `• [play] ...`, or a `[play]` line heading a block up to the next
// blank line) goes only into that store's notes. check / apply refuse notes that name the other side's platform
// (Android / Google Play in App Store notes: Guideline 2.3.10; iOS / App Store in Play notes) unless
// listing.allowPlatformMentions allows the line; the draft leaves such untagged lines out of the other store (pitfall 77).
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Language } from './lib/config';
@ -25,10 +29,11 @@ import type { Ctx } from './lib/ctx';
import { done, log, printJson, warn } from './lib/log';
import { textTable } from './lib/table';
import {
applyLanguageNotes, checkLanguageNotes, checkNotes, cutNotes, findNotesSource, formatNotes, type LangCheck, NOTES_LIMITS, type NoteItem, type NotesFile, noteItems, notesFileText, parseNotesFile,
applyLanguageNotes, checkLanguageNotes, checkNotes, cutNotes, draftForStore, findNotesSource, formatNotes, itemsForStore, languageNoteMentions, type LangCheck, NOTES_LIMITS, type NoteItem, type NotesFile, noteItems, notesFileText, parseNotesFile,
resolveLanguages, setAppleWhatsNew, setPlayWhatsNew,
} from './lib/whats-new';
import * as rt from './lib/rt';
import { hitLine } from './lib/cross-store';
const TAG = 'whats-new';
const storeCode = (l: Language): string => l.apple ?? playCodes(l)[0] ?? l.id;
@ -52,14 +57,16 @@ function printChecks(checks: LangCheck[]): void {
for (const c of checks) for (const w of c.warnings) warn(TAG, `${c.lang}: ${w}`);
}
async function languagesFlow(ctx: Ctx, draft: { play: string; apple: string }): Promise<void> {
async function languagesFlow(ctx: Ctx, draft: { play: string; apple: string; shared: string }): Promise<void> {
const { config, resolve } = ctx.loaded;
const a = ctx.args;
const dir = resolve(a.get('dir') ?? 'store/whats-new');
const langs = resolveLanguages(config.languages, a.list('languages'));
const [src, ...rest] = langs;
// the source text: the shorter Play draft when the source language is on Play (it has to fit 500)
const text = playCodes(src!).length ? draft.play : draft.apple;
// the source text: the shorter Play draft when the source language is on Play (it has to fit 500); with [apple] /
// [play] tags the shared text keeps them (apply splits it per store)
const tagged = draft.shared !== draft.play || draft.shared !== draft.apple;
const text = tagged && /\[(apple|play|ios|android)\]/i.test(draft.shared) ? draft.shared : playCodes(src!).length ? draft.play : draft.apple;
const files: { l: Language; f: NotesFile; write: boolean; note: string }[] = [{ l: src!, f: { lang: src!.id, source: src!.id, status: 'source', text }, write: true, note: 'draft (source)' }];
const hook = a.get('translate');
for (const l of rest) {
@ -97,6 +104,10 @@ export async function run(ctx: Ctx): Promise<void> {
const files = readDir(dir, config.languages);
if (!files.length) throw new Error(`${dir}: no <language id>.md files`);
const checks = checkAll(files, config.whatsNew?.allowWords ?? []);
// other platforms named in a store's notes: a blocker for check and apply (pitfall 77)
const hits = files.flatMap(({ l, f }) => languageNoteMentions(l, f.text, config.listing?.allowPlatformMentions));
for (const h of hits) { const c = checks.find(x => x.lang === files.find(y => playCodes(y.l).includes(h.locale) || y.l.apple === h.locale)?.l.id); c?.problems.push(`${h.store === 'apple' ? 'App Store' : 'Google Play'} line ${h.line}: "${h.term}" (${h.store === 'apple' ? '2.3.10' : 'Play: Metadata'})`); }
for (const h of hits) warn(TAG, `refused: ${hitLine(h)}`);
const bad = checks.filter(c => c.problems.some(p => !p.startsWith('first line')));
if (a._[0] === 'check' || bad.length) {
if (a.has('json')) printJson({ dir, checks, ok: !bad.length });
@ -124,21 +135,30 @@ export async function run(ctx: Ctx): Promise<void> {
const version = a.get('release') ?? (() => { try { return (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version?: string }).version; } catch { return undefined; } })();
const source = a.has('from-git') ? undefined : findNotesSource({ version, source: config.whatsNew?.source, read: rel => { const p = resolve(rel); return existsSync(p) ? readFileSync(p, 'utf8') : undefined; } });
const hook = a.get('hook');
let play: string, apple: string, subjects: string[] = [], items: NoteItem[] = [];
let play: string, apple: string, shared: string, subjects: string[] = [], items: NoteItem[] = [];
if (source) {
log(TAG, `notes from ${source.path} (${source.kind === 'changelog' ? 'CHANGELOG section' : source.kind === 'release-doc' ? 'release doc' : 'whatsNew.source'}), not the git log`);
const body = hook ? `${hook}\n${source.text}` : source.text;
play = cutNotes(body, NOTES_LIMITS.play);
apple = cutNotes(body, NOTES_LIMITS.apple);
const dp = draftForStore(body, 'play', config.listing?.allowPlatformMentions), da = draftForStore(body, 'apple', config.listing?.allowPlatformMentions);
for (const [s, d] of [['Google Play', dp], ['App Store', da]] as const) for (const l of d.dropped) warn(TAG, `left out of the ${s} draft (names another platform; tag it [${s === 'App Store' ? 'play' : 'apple'}] in ${source.path}): ${l}`);
shared = cutNotes(body, NOTES_LIMITS.apple);
play = cutNotes(dp.text, NOTES_LIMITS.play);
apple = cutNotes(da.text, NOTES_LIMITS.apple);
} else {
subjects = git(['log', range, '--no-merges', '--format=%s'], root).split('\n').filter(Boolean);
items = noteItems(subjects);
log(TAG, `${subjects.length} commits since ${since || 'the first commit'} -> ${items.length} items, drafted from commit messages (no docs/RELEASE-<version>.md, CHANGELOG section or whatsNew.source): rewrite them in player words`);
play = formatNotes(items, { hook, limit: NOTES_LIMITS.play });
apple = formatNotes(items, { hook, limit: NOTES_LIMITS.apple });
const pick = (store: 'play' | 'apple') => {
const d = draftForStore(formatNotes(itemsForStore(items, store), { hook, limit: NOTES_LIMITS[store] }), store, config.listing?.allowPlatformMentions);
for (const l of d.dropped) warn(TAG, `left out of the ${store === 'apple' ? 'App Store' : 'Google Play'} draft (names another platform): ${l}`);
return d.text;
};
shared = formatNotes(items, { hook, limit: NOTES_LIMITS.apple });
play = pick('play');
apple = pick('apple');
}
for (const [store, text] of [['play', play], ['apple', apple]] as const) for (const p of checkNotes(text, store, allow)) warn(TAG, `${store}: ${p}`);
if (a.has('languages')) return languagesFlow(ctx, { play, apple });
if (a.has('languages')) return languagesFlow(ctx, { play, apple, shared });
if (a.has('json')) printJson({ since: since || null, source: source ? { kind: source.kind, path: source.path } : null, commits: subjects.length, items, play, apple, limits: NOTES_LIMITS });
else console.log(`--- draft (Play, ${[...play].length}/500) ---\n${play}\n---`);
if (a.has('dry')) return;

View file

@ -9,6 +9,8 @@ fails without the fix.
| # | date | lesson | guard |
|---|---|---|---|
| 78 | 2026-10 | `storebound api <write> --yes` with stdout piped or redirected printed a dry run and exited 0 (twice); the owner took it as sent. | `decide` (`cli/api/gate.ts`) refuses with `NOT_A_TTY`: exit 1, "refused: writes through `storebound api` need an interactive terminal ... Nothing was sent." on stderr, the dedicated command named; without `--yes` still a dry run. `tests/api.test.ts`. |
| 77 | 2026-10 | A Mac App Store version was rejected under Guideline 2.3.10: its What's New came from release notes shared with Google Play ("Google Play Games", "Android"); storebound wrote it without a word. Play's Metadata policy is the mirror case. | `cli/lib/cross-store.ts`: other platforms per locale, field and line (App Store text: Android, Google Play (Games), Play Store, Galaxy Store, Amazon Appstore, Steam, Microsoft Store, AppGallery, localized forms; Play text: iOS, iPhone, iPad, App Store, Apple Arcade, Game Center, TestFlight). Every text write refuses (apple / play listing, play release / upload notes, listing set, whats-new check / apply, the desktop editor and storebound edit); check / validate blockers; scan `other-platforms` (2.3.10) and `play-other-platforms` (Play: Metadata). `listing.allowPlatformMentions` for the owner's functional mentions; whats-new splits `[apple]` / `[play]` lines. `tests/cross-store.test.ts`. |
| 74 | 2026-10 | Apple sharpened Guideline 2.3.1 (hidden, dormant or undocumented features; obfuscation that hides features is grounds for removal). Apps with remote config / feature flags or remote paywalls whose review notes said nothing risk a rejection or removal when a feature or a hard paywall is switched on after review; Play's Deceptive behavior policy covers the same. Paywall tests and designs are fine once review knows; a business-model change needs a new review and a description update. | `remoteFeatureFindings` (`cli/lib/scan/rules.ts`, SDK table `REMOTE_SDKS` in `cli/lib/scan/sdks.ts`): scan / validate report `remote-features` for Apple (2.3.1) and Play (Deceptive behavior), medium, unless the review notes (apple.info.reviewNotes, reviewAccess.instructions, accounts.inApp) mention remote config, flags, A/B, a paywall test, an experiment or "remotely"; check prints it as a warning. Warn only: storebound never writes the note; the templates carry a commented example line. Code obfuscators are a low note, minify alone nothing. `tests/remote-features.test.ts`. |
| 73 | 2026-10 | A game with guest play (no sign-in; `reviewAccess.signInRequired: false`, Play Sign in details "No restrictions") was rejected by Play: "Play Console Requirements: provide valid login credentials". The first screen had an empty player-name field with Continue disabled until a name was typed and an "I Have a Code" link to a transfer / recovery code dialog; the reviewer read it as a login wall. | `profileIssues` (`cli/lib/profile.ts`, called by `check` with `SIGNALS.firstRunGate` hits from `cli/lib/scan/rules.ts`): warns "Play may reject as a login wall; add guest steps" for a Play app with a first-run gate or IAP and no `reviewAccess.instructions`, and when the steps exceed 500 characters. The `play-sign-in-details` plan (`cli/lib/console-play-plans.ts`): with guest steps "restricted" and one entry "Guest play (no account needed)" without credentials, the steps in "Any other information", the dialog's required full-access box as an owner step; Add and Save the agent's. `tests/guest-login-wall.test.ts`. |
| 72 | 2026-10 | A game: after Play production 1.0.0 (3) was rejected (Broken Functionality), the fix 1.0.1 (5) went through upload and track updates, then edits.validate answered 400 "Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true". Play refuses the automatic send after a rejection, with other pending changes or managed publishing. | `cli/play/client.ts` (edit flow) with `cli/play/review-pending.ts`: on exactly that 400 the same edit is validated and committed again with changesNotSentForReview=true (a dry run validates that way and says so), the Publishing overview deep link is printed (the owner's button), the commit is recorded in .storebound/ and `play status` shows it; `--not-sent-for-review` on play release / upload / rollout / listing / images (MCP `not_sent_for_review`). `tests/play-not-sent-for-review.test.ts`. Only edits.commit takes changesNotSentForReview: validate answers 400 "Unknown name ... Cannot bind query parameter" (the fake store does too). |

View file

@ -25,6 +25,9 @@ true; never use competitor or other apps' or games' names, "best / #1", or unrel
- **Screenshot captions**: write them with the words people search for ("Budget planner with shared lists", "Match 3 puzzles offline", not "Fun awaits").
Claims that Apple reads caption text for search ranking are unverified; good captions help conversion either way.
- Changing keywords needs a new version (submitted with it); promotional text (170) changes any time but is not indexed.
- **No other platforms (2.3.10)**: no Android, Google Play (Games), Play Store, Galaxy Store, Amazon Appstore, Steam,
Microsoft Store, AppGallery (and localized forms such as "Google Play Spiele", "安卓") in any App Store field,
What's New included. Every App Store text write refuses them (store-pitfalls 77).
## Google Play
- **Indexed**: title (30), short description (80), full description (4000). There is no keyword field.
@ -33,6 +36,9 @@ true; never use competitor or other apps' or games' names, "best / #1", or unrel
- **No stuffing**: a word in more than ~3 % of the full description, keyword lists, ALL CAPS titles, emoji in the
title, or "best / top / #1" break the metadata policy. `check --aso` warns on these.
- Localize every listing you can; Play machine-translates otherwise, which ranks and converts worse.
- **No Apple platforms (Metadata policy)**: no iOS, iPhone, iPad, App Store, Apple Arcade, Game Center or TestFlight in
the title, descriptions or release notes; every Play text write refuses them. A real sign-in button name ("Sign in
with Apple") stays only through the owner's `listing.allowPlatformMentions` entry.
## Workflow
1. List 20-30 candidate terms with the owner: what people would type to find this app (the job it does, the problem it solves; for a game:

View file

@ -87,6 +87,11 @@ Plain, polite, specific; one guideline per paragraph; no blame, no threats, no "
- **2.3 Accurate metadata**: screenshots must show the real app (for a game: real gameplay); no prices, no "free"
claims for paid items, no other platforms' UI; keywords without competitor names; age rating answers that match
the content.
- **2.3.10 Accurate Metadata (other platforms)**: "Android", "Google Play", "Google Play Games" or another store in
any App Store text, often a What's New copied from notes shared with Play (store-pitfalls 77). Fix: remove the
lines (tag shared notes `[play]` / `[apple]`, `storebound whats-new apply`), `storebound check` must show no
`other platform` blocker, push with `apple listing --version x.y.z`, reply "removed the references to other
platforms" and resubmit. Play's mirror case is the Metadata policy (iOS, App Store, Game Center in Play text).
- **2.3.1 Hidden or undocumented features** (Play: Deceptive behavior): remote config, feature flags or a remote
paywall the review notes did not mention (`storebound scan` rule `remote-features`, store-pitfalls 74). Fix: say in
the notes which SDK changes what remotely; a business-model change needs a new review and a description update.

View file

@ -36,8 +36,10 @@ storebound api apple salesReports_getCollection --param ... --out report.gz #
## Write (only the owner sends)
1. Run the write without `--yes`: it prints the exact request (method, URL, body; the key is never shown) and sends
nothing. Show that to the owner.
2. The owner runs it in their own terminal with `--yes`. Outside an interactive terminal, under MCP or in the desktop
app, a write is never sent, whatever the flags.
2. The owner runs it in their own terminal with `--yes`. Under MCP or in the desktop app a write is never sent,
whatever the flags. `--yes` with stdout not a TTY (piped `| tail`, redirected `> out.json`, an agent's shell) is
refused: exit 1 and "refused: writes through `storebound api` need an interactive terminal ... Nothing was sent."
on stderr (store-pitfalls 78). Never pipe or redirect a write; use the dedicated command the message names.
3. Owner-only operations (cli/api/gate.ts: every DELETE, submissions and their cancel, release requests, phased
release changes, product page test start / stop, price schedules and price migrations, review replies, tester and
user invitations, Play `edits.commit`, product activation, refunds, revokes and cancels, app signing) also ask for

View file

@ -763,7 +763,8 @@ that fails without the fix (see CONTRIBUTING.md).
bundleIdCapabilities answers, lag, a write that never sticks).
## 66. `storebound api apple ... --body f.json --yes` only dry-ran from an agent
- **Do:** expected: writes through `storebound api` are sent only from an interactive terminal, by design. Use the
- **Do:** expected: writes through `storebound api` are sent only from an interactive terminal, by design (since
pitfall 78 it is a loud refusal with exit 1, not a dry run). Use the
dedicated command the message names (bundle id capabilities: `apple signing ensure`; IAPs: `apple iap`; listing:
`apple listing`, ...), which runs anywhere with its own dry run and read-back.
- **Guard:** `dedicatedCommand` (cli/api/gate.ts), `tests/signing-capabilities.test.ts`.
@ -925,3 +926,26 @@ that fails without the fix (see CONTRIBUTING.md).
Uploading and selecting are normal steps, not owner buttons; the version's Submit for Review stays the owner's.
- **Guard:** `cli/apple/creative-assets.ts`, `tests/creative-assets.test.ts` (fake App Store Connect: dry run, upload,
waiting, select, replace on a changed file, nothing on the same file).
## 77. What's New shared with Play rejected on the App Store (2.3.10)
- **Symptom:** a Mac App Store version was rejected under Guideline 2.3.10 (Accurate Metadata): its What's New came
from release notes shared with Google Play and had "Google Play Games" and "Android" lines. storebound wrote it
without a word (the old check was a warning in the editor only).
- **Cause:** one notes source for both stores, no per-store split, and no write that refused other platforms. Google
Play's Metadata policy is the mirror case (iOS, iPhone, App Store, Game Center, TestFlight in Play text).
- **Do:** tag store-specific lines `[play]` / `[apple]` in shared notes (whats-new skill); `storebound check` must
show no `other platform` line. A functional mention (a "Continue with Google" / "Sign in with Apple" button) is the
owner's call: `listing.allowPlatformMentions: [{ store, text, reason }]`. Never add an entry yourself.
- **Guard:** `cli/lib/cross-store.ts` (term lists, per locale / field / line, the allowlist, `notesForStore`).
Refused before anything is sent, dry runs included: `apple listing`, `play listing`, `play release` /
`play upload` (release notes), `listing set`, `whats-new check` / `apply`, the desktop editor and `storebound edit`
(`appleLocaleIssues` / `playLangIssues` errors). `check` / `validate` blockers (`other-platforms`), `scan` rules
`other-platforms` (2.3.10) and `play-other-platforms` (Play: Metadata). `tests/cross-store.test.ts`.
## 78. `storebound api ... --yes` piped or redirected became a silent dry run
- **Symptom:** `storebound api apple <write> --yes | tail` (or `> out.json`) printed a dry run and exited 0; the
owner thought it was sent. Happened twice.
- **Cause:** writes need an interactive terminal; without one the gate fell back to a dry run.
- **Do:** run writes without a pipe or redirect in a terminal, or use the dedicated storebound command.
- **Guard:** `decide` in `cli/api/gate.ts` refuses (`NOT_A_TTY`): exit 1, the message on stderr, nothing sent, the
dedicated command named. Without `--yes` a write is still a plain dry run. `tests/api.test.ts`.

View file

@ -29,6 +29,20 @@ CI, PR, OTA, fingerprint, bump, deps, ...), version-control words (commit, merge
code identifiers (`saveSlot`, `max_lives`). Each warning names the word and a plain alternative. Rewrite the line;
when a flagged word is a real player-facing term (a character called "Sentry"), add it to `whatsNew.allowWords`.
## One source, two stores: tag store-specific lines
A notes source shared by both stores must not carry one store's lines into the other: a Mac App Store version was
rejected under Guideline 2.3.10 for a What's New with "Google Play Games" and "Android" lines (store-pitfalls 77).
- Tag a line for one store: `[play] Achievements on Google Play Games.` or `• [apple] Game Center leaderboards.`
(also `[android]` / `[ios]`). A line that is only `[play]` starts a block for that store up to the next blank line.
Untagged lines go to both stores; the tag itself is removed.
- The draft leaves an untagged line that names the other side's platform out of that store's draft and says so
(tag it to make it explicit). `whats-new check` and `apply` refuse notes that still name Android, Google Play,
Steam, ... in App Store text or iOS, iPhone, App Store, Game Center, TestFlight in Play text, with language, line
and term; `apple listing`, `play listing`, `play release` / `play upload` (release notes), `listing set` and the
desktop editor refuse the same, dry runs included.
- A functional mention (a "Continue with Google" or "Sign in with Apple" button) is the owner's call: an entry in
`listing.allowPlatformMentions` (`{ store, text, reason }`). Never add one yourself to get past the check.
## Write it for your users
1. Run `storebound whats-new --dry`, read the draft with the owner.
2. Rewrite:
@ -68,5 +82,5 @@ when a flagged word is a real player-facing term (a character called "Sentry"),
## Gotchas
- Squash-merged repos: the PR titles are the subjects; keep them in conventional form (`feat: ...`) to get clean drafts.
- Commits marked `[skip notes]` or `[internal]` are left out.
- Commits marked `[skip notes]` or `[internal]` are left out; `feat: [play] ...` subjects go only into the Play draft.
- Release notes are public: nothing about cheats, exploits, unreleased content or private names.

View file

@ -167,11 +167,11 @@ describe('write gating', () => {
const w = { ...base, kind: 'write' as const, method: 'PATCH' };
expect(decide(w)).toMatchObject({ action: 'dry', why: expect.stringContaining('--yes') });
expect(decide({ ...w, yes: true, dry: true }).action).toBe('dry');
expect(decide({ ...w, yes: true, tty: false })).toMatchObject({ action: 'dry', why: expect.stringContaining('not an interactive terminal') });
expect(decide({ ...w, yes: true, tty: false })).toMatchObject({ action: 'refuse', why: expect.stringContaining('need an interactive terminal') });
expect(decide({ ...w, yes: true })).toEqual({ action: 'send' });
const o = { ...w, kind: 'owner' as const };
expect(decide({ ...o, yes: true })).toEqual({ action: 'confirm', typed: true });
expect(decide({ ...o, yes: true, tty: false }).action).toBe('dry');
expect(decide({ ...o, yes: true, tty: false }).action).toBe('refuse');
// MCP / desktop: never a write, whatever the flags
expect(decide({ ...w, yes: true, agent: true }).action).toBe('refuse');
expect(decide({ ...o, yes: true, agent: true })).toMatchObject({ action: 'refuse', why: expect.stringContaining('never through MCP') });
@ -222,10 +222,16 @@ describe('the CLI never sends a write it should not', () => {
expect(r.code).toBe(0);
expect(JSON.parse(r.out)).toMatchObject({ dryRun: true, gate: 'write', request: { method: 'PUT', url: 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/com.example.app/edits/1/tracks/beta' } });
});
test('--yes outside a terminal still only prints', () => {
test('--yes outside a terminal (piped / redirected) is refused loudly: exit 1, stderr, nothing sent', () => {
const r = cli(['apple', 'DELETE', '/v1/appScreenshots/1', '--yes']);
expect(r.code).not.toBe(0);
expect(r.err).toContain('refused: writes through `storebound api` need an interactive terminal');
expect(r.err).toContain('Nothing was sent');
expect(r.out).not.toContain('dry run');
});
test('without --yes, a piped write is still a plain dry run (exit 0)', () => {
const r = cli(['apple', 'DELETE', '/v1/appScreenshots/1']);
expect(r.code).toBe(0);
expect(r.out).toContain('not an interactive terminal');
expect(r.out).toContain('owner only: this deletes');
});
test('under MCP and in api get, writes are refused', () => {

171
tests/cross-store.test.ts Normal file
View file

@ -0,0 +1,171 @@
// Pitfall 77: other platforms or stores named in one store's text (App Store 2.3.10, Play Metadata policy).
import { describe, expect, test } from 'bun:test';
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileMentions, notesForStore, refuseMentions, textMentions, type PlatformAllow } from '../cli/lib/cross-store';
import { appleLocaleIssues, playLangIssues } from '../cli/lib/listing-rules';
import { setPlan } from '../cli/listing-store';
import { getByPath, parseOps, planEdits, type EditSources } from '../cli/lib/edit';
import { applyLanguageNotes, checkLanguageNotes, draftForStore, languageNoteMentions } from '../cli/lib/whats-new';
import { runScan } from '../cli/lib/scan';
const APPLE_MD = `# App Store
## en-US · English
### description
\`\`\`
A calm puzzle game.
\`\`\`
### whatsNew
\`\`\`
New levels.
Achievements on Google Play Games.
Fixed a crash on Android 14.
\`\`\`
## de-DE · Deutsch
### whatsNew
\`\`\`
Neue Level.
Erfolge in Google Play Spiele.
\`\`\`
`;
const PLAY_MD = `# Listing
## English
<!-- play: en-US -->
### Play short description (max 80)
\`\`\`
A calm puzzle game
\`\`\`
### Play full description (max 4000)
\`\`\`
Play offline.
Also on iPhone and iPad.
\`\`\`
`;
describe('the checker: per locale, per line', () => {
test('App Store text: the exact term, field, locale, line and file line; longest term wins', () => {
const hits = fileMentions('apple', 'store/appstore.md', APPLE_MD);
expect(hits.map(h => [h.locale, h.field, h.line, h.term, h.fileLine])).toEqual([
['en-US', 'whatsNew', 2, 'Google Play Games', 13],
['en-US', 'whatsNew', 3, 'Android', 14],
['de-DE', 'whatsNew', 2, 'Google Play Spiele', 22],
]);
expect(hits[0]!.fix).toContain('[play]');
});
test('Play text: iPhone / iPad with the Play field name', () => {
const hits = fileMentions('play', 'store/listing.md', PLAY_MD);
expect(hits.map(h => [h.locale, h.field, h.line, h.term])).toEqual([['en-US', 'full', 2, 'iPhone'], ['en-US', 'full', 2, 'iPad']]);
});
test('whole words only; localized and CJK forms; tags for the other store', () => {
expect(textMentions('Androids are robots. androidal', { store: 'apple', locale: 'x', field: 'description' })).toEqual([]);
expect(textMentions('Full steam ahead', { store: 'apple', locale: 'x', field: 'description' })).toEqual([]);
expect(textMentions('Jetzt auf Steam', { store: 'apple', locale: 'x', field: 'description' }).map(h => h.term)).toEqual(['Steam']);
expect(textMentions('支持安卓', { store: 'apple', locale: 'zh-Hans', field: 'description' }).map(h => h.term)).toEqual(['安卓']);
expect(textMentions('[play] Cloud saves', { store: 'apple', locale: 'x', field: 'whatsNew' }).map(h => h.term)).toEqual(['[play]']);
expect(textMentions('a fair app store', { store: 'play', locale: 'x', field: 'full' })).toEqual([]);
expect(textMentions('Syncs with Game Center and TestFlight builds on iOS', { store: 'play', locale: 'x', field: 'full' }).map(h => h.term)).toEqual(['Game Center', 'TestFlight', 'iOS']);
});
});
describe('the allowlist: only the owner\'s entries, matched per line', () => {
const allow: PlatformAllow[] = [{ store: 'play', text: 'Sign in with Apple', reason: 'the sign-in button' }, { store: 'apple', text: 'Continue with Google Play Games', reason: 'cross-progress button' }];
test('an allowed line keeps its terms; other lines and the other store still hit', () => {
expect(textMentions('Tap Sign in with Apple to start', { store: 'play', locale: 'en-US', field: 'full', allow })).toEqual([]);
expect(textMentions('Also on iPhone', { store: 'play', locale: 'en-US', field: 'full', allow }).length).toBe(1);
expect(textMentions('Sign in with Apple', { store: 'apple', locale: 'en-US', field: 'description', allow })).toEqual([]);
expect(textMentions('Continue with Google Play Games', { store: 'apple', locale: 'en-US', field: 'description', allow })).toEqual([]);
expect(textMentions('Continue with Google Play Games on Android', { store: 'apple', locale: 'en-US', field: 'description', allow }).map(h => h.term)).toEqual(['Android']);
});
});
describe('writes refuse for both stores', () => {
test('listing rules: an error for each store (the desktop editor and storebound edit stop on it)', () => {
const a = appleLocaleIssues('en-US', { subtitle: 's', promotionalText: 'p', keywords: 'puzzle', description: 'd', whatsNew: 'Now on Google Play' }, 'Game');
expect(a.find(i => i.field === 'whatsNew')).toMatchObject({ level: 'error', msg: expect.stringContaining('2.3.10') });
const p = playLangIssues('en-US', { short: 'A game', full: 'Play it on your iPad', whatsNew: 'Fixed TestFlight builds' }, 'Game');
expect(p.filter(i => i.level === 'error').map(i => i.field).sort()).toEqual(['full', 'whatsNew']);
expect(p.find(i => i.field === 'full' && i.level === 'error')!.msg).toContain('Play: Metadata');
expect(appleLocaleIssues('en-US', { subtitle: 's', promotionalText: 'p', keywords: 'puzzle', description: 'Sign in with Google', whatsNew: 'x' }, 'Game',
[{ store: 'apple', text: 'Sign in with Google', reason: 'button' }]).some(i => i.level === 'error')).toBe(false);
});
test('store-only listing edit (listing set): both stores refused on the fields it sends', () => {
const ap = setPlan('apple', 'en-US', { whatsNew: 'old' }, { whatsNew: 'Now with Android cross-play' }, { appName: 'Game', editable: true });
expect(ap.issues.some(i => i.level === 'error' && i.field === 'whatsNew')).toBe(true);
const pl = setPlan('play', 'en-US', { title: 'Game', short: 'Old', full: 'Full.' }, { short: 'The best iOS puzzle' }, { appName: 'Game' });
expect(pl.issues.some(i => i.level === 'error' && i.field === 'short')).toBe(true);
const ok = setPlan('play', 'en-US', { title: 'Game', short: 'Old', full: 'Full.' }, { short: 'Sign in with Apple supported' }, { appName: 'Game', allow: [{ store: 'play', text: 'Sign in with Apple', reason: 'button' }] });
expect(ok.issues.some(i => i.level === 'error')).toBe(false);
});
test('the desktop editor write path (storebound edit) counts them as errors', () => {
const src: EditSources = {
config: { file: '/x/storebound.config.ts', rel: 'storebound.config.ts', text: 'export default {}\n' },
play: { file: '/x/store/listing.md', rel: 'store/listing.md', text: PLAY_MD.replace('Also on iPhone and iPad.\n', '') },
apple: { file: '/x/store/appstore.md', rel: 'store/appstore.md', text: APPLE_MD },
contact: { file: '/x/.storebound/credentials.json', rel: '.storebound/credentials.json', text: '{}' },
appName: 'Game', appleName: 'Game',
};
const apple = planEdits(parseOps(JSON.stringify([{ kind: 'listing', store: 'apple', lang: 'en-US', field: 'description', value: 'Get it on Google Play too.' }])), src, p => getByPath({}, p));
expect(apple.plan.issues.some(i => i.level === 'error' && i.msg.includes('Google Play'))).toBe(true);
const play = planEdits(parseOps(JSON.stringify([{ kind: 'listing', store: 'play', lang: 'en-US', field: 'full', value: 'Best on iPhone.' }])), src, p => getByPath({}, p));
expect(play.plan.errors).toBeGreaterThan(0);
});
test('refuseMentions throws one message with every hit, nothing sent', () => {
expect(() => refuseMentions(textMentions('On Android', { store: 'apple', locale: 'en-US', field: 'whatsNew' }), 'apple listing'))
.toThrow(/refused: apple listing: .*App Store text \(2\.3\.10\); nothing was sent\n.*en-US whatsNew line 1: "Android"/);
expect(() => refuseMentions([], 'x')).not.toThrow();
});
});
describe('whats-new: one shared text, per-store notes', () => {
const shared = 'New levels.\n• [play] Achievements on Google Play Games.\n• [apple] Game Center leaderboards.\n[android] Fixed a crash on Android 14.\n\n[play]\nPlay-only block line.\n\nShared again.';
test('tagged lines split per store, tags removed; a [play] heading line starts a block until a blank line', () => {
expect(notesForStore(shared, 'apple')).toBe('New levels.\n• Game Center leaderboards.\n\nShared again.');
expect(notesForStore(shared, 'play')).toBe('New levels.\n• Achievements on Google Play Games.\nFixed a crash on Android 14.\n\nPlay-only block line.\n\nShared again.');
});
test('the draft leaves untagged other-platform lines out of the wrong store and says which', () => {
const d = draftForStore('New levels.\nAchievements on Google Play Games.', 'apple');
expect(d).toEqual({ text: 'New levels.', dropped: ['Achievements on Google Play Games.'] });
expect(draftForStore('New levels.\nAchievements on Google Play Games.', 'play').dropped).toEqual([]);
});
test('apply writes each store its own lines; tagged notes pass the cross-store check', () => {
const l = { id: 'en', apple: 'en-US', play: 'en-US' };
const md = { apple: '## en-US\n\n### whatsNew\n```\nold\n```\n', play: '## English\n<!-- play: en-US -->\n\n### What\'s new (max 500)\n```\nold\n```\n' };
const r = applyLanguageNotes(md, l, shared);
expect(r.apple).toContain('• Game Center leaderboards.');
expect(r.apple).not.toContain('Google Play');
expect(r.play).toContain('Achievements on Google Play Games.');
expect(r.play).not.toContain('Game Center');
expect(languageNoteMentions(l, shared)).toEqual([]);
const untagged = languageNoteMentions(l, 'New levels.\nAchievements on Google Play Games.');
expect(untagged.map(h => [h.store, h.locale, h.line, h.term])).toEqual([['apple', 'en-US', 2, 'Google Play Games']]);
// each store's limit counts its own notes
expect(checkLanguageNotes(l, { lang: 'en', source: 'en', status: 'source', text: shared }).problems).toEqual([]);
});
});
describe('scan: guideline ids per store', () => {
test('App Store 2.3.10 and Play: Metadata, with file and line', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-cross-'));
try {
cpSync(join(import.meta.dir, 'fixtures', 'scan', 'android'), dir, { recursive: true });
mkdirSync(join(dir, 'store'), { recursive: true });
writeFileSync(join(dir, 'store', 'listing.md'), PLAY_MD);
const r = (await runScan(dir)).report;
const f = r.findings.find(x => x.rule === 'play-other-platforms')!;
expect(f).toMatchObject({ store: 'play', guideline: 'Play: Metadata' });
expect(f.evidence[0]).toMatchObject({ file: 'store/listing.md', line: 14 });
} finally { rmSync(dir, { recursive: true, force: true }); }
const ios = (await runScan(join(import.meta.dir, 'fixtures', 'scan', 'ios-native'))).report;
expect(ios.findings.find(x => x.rule === 'other-platforms')).toMatchObject({ store: 'apple', guideline: '2.3.10' });
});
});

View file

@ -140,8 +140,8 @@ describe('scope and leftovers', () => {
describe('storebound api writes outside a terminal', () => {
test('the dry reason says TTY by design and the dedicated command is named', () => {
const d = decide({ kind: 'write', method: 'POST', dry: false, yes: true, getOnly: false, tty: false, agent: false });
expect(d).toMatchObject({ action: 'dry' });
expect((d as { why: string }).why).toContain('by design');
expect(d).toMatchObject({ action: 'refuse' });
expect((d as { why: string }).why).toContain('Nothing was sent');
expect(dedicatedCommand('apple', 'bundleIdCapabilities_createInstance')).toContain('apple signing ensure');
expect(dedicatedCommand('apple', 'apps_getInstance')).toBeNull();
});