feat: 增加配置中心纯运行数据发布流程 (#122)

This commit is contained in:
2026-10-16 20:04:55 +08:00
committed by GitHub
parent d77e4a27ac
commit fd6261859b
6 changed files with 9846 additions and 0 deletions

253
.github/workflows/publish-runtime.yml vendored Normal file
View File

@@ -0,0 +1,253 @@
# 上游部署模板:经审阅复制到 config-center 的 .github/workflows/publish-runtime.yml。
# 需先提交独立受审工具及 metadata;本模板不是本地执行入口,也不代表已经持续上线。
name: Publish Runtime Data
on:
workflow_run:
workflows: [Schema Validation]
types: [completed]
branches: [main]
permissions:
contents: read
concurrency:
group: config-center-runtime-publication
cancel-in-progress: false
jobs:
verify:
if: >-
github.repository == 'desirecore/config-center' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.head_repository.id == github.event.repository.id &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
actions: read
outputs:
eligible: ${{ steps.gate.outputs.eligible }}
source_sha: ${{ steps.gate.outputs.source_sha }}
steps:
- name: 固定 Node 工具链
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '24'
- name: 复核同仓库精确校验运行与必需 job
id: gate
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs')
;(async () => {
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))
const expected = 'desirecore/config-center', runEvent = event.workflow_run
const sha = runEvent?.head_sha
if (process.env.GITHUB_REPOSITORY !== expected || event.repository?.full_name !== expected
|| runEvent?.head_repository?.id !== event.repository?.id
|| runEvent?.head_repository?.full_name !== expected || runEvent.event !== 'push'
|| runEvent.head_branch !== 'main' || runEvent.conclusion !== 'success'
|| !/^[a-f0-9]{40}$/.test(sha ?? '') || !Number.isSafeInteger(runEvent.id)
|| !Number.isSafeInteger(runEvent.run_attempt) || runEvent.run_attempt < 1) throw new Error('guard')
const request = async path => {
const response = await fetch(`https://api.github.com/repos/${expected}${path}`, {
headers: { accept: 'application/vnd.github+json', authorization: `Bearer ${process.env.GH_TOKEN}`,
'x-github-api-version': '2022-11-28' }, signal: AbortSignal.timeout(20000),
})
if (!response.ok) throw new Error('api')
return response.json()
}
const run = await request(`/actions/runs/${runEvent.id}`)
if (run.id !== runEvent.id || run.run_attempt !== runEvent.run_attempt || run.head_sha !== sha
|| run.repository?.id !== event.repository.id || run.head_repository?.id !== event.repository.id
|| run.event !== 'push' || run.head_branch !== 'main' || run.status !== 'completed'
|| run.conclusion !== 'success' || run.name !== 'Schema Validation'
|| !Number.isSafeInteger(run.workflow_id) || run.workflow_id !== runEvent.workflow_id) throw new Error('run')
const workflow = await request(`/actions/workflows/${run.workflow_id}`)
if (workflow.id !== run.workflow_id || workflow.path !== '.github/workflows/validate.yml'
|| workflow.name !== 'Schema Validation') throw new Error('workflow')
const jobs = await request(`/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100`)
if (!Array.isArray(jobs.jobs) || !Number.isSafeInteger(jobs.total_count)
|| jobs.jobs.length !== jobs.total_count) throw new Error('jobs')
const required = jobs.jobs.filter(job => job.name === 'Validate against frozen schema')
if (required.length !== 1 || required[0].run_id !== run.id || required[0].head_sha !== sha
|| required[0].run_attempt !== run.run_attempt || required[0].status !== 'completed'
|| required[0].conclusion !== 'success') throw new Error('required_job')
for (const name of ['Run validator', 'Run unit tests']) {
const steps = required[0].steps?.filter(step => step.name === name)
if (steps?.length !== 1 || steps[0].status !== 'completed' || steps[0].conclusion !== 'success') throw new Error('required_step')
}
const main = await request('/git/ref/heads/main')
const eligible = main.object?.sha === sha
fs.appendFileSync(process.env.GITHUB_OUTPUT, `eligible=${eligible}\nsource_sha=${sha}\n`)
console.log(eligible ? '精确 main 校验已通过;允许纯运行派生。' : 'main 已前进;跳过旧校验运行,等待新 main 校验。')
})().catch(() => { console.error('config_center_runtime_ci_verification_failed'); process.exitCode = 1 })
NODE
publish:
needs: verify
if: needs.verify.outputs.eligible == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- name: 读取可信发布工具(不是触发运行的 artifact)
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
ref: ${{ github.workflow_sha }}
path: runtime-tool
persist-credentials: false
fetch-depth: 1
sparse-checkout: .github/runtime-publication
- name: 读取已验证的精确来源快照
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
ref: ${{ needs.verify.outputs.source_sha }}
path: runtime-source
persist-credentials: false
fetch-depth: 1
submodules: false
- name: 固定 Node 工具链
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '24'
- name: 审核工具摘要并派生独立本地 runtime
env:
SOURCE_SHA: ${{ needs.verify.outputs.source_sha }}
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs'), path = require('node:path'), os = require('node:os')
const crypto = require('node:crypto'), cp = require('node:child_process')
function main() {
const sha1 = /^[a-f0-9]{40}$/, sha256 = /^[a-f0-9]{64}$/
if (!sha1.test(process.env.SOURCE_SHA ?? '')) throw new Error('source')
const toolRoot = path.join(process.env.GITHUB_WORKSPACE, 'runtime-tool', '.github', 'runtime-publication')
const tool = path.join(toolRoot, 'publisher.cjs'), metadataFile = path.join(toolRoot, 'publisher.metadata.json')
for (const file of [tool, metadataFile]) {
const info = fs.lstatSync(file)
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size < 1 || info.size > 8 * 1024 * 1024) throw new Error('tool')
}
const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf8'))
const builtin = new Set(require('node:module').builtinModules.map(name => name.replace(/^node:/, '')))
if (metadata.formatVersion !== 1 || metadata.profile !== 'config-center-runtime-publisher-node-cjs-v1'
|| !sha1.test(metadata.sourceCommit ?? '') || !sha256.test(metadata.sha256 ?? '')
|| !Array.isArray(metadata.inputs) || metadata.inputs.length < 1 || metadata.inputs.length > 2048
|| !Array.isArray(metadata.externalImports)
|| metadata.externalImports.some(name => typeof name !== 'string' || !builtin.has(name.replace(/^node:/, '')))) throw new Error('metadata')
const inputNames = new Set()
for (const input of metadata.inputs) {
if (typeof input.path !== 'string' || input.path.length < 1 || input.path.length > 512
|| input.path.includes('\\') || input.path.split('/').some(part => !part || part === '.' || part === '..')
|| /[\x00-\x1f:]/.test(input.path) || !sha256.test(input.sha256 ?? '') || inputNames.has(input.path)) throw new Error('input')
inputNames.add(input.path)
}
if (crypto.createHash('sha256').update(fs.readFileSync(tool)).digest('hex') !== metadata.sha256) throw new Error('tool_digest')
const scratch = fs.mkdtempSync(path.join(process.env.RUNNER_TEMP, 'dc-runtime-publication-'))
const home = path.join(scratch, 'home'), hooks = path.join(scratch, 'hooks'), template = path.join(scratch, 'template')
for (const dir of [home, hooks, template]) fs.mkdirSync(dir)
const runtimeDir = path.join(scratch, 'runtime.git')
const cleanEnv = Object.fromEntries(['PATH', 'Path', 'SystemRoot', 'windir', 'TEMP', 'TMP', 'TMPDIR']
.flatMap(key => process.env[key] === undefined ? [] : [[key, process.env[key]]]))
const gitEnv = { ...cleanEnv, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home,
GIT_CONFIG_GLOBAL: os.devNull, GIT_CONFIG_SYSTEM: os.devNull, GIT_CONFIG_NOSYSTEM: '1',
GIT_TEMPLATE_DIR: template, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', GIT_ALLOW_PROTOCOL: 'https',
GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1' }
const runGit = args => cp.execFileSync('git', ['-c', `core.hooksPath=${hooks}`, '-c', 'core.fsmonitor=false',
'-c', 'gc.auto=0', '-c', 'maintenance.auto=false', ...args], { cwd: scratch, env: gitEnv,
encoding: 'utf8', stdio: 'pipe', timeout: 60000, maxBuffer: 8 * 1024 * 1024 }).trim()
const url = 'https://github.com/desirecore/config-center.git'
const line = runGit(['ls-remote', '--refs', url, 'refs/heads/runtime'])
const expectedRuntimeCommit = line ? line.split('\t')[0] : null
if (line && (!sha1.test(expectedRuntimeCommit) || line !== `${expectedRuntimeCommit}\trefs/heads/runtime`)) throw new Error('runtime_ref')
runGit(['init', '--bare', '--object-format=sha1', '--initial-branch=runtime', runtimeDir])
if (expectedRuntimeCommit) {
// 不抓作者 main、不抓 tags、不给 depth;publisher 会核整条纯 runtime 历史。
runGit([`--git-dir=${runtimeDir}`, 'fetch', '--no-tags', '--no-recurse-submodules', '--no-write-fetch-head',
url, 'refs/heads/runtime:refs/heads/runtime'])
if (runGit([`--git-dir=${runtimeDir}`, 'rev-parse', 'refs/heads/runtime']) !== expectedRuntimeCommit) throw new Error('runtime_moved')
}
const source = path.join(process.env.GITHUB_WORKSPACE, 'runtime-source')
// 使用已验证公开来源提交的作者身份,不读取 runner 或用户的全局身份配置。
const author = runGit(['-C', source, 'show', '-s', '--format=%an%x00%ae', process.env.SOURCE_SHA]).split('\0')
if (author.length !== 2) throw new Error('author')
const stdout = cp.execFileSync(process.execPath, [tool, '--source', source, '--source-commit', process.env.SOURCE_SHA,
'--output', runtimeDir, '--apply', '--author-name', author[0], '--author-email', author[1]], {
env: cleanEnv, encoding: 'utf8', stdio: 'pipe', timeout: 300000, maxBuffer: 2 * 1024 * 1024,
})
const result = JSON.parse(stdout.split('\n')[0])
if (result.sourceCommit !== process.env.SOURCE_SHA || result.dryRun !== false || typeof result.changed !== 'boolean'
|| !sha1.test(result.runtimeCommit ?? '') || !sha256.test(result.payloadDigest ?? '')
|| !Number.isSafeInteger(result.dataVersion) || result.dataVersion < 1
|| !Number.isSafeInteger(result.fileCount) || result.fileCount < 1 || result.fileCount > 4096
|| result.validationBoundary !== 'runtime-payload-and-client-contract-only') throw new Error('result')
fs.writeFileSync(path.join(process.env.RUNNER_TEMP, 'config-center-runtime-publication.json'), JSON.stringify({
sourceCommit: result.sourceCommit, runtimeDir, home, hooks, template, expectedRuntimeCommit,
runtimeCommit: result.runtimeCommit, changed: result.changed, payloadDigest: result.payloadDigest,
dataVersion: result.dataVersion, fileCount: result.fileCount,
}), { flag: 'wx', mode: 0o600 })
console.log(result.changed ? '纯运行字节已改变;本地候选已派生,尚未推送。' : '纯运行字节未改变;没有新增 runtime 提交。')
}
try { main() }
catch { console.error('config_center_runtime_ci_derivation_failed'); process.exitCode = 1 }
NODE
- name: 仅快进发布 runtime ref
env:
GH_TOKEN: ${{ github.token }}
SOURCE_SHA: ${{ needs.verify.outputs.source_sha }}
shell: bash
run: |
node <<'NODE'
const fs = require('node:fs'), path = require('node:path'), os = require('node:os'), cp = require('node:child_process')
function main() {
const receipt = JSON.parse(fs.readFileSync(path.join(process.env.RUNNER_TEMP, 'config-center-runtime-publication.json'), 'utf8'))
const sha1 = /^[a-f0-9]{40}$/
if (receipt.sourceCommit !== process.env.SOURCE_SHA || !sha1.test(receipt.sourceCommit)
|| !sha1.test(receipt.runtimeCommit) || typeof receipt.changed !== 'boolean'
|| (receipt.expectedRuntimeCommit !== null && !sha1.test(receipt.expectedRuntimeCommit))) throw new Error('receipt')
if (!receipt.changed) { console.log('无运行内容变化;跳过推送。'); return }
const cleanEnv = Object.fromEntries(['PATH', 'Path', 'SystemRoot', 'windir', 'TEMP', 'TMP', 'TMPDIR']
.flatMap(key => process.env[key] === undefined ? [] : [[key, process.env[key]]]))
const gitEnv = { ...cleanEnv, HOME: receipt.home, USERPROFILE: receipt.home, XDG_CONFIG_HOME: receipt.home,
GIT_CONFIG_GLOBAL: os.devNull, GIT_CONFIG_SYSTEM: os.devNull, GIT_CONFIG_NOSYSTEM: '1', GIT_TEMPLATE_DIR: receipt.template,
GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', GIT_ALLOW_PROTOCOL: 'https', GIT_OPTIONAL_LOCKS: '0',
GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1' }
const runGit = (args, auth = false) => cp.execFileSync('git', ['-c', `core.hooksPath=${receipt.hooks}`,
'-c', 'core.fsmonitor=false', '-c', 'gc.auto=0', '-c', 'maintenance.auto=false', ...args], {
cwd: process.env.RUNNER_TEMP, encoding: 'utf8', stdio: 'pipe', timeout: 60000, maxBuffer: 1024 * 1024,
env: auth ? { ...gitEnv, GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader',
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${process.env.GH_TOKEN}`).toString('base64')}` } : gitEnv,
}).trim()
const url = 'https://github.com/desirecore/config-center.git'
const ref = 'refs/heads/runtime', gitDir = `--git-dir=${receipt.runtimeDir}`
const remoteOid = name => {
const line = runGit(['ls-remote', '--refs', url, name])
if (!line) return null
const oid = line.split('\t')[0]
if (!sha1.test(oid) || line !== `${oid}\t${name}`) throw new Error('ref')
return oid
}
if (remoteOid('refs/heads/main') !== receipt.sourceCommit) {
console.log('main 已前进;候选保留于本轮临时目录,不发布旧来源。'); return
}
if (remoteOid(ref) !== receipt.expectedRuntimeCommit) throw new Error('runtime_changed')
const expectedParents = receipt.expectedRuntimeCommit ? `${receipt.runtimeCommit} ${receipt.expectedRuntimeCommit}` : receipt.runtimeCommit
if (runGit([gitDir, 'rev-parse', ref]) !== receipt.runtimeCommit
|| runGit([gitDir, 'rev-list', '--parents', '-1', receipt.runtimeCommit]) !== expectedParents) throw new Error('parent')
if (!process.env.GH_TOKEN) throw new Error('token')
// 只发送一个固定 ref,不用 +/force/lease/mirror;远端须另行禁止回退及删除。
// 普通快进 push 不冒称跨管理员外部回退的绝对 expected-old lease。
runGit([gitDir, 'push', '--porcelain', '--no-follow-tags', url, `${receipt.runtimeCommit}:${ref}`], true)
if (remoteOid(ref) !== receipt.runtimeCommit) throw new Error('published_ref_changed')
console.log(`已发布纯运行数据 ${receipt.dataVersion},runtime ${receipt.runtimeCommit};本流程未写作者 main。`)
}
try { main() }
catch { console.error('config_center_runtime_ci_push_failed'); process.exitCode = 1 }
NODE