mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
fix(release-notes): strip non-Apple platform references from App Store What's New
App Review guideline 2.3.10 rejected the macOS 18.12.0 build because its What's New text mentioned Android (carried over from the shared GitHub changelog, which also lists Linux/GNOME desktop fixes). The Apple deliver prep stripped markdown but never filtered platform-specific lines, so the same rejection would recur on every release with an Android/Linux commit. Add stripOtherPlatformLines() to the App Store prep step: drop any changelog line naming a non-Apple platform (bullets, headings, and intro prose, so the AI generation path can't leak a name either) and remove any section heading left empty, logging each dropped line. The GitHub and Play Store changelogs are unaffected. Refactor the script to export its logic and add tests.
This commit is contained in:
parent
cfd84ff738
commit
762fd3baab
3 changed files with 273 additions and 31 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/<locale>/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,
|
||||
};
|
||||
|
|
|
|||
147
tools/prepare-appstore-release-notes.test.js
Normal file
147
tools/prepare-appstore-release-notes.test.js
Normal file
|
|
@ -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])/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue