新增 CI secrets 同步到注册中心的独立 workflow
Docker Build and Push / build-image (push) Successful in 10m57s

This commit is contained in:
jif2.zhang
2026-09-20 20:32:54 +08:00
parent 508419180c
commit 53f63e0986
2 changed files with 229 additions and 0 deletions
+52
View File
@@ -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 }}
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env node
// 将 Gitea CI/CD secrets 明文写回注册中心 (Registry 配置管理中心) 的 MCP 服务
// 传输层: MCP streamable HTTP (POST /mcp), 认证 Bearer <REGISTRY_MCP_TOKEN>
// 详见 workflow: .gitea/workflows/sync-registry.yml
//
// 用法:
// REGISTRY_MCP_URL=http://bh.vps.honor3.com:8008/mcp \
// REGISTRY_MCP_TOKEN=<MCP 访问令牌> \
// 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)
})