ci: 构建客户端 workflow(Windows / Linux / macOS)
构建客户端 / 检查(类型 + 单元测试) (push) Successful in 7m59s
构建客户端 / Linux x64(deb + AppImage) (push) Failing after 10m45s
构建客户端 / macOS 通用(.app + .dmg) (push) Skipped
构建客户端 / Windows x64(NSIS 安装包) (push) Skipped

触发:push 到 main(只认 desktop/ 与 protocol/ 的改动)、v* tag、PR、手动。
产物:Linux deb + AppImage、Windows NSIS 安装包、macOS 通用 dmg,各自传 artifact。

几个不是随手写的点:
- 三个平台都显式 --target / CARGO_BUILD_TARGET。desktop/.cargo/config.toml 里钉着
  aarch64-apple-darwin,不覆盖的话 Linux/Windows 的 runner 会去编 macOS 的目标,
  然后在依赖深处以 libc 找不到 MAP_FAILED 这类形式失败。
- protocol 的测试写 cargo +stable:protocol/ 没有自己的 rust-toolchain.toml,会被
  根目录那个 `esp` 接住,而 CI 上没有那个工具链(espup 要下几百 MB)。
- Windows 与 macOS 两个 job 默认不跑(本仓库目前只有 Linux runner),要用仓库变量
  ENABLE_WINDOWS / ENABLE_MACOS 打开;没有 runner 时它们只会一直排队。
- cargo 只缓存 registry、不缓存 target/:容器化的 runner 每次都是干净的,拉几 GB
  的 target 缓存常常比重新编译还慢。
- 守门 job 先跑 tsc + vite build + protocol/core 的单元测试,三个构建都 needs 它。

本地(Windows)实测:同一条 pnpm tauri build --target x86_64-pc-windows-msvc
--bundles nsis 真的编出了 src-tauri/target/x86_64-pc-windows-msvc/release/bundle/
nsis/cam-desktop_0.1.0_x64-setup.exe(2.97 MiB),也验证了 --target 确实盖住了
.cargo/config.toml;protocol 15 个测试、cam-core 80 个测试、tsc、vite build 都过;
三个 Cargo.lock 与 --locked 一致;workflow 本身过 actionlint(带 shellcheck)。
Linux 与 macOS 的打包只能在你们的 runner 上验,第一次跑完把日志贴回来。
This commit is contained in:
2026-10-15 18:41:31 +08:00
parent a93ae065cf
commit 98dc87ec6a
+464
View File
@@ -0,0 +1,464 @@
# 构建桌面客户端(desktop/,Tauri v2 + React)。
#
# 产物都挂在这次运行下面(Actions 页面直接下载),保留 14 天:
# Linux x64 deb + AppImage cam-desktop_<版本>_amd64.deb / .AppImage ← 默认每次编
# Windows x64 NSIS 安装包 cam-desktop_<版本>_x64-setup.exe ← 有 runner 才编
# macOS 通用 .app + .dmg arm64 与 x86_64 合成一个包 ← 有 runner 才编
#
# ── 触发 ─────────────────────────────────────────────────────────────────────
# * push 到 main/master —— 只认 desktop/、protocol/ 与本文件的改动,改固件不会
# 白占 runner
# * 打 v* tag、开 PR
# * 手动 Run workflow:逐个平台勾选,不进 paths 过滤("就想编一次"走它)
# Windows 与 macOS 两个 job 目前默认不跑,原因与打开方式见下面第 1 条。
#
# ── 跑在 Gitea 上要先对上的两件事 ────────────────────────────────────────────
# 1. runner 标签是注册 runner 时自己起的名字,猜不到,所以三个 job 都写成
# "仓库变量优先、常见标签兜底":
# runs-on: ${{ vars.RUNNER_LINUX || 'ubuntu-latest' }}
# 标签对不上时 job 是**排队**而不是报错 —— 排队会一直占着这次运行不结束,
# 所以第一次跑之前先看一眼 runner 页面上那几行标签。
#
# 本仓库目前只有 Linux 的两个 runner 标签(ubuntu-latest / ubuntu-24.04 /
# ubuntu-22.04),所以**默认只跑 Linux**:Windows 与 macOS 两个 job 要等
# 仓库变量 ENABLE_WINDOWS / ENABLE_MACOS 设成 true 才会跑(手动 Run workflow
# 时另看勾选框),机器的标签则用 RUNNER_WINDOWS / RUNNER_MACOS 指过去。
# 等那两台机器上的 runner 注册好再打开,否则每次 push 都会多出两个永远排队的
# job。
#
# 为什么不能拿 Linux runner 交叉编出来:
# * Windows:Rust 侧用 mingw 交叉编还能出个裸 exe,但 tauri 的 NSIS 打包器
# 本身只能在 Windows 上跑,出不了安装包;
# * macOS:要 Apple SDK 与 codesign/lipo,只能在 macOS 上编。
# 所以这两个平台的 job 留着是为了"机器到位那天直接开",不是摆设。
# 那两台机器上要预装好:Windows 的 MSVC Build Tools(link.exe)、macOS 的
# Xcode Command Line Tools,以及 rustup(dtolnay/rust-toolchain 靠它装工具链)。
# Linux 的 job 通常跑在容器里,所以没装 rustup 也没关系 —— 见下面那步兜底。
# 2. 这个文件放在 .github/workflows/ 下就够:Gitea 的 [actions] WORKFLOW_DIRS
# 默认值就是 [".gitea/workflows", ".github/workflows"](源码里的默认值,且
# .yml 与 .yaml 都认),所以两个目录都是原生支持的,不用挪。
#
# ── 三个平台都显式写 --target,这不是啰嗦 ────────────────────────────────────
# desktop/.cargo/config.toml 里钉着 `target = "aarch64-apple-darwin"`(本机开发用;
# 它存在的理由是 desktop/ 嵌在固件目录树里,不然会继承根目录那份
# xtensa-esp32s3-espidf 的交叉编译配置)。CI 上不覆盖它,Linux 与 Windows 的 runner
# 会去编 macOS 的目标,然后在依赖深处以 libc 找不到 MAP_FAILED 这类形式失败。
# 所以每个 job 都把真正的目标交两遍:CARGO_BUILD_TARGET 给裸 cargo(测试)用,
# --target 给 tauri build 用(打包路径也跟着它走)。
#
# ── 还有一条:仓库根目录的 rust-toolchain.toml 钉的是 `esp` ─────────────────
# 那是给 ESP32 固件的定制工具链,CI 上没有、也不打算装(espup 要下几百 MB)。
# rustup 是按**当前目录**往上找 rust-toolchain.toml 的:
# * desktop/** 有自己的 rust-toolchain.toml(stable),所以那里的 cargo 直接可用;
# * protocol/ 没有,会一路找到根目录那个 esp —— 所以那条测试写成 `cargo +stable`,
# 显式越过它。
# 任何在仓库根目录直接跑 cargo 的步骤都会去要 esp 工具链,别加。
#
# ── 没做的事 ────────────────────────────────────────────────────────────────
# 不签名、不公证:Windows 的 exe 与 macOS 的 app 在别人机器上会被 SmartScreen /
# Gatekeeper 拦一次(右键打开即可)。要做的话给 macOS 那几个 job 加
# APPLE_CERTIFICATE / APPLE_SIGNING_IDENTITY / APPLE_ID / APPLE_PASSWORD /
# APPLE_TEAM_ID(tauri 认这些环境变量,构建步骤不用改)。也没有自动发 Release:
# 要发的话在最后加一个下载全部 artifact 再创建 release 的 job。
name: 构建客户端
on:
push:
branches: [main, master]
tags: ["v*"]
# GitHub 语义下 tag 推送不受 paths 过滤影响;如果你们的 Gitea 版本把 tag 也
# 一起过滤掉了(打 tag 不触发),把这三行 paths 删掉即可。
paths:
- "desktop/**"
- "protocol/**"
- ".github/workflows/build-client.yml"
pull_request:
paths:
- "desktop/**"
- "protocol/**"
- ".github/workflows/build-client.yml"
workflow_dispatch:
inputs:
# Linux 的 runner 有,默认勾上;另两个平台目前没有 runner,默认不勾 ——
# 勾了而没有对应 runner,那个 job 会一直排队。
linux:
description: "构建 Linux(deb + AppImage)"
type: boolean
default: true
windows:
description: "构建 Windows(NSIS 安装包)——需要 Windows runner"
type: boolean
default: false
macos:
description: "构建 macOS(通用 dmg)——需要 macOS runner"
type: boolean
default: false
# 同一分支连推两次时把上一次没跑完的取消掉:自建 runner 数量有限,让过期的构建
# 占着机器没有意义。老版本 runner 忽略这个键,不影响构建。
concurrency:
group: build-client-${{ github.ref }}
cancel-in-progress: true
env:
# 与本机开发对齐:node 24 / pnpm 12(pnpm-lock.yaml 就是 pnpm 12.8.1 写的,
# lockfileVersion 9.0)。改这两个数之前先确认 lock 文件还认。
NODE_VERSION: "24"
PNPM_VERSION: "12"
CARGO_TERM_COLOR: always
RUST_BACKTRACE: "1"
jobs:
# ───────────────────────────────────────────────────────────────────────────
# 守门:类型检查、前端构建、两个不依赖窗口的 crate 的单元测试。
# 下面每个构建 job 都 needs 它 —— 编到一半才发现 tsc 报错,是最浪费 runner 的
# 一种失败。src-tauri 的 30 个测试放在 Linux 那个 job 里跑(它本来就要装
# webkit 依赖,而且那几条录制测试要真的起 ffmpeg)。
# ───────────────────────────────────────────────────────────────────────────
checks:
name: 检查(类型 + 单元测试)
runs-on: ${{ vars.RUNNER_LINUX || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
# runner 镜像里多半没有 rustup,而 dtolnay/rust-toolchain 靠它装工具链。
# 这里只装一个最小的 rustup 本身,工具链交给下面那个 action —— 这样自建
# 镜像不用为了 CI 专门烤一个带 Rust 的镜像。
# 国内网络慢的话,在 runner 的环境变量里给 RUSTUP_DIST_SERVER 指一个镜像
# (如 https://mirrors.ustc.edu.cn/rust-static)会快很多。
- name: 确保有 rustup
run: |
if command -v rustup >/dev/null 2>&1; then
rustup --version
exit 0
fi
if command -v curl >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain none
elif command -v wget >/dev/null 2>&1; then
wget -qO- https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain none
else
echo "装 rustup 需要 curl 或 wget,这个镜像里两个都没有" >&2
exit 1
fi
if [ -n "$GITHUB_PATH" ]; then
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
# pnpm 必须在 setup-node 之前:setup-node 的 cache: pnpm 要用它算 store 路径。
- name: 装 pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: 装 Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: desktop/pnpm-lock.yaml
- name: 前端依赖(严格按 lock 装)
working-directory: desktop
run: pnpm install --frozen-lockfile
- name: 类型检查(tsc --noEmit)
working-directory: desktop
run: pnpm run check
- name: 前端构建(vite)
working-directory: desktop
run: pnpm build
- name: 装 Rust(stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
# 只缓存 registry(crate 源码),不缓存 target/:自建 runner 的容器每次都是
# 干净的,几 GB 的 target 缓存拉下来的时间常常比重新编译还长,磁盘也扛不住。
# 缓存服务在不同 Gitea 版本上差异较大,所以失败不让它拖垮构建。
- name: cargo 缓存
uses: actions/cache@v4
continue-on-error: true
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('protocol/Cargo.lock', 'desktop/core/Cargo.lock', 'desktop/src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-
# protocol 没有自己的 rust-toolchain.toml,会被根目录那个 `esp` 接住,
# 所以这里必须 +stable 显式指定。
- name: protocol 单元测试(15)
working-directory: protocol
run: cargo +stable test --locked --target x86_64-unknown-linux-gnu
# core 在 desktop/ 下面,desktop/rust-toolchain.toml 已经是 stable,不用 +stable。
- name: cam-core 单元测试(80)
working-directory: desktop/core
run: cargo test --locked --target x86_64-unknown-linux-gnu
# ───────────────────────────────────────────────────────────────────────────
# Linux x64:deb + AppImage
# ───────────────────────────────────────────────────────────────────────────
linux:
name: Linux x64(deb + AppImage)
needs: checks
if: ${{ github.event.inputs.linux != 'false' }}
runs-on: ${{ vars.RUNNER_LINUX || 'ubuntu-latest' }}
timeout-minutes: 120
env:
TARGET: x86_64-unknown-linux-gnu
CARGO_BUILD_TARGET: x86_64-unknown-linux-gnu
steps:
- uses: actions/checkout@v4
- name: 确保有 rustup
run: |
if command -v rustup >/dev/null 2>&1; then
rustup --version
exit 0
fi
if command -v curl >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain none
elif command -v wget >/dev/null 2>&1; then
wget -qO- https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain none
else
echo "装 rustup 需要 curl 或 wget,这个镜像里两个都没有" >&2
exit 1
fi
if [ -n "$GITHUB_PATH" ]; then
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
- name: 装系统依赖
run: |
# 容器里跑的 runner 通常是 root,没有 sudo;托管 runner 上有。
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO=sudo
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
libxdo-dev \
libssl-dev \
xdg-utils \
build-essential curl wget file \
ffmpeg
# AppImage 打包要运行 linuxdeploy(它本身是个 AppImage);24.04 起包名带 t64。
$SUDO apt-get install -y libfuse2 || $SUDO apt-get install -y libfuse2t64 || true
- name: 装 Rust(stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: 装 pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: 装 Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: desktop/pnpm-lock.yaml
- name: 前端依赖(严格按 lock 装)
working-directory: desktop
run: pnpm install --frozen-lockfile
- name: cargo 缓存
uses: actions/cache@v4
continue-on-error: true
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('protocol/Cargo.lock', 'desktop/core/Cargo.lock', 'desktop/src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-
# 上面 apt 装了 ffmpeg,所以 recording.rs 里那几条"真的起 ffmpeg 并读回帧数"
# 的测试不会走 has_ffmpeg() 的跳过分支,是真的在验。
# 如果这台 runner 上它们长期不稳定,删掉这一步即可 —— 它们不在守门 job 里,
# 不影响另外两个平台的产物。
- name: Tauri 后端单元测试(30)
working-directory: desktop/src-tauri
run: cargo test --locked --target ${{ env.TARGET }}
# beforeBuildCommand(pnpm build)由 tauri 自己跑,这里不用再 build 一次。
- name: 打包(tauri build)
working-directory: desktop
run: pnpm tauri build --target ${{ env.TARGET }} --bundles deb,appimage
- name: 上传产物
uses: actions/upload-artifact@v4
with:
name: cam-desktop-linux-x64
# 一个都没找到就报错:产物路径写错却"构建成功"是最难发现的一种失败。
if-no-files-found: error
retention-days: 14
path: |
desktop/src-tauri/target/${{ env.TARGET }}/release/bundle/deb/*.deb
desktop/src-tauri/target/${{ env.TARGET }}/release/bundle/appimage/*.AppImage
# ───────────────────────────────────────────────────────────────────────────
# Windows x64:NSIS 安装包
# ───────────────────────────────────────────────────────────────────────────
windows:
name: Windows x64(NSIS 安装包)
needs: checks
# 默认不跑:本仓库目前没有 Windows runner,跑了只会一直排队。有了机器之后
# 在仓库变量里加 ENABLE_WINDOWS=true 就会跟着每次 push 自动跑;手动触发时
# 勾 "构建 Windows" 也能单独跑一次。标签用 RUNNER_WINDOWS 指。
if: >-
${{
(github.event_name == 'workflow_dispatch' && github.event.inputs.windows == 'true')
|| (github.event_name != 'workflow_dispatch' && vars.ENABLE_WINDOWS == 'true')
}}
# 自建 Windows runner 上要有 MSVC Build Tools(tauri 的 Rust 侧要 link.exe)、
# WebView2 运行时(装了包的应用要它才能起窗口;打包本身不需要)与 rustup。
runs-on: ${{ vars.RUNNER_WINDOWS || 'windows-latest' }}
timeout-minutes: 120
env:
TARGET: x86_64-pc-windows-msvc
CARGO_BUILD_TARGET: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v4
- name: 装 Rust(stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: 装 pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: 装 Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: desktop/pnpm-lock.yaml
- name: 前端依赖(严格按 lock 装)
working-directory: desktop
run: pnpm install --frozen-lockfile
- name: cargo 缓存
uses: actions/cache@v4
continue-on-error: true
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('protocol/Cargo.lock', 'desktop/core/Cargo.lock', 'desktop/src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-
# nsis 是纯安装包,不需要 WiX;tauri 会自己把 NSIS 下到用户缓存里,所以
# 这台 runner 要能访问 GitHub(离线环境改成 --bundles msi 也一样要下 WiX)。
- name: 打包(tauri build)
working-directory: desktop
run: pnpm tauri build --target ${{ env.TARGET }} --bundles nsis
- name: 上传产物
uses: actions/upload-artifact@v4
with:
name: cam-desktop-windows-x64
if-no-files-found: error
retention-days: 14
path: |
desktop/src-tauri/target/${{ env.TARGET }}/release/bundle/nsis/*.exe
# ───────────────────────────────────────────────────────────────────────────
# macOS 通用包:.app + .dmg
# ───────────────────────────────────────────────────────────────────────────
macos:
name: macOS 通用(.app + .dmg)
needs: checks
# 同 Windows:默认不跑,有机器之后设仓库变量 ENABLE_MACOS=true,标签用
# RUNNER_MACOS 指。
if: >-
${{
(github.event_name == 'workflow_dispatch' && github.event.inputs.macos == 'true')
|| (github.event_name != 'workflow_dispatch' && vars.ENABLE_MACOS == 'true')
}}
# 自建 macOS runner 上要有 Xcode Command Line Tools(链接要它)与 rustup。
runs-on: ${{ vars.RUNNER_MACOS || 'macos-latest' }}
timeout-minutes: 150
env:
# 注意:这里**不能**设 CARGO_BUILD_TARGET —— universal-apple-darwin 是 tauri
# 自己的伪目标(编两遍再 lipo 合成),裸 cargo 不认它。这个 job 里也没有裸
# cargo 步骤,所有目标都由 tauri build 的 --target 交代。
TARGET: universal-apple-darwin
steps:
- uses: actions/checkout@v4
# 通用包要两个架构的 std 都在。
- name: 装 Rust(stable + 两个 mac 目标)
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: 装 pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: 装 Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: desktop/pnpm-lock.yaml
- name: 前端依赖(严格按 lock 装)
working-directory: desktop
run: pnpm install --frozen-lockfile
- name: cargo 缓存
uses: actions/cache@v4
continue-on-error: true
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('protocol/Cargo.lock', 'desktop/core/Cargo.lock', 'desktop/src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-
# 只想要当前架构、想省一半编译时间的话,把 --target 换成 aarch64-apple-darwin
# (Apple Silicon 的 runner)并去掉上面 targets 里的 x86_64 即可。
- name: 打包(tauri build)
working-directory: desktop
run: pnpm tauri build --target ${{ env.TARGET }} --bundles app,dmg
# 第二行是兜底:不同 tauri-cli 版本给通用包放的目录略有差异,宁可多写一行,
# 也不要上传时静默少东西。(注意 path 是块标量,里面写 # 是路径不是注释。)
- name: 上传产物
uses: actions/upload-artifact@v4
with:
name: cam-desktop-macos-universal
if-no-files-found: error
retention-days: 14
path: |
desktop/src-tauri/target/${{ env.TARGET }}/release/bundle/dmg/*.dmg
desktop/src-tauri/target/*/release/bundle/dmg/*.dmg