mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
👷 build(ci): fix changelog auto-generation in release workflow (#12763)
After auto-tag-release.yml was introduced, semantic-release in release.yml stopped working because the tag already exists when it runs. This caused CHANGELOG.md to never be updated. Fix: move changelog generation into auto-tag-release.yml with a custom script that parses git log and generates gitmoji-formatted entries, matching the existing CHANGELOG.md format. Remove the broken semantic-release step from release.yml.
This commit is contained in:
@@ -72,6 +72,23 @@ jobs:
|
|||||||
git checkout main
|
git checkout main
|
||||||
git pull --rebase origin 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)
|
- name: Resolve patch version (patch bump)
|
||||||
id: patch-version
|
id: patch-version
|
||||||
if: steps.patch.outputs.should_tag == 'true'
|
if: steps.patch.outputs.should_tag == 'true'
|
||||||
@@ -117,12 +134,10 @@ jobs:
|
|||||||
echo "✅ Tag v$VERSION does not exist, can create"
|
echo "✅ Tag v$VERSION does not exist, can create"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Bump package.json version (before tagging)
|
- name: Bump package.json version
|
||||||
if: env.SHOULD_TAG == 'true' && steps.check-tag.outputs.exists == 'false'
|
if: env.SHOULD_TAG == 'true' && steps.check-tag.outputs.exists == 'false'
|
||||||
id: bump-version
|
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ env.VERSION }}"
|
VERSION="${{ env.VERSION }}"
|
||||||
KIND="${{ env.KIND }}"
|
|
||||||
echo "📝 Bumping package.json version to: $VERSION"
|
echo "📝 Bumping package.json version to: $VERSION"
|
||||||
|
|
||||||
# Validate VERSION is strict semver before writing
|
# Validate VERSION is strict semver before writing
|
||||||
@@ -131,10 +146,6 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Configure git
|
|
||||||
git config --global user.name "lobehubbot"
|
|
||||||
git config --global user.email "i@lobehub.com"
|
|
||||||
|
|
||||||
# Update package.json using Node.js
|
# Update package.json using Node.js
|
||||||
node -e "
|
node -e "
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@@ -149,8 +160,26 @@ jobs:
|
|||||||
console.log('✅ package.json updated to', target);
|
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
|
# 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]"
|
COMMIT_MSG="🔖 chore(release): release version v$VERSION [skip ci]"
|
||||||
git commit -m "$COMMIT_MSG" || echo "Nothing to commit"
|
git commit -m "$COMMIT_MSG" || echo "Nothing to commit"
|
||||||
git push origin HEAD:main
|
git push origin HEAD:main
|
||||||
|
|||||||
@@ -66,38 +66,6 @@ jobs:
|
|||||||
- name: Test App
|
- name: Test App
|
||||||
run: bun run 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
|
- name: Workflow
|
||||||
run: bun run workflow:readme
|
run: bun run workflow:readme
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@
|
|||||||
"type-check:tsc": "tsc --noEmit",
|
"type-check:tsc": "tsc --noEmit",
|
||||||
"workflow:cdn": "tsx ./scripts/cdnWorkflow/index.ts",
|
"workflow:cdn": "tsx ./scripts/cdnWorkflow/index.ts",
|
||||||
"workflow:changelog": "tsx ./scripts/changelogWorkflow/index.ts",
|
"workflow:changelog": "tsx ./scripts/changelogWorkflow/index.ts",
|
||||||
|
"workflow:changelog:gen": "tsx ./scripts/changelogWorkflow/generateChangelog.ts",
|
||||||
"workflow:countCharters": "tsx scripts/countEnWord.ts",
|
"workflow:countCharters": "tsx scripts/countEnWord.ts",
|
||||||
"workflow:dbml": "tsx ./scripts/dbmlWorkflow/index.ts",
|
"workflow:dbml": "tsx ./scripts/dbmlWorkflow/index.ts",
|
||||||
"workflow:docs": "tsx ./scripts/docsWorkflow/index.ts",
|
"workflow:docs": "tsx ./scripts/docsWorkflow/index.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 = '<a name="readme-top"></a>\n\n# Changelog';
|
||||||
|
const BACK_TO_TOP = `<div align="right">
|
||||||
|
|
||||||
|
[](#readme-top)
|
||||||
|
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
interface Commit {
|
||||||
|
hash: string;
|
||||||
|
issues: string[];
|
||||||
|
scope: string;
|
||||||
|
subject: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypeConfig {
|
||||||
|
detail: string;
|
||||||
|
emoji: string;
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_MAP: Record<string, TypeConfig> = {
|
||||||
|
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<string, Commit[]> = {};
|
||||||
|
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, Commit[]>): 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, Commit[]>): 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})`,
|
||||||
|
'',
|
||||||
|
`<sup>Released on **${today}**</sup>`,
|
||||||
|
'',
|
||||||
|
formatSummarySection(groups),
|
||||||
|
'<br/>',
|
||||||
|
'',
|
||||||
|
'<details>',
|
||||||
|
'<summary><kbd>Improvements and Fixes</kbd></summary>',
|
||||||
|
'',
|
||||||
|
formatDetailSection(groups),
|
||||||
|
'</details>',
|
||||||
|
'',
|
||||||
|
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();
|
||||||
Reference in New Issue
Block a user