From 8999b29646373cb32d0b16febc1960f7a20976fd Mon Sep 17 00:00:00 2001 From: cheney Date: Fri, 7 Aug 2026 10:04:57 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=9F=E6=96=B0=20CICD=EF=BC=8C=E6=AF=8F?= =?UTF-8?q?=E6=AC=A1=E5=88=B6=E4=BD=9C=20ota=20=E5=8D=87=E7=BA=A7=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ota-build.yml | 72 ++++++++++++++++++++++++++++++++++ readme | 20 ++++++++++ scripts/build-ota.mjs | 64 +++++++++++++++--------------- 3 files changed, 123 insertions(+), 33 deletions(-) create mode 100644 .gitea/workflows/ota-build.yml diff --git a/.gitea/workflows/ota-build.yml b/.gitea/workflows/ota-build.yml new file mode 100644 index 0000000..01df4d0 --- /dev/null +++ b/.gitea/workflows/ota-build.yml @@ -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 diff --git a/readme b/readme index 97a9f76..47f8653 100644 --- a/readme +++ b/readme @@ -92,3 +92,23 @@ node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json Web 浏览器访问 (同源) 时, 服务端返回简化版 manifest, 仅显示"有新版本请刷新". 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 排除. diff --git a/scripts/build-ota.mjs b/scripts/build-ota.mjs index 2cd78b2..15fd6f9 100644 --- a/scripts/build-ota.mjs +++ b/scripts/build-ota.mjs @@ -1,10 +1,11 @@ #!/usr/bin/env node // OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap) -// 用法: node scripts/build-ota.mjs [--out ] [--sequence ] [--min-binary ] [--notes ] [--no-sign] +// 用法: +// node scripts/build-ota.mjs [--out ] [--sequence ] [--min-binary ] [--notes ] [--no-sign] [--key ] // 流程: // 1) 校验 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/...) // 5) 全部产物输出到 out 目录 (默认 ota-package/) // @@ -29,8 +30,9 @@ const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用 const MIN_BINARY = getArg('--min-binary', '') const NOTES = getArg('--notes', '') 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_FROM_ENV = !!process.env.MINISIGN_PRIVATE_KEY // 旧版 tauri.conf.json 里的 pubkey (自动同步过去, 方便用户复制) 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 pubkey = '' if (!NO_SIGN) { - if (!fs.existsSync(KEY_PATH)) { - console.error('[build-ota] 私钥不存在:', KEY_PATH) + // CI 注入: 私钥走环境变量, 写到临时文件, 签名后立即删除 (避免落盘) + 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 ') console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key') process.exit(1) } - // minisign -Sm -s [-W] 生成 detached signature - const sigArgs = ['-Sm', BUNDLE_PATH, '-s', KEY_PATH] - // 私钥无密码时不需要 -W + // minisign -Sm -s 生成 detached signature + const sigArgs = ['-Sm', BUNDLE_PATH, '-s', KEY_PATH_USE] 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) { console.error('[build-ota] minisign 签名失败') process.exit(1) @@ -81,7 +98,6 @@ if (!NO_SIGN) { const PUB_PATH = path.join(path.dirname(KEY_PATH), 'hotswap.pub') if (fs.existsSync(PUB_PATH)) { pubkey = fs.readFileSync(PUB_PATH, 'utf8').trim() - // 提取 base64 部分 (第二行) const lines = pubkey.split(/\r?\n/) 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)) // 4) 提示 -console.log('\n=== OTA 包生成完成 ===') +console.log('') +console.log('=== OTA 包生成完成 ===') console.log('输出目录: ', OUT_DIR) console.log('版本: ', manifest.version) 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('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)') 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) - // 自动同步 (可选, 注释掉默认行为; 用户自己粘更安全) - // syncTauriConf(pubkey) } -console.log('\n下一步: 把这两个文件发布到 OTA 存储:') +console.log('') +console.log('下一步: 把这两个文件发布到 OTA 存储:') console.log(' ' + BUNDLE_NAME) console.log(' latest.json') 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) - } -}