Jenkins 定时构建增加版本未变化去重
- Full Build 改为每小时触发,并按已解析 commit 与全部构建参数判断是否重复 - AGC Windows Build 增加每小时触发与同一套去重逻辑,跳过时不再安装依赖、构建和上传 - 跳过记录为 NOT_BUILT,越过跳过记录后最近一次实际构建成功才允许跳过,失败或中断可重试 - 生产运维门禁补充两个 Job 的去重约束与各阶段跳过检查 - 同步开发运维文档与共享开发工作流说明
This commit is contained in:
@@ -72,6 +72,10 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m
|
||||
3. 确认相关当前文档与共享记忆已同步,且 docs 入口没有指向已删除或退役实现依据。
|
||||
4. 提交标题使用中文,标题后逐行写明本次变更。
|
||||
|
||||
## Jenkins 定时构建去重
|
||||
|
||||
全量构建发布 Job 每小时检查一次。只有 timer 触发、解析后的 Git commit 与 Job 参数均未变化、最近一次实际运行成功时才跳过;连续的版本未变化记录不更新成功基线。参数比对排除通知邮箱,保留分支、部署目标、数据库和发布选项。失败、中断、不稳定或缺少历史信息时重新执行,手动构建始终执行;跳过不得触发下游构建、部署、维护操作或邮件。实现与生效步骤见 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## Gitea CI 依赖闭合
|
||||
|
||||
`.gitea/workflows/project-ci.yml` 的客户端门禁拆成八个 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust shard 1/4` 到 `4/4` 各只预取 AGC 壳 manifest 并各跑一片(AGC 壳那份 `Cargo.lock` 的 path 依赖已含 `platform-llm`、`platform-agent`、`agent-runtime-core` 与 `shared-contracts`),`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与两个独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。AGC 壳的 4 个分片 job、smoke job 与 crates job 只用 cargo 与 node 内建模块,因此不执行 `npm ci`。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译一次后按 `--list` 名单分 4 片:CI 的每个分片 job 用 `--shard-index=<i>` 只跑自己那片,片内保持 `--test-threads=1` 并各自使用独立 `TMPDIR`,片与片之间靠 job 级并发摊开;本地不传 `--shard-index` 时仍是同一条命令把 4 片放进程里并行。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片 job 都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。
|
||||
|
||||
@@ -133,7 +133,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 并将其状态纳入批次 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`。
|
||||
|
||||
自 2026-07-11 起,`Genarrative-Full-Build-And-Deploy` 的每日 04:00 timer 默认以 `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-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 定时发布行为。
|
||||
|
||||
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 才会采用这些规则;首次加载会正常构建并建立基线。
|
||||
|
||||
`Genarrative-Agc-Windows-Build` 使用同一套每小时去重规则:仅纯 timer 触发、已解析 commit 和全部 AGC Job 参数未变化、最近一次实际构建成功时标记 `NOT_BUILT`。跳过会停止工具链检查、依赖安装、Windows 安装包构建、OSS 上传和归档;失败 / 中断可重试,手动构建始终执行。AGC Job 的通知说明和发布备注属于构建输入,因此参数变化会重新构建。
|
||||
|
||||
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,3 +1,23 @@
|
||||
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'
|
||||
@@ -9,6 +29,10 @@ 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'
|
||||
@@ -68,10 +92,25 @@ 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 '''
|
||||
@@ -108,6 +147,9 @@ pipeline {
|
||||
}
|
||||
|
||||
stage('Install dependencies') {
|
||||
when {
|
||||
expression { return env.SKIP_UNCHANGED_BUILD != 'true' }
|
||||
}
|
||||
steps {
|
||||
withEnv(["PATH=${env.AGC_WINDOWS_PATH}"]) {
|
||||
powershell '''
|
||||
@@ -119,6 +161,9 @@ pipeline {
|
||||
}
|
||||
|
||||
stage('Build and upload') {
|
||||
when {
|
||||
expression { return env.SKIP_UNCHANGED_BUILD != 'true' }
|
||||
}
|
||||
steps {
|
||||
withCredentials([
|
||||
string(credentialsId: 'AliyunAccessKeyId', variable: 'AGC_OSS_ACCESS_KEY_ID'),
|
||||
@@ -152,6 +197,9 @@ 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,3 +1,25 @@
|
||||
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
|
||||
|
||||
@@ -8,7 +30,7 @@ pipeline {
|
||||
}
|
||||
|
||||
triggers {
|
||||
cron('0 4 * * *')
|
||||
cron('H * * * *')
|
||||
}
|
||||
|
||||
environment {
|
||||
@@ -104,6 +126,9 @@ 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 凭据。')
|
||||
}
|
||||
@@ -113,11 +138,21 @@ 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 {
|
||||
@@ -180,6 +215,9 @@ pipeline {
|
||||
}
|
||||
|
||||
stage('Deploy Stdb') {
|
||||
when {
|
||||
expression { return env.SKIP_UNCHANGED_BUILD != 'true' }
|
||||
}
|
||||
steps {
|
||||
build job: params.STDB_PUBLISH_JOB_NAME,
|
||||
wait: true,
|
||||
@@ -208,6 +246,7 @@ 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 {
|
||||
@@ -218,6 +257,9 @@ pipeline {
|
||||
}
|
||||
|
||||
stage('Deploy Api') {
|
||||
when {
|
||||
expression { return env.SKIP_UNCHANGED_BUILD != 'true' }
|
||||
}
|
||||
steps {
|
||||
build job: params.API_DEPLOY_JOB_NAME,
|
||||
wait: true,
|
||||
@@ -244,6 +286,9 @@ pipeline {
|
||||
}
|
||||
|
||||
stage('Deploy Web') {
|
||||
when {
|
||||
expression { return env.SKIP_UNCHANGED_BUILD != 'true' }
|
||||
}
|
||||
steps {
|
||||
build job: params.WEB_DEPLOY_JOB_NAME,
|
||||
wait: true,
|
||||
@@ -263,6 +308,7 @@ pipeline {
|
||||
|
||||
stage('Exit Maintenance') {
|
||||
when {
|
||||
expression { return env.SKIP_UNCHANGED_BUILD != 'true' }
|
||||
expression { return params.EXIT_MAINTENANCE_MODE_AFTER_COMPLETION != false }
|
||||
}
|
||||
agent none
|
||||
@@ -290,6 +336,9 @@ 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),
|
||||
|
||||
@@ -605,7 +605,7 @@ const checks = [
|
||||
includes:
|
||||
"choice(name: 'STDB_API_ROLLOUT_MODE', choices: ['normal', 'pause-after-stdb']",
|
||||
reason:
|
||||
'Full Build 的 04:00 定时任务必须默认 normal 完整发布仅供开发使用的 dev 服务器。',
|
||||
'Full Build 的每小时定时任务必须默认 normal 完整发布仅供开发使用的 dev 服务器。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
@@ -7639,6 +7639,107 @@ 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'",
|
||||
]) {
|
||||
if (!agcPipelineContent.includes(snippet)) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`[check:production-ops] AGC Windows Build 缺少版本未变化去重约束: ${snippet}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const agcStageName of [
|
||||
'Toolchain preflight',
|
||||
'Install dependencies',
|
||||
'Build and upload',
|
||||
'Archive release',
|
||||
]) {
|
||||
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' }",
|
||||
)
|
||||
) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`[check:production-ops] AGC Windows Build 的 ${agcStageName} 必须在版本未变化时跳过。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const forcedBuildOnlyCalls = fullPipelineContent.match(
|
||||
/booleanParam\(name: 'PUBLISH_AFTER_BUILD', value: false\)/gu,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user