This commit is contained in:
@@ -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 }}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user