🔨 chore: add auto-tag release workflow and interactive release script (#12236)

* init

* add missing deps
This commit is contained in:
Innei 2026-02-11 12:43:43 +08:00 committed by GitHub
parent a83dc4d4ed
commit 07f9c2a6a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 454 additions and 5 deletions

108
.github/workflows/auto-tag-release.yml vendored Normal file
View file

@ -0,0 +1,108 @@
name: Auto Tag Release
permissions:
contents: write
on:
pull_request:
types: [closed]
branches:
- main
jobs:
auto-tag:
name: Auto Tag Release
runs-on: ubuntu-latest
# Only trigger when PR is merged
if: github.event.pull_request.merged == true
steps:
- name: Checkout
uses: actions/checkout@v6
with:
token: ${{ secrets.GH_TOKEN }}
# Fetch full history for proper tagging
fetch-depth: 0
- name: Check and extract version from PR title
id: extract-version
run: |
PR_TITLE="${{ github.event.pull_request.title }}"
echo "PR Title: $PR_TITLE"
# Match "🚀 release: v{x.x.x}" format
if [[ "$PR_TITLE" =~ ^🚀[[:space:]]+release:[[:space:]]*v([0-9]+\.[0-9]+\.[0-9]+.*)$ ]]; then
VERSION="${BASH_REMATCH[1]}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "should_tag=true" >> $GITHUB_OUTPUT
echo "✅ Detected release PR, version: v$VERSION"
else
echo "should_tag=false" >> $GITHUB_OUTPUT
echo "⏭️ Not a release PR, skipping tag creation"
fi
- name: Check if tag already exists
if: steps.extract-version.outputs.should_tag == 'true'
id: check-tag
run: |
VERSION="${{ steps.extract-version.outputs.version }}"
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "⚠️ Tag v$VERSION already exists"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "✅ Tag v$VERSION does not exist, can create"
fi
- name: Create Tag
if: steps.extract-version.outputs.should_tag == 'true' && steps.check-tag.outputs.exists == 'false'
run: |
VERSION="${{ steps.extract-version.outputs.version }}"
echo "🏷️ Creating tag: v$VERSION"
# Configure git
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
# Get PR merge commit SHA
MERGE_SHA="${{ github.event.pull_request.merge_commit_sha }}"
# Create annotated tag with single line message
git tag -a "v$VERSION" "$MERGE_SHA" -m "🚀 release: v$VERSION | PR #${{ github.event.pull_request.number }} | Author: ${{ github.event.pull_request.user.login }}"
# Push tag
git push origin "v$VERSION"
echo "✅ Tag v$VERSION created successfully!"
- name: Create GitHub Release
if: steps.extract-version.outputs.should_tag == 'true' && steps.check-tag.outputs.exists == 'false'
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ steps.extract-version.outputs.version }}
name: 🚀 Release v${{ steps.extract-version.outputs.version }}
body: |
## 📦 Release v${{ steps.extract-version.outputs.version }}
This release was automatically published from PR #${{ github.event.pull_request.number }}.
### Changes
See PR description: ${{ github.event.pull_request.html_url }}
### Commit Message
${{ github.event.pull_request.body }}
draft: false
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Output result
run: |
if [ "${{ steps.extract-version.outputs.should_tag }}" == "true" ]; then
if [ "${{ steps.check-tag.outputs.exists }}" == "true" ]; then
echo "⚠️ Result: Tag v${{ steps.extract-version.outputs.version }} already exists, skipping creation"
else
echo "✅ Result: Tag v${{ steps.extract-version.outputs.version }} created successfully!"
fi
else
echo " Result: Not a release PR, no tag created"
fi

View file

@ -7,9 +7,8 @@ permissions:
on:
push:
branches:
- main
- next
tags:
- 'v*.*.*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -18,7 +17,6 @@ concurrency:
jobs:
release:
name: Release
if: github.repository == 'lobehub/lobehub'
runs-on: ubuntu-latest
services:
@ -29,6 +27,7 @@ jobs:
options: >-
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
ports:
- 5432:5432
@ -66,11 +65,43 @@ 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: Update package.json version
run: |
VERSION="${{ steps.get-version.outputs.version }}"
echo "📝 Updating package.json version to: $VERSION"
# Update package.json using Node.js
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
pkg.version = '$VERSION';
fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\\n');
console.log('✅ package.json updated');
"
# Commit changes
git config --global user.name "lobehubbot"
git config --global user.email "i@lobehub.com"
git add package.json
git commit -m "🔧 chore(release): bump version to v$VERSION [skip ci]" || echo "Nothing to commit"
git push origin HEAD:main
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
- 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

View file

@ -86,6 +86,7 @@
"reinstall": "rm -rf .next && rm -rf node_modules && rm -rf ./packages/*/node_modules && pnpm -r exec rm -rf node_modules && pnpm install",
"reinstall:desktop": "rm -rf pnpm-lock.yaml && rm -rf node_modules && pnpm -r exec rm -rf node_modules && pnpm install --node-linker=hoisted",
"release": "semantic-release",
"release:branch": "tsx ./scripts/releaseWorkflow/index.ts",
"self-hosting:docker": "docker build -t lobehub:local .",
"self-hosting:docker-cn": "docker build -t lobehub-local --build-arg USE_CN_MIRROR=true .",
"start": "next start -p 3210",
@ -381,6 +382,7 @@
"@playwright/test": "^1.58.0",
"@prettier/sync": "^0.6.1",
"@semantic-release/exec": "^6.0.3",
"@inquirer/prompts": "^8.2.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@ -468,4 +470,4 @@
"better-call": "1.1.8"
}
}
}
}

View file

@ -0,0 +1,308 @@
import { execSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { confirm, select } from '@inquirer/prompts';
import { consola } from 'consola';
import * as semver from 'semver';
const ROOT_DIR = process.cwd();
const PACKAGE_JSON_PATH = path.join(ROOT_DIR, 'package.json');
// Version type
type VersionType = 'patch' | 'minor' | 'major';
// Check if in a Git repository
function checkGitRepo(): void {
try {
execSync('git rev-parse --git-dir', { stdio: 'ignore' });
} catch {
consola.error('❌ Current directory is not a Git repository');
process.exit(1);
}
}
// Get current version from package.json
function getCurrentVersion(): string {
try {
const pkg = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, 'utf8'));
return pkg.version;
} catch {
consola.error('❌ Unable to read version from package.json');
process.exit(1);
}
}
// Calculate new version based on type
function bumpVersion(currentVersion: string, type: VersionType): string {
const newVersion = semver.inc(currentVersion, type);
if (!newVersion) {
consola.error(`❌ Unable to calculate new version (current: ${currentVersion}, type: ${type})`);
process.exit(1);
}
return newVersion;
}
// Get version type from command line arguments
function getVersionTypeFromArgs(): VersionType | null {
const args = process.argv.slice(2);
if (args.includes('--patch')) return 'patch';
if (args.includes('--minor')) return 'minor';
if (args.includes('--major')) return 'major';
return null;
}
// Interactive version type selection
async function selectVersionTypeInteractive(): Promise<VersionType> {
const currentVersion = getCurrentVersion();
const choices: { name: string; value: VersionType }[] = [
{
value: 'patch',
name: `🔧 patch - Bug fixes (e.g., ${currentVersion} -> ${bumpVersion(currentVersion, 'patch')})`,
},
{
value: 'minor',
name: `✨ minor - New features (e.g., ${currentVersion} -> ${bumpVersion(currentVersion, 'minor')})`,
},
{
value: 'major',
name: `🚀 major - Breaking changes (e.g., ${currentVersion} -> ${bumpVersion(currentVersion, 'major')})`,
},
];
const answer = await select<VersionType>({
choices,
message: 'Select version bump type:',
});
return answer;
}
// Secondary confirmation
async function confirmRelease(version: string, type: VersionType): Promise<boolean> {
const currentVersion = getCurrentVersion();
consola.box(
`
📦 Release Confirmation
Current: ${currentVersion}
New: ${version}
Type: ${type}
Branch: release/v${version}
Target: main
`.trim(),
);
const confirmed = await confirm({
default: true,
message: 'Confirm to create release branch and submit PR?',
});
return confirmed;
}
// Checkout and pull latest dev branch
function checkoutAndPullDev(): void {
try {
// Check for dev branch
const branches = execSync('git branch -a', { encoding: 'utf-8' });
const hasLocalDev = branches.includes(' dev\n') || branches.startsWith('* dev\n');
const hasRemoteDev = branches.includes('remotes/origin/dev');
if (!hasLocalDev && !hasRemoteDev) {
consola.error('❌ Dev branch not found (local or remote)');
process.exit(1);
}
consola.info('📥 Fetching latest dev branch...');
if (hasRemoteDev) {
// Checkout from remote dev branch
try {
execSync('git checkout dev', { stdio: 'ignore' });
execSync('git pull origin dev', { stdio: 'inherit' });
} catch {
// Create from remote if local doesn't exist
execSync('git checkout -b dev origin/dev', { stdio: 'inherit' });
}
} else {
// Local dev branch only
execSync('git checkout dev', { stdio: 'inherit' });
execSync('git pull', { stdio: 'inherit' });
}
consola.success('✅ Switched to latest dev branch');
} catch (error) {
consola.error('❌ Failed to switch or pull dev branch');
consola.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
// Create release branch with version marker commit
function createReleaseBranch(version: string, versionType: VersionType): void {
const branchName = `release/v${version}`;
try {
consola.info(`🌿 Creating branch: ${branchName}...`);
execSync(`git checkout -b ${branchName}`, { stdio: 'inherit' });
consola.success(`✅ Created and switched to branch: ${branchName}`);
// Create empty commit to mark the release
const markerMessage = getReleaseMarkerMessage(versionType, version);
consola.info(`📝 Creating version marker commit...`);
execSync(`git commit --allow-empty -m "${markerMessage}"`, { stdio: 'inherit' });
consola.success(`✅ Created version marker commit: ${markerMessage}`);
} catch (error) {
consola.error(`❌ Failed to create branch or commit: ${branchName}`);
consola.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
// Get release marker commit message
function getReleaseMarkerMessage(versionType: VersionType, version: string): string {
// Use gitmoji format for commit message
const gitmojiMap = {
major: '🚀',
minor: '✨',
patch: '🔧',
};
const emoji = gitmojiMap[versionType];
return `${emoji} chore(release): prepare release v${version}`;
}
// Push branch to remote
function pushBranch(version: string): void {
const branchName = `release/v${version}`;
try {
consola.info(`📤 Pushing branch to remote...`);
execSync(`git push -u origin ${branchName}`, { stdio: 'inherit' });
consola.success(`✅ Pushed branch to remote: ${branchName}`);
} catch (error) {
consola.error('❌ Failed to push branch');
consola.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
// Create Pull Request
function createPullRequest(version: string): void {
const title = `🚀 release: v${version}`;
const body = `## 📦 Release v${version}
This branch contains changes for the upcoming v${version} release.
### Change Type
- Checked out from dev branch and merged to main branch
### Release Process
1. Release branch created
2. Pushed to remote
3. 🔄 Waiting for PR review and merge
4. Release workflow triggered after merge
---
Created by release script`;
try {
consola.info('🔀 Creating Pull Request...');
// Create PR using gh CLI
const cmd = `gh pr create \
--title "${title}" \
--body "${body}" \
--base main \
--head release/v${version} \
--label "release"`;
execSync(cmd, { stdio: 'inherit' });
consola.success('✅ PR created successfully!');
} catch (error) {
consola.error('❌ Failed to create PR');
consola.error(error instanceof Error ? error.message : String(error));
consola.info('\n💡 Tip: Make sure GitHub CLI (gh) is installed and logged in');
consola.info(' Install: https://cli.github.com/');
consola.info(' Login: gh auth login');
process.exit(1);
}
}
// Display completion info
function showCompletion(version: string): void {
const branchName = `release/v${version}`;
consola.box(
`
🎉 Release process started!
Branch created: ${branchName}
Pushed to remote
PR created targeting main branch
📋 PR Title: 🚀 release: v${version}
Next steps:
1. Open the PR link to view details
2. Complete code review
3. Merge PR to main branch
4. Wait for release workflow to complete
`.trim(),
);
}
// Main function
async function main(): Promise<void> {
consola.info('🚀 LobeChat Release Script\n');
// 1. Check Git repository
checkGitRepo();
// 2. Checkout and pull latest dev branch (ensure we have the latest version)
checkoutAndPullDev();
// 3. Get version type
let versionType = getVersionTypeFromArgs();
if (!versionType) {
// No args, enter interactive mode
versionType = await selectVersionTypeInteractive();
}
// 4. Calculate new version
const currentVersion = getCurrentVersion();
const newVersion = bumpVersion(currentVersion, versionType);
// 5. Secondary confirmation
const confirmed = await confirmRelease(newVersion, versionType);
if (!confirmed) {
consola.info('❌ Release process cancelled');
process.exit(0);
}
// 6. Create release branch (with version marker commit)
createReleaseBranch(newVersion, versionType);
// 7. Push to remote
pushBranch(newVersion);
// 8. Create PR
createPullRequest(newVersion);
// 9. Show completion info
showCompletion(newVersion);
}
main().catch((error) => {
consola.error('❌ Error occurred:', error);
process.exit(1);
});