// 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:-}" >&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 ?: ''}" } } }