// Draft user-facing release notes from the git log and write them into the listing files (text only; nothing is // sent to a store). A human (or an agent with the whats-new skill) rewrites and translates before `play release` / // `apple listing` push them. // storebound whats-new [--since v1.1.0] [--release 1.2.0] [--from-git] [--lang en-US] [--all] [--hook "text"] [--dry] // The text comes from a player-facing source when there is one (whatsNew.source, docs/RELEASE-.md, the // CHANGELOG.md section of the version or Unreleased; version: --release, else package.json); only then the git log. // --since: default the latest tag (git describe --tags --abbrev=0); --lang: the Play code / App Store locale to write // (default: the Play default language and the first App Store locale); --all: write the same draft into every // language (to translate afterwards; `storebound check` warns while they are identical). // // Many languages, one file each (the CLI never calls a translation service and needs no key for one): // storebound whats-new --languages en-US,de-DE,tr [--dir store/whats-new] [--translate ""] [--force] [--dry] [--json] // the draft goes into the first language's file (status=source); every other language gets a file with the // source text and status=translate for your agent to translate (whats-new skill), or the output of --translate: // a command of yours run once per language with the source text on stdin and STOREBOUND_SOURCE_LANG / // STOREBOUND_TARGET_LANG (App Store locale, else Play code, else language id) in the environment; its stdout is // 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] [--platform ios|macos|tvos|visionos] [--dry] [--json] checked files -> both listing files // (--platform only goes into the printed apple listing command: the App Store's what's new is per platform version) // 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'; import { playCodes } from './lib/config'; import type { Ctx } from './lib/ctx'; import { done, log, printJson, warn } from './lib/log'; import { textTable } from './lib/table'; import { 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 { parsePlatform } from './apple/platform'; import { hitLine } from './lib/cross-store'; const TAG = 'whats-new'; const storeCode = (l: Language): string => l.apple ?? playCodes(l)[0] ?? l.id; /** The language files of a notes folder, in config order. */ function readDir(dir: string, langs: Language[]): { l: Language; f: NotesFile }[] { if (!existsSync(dir)) throw new Error(`no ${dir} (storebound whats-new --languages ... writes it)`); const names = new Set(readdirSync(dir).filter(n => n.endsWith('.md')).map(n => n.slice(0, -3))); return langs.filter(l => names.has(l.id)).map(l => ({ l, f: parseNotesFile(readFileSync(join(dir, `${l.id}.md`), 'utf8'), l.id) })); } function checkAll(files: { l: Language; f: NotesFile }[], allow: readonly string[]): LangCheck[] { const src = files.find(x => x.f.status === 'source'); return files.map(x => checkLanguageNotes(x.l, x.f, src && src !== x ? src.f.text : undefined, allow)); } function printChecks(checks: LangCheck[]): void { console.log(textTable(['language', 'chars', 'play (500)', 'app store (4000)', 'problems'], checks.map(c => [c.lang, c.chars, c.play ? (c.play.ok ? 'ok' : 'OVER') : '-', c.apple ? (c.apple.ok ? 'ok' : 'OVER') : '-', c.problems.join('; ')]))); // jargon never blocks: players read these notes, so name each word and a plain alternative (whatsNew.allowWords keeps one) 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; shared: string }): Promise { 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); 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) { const path = join(dir, `${l.id}.md`); const cur = existsSync(path) ? parseNotesFile(readFileSync(path, 'utf8'), l.id) : null; if (cur && cur.status === 'translated' && !a.has('force')) { files.push({ l, f: cur, write: false, note: 'kept (translated; --force replaces it)' }); continue; } if (hook && !a.has('dry')) { const r = rt.spawnSync(['sh', '-c', hook], { stdin: new TextEncoder().encode(text), stdout: 'pipe', stderr: 'pipe', env: { ...process.env, STOREBOUND_SOURCE_LANG: storeCode(src!), STOREBOUND_TARGET_LANG: storeCode(l), STOREBOUND_TARGET_LANG_ID: l.id } }); const out = r.stdout.toString().trim(); if (r.exitCode !== 0 || !out) { warn(TAG, `${l.id}: --translate failed (exit ${r.exitCode}): ${r.stderr.toString().trim().slice(0, 200)}`); files.push({ l, f: { lang: l.id, source: src!.id, status: 'translate', text }, write: true, note: 'to translate (hook failed)' }); continue; } files.push({ l, f: { lang: l.id, source: src!.id, status: 'translated', text: out }, write: true, note: 'translated by the hook' }); } else files.push({ l, f: { lang: l.id, source: src!.id, status: 'translate', text }, write: true, note: hook ? 'would run the --translate hook' : 'to translate (your agent: whats-new skill)' }); } const checks = checkAll(files.map(x => ({ l: x.l, f: x.f })), config.whatsNew?.allowWords ?? []); if (a.has('json')) printJson({ dir, dryRun: a.has('dry'), files: files.map((x, i) => ({ lang: x.l.id, file: join(dir, `${x.l.id}.md`), status: x.f.status, write: x.write, note: x.note, check: checks[i] })) }); else { for (const x of files) log(TAG, `${join(dir, `${x.l.id}.md`)}: ${x.note}`); printChecks(checks); } if (a.has('dry')) { done(TAG, 'dry run: nothing written'); return; } mkdirSync(dir, { recursive: true }); for (const x of files) if (x.write) writeFileSync(join(dir, `${x.l.id}.md`), notesFileText(x.f)); done(TAG, `${files.filter(x => x.write).length} file(s) in ${dir}; translate the status=translate ones, then storebound whats-new apply`); } function git(args: string[], cwd: string): string { const r = rt.spawnSync(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' }); if (r.exitCode !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr.toString().trim()}`); return r.stdout.toString(); } export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const a = ctx.args; if (a._[0] === 'check' || a._[0] === 'apply') { parsePlatform(a.get('platform')); // a wrong --platform fails before anything is written const dir = resolve(a.get('dir') ?? 'store/whats-new'); const files = readDir(dir, config.languages); if (!files.length) throw new Error(`${dir}: no .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 }); else printChecks(checks); if (bad.length) { process.exitCode = 1; done(TAG, `${bad.length} language(s) not ready: ${bad.map(c => c.lang).join(', ')}${a._[0] === 'apply' ? '; nothing written' : ''}`); return; } if (a._[0] === 'check') { done(TAG, `${checks.length} language(s) within the store limits`); return; } } const playFile = config.play ? resolve(config.play.listing ?? 'store/listing.md') : undefined; const appleFile = config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : undefined; let md: { play?: string; apple?: string } = { play: playFile ? readFileSync(playFile, 'utf8') : undefined, apple: appleFile ? readFileSync(appleFile, 'utf8') : undefined }; const changed: string[] = []; for (const { l, f } of files) { const r = applyLanguageNotes(md, l, f.text); md = { play: r.play, apple: r.apple }; changed.push(...r.changed); } if (a.has('json')) printJson({ dir, checks, dryRun: a.has('dry'), changed }); else { printChecks(checks); log(TAG, `sections: ${changed.join(', ') || 'none (no matching listing sections)'}`); } if (a.has('dry')) { done(TAG, 'dry run: listing files not written'); return; } if (playFile && md.play !== undefined) writeFileSync(playFile, md.play); if (appleFile && md.apple !== undefined) writeFileSync(appleFile, md.apple); const pf = a.get('platform'); done(TAG, `release notes of ${files.length} language(s) written into the listing files; push them with play release / apple listing --version x.y.z${pf ? ` --platform ${pf}` : ''} (the App Store's what's new belongs to one platform's version: apple listing picks the platform with the editable version, or asks)`); return; } const since = a.get('since') ?? (() => { try { return git(['describe', '--tags', '--abbrev=0'], root).trim(); } catch { return ''; } })(); const range = since ? `${since}..HEAD` : 'HEAD'; const allow = config.whatsNew?.allowWords ?? []; // a player-facing source (whatsNew.source, docs/RELEASE-.md, the CHANGELOG section) beats the git log 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, 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; 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-.md, CHANGELOG section or whatsNew.source): rewrite them in player words`); 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, 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; const all = a.has('all'); if (config.play) { const f = resolve(config.play.listing ?? 'store/listing.md'); const lang = all ? '*' : a.get('lang') ?? config.play.defaultLanguage ?? 'en-US'; const r = setPlayWhatsNew(readFileSync(f, 'utf8'), lang, play); if (r.changed.length) { writeFileSync(f, r.md); log('whats-new', `${f}: ${r.changed.join(', ')}`); } else warn('whats-new', `${f}: no section for ${lang}`); } if (config.apple) { const f = resolve(config.apple.listing ?? 'store/appstore.md'); const first = config.apple.primaryLocale ?? config.languages.find(l => l.apple)?.apple ?? 'en-US'; const locale = all ? '*' : a.get('lang') ?? first; const r = setAppleWhatsNew(readFileSync(f, 'utf8'), locale, apple); if (r.changed.length) { writeFileSync(f, r.md); log('whats-new', `${f}: ${r.changed.join(', ')}`); } else warn('whats-new', `${f}: no section for ${locale}`); } log('whats-new', 'draft written: edit it, translate every language, then storebound check'); }