// 定时版本调度管线:解析远端分支版本,只有和上一次触发过的版本不同时, // 才用同一个固定 commit 触发 Full Build、AGC 发号与 AGC Windows/macOS 渠道构建。 pipeline { agent { label 'linux && genarrative-build' } options { disableConcurrentBuilds() skipDefaultCheckout(true) buildDiscarder(logRotator(numToKeepStr: '200', artifactNumToKeepStr: '10')) } triggers { cron('H * * * *') } 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_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-triggered-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: '勾选后忽略版本比较,强制触发两个下游管线') } 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 "远端版本: branch=${SOURCE_BRANCH} revision=${revision}" ''' } script { env.REMOTE_REVISION = readFile('.jenkins-remote-revision').trim() env.LAST_TRIGGERED_REVISION = fileExists(env.REVISION_STATE_FILE) ? readFile(env.REVISION_STATE_FILE).trim() : '' env.REVISION_CHANGED = (params.FORCE_TRIGGER || env.LAST_TRIGGERED_REVISION != env.REMOTE_REVISION) ? 'true' : 'false' if (env.REVISION_CHANGED != 'true') { currentBuild.result = 'NOT_BUILT' currentBuild.description = "版本未变化,跳过:${env.SOURCE_BRANCH} 仍是 ${env.REMOTE_REVISION.take(12)}" echo currentBuild.description } } } } // 按「本轮到达的提交」判定两条下游各自是否需要跑: // - AGC:只有出现客户端相关路径才发布 Windows 客户端(避免纯文档提交推高版本号)。 // - Full Build:只在改动全部落在「与线上站点/后端无关」的路径时跳过(fail-open 到部署)。 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="$(cat "${REVISION_STATE_FILE}" 2>/dev/null || true)" if [[ -z "${previous}" ]]; then printf 'agc=changed\nfull=changed\n' exit 0 fi 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 if ! git -C "${AGC_SCOPE_CACHE_DIR}" cat-file -e "${previous}^{commit}" 2>/dev/null; then echo "浅取窗口内没有 ${previous},按需要发布处理" >&2 printf 'agc=changed\nfull=changed\n' exit 0 fi changed_paths="$(git -C "${AGC_SCOPE_CACHE_DIR}" diff --name-only "${previous}" "${REMOTE_REVISION}" 2>/dev/null || true)" agc_scope=unchanged full_scope=unchanged 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 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 <<< "${changed_paths}" printf 'agc=%s\nfull=%s\n' "${agc_scope}" "${full_scope}" ''', returnStdout: true).trim() } catch (error) { echo "发布范围判定失败,按需要发布处理:${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 "发布范围:AGC=${env.AGC_RELEASE_SCOPE} FullBuild=${env.FULL_BUILD_SCOPE}(上一轮已触发 revision=${env.LAST_TRIGGERED_REVISION ?: '无'})" } } } } stage('Trigger Downstream Pipelines') { when { expression { return env.REVISION_CHANGED == 'true' } } steps { script { // 两个下游管线必须拿到同一个固定 revision,避免各自解析分支 HEAD 造成版本漂移。 def pinnedRevision = env.REMOTE_REVISION def pinnedParameters = [ string(name: 'SOURCE_BRANCH', value: env.SOURCE_BRANCH), string(name: 'COMMIT_HASH', value: pinnedRevision), string(name: 'DATABASE_BACKUP_MODE', value: 'skip'), ] def fullTriggered = false if (params.FORCE_TRIGGER || env.FULL_BUILD_SCOPE != 'unchanged') { build job: env.FULL_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters fullTriggered = true } else { echo "本轮提交全部与线上站点/后端无关,跳过 ${env.FULL_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER" } def agcTriggered = false if (params.FORCE_TRIGGER || env.AGC_RELEASE_SCOPE != 'unchanged') { // 客户端版本号只在发号 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: 'dev-win'), ] 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 客户端版本号: ${env.AGC_GLOBAL_VERSION}" def agcParameters = pinnedParameters + [ string(name: 'AGC_RELEASE_VERSION', value: env.AGC_GLOBAL_VERSION), ] build job: env.AGC_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcParameters // macOS 与 Windows 共用发号 Job 下发的同一个总号:调度只透传号与固定 revision, // 不允许 Mac 侧自行发号,否则同一轮两个平台会拿到不同的号。 def agcMacosParameters = [ string(name: 'SOURCE_BRANCH', value: env.SOURCE_BRANCH), string(name: 'COMMIT_HASH', value: pinnedRevision), string(name: 'AGC_UPDATE_CHANNEL', value: 'dev'), string(name: 'AGC_RELEASE_VERSION', value: env.AGC_GLOBAL_VERSION), // Mac 节点是办公机:离线期间排队的旧构建在节点回来后必须自行让位, // 不允许发布一轮已经过期的版本。 booleanParam(name: 'SKIP_IF_SUPERSEDED', value: true), ] build job: env.AGC_MACOS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcMacosParameters agcTriggered = true } else { echo "本轮提交不含 AGC 相关路径,跳过 ${env.AGC_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER" } writeFile file: env.REVISION_STATE_FILE, text: pinnedRevision def triggered = [] if (fullTriggered) { triggered.add(env.FULL_BUILD_JOB_NAME) } if (agcTriggered) { triggered.add(env.AGC_BUILD_JOB_NAME) } if (agcTriggered) { triggered.add(env.AGC_MACOS_BUILD_JOB_NAME) } def target = "${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}" currentBuild.description = triggered.isEmpty() ? "本轮提交与两条下游都无关,未触发任何发布:${target}" : "已触发 ${triggered.join(' 与 ')}:${target}" echo currentBuild.description } } } } post { always { echo "调度结束: branch=${env.SOURCE_BRANCH} revision=${env.REMOTE_REVISION ?: ''} changed=${env.REVISION_CHANGED ?: ''}" } } }