新增定时版本调度管线并收回两条下游管线的定时与去重
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m9s
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 5m11s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m1s
Project CI / AI game creator shell Rust crates (push) Successful in 2m36s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m20s
Project CI / Frontend tests (push) Successful in 4m23s
Project CI / Repository checks (push) Successful in 4m13s
Project CI / Native shell tests (push) Successful in 6m44s
Project CI / Backend tests (push) Successful in 8m22s
Project CI / AI game creator shell web tests (push) Successful in 3m28s

- 新增 Genarrative-Scheduled-Revision-Trigger:每小时用 git ls-remote 解析分支版本,与上一次触发过的 revision 不同才继续
- 触发时把同一个固定 COMMIT_HASH 同时传给 Full Build 与 AGC Windows Build,保证两条管线构建同一个版本
- Full Build 与 AGC Windows Build 去掉自带定时触发器与管线内版本去重
- 新增 scheduled-revision-trigger Job 配置 XML,使用本机 Git 入口与既有 SSH 凭据,定时器留在 Jenkinsfile
- 生产运维门禁改为校验调度管线约束,并拦住两条下游管线重新加 cron 或自行去重
- 同步开发运维文档、共享开发工作流与踩坑记录
This commit is contained in:
kdletters
2026-09-16 10:55:16 +08:00
parent 5aa616134c
commit 576ff07a5e
8 changed files with 226 additions and 194 deletions
@@ -72,9 +72,9 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m
3. 确认相关当前文档与共享记忆已同步,且 docs 入口没有指向已删除或退役实现依据。
4. 提交标题使用中文,标题后逐行写明本次变更。
## Jenkins 定时构建去重
## Jenkins 定时版本调度
全量构建发布 Job 每小时检查一次。只有 timer 触发、解析后的 Git commit 与 Job 参数均未变化、最近一次实际运行成功时才跳过;连续的版本未变化记录不更新成功基线。参数比对排除通知邮箱,保留分支、部署目标、数据库和发布选项。失败、中断、不稳定或缺少历史信息时重新执行,手动构建始终执行;跳过不得触发下游构建、部署、维护操作或邮件。实现与生效步骤见 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
定时与版本比较只保留在 `Genarrative-Scheduled-Revision-Trigger` 一处:每小时用 `git ls-remote` 解析 `SOURCE_BRANCH` 远端 HEAD,与上一次触发过的 revision 比较,变化时才把同一个 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy``Genarrative-Agc-Windows-Build`,保证两条管线构建同一个版本。`Genarrative-Full-Build-And-Deploy``Genarrative-Agc-Windows-Build` 不得自带 `triggers` / `cron`,也不得在管线内再做一套版本去重;`npm run check:production-ops` 会拦住这两类回退。调度状态与生效步骤见 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Gitea CI 依赖闭合
@@ -3875,7 +3875,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- 现象:在 Jenkins Job 页面给 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 配了默认值,下一次加载 Declarative Pipeline 后又变空或恢复旧描述;04:00 Full Job 还可能因默认选择 `pause-after-stdb` 且 approvers 为空而失败。
- 原因:这些 Job 使用 Pipeline script from SCM`parameters {}``triggers {}` 会作为 Job property 回写现场配置;只改 UI 不是持久修复。构建编排如果不显式关闭下游 `PUBLISH_AFTER_BUILD`,还会受下游默认值漂移影响。
- 处理:credential ID 和参数默认值写回三个 Jenkinsfile;仅供开发使用的 dev 定时 Full Job 默认 `STDB_API_ROLLOUT_MODE=normal`,三路 Build 调用显式传 `PUBLISH_AFTER_BUILD=false`,再由 Full Job 统一按 Stdb → API → Web 发布。Secret 原文只放 Jenkins Secret File,旧 Secret Text 保留给 Import / Export。
- 验证:推送后让 Full / Stdb Build 用不存在的源码分支在 checkout 阶段 fail-closed,让 Stdb Publish 用空构建版本在 Prepare 阶段 fail-closed,以安全刷新参数 schema;随后只读检查三个 live `config.xml` 的参数描述和默认值,确认 Full timer 仍为 `0 4 * * *`rollout 默认值为 `normal`,并确认刷新运行未进入 publish / deploy stage。
- 验证:推送后让 Full / Stdb Build 用不存在的源码分支在 checkout 阶段 fail-closed,让 Stdb Publish 用空构建版本在 Prepare 阶段 fail-closed,以安全刷新参数 schema;随后只读检查三个 live `config.xml` 的参数描述和默认值,确认 rollout 默认值为 `normal`、Full 与 AGC Job 都不再带 cron(定时只来自 `Genarrative-Scheduled-Revision-Trigger`,并确认刷新运行未进入 publish / deploy stage。
- 关联:`jenkins/Jenkinsfile.production-full-build-and-deploy``jenkins/Jenkinsfile.production-stdb-module-build``jenkins/Jenkinsfile.production-stdb-module-publish``scripts/check-production-ops-guardrails.mjs`
## 维护模式内网全站放行不能信任 X-Forwarded-For
@@ -135,11 +135,11 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去
普通图片错误素材类型清理使用 `npm run spacetime:editor-image-asset-kind:clean -- --database <database> --server-url <url>`,只能由已授权 migration operator 执行。先进入维护模式并发布包含 `clean_editor_image_asset_kind_and_return` 的 SpacetimeDB module,并保持旧版本 API / controller / worker 停止;随后运行默认全量 dry-run,核对 `asset → project-resource → showcase → canvas` 各 scope 的扫描数、命中行数、字段数和 blocker 均符合预期,再追加 `--apply`。脚本对每批重新 dry-run、绑定包含画布迁移摘要、结构化 layer 与 generation-dialog 权威 JSON 的 SHA-256,最后自动从头复核零命中;任一画布数据异常都会只输出哈希化 ID、scope 与原因并停止,不能跳过。清理只处理精确业务旧值,不修改 `asset_object.asset_kind`、MIME 或媒体类型;project-resource scope 在清行前验证同工程 migration 并将其状态纳入批次 hashlayout version 0 的 legacy 画布可以没有 migration,但 structured 画布缺 migration 必须立即形成 blocker,资源行不得先被清空;清行后能保持原 status 不变量时立即刷新摘要,否则只允许留给后续精确 canvas 字段清理收口。canvas scope 在任何布局写入前再次按 active / backfilled / rolled_back 状态验证原 migration 凭证和双份 legacy / structured 不变量,将 `editor_canvas_generation_dialog.dialog_json` 与 layer rows 一并扫描并在同一事务 patch;只允许本批资源清零及精确字段删除造成的差异,写入后从全部结构化权威行重建 layout、再次复核新状态才受控重签摘要,同时保持业务 revision、migration status 与全部时间戳不变。新版本 API、SpacetimeDB storage 创建入口、legacy 画布元数据提取和项目资源落表边界都会将 trim 后精确等于 `image``assetKind` 归一为 `NULL`,防止旧页面、滞留请求或 legacy 保存重新制造废弃值。完成零残留复核,并分别确认 cleaned backfilled 可激活、active 可继续保存、rolled_back 可重复复检后恢复应用版本,最后退出维护。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-clean-editor-image-asset-kind.mjs``scripts/spacetime-migration-common.mjs`
`Genarrative-Full-Build-And-Deploy` 每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名分散),默认以 `DEPLOY_TARGET=development``STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate。三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,不得依赖下游 Job 默认值或提前各自发布;统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。上文“定时构建缺少审批人时失败”的旧口径不再作为当前 dev 定时发布行为
`Genarrative-Scheduled-Revision-Trigger` 是唯一的定时入口,每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名散列,不等同于整点)。它只用 `git ls-remote` 解析 `SOURCE_BRANCH`(默认 `master`)的远端 HEAD,不 checkout 工作区;解析出的完整 commit 与上一次触发过的 revision 相同则标记 `NOT_BUILT` 并结束,不触发任何下游
Full Job 在 Resolve Source 完成后,以已解析的完整 Git commit 和除 `NOTIFICATION_EMAILS` 外的全部 Job 参数判断是否重复;分支、部署目标、数据库、构建选项或发布参数变化都会重新执行。仅 timer 触发会去重:向前越过本逻辑标记的 `NOT_BUILT` 跳过记录后,最近一次实际运行必须为 `SUCCESS`,且 commit 和参数均相同,才将本次标为 `NOT_BUILT` 并在构建描述中记录基线构建号。跳过时不启动下游 Build / Deploy / Publish、不进入审批或维护退出阶段,也不发送结束邮件。首次运行、旧记录缺少比对信息、最近实际运行失败 / 中断 / 不稳定、源码或参数变化时均执行;手动构建始终执行,可用于同 commit 重建。去重状态保存在 Jenkins 构建记录中,不依赖可被清理的 workspace 文件;连续跳过不替代最近成功基线。修改推送后,live Job 需加载新 Jenkinsfile 才会采用这些规则;首次加载会正常构建并建立基线
revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy``Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Full Job 默认以 `DEPLOY_TARGET=development``STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`
`Genarrative-Agc-Windows-Build` 使用同一套每小时去重规则:仅纯 timer 触发、已解析 commit 和全部 AGC Job 参数未变化、最近一次实际构建成功时标记 `NOT_BUILT`。跳过会停止工具链检查、依赖安装、Windows 安装包构建、OSS 上传和归档;失败 / 中断可重试,手动构建始终执行。AGC Job 的通知说明和发布备注属于构建输入,因此参数变化会重新构建
调度状态是调度 Job 工作区里的 `.jenkins-last-triggered-revision`,构建描述同时回显本次 revision 与结果。工作区被清理(例如 `Wipe Out Workspace`)或状态文件缺失时,下一次运行按“版本变化”处理并触发一次,之后恢复稳定;需要重建同一版本时勾选 `FORCE_TRIGGER`。Job 按仓库内 `jenkins/scheduled-revision-trigger-job-config.xml` 创建:`scriptPath=jenkins/Jenkinsfile.scheduled-revision-trigger`、Git 入口 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`、凭据 `genarrative-local-gitea-ssh``<triggers/>` 留空(定时器写在 Jenkinsfile 里)。推送后必须让三个 live Job 各自加载一次新 Jenkinsfile,并只读核对 `config.xml`Full 与 AGC 不再有 cron,定时只来自新调度 Job;只改 Jenkinsfile 而不确认 live 配置时,旧 cron 仍会继续触发
Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发布成功后是否退出维护,默认勾选以保持历史行为。Full 对 Stdb Publish 和 API Deploy 两个下游阶段都固定传 `KEEP_MAINTENANCE_MODE=true`,让 maintenance marker 持续覆盖 Stdb → API → Web 整段发布;Web Deploy 成功后才进入独立 `Exit Maintenance` 阶段。该阶段只能通过 `agent none` 和显式 `node(...)` 分配目标机,直接执行 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`;目标机不得 checkout Git、挂载 Git SSH 凭据或依赖 Jenkins workspace 源码。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。
@@ -1,23 +1,3 @@
def findUnchangedSuccessfulBuild(run, String sourceCommit, String buildInputs) {
def causes = run.getBuildCauses()
if (!sourceCommit || !buildInputs || !causes || causes.any { it._class != 'hudson.triggers.TimerTrigger$TimerTriggerCause' }) {
return null
}
def previous = run.previousBuild
while (previous != null) {
def variables = previous.getBuildVariables()
if (previous.result == 'NOT_BUILT' && variables.SKIP_UNCHANGED_BUILD == 'true') {
previous = previous.previousBuild
continue
}
if (previous.result == 'SUCCESS' && variables.SOURCE_COMMIT == sourceCommit && variables.FULL_BUILD_INPUTS == buildInputs) {
return previous.number
}
return null
}
return null
}
pipeline {
agent {
label 'windows && win2022'
@@ -29,10 +9,6 @@ pipeline {
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
}
triggers {
cron('H * * * *')
}
environment {
GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git'
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
@@ -92,25 +68,10 @@ pipeline {
Write-Host "源码 commit=$resolved"
'''
}
script {
env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim()
env.SKIP_UNCHANGED_BUILD = 'false'
env.FULL_BUILD_INPUTS = groovy.json.JsonOutput.toJson(params.sort())
def unchangedBuildNumber = findUnchangedSuccessfulBuild(currentBuild, env.SOURCE_COMMIT, env.FULL_BUILD_INPUTS)
if (unchangedBuildNumber != null) {
env.SKIP_UNCHANGED_BUILD = 'true'
currentBuild.result = 'NOT_BUILT'
currentBuild.description = "版本未变化,跳过;与成功构建 #${unchangedBuildNumber} 相同:${env.SOURCE_COMMIT.take(12)}"
echo currentBuild.description
}
}
}
}
stage('Toolchain preflight') {
when {
expression { return env.SKIP_UNCHANGED_BUILD != 'true' }
}
steps {
withEnv(["PATH=${env.AGC_WINDOWS_PATH}", "OSSUTIL_BIN=${params.OSSUTIL_BIN}"]) {
powershell '''
@@ -147,9 +108,6 @@ pipeline {
}
stage('Install dependencies') {
when {
expression { return env.SKIP_UNCHANGED_BUILD != 'true' }
}
steps {
withEnv(["PATH=${env.AGC_WINDOWS_PATH}"]) {
powershell '''
@@ -161,9 +119,6 @@ pipeline {
}
stage('Build and upload') {
when {
expression { return env.SKIP_UNCHANGED_BUILD != 'true' }
}
steps {
withCredentials([
string(credentialsId: 'AliyunAccessKeyId', variable: 'AGC_OSS_ACCESS_KEY_ID'),
@@ -197,9 +152,6 @@ pipeline {
}
stage('Archive release') {
when {
expression { return env.SKIP_UNCHANGED_BUILD != 'true' }
}
steps {
archiveArtifacts artifacts: 'apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json,.jenkins-source-commit', fingerprint: true
}
@@ -1,25 +1,3 @@
def findUnchangedSuccessfulBuild(run, String sourceCommit, String buildInputs) {
def causes = run.getBuildCauses()
if (!sourceCommit || !buildInputs || !causes || causes.any { it._class != 'hudson.triggers.TimerTrigger$TimerTriggerCause' }) {
return null
}
def previous = run.previousBuild
while (previous != null) {
def variables = previous.getBuildVariables()
// 只越过本逻辑生成的跳过记录;失败、中断或其他未构建记录都必须允许重试。
if (previous.result == 'NOT_BUILT' && variables.SKIP_UNCHANGED_BUILD == 'true') {
previous = previous.previousBuild
continue
}
if (previous.result == 'SUCCESS' && variables.SOURCE_COMMIT == sourceCommit && variables.FULL_BUILD_INPUTS == buildInputs) {
return previous.number
}
return null
}
return null
}
pipeline {
agent none
@@ -29,10 +7,6 @@ pipeline {
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
}
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'
@@ -126,9 +100,6 @@ pipeline {
script {
env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim()
env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER
env.SKIP_UNCHANGED_BUILD = 'false'
// 参数均为 Job 公开配置或凭据 ID;通知邮箱不影响构建发布结果。
env.FULL_BUILD_INPUTS = groovy.json.JsonOutput.toJson(params.findAll { name, value -> name != 'NOTIFICATION_EMAILS' }.sort())
if (!params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) {
error('MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID 必须引用受保护的 Jenkins Secret File 凭据。')
}
@@ -138,21 +109,11 @@ pipeline {
if (params.STDB_API_ROLLOUT_MODE == 'pause-after-stdb' && !params.STDB_API_ROLLOUT_APPROVERS?.trim()) {
error('pause-after-stdb 必须填写 STDB_API_ROLLOUT_APPROVERS。')
}
def unchangedBuildNumber = findUnchangedSuccessfulBuild(currentBuild, env.SOURCE_COMMIT, env.FULL_BUILD_INPUTS)
if (unchangedBuildNumber != null) {
env.SKIP_UNCHANGED_BUILD = 'true'
currentBuild.result = 'NOT_BUILT'
currentBuild.description = "版本未变化,跳过;与成功构建 #${unchangedBuildNumber} 相同:${env.SOURCE_COMMIT.take(12)}"
echo currentBuild.description
}
}
}
}
stage('Build Components') {
when {
expression { return env.SKIP_UNCHANGED_BUILD != 'true' }
}
// 任一构建分支失败时立即中断其他并行分支,避免失败后继续消耗构建资源。
failFast true
parallel {
@@ -215,9 +176,6 @@ pipeline {
}
stage('Deploy Stdb') {
when {
expression { return env.SKIP_UNCHANGED_BUILD != 'true' }
}
steps {
build job: params.STDB_PUBLISH_JOB_NAME,
wait: true,
@@ -246,7 +204,6 @@ pipeline {
stage('Stdb / Api Rollout Gate') {
when {
expression { return env.SKIP_UNCHANGED_BUILD != 'true' }
expression { return params.STDB_API_ROLLOUT_MODE == 'pause-after-stdb' }
}
steps {
@@ -257,9 +214,6 @@ pipeline {
}
stage('Deploy Api') {
when {
expression { return env.SKIP_UNCHANGED_BUILD != 'true' }
}
steps {
build job: params.API_DEPLOY_JOB_NAME,
wait: true,
@@ -286,9 +240,6 @@ pipeline {
}
stage('Deploy Web') {
when {
expression { return env.SKIP_UNCHANGED_BUILD != 'true' }
}
steps {
build job: params.WEB_DEPLOY_JOB_NAME,
wait: true,
@@ -308,7 +259,6 @@ pipeline {
stage('Exit Maintenance') {
when {
expression { return env.SKIP_UNCHANGED_BUILD != 'true' }
expression { return params.EXIT_MAINTENANCE_MODE_AFTER_COMPLETION != false }
}
agent none
@@ -336,9 +286,6 @@ pipeline {
post {
always {
script {
if (env.SKIP_UNCHANGED_BUILD == 'true') {
return
}
def notificationParameters = [
string(name: 'SOURCE_JOB_NAME', value: env.JOB_NAME),
string(name: 'SOURCE_BUILD_NUMBER', value: env.BUILD_NUMBER),
@@ -0,0 +1,87 @@
// 定时版本调度管线:解析远端分支版本,只有和上一次触发过的版本不同时,
// 才用同一个固定 commit 触发 Full Build 与 AGC Windows Build 两个下游管线。
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'
REVISION_STATE_FILE = '.jenkins-last-triggered-revision'
}
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:-<empty>}" >&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
}
}
}
}
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),
]
build job: env.FULL_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters
build job: env.AGC_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters
writeFile file: env.REVISION_STATE_FILE, text: pinnedRevision
currentBuild.description = "已触发 ${env.FULL_BUILD_JOB_NAME} 与 ${env.AGC_BUILD_JOB_NAME}${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}"
echo currentBuild.description
}
}
}
}
post {
always {
echo "调度结束: branch=${env.SOURCE_BRANCH} revision=${env.REMOTE_REVISION ?: ''} changed=${env.REVISION_CHANGED ?: ''}"
}
}
}
@@ -0,0 +1,55 @@
<?xml version='1.1' encoding='UTF-8'?>
<flow-definition plugin="workflow-job">
<actions/>
<description>按小时检查源码分支版本,只有版本变化时用同一个 commit 触发 Full Build 与 AGC Windows Build。</description>
<keepDependencies>false</keepDependencies>
<properties>
<hudson.model.ParametersDefinitionProperty>
<parameterDefinitions>
<hudson.model.StringParameterDefinition>
<name>SOURCE_BRANCH</name>
<description>被检查的源码分支;默认 master</description>
<defaultValue>master</defaultValue>
<trim>true</trim>
</hudson.model.StringParameterDefinition>
<hudson.model.BooleanParameterDefinition>
<name>FORCE_TRIGGER</name>
<description>勾选后忽略版本比较,强制触发两个下游管线</description>
<defaultValue>false</defaultValue>
</hudson.model.BooleanParameterDefinition>
</parameterDefinitions>
</hudson.model.ParametersDefinitionProperty>
</properties>
<definition class="org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition" plugin="workflow-cps">
<scm class="hudson.plugins.git.GitSCM" plugin="git">
<configVersion>2</configVersion>
<userRemoteConfigs>
<hudson.plugins.git.UserRemoteConfig>
<url>ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git</url>
<credentialsId>genarrative-local-gitea-ssh</credentialsId>
<refspec>+refs/heads/master:refs/remotes/origin/master</refspec>
</hudson.plugins.git.UserRemoteConfig>
</userRemoteConfigs>
<branches>
<hudson.plugins.git.BranchSpec>
<name>*/master</name>
</hudson.plugins.git.BranchSpec>
</branches>
<doGenerateSubmoduleConfigurations>false</doGenerateSubmoduleConfigurations>
<submoduleCfg class="empty-list"/>
<extensions>
<hudson.plugins.git.extensions.impl.CloneOption>
<shallow>true</shallow>
<noTags>true</noTags>
<reference></reference>
<depth>1</depth>
<honorRefspec>true</honorRefspec>
</hudson.plugins.git.extensions.impl.CloneOption>
</extensions>
</scm>
<scriptPath>jenkins/Jenkinsfile.scheduled-revision-trigger</scriptPath>
<lightweight>false</lightweight>
</definition>
<triggers/>
<disabled>false</disabled>
</flow-definition>
+78 -87
View File
@@ -7639,107 +7639,98 @@ const fullPipelineContent = readFileSync(
'jenkins/Jenkinsfile.production-full-build-and-deploy',
'utf8',
);
for (const stageName of [
'Build Components',
'Deploy Stdb',
'Stdb / Api Rollout Gate',
'Deploy Api',
'Deploy Web',
'Exit Maintenance',
]) {
const stageStart = fullPipelineContent.indexOf(`stage('${stageName}')`);
const stageBody =
stageStart >= 0 ? fullPipelineContent.slice(stageStart) : '';
const stageConditions = stageBody.split(/\b(?:steps|parallel)\s*\{/u)[0];
if (
!stageConditions.includes(
"expression { return env.SKIP_UNCHANGED_BUILD != 'true' }",
)
) {
failed = true;
console.error(
`[check:production-ops] Full Build 的 ${stageName} 必须在版本未变化时跳过,禁止触发下游构建、部署或维护操作。`,
);
}
}
for (const [snippet, reason] of [
["cron('H * * * *')", '必须每小时检查源码变化'],
[
"causes.any { it._class != 'hudson.triggers.TimerTrigger$TimerTriggerCause' }",
'去重只允许纯 timer 触发,手动或重放必须执行',
],
[
"previous.result == 'NOT_BUILT' && variables.SKIP_UNCHANGED_BUILD == 'true'",
'只允许越过本逻辑标记的跳过记录',
],
[
"previous.result == 'SUCCESS' && variables.SOURCE_COMMIT == sourceCommit && variables.FULL_BUILD_INPUTS == buildInputs",
'必须同时验证最近实际运行成功、源码相同和参数相同',
],
[
"groovy.json.JsonOutput.toJson(params.findAll { name, value -> name != 'NOTIFICATION_EMAILS' }.sort())",
'必须稳定记录全部构建发布参数,仅排除通知邮箱',
],
[
"currentBuild.result = 'NOT_BUILT'",
'跳过记录必须为 NOT_BUILT,不能污染成功基线',
],
]) {
if (!fullPipelineContent.includes(snippet)) {
failed = true;
console.error(`[check:production-ops] Full Build ${reason}`);
}
}
if (
!/always\s*\{\s*script\s*\{\s*if \(env\.SKIP_UNCHANGED_BUILD == 'true'\)\s*\{\s*return\s*\}/u.test(
fullPipelineContent,
)
) {
failed = true;
console.error(
'[check:production-ops] Full Build 跳过时不得触发结束邮件通知。',
);
}
const agcPipelineContent = readFileSync(
'jenkins/Jenkinsfile.ai-game-creator-shell-build',
'utf8',
);
for (const snippet of [
"cron('H * * * *')",
"causes.any { it._class != 'hudson.triggers.TimerTrigger$TimerTriggerCause' }",
"previous.result == 'NOT_BUILT' && variables.SKIP_UNCHANGED_BUILD == 'true'",
"previous.result == 'SUCCESS' && variables.SOURCE_COMMIT == sourceCommit && variables.FULL_BUILD_INPUTS == buildInputs",
'env.FULL_BUILD_INPUTS = groovy.json.JsonOutput.toJson(params.sort())',
"currentBuild.result = 'NOT_BUILT'",
const scheduledRevisionTriggerContent = readFileSync(
'jenkins/Jenkinsfile.scheduled-revision-trigger',
'utf8',
);
const scheduledRevisionTriggerJobConfig = readFileSync(
'jenkins/scheduled-revision-trigger-job-config.xml',
'utf8',
);
// 定时与版本比较统一收敛到调度管线,两个下游流水线不得再自带触发器。
for (const [file, content] of [
['jenkins/Jenkinsfile.production-full-build-and-deploy', fullPipelineContent],
['jenkins/Jenkinsfile.ai-game-creator-shell-build', agcPipelineContent],
]) {
if (!agcPipelineContent.includes(snippet)) {
if (/\btriggers\s*\{/u.test(content) || content.includes('cron(')) {
failed = true;
console.error(
`[check:production-ops] AGC Windows Build 缺少版本未变化去重约束: ${snippet}`,
`[check:production-ops] ${file} 不得自带定时触发器;版本检查与触发必须由 Genarrative-Scheduled-Revision-Trigger 统一负责。`,
);
}
}
for (const agcStageName of [
'Toolchain preflight',
'Install dependencies',
'Build and upload',
'Archive release',
for (const [snippet, reason] of [
["cron('H * * * *')", '必须每小时检查一次远端版本'],
['disableConcurrentBuilds()', '必须禁止并发触发,避免同一版本重复触发下游'],
['skipDefaultCheckout(true)', '不得依赖本地 SCM 工作区'],
['git ls-remote', '必须直接从远端解析分支版本'],
['> .jenkins-remote-revision', '解析出的版本必须先落盘再参与比较'],
['fileExists(env.REVISION_STATE_FILE)', '必须读取上一次触发过的版本'],
[
"currentBuild.result = 'NOT_BUILT'",
'版本未变化时必须标记为未触发,不能静默成功',
],
[
"FULL_BUILD_JOB_NAME = 'Genarrative-Full-Build-And-Deploy'",
'必须声明 Full Build 目标 Job',
],
[
"AGC_BUILD_JOB_NAME = 'Genarrative-Agc-Windows-Build'",
'必须声明 AGC Windows Build 目标 Job',
],
[
"string(name: 'COMMIT_HASH', value: pinnedRevision)",
'必须把同一个固定 revision 传给两个下游管线',
],
]) {
const agcStageStart = agcPipelineContent.indexOf(`stage('${agcStageName}')`);
const agcStageBody =
agcStageStart >= 0 ? agcPipelineContent.slice(agcStageStart) : '';
const agcStageConditions = agcStageBody.split(/\bsteps\s*\{/u)[0];
if (
!agcStageConditions.includes(
"expression { return env.SKIP_UNCHANGED_BUILD != 'true' }",
)
) {
if (!scheduledRevisionTriggerContent.includes(snippet)) {
failed = true;
console.error(
`[check:production-ops] AGC Windows Build 的 ${agcStageName} 必须在版本未变化时跳过。`,
);
console.error(`[check:production-ops] 调度管线${reason}`);
}
}
const scheduledPinnedParameterCalls = scheduledRevisionTriggerContent.match(
/parameters: pinnedParameters/gu,
);
if ((scheduledPinnedParameterCalls?.length ?? 0) !== 2) {
failed = true;
console.error(
'[check:production-ops] 调度管线必须用同一份 pinnedParameters 同时触发 Full Build 与 AGC Windows Build。',
);
}
if (
!scheduledRevisionTriggerJobConfig.includes(
'<scriptPath>jenkins/Jenkinsfile.scheduled-revision-trigger</scriptPath>',
) ||
!scheduledRevisionTriggerJobConfig.includes(
'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git',
) ||
!scheduledRevisionTriggerJobConfig.includes(
'<credentialsId>genarrative-local-gitea-ssh</credentialsId>',
) ||
!scheduledRevisionTriggerJobConfig.includes('<triggers/>')
) {
failed = true;
console.error(
'[check:production-ops] scheduled-revision-trigger Job 必须指向调度 Jenkinsfile、使用本机 Git 入口与既有 SSH 凭据,并把定时器留在 Jenkinsfile。',
);
}
if (
!fullPipelineContent.includes(
"string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT)",
) ||
!agcPipelineContent.includes('checkout --detach $commit')
) {
failed = true;
console.error(
'[check:production-ops] Full Build 与 AGC Windows Build 必须按上游传入的固定 commit 构建同一个版本。',
);
}
const forcedBuildOnlyCalls = fullPipelineContent.match(
/booleanParam\(name: 'PUBLISH_AFTER_BUILD', value: false\)/gu,
);