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 = `
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#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();