diff --git a/.github/workflows/auto-tag-release.yml b/.github/workflows/auto-tag-release.yml
index a5b0c85016..640253733d 100644
--- a/.github/workflows/auto-tag-release.yml
+++ b/.github/workflows/auto-tag-release.yml
@@ -72,6 +72,23 @@ jobs:
git checkout main
git pull --rebase origin main
+ - name: Setup Node.js
+ if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24.11.1
+ package-manager-cache: false
+
+ - name: Install bun
+ if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install deps
+ if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
+ run: bun i
+
- name: Resolve patch version (patch bump)
id: patch-version
if: steps.patch.outputs.should_tag == 'true'
@@ -117,12 +134,10 @@ jobs:
echo "✅ Tag v$VERSION does not exist, can create"
fi
- - name: Bump package.json version (before tagging)
+ - name: Bump package.json version
if: env.SHOULD_TAG == 'true' && steps.check-tag.outputs.exists == 'false'
- id: bump-version
run: |
VERSION="${{ env.VERSION }}"
- KIND="${{ env.KIND }}"
echo "📝 Bumping package.json version to: $VERSION"
# Validate VERSION is strict semver before writing
@@ -131,10 +146,6 @@ jobs:
exit 1
fi
- # Configure git
- git config --global user.name "lobehubbot"
- git config --global user.email "i@lobehub.com"
-
# Update package.json using Node.js
node -e "
const fs = require('fs');
@@ -149,8 +160,26 @@ jobs:
console.log('✅ package.json updated to', target);
"
+ - name: Generate changelog
+ if: env.SHOULD_TAG == 'true' && steps.check-tag.outputs.exists == 'false'
+ run: bun run workflow:changelog:gen
+
+ - name: Build static changelog
+ if: env.SHOULD_TAG == 'true' && steps.check-tag.outputs.exists == 'false'
+ run: bun run workflow:changelog
+
+ - name: Commit release changes and push
+ if: env.SHOULD_TAG == 'true' && steps.check-tag.outputs.exists == 'false'
+ id: bump-version
+ run: |
+ VERSION="${{ env.VERSION }}"
+
+ # Configure git
+ git config --global user.name "lobehubbot"
+ git config --global user.email "i@lobehub.com"
+
# Commit changes (if any) and push
- git add package.json
+ git add package.json CHANGELOG.md changelog/
COMMIT_MSG="🔖 chore(release): release version v$VERSION [skip ci]"
git commit -m "$COMMIT_MSG" || echo "Nothing to commit"
git push origin HEAD:main
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 08005e067d..2c4af5fde0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -66,38 +66,6 @@ jobs:
- name: Test App
run: bun run test-app
- - name: Extract version from tag
- id: get-version
- run: |
- # Extract version from github.ref (refs/tags/v1.0.0 -> 1.0.0)
- VERSION=${GITHUB_REF#refs/tags/v}
- echo "version=$VERSION" >> $GITHUB_OUTPUT
- echo "📦 Release version: v$VERSION"
-
- - name: Verify package.json version matches tag
- run: |
- VERSION="${{ steps.get-version.outputs.version }}"
- echo "🔎 Checking package.json version equals tag: $VERSION"
- node -e "
- const fs = require('fs');
- const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
- const expected = '$VERSION';
- const actual = pkg.version;
- if (actual !== expected) {
- console.error('❌ Version mismatch: package.json=' + actual + ' tag=' + expected);
- process.exit(1);
- }
- console.log('✅ Version OK:', actual);
- "
-
- - name: Release
- run: bun run release
- env:
- GH_TOKEN: ${{ secrets.GH_TOKEN }}
- NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- # Pass version to semantic-release
- SEMANTIC_RELEASE_VERSION: ${{ steps.get-version.outputs.version }}
-
- name: Workflow
run: bun run workflow:readme
diff --git a/package.json b/package.json
index 3de1efe45a..9e5b60b53e 100644
--- a/package.json
+++ b/package.json
@@ -110,6 +110,7 @@
"type-check:tsc": "tsc --noEmit",
"workflow:cdn": "tsx ./scripts/cdnWorkflow/index.ts",
"workflow:changelog": "tsx ./scripts/changelogWorkflow/index.ts",
+ "workflow:changelog:gen": "tsx ./scripts/changelogWorkflow/generateChangelog.ts",
"workflow:countCharters": "tsx scripts/countEnWord.ts",
"workflow:dbml": "tsx ./scripts/dbmlWorkflow/index.ts",
"workflow:docs": "tsx ./scripts/docsWorkflow/index.ts",
diff --git a/scripts/changelogWorkflow/generateChangelog.ts b/scripts/changelogWorkflow/generateChangelog.ts
new file mode 100644
index 0000000000..dfb302eeae
--- /dev/null
+++ b/scripts/changelogWorkflow/generateChangelog.ts
@@ -0,0 +1,209 @@
+import { execSync } from 'node:child_process';
+import { readFileSync, writeFileSync } from 'node:fs';
+import path from 'node:path';
+
+import { consola } from 'consola';
+
+const REPO_URL = 'https://github.com/lobehub/lobe-chat';
+const CHANGELOG_TITLE = '\n\n# Changelog';
+const BACK_TO_TOP = `
+
+[](#readme-top)
+
+
`;
+
+interface Commit {
+ hash: string;
+ issues: string[];
+ scope: string;
+ subject: string;
+ type: string;
+}
+
+interface TypeConfig {
+ detail: string;
+ emoji: string;
+ summary: string;
+}
+
+const TYPE_MAP: Record = {
+ feat: { emoji: '✨', summary: 'Features', detail: "What's improved" },
+ fix: { emoji: '🐛', summary: 'Bug Fixes', detail: "What's fixed" },
+ hotfix: { emoji: '🐛', summary: 'Bug Fixes', detail: "What's fixed" },
+ perf: { emoji: '⚡', summary: 'Performance Improvements', detail: 'Performance Improvements' },
+ style: { emoji: '💄', summary: 'Styles', detail: 'Styles' },
+ refactor: { emoji: '♻️', summary: 'Code Refactoring', detail: 'Code Refactoring' },
+ build: { emoji: '👷', summary: 'Build System', detail: 'Build System' },
+};
+
+const git = (cmd: string) => execSync(`git ${cmd}`, { encoding: 'utf8' }).trim();
+
+const getLastTag = (): string | null => {
+ try {
+ return git('describe --tags --abbrev=0 HEAD');
+ } catch {
+ return null;
+ }
+};
+
+const getPreviousTag = (currentTag: string): string | null => {
+ try {
+ return git(`describe --tags --abbrev=0 ${currentTag}^`);
+ } catch {
+ return null;
+ }
+};
+
+const getCommits = (from: string | null): Commit[] => {
+ const range = from ? `${from}..HEAD` : 'HEAD';
+ const SEP = '---COMMIT_SEP---';
+ let log: string;
+ try {
+ log = git(`log ${range} --format="%H %s${SEP}"`);
+ } catch {
+ return [];
+ }
+
+ const commits: Commit[] = [];
+ for (const entry of log.split(SEP)) {
+ const line = entry.trim();
+ if (!line) continue;
+
+ const spaceIdx = line.indexOf(' ');
+ if (spaceIdx === -1) continue;
+ const hash = line.slice(0, spaceIdx);
+ let subject = line.slice(spaceIdx + 1);
+
+ // Strip leading gitmoji (unicode emoji or :shortcode: format)
+ subject = subject
+ .replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+\s*/u, '')
+ .replace(/^:[a-z_]+:\s*/i, '');
+
+ // Parse conventional commit: type(scope): message
+ const match = subject.match(/^(\w+)(?:\(([^)]*)\))?:\s*(.+)/);
+ if (!match) continue;
+
+ const [, type, scope, msg] = match;
+ if (!TYPE_MAP[type]) continue;
+
+ // Extract issue references
+ const issues: string[] = [];
+ const issueRe = /#(\d+)/g;
+ let m: RegExpExecArray | null;
+ while ((m = issueRe.exec(msg)) !== null) {
+ issues.push(m[1]);
+ }
+
+ // Strip issue refs from subject: "closes #123", "(#123)"
+ const cleanSubject = msg
+ .replaceAll(/,?\s*closes?\s+#\d+/gi, '')
+ .replaceAll(/\s*\(#\d+\)/g, '')
+ .trim();
+
+ commits.push({
+ hash: hash.slice(0, 7),
+ issues,
+ scope: scope || 'misc',
+ subject: cleanSubject,
+ type,
+ });
+ }
+
+ return commits;
+};
+
+const groupByType = (commits: Commit[]) => {
+ const groups: Record = {};
+ for (const commit of commits) {
+ const key = commit.type === 'hotfix' ? 'fix' : commit.type;
+ if (!groups[key]) groups[key] = [];
+ groups[key].push(commit);
+ }
+ return groups;
+};
+
+const formatSummarySection = (groups: Record): string => {
+ const sections: string[] = [];
+ for (const [type, commits] of Object.entries(groups)) {
+ const cfg = TYPE_MAP[type];
+ if (!cfg) continue;
+ sections.push(`#### ${cfg.emoji} ${cfg.summary}\n`);
+ for (const c of commits) {
+ sections.push(`- **${c.scope}**: ${c.subject}.`);
+ }
+ sections.push('');
+ }
+ return sections.join('\n');
+};
+
+const formatDetailSection = (groups: Record): string => {
+ const sections: string[] = [];
+ for (const [type, commits] of Object.entries(groups)) {
+ const cfg = TYPE_MAP[type];
+ if (!cfg) continue;
+ sections.push(`#### ${cfg.detail}\n`);
+ for (const c of commits) {
+ const closes = c.issues.map((i) => `closes [#${i}](${REPO_URL}/issues/${i})`).join(', ');
+ const ref = `([${c.hash}](${REPO_URL}/commit/${c.hash}))`;
+ const suffix = [closes, ref].filter(Boolean).join(' ');
+ sections.push(`- **${c.scope}**: ${c.subject}${closes ? ', ' : ' '}${suffix}`);
+ }
+ sections.push('');
+ }
+ return sections.join('\n');
+};
+
+const run = () => {
+ const root = path.resolve(__dirname, '../..');
+ const pkgPath = path.resolve(root, 'package.json');
+ const changelogPath = path.resolve(root, 'CHANGELOG.md');
+ const version = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
+ const today = new Date().toISOString().split('T')[0];
+
+ const lastTag = getLastTag();
+ const prevTag = lastTag ? getPreviousTag(lastTag) : null;
+ const fromTag = lastTag ?? prevTag;
+
+ const commits = getCommits(fromTag);
+
+ if (commits.length === 0) {
+ consola.warn('No conventional commits found since last tag, skipping changelog generation');
+ return;
+ }
+
+ const groups = groupByType(commits);
+
+ // Determine heading level: minor (feat) -> ##, patch -> ###
+ const headingLevel = groups['feat'] ? '##' : '###';
+ const compareUrl = fromTag
+ ? `${REPO_URL}/compare/${fromTag}...v${version}`
+ : `${REPO_URL}/releases/tag/v${version}`;
+
+ const entry = [
+ `${headingLevel} [Version ${version}](${compareUrl})`,
+ '',
+ `Released on **${today}**`,
+ '',
+ formatSummarySection(groups),
+ '
',
+ '',
+ '',
+ 'Improvements and Fixes
',
+ '',
+ formatDetailSection(groups),
+ ' ',
+ '',
+ BACK_TO_TOP,
+ ].join('\n');
+
+ const currentFile = readFileSync(changelogPath, 'utf8').trim();
+ const currentContent = currentFile.startsWith(CHANGELOG_TITLE)
+ ? currentFile.slice(CHANGELOG_TITLE.length).trim()
+ : currentFile;
+
+ const newContent = `${CHANGELOG_TITLE}\n\n${entry}\n\n${currentContent}\n`;
+ writeFileSync(changelogPath, newContent);
+ consola.success(`Changelog updated for v${version}`);
+};
+
+run();