From 53f63e0986664eeaadc457d3062f03e4ca07cf0f Mon Sep 17 00:00:00 2001 From: "jif2.zhang" Date: Sun, 20 Sep 2026 20:32:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20CI=20secrets=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=88=B0=E6=B3=A8=E5=86=8C=E4=B8=AD=E5=BF=83=E7=9A=84?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/sync-registry.yml | 52 +++++++++ scripts/sync-registry.mjs | 177 +++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 .gitea/workflows/sync-registry.yml create mode 100644 scripts/sync-registry.mjs diff --git a/.gitea/workflows/sync-registry.yml b/.gitea/workflows/sync-registry.yml new file mode 100644 index 0000000..4088774 --- /dev/null +++ b/.gitea/workflows/sync-registry.yml @@ -0,0 +1,52 @@ +name: Sync Secrets to Registry + +# 将本仓库 Gitea CI/CD secrets 明文写回注册中心 (Registry 配置管理中心) 的 MCP 服务 +# 目标: service-iboard 项目 (proj-msid70ql-6xbq4w) +# 触发: 仅手动 (workflow_dispatch) +# 依赖 secret: +# - REGISTRY_MCP_TOKEN : 注册中心 MCP 访问令牌 (在注册中心 🔑Token 管理 里生成) +# - 其余 secret 与三个构建 workflow 引用的保持一致 (未设置则跳过对应键) +# 注意: 传输为明文 http, 请保证注册中心仅内网可达; 若可访问则改用 https + +on: + workflow_dispatch: + inputs: + dry_run: + description: '仅预演不写入 (true=只打印计划)' + required: false + default: 'false' + +jobs: + sync-secrets: + runs-on: ubuntu-latest + permissions: + contents: read + env: + REGISTRY_MCP_URL: http://bh.vps.honor3.com:8008/mcp + REGISTRY_PROJECT_ID: proj-msid70ql-6xbq4w + REGISTRY_IDC: '1' + REGISTRY_ENVIRONMENT: '1' + steps: + - uses: https://gitea.com/actions/checkout@v4 + + - name: Setup Node + uses: https://gitea.com/actions/setup-node@v4 + with: + node-version: 22 + + - name: Sync secrets via MCP + run: | + if [ "${{ inputs.dry_run }}" = "true" ]; then + node scripts/sync-registry.mjs --dry-run + else + node scripts/sync-registry.mjs + fi + env: + REGISTRY_MCP_TOKEN: ${{ secrets.REGISTRY_MCP_TOKEN }} + MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} \ No newline at end of file diff --git a/scripts/sync-registry.mjs b/scripts/sync-registry.mjs new file mode 100644 index 0000000..bfa01cb --- /dev/null +++ b/scripts/sync-registry.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node +// 将 Gitea CI/CD secrets 明文写回注册中心 (Registry 配置管理中心) 的 MCP 服务 +// 传输层: MCP streamable HTTP (POST /mcp), 认证 Bearer +// 详见 workflow: .gitea/workflows/sync-registry.yml +// +// 用法: +// REGISTRY_MCP_URL=http://bh.vps.honor3.com:8008/mcp \ +// REGISTRY_MCP_TOKEN= \ +// REGISTRY_PROJECT_ID=proj-msid70ql-6xbq4w \ +// node scripts/sync-registry.mjs [--dry-run] +// +// 安全: 本脚本不会把任何 secret 的值打印到日志, 只输出 键名 + 结果。 +// 注意: 当前通过明文 http 传输, 请确保注册中心仅在内网可达 (或改用 https)。 + +const MCP_URL = process.env.REGISTRY_MCP_URL || 'http://bh.vps.honor3.com:8008/mcp' +const MCP_TOKEN = process.env.REGISTRY_MCP_TOKEN || '' +const PROJECT_ID = process.env.REGISTRY_PROJECT_ID || 'proj-msid70ql-6xbq4w' +const IDC = process.env.REGISTRY_IDC || '1' +const ENV = process.env.REGISTRY_ENVIRONMENT || '1' +const DRY_RUN = process.argv.includes('--dry-run') + +// CI 环境变量 -> 注册中心配置键 (key 支持 / 嵌套, 本项目分组映射为 path => group/key) +const SECRET_MAP = [ + { env: 'MINISIGN_PRIVATE_KEY', key: 'minisign.privateKey', group: 'secret', type: 'string', description: 'minisign 签名 OTA 升级包的私钥' }, + { env: 'DATABASE_URL', key: 'database.url', group: 'secret', type: 'string', description: 'MySQL 连接串' }, + { env: 'JWT_SECRET', key: 'jwt.secret', group: 'secret', type: 'string', description: 'JWT 签名密钥' }, + { env: 'ANDROID_KEYSTORE_B64', key: 'android.keystore.b64', group: 'secret', type: 'string', description: 'Android release keystore (base64)' }, + { env: 'ANDROID_KEY_ALIAS', key: 'android.key.alias', group: 'secret', type: 'string', description: 'Android keystore 别名' }, + { env: 'ANDROID_KEY_PASSWORD', key: 'android.key.password', group: 'secret', type: 'string', description: 'Android keystore 密码' }, + { env: 'PACKAGE_TOKEN', key: 'package.token', group: 'secret', type: 'string', description: 'Gitea Generic Package 写权限 token' }, +] + +if (!MCP_TOKEN) { + console.error('缺少 REGISTRY_MCP_TOKEN (MCP 访问令牌)') + process.exit(2) +} + +let sessionId = null +let nextId = 0 + +function parseSse(text) { + let data = '' + for (const line of text.split(/\r?\n/)) { + if (line.startsWith('data:')) data += line.slice(5).trim() + } + if (!data) throw new Error(`MCP 响应没有 data 字段: ${text.slice(0, 200)}`) + const obj = JSON.parse(data) + if (obj.error) { + const e = new Error(`${obj.error.code}: ${obj.error.message}`) + e.mcpError = obj.error + throw e + } + return obj +} + +async function rpc(method, params) { + const headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + } + if (MCP_TOKEN) headers.Authorization = `Bearer ${MCP_TOKEN}` + if (sessionId) headers['mcp-session-id'] = sessionId + + const res = await fetch(MCP_URL, { + method: 'POST', + headers, + body: JSON.stringify({ jsonrpc: '2.0', id: ++nextId, method, params }), + }) + const sid = res.headers.get('mcp-session-id') + if (sid) sessionId = sid + const text = await res.text() + return parseSse(text) +} + +async function callTool(name, args) { + const payload = await rpc('tools/call', { name, arguments: args }) + if (payload.result?.isError) { + throw new Error(`工具 ${name} 执行失败: ${JSON.stringify(payload.result.content || {})}`) + } + const sc = payload.result?.structuredContent + if (sc !== undefined && sc !== null) return sc + const content = payload.result?.content || [] + const text = content.map((c) => c.text || '').join('\n').trim() + if (text) { + try { return JSON.parse(text) } catch { return { text } } + } + return payload.result || {} +} + +async function main() { + console.log(`== 同步 CI secrets -> 注册中心 (project=${PROJECT_ID})${DRY_RUN ? ' [dry-run]' : ''}`) + + // 1) initialize 拿 mcp-session-id (rpc 内部已缓存 header) + await rpc('initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'iboard-ci', version: '1.0.0' }, + }) + + // 2) 记录当前模式, 非 edit 则临时切到 edit (写 value 需要), 结束后恢复 + const mode = (await callTool('get_mode', {})).mode + console.log(`当前模式: ${mode}`) + const needEdit = mode !== 'edit' + if (needEdit) { + if (DRY_RUN) { + console.log('(dry-run) 待切换到 edit 模式') + } else { + await callTool('set_mode', { mode: 'edit' }) + console.log('已临时切换为 edit 模式') + } + } + + try { + // 3) 拉取现有配置, 键为 group/idc/environment/key + const list = await callTool('list_configs', { projectId: PROJECT_ID }) + const configs = Array.isArray(list.configs) ? list.configs : list + const existing = new Map() + for (const c of configs || []) { + if (!c || !c.key) continue + existing.set(`${c.idc || ''}|${c.environment || ''}|${c.group || ''}|${c.key}`, c) + } + + // 4) 逐个 upsert + let updated = 0 + let created = 0 + let skipped = 0 + for (const { env, key, group, type, description } of SECRET_MAP) { + const value = process.env[env] + if (value === undefined || value === '') { + console.log(`- [skip] ${group}/${key} <- 环境变量 ${env} 未设置`) + skipped++ + continue + } + const found = existing.get(`${IDC}|${ENV}|${group}|${key}`) + const target = `${group}/${key}` + if (found) { + if (DRY_RUN) { + console.log(`- [dry-run] update ${target} (cfg ${found.id}) <- ${env}`) + } else { + await callTool('update_config', { projectId: PROJECT_ID, configId: found.id, value }) + console.log(`- [update] ${target} (cfg ${found.id}) <- ${env}`) + } + updated++ + } else { + if (DRY_RUN) { + console.log(`- [dry-run] create ${target} <- ${env}`) + } else { + await callTool('create_config', { + projectId: PROJECT_ID, + key, + value, + type, + description, + idc: IDC, + environment: ENV, + group, + }) + console.log(`- [create] ${target} <- ${env}`) + } + created++ + } + } + + console.log(`\n== 完成: ${DRY_RUN ? 'dry-run' : '写入'} ${updated} 更新 / ${created} 新建 / ${skipped} 跳过 (值不回显)`) + } finally { + // 5) 恢复原模式 (本次未实际切模式时无操作) + if (needEdit && !DRY_RUN) { + await callTool('set_mode', { mode }) + console.log(`已恢复模式: ${mode}`) + } + } +} + +main().catch((err) => { + console.error(`\n同步失败: ${err.message}`) + process.exit(1) +}) \ No newline at end of file