mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
本次发布包含三组修复: - fix(ext-data): 资金流等当日快照接口不再被日期一致性校验误拒写; schema 端点恢复返回真实列类型 (DuckDB 1.5 的 DESCRIBE 列名变更); 拉取配置的分页参数不再保存后静默丢失。 - fix(settings): 系统设置的通知弹窗/声效/语音播报/外链模板/界面缩放 改后端持久化, 修复桌面版换端口 (origin 变化) 后配置丢失。 - fix(minute): 残缺分钟分区不再被当作无数据丢弃; 新增分钟缺口体检 面板, 让「同步完成≠数据完整」可见。 同时引入 DCO 贡献来源声明与再许可授权条款, PR 须逐提交带 Signed-off-by (CI 由 .github/workflows/dco.yml 检查)。 版本号同源 5 处: VERSION / frontend/package.json / backend/pyproject.toml / backend/app/__init__.py 的 _FALLBACK_VERSION / release.yml 的 inputs 默认值; uv.lock 仅改项目自身条目 (上次升版误改 pytesseract 的问题不再复现)。 Signed-off-by: shy3130 <shy3130@qq.com>
343 lines
17 KiB
YAML
343 lines
17 KiB
YAML
name: 桌面客户端发布
|
|
|
|
# 触发方式: 手动 (workflow_dispatch)。
|
|
# 在 GitHub Actions 页面点 "Run workflow", 选择要构建的平台 + 版本号,
|
|
# 构建完成后自动创建/更新 Release 并上传安装包。
|
|
#
|
|
# 为什么不用 tag 自动触发:
|
|
# - 多平台并行构建, mac/linux 未就绪时会自动失败污染 Release
|
|
# - 手动触发可精确控制 "今天只发 Windows, 明天补 mac"
|
|
# - 版本号通过 input 传入, 与 tag 解耦, 更灵活
|
|
#
|
|
# 使用:
|
|
# gh workflow run release.yml -f version=v0.2.0 -f platforms=windows
|
|
# 或 GitHub 网页 Actions → 桌面客户端发布 → Run workflow
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
version:
|
|
description: '版本号 (如 v0.1.33, 用作 Release tag 和标题; 必须与 frontend/package.json 的 version 一致, 否则流水线直接失败)'
|
|
required: true
|
|
default: 'v0.3.15'
|
|
platforms:
|
|
description: '要构建的平台 (逗号分隔: windows,macos,linux)'
|
|
required: true
|
|
default: 'windows'
|
|
prerelease:
|
|
description: '是否为预发布 (勾选则标记为 Pre-release)'
|
|
type: boolean
|
|
default: false
|
|
|
|
permissions:
|
|
contents: write # 创建 Release 需要写权限
|
|
|
|
jobs:
|
|
# 第一步: 过滤出本次要构建的平台
|
|
prepare:
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
matrix: ${{ steps.filter.outputs.matrix }}
|
|
ver: ${{ steps.filter.outputs.ver }}
|
|
steps:
|
|
- name: 检出代码
|
|
# 仅为校验版本一致性 (读 frontend/package.json), 浅检出足够
|
|
uses: actions/checkout@v4
|
|
|
|
- id: filter
|
|
run: |
|
|
# 版本号唯一权威 = frontend/package.json (安装包、页面显示、更新检查同源)。
|
|
# 发版输入与它不一致直接失败, 防止 tag/安装包/页面三处版本漂移。
|
|
PKG_VER=$(python3 -c "import json; print(json.load(open('frontend/package.json'))['version'])")
|
|
REQ_VER='${{ github.event.inputs.version }}'
|
|
if [ "v$PKG_VER" != "$REQ_VER" ]; then
|
|
echo "::error::版本不一致: 发版输入 $REQ_VER, frontend/package.json 为 v$PKG_VER。请以 frontend/package.json 为准, 统一版本号后再发版。"
|
|
exit 1
|
|
fi
|
|
|
|
REQUESTED="${{ github.event.inputs.platforms }}"
|
|
# macOS 仅构建 ARM64 (Apple Silicon), 不构建 Intel:
|
|
# 1) GitHub 最后一个原生 Intel runner (macos-13) 已于 2025-12-04 退役;
|
|
# 2) 项目依赖 polars, 而 polars 自 1.33 起移除了 macOS x86_64 原生 wheel
|
|
# (改 py3-none-any), 在 arm64 runner 上经 Rosetta 无法产出可运行的 x64 包。
|
|
# runner 用 macos-26 (2026-02 GA, 免费, arm64 原生, SDK 更新)。
|
|
ALL='[{"os":"windows-latest","artifact":"TSP-Setup-x64.exe","platform":"windows"},{"os":"macos-26","artifact":"TSP-macos-arm64.dmg","platform":"macos","arch":"arm64"},{"os":"ubuntu-latest","artifact":"TSP-linux-x64.tar.gz","platform":"linux"}]'
|
|
# 按 input 过滤平台 (macos → arm64 单项)
|
|
python3 -c "
|
|
import json, sys
|
|
requested = '${{ github.event.inputs.platforms }}'.split(',')
|
|
requested = [r.strip() for r in requested if r.strip()]
|
|
ver = '${{ github.event.inputs.version }}'.lstrip('v') # v0.3.3 → 0.3.3
|
|
all_platforms = json.loads('''$ALL''')
|
|
selected = [p for p in all_platforms if p['platform'] in requested]
|
|
# 产物文件名带版本号 (TSP-Setup-x64-0.3.3.exe): 用户下载后一眼可辨版本。
|
|
# 按已知后缀插入版本号 — 不能用 rpartition('.') 拆扩展名:
|
|
# "TSP-linux-x64.tar.gz" 会拆成 stem=".tar" + ext="gz", 拼出
|
|
# "TSP-linux-x64.tar-0.3.9.gz", 与 latest.json 清单的通配格式不符。
|
|
for p in selected:
|
|
name = p['artifact']
|
|
for suffix in ('.tar.gz', '.dmg', '.exe'):
|
|
if name.endswith(suffix):
|
|
p['artifact'] = f'{name[:-len(suffix)]}-{ver}{suffix}'
|
|
break
|
|
else:
|
|
raise SystemExit(f'未知产物后缀: {name}')
|
|
print('matrix=' + json.dumps({'include': selected}))
|
|
with open('$GITHUB_OUTPUT', 'a') as f:
|
|
f.write('matrix=' + json.dumps({'include': selected}) + '\n')
|
|
f.write('ver=' + ver + '\n')
|
|
"
|
|
|
|
# 第二步: 各平台并行构建
|
|
build:
|
|
needs: prepare
|
|
runs-on: ${{ matrix.os }}
|
|
strategy:
|
|
fail-fast: false # 单平台失败不影响其他平台
|
|
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
|
|
steps:
|
|
- name: 检出代码
|
|
uses: actions/checkout@v4
|
|
|
|
- name: 安装 pnpm
|
|
uses: pnpm/action-setup@v4
|
|
with:
|
|
version: 9
|
|
|
|
- name: 设置 Node 20
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '20'
|
|
cache: 'pnpm'
|
|
cache-dependency-path: frontend/pnpm-lock.yaml
|
|
|
|
- name: 构建前端
|
|
working-directory: frontend
|
|
run: |
|
|
pnpm install --frozen-lockfile
|
|
pnpm build
|
|
|
|
- name: 安装 uv
|
|
uses: astral-sh/setup-uv@v3
|
|
with:
|
|
enable-cache: true
|
|
|
|
- name: 设置 Python
|
|
run: uv python install 3.12
|
|
|
|
- name: 安装后端依赖 (含 desktop + legacy-cpu, 不含 backtest)
|
|
working-directory: backend
|
|
# --no-dev 排除 pytest/ruff/mypy; --extra desktop 装 pywebview
|
|
# --extra legacy-cpu 装 polars[rtcompat] 运行时兼容内核:
|
|
# 让安装包同时兼容 AVX2 新 CPU 和无 AVX2 的老 CPU (NAS/老服务器/国产 CPU)。
|
|
# rtcompat 会在运行时自动探测 CPU 能力 —— 有 AVX2 用 AVX2, 没有则回退,
|
|
# 对新 CPU 性能几乎无损 (Polars 1.x 官方推荐方案)。
|
|
# 不加 --extra backtest, 主包不含 vectorbt/numba/llvmlite
|
|
run: uv sync --no-dev --extra desktop --extra legacy-cpu
|
|
|
|
- name: 安装 PyInstaller
|
|
# 必须装进 backend 的 uv 环境 (pywebview/polars 等依赖都在那里),
|
|
# 否则根目录 uv run 找不到依赖, 打包出来的产物启动即崩。
|
|
# 注意: 不用 `uv pip install --project backend` —— 在 macos runner 上
|
|
# --project 的 venv 自动发现不可靠 (报 "No virtual environment found")。
|
|
# 用 working-directory 进 backend 目录, 与上面 uv sync 的环境一致。
|
|
working-directory: backend
|
|
run: uv pip install pyinstaller
|
|
|
|
- name: 打包 (PyInstaller)
|
|
# 在 backend 目录跑 (用该目录的 venv: pywebview/polars 等依赖)。
|
|
# 产物输出到 backend/dist/ (PyInstaller 默认路径), 与 packaging/tsp.iss
|
|
# 的 Source 路径 (..\backend\dist\TSP\*) 保持一致。
|
|
# SPECPATH 由 spec 文件位置决定 (packaging/tsp.spec → 项目根),
|
|
# 与 cwd 无关, 故 frontend/dist、tiers.yaml 等相对路径仍正确。
|
|
working-directory: backend
|
|
run: uv run pyinstaller ../packaging/tsp.spec --noconfirm
|
|
|
|
- name: 生成升级保护清单
|
|
# 二次开发升级对比基线: 对打包产出的 _internal 全量文件做 sha256。
|
|
# 相对路径与运行时 _MEIPASS 布局一致, 清单落 _internal/app/ 后随
|
|
# Inno/DMG/tar 一起分发, 运行时由 upgrade_guard 读取。
|
|
# uv run --no-project: 跨平台用 uv 管的 Python; 版本号唯一权威 =
|
|
# frontend/package.json (与 app/__init__._load_version 同源)。
|
|
# shell: bash —— Windows runner 默认是 pwsh, 不认识 FOO=$(...) 语法;
|
|
# 三平台 runner 均自带 bash, 显式指定即可统一。
|
|
shell: bash
|
|
run: |
|
|
APP_VER=$(node -p "require('./frontend/package.json').version")
|
|
uv run --no-project python scripts/generate_manifest.py --root backend/dist/TSP/_internal --output backend/dist/TSP/_internal/app/upgrade_manifest.json --version "$APP_VER"
|
|
|
|
- name: 检查 Polars 兼容内核 (仅 Windows)
|
|
if: matrix.platform == 'windows'
|
|
shell: pwsh
|
|
run: |
|
|
$runtimeRoot = 'backend/dist/TSP/_internal'
|
|
$required = @(
|
|
'_polars_runtime_32/_polars_runtime.pyd',
|
|
'_polars_runtime_compat/_polars_runtime.pyd'
|
|
)
|
|
$missing = $required | Where-Object {
|
|
-not (Test-Path (Join-Path $runtimeRoot $_))
|
|
}
|
|
if ($missing) {
|
|
throw "PyInstaller output is missing Polars runtime(s): $($missing -join ', ')"
|
|
}
|
|
|
|
- name: 安装 Inno Setup (仅 Windows)
|
|
if: matrix.platform == 'windows'
|
|
# Inno Setup 6: 把 PyInstaller 产出封装成单文件安装包
|
|
run: choco install innosetup -y --no-progress
|
|
|
|
- name: 生成 Windows 安装包 (Inno Setup)
|
|
if: matrix.platform == 'windows'
|
|
# 从 frontend/package.json 读版本号传给 ISCC, 保持与 Release tag 一致
|
|
run: |
|
|
$ver = (Get-Content frontend/package.json | ConvertFrom-Json).version
|
|
echo "版本号: $ver"
|
|
ISCC.exe "/DMyAppVersion=$ver" packaging/tsp.iss
|
|
# 把生成的 Setup.exe 移到工作区根 (Release 上传用)
|
|
Move-Item packaging/Output/TSP-Setup-$ver.exe ${{ matrix.artifact }} -Force
|
|
shell: pwsh
|
|
|
|
- name: 生成 DMG (macOS)
|
|
if: matrix.platform == 'macos'
|
|
# 用 create-dmg 生成标准 macOS DMG:
|
|
# 挂载后显示一个带背景的卷, 左侧 app 图标, 右侧 Applications 替身,
|
|
# 用户直接把图标拖进 Applications 即完成安装 (符合 mac 习惯)。
|
|
# --app-drop-link 在指定坐标生成 Applications 替身 (即"拖拽安装"箭头的目标);
|
|
# --skip-jenkins 跳过 Finder 排图标动画 (CI 无 GUI, 否则 AppleScript 卡住)。
|
|
# 工作目录是项目根 (与 Windows/Linux 步骤一致), 产物输出到根。
|
|
# create-dmg 语法: create-dmg [选项] <输出.dmg> <源目录或.app>
|
|
run: |
|
|
brew install create-dmg
|
|
create-dmg \
|
|
--volname "Tick Stock Panel" \
|
|
--window-pos 200 120 \
|
|
--window-size 600 400 \
|
|
--icon-size 100 \
|
|
--icon "TSP.app" 175 190 \
|
|
--app-drop-link 425 190 \
|
|
--hide-extension "TSP.app" \
|
|
--skip-jenkins \
|
|
--no-internet-enable \
|
|
"${{ matrix.artifact }}" \
|
|
backend/dist/TSP.app
|
|
|
|
- name: 压缩产物 (Linux)
|
|
if: matrix.platform == 'linux'
|
|
run: |
|
|
cd backend/dist
|
|
tar -czf ../../${{ matrix.artifact }} TSP
|
|
|
|
- name: 上传产物为构建产物 (备查)
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: ${{ matrix.artifact }}
|
|
path: ${{ matrix.artifact }}
|
|
|
|
- name: 上传到 GitHub Release
|
|
uses: softprops/action-gh-release@v2
|
|
with:
|
|
tag_name: ${{ github.event.inputs.version }}
|
|
name: ${{ github.event.inputs.version }}
|
|
files: ${{ matrix.artifact }}
|
|
prerelease: ${{ github.event.inputs.prerelease }}
|
|
generate_release_notes: true # 自动从 commit 生成 changelog
|
|
body: |
|
|
## 桌面客户端 ${{ github.event.inputs.version }}
|
|
|
|
### ⬇️ 下载
|
|
|
|
| 平台 | 文件 | 下载 |
|
|
| :--- | :--- | :--- |
|
|
| **Windows x64** | `TSP-Setup-x64-${{ needs.prepare.outputs.ver }}.exe` | [⬇️ 立即下载](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TSP-Setup-x64-${{ needs.prepare.outputs.ver }}.exe) |
|
|
| **macOS (Apple Silicon / M 系列芯片)** | `TSP-macos-arm64-${{ needs.prepare.outputs.ver }}.dmg` | [⬇️ 立即下载](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TSP-macos-arm64-${{ needs.prepare.outputs.ver }}.dmg) |
|
|
| **Linux** | `TSP-linux-x64-${{ needs.prepare.outputs.ver }}.tar.gz` | [⬇️ 立即下载](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TSP-linux-x64-${{ needs.prepare.outputs.ver }}.tar.gz) |
|
|
|
|
> 💡 点击「立即下载」直接获取, 无需滚动到底部附件区。
|
|
> 🍎 **macOS 仅提供 Apple Silicon (M 系列芯片) 版本**: 点左上角 → "关于本机", 看"芯片"一项是否为 Apple; Intel 芯片 Mac 暂不支持 (上游 polars 已停止发布 Intel wheel)。
|
|
|
|
### 📋 使用说明
|
|
- **Windows**: 下载后双击运行, 按安装向导操作(无需管理员权限), 自动创建桌面/开始菜单快捷方式
|
|
- **macOS**: 双击 dmg 打开, 将 TSP 拖入 Applications 文件夹; 首次启动需右键→打开(绕过 Gatekeeper)
|
|
- **Linux**: 解压后运行可执行文件
|
|
- 数据存储: Windows 在安装目录的 `data/` 子文件夹 (卸载重装不丢); macOS 在 `~/Library/Application Support/TSP` (替换 App 升级不丢)
|
|
- 内置 Polars 兼容内核, 新老 CPU (无 AVX2) 均可运行
|
|
- 含纯 Polars 回测引擎; 不含 vectorbt 回测(为控制体积)
|
|
- 系统通知: 设置 → 实时监控 → 系统通知
|
|
- 检查更新: 设置 → 系统设置 → 关于
|
|
|
|
# 第三步: 生成 latest.json 更新清单 (客户端「检查更新」的稳定契约)
|
|
manifest:
|
|
needs: build
|
|
# fail-fast: false 时部分平台失败会让 build 整体标红, 但已成功的平台资产
|
|
# 依然有效 — 清单照样生成 (以 Release 上实际存在的资产为准, 天然支持
|
|
# "今天发 Windows, 明天补 mac" 的分批发布: 每次按当前 Release 全量重建)。
|
|
if: ${{ !cancelled() && needs.build.result != 'skipped' }}
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: 生成并上传 latest.json
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
TAG: ${{ github.event.inputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
mkdir -p assets && cd assets
|
|
# 只拉已知的三个安装包 (latest.json 自身等其他资产不进清单);
|
|
# 产物文件名带版本号, 故用通配匹配当前版本
|
|
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
|
|
--pattern 'TSP-Setup-x64-*.exe' \
|
|
--pattern 'TSP-macos-arm64-*.dmg' \
|
|
--pattern 'TSP-linux-x64-*.tar.gz' \
|
|
--clobber || true
|
|
cd ..
|
|
RELEASE_JSON=$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG")
|
|
TAG="$TAG" REPO="$GITHUB_REPOSITORY" RELEASE_JSON="$RELEASE_JSON" python3 - <<'EOF'
|
|
import hashlib, json, os, sys
|
|
from pathlib import Path
|
|
|
|
tag = os.environ["TAG"]
|
|
repo = os.environ["REPO"]
|
|
rel = json.loads(os.environ["RELEASE_JSON"])
|
|
|
|
# version 去掉 v 前缀, 与 package.json 语义版本一致
|
|
# (产物文件名 TSP-Setup-x64-0.3.3.exe 用的就是不带 v 的版本)
|
|
version = tag[1:] if tag.startswith("v") else tag
|
|
|
|
# 资产文件名 → 渠道名 (客户端按渠道取下载地址); 文件名带版本号, 按当前 tag 拼
|
|
channels_cfg = {
|
|
f"TSP-Setup-x64-{version}.exe": "windows",
|
|
f"TSP-macos-arm64-{version}.dmg": "macos-arm64",
|
|
f"TSP-linux-x64-{version}.tar.gz": "linux-x64",
|
|
}
|
|
by_name = {a["name"]: a for a in rel.get("assets", [])}
|
|
|
|
channels = {}
|
|
for name, channel in channels_cfg.items():
|
|
local = Path("assets") / name
|
|
if not local.exists():
|
|
continue # 本次未构建该平台 (分批发布), 清单只列实际存在的
|
|
sha = hashlib.sha256(local.read_bytes()).hexdigest()
|
|
channels[channel] = {
|
|
"name": name,
|
|
"url": by_name[name]["browser_download_url"],
|
|
"size": by_name[name]["size"],
|
|
"sha256": sha,
|
|
}
|
|
|
|
if not channels:
|
|
print("::warning::Release 上没有任何安装包资产, 跳过 latest.json 生成")
|
|
sys.exit(0)
|
|
|
|
manifest = {
|
|
"version": version,
|
|
"tag": tag,
|
|
"released_at": rel.get("published_at") or rel.get("created_at"),
|
|
"prerelease": bool(rel.get("prerelease")),
|
|
"notes_url": f"https://github.com/{repo}/releases/tag/{tag}",
|
|
"channels": channels,
|
|
}
|
|
Path("latest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
print(json.dumps(manifest, indent=2))
|
|
EOF
|
|
# --clobber: 同名资产覆盖 (重跑同一版本时更新清单而不是追加 _01 副本)
|
|
gh release upload "$TAG" latest.json --repo "$GITHUB_REPOSITORY" --clobber
|