mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07: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:
45
.github/workflows/auto-tag-release.yml
vendored
45
.github/workflows/auto-tag-release.yml
vendored
@@ -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
|
||||
|
||||
32
.github/workflows/release.yml
vendored
32
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
209
scripts/changelogWorkflow/generateChangelog.ts
Normal file
209
scripts/changelogWorkflow/generateChangelog.ts
Normal file
@@ -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