Files
Genarrative/jenkins/Jenkinsfile.scheduled-release-trigger
kdletters 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
将正式 Full Build 纳入 release 每日调度
按服务端与客户端 scope 分别触发 release Full Build 和 AGC 双平台
等待并汇总三类下游结果,按成功 lane 推进 revision
同步 release 参数、生产门禁与运维文档
2026-09-22 17:59:57 +08:00

290 lines
16 KiB
Plaintext
Raw Permalink 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.
// 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 ?: ''}"
}
}
}