mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
build(release): add automated release notes
This commit is contained in:
parent
b12ee93a33
commit
17e664f2fc
7 changed files with 809 additions and 102 deletions
7
.github/workflows/build-android.yml
vendored
7
.github/workflows/build-android.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
602
tools/release-notes.js
Normal file
602
tools/release-notes.js
Normal file
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
181
tools/release-notes.test.js
Normal file
181
tools/release-notes.test.js
Normal file
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue