mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
🔨 chore: add auto-tag release workflow and interactive release script (#12236)
* init * add missing deps
This commit is contained in:
parent
a83dc4d4ed
commit
07f9c2a6a0
4 changed files with 454 additions and 5 deletions
108
.github/workflows/auto-tag-release.yml
vendored
Normal file
108
.github/workflows/auto-tag-release.yml
vendored
Normal 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
|
||||
39
.github/workflows/release.yml
vendored
39
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
308
scripts/releaseWorkflow/index.ts
Normal file
308
scripts/releaseWorkflow/index.ts
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue