跟新 CICD,每次制作 ota 升级包
All checks were successful
Docker Build and Push / build-image (push) Successful in 2m50s
All checks were successful
Docker Build and Push / build-image (push) Successful in 2m50s
This commit is contained in:
parent
4f5cdb4325
commit
8999b29646
72
.gitea/workflows/ota-build.yml
Normal file
72
.gitea/workflows/ota-build.yml
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
name: OTA Frontend Build & Sign
|
||||||
|
|
||||||
|
# 前端 OTA 升级包构建 + 签名 + 上传 artifact
|
||||||
|
# 触发: 手动 (workflow_dispatch), 由"我"页/发版前点一下
|
||||||
|
# 输入: sequence / notes (可覆盖默认)
|
||||||
|
# Secret 依赖: MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)
|
||||||
|
# 注意: 此 workflow 不会修改 src-tauri/tauri.conf.json 里的 pubkey,
|
||||||
|
# 换密钥请手动改 conf 并 commit.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
sequence:
|
||||||
|
description: 'OTA sequence (单调递增整数, 留空用 timestamp)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
notes:
|
||||||
|
description: '发布说明 (写入 latest.json.notes)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ''
|
||||||
|
min_binary:
|
||||||
|
description: '最低 binary 版本 (留空用 src-tauri/tauri.conf.json 的 version)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-ota:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://gitea.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: https://gitea.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
run: npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
# 复用 vite build 产物; 不打 Docker (本 workflow 只关心前端)
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Install minisign
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y minisign
|
||||||
|
|
||||||
|
- name: Build & sign OTA package
|
||||||
|
env:
|
||||||
|
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
OUT="ota-package"
|
||||||
|
ARGS="--out ${OUT}"
|
||||||
|
if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi
|
||||||
|
if [ -n "${{ inputs.notes }}" ]; then ARGS="${ARGS} --notes ${{ inputs.notes }}"; fi
|
||||||
|
if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi
|
||||||
|
node scripts/build-ota.mjs ${ARGS}
|
||||||
|
echo "===== latest.json ====="
|
||||||
|
cat ${OUT}/latest.json
|
||||||
|
echo "===== bundle sha256 ====="
|
||||||
|
sha256sum ${OUT}/iboard-ota.tar.gz
|
||||||
|
|
||||||
|
- name: Upload OTA package as artifact
|
||||||
|
uses: https://gitea.com/actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: iboard-ota-${{ inputs.sequence || github.run_number }}
|
||||||
|
path: ota-package/
|
||||||
|
retention-days: 30
|
||||||
|
if-no-files-found: error
|
||||||
20
readme
20
readme
@ -92,3 +92,23 @@ node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json
|
|||||||
|
|
||||||
Web 浏览器访问 (同源) 时, 服务端返回简化版 manifest, 仅显示"有新版本请刷新".
|
Web 浏览器访问 (同源) 时, 服务端返回简化版 manifest, 仅显示"有新版本请刷新".
|
||||||
Web 端不会自动下载包, 也不会替换前端代码.
|
Web 端不会自动下载包, 也不会替换前端代码.
|
||||||
|
|
||||||
|
|
||||||
|
### CI 自动签名 (Gitea Actions)
|
||||||
|
|
||||||
|
.gitea/workflows/ota-build.yml 是手动触发的 OTA 打包工作流 (每次发版前点一次).
|
||||||
|
|
||||||
|
**配置 secret** (Gitea 仓库 -> Settings -> Actions -> Secrets):
|
||||||
|
- MINISIGN_PRIVATE_KEY: 完整 .key 文件内容, **整段** (含首行 `untrusted comment: minisign encrypted secret key` 和 base64 行), 整段作为多行 secret 粘贴即可.
|
||||||
|
|
||||||
|
**使用流程**:
|
||||||
|
1. 在 Gitea Actions 页面选 OTA Frontend Build & Sign -> Run workflow
|
||||||
|
2. 填 sequence (可选, 不填用时间戳) 和
|
||||||
|
otes (可选)
|
||||||
|
3. CI 跑完会在 artifact 里看到 ota-package/iboard-ota.tar.gz + latest.json + iboard-ota.tar.gz.minisig
|
||||||
|
4. 下载 artifact, scp 到服务器 OTA_PACKAGE_DIR 目录
|
||||||
|
5. 客户端"我"页 [检查更新] 即可触发升级
|
||||||
|
|
||||||
|
**注意**:
|
||||||
|
- CI 用的私钥必须**与** auri.conf.json 里 plugins.hotswap.pubkey **配对**; 换密钥时需要同时更新 CI secret 和 conf.
|
||||||
|
- 不要把私钥 commit 到 git, 整个 ota-package/hotswap.key 已被 .gitignore 排除.
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap)
|
// OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap)
|
||||||
// 用法: node scripts/build-ota.mjs [--out <dir>] [--sequence <n>] [--min-binary <x>] [--notes <s>] [--no-sign]
|
// 用法:
|
||||||
|
// node scripts/build-ota.mjs [--out <dir>] [--sequence <n>] [--min-binary <x>] [--notes <s>] [--no-sign] [--key <path>]
|
||||||
// 流程:
|
// 流程:
|
||||||
// 1) 校验 dist/ 已构建
|
// 1) 校验 dist/ 已构建
|
||||||
// 2) 生成 bundle.tar.gz (dist/ 内容)
|
// 2) 生成 bundle.tar.gz (dist/ 内容)
|
||||||
// 3) minisign 签名 (除非 --no-sign)
|
// 3) minisign 签名 (除非 --no-sign; 私钥优先读 MINISIGN_PRIVATE_KEY 环境变量)
|
||||||
// 4) 写 latest.json (manifest, 含 sequence/version/signature/bundle_size/notes/...)
|
// 4) 写 latest.json (manifest, 含 sequence/version/signature/bundle_size/notes/...)
|
||||||
// 5) 全部产物输出到 out 目录 (默认 ota-package/)
|
// 5) 全部产物输出到 out 目录 (默认 ota-package/)
|
||||||
//
|
//
|
||||||
@ -29,8 +30,9 @@ const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用
|
|||||||
const MIN_BINARY = getArg('--min-binary', '')
|
const MIN_BINARY = getArg('--min-binary', '')
|
||||||
const NOTES = getArg('--notes', '')
|
const NOTES = getArg('--notes', '')
|
||||||
const NO_SIGN = args.includes('--no-sign')
|
const NO_SIGN = args.includes('--no-sign')
|
||||||
// minisign 私钥路径 (默认读 ./ota-package/hotswap.key; 也可显式 --key)
|
// 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径)
|
||||||
const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key')))
|
const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key')))
|
||||||
|
const KEY_FROM_ENV = !!process.env.MINISIGN_PRIVATE_KEY
|
||||||
// 旧版 tauri.conf.json 里的 pubkey (自动同步过去, 方便用户复制)
|
// 旧版 tauri.conf.json 里的 pubkey (自动同步过去, 方便用户复制)
|
||||||
const TAURI_CONF = path.join(ROOT, 'src-tauri', 'tauri.conf.json')
|
const TAURI_CONF = path.join(ROOT, 'src-tauri', 'tauri.conf.json')
|
||||||
|
|
||||||
@ -61,15 +63,30 @@ const bundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(BUNDLE_P
|
|||||||
let signature = ''
|
let signature = ''
|
||||||
let pubkey = ''
|
let pubkey = ''
|
||||||
if (!NO_SIGN) {
|
if (!NO_SIGN) {
|
||||||
if (!fs.existsSync(KEY_PATH)) {
|
// CI 注入: 私钥走环境变量, 写到临时文件, 签名后立即删除 (避免落盘)
|
||||||
console.error('[build-ota] 私钥不存在:', KEY_PATH)
|
let KEY_PATH_TMP = null
|
||||||
|
let KEY_PATH_USE = KEY_PATH
|
||||||
|
if (KEY_FROM_ENV) {
|
||||||
|
KEY_PATH_TMP = path.join(OUT_DIR, `.hotswap.key.${process.pid}`)
|
||||||
|
fs.writeFileSync(KEY_PATH_TMP, process.env.MINISIGN_PRIVATE_KEY, { mode: 0o600 })
|
||||||
|
KEY_PATH_USE = KEY_PATH_TMP
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(KEY_PATH_USE)) {
|
||||||
|
console.error('[build-ota] 私钥不存在:', KEY_PATH_USE)
|
||||||
|
console.error(' 注入方式二选一:')
|
||||||
|
console.error(' 1) CI: 设环境变量 MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)')
|
||||||
|
console.error(' 2) 本机: 放 ' + KEY_PATH + ', 或用 --key <path>')
|
||||||
console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key')
|
console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
// minisign -Sm <file> -s <key> [-W] 生成 detached signature
|
// minisign -Sm <file> -s <key> 生成 detached signature
|
||||||
const sigArgs = ['-Sm', BUNDLE_PATH, '-s', KEY_PATH]
|
const sigArgs = ['-Sm', BUNDLE_PATH, '-s', KEY_PATH_USE]
|
||||||
// 私钥无密码时不需要 -W
|
|
||||||
const sigR = spawnSync('minisign', sigArgs, { stdio: 'inherit' })
|
const sigR = spawnSync('minisign', sigArgs, { stdio: 'inherit' })
|
||||||
|
// 清理 CI 临时私钥 (无论成功失败都删)
|
||||||
|
if (KEY_PATH_TMP) {
|
||||||
|
try { fs.unlinkSync(KEY_PATH_TMP) } catch { /* ignore */ }
|
||||||
|
KEY_PATH_TMP = null
|
||||||
|
}
|
||||||
if (sigR.status !== 0) {
|
if (sigR.status !== 0) {
|
||||||
console.error('[build-ota] minisign 签名失败')
|
console.error('[build-ota] minisign 签名失败')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
@ -81,7 +98,6 @@ if (!NO_SIGN) {
|
|||||||
const PUB_PATH = path.join(path.dirname(KEY_PATH), 'hotswap.pub')
|
const PUB_PATH = path.join(path.dirname(KEY_PATH), 'hotswap.pub')
|
||||||
if (fs.existsSync(PUB_PATH)) {
|
if (fs.existsSync(PUB_PATH)) {
|
||||||
pubkey = fs.readFileSync(PUB_PATH, 'utf8').trim()
|
pubkey = fs.readFileSync(PUB_PATH, 'utf8').trim()
|
||||||
// 提取 base64 部分 (第二行)
|
|
||||||
const lines = pubkey.split(/\r?\n/)
|
const lines = pubkey.split(/\r?\n/)
|
||||||
pubkey = (lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ')) || '').trim()
|
pubkey = (lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ')) || '').trim()
|
||||||
}
|
}
|
||||||
@ -110,7 +126,8 @@ const manifest = {
|
|||||||
fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2))
|
fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2))
|
||||||
|
|
||||||
// 4) 提示
|
// 4) 提示
|
||||||
console.log('\n=== OTA 包生成完成 ===')
|
console.log('')
|
||||||
|
console.log('=== OTA 包生成完成 ===')
|
||||||
console.log('输出目录: ', OUT_DIR)
|
console.log('输出目录: ', OUT_DIR)
|
||||||
console.log('版本: ', manifest.version)
|
console.log('版本: ', manifest.version)
|
||||||
console.log('sequence: ', manifest.sequence)
|
console.log('sequence: ', manifest.sequence)
|
||||||
@ -118,32 +135,13 @@ console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB')
|
|||||||
console.log('bundle sha256:', bundleSha256)
|
console.log('bundle sha256:', bundleSha256)
|
||||||
console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)')
|
console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)')
|
||||||
if (pubkey) {
|
if (pubkey) {
|
||||||
console.log('\n请将下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:')
|
console.log('')
|
||||||
|
console.log('请将下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:')
|
||||||
console.log(' ' + pubkey)
|
console.log(' ' + pubkey)
|
||||||
// 自动同步 (可选, 注释掉默认行为; 用户自己粘更安全)
|
|
||||||
// syncTauriConf(pubkey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n下一步: 把这两个文件发布到 OTA 存储:')
|
console.log('')
|
||||||
|
console.log('下一步: 把这两个文件发布到 OTA 存储:')
|
||||||
console.log(' ' + BUNDLE_NAME)
|
console.log(' ' + BUNDLE_NAME)
|
||||||
console.log(' latest.json')
|
console.log(' latest.json')
|
||||||
console.log('并确保 server 进程环境变量 OTA_PACKAGE_DIR 指向该目录。')
|
console.log('并确保 server 进程环境变量 OTA_PACKAGE_DIR 指向该目录。')
|
||||||
|
|
||||||
// 同步 tauri.conf.json pubkey 字段 (用户首次签名后手动确认)
|
|
||||||
// 注释: 启用此函数可自动写入, 但为安全默认关闭
|
|
||||||
function syncTauriConf(pub) {
|
|
||||||
if (!fs.existsSync(TAURI_CONF)) return
|
|
||||||
try {
|
|
||||||
const txt = fs.readFileSync(TAURI_CONF, 'utf8')
|
|
||||||
const newTxt = txt.replace(
|
|
||||||
/"pubkey":\s*"[^"]*"/,
|
|
||||||
'"pubkey": "' + pub + '"'
|
|
||||||
)
|
|
||||||
if (newTxt !== txt) {
|
|
||||||
fs.writeFileSync(TAURI_CONF, newTxt)
|
|
||||||
console.log('\n[build-ota] 已同步 pubkey 到 tauri.conf.json')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[build-ota] 同步 pubkey 失败:', e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user