🔧 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:
Innei
2026-02-13 00:37:03 +08:00
committed by GitHub
parent bbbe3a8d09
commit c11d6de7db
4 changed files with 419 additions and 21 deletions
@@ -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}.`);
+13 -14
View File
@@ -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);
}
+11 -4
View File
@@ -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;
}
}
// 写回文件