From 17e664f2fc3726a3dde8a2aeeb75b0010f320e0a Mon Sep 17 00:00:00 2001 From: johannesjo Date: Sat, 9 May 2026 23:54:43 +0200 Subject: [PATCH] build(release): add automated release notes --- .github/workflows/build-android.yml | 7 +- .github/workflows/build.yml | 8 +- .gitignore | 1 + package.json | 9 +- tools/bump-android-version.js | 103 +---- tools/release-notes.js | 602 ++++++++++++++++++++++++++++ tools/release-notes.test.js | 181 +++++++++ 7 files changed, 809 insertions(+), 102 deletions(-) create mode 100644 tools/release-notes.js create mode 100644 tools/release-notes.test.js diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 7bfb9c264b..b389d05dca 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -115,6 +115,10 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Prepare Google Play release notes + if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') + run: npm run release-notes:prepare-play-store + - name: Upload to Google Play Console if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5 @@ -122,6 +126,7 @@ jobs: serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} packageName: com.superproductivity.superproductivity releaseFiles: android/app/build/outputs/apk/play/release/*.apk - track: internal + tracks: internal status: completed inAppUpdatePriority: 2 + whatsNewDirectory: build/generated-release-notes/play-store diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 88dac414c0..b75c9d7910 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,12 @@ jobs: GH_REPOSITORY: ${{ github.repository }} GH_REF_NAME: ${{ github.ref_name }} run: | - PREV_TAG=$(git tag --sort=-v:refname | grep '^v' | sed -n '2p') + if echo "$GH_REF_NAME" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + PREV_TAG=$(git tag --merged HEAD --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -v "^${GH_REF_NAME}$" | sed -n '1p') + else + PREV_TAG=$(git tag --merged HEAD --sort=-v:refname | grep '^v' | grep -v "^${GH_REF_NAME}$" | sed -n '1p') + fi + { cat build/release-notes.md echo "" @@ -37,7 +42,6 @@ jobs: draft: true prerelease: ${{ contains(github.ref, 'RC') || contains(github.ref, 'beta') || contains(github.ref, 'alpha') }} body_path: /tmp/release-body.md - generate_release_notes: true linux-bin-and-snap-release: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 2901ce16cc..1cb5bd9444 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ src/app/**/*.js src/app/**/*.js.map # added build/hub +build/generated-release-notes/ electron/**/*.js !electron/scripts/bundle-preload.js electron/**/*.js.map diff --git a/package.json b/package.json index 8f6eab17d3..dbf4cd5bc1 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "sync:ios": "npx cap sync ios", "dist:ios:prod": "npm run buildFrontend:prodWeb && npm run sync:ios", "stats": "ng build --configuration production --source-map --stats-json && npx esbuild-visualizer --metadata .tmp/angular-dist/stats.json && xdg-open stats.html", - "test": "cross-env TZ='Europe/Berlin' ng test --watch=false && npm run test:tz:ci", + "test": "npm run release-notes:test && cross-env TZ='Europe/Berlin' ng test --watch=false && npm run test:tz:ci", "test:once": "cross-env TZ='Europe/Berlin' ng test --watch=false", "test:watch": "cross-env TZ='Europe/Berlin' ng test --browsers ChromeHeadless", "test:tz:ci": "npm run test:tz:la", @@ -132,6 +132,8 @@ "test:date-utils": "cross-env TZ='Europe/Berlin' ng test --watch=false --include='src/app/util/**/format-*.spec.ts' --include='src/app/util/**/date-*.spec.ts'", "test:date-utils:tz": "npm run test:date-utils && cross-env TZ='America/Los_Angeles' npm run test:date-utils && cross-env TZ='Asia/Tokyo' npm run test:date-utils", "version": "npm run prebuild && node ./tools/bump-android-version.js && git add -A", + "version:ai:codex": "cross-env SP_RELEASE_NOTES_AI=codex npm version", + "version:ai:claude": "cross-env SP_RELEASE_NOTES_AI=claude npm version", "prettier": "pretty-quick", "prettier:file": "prettier --write", "lint:file": "ng lint --lint-file-patterns", @@ -144,6 +146,11 @@ "test:shard:core": "cross-env TZ='Europe/Berlin' ng test --watch=false --include='src/app/core/**/*.spec.ts'", "test:shard:util": "cross-env TZ='Europe/Berlin' ng test --watch=false --include='src/app/util/**/*.spec.ts'", "checkFile": "node tools/check-file.js", + "release-notes:generate": "node ./tools/release-notes.js generate", + "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", "clean:translations": "node ./tools/clean-translations.js", "plugin-api:build": "cd packages/plugin-api && npm run build", "shared-schema:build": "cd packages/shared-schema && npm run build", diff --git a/tools/bump-android-version.js b/tools/bump-android-version.js index e3a2d18a8b..a565c61bce 100644 --- a/tools/bump-android-version.js +++ b/tools/bump-android-version.js @@ -1,19 +1,12 @@ const fs = require('fs'); const path = require('path'); +const { generateReleaseNotes, getAndroidVersionInfo } = require('./release-notes'); // Read the version from package.json const packageJson = require('../package.json'); const version = packageJson.version; - -// Parse version to extract pre-release info -const versionParts = version.split('-'); -const baseVersion = versionParts[0]; // e.g., "17.0.0" -const preRelease = versionParts[1]; // e.g., "RC.1" or undefined -const isPreRelease = !!preRelease; - -String.prototype.insertAt = function (index, string) { - return this.substr(0, index) + string + this.substr(index); -}; +const { isPreRelease, versionCode, versionCodeWithUnderscores } = + getAndroidVersionInfo(version); // Define the path to build.gradle const gradleFilePath = path.join(__dirname, '..', 'android', 'app', 'build.gradle'); @@ -21,34 +14,9 @@ const gradleFilePath = path.join(__dirname, '..', 'android', 'app', 'build.gradl // Read the build.gradle file let gradleFileContent = fs.readFileSync(gradleFilePath, 'utf8'); -// Calculate versionCode -const baseVersionCode = - baseVersion - .split('.') - .map((num) => num.padStart(2, '0')) - .join('') * 10000; - -let versionCodeDroid; -if (isPreRelease) { - // Pre-release: extract number (RC.1 → 1, alpha.5 → 5) - // Uses suffix 0001-8999 to be LOWER than stable (9000) - const preReleaseNum = parseInt(preRelease.split('.')[1] || '1', 10); - versionCodeDroid = baseVersionCode + preReleaseNum; -} else { - // Stable release: use suffix 9000 to be HIGHER than any RC - versionCodeDroid = baseVersionCode + 9000; -} - -const versionCodeDroidWithUnderscores = versionCodeDroid - .toString() - .padStart(10, '0') - .insertAt(6, '_') - .insertAt(4, '_') - .insertAt(2, '_'); - gradleFileContent = gradleFileContent.replace( /versionCode (\d|_)+/g, - `versionCode ${versionCodeDroidWithUnderscores}`, + `versionCode ${versionCodeWithUnderscores}`, ); gradleFileContent = gradleFileContent.replace( /versionName "[^"]+"/g, @@ -59,65 +27,4 @@ gradleFileContent = gradleFileContent.replace( fs.writeFileSync(gradleFilePath, gradleFileContent, 'utf8'); console.log(`Updated build.gradle to version ${version}`); - -// Skip fastlane changelog for pre-release versions -if (isPreRelease) { - console.log('Pre-release version – skipping fastlane changelog generation'); - process.exit(0); -} - -// CREATE fastlane changelog file -const { execFileSync } = require('child_process'); -const outputDir = path.join( - __dirname, - '..', - 'android', - 'fastlane', - 'metadata', - 'android', - 'en-US', - 'changelogs', -); -const outputFilePath = path.join(outputDir, `${versionCodeDroid}.txt`); - -// Get commit messages since the last tag. -// During `npm version`, the new tag does not exist yet, so -// `git describe --tags --abbrev=0 HEAD` gives us the previous release tag, -// which is exactly the start of the range we want. -let gitLog; -try { - const lastTag = execFileSync('git', ['describe', '--tags', '--abbrev=0', 'HEAD'], { - encoding: 'utf8', - }).trim(); - gitLog = execFileSync( - 'git', - ['log', `${lastTag}...HEAD`, '--no-merges', '--pretty=format:- %s'], - { encoding: 'utf8' }, - ); -} catch (err) { - console.warn(`Could not generate changelog from git tags: ${err.message}`); - console.warn('Falling back to last 20 commits'); - gitLog = execFileSync('git', ['log', '-20', '--no-merges', '--pretty=format:- %s'], { - encoding: 'utf8', - }); -} -// Strip conventional-commit prefixes (e.g. "feat(tasks): " → "") -let latestChanges = gitLog.replace(/^- \w+(\([^)]*\))?!?:\s*/gm, '- '); -// Truncate to 500 chars at line boundaries for Play Store limit -const lines = latestChanges.split('\n'); -let truncated = ''; -for (const line of lines) { - if ((truncated + line + '\n').length > 500) break; - truncated += line + '\n'; -} -latestChanges = truncated.trimEnd() || 'Bug fixes and improvements'; - -// Ensure the output directory exists -if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); -} - -// Write the latest changes to the versioned changelog file -fs.writeFileSync(outputFilePath, latestChanges, 'utf8'); - -console.log(`Wrote latest changes to ${outputFilePath}`); +generateReleaseNotes({ version, isPreRelease, versionCode }); diff --git a/tools/release-notes.js b/tools/release-notes.js new file mode 100644 index 0000000000..5a57ab7cc9 --- /dev/null +++ b/tools/release-notes.js @@ -0,0 +1,602 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const ROOT_DIR = path.join(__dirname, '..'); +const PLAY_STORE_MAX_CHARS = 500; +const DEFAULT_CHANGELOG = 'Bug fixes and improvements'; +const DOWNLOADS_NOTE = + 'For all current downloads, package links, and platform-specific notes: [check the wiki](https://github.com/super-productivity/super-productivity/wiki/2.01-Downloads-and-Install).'; + +const GENERATED_RELEASE_NOTES_DIR = path.join( + ROOT_DIR, + 'build', + 'generated-release-notes', +); +const PLAY_STORE_WHATS_NEW_DIR = path.join(GENERATED_RELEASE_NOTES_DIR, 'play-store'); +const PLAY_STORE_WHATS_NEW_FILE = path.join(PLAY_STORE_WHATS_NEW_DIR, 'whatsnew-en-US'); +const GITHUB_RELEASE_NOTES_FILE = path.join(ROOT_DIR, 'build', 'release-notes.md'); + +const USER_FACING_TYPES = new Set(['feat', 'fix', 'perf']); +const LOW_SIGNAL_TYPES = new Set([ + 'build', + 'chore', + 'ci', + 'docs', + 'refactor', + 'style', + 'test', +]); +const SUPPORTED_AI_PROVIDERS = new Set(['codex', 'claude']); +const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'none', 'off']); +const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']); +const GITHUB_GROUPS = [ + { + heading: 'Features', + isMatch: (commit) => commit.type === 'feat', + }, + { + heading: 'Fixes', + isMatch: (commit) => commit.type === 'fix', + }, + { + heading: 'Performance', + isMatch: (commit) => commit.type === 'perf', + }, + { + heading: 'Other Changes', + isMatch: (commit) => !commit.type || !USER_FACING_TYPES.has(commit.type), + }, +]; + +const RELEASE_NOTES_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['githubMarkdown', 'playStore'], + properties: { + githubMarkdown: { + type: 'string', + description: 'Markdown release notes for GitHub. Use concise grouped bullets.', + }, + playStore: { + type: 'string', + description: 'Plain-text Google Play release notes, max 500 characters.', + }, + }, +}; + +const parseCommitSubject = (subject) => { + const match = subject.match(/^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/); + if (!match) { + return { + type: null, + scope: null, + description: subject.trim(), + raw: subject.trim(), + }; + } + + return { + type: match[1].toLowerCase(), + scope: match[2] || null, + description: match[4].trim(), + raw: subject.trim(), + }; +}; + +const getAndroidVersionInfo = (version) => { + const versionParts = version.split('-'); + const baseVersion = versionParts[0]; + const preRelease = versionParts[1]; + const isPreRelease = !!preRelease; + const baseVersionCode = + Number( + baseVersion + .split('.') + .map((num) => num.padStart(2, '0')) + .join(''), + ) * 10000; + const preReleaseNum = isPreRelease + ? parseInt(preRelease.split('.')[1] || '1', 10) + : null; + const versionCode = isPreRelease + ? baseVersionCode + preReleaseNum + : baseVersionCode + 9000; + const versionCodeWithUnderscores = versionCode + .toString() + .padStart(10, '0') + .replace(/^(\d{2})(\d{2})(\d{2})(\d{4})$/, '$1_$2_$3_$4'); + + return { + baseVersion, + isPreRelease, + versionCode, + versionCodeWithUnderscores, + }; +}; + +const getPreviousTag = ({ version, stableOnly = false }) => + execFileSync('git', ['tag', '--merged', 'HEAD', '--sort=-v:refname'], { + encoding: 'utf8', + }) + .split('\n') + .find((tag) => { + const releaseTag = tag.trim(); + if (releaseTag === `v${version}`) { + return false; + } + return stableOnly ? /^v\d+\.\d+\.\d+$/.test(releaseTag) : /^v/.test(releaseTag); + }) + ?.trim(); + +const getCommitSubjectsSincePreviousTag = ({ version }) => { + try { + const lastTag = getPreviousTag({ version }); + if (!lastTag) { + throw new Error('No previous tag found'); + } + + const gitLog = execFileSync( + 'git', + ['log', `${lastTag}...HEAD`, '--no-merges', '--pretty=format:%s'], + { encoding: 'utf8' }, + ); + return gitLog.split('\n').filter(Boolean); + } catch (err) { + console.warn(`Could not generate changelog from git tags: ${err.message}`); + console.warn('Falling back to last 20 commits'); + const gitLog = execFileSync( + 'git', + ['log', '-20', '--no-merges', '--pretty=format:%s'], + { + encoding: 'utf8', + }, + ); + return gitLog.split('\n').filter(Boolean); + } +}; + +const getCommitSubjectsSinceReleaseBase = ({ isPreRelease, version }) => { + if (isPreRelease) { + return getCommitSubjectsSincePreviousTag({ version }); + } + + try { + const lastStableTag = getPreviousTag({ version, stableOnly: true }); + if (!lastStableTag) { + throw new Error('No previous stable tag found'); + } + + const gitLog = execFileSync( + 'git', + ['log', `${lastStableTag}...HEAD`, '--no-merges', '--pretty=format:%s'], + { encoding: 'utf8' }, + ); + return gitLog.split('\n').filter(Boolean); + } catch (err) { + console.warn(`Could not generate changelog from stable tags: ${err.message}`); + return getCommitSubjectsSincePreviousTag({ version }); + } +}; + +const uniqueByDescription = (commits) => { + const seen = new Set(); + const result = []; + + for (const commit of commits) { + const key = commit.description.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(commit); + } + + return result; +}; + +const toBulletLines = (commits) => + commits.map((commit) => { + const scope = commit.scope ? `**${commit.scope}:** ` : ''; + return `- ${scope}${commit.description}`; + }); + +const toGroupedGithubMarkdown = (commits) => { + const sections = []; + + for (const group of GITHUB_GROUPS) { + const groupCommits = commits.filter(group.isMatch); + if (groupCommits.length === 0) { + continue; + } + sections.push(`### ${group.heading}\n\n${toBulletLines(groupCommits).join('\n')}`); + } + + return sections.join('\n\n') || `### Highlights\n\n- ${DEFAULT_CHANGELOG}`; +}; + +const getUserFacingCommits = (commits) => { + const userFacing = commits.filter((commit) => { + if (commit.type && USER_FACING_TYPES.has(commit.type)) { + return true; + } + return !commit.type || !LOW_SIGNAL_TYPES.has(commit.type); + }); + + return userFacing.length > 0 ? userFacing : commits; +}; + +const truncateAtLineBoundary = (text, maxChars) => { + const lines = text.split('\n'); + let truncated = ''; + + for (const line of lines) { + const next = truncated ? `${truncated}\n${line}` : line; + if (next.length > maxChars) { + break; + } + truncated = next; + } + + if (truncated) { + return truncated; + } + + return text.slice(0, maxChars).trim(); +}; + +const toPlainTextBullets = (commits, maxChars = PLAY_STORE_MAX_CHARS) => { + const text = commits + .map((commit) => `- ${commit.description}`) + .join('\n') + .trim(); + + return truncateAtLineBoundary(text || DEFAULT_CHANGELOG, maxChars) || DEFAULT_CHANGELOG; +}; + +const normalizePlayStoreText = (text) => + truncateAtLineBoundary( + text + .replace(/\r\n/g, '\n') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .trim() || DEFAULT_CHANGELOG, + PLAY_STORE_MAX_CHARS, + ) || DEFAULT_CHANGELOG; + +const writeFileEnsuringDir = (filePath, content) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); +}; + +const readLineSync = () => { + const buffer = Buffer.alloc(1); + const chars = []; + + while (true) { + const bytesRead = fs.readSync(0, buffer, 0, 1, null); + if (bytesRead === 0) { + break; + } + + const char = buffer.toString('utf8', 0, bytesRead); + if (char === '\n') { + break; + } + if (char !== '\r') { + chars.push(char); + } + } + + return chars.join(''); +}; + +const promptGenerateReleaseNotesViaAi = () => { + fs.writeSync(1, 'Generate release notes via AI? [Y/n] '); + return readLineSync(); +}; + +const getDefaultAiProvider = (env = process.env) => + env.SP_RELEASE_NOTES_AI_PROVIDER || 'codex'; + +const resolveAiProvider = ({ + env = process.env, + isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY), + lifecycleEvent = env.npm_lifecycle_event, + prompt = promptGenerateReleaseNotesViaAi, +} = {}) => { + const explicitProvider = env.SP_RELEASE_NOTES_AI?.trim().toLowerCase(); + + if (explicitProvider) { + if (FALSE_ENV_VALUES.has(explicitProvider)) { + return null; + } + if (TRUE_ENV_VALUES.has(explicitProvider)) { + return getDefaultAiProvider(env); + } + return explicitProvider; + } + + if (lifecycleEvent !== 'version' || !isInteractive) { + return null; + } + + const answer = prompt().trim().toLowerCase(); + if (answer === '' || answer === 'y' || answer === 'yes') { + return getDefaultAiProvider(env); + } + if (answer === 'n' || answer === 'no') { + return null; + } + + console.warn(`Unknown answer "${answer}" - using deterministic release notes`); + return null; +}; + +const runCodexReleaseNotes = ({ prompt }) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-release-notes-')); + const schemaFile = path.join(tmpDir, 'schema.json'); + const outputFile = path.join(tmpDir, 'release-notes.json'); + + fs.writeFileSync(schemaFile, JSON.stringify(RELEASE_NOTES_SCHEMA), 'utf8'); + + execFileSync( + 'codex', + [ + '--ask-for-approval', + 'never', + 'exec', + '--sandbox', + 'read-only', + '--ephemeral', + '--output-schema', + schemaFile, + '--output-last-message', + outputFile, + '-', + ], + { + cwd: ROOT_DIR, + encoding: 'utf8', + input: prompt, + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10 * 60 * 1000, + maxBuffer: 10 * 1024 * 1024, + }, + ); + + return fs.readFileSync(outputFile, 'utf8'); +}; + +const runClaudeReleaseNotes = ({ prompt }) => + execFileSync( + 'claude', + [ + '-p', + '--tools', + '', + '--output-format', + 'text', + '--json-schema', + JSON.stringify(RELEASE_NOTES_SCHEMA), + prompt, + ], + { + cwd: ROOT_DIR, + encoding: 'utf8', + timeout: 10 * 60 * 1000, + maxBuffer: 10 * 1024 * 1024, + }, + ); + +const parseAiResponse = (response) => { + const jsonText = response.trim().replace(/^```(?:json)?\n?|\n?```$/g, ''); + const parsed = JSON.parse(jsonText); + + if ( + typeof parsed.githubMarkdown !== 'string' || + typeof parsed.playStore !== 'string' || + parsed.githubMarkdown.trim().length === 0 || + parsed.playStore.trim().length === 0 + ) { + throw new Error('AI release notes response did not match the expected shape'); + } + + return { + githubMarkdown: parsed.githubMarkdown.trim(), + playStore: normalizePlayStoreText(parsed.playStore), + }; +}; + +const buildAiPrompt = ({ + version, + commits, + deterministicGithubMarkdown, + playStoreText, +}) => `You are writing release notes for Super Productivity, an open-source todo and time-tracking app. + +Rewrite the raw commit subjects into user-facing release notes. + +Rules: +- Do not invent features, fixes, issue numbers, platforms, or claims. +- Prefer user-facing changes over internal build/test/refactor details. +- Keep GitHub notes concise and grouped with markdown headings and bullets. +- Keep Play Store notes plain text, no markdown links, max ${PLAY_STORE_MAX_CHARS} characters. +- Return JSON only, matching the provided schema. + +Version: ${version} + +Raw commits: +${JSON.stringify(commits, null, 2)} + +Deterministic fallback GitHub notes: +${deterministicGithubMarkdown} + +Deterministic fallback Play Store notes: +${playStoreText} +`; + +const getAiReleaseNotes = ({ + version, + commits, + deterministicGithubMarkdown, + playStoreText, +}) => { + const provider = resolveAiProvider(); + if (!provider) { + return null; + } + + if (!SUPPORTED_AI_PROVIDERS.has(provider)) { + const message = `Unsupported release notes AI provider "${provider}". Expected codex or claude.`; + if (process.env.SP_RELEASE_NOTES_AI_REQUIRED === '1') { + throw new Error(message); + } + console.warn(message); + console.warn('Falling back to deterministic release notes'); + return null; + } + + const prompt = buildAiPrompt({ + version, + commits, + deterministicGithubMarkdown, + playStoreText, + }); + + try { + const response = + provider === 'claude' + ? runClaudeReleaseNotes({ prompt }) + : runCodexReleaseNotes({ prompt }); + const aiNotes = parseAiResponse(response); + console.log(`Using ${provider} generated release notes`); + return aiNotes; + } catch (err) { + const message = `Could not generate AI release notes with ${provider}: ${err.message}`; + if (process.env.SP_RELEASE_NOTES_AI_REQUIRED === '1') { + throw new Error(message); + } + console.warn(message); + console.warn('Falling back to deterministic release notes'); + return null; + } +}; + +const getAndroidFastlaneChangelogFile = (versionCode) => + path.join( + ROOT_DIR, + 'android', + 'fastlane', + 'metadata', + 'android', + 'en-US', + 'changelogs', + `${versionCode}.txt`, + ); + +const getLegacyFastlaneChangelogFile = (versionCode) => + path.join( + ROOT_DIR, + 'fastlane', + 'metadata', + 'android', + 'en-US', + 'changelogs', + `${versionCode}.txt`, + ); + +const generateReleaseNotes = ({ version, versionCode, isPreRelease }) => { + const commits = uniqueByDescription( + getCommitSubjectsSinceReleaseBase({ isPreRelease, version }).map(parseCommitSubject), + ); + const releaseCommits = + commits.length > 0 ? commits : [parseCommitSubject(DEFAULT_CHANGELOG)]; + const userFacingCommits = getUserFacingCommits(releaseCommits); + const deterministicGithubMarkdown = toGroupedGithubMarkdown(userFacingCommits); + const deterministicPlayStoreText = toPlainTextBullets(userFacingCommits); + const aiReleaseNotes = getAiReleaseNotes({ + version, + commits: releaseCommits, + deterministicGithubMarkdown, + playStoreText: deterministicPlayStoreText, + }); + const githubReleaseNotes = `${DOWNLOADS_NOTE} + +${aiReleaseNotes?.githubMarkdown || deterministicGithubMarkdown} +`; + + writeFileEnsuringDir(GITHUB_RELEASE_NOTES_FILE, githubReleaseNotes); + console.log(`Wrote GitHub release notes to ${GITHUB_RELEASE_NOTES_FILE}`); + + if (isPreRelease) { + console.log('Pre-release version - skipping Play Store changelog generation'); + return; + } + + const playStoreChangelog = aiReleaseNotes?.playStore || deterministicPlayStoreText; + const androidFastlaneChangelogFile = getAndroidFastlaneChangelogFile(versionCode); + writeFileEnsuringDir(androidFastlaneChangelogFile, playStoreChangelog); + + console.log( + `Wrote Android changelog for ${version} to ${androidFastlaneChangelogFile}`, + ); +}; + +const preparePlayStoreReleaseNotes = ({ versionCode }) => { + const candidates = [ + getAndroidFastlaneChangelogFile(versionCode), + getLegacyFastlaneChangelogFile(versionCode), + ]; + const source = candidates.find((candidate) => fs.existsSync(candidate)); + if (!source) { + throw new Error(`No versioned Play Store changelog found for ${versionCode}`); + } + + const sourceText = fs.readFileSync(source, 'utf8'); + const playStoreChangelog = + truncateAtLineBoundary( + sourceText.trim() || DEFAULT_CHANGELOG, + PLAY_STORE_MAX_CHARS, + ) || DEFAULT_CHANGELOG; + + writeFileEnsuringDir(PLAY_STORE_WHATS_NEW_FILE, playStoreChangelog); + + console.log(`Prepared Google Play release notes from ${source}`); + console.log(`Wrote Google Play release notes to ${PLAY_STORE_WHATS_NEW_FILE}`); +}; + +const getCurrentPackageVersion = () => + JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'package.json'), 'utf8')).version; + +if (require.main === module) { + const command = process.argv[2] || 'generate'; + const version = getCurrentPackageVersion(); + const versionInfo = getAndroidVersionInfo(version); + + if (command === 'generate') { + generateReleaseNotes({ version, ...versionInfo }); + } else if (command === 'prepare-play-store') { + preparePlayStoreReleaseNotes(versionInfo); + } else { + console.error(`Unknown release notes command: ${command}`); + process.exit(1); + } +} + +module.exports = { + getAndroidVersionInfo, + generateReleaseNotes, + preparePlayStoreReleaseNotes, + __test: { + getUserFacingCommits, + normalizePlayStoreText, + parseAiResponse, + parseCommitSubject, + resolveAiProvider, + toGroupedGithubMarkdown, + toPlainTextBullets, + truncateAtLineBoundary, + uniqueByDescription, + }, +}; diff --git a/tools/release-notes.test.js b/tools/release-notes.test.js new file mode 100644 index 0000000000..d9132f0389 --- /dev/null +++ b/tools/release-notes.test.js @@ -0,0 +1,181 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { __test, getAndroidVersionInfo } = require('./release-notes'); + +const parse = (subject) => __test.parseCommitSubject(subject); + +test('calculates stable and pre-release Android version codes', () => { + assert.deepEqual(getAndroidVersionInfo('18.5.0'), { + baseVersion: '18.5.0', + isPreRelease: false, + versionCode: 1805009000, + versionCodeWithUnderscores: '18_05_00_9000', + }); + + assert.deepEqual(getAndroidVersionInfo('18.6.0-RC.2'), { + baseVersion: '18.6.0', + isPreRelease: true, + versionCode: 1806000002, + versionCodeWithUnderscores: '18_06_00_0002', + }); +}); + +test('parses conventional and plain commit subjects', () => { + assert.deepEqual(parse('fix(sync): keep Dropbox refresh token'), { + type: 'fix', + scope: 'sync', + description: 'keep Dropbox refresh token', + raw: 'fix(sync): keep Dropbox refresh token', + }); + + assert.deepEqual(parse('plain release note'), { + type: null, + scope: null, + description: 'plain release note', + raw: 'plain release note', + }); +}); + +test('keeps first duplicate release-note description only', () => { + const commits = [ + parse('fix(sync): Avoid duplicate task import'), + parse('fix(tasks): avoid duplicate task import'), + parse('feat(theme): add blur slider'), + ]; + + assert.deepEqual( + __test.uniqueByDescription(commits).map((commit) => commit.raw), + ['fix(sync): Avoid duplicate task import', 'feat(theme): add blur slider'], + ); +}); + +test('filters to user-facing commits and falls back when only internal commits exist', () => { + const mixedCommits = [ + parse('test(sync): stabilize flaky test'), + parse('build(release): add automated release notes'), + parse('fix(sync): repair archive hydration'), + parse('docs: update wiki'), + ]; + assert.deepEqual( + __test.getUserFacingCommits(mixedCommits).map((commit) => commit.raw), + ['fix(sync): repair archive hydration'], + ); + + const internalCommits = [ + parse('test(sync): stabilize flaky test'), + parse('build(release): add automated release notes'), + parse('docs: update wiki'), + ]; + assert.deepEqual( + __test.getUserFacingCommits(internalCommits).map((commit) => commit.raw), + [ + 'test(sync): stabilize flaky test', + 'build(release): add automated release notes', + 'docs: update wiki', + ], + ); +}); + +test('groups GitHub markdown by semantic commit type', () => { + const markdown = __test.toGroupedGithubMarkdown([ + parse('feat(theme): add blur slider'), + parse('fix(sync): repair archive hydration'), + parse('perf(android): prewarm WebView'), + parse('docs: update wiki'), + ]); + + assert.match(markdown, /### Features\n\n- \*\*theme:\*\* add blur slider/); + assert.match(markdown, /### Fixes\n\n- \*\*sync:\*\* repair archive hydration/); + assert.match(markdown, /### Performance\n\n- \*\*android:\*\* prewarm WebView/); + assert.match(markdown, /### Other Changes\n\n- update wiki/); +}); + +test('normalizes Play Store text and enforces the character limit', () => { + const normalized = __test.normalizePlayStoreText( + `- Fixed [sync](https://example.com) issues\r\n${'x'.repeat(600)}`, + ); + + assert.equal(normalized, '- Fixed sync issues'); + assert.ok(normalized.length <= 500); +}); + +test('parses fenced AI JSON and constrains Play Store output', () => { + const response = `\`\`\`json +${JSON.stringify({ + githubMarkdown: '### Fixes\n\n- Fixed sync', + playStore: `- Fixed [sync](https://example.com)\n${'x'.repeat(600)}`, +})} +\`\`\``; + + const parsed = __test.parseAiResponse(response); + + assert.equal(parsed.githubMarkdown, '### Fixes\n\n- Fixed sync'); + assert.equal(parsed.playStore, '- Fixed sync'); + assert.ok(parsed.playStore.length <= 500); +}); + +test('resolves explicit AI provider environment values', () => { + assert.equal( + __test.resolveAiProvider({ + env: { SP_RELEASE_NOTES_AI: 'claude' }, + isInteractive: false, + }), + 'claude', + ); + assert.equal( + __test.resolveAiProvider({ + env: { SP_RELEASE_NOTES_AI: 'false' }, + isInteractive: true, + lifecycleEvent: 'version', + prompt: () => '', + }), + null, + ); + assert.equal( + __test.resolveAiProvider({ + env: { SP_RELEASE_NOTES_AI: '1', SP_RELEASE_NOTES_AI_PROVIDER: 'claude' }, + isInteractive: false, + }), + 'claude', + ); +}); + +test('prompts during npm version only and defaults to AI on enter', () => { + assert.equal( + __test.resolveAiProvider({ + env: {}, + isInteractive: true, + lifecycleEvent: 'version', + prompt: () => '', + }), + 'codex', + ); + assert.equal( + __test.resolveAiProvider({ + env: {}, + isInteractive: true, + lifecycleEvent: 'version', + prompt: () => 'n', + }), + null, + ); + assert.equal( + __test.resolveAiProvider({ + env: {}, + isInteractive: true, + lifecycleEvent: 'release-notes:generate', + prompt: () => '', + }), + null, + ); + assert.equal( + __test.resolveAiProvider({ + env: {}, + isInteractive: false, + lifecycleEvent: 'version', + prompt: () => '', + }), + null, + ); +});