自托管 Gitea CI + Inno Setup 安装程序 + 文件级增量更新 -- 复用指南
1. 架构概览
flowchart TD
CI[CI: dotnet publish 多文件] --> Manifest[生成 manifest.json + minisign 签名]
CI --> Upload[上传所有文件为 release assets]
Manifest --> Upload
Upload --> Release[Gitea Private Release vX.Y.Z]
Local[本地 Windows + ISCC] -.-> Release
Release -- "setup.exe(新装用户首次下载)" --> Wizard[Win 安装向导]
Wizard --> Installed[安装目录: 多文件布局]
Release -- "manifest.json + 单文件 asset (Authorization: token)" --> App[已安装的应用]
App --> Updater[updater.exe 替换 sha256 变化的文件]三层职责
| 层 | 职责 | 频率 |
|---|---|---|
| setup.exe | 首次安装:选目录、铺文件、注册卸载、建快捷方式 | 一次性,极少重出 |
| 多文件运行 | 安装目录中启动 {{AppName}}.exe,结构同 dotnet publish | 每次启动 |
| 增量更新 | updater 比对 sha256 只下载变化文件 | 每次发版自动 |
核心原则
- setup.exe 不进 CI,本地 Windows 手工出包
- setup.exe 不需要每次发版都重出,跨 tag 复用
- 老用户全部走 updater 文件级增量,与 setup.exe 无关
2. 占位符约定
在新项目中替换以下占位符:
| 占位符 | 含义 | 示例 |
|---|---|---|
{{AppName}} | 程序名 / 安装目录名 | FeishuWorkflowTool |
{{AppExeName}} | 主程序 exe 名 | FeishuWorkflowTool.exe |
{{AppPublisher}} | 发布者 | 函证自动化 |
{{AppGuid}} | Inno Setup AppId(生成一次永不改) | {{A3F2C1B0-7E4D-4F9A-8B3C-6D2E1F0A5C9B}} |
{{GiteaBaseURL}} | Gitea 服务器地址 | http://git.fangsiyuan.top |
{{Owner}} | Gitea 用户名 | TomHanck4 |
{{Repo}} | 仓库名 | feishuworkflowtool |
{{MainProject}} | 主程序 csproj 路径 | FeishuWorkflowTool.csproj |
{{UpdaterProject}} | 更新器 csproj 路径 | Updater/Updater.csproj |
{{PackProject}} | manifest 生成工具 csproj 路径 | Tools/Pack/Pack.csproj |
3. Gitea 仓库设置
3.1 创建私有仓库
Gitea 网页 → 新建仓库 → 设为 Private。
3.2 两个 Token 的职责分离
| Token 名称 | 权限 | 用途 | 存放位置 |
|---|---|---|---|
RELEASE_TOKEN | write:repository | CI 创建 release、上传 assets;本地上传 setup.exe | Gitea CI Secrets + 本地环境变量 |
UPDATER_RELEASE_TOKEN | read:repository | 嵌入客户端二进制,运行时拉 release 检查更新 | Gitea CI Secrets(构建时注入) |
两个 token 严格分离。客户端只嵌入只读 token,写权限 token 永远不进二进制。
3.3 CI Secrets 配置
Gitea 仓库 → Settings → Actions → Secrets,添加:
| Secret 名 | 值 |
|---|---|
RELEASE_TOKEN | 写权限 token |
UPDATER_RELEASE_TOKEN | 只读 token |
MINISIGN_PRIVATE_KEY | minisign 签名私钥(可选,用于 manifest 签名) |
3.4 CI Variables 配置
Gitea 仓库 → Settings → Actions → Variables,添加:
| Variable 名 | 值 |
|---|---|
BASE_URL | {{GiteaBaseURL}} |
REPO_OWNER | {{Owner}} |
REPO_NAME | {{Repo}} |
4. Git 本地配置与推送流程
4.1 Remote 配置
git remote add origin http://{{Owner}}@{{GiteaBaseURL}}/{{Owner}}/{{Repo}}.git4.2 发版流程
# 1. 升版本号(修改 csproj 中的 Version / FileVersion / AssemblyVersion)
.\Tools\Bump.ps1 patch # bug 修复:3.2.1 → 3.2.2
.\Tools\Bump.ps1 minor # 新功能:3.2.1 → 3.3.0
.\Tools\Bump.ps1 major # 破坏性变更:3.2.1 → 4.0.0
.\Tools\Bump.ps1 3.5.0 # 直接指定版本号
# 2. 发版(自动 commit + tag + push,触发 CI)
.\Tools\Release.ps1 "• 新增xxx`n• 修复yyy"Release.ps1 会:检测未提交变更 → 自动 commit → 创建 annotated tag → push 触发 CI。
CI 流水线会自动复用或重建同 tag 的 release,实现幂等。
重跑 CI(幂等 tag 策略):
git tag -f v1.0.0 HEAD
git push origin v1.0.0 --force5. CI 流水线模板
文件位置:.gitea/workflows/release.yml
name: Release
on:
push:
tags:
- 'v[0-9]*.[0-9]*.[0-9]*'
jobs:
build-and-release:
runs-on: linux
container:
image: mcr.microsoft.com/dotnet/sdk:8.0
steps:
- name: Install tools
run: |
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
git curl ca-certificates python3 minisign
dotnet --info | head -n 20
- name: Checkout
run: |
TAG="${GITEA_REF_NAME:-${GITHUB_REF_NAME}}"
git clone --depth=1 --branch "$TAG" \
"http://{{Owner}}:${{ secrets.RELEASE_TOKEN }}@{{GiteaBaseURL 去协议}}/{{Owner}}/{{Repo}}.git" \
repo
echo "REPO_DIR=$(pwd)/repo" >> "$GITHUB_ENV"
echo "TAG=$TAG" >> "$GITHUB_ENV"
- name: Extract version from tag
id: version
run: |
TAG="${TAG}"
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Publish main app (win-x64)
working-directory: ${{ env.REPO_DIR }}
run: |
dotnet publish {{MainProject}} \
-c Release \
-r win-x64 \
--self-contained true \
-p:EnableWindowsTargeting=true \
-p:GiteaReleaseToken=${{ secrets.UPDATER_RELEASE_TOKEN }} \
-o ./stage
env:
DOTNET_CLI_TELEMETRY_OPTOUT: 1
- name: Publish Updater (win-x64)
working-directory: ${{ env.REPO_DIR }}
run: |
dotnet publish {{UpdaterProject}} \
-c Release \
-r win-x64 \
--self-contained true \
-p:EnableWindowsTargeting=true \
-o ./stage
env:
DOTNET_CLI_TELEMETRY_OPTOUT: 1
- name: Generate manifest.json
working-directory: ${{ env.REPO_DIR }}
run: |
dotnet run --project {{PackProject}} -- \
--stage ./stage \
--out ./stage/manifest.json \
--app-version "${{ steps.version.outputs.version }}" \
--notes "${{ github.event.head_commit.message }}"
- name: Sign manifest with minisign
working-directory: ${{ env.REPO_DIR }}
run: |
cat > /tmp/minisign.sec <<'MINISIGN_EOF'
${{ secrets.MINISIGN_PRIVATE_KEY }}
MINISIGN_EOF
chmod 600 /tmp/minisign.sec
# -W = prehashed: 先 BLAKE2b-512(manifest) 再 Ed25519 签名
# 产物签名 blob 前 2 字节为 'ED' (0x45 0x44)
# 客户端 MinisignVerifier 必须按 prehashed 模式验签(见 §6.5),否则永远 false
minisign -S -W -m ./stage/manifest.json -s /tmp/minisign.sec
rm -f /tmp/minisign.sec
# ---- 创建 release(幂等:先删同 tag 旧 release) ----
- name: Create Gitea Release
id: create_release
working-directory: ${{ env.REPO_DIR }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.tag }}"
NOTES=$(git log -1 --format=%B "$TAG")
PAYLOAD_FILE=/tmp/release-payload.json
RESPONSE_FILE=/tmp/create-release-response.json
EXISTING_RELEASE_FILE=/tmp/existing-release.json
EXISTING_STATUS=$(curl -sS -o "$EXISTING_RELEASE_FILE" -w "%{http_code}" \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
"${{ vars.BASE_URL }}/api/v1/repos/${{ vars.REPO_OWNER }}/${{ vars.REPO_NAME }}/releases/tags/${TAG}")
case "$EXISTING_STATUS" in
200)
EXISTING_ID=$(EXISTING_RELEASE_FILE="$EXISTING_RELEASE_FILE" python3 - <<'PYEOF'
import json, os, sys
text = open(os.environ["EXISTING_RELEASE_FILE"], encoding="utf-8").read()
try:
data = json.loads(text)
except json.JSONDecodeError:
sys.exit(0)
print(data.get("id", ""))
PYEOF
)
if [ -n "$EXISTING_ID" ]; then
curl -sS -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
"${{ vars.BASE_URL }}/api/v1/repos/${{ vars.REPO_OWNER }}/${{ vars.REPO_NAME }}/releases/${EXISTING_ID}"
fi
;;
404) echo "No existing release for tag $TAG, proceeding." ;;
*) echo "Unexpected status $EXISTING_STATUS" >&2; exit 1 ;;
esac
VERSION="$VERSION" TAG="$TAG" NOTES="$NOTES" PAYLOAD_FILE="$PAYLOAD_FILE" python3 - <<'EOF'
import json, os
payload = {
"tag_name": os.environ["TAG"],
"name": f"v{os.environ['VERSION']}",
"body": os.environ.get("NOTES", ""),
"draft": False,
"prerelease": False,
}
with open(os.environ["PAYLOAD_FILE"], "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False)
EOF
HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
"${{ vars.BASE_URL }}/api/v1/repos/${{ vars.REPO_OWNER }}/${{ vars.REPO_NAME }}/releases" \
--data-binary "@$PAYLOAD_FILE")
RELEASE_ID=$(RESPONSE_FILE="$RESPONSE_FILE" python3 - <<'EOF'
import json, os, sys
text = open(os.environ["RESPONSE_FILE"], encoding="utf-8").read()
data = json.loads(text)
print(data["id"])
EOF
)
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
# ---- 上传 assets ----
- name: Upload manifest.json
run: |
curl -s -X POST \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
-F "attachment=@./stage/manifest.json" \
"${{ vars.BASE_URL }}/api/v1/repos/${{ vars.REPO_OWNER }}/${{ vars.REPO_NAME }}/releases/${{ steps.create_release.outputs.release_id }}/assets?name=manifest.json"
working-directory: ${{ env.REPO_DIR }}
- name: Upload manifest.json.minisig
run: |
curl -s -X POST \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
-F "attachment=@./stage/manifest.json.minisig" \
"${{ vars.BASE_URL }}/api/v1/repos/${{ vars.REPO_OWNER }}/${{ vars.REPO_NAME }}/releases/${{ steps.create_release.outputs.release_id }}/assets?name=manifest.json.minisig"
working-directory: ${{ env.REPO_DIR }}
- name: Upload release assets
working-directory: ${{ env.REPO_DIR }}
run: |
python3 - <<'EOF'
import json, os, subprocess, sys
stage = os.environ.get("STAGE_DIR")
base_url = os.environ.get("GITEA_BASE_URL")
owner = os.environ.get("GITEA_REPO_OWNER")
repo = os.environ.get("GITEA_REPO_NAME")
token = os.environ.get("GITEA_TOKEN")
rel_id = os.environ.get("RELEASE_ID")
with open(os.path.join(stage, "manifest.json")) as f:
manifest = json.load(f)
for entry in manifest.get("files", []):
rel_path = entry["path"]
asset_name = entry.get("assetName") or os.path.basename(rel_path)
file_path = os.path.join(stage, rel_path.replace("/", os.sep))
if not os.path.exists(file_path):
print(f"WARN: missing {file_path}", file=sys.stderr); continue
upload_url = f"{base_url}/api/v1/repos/{owner}/{repo}/releases/{rel_id}/assets?name={asset_name}"
result = subprocess.run([
"curl", "-s", "-X", "POST",
"-H", f"Authorization: token {token}",
"-F", f"attachment=@{file_path}",
upload_url
], capture_output=True, text=True)
if result.returncode != 0:
print(f"ERROR uploading {asset_name}: {result.stderr}", file=sys.stderr)
sys.exit(1)
print(f"Uploaded: {asset_name}")
EOF
env:
STAGE_DIR: ./stage
GITEA_BASE_URL: ${{ vars.BASE_URL }}
GITEA_REPO_OWNER: ${{ vars.REPO_OWNER }}
GITEA_REPO_NAME: ${{ vars.REPO_NAME }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
RELEASE_ID: ${{ steps.create_release.outputs.release_id }}
- name: Print release URL
run: |
echo "Release: ${{ vars.BASE_URL }}/${{ vars.REPO_OWNER }}/${{ vars.REPO_NAME }}/releases/tag/${{ steps.version.outputs.tag }}"
echo "If setup.exe needs update: Tools/Installer/build-setup.ps1 -Version ${{ steps.version.outputs.version }}"6. 客户端 Token 注入机制
6.1 AppConstants 静态属性
// Models/AppConstants.cs
public static string GiteaReleaseToken { get; set; } = "";
public static string GiteaBaseUrl { get; set; } = "{{GiteaBaseURL}}";
public static string GiteaRepoOwner { get; set; } = "{{Owner}}";
public static string GiteaRepoName { get; set; } = "{{Repo}}";6.2 csproj 构建期注入
<!-- {{MainProject}} 末尾 -->
<ItemGroup Condition="'$(GiteaReleaseToken)' != ''">
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>GiteaReleaseToken</_Parameter1>
<_Parameter2>$(GiteaReleaseToken)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>CI 通过 -p:GiteaReleaseToken=${{ secrets.UPDATER_RELEASE_TOKEN }} 传入。
6.3 App.xaml.cs 启动时反射读取
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var releaseToken = Assembly.GetExecutingAssembly()
.GetCustomAttributes<AssemblyMetadataAttribute>()
.FirstOrDefault(a => a.Key == "GiteaReleaseToken")?.Value;
if (!string.IsNullOrEmpty(releaseToken))
AppConstants.GiteaReleaseToken = releaseToken;
// ... 后续初始化
}6.4 HttpClient 统一注入 Authorization
// Services/Update/GiteaReleaseChannel.cs 构造函数
public GiteaReleaseChannel()
{
_httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
var token = AppConstants.GiteaReleaseToken;
if (!string.IsNullOrEmpty(token))
_httpClient.DefaultRequestHeaders.Add("Authorization", $"token {token}");
}DefaultRequestHeaders 对该 _httpClient 的所有请求生效,覆盖 releases 列表 / manifest 下载 / asset 下载三处。
6.5 minisign 验签必须支持 prehashed(关键陷阱)
CI 用 minisign -S -W 产出的签名是 prehashed 模式:先 BLAKE2b-512(manifest),再 Ed25519 签 64 字节哈希。签名 blob 前 2 字节为 ASCII ED(0x45 0x44)。普通 Ed25519 验证库直接喂原始 manifest 字节会永远验失败,从而 CheckForUpdateAsync 静默返回"已是最新版本",整个增量更新通道完全不可用。
症状:从 v0.0.1 到任意后续版本,"检查更新"始终提示"已是最新";CI/Release/manifest 全部产物正常,仅客户端验签失败。这条路径如果只看代码不抓签名头字节,几乎无法定位。
判别:
# 取签名 blob 前 2 字节
sed -n '2p' manifest.json.minisig | base64 -d | head -c 2 | xxd
# 输出 4544 = 'ED' = prehashed
# 输出 4564 = 'Ed' = pure正确实现(BouncyCastle):
using Org.BouncyCastle.Crypto.Digests;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Security;
public static bool Verify(byte[] data, string signatureFileContent)
{
var sigBlob = Convert.FromBase64String(signatureFileContent.Split('\n')[1].Trim());
var pkBlob = Convert.FromBase64String(AppConstants.MinisignPublicKeyBase64);
var algo = (char)sigBlob[0] + "" + (char)sigBlob[1];
var prehashed = algo == "ED";
if (!prehashed && algo != "Ed") return false;
var edSignature = new byte[64];
Array.Copy(sigBlob, 10, edSignature, 0, 64);
var pubKeyBytes = new byte[32];
Array.Copy(pkBlob, 10, pubKeyBytes, 0, 32);
byte[] message;
if (prehashed)
{
var blake = new Blake2bDigest(512);
blake.BlockUpdate(data, 0, data.Length);
message = new byte[64];
blake.DoFinal(message, 0);
}
else
{
message = data;
}
var verifier = SignerUtilities.GetSigner("Ed25519");
verifier.Init(false, new Ed25519PublicKeyParameters(pubKeyBytes));
verifier.BlockUpdate(message, 0, message.Length);
return verifier.VerifySignature(edSignature);
}要点:根据签名头字节决定是否做 BLAKE2b-512 预哈希,不要硬编码模式。CI 改 -S (无 -W) 时签名头会变 Ed,本实现自动兼容。
7. Inno Setup 安装程序
7.1 前置依赖
- Windows 上安装 Inno Setup 6
- 默认路径
C:\Program Files (x86)\Inno Setup 6\ISCC.exe
7.2 目录结构
Tools/Installer/
├── installer.iss # Inno Setup 脚本
├── build-setup.ps1 # 本地构建脚本(UTF-8 BOM)
└── i18n/
└── ChineseSimplified.isl # 中文语言文件(UTF-8 BOM,随仓库分发)7.3 installer.iss 模板
#define AppName "{{AppName}}"
#define AppPublisher "{{AppPublisher}}"
#define AppExeName "{{AppExeName}}"
[Setup]
; AppId 一经设定永不更改,否则 Windows 视为全新程序
AppId={#{{AppGuid}}
AppName={#AppName}
AppVersion={#AppVersion}
AppPublisher={#AppPublisher}
DefaultDirName={autopf}\{#AppName}
DefaultGroupName={#AppName}
OutputBaseFilename={{AppName}}Setup
OutputDir={#OutputDir}
Compression=lzma2/ultra
SolidCompression=yes
PrivilegesRequired=lowest
UninstallDisplayIcon={app}\{#AppExeName}
SetupIconFile={#StageDir}\app.ico
WizardStyle=modern
[Languages]
Name: "chinesesimplified"; MessagesFile: "i18n\ChineseSimplified.isl"
[Tasks]
Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加任务:"; Flags: unchecked
[Files]
Source: "{#StageDir}\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion
[Icons]
Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"
Name: "{group}\卸载 {#AppName}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#AppExeName}"; Description: "立即启动 {#AppName}"; Flags: nowait postinstall skipifsilent
[Code]
procedure InitializeWizard;
begin
{ D 盘存在时默认安装到 D 盘,否则 fallback 到 {autopf}(lowest 权限下为 %LOCALAPPDATA%\Programs) }
if DirExists('D:\') then
WizardForm.DirEdit.Text := 'D:\{#AppName}'
else
WizardForm.DirEdit.Text := ExpandConstant('{autopf}\{#AppName}');
end;InitializeWizard 只设置默认值,用户可在向导中手动修改路径。PrivilegesRequired=lowest 不变,无需管理员权限。
7.4 build-setup.ps1 模板要点
- 文件必须保存为 UTF-8 with BOM(PowerShell 5.x 依赖 BOM 按 UTF-8 解码 .ps1)
- 脚本不应含 emoji 和复杂中文标点,否则 Windows GBK 编码会解析失败
核心三步:
dotnet publish {{MainProject}}→ stage 目录dotnet publish {{UpdaterProject}}→ 同一 stage 目录ISCC.exe installer.iss /DAppVersion=... /DStageDir=... /DOutputDir=...
7.5 AppId 约束
AppId用New-Guid生成一次,写入 installer.iss 后永不更改- 改了 AppId = Windows 认为是全新程序,旧用户无法升级安装
SetupIconFile引用{#StageDir}\app.ico,要求 publish 输出目录包含 app.ico
8. 环境变量速查
8.1 本地 Windows 环境变量
| 变量名 | 用途 | 示例值 |
|---|---|---|
RELEASE_TOKEN | 写权限 token,上传 setup.exe 到 release | gph_xxxxxxx |
GITEA_RELEASE_TOKEN | 只读 token,嵌入 setup.exe 内的二进制 | gpa_xxxxxxx |
GITEA_BASE_URL | Gitea 地址 | http://git.fangsiyuan.top |
OWNER | 仓库拥有者 | TomHanck4 |
REPO | 仓库名 | feishuworkflowtool |
8.2 设置方式
临时(当前 PowerShell 窗口有效)
$env:RELEASE_TOKEN = "你的写权限token"
$env:GITEA_BASE_URL = "http://git.fangsiyuan.top"
$env:OWNER = "TomHanck4"
$env:REPO = "feishuworkflowtool"持久(写入注册表,新窗口也有效)
[Environment]::SetEnvironmentVariable("RELEASE_TOKEN", "你的写权限token", "User")
[Environment]::SetEnvironmentVariable("GITEA_BASE_URL", "http://git.fangsiyuan.top", "User")
[Environment]::SetEnvironmentVariable("OWNER", "TomHanck4", "User")
[Environment]::SetEnvironmentVariable("REPO", "feishuworkflowtool", "User")持久设置存储在注册表 HKEY_CURRENT_USER\Environment。设置后必须新开 PowerShell 窗口才生效。
8.3 验证命令
$env:GITEA_BASE_URL; $env:OWNER; $env:REPO; $env:RELEASE_TOKEN.Length9. setup.exe 上传到 Release
9.1 获取 release ID
$tag = "v1.0.0"
$rel = Invoke-RestMethod `
-Headers @{ Authorization = "token $env:RELEASE_TOKEN" } `
"$env:GITEA_BASE_URL/api/v1/repos/$env:OWNER/$env:REPO/releases/tags/$tag"
$rel.id9.2 上传
curl.exe -H "Authorization: token $env:RELEASE_TOKEN" `
-F "attachment=@dist\{{AppName}}Setup.exe" `
"$env:GITEA_BASE_URL/api/v1/repos/$env:OWNER/$env:REPO/releases/$($rel.id)/assets?name={{AppName}}Setup.exe"9.3 跨 tag 复用
- setup.exe 不需要每次发版都重出
- asset 名建议固定为
{{AppName}}Setup.exe(不带版本号),避免每次 tag 都要重传 - 新 release 创建后,从旧 release 下载 setup.exe 再上传到新 release 即可
需要重出 setup.exe 的情况(少见):
- 改了 installer.iss 中
[Setup]/[Files]/[Icons]/[Run]段 - 改了默认安装路径、快捷方式策略、是否需要管理员
- 想给新装用户提供"开箱版本接近最新"的体验
不需要重出的情况(绝大多数):
- 业务代码、UI、第三方依赖、资源、updater 自身的任何更改
- 客户端 token 轮换
- manifest.json 内容变化
10. 增量更新架构
10.1 manifest.json 结构
{
"schema": 1,
"version": "1.0.0",
"channel": "stable",
"releaseDate": "2025-01-01T00:00:00Z",
"notes": "changelog here",
"files": [
{
"path": "{{AppName}}.exe",
"assetName": "{{AppName}}.exe",
"sha256": "abcdef1234567890..."
},
{
"path": "Updater.exe",
"assetName": "Updater.exe",
"sha256": "1234567890abcdef..."
}
]
}10.2 更新流程
- 客户端启动 →
GiteaReleaseChannel拉取 releases 列表 - 找到最新非草稿 release → 下载
manifest.json+manifest.json.minisig - minisign 验签 → 比对本地文件 sha256
- 只下载 sha256 不一致的文件 → 主程序退出 → updater.exe 替换文件
- updater 完成 → 重启主程序
10.3 安全层
| 层 | 机制 | 作用 |
|---|---|---|
| 软保护 | 只读 token 嵌入二进制 | 防爬虫,不防逆向 |
| 硬保护 | minisign 签名验证 | 防篡改,确保完整性 |
| 访问控制 | 私有仓库 | 匿名访问 404 |
10.4 检查更新触发时机(关键设计)
反模式(曾踩过的坑):把 CheckForUpdateAsync() 唯一调用点放在 OAuth 登录回调里。已恢复 session 的存量用户启动后不会重新走 OAuth → 永远不检查更新。
正确设计:
启动时
└─ MainWindow.OnContentRendered
└─ Task.Delay(500) → CheckForUpdateAsync(silent: true) // 后台静默
主界面状态栏
└─ 「检查更新」按钮 → CheckForUpdateAsync(silent: false) // 手动触发,无更新也提示
OAuth 登录回调
└─ CheckForUpdateAsync(silent: true) // 兜底,覆盖首次安装首次登录场景silent 参数语义:
| silent | 有更新 | 无更新 | 异常 |
|---|---|---|---|
| true(启动时) | 弹更新对话框 | 状态栏提示,不弹窗 | 静默吞掉,写 Debug 日志 |
| false(手动按钮) | 弹更新对话框 | 弹"已是最新版本 vX.X.X" | 弹失败提示 |
版本比较:用 System.Version 而非字符串比较,且要先剥离预发布后缀:
private static bool IsNewerVersion(string remote, string local)
{
var r = new Version(remote.Split('-')[0]);
var l = new Version(local.Split('-')[0]);
return r > l;
}11. 验证清单
| 检查项 | 命令 / 操作 | 预期结果 | ||||
|---|---|---|---|---|---|---|
| 匿名访问 | curl -i {{GiteaBaseURL}}/api/v1/repos/{{Owner}}/{{Repo}}/releases/tags/v0.0.1 | HTTP 404/403 | ||||
| 只读 token 访问 | 同上 + -H "Authorization: token <只读token>" | HTTP 200 | ||||
| minisign 签名头 | `curl -L .../manifest.json.minisig \ | sed -n '2p' \ | base64 -d \ | head -c 2 \ | xxd` | 4544 (ED prehashed) |
| 客户端验签 | 启动后看 Debug 输出 [Update] Signature verified | 必须出现,否则参考 §6.5 | ||||
| 安装程序 | 双击 {{AppName}}Setup.exe → 选目录 → 安装 | 中文向导界面 | ||||
| 启动运行 | 从安装目录启动 {{AppName}}.exe | 程序正常启动 | ||||
| 启动时检查更新 | 启动后约 1 秒内,Debug 输出包含 [Update] Got manifest: version=... | 必须出现 | ||||
| 增量更新 | 推新 tag → CI 跑完 → 客户端检测到更新 → updater 替换文件 | 只下载变化文件 | ||||
| 卸载 | 控制面板 → 卸载 | 清理干净 |
12. 新项目快速套用步骤
- 在 Gitea 创建私有仓库,配置两个 token + CI Secrets/Variables
- 复制
.gitea/workflows/release.yml,替换{{占位符}} - 复制
Tools/Installer/整个目录(含 i18n),替换 installer.iss 中的 AppName/GUID 等;如需 D 盘默认路径,保留[Code]段的InitializeWizard - 主程序 csproj 末尾添加
AssemblyMetadataAttribute注入块(GiteaReleaseToken和ReleaseNotes两个) App.xaml.cs启动时反射读取 token- HTTP 通道类构造函数中注入
Authorizationheader - 本地跑
build-setup.ps1出 setup.exe,上传到首个 release - 后续发版:
.\Tools\Bump.ps1 patch|minor|major升版本号,再.\Tools\Release.ps1 "说明"自动 commit + tag + push,setup.exe 跨 tag 复用
13. 版本号管理
13.1 语义规则(SemVer)
格式:MAJOR.MINOR.PATCH,可选后缀 -suffix(如 -beta)。
| 类型 | 触发条件 | 示例 |
|---|---|---|
| PATCH +1 | bug 修复、文字/样式调整、依赖小版本升级,不改变功能行为 | 3.2.1 → 3.2.2 |
| MINOR +1,PATCH 归零 | 新增功能、UI 新模块、新工作流支持,向后兼容 | 3.2.1 → 3.3.0 |
| MAJOR +1,MINOR/PATCH 归零 | 破坏性变更:数据库 schema 不兼容、配置格式变更、删除已有功能 | 3.2.1 → 4.0.0 |
13.2 Tools/Bump.ps1
只修改 csproj 版本号,不做 commit/tag。同步更新 <Version>、<FileVersion>、<AssemblyVersion>。
.\Tools\Bump.ps1 patch # 3.2.1 → 3.2.2
.\Tools\Bump.ps1 minor # 3.2.1 → 3.3.0
.\Tools\Bump.ps1 major # 3.2.1 → 4.0.0
.\Tools\Bump.ps1 3.5.0 # 直接指定版本号13.3 推荐工作流
# 1. 开发完成后,确定变更类型
.\Tools\Bump.ps1 patch|minor|major
# 2. 发版(自动 commit + tag + push)
.\Tools\Release.ps1 "• 新增xxx`n• 修复yyy"14. 设置页 ReleaseNotes 数据驱动
14.1 数据流
.\Tools\Release.ps1 "• 新增xxx\n• 修复yyy"
→ CI: dotnet publish -p:ReleaseNotes="..."
→ csproj AssemblyMetadata["ReleaseNotes"]
→ SettingsViewModel.ReleaseNoteLines (string[])
→ SettingsWindow.xaml ItemsControl 动态渲染
(无内容时整个区块隐藏)14.2 csproj 注入(与 GiteaReleaseToken 同模式)
<!-- 构建期注入更新说明(多条用 \n 分隔) -->
<ItemGroup Condition="'$(ReleaseNotes)' != ''">
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>ReleaseNotes</_Parameter1>
<_Parameter2>$(ReleaseNotes)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>14.3 CI 透传
在 Publish main app 步骤中读取 tag 的 commit message 并透传:
- name: Publish main app (win-x64)
run: |
NOTES=$(git log -1 --format=%B "$TAG")
dotnet publish {{MainProject}} \
-p:GiteaReleaseToken=${{ secrets.UPDATER_RELEASE_TOKEN }} \
-p:ReleaseNotes="$NOTES" \
-o ./stage14.4 ViewModel 读取
public string[] ReleaseNoteLines
{
get
{
var raw = Assembly.GetExecutingAssembly()
.GetCustomAttributes(typeof(AssemblyMetadataAttribute), false)
.Cast<AssemblyMetadataAttribute>()
.FirstOrDefault(a => a.Key == "ReleaseNotes")?.Value;
if (string.IsNullOrWhiteSpace(raw)) return [];
return raw.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
}
public bool HasReleaseNotes => ReleaseNoteLines.Length > 0;14.5 XAML 动态渲染
<Border Visibility="{Binding HasReleaseNotes, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel>
<TextBlock Text="{Binding UpdateTitle}" />
<ItemsControl ItemsSource="{Binding ReleaseNoteLines}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" TextWrapping="Wrap"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>Window.Resources 中声明:<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter"/>
注意:本地 build-setup.ps1 不传 ReleaseNotes,设置页更新区块在本地调试时自动隐藏,只在 CI 构建的正式版本中显示。
15. 踩坑实录(按发生顺序)
15.1 缓存 key 不含 token,导致 token 轮换后旧缓存继续命中
症状:在设置中更新 Mineru token,工作流却仍用旧 token 调用第三方 API。
根因:WorkflowCacheService.GenerateCacheKey 只用 workflowId + folderName + fileName + fileUrl,token 不进 key。token 变了 key 不变 → 命中旧缓存 → 请求根本没发出去。
修法:缓存 key 加 extraKey: string? 参数,调用方传 App.GetEffectiveMineruToken()。token 变 → key 变 → miss → 重发请求。
15.2 SettingsViewModel 用 new() 创建,绕过 DI 单例
症状:点"清除工作流缓存"按钮,磁盘文件清了,下次执行仍用缓存。
根因:WorkflowCacheService 在 DI 注册为 Singleton,但 SettingsViewModel 内部 private readonly WorkflowCacheService _cacheService = new();——构造了一个独立实例。该实例 ClearAllCache 只清自己的内存 + 磁盘,DI 容器里那个被 WorkflowViewModel 持有的 singleton 实例的内存缓存完全没动。
修法:SettingsViewModel 改构造注入 + SettingsWindow 接收注入的 ViewModel + MainWindow.OpenSettingsWindow 改走 App.ServiceProvider.GetRequiredService<SettingsViewModel>()。
通用经验:任何已被 DI 注册的 service,不要再有 new XxxService() 出现在代码里,无论看起来多无害。
15.3 配置默认值优先级:本地用户值 vs. 远端
症状:远端 config/defaults.json 已更新 token,客户端启动后仍用本地旧值。
根因:早期实现是 "本地值优先,本地空时 fallback 远端"。问题是历史用户已往 settings.json 里写过旧 token,本地永远非空,远端值永远走不到。
修法:策略翻转为"远端非空时优先,否则 fallback 到本地用户值"。运维侧 push 新 defaults.json 即可全网生效,无需让用户手动清空设置。
public static string GetEffectiveMineruToken()
{
var remote = AppConstants.RemoteMineruTokenDefault;
if (!string.IsNullOrEmpty(remote)) return remote;
return Settings?.Coze?.MineruToken ?? string.Empty;
}通用经验:可远程轮换的 token / endpoint 配置,应当远端优先,本地只是离线兜底。
15.4 检查更新只在 OAuth 回调里触发
参见 §10.4。
15.5 minisign 验签未支持 prehashed —— 更新通道从未真正工作过
参见 §6.5。这是最隐蔽的一个:所有外部产物(manifest、签名、release、CI 日志)都正常,唯独客户端验签永远 false 静默返回。判别只需看签名头字节 = ED 还是 Ed。
15.6 这几个坑的连锁效应
v0.0.1 ─┐
v3.2.1 ─┤ → 全都装了「永远验不过签的 MinisignVerifier」
v3.3.0 ─┤ → 任意客户端「检查更新」都看不到任何新版本
v3.3.1 ─┤ → 修复一个表层问题就当作版本通了
v3.3.2 ─┘ → 但底层验签问题没暴露
v3.3.3:修复 prehashed verifier教训:发版前必须本地端到端跑一次 manifest 拉取 + 验签,不能只靠 CI 产物外观看起来正确。验证脚本(Python 三行):
import base64, hashlib
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
pk = base64.b64decode(open("pk").read())[10:42]
sig = base64.b64decode(open("manifest.json.minisig").read().splitlines()[1])[10:74]
data = open("manifest.json","rb").read()
Ed25519PublicKey.from_public_bytes(pk).verify(sig, hashlib.blake2b(data, digest_size=64).digest())
# 不抛异常即通过


评论 (0)