Files
config-center/.github/workflows/publish-runtime.yml

254 lines
17 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 上游部署模板:经审阅复制到 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