diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a2b4e1d..14ef965 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,9 +4,11 @@ on:
push:
branches:
- main
+ - dev
pull_request:
branches:
- main
+ - dev
types:
- opened
- synchronize
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
new file mode 100644
index 0000000..04eea67
--- /dev/null
+++ b/.github/workflows/nightly.yml
@@ -0,0 +1,438 @@
+name: Nightly
+
+# 滚动每夜构建:main 合并后自动出各平台安装包,供提前测试。
+# 设计决策见 docs/adr/0006-nightly-rolling-prerelease.md。
+on:
+ # main 合并:等 CI 成功后再构建(不重复跑质量门禁),仅当触发 CI 的是 push 到 main 才继续
+ workflow_run:
+ workflows: ["CI"]
+ types: [completed]
+ branches: [main]
+ # 手动触发:不走 CI 依赖链,只做轻量前置(actionlint + gitleaks)
+ workflow_dispatch:
+
+# 顶层默认无权限;各 job 按需声明最小权限
+permissions: {}
+
+# 滚动 tag 是单一共享目标;串行执行避免发布阶段被新一轮运行取消。
+concurrency:
+ group: nightly
+ cancel-in-progress: false
+
+jobs:
+ prepare:
+ # workflow_run 事件时,仅当触发 CI 的是 push 到 main 且 CI 为 success 才继续;
+ # 手动触发时无 CI 上下文,直接继续。
+ if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push') }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ checks: write
+ outputs:
+ commit_sha: ${{ steps.commit.outputs.sha }}
+ nightly_version: ${{ steps.version.outputs.version }}
+ steps:
+ - name: 提取本次构建对应的 commit
+ id: commit
+ shell: bash
+ env:
+ CI_SHA: ${{ github.event.workflow_run.head_sha }}
+ DISPATCH_SHA: ${{ github.sha }}
+ run: |
+ if [ "${{ github.event_name }}" = "workflow_run" ]; then
+ echo "sha=$CI_SHA" >> "$GITHUB_OUTPUT"
+ else
+ echo "sha=$DISPATCH_SHA" >> "$GITHUB_OUTPUT"
+ fi
+
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ steps.commit.outputs.sha }}
+ fetch-depth: 0
+
+ # 手动触发才走轻量前置;CI 路径下 push/PR 已在 ci.yml 里执行相同检查。
+ - name: actionlint workflow 校验(仅手动触发)
+ if: ${{ github.event_name == 'workflow_dispatch' }}
+ uses: reviewdog/action-actionlint@d63ba7532e0942965320cd8d73cbae4c7b3c5283 # v1.73.1
+ with:
+ reporter: github-check
+ fail_level: error
+
+ - name: Gitleaks 密钥扫描(仅手动触发)
+ if: ${{ github.event_name == 'workflow_dispatch' }}
+ uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: 计算每夜构建版本
+ id: version
+ shell: bash
+ env:
+ GITHUB_SHA: ${{ steps.commit.outputs.sha }}
+ run: |
+ set -euo pipefail
+ version=$(node scripts/nightly-version.mjs --print)
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+
+ build-universal:
+ needs: prepare
+ runs-on: macos-26
+ timeout-minutes: 60
+ permissions:
+ contents: read
+ env:
+ NIGHTLY_VERSION: ${{ needs.prepare.outputs.nightly_version }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.prepare.outputs.commit_sha }}
+ fetch-depth: 0
+
+ - name: 安装 pnpm
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
+
+ - name: 安装 Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: "24"
+ cache: pnpm
+
+ - name: 安装 Rust stable
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ with:
+ toolchain: stable
+ targets: aarch64-apple-darwin,x86_64-apple-darwin
+
+ - name: Rust 缓存
+ uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+ with:
+ workspaces: ./src-tauri -> target
+
+ - name: 安装前端依赖
+ run: pnpm install --frozen-lockfile
+
+ - name: 注入每夜构建版本
+ env:
+ GITHUB_SHA: ${{ needs.prepare.outputs.commit_sha }}
+ run: node scripts/nightly-version.mjs --write-config src-tauri/tauri.nightly.generated.json
+
+ - name: 构建 macOS DMG
+ run: pnpm tauri build --config src-tauri/tauri.nightly.generated.json --target universal-apple-darwin --bundles dmg
+
+ - name: 产物断言 - DMG
+ id: artifact
+ shell: bash
+ run: |
+ set -euo pipefail
+ dmg=$(find src-tauri/target -type f -name "code-manager_${NIGHTLY_VERSION}_universal.dmg" 2>/dev/null | head -1)
+ test -n "$dmg"
+ test -s "$dmg"
+ echo "path=$dmg" >> "$GITHUB_OUTPUT"
+ echo "DMG 存在且非空: $dmg"
+
+ - name: 无头启动冒烟 - macOS
+ shell: bash
+ run: |
+ set -euo pipefail
+ app=$(find src-tauri/target -type d -name "code-manager.app" 2>/dev/null | head -1)
+ test -n "$app"
+ bin=$(find "$app/Contents/MacOS" -maxdepth 1 -type f -name 'code-manager*' 2>/dev/null | head -1)
+ test -n "$bin"
+ test -x "$bin"
+ # GitHub macOS runner 是真实 macOS 会话,可直接启动二进制;启动后断言静置若干秒仍存活。
+ "$bin" >/tmp/nightly-macos.log 2>&1 & pid=$!
+ sleep 12
+ if ! kill -0 "$pid" 2>/dev/null; then
+ echo "应用未存活 12 秒,冒烟失败:"
+ cat /tmp/nightly-macos.log 2>/dev/null || true
+ exit 1
+ fi
+ kill "$pid" 2>/dev/null || true
+ echo "应用无头启动冒烟通过"
+
+ - name: 暂存 macOS 产物
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: nightly-macos
+ path: ${{ steps.artifact.outputs.path }}
+ if-no-files-found: error
+ retention-days: 3
+
+ build-linux:
+ needs: prepare
+ runs-on: ubuntu-24.04
+ timeout-minutes: 60
+ permissions:
+ contents: read
+ env:
+ NIGHTLY_VERSION: ${{ needs.prepare.outputs.nightly_version }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.prepare.outputs.commit_sha }}
+ fetch-depth: 0
+
+ - name: 安装 pnpm
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
+
+ - name: 安装 Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: "24"
+ cache: pnpm
+
+ - name: 安装 Rust stable
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ with:
+ toolchain: stable
+
+ - name: Rust 缓存
+ uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+ with:
+ workspaces: ./src-tauri -> target
+
+ - name: 安装 Linux 系统依赖
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xvfb
+
+ - name: 安装前端依赖
+ run: pnpm install --frozen-lockfile
+
+ - name: 注入每夜构建版本
+ env:
+ GITHUB_SHA: ${{ needs.prepare.outputs.commit_sha }}
+ run: node scripts/nightly-version.mjs --write-config src-tauri/tauri.nightly.generated.json
+
+ - name: 构建 Linux 包
+ run: pnpm tauri build --config src-tauri/tauri.nightly.generated.json --bundles deb,appimage
+
+ - name: 产物断言 - deb/appimage
+ id: artifact
+ shell: bash
+ run: |
+ set -euo pipefail
+ deb=$(find src-tauri/target -type f -name "code-manager_${NIGHTLY_VERSION}_*.deb" 2>/dev/null | head -1)
+ appimg=$(find src-tauri/target -type f -name "code-manager_${NIGHTLY_VERSION}_*.AppImage" 2>/dev/null | head -1)
+ test -n "$deb"
+ test -n "$appimg"
+ test -s "$deb"
+ test -s "$appimg"
+ echo "deb=$deb" >> "$GITHUB_OUTPUT"
+ echo "appimage=$appimg" >> "$GITHUB_OUTPUT"
+ echo "存在且非空: $deb"
+ echo "存在且非空: $appimg"
+
+ - name: 无头启动冒烟 - Linux
+ shell: bash
+ run: |
+ set -euo pipefail
+ appimage=$(find src-tauri/target -type f -name "code-manager_${NIGHTLY_VERSION}_*.AppImage" 2>/dev/null | head -1)
+ test -f "$appimage"
+ # AppImage 在部分 runner 上缺 FUSE,用解包运行代替挂载
+ export APPIMAGE_EXTRACT_AND_RUN=1
+ xvfb-run -a "$appimage" >/tmp/nightly-linux.log 2>&1 & pid=$!
+ sleep 12
+ if ! kill -0 "$pid" 2>/dev/null; then
+ echo "AppImage 未存活 12 秒,冒烟失败:"
+ cat /tmp/nightly-linux.log 2>/dev/null || true
+ exit 1
+ fi
+ kill "$pid" 2>/dev/null || true
+ echo "AppImage 无头启动冒烟通过"
+
+ - name: 暂存 Linux 产物
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: nightly-linux
+ path: |
+ ${{ steps.artifact.outputs.deb }}
+ ${{ steps.artifact.outputs.appimage }}
+ if-no-files-found: error
+ retention-days: 3
+
+ build-windows:
+ needs: prepare
+ runs-on: windows-2025
+ timeout-minutes: 60
+ permissions:
+ contents: read
+ env:
+ NIGHTLY_VERSION: ${{ needs.prepare.outputs.nightly_version }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.prepare.outputs.commit_sha }}
+ fetch-depth: 0
+
+ - name: 安装 pnpm
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
+
+ - name: 安装 Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: "24"
+ cache: pnpm
+
+ - name: 安装 Rust stable
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ with:
+ toolchain: stable
+
+ - name: 安装前端依赖
+ run: pnpm install --frozen-lockfile
+
+ - name: 注入每夜构建版本
+ env:
+ GITHUB_SHA: ${{ needs.prepare.outputs.commit_sha }}
+ run: node scripts/nightly-version.mjs --write-config src-tauri/tauri.nightly.generated.json
+
+ - name: 构建 Windows NSIS
+ run: pnpm tauri build --config src-tauri/tauri.nightly.generated.json --bundles nsis
+
+ - name: 产物断言 - NSIS
+ id: artifact
+ shell: bash
+ run: |
+ set -euo pipefail
+ setup=$(find src-tauri/target -type f -name "code-manager_${NIGHTLY_VERSION}_x64-setup.exe" 2>/dev/null | head -1)
+ if [ -z "$setup" ]; then
+ echo "未找到 NSIS 安装包: ${NIGHTLY_VERSION}"
+ exit 1
+ fi
+ test -s "$setup"
+ echo "path=$setup" >> "$GITHUB_OUTPUT"
+ echo "存在且非空: $setup"
+
+ - name: 无头启动冒烟 - Windows(静默安装)
+ shell: pwsh
+ run: |
+ $setup = Get-ChildItem -Path "src-tauri/target" -Recurse -Filter "code-manager_${env:NIGHTLY_VERSION}_x64-setup.exe" | Select-Object -First 1
+ if (-not $setup) {
+ Write-Error "未找到 NSIS 安装包: ${env:NIGHTLY_VERSION}"
+ exit 1
+ }
+ Write-Host "静默安装: $($setup.Name)"
+ # NSIS 静默安装;安装路径按 productName 约定
+ Start-Process -FilePath $setup.FullName -ArgumentList '/S' -Wait -NoNewWindow
+ $candidates = @(
+ "$env:LOCALAPPDATA\Programs\code-manager\code-manager.exe",
+ "$env:LOCALAPPDATA\code-manager\code-manager.exe"
+ )
+ $exe = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
+ if (-not $exe) {
+ Write-Error "未找到安装后的可执行文件"
+ exit 1
+ }
+ Write-Host "已安装: $exe"
+ $process = $null
+ try {
+ $process = Start-Process -FilePath $exe -PassThru
+ Start-Sleep -Seconds 12
+ if ($process.HasExited) {
+ Write-Error "应用启动后提前退出,exit code: $($process.ExitCode)"
+ exit 1
+ }
+ Write-Host "应用启动并存活 12 秒,冒烟通过"
+ }
+ finally {
+ if ($process -and -not $process.HasExited) {
+ Stop-Process -Id $process.Id -Force
+ $process.WaitForExit()
+ }
+ }
+
+ - name: 暂存 Windows 产物
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: nightly-windows
+ path: ${{ steps.artifact.outputs.path }}
+ if-no-files-found: error
+ retention-days: 3
+
+ publish:
+ needs: [prepare, build-universal, build-linux, build-windows]
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ permissions:
+ contents: write
+ env:
+ COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }}
+ NIGHTLY_VERSION: ${{ needs.prepare.outputs.nightly_version }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.prepare.outputs.commit_sha }}
+ fetch-depth: 0
+
+ - name: 跳过已过期的 main 构建
+ id: freshness
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ run: |
+ set -euo pipefail
+ publish=true
+ if [ "$EVENT_NAME" = "workflow_run" ]; then
+ latest_main=$(git ls-remote origin refs/heads/main | cut -f1)
+ if [ "$latest_main" != "$COMMIT_SHA" ]; then
+ echo "当前构建 $COMMIT_SHA 已落后于 main $latest_main,跳过发布"
+ publish=false
+ fi
+ fi
+ echo "publish=$publish" >> "$GITHUB_OUTPUT"
+
+ - name: 下载全部平台产物
+ if: ${{ steps.freshness.outputs.publish == 'true' }}
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ pattern: nightly-*
+ merge-multiple: true
+ path: dist
+
+ - name: 发布前核验完整产物集
+ if: ${{ steps.freshness.outputs.publish == 'true' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ assert_one() {
+ local pattern=$1
+ local count
+ count=$(find dist -maxdepth 1 -type f -name "$pattern" | wc -l | tr -d ' ')
+ if [ "$count" -ne 1 ]; then
+ echo "产物数量错误: $pattern, expected=1, actual=$count"
+ exit 1
+ fi
+ }
+
+ total=$(find dist -maxdepth 1 -type f | wc -l | tr -d ' ')
+ if [ "$total" -ne 4 ]; then
+ echo "产物总数错误: expected=4, actual=$total"
+ find dist -maxdepth 1 -type f -print
+ exit 1
+ fi
+
+ assert_one "code-manager_${NIGHTLY_VERSION}_universal.dmg"
+ assert_one "code-manager_${NIGHTLY_VERSION}_*.deb"
+ assert_one "code-manager_${NIGHTLY_VERSION}_*.AppImage"
+ assert_one "code-manager_${NIGHTLY_VERSION}_x64-setup.exe"
+
+ - name: 发布滚动 Nightly Release
+ if: ${{ steps.freshness.outputs.publish == 'true' }}
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ # 所有平台构建与冒烟成功后才替换旧 release;新 release 在产物齐全前保持 draft。
+ gh release delete nightly --yes --cleanup-tag 2>/dev/null || true
+ git push origin --delete nightly 2>/dev/null || true
+ gh release create nightly dist/* \
+ --draft \
+ --prerelease \
+ --latest=false \
+ --target "$COMMIT_SHA" \
+ --title "Code Manager 每夜构建 $NIGHTLY_VERSION" \
+ --notes "滚动构建,每一次 main 合并会覆盖本 Release。版本号含 commit 短 sha,报 bug 请附上(如 $NIGHTLY_VERSION)。"
+ gh release edit nightly --draft=false --prerelease --latest=false
diff --git a/CONTEXT.md b/CONTEXT.md
index 23fcccc..e5728f2 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -64,6 +64,23 @@ _Avoid_: 运行中会话(running session,只是其中一个具体状态)
把承载 Claude Code 会话的终端视图带到前台的动作:宿主终端窗口/tab 激活,以及多路复用器(如 herdr)内部的 pane 选中。聚焦的载体随会话所在环境不同而不同(终端 tab、herdr pane、Ghostty term),但语义不变:用户眼睛看到承载该会话的视图并可直接交互。
_Avoid_: 聚焦终端 tab(herdr 场景没有 tab 概念)、激活窗口(只覆盖一半语义)
+### 发布通道(Release Channel)
+
+#### 每夜构建(Nightly)
+
+持续提供主线最新可测试版本的**滚动发布通道**,用于正式发布前体验新功能和反馈问题。新构建替代旧构建,不承诺稳定性或历史版本保留。
+_Avoid_: 预发布、nightly build(裸指构建时)、canary(语义不同,当前无此通道)
+
+#### 预发布(Prerelease)
+
+表示“比 stable 早”的发布标记,不定义具体通道的更新、保留或质量策略。它可用于标记每夜构建,但两者不是同义词。
+_Avoid_: 把它们当同义词(预发布是标记,每夜构建是通道)
+
+#### 候选版本(Release Candidate)
+
+正式发版前的验收版本,与[每夜构建](#每夜构建nightly)同为"发布前通道",但由发布流程人工/半自动产生、目标指向即将发布的版本。当前**无此通道**;术语先行定义以便后续引入时口径一致。
+_Avoid_: 预发布、beta、RC(裸缩写,除非重申语义)
+
### 目录总览(Directory Overview)
**目录总览(Directory Overview)**:
diff --git a/README.md b/README.md
index 3a11caa..7ed3cf8 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,12 @@ The current macOS release packages are not notarized by Apple. Homebrew installs
xattr -rd com.apple.quarantine /Applications/code-manager.app
```
+### Nightly Builds
+
+Want to try the latest features before they are released? Nightly installers for macOS (`dmg`), Linux (`deb` / `AppImage`), and Windows (`setup.exe`) are built automatically on every `main` merge and published as a rolling prerelease at [releases/download/nightly](https://github.com/maguowei/code-manager/releases/download/nightly). The version includes the commit short SHA (e.g. `1.6.0-nightly.ga1b2c3d`) so you can tell exactly which build you have.
+
+Nightly builds are **not signed or notarized**: macOS will block the first launch (right-click → Open, or `xattr -rd com.apple.quarantine /Applications/code-manager.app`), and Windows may show a SmartScreen warning. Nightly builds roll forward on each merge (older ones are overwritten) and do **not** self-update — install a stable release to get automatic updates.
+
### Automatic Updates
The app has built-in automatic updates: it silently checks for new versions on startup, and once one is found you can download and install it with one click in "Settings - App Update", after which it restarts automatically. Users who installed via Homebrew can also keep upgrading with `brew upgrade`; both paths work, and after an in-app update the version Homebrew records will automatically align on the next `brew upgrade`.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 8341b3e..f02e102 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -63,6 +63,12 @@ macOS 当前发布包未经过 Apple 公证。Homebrew 安装会自动移除隔
xattr -rd com.apple.quarantine /Applications/code-manager.app
```
+### 每夜构建(Nightly)
+
+想提前体验尚未发布的新功能?每次 `main` 合并后会自动构建 macOS(`dmg`)、Linux(`deb`/`AppImage`)、Windows(`setup.exe`) 的每夜构建安装包,以滚动预发布形式发布在 [releases/download/nightly](https://github.com/maguowei/code-manager/releases/download/nightly)。版本号含 commit 短 sha(如 `1.6.0-nightly.ga1b2c3d`),可精确定位你正在使用哪次构建。
+
+每夜构建**未签名/未公证**:macOS 首次打开会被拦截(右键 → 打开,或 `xattr -rd com.apple.quarantine /Applications/code-manager.app`),Windows 可能触发 SmartScreen 提示。每夜构建随每次合并向前滚动(旧构建被覆盖),并且**不参与自更新** —— 需要自动更新请安装正式版。
+
### 自动更新
应用内置自动更新:启动时会静默检查新版本,发现后在「设置 - 应用更新」中可一键下载并安装,安装完成后自动重启。通过 Homebrew 安装的用户也可继续用 `brew upgrade` 升级;两种方式都可用,应用内更新后 Homebrew 记录的版本号会在下次 `brew upgrade` 时自动对齐。
diff --git a/docs/adr/0006-nightly-rolling-prerelease.md b/docs/adr/0006-nightly-rolling-prerelease.md
new file mode 100644
index 0000000..f9e4396
--- /dev/null
+++ b/docs/adr/0006-nightly-rolling-prerelease.md
@@ -0,0 +1,23 @@
+# 每夜构建使用滚动预发布通道、临时构建配置与独立更新策略
+
+主分支 `main` 每次合并后在 CI 上自动构建各平台安装包,供用户提前测试尚未发布的功能,同时不影响已安装稳定版的用户自更新。
+
+## Context
+
+正式版由 `release.yml` 在 `v*` tag 时发布,产物发布前已通过质量门禁,但大众只能等正式发版才拿到包含新功能的包。若直接让测试者安装 pre-release 标记的构建,需要解决三个问题:包要能精确定位到某次 `main` 合并(否则报 bug 无从回溯);预发布构建不能污染稳定版的自更新通道;各平台打包器对版本号后缀的容忍度不同(MSI/RPM 拒绝预发布后缀)。
+
+## Decision
+
+- **滚动每夜构建**:固定 `nightly` tag,每次 `main` 合并后用完整的新产物集替换旧 release,`prerelease: true`,下载链接 `releases/download/nightly` 永久稳定,不刷新 tag 历史。
+- **CI 注入版本号**:构建前先断言 `package.json`、`src-tauri/Cargo.toml` 与 `src-tauri/tauri.conf.json` 的正式版本一致,再生成临时 Tauri overlay,把构建版本设为 `<当前semver>-nightly.g<短sha>`(如 `1.6.0-nightly.ga1b2c3d`)。构建通过 `tauri build --config` 合并 overlay,不改写任何正式版本源或 `Cargo.lock`;产物名、About 页、`package_info()` 均可据此定位 commit。
+- **打包器收窄**:因版本号含 `-`,预发布只在 macOS 出 `dmg`、Linux 出 `deb`+`appimage`、Windows 出 `nsis`;`rpm` 与 `msi` 留给正式版。正式版 `release.yml` / `bundle.targets: "all"` 保持不变。
+- **隔离稳定自更新**:稳定版继续查询 `releases/latest` 下的稳定 `latest.json`,GitHub 会排除预发布 release。Nightly overlay 则清空 updater endpoints,运行时再根据 `-nightly.` 版本标识禁用自动与手动检查;任一层失效都不会把 Nightly 引向稳定版安装包。Nightly 仍不注入 `TAURI_SIGNING_PRIVATE_KEY`,也不生成 `.sig` / `latest.json`。Homebrew cask workflow 已有 `prerelease == false` 判断,不会误更新。
+- **上传编排**:不用 `tauri-action`(其"构建+上传"一步、无法插入中间验证步骤)。各平台 job 独立执行 `tauri build` → 产物断言 → 无头启动冒烟 → 上传 workflow artifact;最终 `publish` job 只在全部成功后核验四类产物、删除旧 release、创建 draft 并一次性上传,最后公开为 prerelease。构建失败不会改变当前可下载的 Nightly。
+- **质量门禁**:`push: main` 由 `workflow_run` 等待 `ci.yml` 成功后才构建(不重复跑 verify);`workflow_dispatch` 走独立轻量前置(actionlint + gitleaks)。Nightly workflow 串行执行且不自动取消发布阶段;发布前再次核对 `main`,过期构建直接跳过。
+
+## Consequences
+
+- `main` 合并到包可下载要等 CI 完成,约比正式发布快;无法回溯历史预发布包(滚动语义,旧包被覆盖)。
+- 预发布包不签名/不公证:macOS 触发 Gatekeeper、Windows 触发 SmartScreen,测试者需额外步骤,已写入 README 说明。
+- 每夜构建**不支持**应用内自更新;设置页会明确提示手动安装新 Nightly。后续若要给测试者提供自动更新,需要独立 endpoint、签名产物与通道切换,属另一个决策。
+- 若未来想用 `tauri-action` 统一正式与预发布,需重新评估各平台打包器的版本后缀行为;本决策刻意保留了两条独立流水线。
diff --git a/scripts/nightly-version.mjs b/scripts/nightly-version.mjs
new file mode 100644
index 0000000..969b501
--- /dev/null
+++ b/scripts/nightly-version.mjs
@@ -0,0 +1,111 @@
+// 每夜构建版本注入:校验正式版本源后生成 Tauri 临时 overlay,不改写仓库内的正式版本文件。
+import { execFileSync } from "node:child_process";
+import { readFileSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = resolve(import.meta.dirname, "..");
+const STABLE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
+const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,40}$/i;
+
+function readJson(path) {
+ return JSON.parse(readFileSync(path, "utf8"));
+}
+
+function readCargoPackageVersion(path) {
+ const cargoToml = readFileSync(path, "utf8");
+ const packageSection = cargoToml.match(/\[package\]([\s\S]*?)(?=\n\[|$)/)?.[1];
+ const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
+ if (!version) {
+ throw new Error(`无法从 ${path} 读取 [package].version`);
+ }
+ return version;
+}
+
+/** 读取并校验三个正式版本源,避免 Nightly 建立在漂移的基础版本上。 */
+export function readCanonicalBaseVersion(rootDir = root) {
+ const versions = {
+ "package.json": readJson(resolve(rootDir, "package.json")).version,
+ "src-tauri/Cargo.toml": readCargoPackageVersion(resolve(rootDir, "src-tauri/Cargo.toml")),
+ "src-tauri/tauri.conf.json": readJson(resolve(rootDir, "src-tauri/tauri.conf.json")).version,
+ };
+ const uniqueVersions = new Set(Object.values(versions));
+ if (uniqueVersions.size !== 1) {
+ const details = Object.entries(versions)
+ .map(([path, version]) => `${path}=${String(version)}`)
+ .join(", ");
+ throw new Error(`正式版本源不一致: ${details}`);
+ }
+
+ const [baseVersion] = uniqueVersions;
+ if (typeof baseVersion !== "string" || !STABLE_VERSION_PATTERN.test(baseVersion)) {
+ throw new Error(`正式版本必须是稳定 SemVer: ${String(baseVersion)}`);
+ }
+ return baseVersion;
+}
+
+/** g 前缀保证纯数字且以 0 开头的短 SHA 仍是合法 SemVer prerelease 标识符。 */
+export function createNightlyVersion(baseVersion, commitSha) {
+ if (!STABLE_VERSION_PATTERN.test(baseVersion)) {
+ throw new Error(`正式版本必须是稳定 SemVer: ${baseVersion}`);
+ }
+ const normalizedSha = commitSha.trim().toLowerCase();
+ if (!COMMIT_SHA_PATTERN.test(normalizedSha)) {
+ throw new Error("commit SHA 必须是至少 7 位、至多 40 位的十六进制字符串");
+ }
+ return `${baseVersion}-nightly.g${normalizedSha.slice(0, 7)}`;
+}
+
+/** overlay 同时注入版本并清空 updater endpoint,防止 Nightly 访问稳定更新通道。 */
+export function writeNightlyConfig(outputPath, version) {
+ const config = {
+ version,
+ plugins: {
+ updater: {
+ endpoints: [],
+ },
+ },
+ };
+ writeFileSync(outputPath, `${JSON.stringify(config, null, 2)}\n`);
+}
+
+function resolveCommitSha() {
+ const environmentSha = process.env.GITHUB_SHA?.trim();
+ if (environmentSha) return environmentSha;
+ return execFileSync("git", ["rev-parse", "HEAD"], {
+ cwd: root,
+ encoding: "utf8",
+ }).trim();
+}
+
+export function runCli(args = process.argv.slice(2)) {
+ const command = args[0] ?? "--print";
+ const baseVersion = readCanonicalBaseVersion(root);
+ const version = createNightlyVersion(baseVersion, resolveCommitSha());
+
+ if (process.env.NIGHTLY_VERSION && process.env.NIGHTLY_VERSION !== version) {
+ throw new Error(
+ `Nightly 版本不一致: expected=${process.env.NIGHTLY_VERSION}, actual=${version}`,
+ );
+ }
+
+ if (command === "--write-config") {
+ const outputPath = args[1];
+ if (!outputPath) throw new Error("--write-config 需要输出路径");
+ writeNightlyConfig(resolve(root, outputPath), version);
+ } else if (command !== "--print") {
+ throw new Error(`未知参数: ${command}`);
+ }
+
+ // stdout 是 workflow 的机器可读契约,只输出纯 SemVer。
+ process.stdout.write(`${version}\n`);
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ try {
+ runCli();
+ } catch (error) {
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/nightly-version.test.mjs b/scripts/nightly-version.test.mjs
new file mode 100644
index 0000000..ff16f3b
--- /dev/null
+++ b/scripts/nightly-version.test.mjs
@@ -0,0 +1,85 @@
+import { execFileSync } from "node:child_process";
+import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { resolve } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ createNightlyVersion,
+ readCanonicalBaseVersion,
+ writeNightlyConfig,
+} from "./nightly-version.mjs";
+
+const temporaryDirectories = [];
+
+function makeTemporaryDirectory() {
+ const directory = mkdtempSync(resolve(tmpdir(), "code-manager-nightly-"));
+ temporaryDirectories.push(directory);
+ return directory;
+}
+
+function writeVersionFixture(root, versions) {
+ mkdirSync(resolve(root, "src-tauri"), { recursive: true });
+ writeFileSync(
+ resolve(root, "package.json"),
+ `${JSON.stringify({ version: versions.package }, null, 2)}\n`,
+ );
+ writeFileSync(
+ resolve(root, "src-tauri/Cargo.toml"),
+ `[package]\nname = "code-manager"\nversion = "${versions.cargo}"\n`,
+ );
+ writeFileSync(
+ resolve(root, "src-tauri/tauri.conf.json"),
+ `${JSON.stringify({ version: versions.tauri }, null, 2)}\n`,
+ );
+}
+
+afterEach(() => {
+ for (const directory of temporaryDirectories.splice(0)) {
+ rmSync(directory, { recursive: true, force: true });
+ }
+});
+
+describe("nightly version", () => {
+ it("为数字开头的短 SHA 添加 g 前缀,保持合法 SemVer", () => {
+ expect(createNightlyVersion("1.6.0", "0123456789abcdef")).toBe(
+ "1.6.0-nightly.g0123456",
+ );
+ });
+
+ it("拒绝不稳定基础版本和非法 commit SHA", () => {
+ expect(() => createNightlyVersion("1.6.0-beta.1", "a123456")).toThrow(
+ "正式版本必须是稳定 SemVer",
+ );
+ expect(() => createNightlyVersion("1.6.0", "123456")).toThrow("commit SHA");
+ expect(() => createNightlyVersion("1.6.0", "not-a-sha")).toThrow("commit SHA");
+ });
+
+ it("要求三个正式版本源保持一致", () => {
+ const fixtureRoot = makeTemporaryDirectory();
+ writeVersionFixture(fixtureRoot, { package: "1.6.0", cargo: "1.6.0", tauri: "1.6.1" });
+
+ expect(() => readCanonicalBaseVersion(fixtureRoot)).toThrow("正式版本源不一致");
+ });
+
+ it("生成只包含 Nightly 覆盖项的临时 Tauri 配置", () => {
+ const fixtureRoot = makeTemporaryDirectory();
+ const outputPath = resolve(fixtureRoot, "tauri.nightly.json");
+ writeNightlyConfig(outputPath, "1.6.0-nightly.ga1b2c3d");
+
+ expect(JSON.parse(readFileSync(outputPath, "utf8"))).toEqual({
+ version: "1.6.0-nightly.ga1b2c3d",
+ plugins: { updater: { endpoints: [] } },
+ });
+ });
+
+ it("--print 的 stdout 只包含纯版本号", () => {
+ const scriptPath = resolve(import.meta.dirname, "nightly-version.mjs");
+ const stdout = execFileSync(process.execPath, [scriptPath, "--print"], {
+ cwd: resolve(import.meta.dirname, ".."),
+ env: { ...process.env, GITHUB_SHA: "0123456789abcdef" },
+ encoding: "utf8",
+ });
+
+ expect(stdout).toBe("1.6.0-nightly.g0123456\n");
+ });
+});
diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx
index 037ae52..9f95ac2 100644
--- a/src/components/SettingsDrawer.tsx
+++ b/src/components/SettingsDrawer.tsx
@@ -1,4 +1,3 @@
-import { getVersion } from "@tauri-apps/api/app";
import {
disable as disableAutostart,
enable as enableAutostart,
@@ -309,22 +308,15 @@ function SettingsSectionCard({
// 应用更新设置卡片:展示当前版本,手动检查更新并下载安装(状态机见 useAppUpdater)
function UpdateSettingsCard() {
const { t } = useI18n();
- const { status, availableVersion, progress, checkForUpdate, downloadAndRestart } = useUpdater();
- const [currentVersion, setCurrentVersion] = useState