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:
@@ -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。
|
||||
|
||||
@@ -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')
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlCOUIwRDExQTc5RTFGMzYKUldRMkg1Nm5FUTJibTJ2cGlHY0pkL0dGemxXMUlzc01pVTVMM1U3WGpmWUtrUC8wK2ErSXhLKzEK",
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEI3Q0FBOUQ2MTgwRkM4NjQKUldSa3lBOFkxcW5LdDhLUFZnNUlEdmllY0ZudjFhb2VXTUpSWmpkUmM1cjdoNDFwRW1MZTN5Yi8K",
|
||||
"endpoints": [
|
||||
"https://ashy-dune-0e22d7b00.7.azurestaticapps.net/updater/latest.json"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user