feat(desktop): rotate updater key + concrete release pipeline

- pubkey: rotated to the keypair stored at
  C:\Users\陈晨\.heicode-updater\heicode_updater.key (private side
  is the user's; only the pubkey ships in tauri.conf.json).
- scripts/release-desktop.mjs: one-shot release helper —
  uploads the signed bundle artifacts to Azure Blob (account
  heicodeblob, container msi, public-blob-read) and rewrites
  website/public/updater/latest.json to point at the new URLs.
- UPDATER.md: rewritten with the concrete URLs, container, key
  paths, and step-by-step commands. No more generic placeholders.

Azure Blob setup (done out-of-band, not in this commit):
- Storage account heicodeblob set allowBlobPublicAccess=true
- Container msi set to public-blob read
- Smoke-tested: https://heicodeblob.blob.core.windows.net/msi/<x>
  returns 200 anonymously.

The actual Azure connection string + private-key path live in
scripts/.env.release, which is gitignored under .env.* and was
verified excluded before this commit.

NOTE: pubkey was rotated. Any MSI already in the wild signed by
the *previous* key cannot self-update to this signing chain —
those users need a fresh manual install. This is acceptable for
pre-GA where no public release exists yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 16:50:14 +08:00
co-authored by Claude Opus 4.7
parent 49be79fb9a
commit 739354e786
3 changed files with 227 additions and 50 deletions
+83 -49
View File
@@ -1,62 +1,96 @@
# Desktop 自动更新发版手册
> 客户端 UI(`UpdateChecker.tsx` + `updateStore.ts`)已经做好。本文只讲**每次发版**怎么把 manifest 喂进去。
> 客户端 UI(`UpdateChecker.tsx` + `updateStore.ts`)已经在 AppShell 里挂好。本文只讲**每次发版**怎么把新版本喂进去。
## 一次性准备
## 当前已配置的资源
1. **拿到 minisign 私钥**。`tauri.conf.json` 里的 `pubkey` 对应一把私钥,发版机必须有。如果丢了,重新生成一对并同步更新 `pubkey`,所有已经装在用户机上的旧版本将无法验证新版本(必须手工换包)。
2. **设置签名环境变量**(每次 build 都要):
```pwsh
$env:TAURI_SIGNING_PRIVATE_KEY = "<your minisign private key contents>"
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = "<password if any>"
```
3. **决定 artifact 托管位置**。当前 `endpoints` 指向:
```
https://ashy-dune-0e22d7b00.7.azurestaticapps.net/updater/latest.json
```
manifest 自己住在 website 仓库的 `website/public/updater/latest.json`。
**MSI/DMG 本体不能塞进静态站**(太大 + Azure SWA 有大小限制)—— 需要单独的 CDN:Azure Blob、阿里 OSS、GitHub Release 都行。本文档以 `https://heicode-release.blob.core.windows.net/desktop/<version>/` 占位。
| 资源 | 值 |
|---|---|
| 更新清单 URL(写在 `tauri.conf.json`) | `https://ashy-dune-0e22d7b00.7.azurestaticapps.net/updater/latest.json` |
| MSI/DMG 托管 | Azure Blob,account `heicodeblob`,容器 `msi`(公共只读) |
| 公共下载 URL 模板 | `https://heicodeblob.blob.core.windows.net/msi/desktop/<version>/<filename>` |
| Tauri 签名公钥(已在 `tauri.conf.json`) | `RWRkyA8Y1qnKt8KPVg5IDvieсFnv1aoeWMJRZjdRc5r7h41pEmLe3yb/` |
| 私钥位置(本地) | `C:\Users\陈晨\.heicode-updater\heicode_updater.key` |
| 凭证文件 | `scripts/.env.release`(**已 gitignore,不要 commit**) |
## 每次发版
**重要:私钥丢了等于不能再发更新**。请把 `C:\Users\陈晨\.heicode-updater\heicode_updater.key` 和它的 `.pub` 文件复制一份到云盘/密码管理器/U 盘。
## 每次发版(一条命令搞定上传 + 清单)
### 第 1 步:改版本号
编辑两处,保持一致:
- `cc-haha/desktop/src-tauri/tauri.conf.json` → `"version": "0.1.1"`
- `cc-haha/desktop/package.json` → `"version": "0.1.1"`
### 第 2 步:加载凭证 + 打包
PowerShell:
```powershell
cd cc-haha\desktop
# 加载凭证(私钥路径 + Azure key)
Get-Content scripts\.env.release | ForEach-Object {
if ($_ -match '^([^#=]+)=(.*)$') {
[Environment]::SetEnvironmentVariable($Matches[1].Trim(), $Matches[2].Trim(), 'Process')
}
}
# 把私钥文件内容塞进 TAURI_SIGNING_PRIVATE_KEY(tauri 期望的是内容而不是路径)
$env:TAURI_SIGNING_PRIVATE_KEY = Get-Content $env:TAURI_SIGNING_PRIVATE_KEY_PATH -Raw
```pwsh
# 1. 在 cc-haha/desktop 目录打包,签名私钥已经在环境变量里
cd cc-haha/desktop
bun run tauri build
# 产出:
# src-tauri/target/release/bundle/nsis/heicode_X.Y.Z_x64-setup.exe
# src-tauri/target/release/bundle/nsis/heicode_X.Y.Z_x64-setup.exe.sig
# (mac 上).../bundle/macos/Heicode.app.tar.gz + .sig
# 2. 把 .exe/.app.tar.gz 传到 CDN
# 例如 azcopy copy <local> <blob-url>
# 3. 生成 manifest,写到 website 仓库
node scripts/build-updater-manifest.mjs `
--version 0.1.1 `
--notes "本次更新内容..." `
--base-url https://heicode-release.blob.core.windows.net/desktop/0.1.1 `
--windows-x86_64 src-tauri/target/release/bundle/nsis/heicode_0.1.1_x64-setup.exe `
--darwin-aarch64 src-tauri/target/release/bundle/macos/Heicode_aarch64.app.tar.gz `
--out ../../website/public/updater/latest.json
# 4. 提交 website 仓库(manifest 改动)并触发部署
cd ../../website
git add public/updater/latest.json
git commit -m "release: desktop 0.1.1"
git push # Azure SWA 自动重新部署
```
部署完后:旧版本客户端在启动 5 秒后会自动 `silent` 检查;命中新版本会弹出右上角更新框。
产物(Windows):
- `src-tauri/target/release/bundle/nsis/heicode_0.1.1_x64-setup.exe`
- `src-tauri/target/release/bundle/nsis/heicode_0.1.1_x64-setup.exe.sig` ← 自动产生的签名
## 兜底 / 调试
Mac 类似,产物在 `bundle/macos/`。
- 手动触发检查:Settings 页面里有"检查更新"按钮(`UpdateChecker` + `updateStore.checkForUpdates`)。
- 用户主动忽略某个版本:右上框点"稍后",写 localStorage `cc-haha-dismissed-update-version`。下一个版本号会再次弹。
- 验证 manifest:浏览器直接访问 `https://<host>/updater/latest.json` 应能下载 JSON。
### 第 3 步:跑发布脚本(上传 + 生成清单)
## 当前状态
```powershell
node scripts\release-desktop.mjs `
--notes "0.1.1 修复了 X,新增了 Y" `
--windows-x86_64 src-tauri\target\release\bundle\nsis\heicode_0.1.1_x64-setup.exe
```
- ✅ 客户端 UI:完整
- ✅ `endpoints`:已指向 Azure SWA
- ⏳ `latest.json` 当前只有占位 `platforms: {}`,意味着任何客户端 check 都会返回"已是最新"。第一次走完上面 4 步后才开始真实推送更新。
脚本会:
1. 把 `.exe` + `.exe.sig` 传到 `https://heicodeblob.blob.core.windows.net/msi/desktop/0.1.1/`
2. 把 `website/public/updater/latest.json` 改写成指向这次的 URL + 嵌入签名
mac 同时有产物的话:
```powershell
node scripts\release-desktop.mjs `
--notes "0.1.1 ..." `
--windows-x86_64 src-tauri\target\release\bundle\nsis\heicode_0.1.1_x64-setup.exe `
--darwin-aarch64 src-tauri\target\release\bundle\macos\Heicode_aarch64.app.tar.gz
```
### 第 4 步:把清单 push 到 website 触发 Azure SWA 重新部署
```powershell
cd ..\..\website
git add public\updater\latest.json
git commit -m "release: desktop 0.1.1"
git push # Azure SWA 自动重新部署,~1 分钟生效
```
部署完后:**所有装着旧版本的用户**下次启动 5 秒后会看到右上角弹窗"有更新可用"。
## 怎么验证有没有跑通
1. 浏览器打开 `https://ashy-dune-0e22d7b00.7.azurestaticapps.net/updater/latest.json` —— 应能看到 JSON。
2. 浏览器打开 `platforms.windows-x86_64.url` —— 应能直接下载 MSI。
3. 装一个比 manifest 里 `version` 旧的 Heicode 客户端,启动 5 秒后看右上角。
## 万一出问题
- **手动触发检查**:客户端 Settings 页 → 检查更新。
- **看更新报错**:`updateStore.error` 在 React DevTools 能看到;常见错误:`signature mismatch`(私钥换了但用户客户端里还嵌着旧 pubkey)、`404`(manifest URL 错)。
- **私钥泄漏 / 想换**:重新跑 `bun x tauri signer generate`,更新 `tauri.conf.json` 的 `pubkey`,重发一次 MSI。**所有装着旧版本的用户都得手动重装这一版**才能继续走自动更新通道。
## ⚠ pubkey 已经换过一次
本文档创建时,原仓库里的 `pubkey` 已被替换为 `C:\Users\陈晨\.heicode-updater\heicode_updater.key` 对应的公钥。**如果在此之前已经分发过 MSI 给真实用户**,那些用户的客户端**没办法**自动升级到新签名链 —— 必须给他们一个新 MSI 让他们手动安装一次,之后才能继续 OTA。
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env node
/**
* End-to-end desktop release helper.
*
* 1. Reads the version from src-tauri/tauri.conf.json (you bumped it
* and rebuilt before running this).
* 2. Uploads every supplied bundle artifact + its `.sig` to the
* Azure Blob container `msi` (account `heicodeblob`). Container
* is configured public-blob-read, so the URLs are durable.
* 3. Generates `website/public/updater/latest.json` pointing at
* those URLs and inlining the signatures.
*
* After this script runs you still need to `git commit && git push`
* the website manifest so the static site redeploys.
*
* Usage (PowerShell example):
*
* $env:AZURE_STORAGE_CONNECTION_STRING = "<conn-str>"
* node scripts/release-desktop.mjs `
* --notes "0.1.1 — bug fixes" `
* --windows-x86_64 src-tauri/target/release/bundle/nsis/heicode_0.1.1_x64-setup.exe
*
* Tip: the connection string lives in scripts/.release-credentials
* locally (gitignored). `source` it before running on Mac/Linux, or
* use `Get-Content | ForEach-Object {...}` on Windows.
*/
import {
readFileSync, statSync, mkdirSync, writeFileSync, existsSync,
} from 'node:fs'
import { dirname, basename, resolve, join } from 'node:path'
import { argv, env, exit } from 'node:process'
import { execSync, spawnSync } from 'node:child_process'
const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..')
const CONTAINER = 'msi'
const ACCOUNT = 'heicodeblob'
const PUBLIC_HOST = `https://${ACCOUNT}.blob.core.windows.net/${CONTAINER}`
const MANIFEST_REL = 'website/public/updater/latest.json'
const TAURI_CONF = resolve(import.meta.dirname, '..', 'src-tauri', 'tauri.conf.json')
const PLATFORM_KEYS = [
'windows-x86_64',
'darwin-aarch64',
'darwin-x86_64',
'linux-x86_64',
]
function parseArgs(rawArgs) {
const out = {}
for (let i = 0; i < rawArgs.length; i++) {
const k = rawArgs[i]
if (!k?.startsWith('--')) continue
const next = rawArgs[i + 1]
if (!next || next.startsWith('--')) {
out[k.slice(2)] = true
} else {
out[k.slice(2)] = next
i++
}
}
return out
}
function die(msg, code = 1) {
console.error(`error: ${msg}`)
exit(code)
}
function getTauriVersion() {
const json = JSON.parse(readFileSync(TAURI_CONF, 'utf8'))
if (!json.version) die('tauri.conf.json has no version')
return json.version
}
function uploadBlob(localPath, remoteName) {
if (!env.AZURE_STORAGE_CONNECTION_STRING) {
die('AZURE_STORAGE_CONNECTION_STRING is not set (see scripts/.release-credentials)')
}
const result = spawnSync(
'az',
[
'storage', 'blob', 'upload',
'--container-name', CONTAINER,
'--name', remoteName,
'--file', localPath,
'--overwrite',
'--no-progress',
],
{ stdio: ['ignore', 'pipe', 'pipe'], env, encoding: 'utf8' },
)
if (result.status !== 0) {
console.error(result.stderr)
die(`upload failed for ${remoteName}`)
}
return `${PUBLIC_HOST}/${remoteName}`
}
const args = parseArgs(argv.slice(2))
const version = getTauriVersion()
const versionPrefix = `desktop/${version}`
const platforms = {}
for (const key of PLATFORM_KEYS) {
const path = args[key]
if (!path || path === true) continue
const local = resolve(path)
try { statSync(local) } catch { die(`${key} artifact missing: ${local}`) }
const sigLocal = `${local}.sig`
let signature
try { signature = readFileSync(sigLocal, 'utf8').trim() }
catch { die(`signature missing: ${sigLocal} (rebuild with TAURI_SIGNING_PRIVATE_KEY set)`) }
const remoteArtifact = `${versionPrefix}/${basename(local)}`
const remoteSig = `${versionPrefix}/${basename(sigLocal)}`
console.log(`Uploading ${key}: ${remoteArtifact}`)
const url = uploadBlob(local, remoteArtifact)
uploadBlob(sigLocal, remoteSig)
platforms[key] = { signature, url }
}
if (Object.keys(platforms).length === 0) {
die('no platform artifacts supplied — pass at least one --<target> <path>')
}
const manifest = {
version,
notes: args.notes || `Heicode ${version}`,
pub_date: new Date().toISOString(),
platforms,
}
const manifestPath = resolve(REPO_ROOT, MANIFEST_REL)
if (!existsSync(dirname(manifestPath))) mkdirSync(dirname(manifestPath), { recursive: true })
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
console.log(`\nWrote ${manifestPath}`)
console.log(`Version : ${version}`)
console.log(`Platforms: ${Object.keys(platforms).join(', ')}`)
console.log('\nNext: commit + push website to redeploy Azure SWA.')
console.log(` cd ${join(REPO_ROOT, 'website')}`)
console.log(` git add public/updater/latest.json`)
console.log(` git commit -m "release: desktop ${version}"`)
console.log(' git push')
+1 -1
View File
@@ -31,7 +31,7 @@
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlCOUIwRDExQTc5RTFGMzYKUldRMkg1Nm5FUTJibTJ2cGlHY0pkL0dGemxXMUlzc01pVTVMM1U3WGpmWUtrUC8wK2ErSXhLKzEK",
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEI3Q0FBOUQ2MTgwRkM4NjQKUldSa3lBOFkxcW5LdDhLUFZnNUlEdmllY0ZudjFhb2VXTUpSWmpkUmM1cjdoNDFwRW1MZTN5Yi8K",
"endpoints": [
"https://ashy-dune-0e22d7b00.7.azurestaticapps.net/updater/latest.json"
],