// AGC 总版本号发号 Job:唯一的发号入口。 // // 设计要点(见 docs/technical/【技术方案】AGC总版本号与发号-2026-09-20.md): // - 发号顺序固定为「先写总号 → 再构建 → 再发渠道清单」,失败不回滚,只烧号; // - 各渠道构建只接收号(AGC_RELEASE_VERSION),不自己加号; // - 本 Job 用 disableConcurrentBuilds() 串行化发号(集群未安装 lockable-resources // 插件,因此以 Job 级串行 + 写后回读校验兜底并发); // - dry-run 只预览下一位,不写回、不烧号。 pipeline { agent { label 'linux && genarrative-build' } options { disableConcurrentBuilds() skipDefaultCheckout(true) buildDiscarder(logRotator(numToKeepStr: '100', artifactNumToKeepStr: '20')) // Copy Artifact 保持 Production 权限模式:生产者必须显式授权消费者。 // 所有调度与手动发布管线都要 copy 本 Job 的 agc-global-version.txt 才能把总号 // 透传给渠道构建;缺授权时 copyArtifacts 会报「Unable to find project for // artifact copy」(用户触发的构建按「认证用户」判权,SYSTEM 定时构建会短路放行, // 所以定时调度能过、手动发布必挂;2026-09-20 #103 与 2026-09-21 手动发布 // #6/#7 均命中)。这里必须列出全部消费者:Genarrative-Scheduled-Revision-Trigger、 // Genarrative-Scheduled-Release-Trigger 与 Genarrative-Manual-Build-And-Deploy。 // 改完本文件后必须先单独跑一次本 Job,让 Declarative Pipeline 把 Job property // 写回 Jenkins,再重跑调度或手动发布。 copyArtifactPermission('Genarrative-Scheduled-Revision-Trigger,Genarrative-Scheduled-Release-Trigger,Genarrative-Manual-Build-And-Deploy') } environment { GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git' GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh' GLOBAL_VERSION_OUT = 'agc-global-version.txt' OSSUTIL_TOOLS_DIR = '.jenkins-ossutil' } parameters { choice(name: 'AGC_CHANNEL', choices: ['unified', 'dev-win', 'dev-mac', 'release-unified', 'release-win', 'release-mac'], description: '发号用途标签:unified / release-unified 为一个号同时给两个平台,其余为单渠道热修') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '取发号脚本的源码分支;默认 master') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选:固定提交;留空则解析 SOURCE_BRANCH 当前 revision') booleanParam(name: 'SEED_ONLY', defaultValue: false, description: '仅做一次性播种:写入渠道与仓库版本的最大值作为基线,不递增、不烧号') booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false, description: '勾选后只预览下一个总号,不写回、不烧号') string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 命令名或绝对路径;缺省时本 Job 会下载到工作区私有目录') } stages { stage('Checkout') { steps { deleteDir() script { def checkoutFromRemote = { String remoteUrl, String credentialsId -> checkout([ $class: 'GitSCM', branches: [[name: params.COMMIT_HASH?.trim() ?: "*/${params.SOURCE_BRANCH}"]], doGenerateSubmoduleConfigurations: false, extensions: [ [$class: 'CleanBeforeCheckout'], [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], ], userRemoteConfigs: [[ url: remoteUrl, credentialsId: credentialsId, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}", ]], ]) } checkoutFromRemote(env.GIT_REMOTE_URL, env.GIT_REMOTE_CREDENTIAL_ID) env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() if (params.COMMIT_HASH?.trim() && env.SOURCE_COMMIT != params.COMMIT_HASH.trim()) { // 浅取分支不再含目标提交时立即失败,避免拿到错版本发号。 def contains = sh( script: "git merge-base --is-ancestor ${params.COMMIT_HASH.trim()} HEAD && echo yes || echo no", returnStdout: true, ).trim() if (contains != 'yes') { error("COMMIT_HASH ${params.COMMIT_HASH.trim()} 不属于 ${params.SOURCE_BRANCH}") } sh "git checkout --detach ${params.COMMIT_HASH.trim()}" env.SOURCE_COMMIT = params.COMMIT_HASH.trim() } } } } stage('Ensure ossutil') { steps { script { def requested = params.OSSUTIL_BIN?.trim() ?: 'ossutil' def resolved = sh( script: "command -v ${requested} 2>/dev/null || true", returnStdout: true, ).trim() if (resolved) { env.EFFECTIVE_OSSUTIL_BIN = resolved } else { // 构建节点没有 ossutil 时下载到工作区私有目录,不改主机环境。 sh '''#!/usr/bin/env bash set -euo pipefail version="${AGC_OSSUTIL_VERSION:-2.1.2}" mkdir -p "${OSSUTIL_TOOLS_DIR}" if [[ ! -x "${OSSUTIL_TOOLS_DIR}/ossutil" ]]; then curl -fsSL -o "${OSSUTIL_TOOLS_DIR}/ossutil.zip" \\ "https://gosspublic.alicdn.com/ossutil/v2/${version}/ossutil-${version}-linux-amd64.zip" # 官方 zip 解压后是带版本号的目录,二进制不在包根,必须先定位再落到固定路径。 unzip -o -q "${OSSUTIL_TOOLS_DIR}/ossutil.zip" -d "${OSSUTIL_TOOLS_DIR}" extracted="$(find "${OSSUTIL_TOOLS_DIR}" -maxdepth 3 -type f -name ossutil -print -quit)" if [[ -z "${extracted}" ]]; then echo "ossutil 解压后未找到可执行文件:${OSSUTIL_TOOLS_DIR}" >&2 exit 1 fi cp -f "${extracted}" "${OSSUTIL_TOOLS_DIR}/ossutil" chmod +x "${OSSUTIL_TOOLS_DIR}/ossutil" fi # ossutil v2 没有 `--version` 选项,这里用可执行性校验代替版本打印,避免误判失败。 if [[ ! -x "${OSSUTIL_TOOLS_DIR}/ossutil" ]]; then echo "ossutil 未就绪:${OSSUTIL_TOOLS_DIR}/ossutil" >&2 exit 1 fi echo "ossutil ready: ${OSSUTIL_TOOLS_DIR}/ossutil" ''' env.EFFECTIVE_OSSUTIL_BIN = "${env.WORKSPACE}/${env.OSSUTIL_TOOLS_DIR}/ossutil" } } } } stage('Issue Global Version') { steps { withCredentials([ string(credentialsId: 'AliyunAccessKeyId', variable: 'AGC_OSS_ACCESS_KEY_ID'), string(credentialsId: 'AliyunaccessKeySecret', variable: 'AGC_OSS_ACCESS_KEY_SECRET'), ]) { withEnv([ "OSSUTIL_BIN=${env.EFFECTIVE_OSSUTIL_BIN}", "AGC_RELEASE_DRY_RUN=${params.AGC_RELEASE_DRY_RUN ? '1' : '0'}", ]) { sh '''#!/usr/bin/env bash set -euo pipefail seed_args=() if [[ "${SEED_ONLY}" == "true" ]]; then seed_args+=(--seed-only); fi dry_args=() if [[ "${AGC_RELEASE_DRY_RUN}" == "1" ]]; then dry_args+=(--dry-run); fi node apps/ai-game-creator-shell/scripts/issue-global-version.mjs \\ --channel "${AGC_CHANNEL}" \\ --commit "${SOURCE_COMMIT}" \\ --build-id "${BUILD_NUMBER}" \\ --out "${GLOBAL_VERSION_OUT}" \\ "${seed_args[@]}" "${dry_args[@]}" ''' script { env.AGC_GLOBAL_VERSION = readFile(env.GLOBAL_VERSION_OUT).trim() currentBuild.description = "AGC 总版本号 ${env.AGC_GLOBAL_VERSION}(channel=${params.AGC_CHANNEL}" + "${params.SEED_ONLY ? ',播种' : ''}${params.AGC_RELEASE_DRY_RUN ? ',dry-run' : ''})" echo currentBuild.description } } } } } } post { always { archiveArtifacts artifacts: "agc-global-version.txt", allowEmptyArchive: true, fingerprint: true } } }