diff --git a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs index eb5394595..442df54d1 100644 --- a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs +++ b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs @@ -33,6 +33,29 @@ import { */ const appRoot = fileURLToPath(new URL('..', import.meta.url)); const repoRoot = path.resolve(appRoot, '../..'); + +/** + * 产品名只从 Tauri 配置读取:它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。 + * 写死会在改名后让入口静默找错对象(清理、打包、归档三处一起失效)。 + */ +function readProductName() { + const read = (file) => + JSON.parse(fs.readFileSync(path.join(appRoot, 'src-tauri', file), 'utf8')); + const base = read('tauri.conf.json'); + const macosPath = path.join(appRoot, 'src-tauri', 'tauri.macos.conf.json'); + const productName = fs.existsSync(macosPath) + ? (read('tauri.macos.conf.json').productName ?? base.productName) + : base.productName; + assert.ok( + typeof productName === 'string' && productName.trim().length > 0, + 'Tauri 配置缺少 productName', + ); + return productName; +} + +const productName = readProductName(); +const appBundleName = `${productName}.app`; +const updaterArtifactName = `${productName}.app.tar.gz`; assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行'); assert.equal( process.env.JENKINS_URL?.length > 0, @@ -74,6 +97,8 @@ process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target'); const context = resolveReleaseContext(['--target=universal-apple-darwin']); const partition = resolveReleasePartition(context.channel, context.target); const version = await prepareReleaseVersion(context); +// 首装包名必须保持 `<产品名>_<版本>_universal.dmg`:清单侧按该后缀唯一匹配本次产物。 +const firstInstallName = `${productName}_${version}_universal.dmg`; // 幂等边界:workspace 会保留上一轮产物。先删掉本次将要写出的对象,否则 // 1) hdiutil 会因同名 DMG 已存在直接失败(首次实跑即命中); @@ -81,10 +106,10 @@ const version = await prepareReleaseVersion(context); // 只删本次要写出的确切路径,不动其它版本产物与编译缓存。 const macosBundle = path.join(context.bundleRoot, 'macos'); for (const stale of [ - path.join(macosBundle, '陶泥儿.app.tar.gz'), - path.join(macosBundle, '陶泥儿.app.tar.gz.sig'), - path.join(macosBundle, `陶泥儿_${version}_universal.dmg`), - path.join(macosBundle, `陶泥儿_${version}_universal.dmg.sha256`), + path.join(macosBundle, updaterArtifactName), + path.join(macosBundle, `${updaterArtifactName}.sig`), + path.join(macosBundle, `${firstInstallName}`), + path.join(macosBundle, `${firstInstallName}.sha256`), path.join(context.bundleRoot, 'latest.json'), path.join(context.bundleRoot, 'release-notes.txt'), ]) { @@ -106,7 +131,7 @@ const command = (binary, argv, options = {}) => execFileSync(binary, argv, { cwd: repoRoot, stdio: 'inherit', ...options }); runTauriBuild(args, context); -const app = path.join(context.bundleRoot, 'macos/陶泥儿.app'); +const app = path.join(context.bundleRoot, 'macos', appBundleName); for (const architecture of ['arm64', 'x86_64']) { command(process.execPath, [ path.join(appRoot, 'scripts/check-macos-bundle.mjs'), @@ -119,17 +144,17 @@ for (const architecture of ['arm64', 'x86_64']) { // DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `__universal.dmg`。 const dmgDirectory = path.join(context.bundleRoot, 'macos'); fs.mkdirSync(dmgDirectory, { recursive: true }); -const dmg = path.join(dmgDirectory, `陶泥儿_${version}_universal.dmg`); +const dmg = path.join(dmgDirectory, firstInstallName); const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-ci-dmg-')); try { - command('ditto', [app, path.join(stage, '陶泥儿.app')]); + command('ditto', [app, path.join(stage, appBundleName)]); fs.symlinkSync('/Applications', path.join(stage, 'Applications')); command('hdiutil', [ 'create', // 前面已删除同名对象;这里再要求显式覆盖,避免残留文件让构建以「文件已存在」失败。 '-ov', '-volname', - '陶泥儿', + productName, '-srcfolder', stage, '-format', diff --git a/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs index 1466a7661..a3f60765e 100644 --- a/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs +++ b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs @@ -22,7 +22,9 @@ assert.ok( const root = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'agc-macos-bundle-')), ); -const app = path.join(root, '陶泥儿 隔离测试.app'); +// 产品名从传入的 .app 推导,不在校验脚本里写死;改名后校验对象仍指向同一个包。 +const appBundleName = path.basename(source); +const app = path.join(root, `隔离-${appBundleName}`); const home = path.join(root, 'home'); const config = path.join(root, 'config'); const tmp = path.join(root, 'tmp'); @@ -55,6 +57,41 @@ function run(command, args) { return result; } +/** + * APFS 上优先用 `ditto --clone`:整包按区块克隆,秒级完成且几乎不占额外空间。 + * 跨卷或非 APFS 时回退到真实复制;两种路径都必须产出可独立改动的副本, + * 因为「缺组件拒绝」用例会在副本里改名文件。 + */ +function copyBundle(from, to) { + const cloned = spawnSync('/usr/bin/ditto', ['--clone', from, to], { + encoding: 'utf8', + }); + if ( + cloned.status === 0 && + fs.existsSync(path.join(to, 'Contents/Info.plist')) + ) { + return 'clone'; + } + fs.cpSync(from, to, { recursive: true }); + return 'copy'; +} + +/** 可执行名以包内 Info.plist 为准:它是稳定契约,但没必要在校验脚本里重复硬编码。 */ +function readBundleExecutable(appPath) { + const plist = path.join(appPath, 'Contents/Info.plist'); + const result = spawnSync( + '/usr/libexec/PlistBuddy', + ['-c', 'Print :CFBundleExecutable', plist], + { encoding: 'utf8' }, + ); + const name = (result.stdout ?? '').trim(); + assert.ok( + name.length > 0, + `无法从 Info.plist 读取 CFBundleExecutable:${plist}`, + ); + return name; +} + async function hashFile(file) { const hash = createHash('sha256'); for await (const chunk of fs.createReadStream(file)) hash.update(chunk); @@ -140,15 +177,12 @@ async function handshake(executable) { } try { - fs.cpSync(source, app, { recursive: true }); + const copiedWith = copyBundle(source, app); const resources = path.join(app, 'Contents/Resources'); const platform = architecture === 'arm64' ? 'darwin-arm64' : 'darwin-x64'; const bundle = path.join(resources, 'coding-agent/mac-native', platform); const executable = path.join(bundle, 'bin/codex'); - const main = path.join( - app, - 'Contents/MacOS/genarrative-ai-game-creator-shell', - ); + const main = path.join(app, 'Contents/MacOS', readBundleExecutable(app)); const mainArchitectures = run('/usr/bin/lipo', ['-archs', main]); assert.equal(mainArchitectures.status, 0); assert.ok(mainArchitectures.stdout.split(/\s+/).includes(architecture)); @@ -240,7 +274,7 @@ try { assert.notEqual(broken.status, 0); assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/); console.log( - `PASS (${architecture}): 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝`, + `PASS (${architecture}, 副本=${copiedWith}): 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝`, ); console.log( '未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提', diff --git a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs index 377d29a4b..c3aba538e 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs @@ -77,11 +77,19 @@ test('CI pipeline is manual, publishes the macOS partition and never reuses a de 'AGC_RELEASE_VERSION', 'OSSUTIL_BIN', // 并行度必须可调:节点是共用机器,写死容易把整机压满或反过来浪费一半核心。 - "string(name: 'CARGO_BUILD_JOBS'", + "string(name: 'CARGO_BUILD_JOBS', defaultValue: '8'", 'CARGO_BUILD_JOBS=${params.CARGO_BUILD_JOBS}', + // Agent 工作区按约定匹配,不写死节点名:节点改名(-local → -01)后守卫仍成立。 + '"$HOME"/Library/Jenkins/agents/*/workspace/*', + // 上一次发布的 commit 落在 master 上,取到它更新摘要才不会退化成「最近提交」。 + 'refs/heads/master:refs/remotes/origin/master', ]) { assert.ok(pipeline.includes(required), required); } + assert.ok( + !pipeline.includes('genarrative-agc-macos-local'), + 'Jenkinsfile 不得写死具体节点名', + ); // dry-run 必须是默认值:不显式取消勾选就不得写入 OSS。 assert.match( pipeline, @@ -119,10 +127,39 @@ test('macOS release entry verifies the updater signature before uploading', () = // workspace 会跨构建保留产物:必须先删本次要写的对象,否则会因同名 DMG 失败, // 或让上一轮遗留的 .sig 让验签门禁误通过。 for (const required of [ - "path.join(macosBundle, '陶泥儿.app.tar.gz.sig')", + // 清理对象用派生的产品名算出来,而不是写死某个名字。 + '${updaterArtifactName}.sig', + '${firstInstallName}.sha256', 'fs.rmSync(stale, { force: true })', "'-ov'", ]) { assert.ok(entry.includes(required), required); } }); + +test('macOS release entry and smoke script derive product names from config and the bundle', () => { + const entry = fs.readFileSync( + new URL('./build-macos-ci.mjs', import.meta.url), + 'utf8', + ); + // 产品名决定 *.app、updater 归档与 DMG 卷名:写死会在改名后静默找错对象。 + assert.ok(entry.includes('readProductName'), '入口必须从 Tauri 配置读产品名'); + assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名'); + assert.ok( + entry.includes('_${version}_universal.dmg'), + '首装包名必须保留清单侧唯一匹配所需的后缀', + ); + + const smoke = fs.readFileSync( + new URL('./check-macos-bundle.mjs', import.meta.url), + 'utf8', + ); + assert.ok(!smoke.includes('陶泥儿'), '校验脚本不得写死产品名'); + for (const required of [ + 'path.basename(source)', + 'Print :CFBundleExecutable', + "'--clone'", + ]) { + assert.ok(smoke.includes(required), required); + } +}); diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 64c175fe3..7255a6304 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -728,21 +728,21 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. ### AGC macOS 手动构建节点 -Mac universal 构建脚本位于 `jenkins/Jenkinsfile.ai-game-creator-shell-macos-build`,只对专用 `genarrative-agc-macos` 标签运行。节点按 EXCLUSIVE、单 executor 配置,Job 禁止并发且不设置 trigger;不接入现有每小时版本调度,不改 Windows 发布职责。它使用独立 Jenkins workspace,禁止指向开发 checkout 或共享其可写 target/node_modules。 +Mac universal 构建脚本位于 `jenkins/Jenkinsfile.ai-game-creator-shell-macos-build`,只对专用 `genarrative-agc-macos` 标签运行。节点按 EXCLUSIVE、单 executor 配置,Job 禁止并发且不设置 trigger;不接入现有每小时版本调度,不改 Windows 发布职责。它使它只在专用 Agent 目录下构建:默认按约定匹配 `$HOME/Library/Jenkins/agents//workspace/`(不写死节点名,节点改名后仍成立),也可用 `AGC_AGENT_ROOT` 显式覆盖;禁止指向开发 checkout 或共享其可写 target/node_modules。 Job 名为 `Genarrative-Agc-MacOS-Build`,SCM 直接读取仓库内上述 Jenkinsfile,参数为 `SOURCE_BRANCH`、`COMMIT_HASH`、`AGC_UPDATE_CHANNEL`、`AGC_RELEASE_VERSION`、`AGC_RELEASE_DRY_RUN`、`AGC_UPDATE_RELEASE_NOTES`、`OSSUTIL_BIN`、`CARGO_BUILD_JOBS`。渠道参数是基础名(不含系统,默认 `dev`),脚本不接受 `dev-mac` 这类系统后缀,写入分区固定推导为 `-mac`:这与 Windows Job 的 `-win` 对称,也延续已发布客户端的端点。 -该 Job 的职责是构建并发布 `-mac` 分区更新:执行 `npm ci` 后使用锁文件校验并补齐两种 macOS Codex 原生依赖,再调用 `scripts/build-macos-ci.mjs`(AGC 应用目录下)生成 universal app、arm64/x86_64 隔离 smoke、universal DMG 与分区清单 `latest.json`,用产物内烘焙的公钥复核更新包签名(`verify-updater-signature.mjs`),最后按 `AGC_RELEASE_DRY_RUN` 决定是否上传 OSS。归档限 `artifacts/` 下的 DMG、SHA-256、`latest.json`、更新包签名、更新摘要、非敏感构建清单和源码 commit;不归档用户 HOME、Jenkins secret、原始工作目录或全量日志。 +该 Job 的职责是构建并发布 `-mac` 分区更新:执行 `npm ci` 后使用锁文件校验并补齐两种 macOS Codex 原生依赖,再调用 `scripts/build-macos-ci.mjs`(AGC 应用目录下)生成 universal app、arm64/x86_64 隔离 smoke、universal DMG 与分区清单 `latest.json`,用产物内烘焙的公钥复核更新包签名(`verify-updater-signature.mjs`),最后按 `AGC_RELEASE_DRY_RUN` 决定是否上传 OSS。签到会同时取 `master`,让渠道清单里上一次发布的 commit 可解析——缺了它更新摘要会退化成「最近客户端改动」(该步失败只降级摘要,不阻断发布)。产物名(`*.app`、updater 归档、DMG、卷名)一律从 Tauri `productName` 推导,校验脚本从包内 `Info.plist` 读取可执行名,改产品名不会让入口静默找错对象;隔离 smoke 用 `ditto --clone` 复制副本(实测整轮 6.8 秒,此前整包复制约 1 分钟),并在构建前删除本次将写出的 DMG/更新包/签名,保证归档产物一定来自本次构建。归档限 `artifacts/` 下的 DMG、SHA-256、`latest.json`、更新包签名、更新摘要、非敏感构建清单和源码 commit;不归档用户 HOME、Jenkins secret、原始工作目录或全量日志。 发布凭据全部走 Jenkins 全局凭据,并在 `withCredentials` 内注入当前进程:`AgcUpdaterSigningKey`(与 `AgcUpdaterSigningKeyPassword`)映射为 `TAURI_SIGNING_PRIVATE_KEY` / `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,`AliyunAccessKeyId` / `AliyunaccessKeySecret` 映射为 `AGC_OSS_ACCESS_KEY_ID` / `AGC_OSS_ACCESS_KEY_SECRET`;私钥与凭据不写入 workspace、日志或归档产物。上传顺序为更新包、签名、首装包,三者全部成功后才覆盖 `agc/-mac/latest.json` 指针;`AGC_RELEASE_DRY_RUN` 默认开启,dry-run 只打印将上传的对象、不写任何 OSS 对象。Mac 节点需要 `ossutil`(实测 1.7.19 原生 arm64 可用,装在 `~/.local/bin`,已在 Job 的 PATH 内),可用 `OSSUTIL_BIN` 指定命令名或绝对路径。首次发布建议显式指定 `AGC_RELEASE_VERSION`,避免按渠道高水位递增时出现版本链回退。 产物边界:macOS 代码签名与公证暂缺,构建通过剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名,**不得使用 `--no-sign`**——该标志会连带跳过 updater 的 minisign 签名,产物缺少 `.sig` 会直接卡在验签门禁(首次实跑即命中该坑)。构建清单按 `codesign -dv` 实测记录 `appleSigned` / `appleSignatureKind`(如 `adhoc`),并固定记录 `notarized=false`。用户首次安装需要在 Gatekeeper 中手动放行;更新包校验本身只依赖 minisign 签名,因此未签名不阻断自动更新的校验环节,但「安装 → 重启接管新版本」的实机闭环仍未验证,不得以构建成功替代。 -该 Job 的 Rust 编译走 sccache 对象缓存,与 `Genarrative-Api-Build`、`Genarrative-Stdb-Module-Build` 一致:`RUSTC_WRAPPER=sccache`、`CARGO_INCREMENTAL=0`、`SCCACHE_CACHE_SIZE=20G`。`SCCACHE_DIR` 默认落在稳定缓存根 `~/caches/genarrative-jenkins/agc-macos/sccache`,可用 `GENARRATIVE_AGC_MACOS_CACHE_ROOT` 覆盖;缓存根固定在 HOME 下而不是 `WORKSPACE` 内,避免 workspace 重建后无改动也触发近似冷构建。节点是 8 逻辑核(4P+4E)的共用机器,`CARGO_BUILD_JOBS` 因此做成 Job 参数(默认 `6`),既不全占也让出余量;首次构建 sccache 必然全未命中(实测 0 命中 / 407 未命中),此后的构建才逐步吃到缓存。节点未安装 sccache 时管线打印提示并回退到真实 `rustc`,不阻断构建。universal 双架构各自独立编译,依赖 crate 的复用收益约为单架构的两倍;命中情况由构建末尾的 `sccache --show-stats` 输出,供判断是否需要预热或调整缓存上限。 +该 Job 的 Rust 编译走 sccache 对象缓存,与 `Genarrative-Api-Build`、`Genarrative-Stdb-Module-Build` 一致:`RUSTC_WRAPPER=sccache`、`CARGO_INCREMENTAL=0`、`SCCACHE_CACHE_SIZE=20G`。`SCCACHE_DIR` 默认落在稳定缓存根 `~/caches/genarrative-jenkins/agc-macos/sccache`,可用 `GENARRATIVE_AGC_MACOS_CACHE_ROOT` 覆盖;缓存根固定在 HOME 下而不是 `WORKSPACE` 内,避免 workspace 重建后无改动也触发近似冷构建。节点是 8 逻辑核(4P+4E)的共用机器,`CARGO_BUILD_JOBS` 因此做成 Job 参数(默认 `8`,吃满节点)。该值不只是并行 crate 数——cargo 会把它作为 jobserver 令牌上限传给 rustc,主 crate 的 codegen 也受它限制,因此写小会直接拖长整条构建;节点只有 24 GB 内存且是日常办公机,若构建期间出现明显换页再临时调低。首次构建 sccache 必然全未命中(实测 0 命中 / 407 未命中),此后的构建才逐步吃到缓存。节点未安装 sccache 时管线打印提示并回退到真实 `rustc`,不阻断构建。universal 双架构各自独立编译,依赖 crate 的复用收益约为单架构的两倍;命中情况由构建末尾的 `sccache --show-stats` 输出,供判断是否需要预热或调整缓存上限。 首次端到端验证(2026-09-20,build #3):`SUCCESS`,43 分钟。产出 `陶泥儿_0.1.68_universal.dmg`、`陶泥儿.app.tar.gz` 与 `.sig`、`latest.json`、`build-manifest.json`;两个架构的隔离 smoke(arm64 / Rosetta x86_64)通过,更新包用产物内烘焙公钥复核通过(`alg=ED`,`keyId=cb883447e3e87c4e`),dry-run 只打印 4 个上传对象、未写 OSS。构建清单实测记录 `appleSigned=false`、`appleSignatureKind=adhoc`、`notarized=false`。首次实跑暴露并修掉两个入口缺陷:传 `--no-sign` 会连带跳过 updater 的 minisign 签名(产物无 `.sig`),以及复用 workspace 里残留的同名 DMG 让 `hdiutil create` 直接失败、残留旧 `.sig` 还可能让验签误通过——入口现在会在构建前删除本次将写出的确切产物并要求 `hdiutil -ov`。 -耗时构成:`CARGO_BUILD_JOBS=4` 时首次构建 78 分钟,提到 6 后 41–43 分钟;剩余时间主要花在主 crate 每个架构各一遍 codegen(每次 Tauri 构建都会重写前端 `dist`,触发 `build.rs` 重跑)。该优化点见 `docs/project-memory/shared-memory/pitfalls.md`,需单独设计。 +耗时构成:`CARGO_BUILD_JOBS=4` 时首次构建 78 分钟,提到 6 后 41–43 分钟,提到 8 进一步吃满节点;剩余时间主要花在主 crate 每个架构各一遍 codegen(每次 Tauri 构建都会重写前端 `dist`,触发 `build.rs` 重跑)。该优化点见 `docs/project-memory/shared-memory/pitfalls.md`,需单独设计。 当前用户的 LaunchAgent 受登录、休眠与局域网连通性影响,不能视为长期无人值守构建机。该用户进程也不是权限沙箱,只能承接受信任仓库和 Job;不能把匹配标签当作隔离恶意构建的措施。节点注册、上线和首个 Job 的成功必须以控制器实时状态确认,脚本入库不代表管线已经接通。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build index 765b2c476..8a72c1550 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build @@ -11,11 +11,11 @@ pipeline { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '必须含 universal 双架构依赖实现的受信任分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,固定属于源码分支的提交;不递增应用版本') string(name: 'AGC_UPDATE_CHANNEL', defaultValue: 'dev', description: 'AGC 发布渠道(基础名,不含系统):dev、release 或自定义小写名称;写入的分区固定为 -mac') - string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选三段版本号;留空则按 dev-mac 渠道清单高水位递增 patch。首次发布建议显式指定,避免版本链回退') - booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: true, description: '勾选后只构建、验签并打印将上传的对象,不写 OSS;取消勾选才真正发布到 dev-mac 渠道') + string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选三段版本号;留空则按 -mac 分区清单高水位递增 patch。首次发布建议显式指定,避免版本链回退') + booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: true, description: '勾选后只构建、验签并打印将上传的对象,不写 OSS;取消勾选才真正发布到 -mac 分区') string(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选单行更新摘要;留空则由发布脚本按提交自动汇总') string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 命令名或绝对路径(Mac 节点默认装在 ~/.local/bin/ossutil)') - string(name: 'CARGO_BUILD_JOBS', defaultValue: '6', description: '并行 rustc 任务数:节点 8 核(4P+4E)且需留余量给日常使用,6 是经实测的折中值;只影响本次构建') + string(name: 'CARGO_BUILD_JOBS', defaultValue: '8', description: '并行 rustc 任务数,默认吃满节点 8 核(4P+4E)。该值同时作为 rustc codegen 的 jobserver 令牌上限;节点只有 24 GB 内存且是日常办公机,若构建期间出现明显换页可临时调低。只影响本次构建') } environment { GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git' @@ -34,11 +34,16 @@ pipeline { stages { stage('Checkout') { steps { - // 由节点配置 AGC_AGENT_ROOT;不允许把开发工作树当作 Jenkins workspace。 + // 只允许在专用 Agent 目录下构建:默认按约定匹配 $HOME/Library/Jenkins/agents//workspace/, + // 不写死某个节点名(节点改名后仍成立),也可用 AGC_AGENT_ROOT 显式覆盖。 + // 目的是防止把开发 checkout 当成 Jenkins workspace。 sh ''' set -eu - AGC_AGENT_ROOT="${AGC_AGENT_ROOT:-$HOME/Library/Jenkins/agents/genarrative-agc-macos-local}" - case "$WORKSPACE" in "$AGC_AGENT_ROOT"/workspace/*) ;; *) echo '拒绝非专用 Agent 工作区'; exit 1;; esac + if [ -n "${AGC_AGENT_ROOT:-}" ]; then + case "$WORKSPACE" in "$AGC_AGENT_ROOT"/workspace/*) ;; *) echo "拒绝非专用 Agent 工作区:$WORKSPACE"; exit 1;; esac + else + case "$WORKSPACE" in "$HOME"/Library/Jenkins/agents/*/workspace/*) ;; *) echo "拒绝非专用 Agent 工作区:$WORKSPACE"; exit 1;; esac + fi test "$(uname -s)" = Darwin git check-ref-format --branch "$SOURCE_BRANCH" >/dev/null ''' @@ -52,6 +57,10 @@ pipeline { fi test "$(git remote get-url origin)" = "$GIT_REMOTE_URL" git fetch --no-tags origin "+refs/heads/$SOURCE_BRANCH:refs/remotes/origin/$SOURCE_BRANCH" + # 同时取 master:渠道清单里的上一次发布 commit 落在 master 上,缺了它更新摘要会退化成 + # 「最近客户端改动」。这一步只是摘要质量,失败不阻断发布。 + git fetch --no-tags origin "+refs/heads/master:refs/remotes/origin/master" || + echo '[agc-macos] 拉取 master 失败:本次更新摘要可能退化为最近提交列表。' ref="refs/remotes/origin/$SOURCE_BRANCH" if [ -n "$COMMIT_HASH" ]; then case "$COMMIT_HASH" in *[!0-9a-fA-F]* ) echo 'COMMIT_HASH 必须为十六进制'; exit 1;; esac