mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
🔧 build: add canary desktop release workflow (#12286)
🔧 build: add canary desktop release workflow and channel support
Add automated canary build pipeline triggered by build/fix/style commits
on canary branch, with concurrency control to cancel stale builds.
This commit is contained in:
@@ -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}.`);
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<string, string | undefined>) => {
|
||||
execSync(command, {
|
||||
@@ -89,7 +89,7 @@ const main = async () => {
|
||||
|
||||
if (!validateChannel(channel)) {
|
||||
console.error(
|
||||
'Missing or invalid channel. Usage: npm run desktop:build-channel -- <stable|beta|nightly> [version] [--keep-changes]',
|
||||
'Missing or invalid channel. Usage: npm run desktop:build-channel -- <stable|beta|nightly|canary> [version] [--keep-changes]',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -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 <version> <stable|beta|nightly>',
|
||||
'Missing parameters. Usage: bun run setDesktopVersion.ts <version> <stable|beta|nightly|canary>',
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 写回文件
|
||||
|
||||
Reference in New Issue
Block a user