// 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] [--lang en-US] [--all] [--hook "text"] [--dry] // --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). import { readFileSync, writeFileSync } from 'node:fs'; import type { Ctx } from './lib/ctx'; import { log, warn } from './lib/log'; import { checkNotes, formatNotes, NOTES_LIMITS, noteItems, setAppleWhatsNew, setPlayWhatsNew } from './lib/whats-new'; function git(args: string[], cwd: string): string { const r = Bun.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; const since = a.get('since') ?? (() => { try { return git(['describe', '--tags', '--abbrev=0'], root).trim(); } catch { return ''; } })(); const range = since ? `${since}..HEAD` : 'HEAD'; const subjects = git(['log', range, '--no-merges', '--format=%s'], root).split('\n').filter(Boolean); const items = noteItems(subjects); log('whats-new', `${subjects.length} commits since ${since || 'the first commit'} -> ${items.length} user-facing items`); const hook = a.get('hook'); const play = formatNotes(items, { hook, limit: NOTES_LIMITS.play }); const apple = formatNotes(items, { hook, limit: NOTES_LIMITS.apple }); for (const [store, text] of [['play', play], ['apple', apple]] as const) for (const p of checkNotes(text, store)) warn('whats-new', `${store}: ${p}`); 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'); }