From 07f9c2a6a0f797b7260fcd751d5e00c0fd75ff87 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 11 Feb 2026 12:43:43 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A8=20chore:=20add=20auto-tag=20releas?= =?UTF-8?q?e=20workflow=20and=20interactive=20release=20script=20(#12236)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init * add missing deps --- .github/workflows/auto-tag-release.yml | 108 +++++++++ .github/workflows/release.yml | 39 +++- package.json | 4 +- scripts/releaseWorkflow/index.ts | 308 +++++++++++++++++++++++++ 4 files changed, 454 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/auto-tag-release.yml create mode 100644 scripts/releaseWorkflow/index.ts diff --git a/.github/workflows/auto-tag-release.yml b/.github/workflows/auto-tag-release.yml new file mode 100644 index 0000000000..dfb788a194 --- /dev/null +++ b/.github/workflows/auto-tag-release.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71e08baf11..f631973a1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/package.json b/package.json index b4d0459755..db2450f514 100644 --- a/package.json +++ b/package.json @@ -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" } } -} +} \ No newline at end of file diff --git a/scripts/releaseWorkflow/index.ts b/scripts/releaseWorkflow/index.ts new file mode 100644 index 0000000000..70a9c71acc --- /dev/null +++ b/scripts/releaseWorkflow/index.ts @@ -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 { + 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({ + choices, + message: 'Select version bump type:', + }); + + return answer; +} + +// Secondary confirmation +async function confirmRelease(version: string, type: VersionType): Promise { + 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 { + 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); +});