54a5401cce
Project CI / AI game creator shell Rust crates (push) Successful in 1m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m54s
Project CI / Native shell tests (push) Failing after 4m4s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m23s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 7m53s
Project CI / Backend tests (push) Successful in 7m54s
Project CI / Frontend tests (push) Successful in 5m34s
Project CI / Repository checks (push) Successful in 5m31s
Project CI / AI game creator shell web tests (push) Successful in 4m16s
按服务端与客户端 scope 分别触发 release Full Build 和 AGC 双平台 等待并汇总三类下游结果,按成功 lane 推进 revision 同步 release 参数、生产门禁与运维文档
290 lines
16 KiB
Plaintext
290 lines
16 KiB
Plaintext
// release 每日调度管线:每天 04:00 检查源码分支,
|
||
// 按独立 scope 触发 release Full Build 与 AGC Windows/macOS,
|
||
// 等待并汇总结果;只有对应 lane 成功后才推进该 lane 的成功 revision。
|
||
pipeline {
|
||
agent {
|
||
label 'linux && genarrative-build'
|
||
}
|
||
|
||
options {
|
||
disableConcurrentBuilds()
|
||
skipDefaultCheckout(true)
|
||
buildDiscarder(logRotator(numToKeepStr: '100', artifactNumToKeepStr: '10'))
|
||
}
|
||
|
||
triggers {
|
||
cron('0 4 * * *')
|
||
}
|
||
|
||
environment {
|
||
GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'
|
||
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
|
||
FULL_BUILD_JOB_NAME = 'Genarrative-Full-Build-And-Deploy'
|
||
AGC_WINDOWS_BUILD_JOB_NAME = 'Genarrative-Agc-Windows-Build'
|
||
AGC_MACOS_BUILD_JOB_NAME = 'Genarrative-Agc-MacOS-Build'
|
||
AGC_GLOBAL_VERSION_JOB_NAME = 'Genarrative-Agc-Global-Version-Issue'
|
||
AGC_GLOBAL_VERSION_ARTIFACT = 'agc-global-version.txt'
|
||
REVISION_STATE_FILE = '.jenkins-last-release-revision'
|
||
FULL_REVISION_STATE_FILE = '.jenkins-last-release-full-revision'
|
||
AGC_REVISION_STATE_FILE = '.jenkins-last-release-agc-revision'
|
||
AGC_SCOPE_CACHE_DIR = '.agc-release-scope-cache'
|
||
}
|
||
|
||
parameters {
|
||
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '被检查的源码分支;默认 master')
|
||
booleanParam(name: 'FORCE_TRIGGER', defaultValue: false, description: '勾选后忽略版本与路径比较,强制发布 release 服务端与 AGC 双平台')
|
||
choice(name: 'DATABASE_BACKUP_MODE', choices: ['async', 'sync', 'skip'], description: 'release 发布前 SpacetimeDB 备份策略;常规使用 async,高风险迁移使用 sync,skip 仅限明确无需备份')
|
||
choice(name: 'STDB_API_ROLLOUT_MODE', choices: ['normal', 'pause-after-stdb'], description: 'normal 为自动完成 Stdb → API;pause-after-stdb 在发布 Stdb 后等待审批人放行 API')
|
||
string(name: 'STDB_API_ROLLOUT_APPROVERS', defaultValue: '', description: 'pause-after-stdb 必填:允许放行 API 的 Jenkins 用户/组,多个值用逗号分隔')
|
||
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: true, description: 'release API 是否构建、归档并部署 Pingora 影子网关')
|
||
booleanParam(name: 'EXIT_MAINTENANCE_MODE_AFTER_COMPLETION', defaultValue: true, description: '完整发布成功后是否退出维护模式;首次验收可暂时关闭')
|
||
}
|
||
|
||
stages {
|
||
stage('Resolve Revision') {
|
||
steps {
|
||
withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) {
|
||
sh '''#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
export GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?缺少 Git SSH 凭据} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
|
||
revision="$(git ls-remote "${GIT_REMOTE_URL:?缺少 GIT_REMOTE_URL}" "refs/heads/${SOURCE_BRANCH}" | awk 'NR == 1 { print $1 }')"
|
||
if [[ ! "${revision}" =~ ^[0-9a-f]{40}$ ]]; then
|
||
echo "无法解析远端分支版本: branch=${SOURCE_BRANCH} revision=${revision:-<empty>}" >&2
|
||
exit 1
|
||
fi
|
||
printf '%s' "${revision}" > .jenkins-remote-revision
|
||
echo "release 远端版本: branch=${SOURCE_BRANCH} revision=${revision}"
|
||
'''
|
||
}
|
||
script {
|
||
env.REMOTE_REVISION = readFile('.jenkins-remote-revision').trim()
|
||
def legacyRevision = fileExists(env.REVISION_STATE_FILE) ? readFile(env.REVISION_STATE_FILE).trim() : ''
|
||
env.LAST_FULL_REVISION = fileExists(env.FULL_REVISION_STATE_FILE) ? readFile(env.FULL_REVISION_STATE_FILE).trim() : legacyRevision
|
||
env.LAST_AGC_REVISION = fileExists(env.AGC_REVISION_STATE_FILE) ? readFile(env.AGC_REVISION_STATE_FILE).trim() : legacyRevision
|
||
env.FULL_REVISION_CHANGED = (params.FORCE_TRIGGER || env.LAST_FULL_REVISION != env.REMOTE_REVISION) ? 'true' : 'false'
|
||
env.AGC_REVISION_CHANGED = (params.FORCE_TRIGGER || env.LAST_AGC_REVISION != env.REMOTE_REVISION) ? 'true' : 'false'
|
||
env.REVISION_CHANGED = (env.FULL_REVISION_CHANGED == 'true' || env.AGC_REVISION_CHANGED == 'true') ? 'true' : 'false'
|
||
if (params.STDB_API_ROLLOUT_MODE == 'pause-after-stdb' && !params.STDB_API_ROLLOUT_APPROVERS?.trim()) {
|
||
error('pause-after-stdb 必须填写 STDB_API_ROLLOUT_APPROVERS。')
|
||
}
|
||
if (env.REVISION_CHANGED != 'true') {
|
||
currentBuild.result = 'NOT_BUILT'
|
||
currentBuild.description = "release 版本未变化,跳过:${env.SOURCE_BRANCH} 仍是 ${env.REMOTE_REVISION.take(12)}"
|
||
echo currentBuild.description
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
stage('Resolve Release Scope') {
|
||
when {
|
||
expression { return env.REVISION_CHANGED == 'true' }
|
||
}
|
||
steps {
|
||
withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) {
|
||
script {
|
||
// 判定失败一律按「需要发布」处理,避免路径过滤阻塞正式发布。
|
||
def output = 'agc=changed\nfull=changed'
|
||
try {
|
||
output = sh(script: '''#!/usr/bin/env bash
|
||
set -uo pipefail
|
||
export GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?缺少 Git SSH 凭据} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
|
||
previous_agc="$(cat "${AGC_REVISION_STATE_FILE}" 2>/dev/null || cat "${REVISION_STATE_FILE}" 2>/dev/null || true)"
|
||
previous_full="$(cat "${FULL_REVISION_STATE_FILE}" 2>/dev/null || cat "${REVISION_STATE_FILE}" 2>/dev/null || true)"
|
||
mkdir -p "${AGC_SCOPE_CACHE_DIR}"
|
||
if [[ ! -d "${AGC_SCOPE_CACHE_DIR}/.git" ]]; then
|
||
git -C "${AGC_SCOPE_CACHE_DIR}" init --quiet
|
||
git -C "${AGC_SCOPE_CACHE_DIR}" remote add origin "${GIT_REMOTE_URL}" 2>/dev/null || true
|
||
fi
|
||
refspec="+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}"
|
||
if ! git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags --filter=blob:none origin "${refspec}"; then
|
||
git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags origin "${refspec}" || { printf 'agc=changed\nfull=changed\n'; exit 0; }
|
||
fi
|
||
changed_paths_for() {
|
||
local previous="$1"
|
||
if [[ -z "${previous}" ]] || ! git -C "${AGC_SCOPE_CACHE_DIR}" cat-file -e "${previous}^{commit}" 2>/dev/null; then
|
||
printf '__changed__\n'
|
||
return
|
||
fi
|
||
git -C "${AGC_SCOPE_CACHE_DIR}" diff --name-only "${previous}" "${REMOTE_REVISION}" 2>/dev/null || printf '__changed__\n'
|
||
}
|
||
agc_paths="$(changed_paths_for "${previous_agc}")"
|
||
agc_scope=unchanged
|
||
if [[ "${agc_paths}" == '__changed__' ]]; then
|
||
agc_scope=changed
|
||
else
|
||
while IFS= read -r changed_path; do
|
||
[[ -z "${changed_path}" ]] && continue
|
||
case "${changed_path}" in
|
||
apps/ai-game-creator-shell/*|packages/*|server-rs/crates/*|plugins/agc-cocos-editor/*|plugins/agc-unity-editor/*|plugins/agc-godot-editor/*|apps/desktop-shell/src-tauri/icons/*|package.json|package-lock.json)
|
||
agc_scope=changed
|
||
;;
|
||
esac
|
||
done <<< "${agc_paths}"
|
||
fi
|
||
full_paths="$(changed_paths_for "${previous_full}")"
|
||
full_scope=unchanged
|
||
if [[ "${full_paths}" == '__changed__' ]]; then
|
||
full_scope=changed
|
||
else
|
||
while IFS= read -r changed_path; do
|
||
[[ -z "${changed_path}" ]] && continue
|
||
case "${changed_path}" in
|
||
docs/*|.codex/*|jenkins/*|apps/ai-game-creator-shell/*|apps/mobile-shell/*|apps/desktop-shell/*|apps/preview-deployer-web/*|tools/*|*.md)
|
||
;;
|
||
*)
|
||
full_scope=changed
|
||
;;
|
||
esac
|
||
done <<< "${full_paths}"
|
||
fi
|
||
printf 'agc=%s\nfull=%s\n' "${agc_scope}" "${full_scope}"
|
||
''', returnStdout: true).trim()
|
||
} catch (error) {
|
||
echo "release 发布范围判定失败,按需要发布处理:${error}"
|
||
output = 'agc=changed\nfull=changed'
|
||
}
|
||
def values = output.split('\n').collect { it.trim() }.findAll { it }
|
||
def readScope = { String key ->
|
||
def entry = values.find { it.startsWith(key + '=') }
|
||
def value = entry == null ? '' : entry.split('=')[1]
|
||
return (value == 'unchanged') ? 'unchanged' : 'changed'
|
||
}
|
||
env.AGC_RELEASE_SCOPE = readScope('agc')
|
||
env.FULL_BUILD_SCOPE = readScope('full')
|
||
echo "release 发布范围:AGC=${env.AGC_RELEASE_SCOPE} FullBuild=${env.FULL_BUILD_SCOPE}(AGC=${env.LAST_AGC_REVISION ?: '无'},Full=${env.LAST_FULL_REVISION ?: '无'})"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
stage('Trigger Release Downstreams') {
|
||
when {
|
||
expression { return env.REVISION_CHANGED == 'true' }
|
||
}
|
||
steps {
|
||
script {
|
||
def pinnedRevision = env.REMOTE_REVISION
|
||
def fullTriggered = params.FORCE_TRIGGER || env.FULL_BUILD_SCOPE != 'unchanged'
|
||
def agcTriggered = params.FORCE_TRIGGER || env.AGC_RELEASE_SCOPE != 'unchanged'
|
||
def fullParameters = [
|
||
string(name: 'SOURCE_BRANCH', value: env.SOURCE_BRANCH),
|
||
string(name: 'COMMIT_HASH', value: pinnedRevision),
|
||
string(name: 'DEPLOY_TARGET', value: 'release'),
|
||
booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: true),
|
||
string(name: 'DATABASE_BACKUP_MODE', value: params.DATABASE_BACKUP_MODE),
|
||
string(name: 'STDB_API_ROLLOUT_MODE', value: params.STDB_API_ROLLOUT_MODE),
|
||
string(name: 'STDB_API_ROLLOUT_APPROVERS', value: params.STDB_API_ROLLOUT_APPROVERS?.trim() ?: ''),
|
||
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', value: params.INCLUDE_PINGORA_GATEWAY),
|
||
booleanParam(name: 'EXIT_MAINTENANCE_MODE_AFTER_COMPLETION', value: params.EXIT_MAINTENANCE_MODE_AFTER_COMPLETION),
|
||
]
|
||
if (!fullTriggered) {
|
||
echo "上一轮 release 调度后没有服务端相关变更,跳过 ${env.FULL_BUILD_JOB_NAME}"
|
||
}
|
||
if (!agcTriggered) {
|
||
echo "上一轮 release 调度后没有 AGC 客户端相关变更,跳过 ${env.AGC_WINDOWS_BUILD_JOB_NAME} 与 ${env.AGC_MACOS_BUILD_JOB_NAME}"
|
||
}
|
||
def outcome = [:]
|
||
def runDownstream = { String jobName, List jobParameters, int timeoutMinutes ->
|
||
try {
|
||
timeout(time: timeoutMinutes, unit: 'MINUTES') {
|
||
def run = build job: jobName,
|
||
wait: true,
|
||
propagate: false,
|
||
parameters: jobParameters
|
||
outcome[jobName] = "#${run.number}:${run.result}"
|
||
if (run.result != 'SUCCESS') {
|
||
error("${jobName} 结果=${run.result}")
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (!outcome[jobName]) {
|
||
outcome[jobName] = "异常:${err.message}"
|
||
}
|
||
echo "${jobName} 等待失败: ${err.message}"
|
||
throw err
|
||
}
|
||
}
|
||
def branches = [:]
|
||
def fullTimeout = params.STDB_API_ROLLOUT_MODE == 'pause-after-stdb' ? 300 : 150
|
||
if (fullTriggered) {
|
||
branches[env.FULL_BUILD_JOB_NAME] = {
|
||
runDownstream(env.FULL_BUILD_JOB_NAME, fullParameters, fullTimeout)
|
||
}
|
||
}
|
||
if (agcTriggered) {
|
||
// Windows 与 macOS 共用一个总号;发号 Job 是唯一发号源。
|
||
def versionRun = build job: env.AGC_GLOBAL_VERSION_JOB_NAME,
|
||
wait: true,
|
||
propagate: true,
|
||
parameters: [
|
||
string(name: 'SOURCE_BRANCH', value: env.SOURCE_BRANCH),
|
||
string(name: 'COMMIT_HASH', value: pinnedRevision),
|
||
string(name: 'AGC_CHANNEL', value: 'release-unified'),
|
||
]
|
||
copyArtifacts(
|
||
projectName: env.AGC_GLOBAL_VERSION_JOB_NAME,
|
||
selector: specific(versionRun.number.toString()),
|
||
filter: env.AGC_GLOBAL_VERSION_ARTIFACT,
|
||
target: '.',
|
||
flatten: true,
|
||
)
|
||
env.AGC_GLOBAL_VERSION = readFile(env.AGC_GLOBAL_VERSION_ARTIFACT).trim()
|
||
echo "本轮 AGC release 客户端总版本号: ${env.AGC_GLOBAL_VERSION}"
|
||
def agcParameters = [
|
||
string(name: 'SOURCE_BRANCH', value: env.SOURCE_BRANCH),
|
||
string(name: 'COMMIT_HASH', value: pinnedRevision),
|
||
string(name: 'AGC_UPDATE_CHANNEL', value: 'release'),
|
||
string(name: 'AGC_RELEASE_VERSION', value: env.AGC_GLOBAL_VERSION),
|
||
]
|
||
// macOS 节点是办公机:离线期间排队的旧构建在节点回来后必须自行让位。
|
||
def agcMacosParameters = agcParameters + [
|
||
booleanParam(name: 'SKIP_IF_SUPERSEDED', value: true),
|
||
]
|
||
branches[env.AGC_WINDOWS_BUILD_JOB_NAME] = {
|
||
runDownstream(env.AGC_WINDOWS_BUILD_JOB_NAME, agcParameters, 120)
|
||
}
|
||
branches[env.AGC_MACOS_BUILD_JOB_NAME] = {
|
||
runDownstream(env.AGC_MACOS_BUILD_JOB_NAME, agcMacosParameters, 120)
|
||
}
|
||
}
|
||
if (!branches.isEmpty()) {
|
||
try {
|
||
parallel branches
|
||
} catch (err) {
|
||
echo "release 并行下游存在失败分支: ${err.message}"
|
||
}
|
||
}
|
||
def fullResult = outcome[env.FULL_BUILD_JOB_NAME]
|
||
def windowsResult = outcome[env.AGC_WINDOWS_BUILD_JOB_NAME]
|
||
def macosResult = outcome[env.AGC_MACOS_BUILD_JOB_NAME]
|
||
def fullSucceeded = !fullTriggered || (fullResult != null && fullResult.endsWith(':SUCCESS'))
|
||
def agcSucceeded = !agcTriggered || (windowsResult != null && windowsResult.endsWith(':SUCCESS') && macosResult != null && macosResult.endsWith(':SUCCESS'))
|
||
if (fullSucceeded) {
|
||
writeFile file: env.FULL_REVISION_STATE_FILE, text: pinnedRevision
|
||
}
|
||
if (agcSucceeded) {
|
||
writeFile file: env.AGC_REVISION_STATE_FILE, text: pinnedRevision
|
||
}
|
||
if (fullSucceeded && agcSucceeded) {
|
||
writeFile file: env.REVISION_STATE_FILE, text: pinnedRevision
|
||
}
|
||
def summary = outcome.collect { jobName, result -> "${jobName}=${result}" }.join(' ')
|
||
def target = "${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}"
|
||
currentBuild.description = "release 调度 ${target};FullBuild=${fullTriggered ? 'triggered' : 'unchanged'};AGC=${agcTriggered ? 'triggered' : 'unchanged'}${summary ? ';' + summary : ''}"
|
||
echo currentBuild.description
|
||
if (!fullSucceeded || !agcSucceeded) {
|
||
error("release 下游未全部成功: ${summary}")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
post {
|
||
always {
|
||
echo "release 调度结束: branch=${env.SOURCE_BRANCH} revision=${env.REMOTE_REVISION ?: ''} changed=${env.REVISION_CHANGED ?: ''} agcScope=${env.AGC_RELEASE_SCOPE ?: ''} fullScope=${env.FULL_BUILD_SCOPE ?: ''}"
|
||
}
|
||
}
|
||
}
|