diff --git a/.github/workflows/release-desktop-canary.yml b/.github/workflows/release-desktop-canary.yml new file mode 100644 index 0000000000..9aac6e0a99 --- /dev/null +++ b/.github/workflows/release-desktop-canary.yml @@ -0,0 +1,392 @@ +name: Release Desktop Canary + +# ============================================ +# Canary 自动发版工作流 +# ============================================ +# 触发条件: +# 1. canary 分支有 push (合入 PR) 且 commit 前缀为 style/feat/fix/refactor +# 2. 手动触发 (workflow_dispatch) +# +# 并发策略: +# 同一 workflow 仅保留最新一次运行,自动取消排队中的旧 build +# +# 版本策略: +# 基于最新 stable tag 的 minor+1, 格式: X.(Y+1).0-canary.YYYYMMDDHHMM +# 例: 当前 tag v2.1.28 → v2.2.0-canary.202602121400 +# ============================================ + +on: + push: + branches: + - canary + workflow_dispatch: + inputs: + force: + description: 'Force build (skip commit message check)' + required: false + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +permissions: read-all + +env: + NODE_VERSION: '24.11.1' + +jobs: + # ============================================ + # 检查 commit 前缀并计算版本号 + # ============================================ + calculate-version: + name: Calculate Canary Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + should_build: ${{ steps.check.outputs.should_build }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Check commit message prefix + id: check + run: | + # 手动触发 + force 时跳过检查 + if [ "${{ inputs.force }}" == "true" ]; then + echo "should_build=true" >> $GITHUB_OUTPUT + echo "🔧 Force build requested, skipping commit check" + exit 0 + fi + + # 手动触发 (无 force) 也直接构建 + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + echo "should_build=true" >> $GITHUB_OUTPUT + echo "🔧 Manual trigger, proceeding with build" + exit 0 + fi + + # 获取本次 push 的 head commit message + commit_msg=$(git log -1 --pretty=%s HEAD) + echo "📝 Head commit: $commit_msg" + + # 检查是否匹配 style/feat/fix/refactor 前缀 (支持 gitmoji 前缀) + if echo "$commit_msg" | grep -qiE '^(💄\s*)?style(\(.+\))?:|^(✨\s*)?feat(\(.+\))?:|^(🐛\s*)?fix(\(.+\))?:|^(♻️\s*)?refactor(\(.+\))?:'; then + echo "should_build=true" >> $GITHUB_OUTPUT + echo "✅ Commit matches canary build trigger: $commit_msg" + else + echo "should_build=false" >> $GITHUB_OUTPUT + echo "⏭️ Commit does not match style/feat/fix/refactor prefix, skipping: $commit_msg" + fi + + - name: Calculate canary version + if: steps.check.outputs.should_build == 'true' + id: version + run: | + # 获取最新的 stable tag (排除 nightly/canary/beta 等) + latest_tag=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) + + if [ -z "$latest_tag" ]; then + echo "❌ No stable tag found" + exit 1 + fi + + echo "📌 Latest stable tag: $latest_tag" + + # 去掉 v 前缀 + base_version="${latest_tag#v}" + + # 解析 major.minor.patch + IFS='.' read -r major minor patch <<< "$base_version" + + # minor + 1, patch 归零 + new_minor=$((minor + 1)) + timestamp=$(date -u +"%Y%m%d%H%M") + + version="${major}.${new_minor}.0-canary.${timestamp}" + tag="v${version}" + + echo "version=${version}" >> $GITHUB_OUTPUT + echo "tag=${tag}" >> $GITHUB_OUTPUT + echo "✅ Canary version: ${version}" + echo "🏷️ Tag: ${tag}" + + # ============================================ + # 代码质量检查 + # ============================================ + test: + name: Code quality check + needs: [calculate-version] + if: needs.calculate-version.outputs.should_build == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout base + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + package-manager-cache: false + + - name: Install bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install deps + run: bun i + + - name: Lint + run: bun run lint + + # ============================================ + # 多平台构建 + # ============================================ + build: + needs: [calculate-version, test] + if: needs.calculate-version.outputs.should_build == 'true' + name: Build Desktop App + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-15, macos-15-intel, windows-2025, ubuntu-latest] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup build environment + uses: ./.github/actions/desktop-build-setup + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Set package version + run: npm run workflow:set-desktop-version ${{ needs.calculate-version.outputs.version }} canary + + # macOS 构建前清理 (修复 hdiutil 问题) + - name: Clean previous build artifacts (macOS) + if: runner.os == 'macOS' + run: | + sudo rm -rf apps/desktop/release || true + sudo rm -rf apps/desktop/dist || true + sudo rm -rf /tmp/electron-builder* || true + + # macOS 构建 + - name: Build artifact on macOS + if: runner.os == 'macOS' + run: npm run desktop:package:app + env: + UPDATE_CHANNEL: canary + APP_URL: http://localhost:3015 + DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres' + KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=' + CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + CSC_FOR_PULL_REQUEST: true + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }} + NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }} + + # Windows 构建 + - name: Build artifact on Windows + if: runner.os == 'Windows' + run: npm run desktop:package:app + env: + UPDATE_CHANNEL: canary + APP_URL: http://localhost:3015 + DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres' + KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=' + NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }} + NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }} + TEMP: C:\temp + TMP: C:\temp + + # Linux 构建 + - name: Build artifact on Linux + if: runner.os == 'Linux' + run: npm run desktop:package:app + env: + UPDATE_CHANNEL: canary + APP_URL: http://localhost:3015 + DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres' + KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=' + NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }} + NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }} + + - name: Upload artifacts + uses: ./.github/actions/desktop-upload-artifacts + with: + artifact-name: release-${{ matrix.os }} + retention-days: 3 + + # ============================================ + # 合并 macOS 多架构 latest-mac.yml 文件 + # ============================================ + merge-mac-files: + needs: [build] + name: Merge macOS Release Files + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + package-manager-cache: false + + - name: Install bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Download artifacts + uses: actions/download-artifact@v7 + with: + path: release + pattern: release-* + merge-multiple: true + + - name: List downloaded artifacts + run: ls -R release + + - name: Install yaml only for merge step + run: | + cd scripts/electronWorkflow + if [ ! -f package.json ]; then + echo '{"name":"merge-mac-release","private":true}' > package.json + fi + bun add --no-save yaml@2.8.1 + + - name: Merge latest-mac.yml files + run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js + + - name: Upload artifacts with merged macOS files + uses: actions/upload-artifact@v6 + with: + name: merged-release + path: release/ + retention-days: 1 + + # ============================================ + # 创建 Canary Release + # ============================================ + publish-release: + needs: [merge-mac-files, calculate-version] + name: Publish Canary Release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download merged artifacts + uses: actions/download-artifact@v7 + with: + name: merged-release + path: release + + - name: List final artifacts + run: ls -R release + + - name: Create Canary Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ needs.calculate-version.outputs.tag }} + name: 'Desktop Canary ${{ needs.calculate-version.outputs.tag }}' + prerelease: true + body: | + ## 🐤 Canary Build — ${{ needs.calculate-version.outputs.tag }} + + > Automated canary build from `canary` branch. + + ### ⚠️ Important Notes + + - **This is an automated canary build and is NOT intended for production use.** + - Canary builds are triggered by `build`/`fix`/`style` commits on the `canary` branch. + - May contain **unstable or incomplete changes**. **Use at your own risk.** + - It is strongly recommended to **back up your data** before using a canary build. + + ### 📦 Installation + + Download the appropriate installer for your platform from the assets below. + + | Platform | File | + |----------|------| + | macOS (Apple Silicon) | `.dmg` (arm64) | + | macOS (Intel) | `.dmg` (x64) | + | Windows | `.exe` | + | Linux | `.AppImage` / `.deb` | + files: | + release/latest* + release/*.dmg* + release/*.zip* + release/*.exe* + release/*.AppImage + release/*.deb* + release/*.snap* + release/*.rpm* + release/*.tar.gz* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ============================================ + # 清理旧的 Canary Releases (保留最近 7 个) + # ============================================ + cleanup-old-canaries: + needs: [publish-release] + name: Cleanup Old Canary Releases + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Delete old canary releases + uses: actions/github-script@v7 + with: + script: | + const { data: releases } = await github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + }); + + const canaryReleases = releases + .filter(r => r.tag_name.includes('-canary.')) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + + const toDelete = canaryReleases.slice(7); + + for (const release of toDelete) { + console.log(`🗑️ Deleting old canary release: ${release.tag_name}`); + + // Delete the release + await github.rest.repos.deleteRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.id, + }); + + // Delete the tag + try { + await github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${release.tag_name}`, + }); + } catch (e) { + console.log(`⚠️ Could not delete tag ${release.tag_name}: ${e.message}`); + } + } + + console.log(`✅ Cleanup complete. Kept ${Math.min(canaryReleases.length, 7)} canary releases, deleted ${toDelete.length}.`); diff --git a/apps/desktop/electron-builder.mjs b/apps/desktop/electron-builder.mjs index c215a161e0..64e989edc9 100644 --- a/apps/desktop/electron-builder.mjs +++ b/apps/desktop/electron-builder.mjs @@ -1,9 +1,10 @@ -import dotenv from 'dotenv'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import dotenv from 'dotenv'; + import { copyNativeModules, copyNativeModulesToSource, @@ -27,9 +28,11 @@ const updateServerUrl = process.env.UPDATE_SERVER_URL; console.log(`🚄 Build Version ${packageJSON.version}, Channel: ${channel}`); console.log(`🏗️ Building for architecture: ${arch}`); +// Channel identity derived solely from UPDATE_CHANNEL env var. +// Adding a new channel won't break stable detection. +const isStable = !channel || channel === 'stable'; const isNightly = channel === 'nightly'; -const isBeta = packageJSON.name.includes('beta'); -const isStable = !isNightly && !isBeta; +const isBeta = channel === 'beta'; // 根据 channel 配置不同的 publish provider // - Stable + UPDATE_SERVER_URL: 使用 generic (自定义 HTTP 服务器) @@ -80,9 +83,10 @@ const protocolScheme = getProtocolScheme(); // Determine icon file based on version type const getIconFileName = () => { - if (isNightly) return 'Icon-nightly'; + if (isStable) return 'Icon'; if (isBeta) return 'Icon-beta'; - return 'Icon'; + // nightly, canary, and any future pre-release channels share nightly icon + return 'Icon-nightly'; }; /** @@ -249,15 +253,10 @@ const config = { hardenedRuntime: hasAppleCertificate, notarize: hasAppleCertificate, ...(hasAppleCertificate ? {} : { identity: null }), - target: - // 降低构建时间,nightly 只打 dmg - // 根据当前机器架构只构建对应架构的包 - isNightly - ? [{ arch: [arch === 'arm64' ? 'arm64' : 'x64'], target: 'dmg' }] - : [ - { arch: [arch === 'arm64' ? 'arm64' : 'x64'], target: 'dmg' }, - { arch: [arch === 'arm64' ? 'arm64' : 'x64'], target: 'zip' }, - ], + target: [ + { arch: [arch === 'arm64' ? 'arm64' : 'x64'], target: 'dmg' }, + { arch: [arch === 'arm64' ? 'arm64' : 'x64'], target: 'zip' }, + ], }, npmRebuild: true, nsis: { diff --git a/scripts/electronWorkflow/buildDesktopChannel.ts b/scripts/electronWorkflow/buildDesktopChannel.ts index 6777076c21..b63b9b0edb 100644 --- a/scripts/electronWorkflow/buildDesktopChannel.ts +++ b/scripts/electronWorkflow/buildDesktopChannel.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import fs from 'fs-extra'; -type ReleaseChannel = 'stable' | 'beta' | 'nightly'; +type ReleaseChannel = 'stable' | 'beta' | 'nightly' | 'canary'; const rootDir = path.resolve(__dirname, '../..'); const desktopDir = path.join(rootDir, 'apps/desktop'); @@ -74,7 +74,7 @@ const restoreFile = async (filePath: string, content?: Buffer) => { }; const validateChannel = (channel: string): channel is ReleaseChannel => - channel === 'stable' || channel === 'beta' || channel === 'nightly'; + channel === 'stable' || channel === 'beta' || channel === 'nightly' || channel === 'canary'; const runCommand = (command: string, env?: Record) => { execSync(command, { @@ -89,7 +89,7 @@ const main = async () => { if (!validateChannel(channel)) { console.error( - 'Missing or invalid channel. Usage: npm run desktop:build-channel -- [version] [--keep-changes]', + 'Missing or invalid channel. Usage: npm run desktop:build-channel -- [version] [--keep-changes]', ); process.exit(1); } diff --git a/scripts/electronWorkflow/setDesktopVersion.ts b/scripts/electronWorkflow/setDesktopVersion.ts index 420789753c..2471931354 100644 --- a/scripts/electronWorkflow/setDesktopVersion.ts +++ b/scripts/electronWorkflow/setDesktopVersion.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import fs from 'fs-extra'; -type ReleaseType = 'stable' | 'beta' | 'nightly'; +type ReleaseType = 'stable' | 'beta' | 'nightly' | 'canary'; // 获取脚本的命令行参数 const version = process.argv[2]; @@ -11,14 +11,14 @@ const releaseType = process.argv[3] as ReleaseType; // 验证参数 if (!version || !releaseType) { console.error( - 'Missing parameters. Usage: bun run setDesktopVersion.ts ', + 'Missing parameters. Usage: bun run setDesktopVersion.ts ', ); process.exit(1); } -if (!['stable', 'beta', 'nightly'].includes(releaseType)) { +if (!['stable', 'beta', 'nightly', 'canary'].includes(releaseType)) { console.error( - `Invalid release type: ${releaseType}. Must be one of 'stable', 'beta', 'nightly'.`, + `Invalid release type: ${releaseType}. Must be one of 'stable', 'beta', 'nightly', 'canary'.`, ); process.exit(1); } @@ -95,6 +95,13 @@ function updatePackageJson() { updateAppIcon('nightly'); break; } + case 'canary': { + packageJson.productName = 'LobeHub-Canary'; + packageJson.name = 'lobehub-desktop-canary'; + console.log('🐤 Setting as Canary version.'); + updateAppIcon('nightly'); + break; + } } // 写回文件