diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 5caddccfb..3dd59f677 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -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 依赖闭合 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a34032ac2..e8d132d9f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index b426bb287..e036f5205 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -135,11 +135,11 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去 普通图片错误素材类型清理使用 `npm run spacetime:editor-image-asset-kind:clean -- --database --server-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 并将其状态纳入批次 hash,layout 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`、`` 留空(定时器写在 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,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index 0ee667c62..3e304cf63 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -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 } diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index 87143ad85..cb875f277 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -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), diff --git a/jenkins/Jenkinsfile.scheduled-revision-trigger b/jenkins/Jenkinsfile.scheduled-revision-trigger new file mode 100644 index 000000000..c2ab95594 --- /dev/null +++ b/jenkins/Jenkinsfile.scheduled-revision-trigger @@ -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:-}" >&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 ?: ''}" + } + } +} diff --git a/jenkins/scheduled-revision-trigger-job-config.xml b/jenkins/scheduled-revision-trigger-job-config.xml new file mode 100644 index 000000000..70a48e081 --- /dev/null +++ b/jenkins/scheduled-revision-trigger-job-config.xml @@ -0,0 +1,55 @@ + + + + 按小时检查源码分支版本,只有版本变化时用同一个 commit 触发 Full Build 与 AGC Windows Build。 + false + + + + + SOURCE_BRANCH + 被检查的源码分支;默认 master + master + true + + + FORCE_TRIGGER + 勾选后忽略版本比较,强制触发两个下游管线 + false + + + + + + + 2 + + + ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git + genarrative-local-gitea-ssh + +refs/heads/master:refs/remotes/origin/master + + + + + */master + + + false + + + + true + true + + 1 + true + + + + jenkins/Jenkinsfile.scheduled-revision-trigger + false + + + false + diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 42149858f..d0081ac88 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -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( + 'jenkins/Jenkinsfile.scheduled-revision-trigger', + ) || + !scheduledRevisionTriggerJobConfig.includes( + 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git', + ) || + !scheduledRevisionTriggerJobConfig.includes( + 'genarrative-local-gitea-ssh', + ) || + !scheduledRevisionTriggerJobConfig.includes('') +) { + 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, );