自托管 Gitea CI + Inno Setup 安装程序 + 文件级增量更新 -- 复用指南
AI

自托管 Gitea CI + Inno Setup 安装程序 + 文件级增量更新 -- 复用指南

TomHanck4
2026-05-28 / 0 评论 / 1 阅读 / 正在检测是否收录...

自托管 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_TOKENwrite:repositoryCI 创建 release、上传 assets;本地上传 setup.exeGitea CI Secrets + 本地环境变量
UPDATER_RELEASE_TOKENread: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_KEYminisign 签名私钥(可选,用于 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}}.git

4.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 --force

5. 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 编码会解析失败
  • 核心三步:

    1. dotnet publish {{MainProject}} → stage 目录
    2. dotnet publish {{UpdaterProject}} → 同一 stage 目录
    3. 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 到 releasegph_xxxxxxx
GITEA_RELEASE_TOKEN只读 token,嵌入 setup.exe 内的二进制gpa_xxxxxxx
GITEA_BASE_URLGitea 地址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.Length

9. 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.id

9.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 更新流程

  1. 客户端启动 → GiteaReleaseChannel 拉取 releases 列表
  2. 找到最新非草稿 release → 下载 manifest.json + manifest.json.minisig
  3. minisign 验签 → 比对本地文件 sha256
  4. 只下载 sha256 不一致的文件 → 主程序退出 → updater.exe 替换文件
  5. 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.1HTTP 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. 新项目快速套用步骤

  1. 在 Gitea 创建私有仓库,配置两个 token + CI Secrets/Variables
  2. 复制 .gitea/workflows/release.yml,替换 {{占位符}}
  3. 复制 Tools/Installer/ 整个目录(含 i18n),替换 installer.iss 中的 AppName/GUID 等;如需 D 盘默认路径,保留 [Code] 段的 InitializeWizard
  4. 主程序 csproj 末尾添加 AssemblyMetadataAttribute 注入块(GiteaReleaseToken 和 ReleaseNotes 两个)
  5. App.xaml.cs 启动时反射读取 token
  6. HTTP 通道类构造函数中注入 Authorization header
  7. 本地跑 build-setup.ps1 出 setup.exe,上传到首个 release
  8. 后续发版:.\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 +1bug 修复、文字/样式调整、依赖小版本升级,不改变功能行为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 ./stage

14.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

评论 (0)

取消