diff --git a/package.json b/package.json index c13b47fb0d..1e7ba66959 100644 --- a/package.json +++ b/package.json @@ -160,7 +160,7 @@ "release-notes:generate:codex": "cross-env SP_RELEASE_NOTES_AI=codex node ./tools/release-notes.js generate", "release-notes:generate:claude": "cross-env SP_RELEASE_NOTES_AI=claude node ./tools/release-notes.js generate", "release-notes:prepare-play-store": "node ./tools/release-notes.js prepare-play-store", - "release-notes:test": "node --test tools/release-notes.test.js", + "release-notes:test": "node --test tools/release-notes.test.js tools/prepare-appstore-release-notes.test.js", "clean:translations": "node ./tools/clean-translations.js", "plugin-api:build": "cd packages/plugin-api && npm run build", "packages:test": "npm run sync-core:test && npm run sync-providers:test", diff --git a/tools/prepare-appstore-release-notes.js b/tools/prepare-appstore-release-notes.js index 7c8b987a37..bc64737a06 100644 --- a/tools/prepare-appstore-release-notes.js +++ b/tools/prepare-appstore-release-notes.js @@ -6,6 +6,13 @@ // headings, emphasis and links and drops the GitHub-only downloads footer. The // result is written for the App Store deliver lanes (fastlane/Fastfile). // +// It also removes bullets that reference non-Apple platforms (Android, Linux, +// Windows, …). The shared release-notes generator intentionally keeps those for +// the GitHub/Play Store changelogs, but App Review guideline 2.3.10 rejects +// metadata that talks about third-party platforms ("Revise the app's What's New +// text to remove Android references"). Those changes also don't apply to the +// macOS/iOS builds, so dropping them is correct on both counts. +// // Usage: node tools/prepare-appstore-release-notes.js [outFile] [locale] // outFile defaults to fastlane/appstore_metadata//release_notes.txt // locale defaults to en-US @@ -18,11 +25,6 @@ const SOURCE_FILE = path.join(ROOT_DIR, 'build', 'release-notes.md'); // App Store Connect caps "What's New" at 4000 characters. const MAX_CHARS = 4000; -const locale = process.argv[3] || 'en-US'; -const outFile = - process.argv[2] || - path.join(ROOT_DIR, 'fastlane', 'appstore_metadata', locale, 'release_notes.txt'); - // GitHub-only footer lines that don't belong in an App Store "What's New". // Anchored to the start of the line so they can't swallow a legitimate content // line that merely mentions "download" (e.g. a fix about a download dialog). @@ -36,6 +38,71 @@ const FOOTER_PATTERNS = [ /^\s*https?:\/\/\S+\s*$/i, ]; +// Non-Apple platforms whose names must not appear in App Store metadata +// (guideline 2.3.10). Matched as whole words, case-insensitively. Intentionally +// aggressive: any changelog line naming one of these is dropped wholesale (and +// logged), since it describes a change that doesn't ship on the macOS/iOS builds +// anyway. The bias is deliberate — a false negative (a platform name leaking to +// Apple) costs another rejection, while a false positive (e.g. the plural noun +// "windows") only drops a line that is logged and recoverable. Edit this list — +// not the call sites — if a future platform needs covering. +const OTHER_PLATFORM_PATTERNS = [ + /\bandroid\b/i, + /\blinux\b/i, + /\bwindows\b/i, + /\bgnome\b/i, + /\bx11\b/i, + /\bwayland\b/i, + /\bkde\b/i, + /\bflatpak\b/i, + /\bappimage\b/i, + /\bsnapcraft\b/i, + /\baur\b/i, +]; + +const isMarkdownHeading = (line) => /^\s*#{1,6}\s/.test(line); + +// Drop a section heading that has no content line after it (before the next +// heading). Single backward pass: walking bottom-up, "did real content follow +// this heading?" is already known by the time we reach the heading, so no +// per-heading forward re-scan is needed. +const dropEmptyHeadings = (lines) => { + const reversed = []; + let sawContentSinceHeading = false; + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (isMarkdownHeading(line)) { + if (!sawContentSinceHeading) { + continue; + } + sawContentSinceHeading = false; + } else if (line.trim() !== '') { + sawContentSinceHeading = true; + } + reversed.push(line); + } + return reversed.reverse(); +}; + +// Drop every line that names a non-Apple platform — bullets, but also section +// headings and intro prose, since the AI generation path (SP_RELEASE_NOTES_AI) +// can fold a platform name into a heading or sentence the deterministic +// generator never would, and a single leaked name re-triggers the 2.3.10 +// rejection. Then drop any section heading the removals left empty (e.g. a +// release whose only "Fixes" were Android-specific must not emit a dangling +// "Fixes" header). `onDrop` reports each removed line for CI logs. +const stripOtherPlatformLines = (markdown, onDrop = () => {}) => { + const kept = markdown.split('\n').filter((line) => { + const namesOtherPlatform = OTHER_PLATFORM_PATTERNS.some((re) => re.test(line)); + if (namesOtherPlatform) { + onDrop(line.trim()); + } + return !namesOtherPlatform; + }); + + return dropEmptyHeadings(kept).join('\n'); +}; + const toPlainText = (markdown) => markdown .split('\n') @@ -62,32 +129,60 @@ const toPlainText = (markdown) => .replace(/\n{3,}/g, '\n\n') .trim(); -// Always ensure the deliver metadata dir exists so the App Store lanes never -// fail on a missing metadata_path; an empty dir simply leaves "What's New" -// untouched in App Store Connect. -fs.mkdirSync(path.dirname(outFile), { recursive: true }); - -if (!fs.existsSync(SOURCE_FILE)) { - console.error(`No release notes source found at ${SOURCE_FILE}; skipping.`); - process.exit(0); -} - -let text = toPlainText(fs.readFileSync(SOURCE_FILE, 'utf8')); - -if (!text) { - console.error('Release notes are empty after processing; skipping.'); - process.exit(0); -} - -const chars = [...text]; -if (chars.length > MAX_CHARS) { - // Slice by code points (not UTF-16 units) so a multi-byte character (e.g. an - // emoji surrogate pair) is never cut in half. Keeps us within ASC's cap. - text = `${chars - .slice(0, MAX_CHARS - 1) +// Truncate by code points (not UTF-16 units) so a multi-byte character (e.g. an +// emoji surrogate pair) is never cut in half. Keeps us within ASC's cap. +const truncateToMaxChars = (text, maxChars = MAX_CHARS) => { + const chars = [...text]; + if (chars.length <= maxChars) { + return text; + } + return `${chars + .slice(0, maxChars - 1) .join('') .trimEnd()}…`; +}; + +const buildAppStoreReleaseNotes = (markdown, onDrop) => + truncateToMaxChars(toPlainText(stripOtherPlatformLines(markdown, onDrop))); + +const main = () => { + const locale = process.argv[3] || 'en-US'; + const outFile = + process.argv[2] || + path.join(ROOT_DIR, 'fastlane', 'appstore_metadata', locale, 'release_notes.txt'); + + // Always ensure the deliver metadata dir exists so the App Store lanes never + // fail on a missing metadata_path; an empty dir simply leaves "What's New" + // untouched in App Store Connect. + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + + if (!fs.existsSync(SOURCE_FILE)) { + console.error(`No release notes source found at ${SOURCE_FILE}; skipping.`); + process.exit(0); + } + + const text = buildAppStoreReleaseNotes(fs.readFileSync(SOURCE_FILE, 'utf8'), (line) => + console.error(`Dropping non-Apple-platform release note: ${line}`), + ); + + if (!text) { + console.error('Release notes are empty after processing; skipping.'); + process.exit(0); + } + + fs.writeFileSync(outFile, `${text}\n`, 'utf8'); + console.log(`Wrote App Store release notes (${text.length} chars) to ${outFile}`); +}; + +if (require.main === module) { + main(); } -fs.writeFileSync(outFile, `${text}\n`, 'utf8'); -console.log(`Wrote App Store release notes (${text.length} chars) to ${outFile}`); +module.exports = { + MAX_CHARS, + OTHER_PLATFORM_PATTERNS, + buildAppStoreReleaseNotes, + stripOtherPlatformLines, + toPlainText, + truncateToMaxChars, +}; diff --git a/tools/prepare-appstore-release-notes.test.js b/tools/prepare-appstore-release-notes.test.js new file mode 100644 index 0000000000..777b4cc937 --- /dev/null +++ b/tools/prepare-appstore-release-notes.test.js @@ -0,0 +1,147 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + buildAppStoreReleaseNotes, + stripOtherPlatformLines, + toPlainText, + truncateToMaxChars, +} = require('./prepare-appstore-release-notes'); + +test('strips bullets that name a non-Apple platform', () => { + const markdown = [ + '### Fixes', + '', + '- Keep the task list anchored when scheduling (#8533)', + '- Correct edge-to-edge IME inset on Android (#8508)', + '- Crisp, flicker-free Linux tray icon (#4905)', + '- Restore custom title bar on GNOME-X11 (#8485)', + ].join('\n'); + + const dropped = []; + const result = stripOtherPlatformLines(markdown, (line) => dropped.push(line)); + + assert.match(result, /Keep the task list anchored/); + assert.doesNotMatch(result, /Android/i); + assert.doesNotMatch(result, /Linux/i); + assert.doesNotMatch(result, /GNOME/i); + assert.equal(dropped.length, 3); +}); + +test('keeps Apple-platform bullets untouched', () => { + const markdown = [ + '### Fixes', + '', + '- Restore custom macOS title bar', + '- Fix iOS notification sound', + ].join('\n'); + + const result = stripOtherPlatformLines(markdown); + + assert.match(result, /macOS title bar/); + assert.match(result, /iOS notification sound/); +}); + +test('strips platform names in headings and intro prose, not only bullets', () => { + // The AI generation path can emit free-form headings/prose; a platform name + // anywhere (not just in a bullet) must be removed. + const markdown = [ + 'Big Android battery improvements land this release.', + '', + '### Linux', + '', + '- Fix the tray icon on Linux', + '', + '### Features', + '', + '- Add Focus Mode notifications', + ].join('\n'); + + const dropped = []; + const result = stripOtherPlatformLines(markdown, (line) => dropped.push(line)); + + assert.doesNotMatch(result, /android/i); + assert.doesNotMatch(result, /linux/i); + assert.match(result, /### Features/); + assert.match(result, /Add Focus Mode notifications/); + // intro prose + "### Linux" heading + its bullet. + assert.equal(dropped.length, 3); +}); + +test('removes a section heading left empty after stripping', () => { + const markdown = [ + '### Features', + '', + '- Add focus-mode session notifications', + '', + '### Fixes', + '', + '- Paint window decor behind WebView on Android', + ].join('\n'); + + const result = stripOtherPlatformLines(markdown); + + assert.match(result, /### Features/); + assert.match(result, /focus-mode session notifications/); + // The only "Fixes" bullet was Android-specific, so the heading must go too. + assert.doesNotMatch(result, /### Fixes/); + assert.doesNotMatch(result, /Android/i); +}); + +test('does not treat substrings (e.g. snapshot, windowing) as platforms', () => { + const markdown = [ + '### Fixes', + '', + '- Restore a snapshot after a failed sync', + '- Improve task windowing performance', + ].join('\n'); + + const dropped = []; + const result = stripOtherPlatformLines(markdown, (line) => dropped.push(line)); + + assert.match(result, /Restore a snapshot/); + assert.match(result, /task windowing/); + assert.equal(dropped.length, 0); +}); + +test('end-to-end: produces clean plain-text What’s New without platform names', () => { + // Mirrors the real build/release-notes.md shape that Apple rejected for 18.12.0. + const markdown = [ + 'For all current downloads, package links, and platform-specific notes: [check the wiki](https://example.com/wiki).', + '', + '### Features', + '', + '- **focus-mode:** surface session-done + notify on countdown completion (#8475)', + '- **plainspace:** add integration for shared projects (#8424)', + '', + '### Fixes', + '', + '- **android:** paint window decor behind WebView to kill keyboard white flash', + '- **tasks:** preserve manual order within a tag when sorting by tag (#8490)', + ].join('\n'); + + const text = buildAppStoreReleaseNotes(markdown); + + // Footer link and markdown stripped. + assert.doesNotMatch(text, /check the wiki/i); + assert.doesNotMatch(text, /\(https?:\/\//); + assert.doesNotMatch(text, /\*\*/); + // No third-party platform references survive. + assert.doesNotMatch(text, /android/i); + // Apple-relevant content is kept and bulletized (scope prefix retained). + assert.match(text, /• focus-mode: surface session-done/); + assert.match(text, /• tasks: preserve manual order within a tag/); +}); + +test('toPlainText converts markdown bullets/links/emphasis to plain text', () => { + const result = toPlainText('### Fixes\n\n- **scope:** fixed a [thing](https://x.y)'); + assert.equal(result, 'Fixes\n\n• scope: fixed a thing'); +}); + +test('truncateToMaxChars never splits a surrogate pair', () => { + const text = '😀😀😀😀'; + const result = truncateToMaxChars(text, 2); + // 1 emoji (2 code units) + ellipsis, not a lone surrogate. + assert.equal(result, '😀…'); + assert.doesNotMatch(result, /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/); +});