From 48985d3447f515ac11c1e6c08b462e992b94ecd0 Mon Sep 17 00:00:00 2001 From: suzmii Date: Fri, 18 Sep 2026 19:34:23 +0800 Subject: [PATCH 01/16] =?UTF-8?q?=E6=8E=A5=E5=85=A5Mac=E9=80=9A=E7=94=A8?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E4=B8=8EJenkins=E5=BD=92=E6=A1=A3=E7=AE=A1?= =?UTF-8?q?=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐macOS universal双架构Codex资源与构建校验 统一发布清单和检查脚本支持universal目标 新增锁定原生依赖完整性校验与隔离构建smoke 新增Mac Jenkins Agent归档构建Job与本机构建接入规范 --- .gitignore | 2 + .../scripts/build-macos-ci.mjs | 113 +++++++++++++++ .../scripts/build-release.mjs | 10 +- .../scripts/build-release.test.mjs | 45 ++++-- .../scripts/check-config.mjs | 26 ++-- .../scripts/check-macos-bundle.mjs | 66 ++++++--- .../scripts/prepare-macos-codex.mjs | 134 ++++++++++++++++++ .../scripts/prepare-macos-codex.test.mjs | 85 +++++++++++ apps/ai-game-creator-shell/src-tauri/build.rs | 20 ++- .../src-tauri/build_support/codex_bundle.rs | 9 +- .../src-tauri/tauri.macos.conf.json | 21 ++- ...实施计划】Mac本机构建节点接入-2026-09-18.md | 14 ++ .../【实施计划】Mac通用安装包-2026-09-18.md | 16 +++ ...里程碑】Mac本机构建节点接入-2026-09-18.md | 34 +++++ ...程碑】Mac通用安装包与构建管线-2026-09-18.md | 36 +++++ docs/project-memory/shared-memory/pitfalls.md | 4 + ...方案】AGC客户端更新检查与下载-2026-08-31.md | 10 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 8 ++ ...kinsfile.ai-game-creator-shell-macos-build | 94 ++++++++++++ 20 files changed, 685 insertions(+), 66 deletions(-) create mode 100644 apps/ai-game-creator-shell/scripts/build-macos-ci.mjs create mode 100644 apps/ai-game-creator-shell/scripts/prepare-macos-codex.mjs create mode 100644 apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs create mode 100644 docs/project-memory/plans/【实施计划】Mac本机构建节点接入-2026-09-18.md create mode 100644 docs/project-memory/plans/【实施计划】Mac通用安装包-2026-09-18.md create mode 100644 docs/project-memory/plans/【里程碑】Mac本机构建节点接入-2026-09-18.md create mode 100644 docs/project-memory/plans/【里程碑】Mac通用安装包与构建管线-2026-09-18.md create mode 100644 jenkins/Jenkinsfile.ai-game-creator-shell-macos-build diff --git a/.gitignore b/.gitignore index 34e0fbde7..10390a82e 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,8 @@ temp*build*/ /apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-package.json /apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json /apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-arm64/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-x64/ /plugins/agc-cocos-editor/native/payload/ /apps/ai-game-creator-shell/logs/ /apps/ai-game-creator-shell/.llm-drafts/ diff --git a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs new file mode 100644 index 000000000..acafcc0fd --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { resolveReleaseContext, runTauriBuild } from './build-release.mjs'; + +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const repoRoot = path.resolve(appRoot, '../..'); +assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行'); +assert.equal( + process.env.JENKINS_URL?.length > 0, + true, + '此入口仅用于 Jenkins 独立工作区', +); +assert.equal( + fs.realpathSync(process.env.WORKSPACE || '.'), + fs.realpathSync(repoRoot), + '必须在 Jenkins workspace 根目录执行', +); +const space = fs.statfsSync(repoRoot); +assert.ok( + space.bavail * space.bsize >= 8 * 1024 ** 3, + '构建前至少需要 8 GiB 可用空间;禁止自动清理开发缓存', +); + +// 本入口永不发布,不使用 Agent 用户可能持有的发布或 Apple 认证环境。 +for (const key of Object.keys(process.env)) { + if (/^(TAURI_SIGNING_|APPLE_|AGC_OSS_)/u.test(key)) delete process.env[key]; +} +process.env.RUSTC_WRAPPER = ''; +process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target'); +const context = resolveReleaseContext(['--target=universal-apple-darwin']); +const args = [ + '--target=universal-apple-darwin', + '--bundles', + 'app', + '--ci', + '--no-sign', + '--config', + '{"bundle":{"createUpdaterArtifacts":false}}', +]; +const command = (binary, argv, options = {}) => + execFileSync(binary, argv, { cwd: repoRoot, stdio: 'inherit', ...options }); +runTauriBuild(args, context); +const app = path.join(context.bundleRoot, 'macos/陶泥儿.app'); +for (const architecture of ['arm64', 'x86_64']) { + command(process.execPath, [ + path.join(appRoot, 'scripts/check-macos-bundle.mjs'), + app, + architecture, + '--universal', + ]); +} +const version = JSON.parse( + fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'), +).version; +assert.match(version, /^\d+\.\d+\.\d+$/u); +const artifacts = path.join(repoRoot, 'artifacts'); +// 只清理本 Job 的归档输出,不能把上次 DMG 当成本次成功产物。 +fs.rmSync(artifacts, { recursive: true, force: true }); +fs.mkdirSync(artifacts, { recursive: true }); +const dmg = path.join(artifacts, `陶泥儿_${version}_universal.dmg`); +const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-ci-dmg-')); +try { + command('ditto', [app, path.join(stage, '陶泥儿.app')]); + fs.symlinkSync('/Applications', path.join(stage, 'Applications')); + command('hdiutil', [ + 'create', + '-volname', + '陶泥儿', + '-srcfolder', + stage, + '-format', + 'UDZO', + dmg, + ]); + command('hdiutil', ['verify', dmg]); +} finally { + fs.rmSync(stage, { recursive: true, force: true }); +} +const hash = createHash('sha256'); +for await (const chunk of fs.createReadStream(dmg)) hash.update(chunk); +fs.writeFileSync( + `${dmg}.sha256`, + `${hash.digest('hex')} ${path.basename(dmg)}\n`, +); +const commit = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', +}).trim(); +fs.writeFileSync( + path.join(artifacts, 'build-manifest.json'), + `${JSON.stringify( + { + version, + commit, + target: context.target, + channel: context.channel, + signed: false, + notarized: false, + uploaded: false, + smoke: ['arm64', 'x86_64'], + intelSmoke: process.arch === 'arm64' ? 'Rosetta' : 'native', + }, + null, + 2, + )}\n`, +); +console.log('[macOS CI] universal 包与校验文件已生成;未发布、未签名或公证'); diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index b1fc307f5..478c58719 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -42,16 +42,12 @@ function explicitBuildTarget(args) { } function validateReleaseTarget(target) { - if (target === 'universal-apple-darwin') { - throw new Error( - '内置 Codex 资源仅支持 macOS 单架构构建,请使用 aarch64-apple-darwin 或 x86_64-apple-darwin', - ); - } if ( ![ 'x86_64-pc-windows-msvc', 'aarch64-apple-darwin', 'x86_64-apple-darwin', + 'universal-apple-darwin', ].includes(target) ) { throw new Error(`不支持的发布目标:${target}`); @@ -197,10 +193,12 @@ export function updateManifestUrl(channel = resolveReleaseChannel()) { } /** - * 单架构产物只登记实际目标,不能把同一原生资源映射为另一架构。 + * universal 主程序与双目录原生资源共用一个更新包;单架构只登记实际目标。 */ export function resolveManifestPlatformKeys(target = defaultTarget()) { validateReleaseTarget(target); + if (target === 'universal-apple-darwin') + return ['darwin-aarch64', 'darwin-x86_64']; if (target === 'aarch64-apple-darwin') return ['darwin-aarch64']; if (target === 'x86_64-apple-darwin') return ['darwin-x86_64']; if (target.includes('windows')) { diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 8cb9c0214..0596cefe9 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -39,13 +39,12 @@ import { const windowsTarget = 'x86_64-pc-windows-msvc'; const universalTarget = 'universal-apple-darwin'; -test('native sidecar builds reject universal targets and accept each macOS architecture', () => { - assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/); - assert.throws( - () => buildTauriBuildArguments(['--target=universal-apple-darwin']), - /单架构/, - ); - for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) { +test('native sidecar builds accept universal and each macOS architecture', () => { + for (const target of [ + universalTarget, + 'aarch64-apple-darwin', + 'x86_64-apple-darwin', + ]) { assert.deepEqual(buildTauriBuildArguments([], target), [ 'build', '--target', @@ -152,8 +151,11 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { }); }); -test('macOS manifests only advertise the architecture actually built', () => { - assert.throws(() => resolveManifestPlatformKeys(universalTarget), /单架构/); +test('macOS manifests advertise exactly the architectures actually built', () => { + assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [ + 'darwin-aarch64', + 'darwin-x86_64', + ]); assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [ 'darwin-aarch64', ]); @@ -197,7 +199,6 @@ test('release context resolves explicit targets before environment/default and f ['--target='], ['--target', '--no-bundle'], ['--target', windowsTarget, '--target=aarch64-apple-darwin'], - ['--target', universalTarget], ['--target', 'unknown'], ]) assert.throws(() => resolveReleaseContext(args, {})); @@ -314,8 +315,8 @@ test('invalid target or mismatched channel fails before any release side effect' }, }; await assert.rejects( - () => buildRelease(['--target', universalTarget], sideEffects), - /单架构/, + () => buildRelease(['--target', 'unknown'], sideEffects), + /不支持的发布目标/, ); await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () => assert.rejects( @@ -326,6 +327,26 @@ test('invalid target or mismatched channel fails before any release side effect' assert.equal(touched, false); }); +test('universal uses the Mac channel and the same signed artifact for both architectures', () => { + const context = resolveReleaseContext(['--target', universalTarget], { + AGC_BUILD_TARGET: windowsTarget, + }); + assert.equal(context.channel, 'dev-mac'); + assert.ok(context.bundleRoot.includes(universalTarget)); + withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => { + const manifest = createUpdateManifest(artifact, context); + assert.deepEqual(Object.keys(manifest.platforms), [ + 'darwin-aarch64', + 'darwin-x86_64', + ]); + assert.deepEqual( + manifest.platforms['darwin-aarch64'], + manifest.platforms['darwin-x86_64'], + ); + assert.match(manifest.platforms['darwin-aarch64'].url, /\/dev-mac\//); + }); +}); + test('Windows remains the default and explicit Windows overrides macOS environment', () => { const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg']; for (const context of [ diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index fe8943608..4fc8910a9 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1367,18 +1367,20 @@ if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) { assert.deepEqual( macosTauriConfig.bundle?.resources, Object.fromEntries([ - ...[ - 'bin/codex', - 'bin/codex-code-mode-host', - 'codex-path/rg', - 'codex-resources/zsh/bin/zsh', - 'codex-package.json', - 'NOTICE.md', - 'manifest.json', - ].map((file) => [ - `resources/codex/mac-native/${file}`, - `coding-agent/mac-native/${file}`, - ]), + ...['darwin-arm64', 'darwin-x64'].flatMap((arch) => + [ + 'bin/codex', + 'bin/codex-code-mode-host', + 'codex-path/rg', + 'codex-resources/zsh/bin/zsh', + 'codex-package.json', + 'NOTICE.md', + 'manifest.json', + ].map((file) => [ + `resources/codex/mac-native/${arch}/${file}`, + `coding-agent/mac-native/${arch}/${file}`, + ]), + ), ['resources/plugins', 'plugins'], ]), 'macOS must bundle the complete native Codex layout and plugin workspace', 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 78e38fdd9..1466a7661 100644 --- a/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs +++ b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs @@ -8,6 +8,13 @@ import path from 'node:path'; // 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。 assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行'); const source = path.resolve(process.argv[2] || ''); +const architecture = + process.argv[3] || (process.arch === 'arm64' ? 'arm64' : 'x86_64'); +assert.ok( + ['arm64', 'x86_64'].includes(architecture), + '架构只接受 arm64 / x86_64', +); +const requireUniversal = process.argv.includes('--universal'); assert.ok( source.endsWith('.app') && fs.statSync(source).isDirectory(), '请传入 .app 绝对路径', @@ -31,13 +38,19 @@ const env = { }; function run(command, args) { - const result = spawnSync(command, args, { - cwd: root, - env, - encoding: 'utf8', - timeout: 30_000, - maxBuffer: 1024 * 1024, - }); + // 只强制被测应用切片;本机 Xcode 检查工具可能仅提供宿主架构。 + const useSlice = command.startsWith(`${app}${path.sep}`); + const result = spawnSync( + useSlice ? '/usr/bin/arch' : command, + useSlice ? [`-${architecture}`, command, ...args] : args, + { + cwd: root, + env, + encoding: 'utf8', + timeout: 120_000, + maxBuffer: 1024 * 1024, + }, + ); assert.ifError(result.error); return result; } @@ -60,7 +73,7 @@ async function handshake(executable) { await new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new Error('app-server 初始化超时')), - 15_000, + 120_000, ); const finish = (error) => { clearTimeout(timer); @@ -129,20 +142,39 @@ async function handshake(executable) { try { fs.cpSync(source, app, { recursive: true }); const resources = path.join(app, 'Contents/Resources'); - const bundle = path.join(resources, 'coding-agent/mac-native'); + 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 mainArchitectures = run('/usr/bin/lipo', ['-archs', main]); + assert.equal(mainArchitectures.status, 0); + assert.ok(mainArchitectures.stdout.split(/\s+/).includes(architecture)); + if (requireUniversal) { + assert.deepEqual(mainArchitectures.stdout.trim().split(/\s+/).sort(), [ + 'arm64', + 'x86_64', + ]); + for (const platform of ['darwin-arm64', 'darwin-x64']) { + assert.ok( + fs.existsSync( + path.join( + resources, + 'coding-agent/mac-native', + platform, + 'manifest.json', + ), + ), + ); + } + } const manifest = JSON.parse( fs.readFileSync(path.join(bundle, 'manifest.json'), 'utf8'), ); assert.equal(manifest.schemaVersion, 'genarrative-codex-sidecar.v2'); - assert.equal( - manifest.platform, - process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64', - ); + assert.equal(manifest.platform, platform); assert.equal(manifest.version, 'codex-cli 0.147.0'); const components = [ 'bin/codex', @@ -159,11 +191,7 @@ try { fs.accessSync(file, fs.constants.X_OK); const arch = run('/usr/bin/lipo', ['-archs', file]); assert.equal(arch.status, 0, component); - assert.equal( - arch.stdout.trim(), - process.arch === 'arm64' ? 'arm64' : 'x86_64', - component, - ); + assert.equal(arch.stdout.trim(), architecture, component); } } assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md'))); @@ -212,7 +240,7 @@ try { assert.notEqual(broken.status, 0); assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/); console.log( - 'PASS: 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝', + `PASS (${architecture}): 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝`, ); console.log( '未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提', diff --git a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.mjs b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.mjs new file mode 100644 index 000000000..6335fda74 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const repoRoot = path.resolve(appRoot, '../..'); +const platforms = { + arm64: 'aarch64-apple-darwin', + x64: 'x86_64-apple-darwin', +}; + +export function lockedMacPackage(lock, arch, version) { + assert.ok(Object.hasOwn(platforms, arch), '未知 macOS 架构'); + const alias = `@openai/codex-darwin-${arch}`; + const entry = lock.packages?.[`node_modules/${alias}`]; + assert.equal( + entry?.version, + `${version}-darwin-${arch}`, + '原生依赖必须与应用锁定版本一致', + ); + assert.deepEqual(entry.os, ['darwin']); + assert.deepEqual(entry.cpu, [arch]); + const url = new URL(entry.resolved); + assert.equal(url.protocol, 'https:'); + assert.equal( + url.hostname, + 'registry.npmjs.org', + '只下载锁定的官方 npm 原生包', + ); + assert.equal(url.username + url.password + url.search + url.hash, ''); + assert.match(entry.integrity, /^sha512-[A-Za-z0-9+/]+={0,2}$/); + return { alias, target: platforms[arch], ...entry }; +} + +export function verifyPackageIntegrity(bytes, expected) { + const actual = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; + assert.equal(actual, expected, 'Codex 下载包 lockfile integrity 不匹配'); +} + +export function validateArchiveListing(listing) { + const files = listing.trim().split(/\r?\n/u); + assert.ok(files.length > 0); + for (const file of files) { + assert.ok(file.startsWith('package/'), '原生包必须只有 package 根目录'); + assert.ok( + !file.split('/').includes('..') && !file.includes('\\'), + '压缩包路径不安全', + ); + } +} + +export async function prepareMacosCodex() { + assert.equal(process.platform, 'darwin', '该入口仅用于 macOS 构建机'); + const lock = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8'), + ); + const app = JSON.parse( + fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'), + ); + const version = app.devDependencies['@openai/codex']; + assert.match(version, /^\d+\.\d+\.\d+$/u, 'Codex 必须锁定精确版本'); + const cache = path.join(appRoot, 'src-tauri/target/.macos-native-cache'); + fs.mkdirSync(cache, { recursive: true }); + for (const arch of Object.keys(platforms)) { + const entry = lockedMacPackage(lock, arch, version); + const archive = path.join(cache, `codex-${entry.version}.tgz`); + if (!fs.existsSync(archive)) { + const response = await fetch(entry.resolved, { + signal: AbortSignal.timeout(300_000), + }); + assert.ok(response.ok, `原生包下载失败 HTTP ${response.status}`); + const bytes = Buffer.from(await response.arrayBuffer()); + verifyPackageIntegrity(bytes, entry.integrity); + const partial = `${archive}.${process.pid}.tmp`; + fs.writeFileSync(partial, bytes); + fs.renameSync(partial, archive); + } + verifyPackageIntegrity(fs.readFileSync(archive), entry.integrity); + validateArchiveListing( + execFileSync('tar', ['-tzf', archive], { encoding: 'utf8' }), + ); + // 拒绝链接、设备及其它特殊条目,不能让 tar 在包目录之外写入。 + const entries = execFileSync('tar', ['-tvzf', archive], { + encoding: 'utf8', + }); + assert.ok( + entries + .trim() + .split(/\r?\n/u) + .every((line) => /^[-d]/u.test(line)), + '原生包禁止链接或特殊文件', + ); + const parent = path.join(repoRoot, 'node_modules/@openai'); + fs.mkdirSync(parent, { recursive: true }); + const stage = fs.mkdtempSync(path.join(parent, '.mac-native-')); + try { + execFileSync( + 'tar', + ['-xzf', archive, '-C', stage, '--strip-components=1'], + { stdio: 'pipe' }, + ); + const metadata = JSON.parse( + fs.readFileSync( + path.join(stage, 'vendor', entry.target, 'codex-package.json'), + 'utf8', + ), + ); + assert.equal(metadata.version, version); + assert.equal(metadata.target, entry.target); + assert.equal(metadata.entrypoint, 'bin/codex'); + const destination = path.join(repoRoot, 'node_modules', entry.alias); + assert.ok( + !fs.existsSync(destination) || + !fs.lstatSync(destination).isSymbolicLink(), + '拒绝覆盖链接依赖', + ); + fs.rmSync(destination, { recursive: true, force: true }); + fs.renameSync(stage, destination); + } finally { + fs.rmSync(stage, { recursive: true, force: true }); + } + console.log(`[macOS Codex] ${entry.version}: lockfile integrity 已验证`); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + await prepareMacosCodex(); +} 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 new file mode 100644 index 000000000..2fd93ddf1 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import { test } from 'node:test'; + +import { + lockedMacPackage, + validateArchiveListing, + verifyPackageIntegrity, +} from './prepare-macos-codex.mjs'; + +const lock = JSON.parse( + fs.readFileSync(new URL('../../../package-lock.json', import.meta.url)), +); +const version = JSON.parse( + fs.readFileSync(new URL('../package.json', import.meta.url)), +).devDependencies['@openai/codex']; + +test('both macOS dependencies resolve from the lockfile without floating versions', () => { + assert.equal( + lockedMacPackage(lock, 'arm64', version).target, + 'aarch64-apple-darwin', + ); + assert.equal( + lockedMacPackage(lock, 'x64', version).target, + 'x86_64-apple-darwin', + ); + assert.throws(() => lockedMacPackage(lock, 'other', version)); + assert.throws(() => lockedMacPackage(lock, 'x64', '0.0.0')); +}); + +test('native package integrity rejects tampering', () => { + const bytes = Buffer.from('pinned package'); + const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; + verifyPackageIntegrity(bytes, integrity); + assert.throws(() => + verifyPackageIntegrity(Buffer.from('modified'), integrity), + ); +}); + +test('archive traversal and non-package entries fail closed', () => { + validateArchiveListing( + 'package/package.json\npackage/vendor/target/bin/codex\n', + ); + for (const listing of [ + '', + '/tmp/payload', + 'package/../private', + 'other/file', + 'package/..\\file', + ]) { + assert.throws(() => validateArchiveListing(listing)); + } +}); + +test('CI pipeline is manual archive-only and does not reuse a developer workspace', () => { + const pipeline = fs.readFileSync( + new URL( + '../../../jenkins/Jenkinsfile.ai-game-creator-shell-macos-build', + import.meta.url, + ), + 'utf8', + ); + for (const required of [ + 'genarrative-agc-macos', + 'disableConcurrentBuilds()', + '$AGC_AGENT_ROOT', + 'StrictHostKeyChecking=yes', + 'git merge-base --is-ancestor', + 'allowEmptyArchive: false', + ]) { + assert.ok(pipeline.includes(required), required); + } + for (const forbidden of [ + 'triggers {', + 'cron(', + 'pollSCM(', + 'release:upload', + 'AgcUpdaterSigningKey', + 'AliyunAccessKeyId', + 'git clean -fdx', + ]) { + assert.ok(!pipeline.includes(forbidden), forbidden); + } +}); diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 6bca6d67a..ecd11f895 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -31,7 +31,23 @@ fn sha256_file(path: &std::path::Path) -> Result { fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { let target = env::var("TARGET").expect("Cargo TARGET"); println!("cargo:rustc-env=AGC_BUILD_TARGET={target}"); - let Some(layout) = codex_bundle::for_target(&target) else { + if target.contains("apple-darwin") { + // Tauri 的 universal 两次 Cargo 编译共用 resource staging, + // 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。 + let staging = manifest_dir.join("resources/codex/mac-native"); + if staging.exists() { + fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败"); + } + for target in ["aarch64-apple-darwin", "x86_64-apple-darwin"] { + stage_codex_target(manifest_dir, target); + } + } else { + stage_codex_target(manifest_dir, &target); + } +} + +fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { + let Some(layout) = codex_bundle::for_target(target) else { assert!( !target.contains("windows") && !target.contains("apple-darwin"), "不支持的 Codex 随包目标:{target}" @@ -81,7 +97,7 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { &fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"), ) .expect("Codex 原生包元数据无效"); - codex_bundle::validate_package_metadata(&metadata, &target, layout) + codex_bundle::validate_package_metadata(&metadata, target, layout) .unwrap_or_else(|error| panic!("{error}")); let target_dir = manifest_dir.join("resources/codex").join(layout.directory); let notice = target_dir.join("NOTICE.md"); diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs index 1811f6a25..8abb36294 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs @@ -49,7 +49,11 @@ pub fn for_target(target: &str) -> Option { } else { "codex-darwin-x64" }, - directory: "mac-native", + directory: if target.starts_with("aarch64") { + "mac-native/darwin-arm64" + } else { + "mac-native/darwin-x64" + }, executable: "bin/codex", files: MAC_FILES, }), @@ -90,6 +94,9 @@ mod tests { let intel = for_target("x86_64-apple-darwin").unwrap(); assert_eq!(intel.platform, "darwin-x64"); assert_eq!(intel.npm_package, "codex-darwin-x64"); + assert_eq!(mac.directory, "mac-native/darwin-arm64"); + assert_eq!(intel.directory, "mac-native/darwin-x64"); + assert_ne!(mac.directory, intel.directory); let windows = for_target("x86_64-pc-windows-msvc").unwrap(); assert_eq!(windows.directory, "win-x64"); assert_eq!(windows.files.len(), 6); diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json index 67695ad22..019a0f248 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json @@ -5,13 +5,20 @@ "minimumSystemVersion": "15.0" }, "resources": { - "resources/codex/mac-native/bin/codex": "coding-agent/mac-native/bin/codex", - "resources/codex/mac-native/bin/codex-code-mode-host": "coding-agent/mac-native/bin/codex-code-mode-host", - "resources/codex/mac-native/codex-path/rg": "coding-agent/mac-native/codex-path/rg", - "resources/codex/mac-native/codex-resources/zsh/bin/zsh": "coding-agent/mac-native/codex-resources/zsh/bin/zsh", - "resources/codex/mac-native/codex-package.json": "coding-agent/mac-native/codex-package.json", - "resources/codex/mac-native/NOTICE.md": "coding-agent/mac-native/NOTICE.md", - "resources/codex/mac-native/manifest.json": "coding-agent/mac-native/manifest.json", + "resources/codex/mac-native/darwin-arm64/bin/codex": "coding-agent/mac-native/darwin-arm64/bin/codex", + "resources/codex/mac-native/darwin-arm64/bin/codex-code-mode-host": "coding-agent/mac-native/darwin-arm64/bin/codex-code-mode-host", + "resources/codex/mac-native/darwin-arm64/codex-path/rg": "coding-agent/mac-native/darwin-arm64/codex-path/rg", + "resources/codex/mac-native/darwin-arm64/codex-resources/zsh/bin/zsh": "coding-agent/mac-native/darwin-arm64/codex-resources/zsh/bin/zsh", + "resources/codex/mac-native/darwin-arm64/codex-package.json": "coding-agent/mac-native/darwin-arm64/codex-package.json", + "resources/codex/mac-native/darwin-arm64/NOTICE.md": "coding-agent/mac-native/darwin-arm64/NOTICE.md", + "resources/codex/mac-native/darwin-arm64/manifest.json": "coding-agent/mac-native/darwin-arm64/manifest.json", + "resources/codex/mac-native/darwin-x64/bin/codex": "coding-agent/mac-native/darwin-x64/bin/codex", + "resources/codex/mac-native/darwin-x64/bin/codex-code-mode-host": "coding-agent/mac-native/darwin-x64/bin/codex-code-mode-host", + "resources/codex/mac-native/darwin-x64/codex-path/rg": "coding-agent/mac-native/darwin-x64/codex-path/rg", + "resources/codex/mac-native/darwin-x64/codex-resources/zsh/bin/zsh": "coding-agent/mac-native/darwin-x64/codex-resources/zsh/bin/zsh", + "resources/codex/mac-native/darwin-x64/codex-package.json": "coding-agent/mac-native/darwin-x64/codex-package.json", + "resources/codex/mac-native/darwin-x64/NOTICE.md": "coding-agent/mac-native/darwin-x64/NOTICE.md", + "resources/codex/mac-native/darwin-x64/manifest.json": "coding-agent/mac-native/darwin-x64/manifest.json", "resources/plugins": "plugins" } }, diff --git a/docs/project-memory/plans/【实施计划】Mac本机构建节点接入-2026-09-18.md b/docs/project-memory/plans/【实施计划】Mac本机构建节点接入-2026-09-18.md new file mode 100644 index 000000000..769673d6b --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Mac本机构建节点接入-2026-09-18.md @@ -0,0 +1,14 @@ +# Mac 本机构建节点接入实施计划 + +- Version: 1 +- Status: preparing-network-required +- Date: 2026-09-18 +- Parent Spec: `【里程碑】Mac本机构建节点接入-2026-09-18.md` + +1. 本地准备 universal 依赖预备、CI 打包脚本和 Jenkinsfile,运行离线单测、配置门禁及编码检查。 +2. 恢复内网后只读核对 Jenkins 版本、既有节点/Job、Git 凭据标识与插件;已存在对象优先核对,不重复创建。 +3. 从同一控制器取得 agent.jar,以当前已有 Java 21 运行专用 inbound Agent;secret 保存在用户私有目录,LaunchAgent 参数只引用 secret 文件,不保存控制器 API Token。 +4. 建立单 executor、EXCLUSIVE 的专用 Mac 节点与手动 archive-only Job。源码必须是可追溯 Git 提交,未推送改动需另行确认源码交付方式,不能默认推送。 +5. 初次构建核对独立工作目录、空间、目标与依赖;在 Jenkins 实际 SUCCESS 后确认 DMG、SHA-256 和两架构 smoke 证据。 + +失败边界:网络不可达不启动重试服务;节点/Job 修改前保留原配置;不更改其它节点、Job 或调度。禁止把 CLI 日志、认证文件和私有路径提交 Git。未获得真实 SUCCESS 前保留活动计划。 diff --git a/docs/project-memory/plans/【实施计划】Mac通用安装包-2026-09-18.md b/docs/project-memory/plans/【实施计划】Mac通用安装包-2026-09-18.md new file mode 100644 index 000000000..9babc8f89 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Mac通用安装包-2026-09-18.md @@ -0,0 +1,16 @@ +# Mac 通用安装包实施计划 + +- Version: 1 +- Status: awaiting-user-acceptance +- Date: 2026-09-18 +- Parent Spec: `【里程碑】Mac通用安装包与构建管线-2026-09-18.md` + +1. 等待当前 Intel 编译结束,避免共用 staging 并发写。 +2. macOS build.rs 按白名单分别 stage 两套原生资源;共享 Layout 使用架构子目录,运行时原有 hash/版本校验不变。 +3. Tauri macOS 映射双目录,发布入口接受 universal 并生成两个清单键;更新配置门禁与定向测试。 +4. 安装包 smoke 支持显式选择主程序切片,验证 universal 主程序与该切片对应原生依赖。 +5. 用 Tauri universal 构建 app,分别做 arm64/Rosetta smoke,hdiutil 生成新 universal DMG,校验并交付。 + +不发布、不使用私钥;依赖仅从锁定 npm tarball 下载并对照 lockfile integrity。磁盘不足停止,不擅自删除其它 target/cache。保留单架构 DMG。Jenkins 配置作为后续门禁,不混入本地包构建。 + +本地构建与双架构隔离验证完成,证据见对应里程碑。更新 master 时先停止旧构建,保护并恢复改动后重建;未进行 Jenkins 写操作,待确认 Mac Agent 再制定管线实施计划。 diff --git a/docs/project-memory/plans/【里程碑】Mac本机构建节点接入-2026-09-18.md b/docs/project-memory/plans/【里程碑】Mac本机构建节点接入-2026-09-18.md new file mode 100644 index 000000000..728a3a31d --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Mac本机构建节点接入-2026-09-18.md @@ -0,0 +1,34 @@ +# Mac 本机构建节点接入 + +- Version: 1 +- Status: reviewed +- Date: 2026-09-18 +- Parent Spec: `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` + +## 交付与边界 + +用户已确认使用当前 Mac,允许配置本机后台 Agent、创建 Jenkins 节点与构建 Job,并触发一次验证构建。复用已有 universal 产物合同与 Windows 管线的仓库访问凭据,仅手动构建和归档,无 OSS 上传、更新签名、Apple 签名/公证或定时调度。远程 Git 推送不在本次确认内。 + +节点仅运行明确匹配 `genarrative-agc-macos` 的可信构建,单 executor;Job 禁止并发。Agent 根、workspace、原生 staging 与 target 均独立于开发 checkout,禁止在开发目录执行 npm ci、git clean/reset 或 Cargo 构建。登录用户 Agent 不等于安全沙箱,Jenkins 管理员与获准运行此 Job 的人必须受信任。登出、休眠或脱离内网将影响节点可用性。 + +## 验收 + +1. 从 Jenkins 当前控制器下载匹配的 agent.jar,Java 21 兼容检查通过,凭据留在仓库外、权限受限,不输出 secret。 +2. 节点实际 online,专用标签、EXCLUSIVE、单 executor;本地 LaunchAgent 可重启、可卸载。 +3. SCM 构建固定源码 commit,依赖从 lockfile 安装,两种原生 Codex 包 integrity 校验通过。 +4. universal Release、两架构 smoke、DMG verify 通过;归档只有安装包、摘要和非敏感来源信息。 +5. 真实 Jenkins build 为 SUCCESS 且归档存在。不以 XML 创建或本地单测冒充远端运行成功。 + +## 初始检查与风险 + +2026-09-18 当前机器具备 Java 21、两种 Rust Apple target 与 Rosetta;剩余磁盘约 11 GiB,独立 checkout 构建前须检查空间,不清理用户缓存。控制器地址连续连接超时,属于网络阶段,尚未使用认证凭据或创建后台服务。必须先恢复内网可达性;不修改本机网络路由或代理规避此限制。 + +## 编码前评审 + +只扩展现有 Jenkinsfile/应用打包脚本,不新增发布系统。用专用工作区且保持不发布,是当前明确授权内的最小闭环。代码未推送前不可把远端 master 当作已具备 universal 双资源实现;首跑源码来源必须明确记录,不能暗用开发工作树。 + +## 当前证据与阻塞 + +Jenkinsfile、锁定双架构依赖预备脚本与 archive-only CI 打包入口已在本地准备。定向 Node 测试 37 项通过,类型/配置、编码、文档索引、定向 ESLint 与 diff 检查通过;未执行完整 Jenkins 构建或在线 Groovy 校验。 + +控制器直连多次超时,经当前代理请求返回 502;Java 21 已存在,无需安装新 JDK。尚未注册节点、创建 Job、保存 Agent secret、安装 LaunchAgent 或触发远端构建。需先恢复内网连接,再按本规范验收真实节点及构建状态;不把本地代码准备描述为已经接入成功。 diff --git a/docs/project-memory/plans/【里程碑】Mac通用安装包与构建管线-2026-09-18.md b/docs/project-memory/plans/【里程碑】Mac通用安装包与构建管线-2026-09-18.md new file mode 100644 index 000000000..5319d072a --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Mac通用安装包与构建管线-2026-09-18.md @@ -0,0 +1,36 @@ +# Mac 通用安装包与构建管线 + +- Version: 1 +- Status: awaiting-user-acceptance +- Date: 2026-09-18 +- Parent Spec: `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md` + +## 当前交付门禁 + +当前仅交付 AGC 0.1.67 universal 本地 DMG,版本、Codex 0.147.0 与 macOS 15.0 下限不变。不生成私钥、不签名公证、不发布、不推送。 + +主程序含 arm64/x86_64 两个切片;macOS 资源分 `darwin-arm64` 与 `darwin-x64`,两套原生依赖保持上游相对路径和独立清单。运行切片只读取对应目录。构建必须校验两套锁定包身份、目标、版本与完整性;Windows 路径和资源不变。不合并原生 Codex 二进制,不改写其上游元数据。 + +## 验收 + +- 发布上下文接受 universal,dev-mac 的两个平台键同 URL/签名。 +- 主程序 lipo 两切片,资源检查两套原生架构、摘要和可执行位。 +- Apple Silicon 与 Rosetta 各做隔离 HOME/PATH 的真实 app-server 握手、正式程序查找与缺组件拒绝;Rosetta 不代替 Intel 真机。 +- 生成独立 universal DMG,不覆盖已有单架构 DMG;镜像校验、类型/配置/定向测试与编码检查通过。 + +## 后续管线边界 + +用户要求安装包完成后接入 Jenkins。先只读检查节点与现有 Job;创建/修改 Job、凭据或触发构建前额外确认。只有当前安装包门禁完成后再形成管线实现计划;不把凭据保存进源码、日志、计划或打包资源。 + +## 编码前评审 + +采用分架构资源而非 lipo Codex,避免破坏原生包元数据及 code-mode host、zsh 的资源寻址。macOS 共用配置明确列举两套文件,必须失败关闭,单架构诊断包也携带完整资源。现有 single-arch 选择逻辑改为同架构子目录,不涉及持久化数据迁移。 + +## 验收证据 + +- 同步至 master `9a21690fe`,本地 universal 修改无冲突恢复;未推送。 +- Tauri universal Release 构建通过,主程序 lipo 显示 arm64/x86_64,Info.plist 版本 0.1.67、最低 macOS 15.0。 +- `check-macos-bundle.mjs arm64 --universal` 与 `x86_64 --universal` 均通过:每架构资源摘要、原生身份、执行权限、正式 Codex 选择、隔离 app-server 握手、缺 code-mode host 时拒绝。x86_64 在 Rosetta 执行,尚非 Intel 真机。 +- 发布/feature/上传定向 Node 测试 33 项通过,共享 Codex 布局模块在隔离 Cargo harness 的 2 项测试通过;类型/配置、定向 ESLint、编码/文档和 diff 检查通过。 +- universal `.app` 约 672 MiB,DMG 约 290 MiB;hdiutil 完整性校验通过。未做 GUI、账号/Provider、Intel 真机或签名公证验收。 +- Jenkins 只读检查确认现有节点为 Linux 与 Windows,尚无 macOS Agent;创建管线前需用户指定并授权接入 Mac 节点。本地凭据不进入源码或验证产物。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index eca895f7d..8e90f0a37 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,9 @@ # 踩坑与排障记录 +## universal 主程序必须配套双架构原生依赖 + +AGC macOS 主程序可合并为 universal,但 Codex 原生包的 `codex-package.json`、code-mode host 和 zsh 仍有架构身份。两套包应各自保留上游布局与摘要,放入 `coding-agent/mac-native/darwin-arm64/`、`darwin-x64/`,由正在运行的主程序切片选择;不能只把主程序用 lipo 合并后复用最后一次构建的单架构资源。Tauri universal 两次 Cargo 构建共用 staging,每次都必须 stage 完整的两套资源。发布清单两个平台键同 URL/签名,只在 universal 产物上成立;Rosetta 隔离 smoke 不代替 Intel 真机验收。 + ## 生成草稿与异步展示边界必须按身份隔离 非模态生成浮层切换占位时按 draftId 分实例,卸载保留未提交/失败草稿,成功提交不再复活草稿;旧项目占位不存在时丢弃其保存回调。失败重试保留原请求输入和引用身份,引用失效不能静默过滤;修改已绑定输入须明确另起请求,不伪装成原请求重试。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 03bd90534..d9cda42ee 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -75,11 +75,11 @@ | 渠道 | 构建目标 | 清单平台键 | 更新包 | 清单地址 | | --------- | ------------------------ | ---------------------------------------------- | ------------------------ | ------------------------------------ | | `dev-win` | `x86_64-pc-windows-msvc` | `windows-x86_64` | NSIS `.exe` + `.exe.sig` | `/agc/dev-win/latest.json` | -| `dev-mac` | `aarch64-apple-darwin` 或 `x86_64-apple-darwin` | 对应 `darwin-aarch64` 或 `darwin-x86_64` | `*.app.tar.gz` + `.sig` | `/agc/dev-mac/latest.json` | +| `dev-mac` | `universal-apple-darwin` | `darwin-aarch64` + `darwin-x86_64`(同一对象) | `*.app.tar.gz` + `.sig` | `/agc/dev-mac/latest.json` | - 对象布局:清单固定写成 `agc//latest.json`;安装包与签名写成 `agc///` 与 `.sig`。 -- macOS 当前采用单架构包:Apple Silicon 使用 `aarch64-apple-darwin`,Intel 使用 `x86_64-apple-darwin`;每次生成的清单只登记本次实际构建的架构,不把单架构原生 Codex 资源挂到另一架构。`universal-apple-darwin` 在版本读取/写入、构建和清单生成之前拒绝。 -- 渠道清单以实际运行架构为键。两种单架构构建不可轮流覆盖同一个 `latest.json` 并宣称双架构均可更新;当前不实现跨构建合并,Intel 发布需先完成其构建验证与多架构清单发布方案。 +- macOS 正式交付使用 universal 主程序,同时携带分目录的 arm64/x64 原生 Codex 组件;每个组件保持上游单架构布局与独立 SHA-256 清单,运行中的主程序切片只选择同架构目录。不得把两套原生包的元数据或辅助程序混装。 +- universal 更新包的两个平台键指向同一个 `.app.tar.gz` 和签名。单架构构建仅登记本架构供诊断,不轮流覆盖 dev-mac 正式清单;正式双架构发布必须构建 universal。 - 构建期要求:打开 `bundle.createUpdaterArtifacts` 以生成 `.sig`;构建环境提供签名私钥与密码(私钥内容不得入库);公钥写入客户端配置。公钥在首个带更新能力的版本发布后不可更换,更换等于放弃自动更新(只能手动重装)。 - 版本递增按渠道独立进行:发布脚本读取该渠道远端 `latest.json` 的 `version`,与本地版本取较高者递增 patch;两个渠道的版本号互不影响。 - 版本高水位:发布脚本取「渠道清单版本」与「旧协议迁移指针版本」(迁移窗口内)中的较大值再递增。只看渠道清单会在渠道启用初期把版本链改小 —— 2026-09-17 首次渠道发布即把旧指针的 0.1.57 退回 0.1.48,随后以显式 0.1.60 纠偏;迁移窗口结束(旧指针 404)后自动只剩渠道清单,`dev-mac` 不参与旧指针比较。 @@ -106,7 +106,7 @@ | 条款 | 验收方式 | 证据 | | ---------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | 渠道与端点映射、渠道校验 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过(默认渠道、错配失败关闭、未知渠道失败关闭) | -| macOS 单架构清单与 universal 拒绝 | 定向发布脚本测试 | 单架构各用对应平台键;拒绝未闭合的 universal 发布 | +| macOS universal 清单与双架构依赖 | 定向发布脚本与安装包验证 | 两个平台键同 URL/签名;两套资源独立校验,真机验收另记 | | 缺签名时失败关闭 | 同上 | 通过 | | 开发态不检查更新 | `vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts` | 通过(开关关闭时不请求清单) | | 旧自研链路整条删除 | 代码检索无残留命令、事件与白名单条目 | 通过(`download_agc_update` / 下载事件 / 清单常量均无残留) | @@ -128,7 +128,7 @@ 已决策: -- macOS 采用单架构包,只登记实际构建架构;Intel 真机构建与跨架构清单合并未验收,不公开宣称双架构分发就绪。 +- macOS 采用 universal 包,两个平台键对应同一更新产物;主程序用 lipo 检查两种架构,Codex 资源分别做原生身份/摘要与启动验证。Rosetta 结果不代替 Intel 真机验收。 - 旧客户端迁移桥:保留一个版本周期。渠道清单上线后,发布管线同时把旧的 `agc/latest.json`(sha256 格式)指向 `dev-win` 最新安装包,让已发布客户端自动升级到新协议;下个周期整条删除。 - 签名密钥:由本仓库维护者生成并保管,私钥保存在仓库外(`%USERPROFILE%\.tauri\genarrative-agc-updater.key`),只有公钥进入客户端配置;Jenkins 用受保护凭据 `AgcUpdaterSigningKey` 与 `AgcUpdaterSigningKeyPassword` 注入为 Tauri 打包器读取的 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,本机可用 `TAURI_SIGNING_PRIVATE_KEY_PATH` 指向同一私钥。当前密钥不带密码;首次发布前仍可重新生成,首次发布后不可更换。 - macOS 发布方式:`dev-mac` 产物在本机 mac 上执行发布入口上传,Jenkins 暂不新增 macOS 节点;macOS 代码签名与公证凭据未确认前,相关闭环记为未验证项,不静默通过。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index aa1af47d2..eeae67991 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -392,11 +392,11 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 调度边界:正式 DAG、manifest、Agent task/session/run 身份、队列、锁、委派、all-join、完成门、Provider lifecycle、持久 retry/handoff 与 `needs-reconciliation` 继续由现有 AGC Runtime 掌控。每个被调度节点在 `codex_cli` 模式下直接启动一次非交互 `codex exec` 充当该节点的推理 Agent;Codex 返回当前 Runtime 广告函数的结构化调用,Runtime 仍是唯一 ToolHost,不允许 CLI 自己写项目、执行命令、调用 MCP 或形成第二套 revision / verification 真相。 - 安装包侧车:Windows x64 release 固定随 Tauri resource 打包 `@openai/codex@0.147.0` 的原生 `codex.exe`;Rust build script 从 AGC 子包锁定依赖 stage 到 resource,并写入版本与 SHA-256 清单。Windows 侧车映射只写入 `tauri.windows.conf.json`,通用 `tauri.conf.json` 不得让 Linux / macOS 构建依赖未生成的 Windows 二进制。运行时只在文件摘要和 `codex-cli` 版本同时匹配清单时优先选内置侧车;缺失、损坏或版本漂移时跳过它,按既有 npm 安装、PATH 顺序回退。安装包同时携带 Apache-2.0 第三方声明;API Key、`auth.json`、Cookie、Token、用户 `CODEX_HOME`、用户配置和项目数据绝不打包。 - Windows x64 release 安装包只生成 NSIS,不生成 MSI:`tauri.windows.conf.json` 的 `bundle.targets` 固定为 `["nsis"]`,通用配置继续保留其它平台的默认打包目标。安装后的产品名、开始菜单 / 桌面快捷方式和 EXE 产品描述统一由 `tauri.conf.json` 的 `productName: "陶泥儿"` 生成;应用 identifier 与内部可执行文件名保持稳定。内置 Codex 资源安装到顶层 `coding-agent/win-x64/`,运行时从同一路径查找 `bin/codex.exe` 与 `manifest.json`;仓库 staging 仍使用 `resources/codex/win-x64/`,包内子目录、组件名、版本和完整性校验保持原合同。 -- macOS 单架构安装包同样必须携带锁定版本的原生 Codex、`codex-code-mode-host`、`rg`、上游 zsh、`codex-package.json` 和第三方声明,保留上游相对布局;构建时按 Cargo 目标选择 npm 原生依赖,缺文件、版本或目标不匹配立即失败,不借用开发机 PATH 里的 Codex。资源只在 `tauri.macos.conf.json` 映射到 `Contents/Resources/coding-agent/mac-native/`。构建与运行共享平台文件白名单,运行时由当前 `.app/Contents/MacOS` 定位相邻 `Resources`,完整性与版本验证通过后优先使用内置组件;失败沿既有外部安装回退,不能运行未校验的内置文件。单架构资源不能冒充 universal 包。 +- macOS 安装包必须携带锁定版本的原生 Codex、`codex-code-mode-host`、`rg`、上游 zsh、`codex-package.json` 和第三方声明,保留上游相对布局;构建时按 Cargo 目标选择 npm 原生依赖,缺文件、版本或目标不匹配立即失败,不借用开发机 PATH 里的 Codex。资源只在 `tauri.macos.conf.json` 映射到 `Contents/Resources/coding-agent/mac-native/darwin-arm64/` 与 `darwin-x64/`。构建与运行共享平台文件白名单,运行时由当前 `.app/Contents/MacOS` 定位相邻 `Resources`,完整性与版本验证通过后优先使用内置组件;失败沿既有外部安装回退,不能运行未校验的内置文件。universal 主程序同时携带两套独立原生资源,运行切片按 Cargo 目标只选择对应目录;macOS 构建必须预备两套锁定原生依赖,不读取全局 Codex。 - 内置插件的清单、运行入口与面板同时在 Windows/macOS 随包分发,继续由既有 PluginHost 的应用资源目录扫描入口发现;不携带开发依赖、缓存、测试或私有配置。插件文件随包不等于原生适配器跨平台:Cocos 进程桥接仍受现有 Windows 实现和 feature 门禁约束,macOS 原生桥接另行设计与验收,不复制 Windows DLL 冒充支持。系统 Node、用户 Cocos Creator、账号登录、网络和生成工程的 npm 工具链仍是现有外部前提,不在此次 Codex 侧车补齐中隐式变更。 - macOS 安装包验收必须包括:脱离仓库位置的 `.app` 资源与架构检查、受限 PATH/隔离 HOME 下内置 Codex 启动和 app-server 握手、必需文件缺失/篡改/平台错误的拒绝测试,以及 DMG 完整性检查。真实登录、Provider 对话、GUI 和 Cocos 操作必须独立列出证据,不能用压缩包生成或 `--version` 成功替代。未配置正式签名、公证的本地测试包不得作为公开发行包。 - macOS 安装包的系统下限取主程序和全部原生组件中的最高要求;锁定 Codex 0.147.0 原生依赖所携带的 zsh 要求 macOS 15.0,因此 `bundle.macOS.minimumSystemVersion` 明确为 `15.0`。更新原生依赖时重新检查 Mach-O 的系统下限,不能只按 AGC 主程序宣称兼容版本。 -- 发布链路的目标解析和单架构清单以《AGC客户端更新检查与下载》为准:CLI 目标优先,版本、构建、端点、bundle 与更新清单共用单一发布上下文。插件能力以《AGC通用插件宿主与编辑器适配》为准:无已注册 Cocos 原生适配器时隐藏且拒绝启动,前端自动启动只消费后端可用性投影。 +- 发布链路的目标解析和 universal 清单以《AGC客户端更新检查与下载》为准:CLI 目标优先,版本、构建、端点、bundle 与更新清单共用单一发布上下文。插件能力以《AGC通用插件宿主与编辑器适配》为准:无已注册 Cocos 原生适配器时隐藏且拒绝启动,前端自动启动只消费后端可用性投影。 - CLI 安全边界:CLI 固定使用 argv 启动,禁止 shell 拼接;工作目录使用本次请求专用的空临时目录,不把游戏项目绝对路径写入 prompt、stdout、stderr 或持久记录。调用固定使用 ephemeral、忽略用户配置和 exec rules、read-only sandbox、never approval,并关闭 Codex shell tool;只继承 CLI 运行和认证所需的最小环境,显式移除宿主 `CODEX_API_KEY`。用户级 Codex 登录态继续由本机 Codex 自己读取,API Key、auth 文件、Cookie、Token、`CODEX_HOME` 私有内容不得复制到项目配置、Runtime sidecar、Agent DB、conversation 或日志;stdout / stderr 无换行时也受硬上限约束,stderr 诊断只记录固定分类、字节数和 SHA-256。 - 协议边界:Runtime 把既有 `LlmRunRequest` 的消息和当前函数目录编码为有界 prompt,并从同一函数 JSON Schema 生成 Codex structured-output schema。CLI 输出转换为现有 `LlmRunResponse / LlmToolCall` 后,继续经过 native tool / MCP 参数校验、动作上限、权限、pending、receipt、验证与格式修复链;最终回复仍走唯一提交路径,不新增平行响应协议。 - 取消与恢复:Codex 子进程绑定当前 Provider request lifecycle,取消、暂停、Runner draining 或 GUI owner 丢失时终止并回收当前进程;started 后没有可信终态仍沿现有 Provider reconciliation 处理。`agentMode`、CLI 可执行身份和影响输出的 Codex 参数进入 `providerConfigFingerprint`,模式切换不得消费另一模式遗留的 retry/handoff。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 92e4d4fc6..f20a47532 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -716,6 +716,14 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. `Genarrative-Web-Build` 打包 `web.tar.gz` 前、`Genarrative-Web-Deploy` 解包后都会把 Web 静态目录规范为目录 `755`、文件 `644`。如果前端页面能打开但 public 图片、字体或音频返回 `403 Forbidden`,优先检查当前 `/srv/genarrative/web` 指向的 release 中对应文件权限是否被异常归档为 `600`,临时恢复可对该 release 的 `web` 目录执行目录 `755`、文件 `644` 的权限修正。 +### 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。 + +该 Job 的目标仅为构建并归档:保留源码版本、不读取远端版本、不注入发布私钥/OSS 凭据、不运行 release upload。执行 `npm ci` 后使用锁文件校验并补齐两种 macOS Codex 原生依赖,再调用 `scripts/build-macos-ci.mjs`(AGC 应用目录下)生成 universal app 与 DMG、分别运行 arm64/x86_64 隔离 smoke。归档限 `artifacts/` 下的 DMG、SHA-256、非敏感构建清单和源码 commit;不归档用户 HOME、Jenkins secret、原始工作目录或全量日志。 + +当前用户的 LaunchAgent 受登录、休眠与局域网连通性影响,不能视为长期无人值守构建机。该用户进程也不是权限沙箱,只能承接受信任仓库和 Job;不能把匹配标签当作隔离恶意构建的措施。节点注册、上线和首个 Job 的成功必须以控制器实时状态确认,脚本入库不代表管线已经接通。 + ### CI 宿主 CPU 上限(Jenkins 16 核 / Gitea Actions runner 12 核) `genarrative-station` 上 Jenkins Built-In Node 与 Gitea Actions runner 容器共用同一台 32 逻辑核宿主机,两路 CI 都必须有硬上限,避免构建期把机器顶满、让交互用户卡顿。Jenkins 固定 16 核(50%):在宿主执行 `systemctl set-property jenkins.service CPUQuota=1600%`,立即生效且不需要重启 Jenkins,drop-in 落在 `/etc/systemd/system.control/jenkins.service.d/50-CPUQuota.conf`;该配额覆盖 Built-In Node 上所有子构建(Web / Api / Stdb 的 `npm ci`、Vitest、`tsc`、`cargo` 都跑在这台机器上),Deploy 阶段在远端 `genarrative-dev-deploy` / `genarrative-release-deploy` agent 执行,不受该上限约束。Gitea Actions runner 固定 12 核(37.5%):`/opt/gitea-stack/compose.yml` 的 `runner.cpus` 为 `"12.0"`,调整运行中的容器用 `docker update --cpus=12 gitea-runner`(不重建容器、不中断在跑 job);需要让容器配置与 compose 完全一致时,先确认 Gitea 没有 `in_progress` run,再 `cd /opt/gitea-stack && docker compose up -d runner`。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build new file mode 100644 index 000000000..672f2906e --- /dev/null +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build @@ -0,0 +1,94 @@ +pipeline { + agent { label 'genarrative-agc-macos' } + options { + disableConcurrentBuilds() + skipDefaultCheckout(true) + timeout(time: 90, unit: 'MINUTES') + buildDiscarder(logRotator(numToKeepStr: '10', artifactNumToKeepStr: '3')) + timestamps() + } + parameters { + string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '必须含 universal 双架构依赖实现的受信任分支') + string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,固定属于源码分支的提交;不递增应用版本') + } + environment { + GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git' + GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh' + AGC_UPDATE_CHANNEL = 'dev-mac' + RUSTC_WRAPPER = '' + CARGO_BUILD_JOBS = '4' + } + stages { + stage('Checkout') { + steps { + // 由节点配置 AGC_AGENT_ROOT;不允许把开发工作树当作 Jenkins workspace。 + sh ''' + set -eu + test -n "$AGC_AGENT_ROOT" + case "$WORKSPACE" in "$AGC_AGENT_ROOT"/workspace/*) ;; *) echo '拒绝非专用 Agent 工作区'; exit 1;; esac + test "$(uname -s)" = Darwin + git check-ref-format --branch "$SOURCE_BRANCH" >/dev/null + ''' + withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GIT_SSH_KEY', usernameVariable: 'GIT_SSH_USER')]) { + sh ''' + set -eu + export GIT_SSH_COMMAND="ssh -i \\"$GIT_SSH_KEY\\" -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" + if [ ! -d .git ]; then + git init + git remote add origin "$GIT_REMOTE_URL" + fi + test "$(git remote get-url origin)" = "$GIT_REMOTE_URL" + git fetch --no-tags origin "+refs/heads/$SOURCE_BRANCH:refs/remotes/origin/$SOURCE_BRANCH" + 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 + test "${#COMMIT_HASH}" -ge 7 && test "${#COMMIT_HASH}" -le 40 + git cat-file -e "$COMMIT_HASH^{commit}" + git merge-base --is-ancestor "$COMMIT_HASH" "$ref" + ref="$COMMIT_HASH" + fi + git reset --hard "$ref" + # 不清除 node_modules/target 缓存;被跟踪内容始终来自上述 commit。 + git clean -fd + git rev-parse HEAD > .jenkins-source-commit + ''' + } + } + } + stage('Toolchain and dependencies') { + steps { + sh ''' + set -eu + test "$(uname -m)" = arm64 + node --version + npm --version + cargo --version + xcrun --find clang + xcrun --find lipo + arch -x86_64 /usr/bin/uname -m + rustup target add aarch64-apple-darwin x86_64-apple-darwin + npm ci --no-audit --no-fund + node apps/ai-game-creator-shell/scripts/prepare-macos-codex.mjs + ''' + } + } + stage('Validate and build universal') { + steps { + sh ''' + set -eu + node --test apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs + node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs + node apps/ai-game-creator-shell/scripts/build-macos-ci.mjs + ''' + } + } + stage('Archive only') { + steps { + archiveArtifacts artifacts: 'artifacts/*.dmg,artifacts/*.sha256,artifacts/build-manifest.json,.jenkins-source-commit', fingerprint: true, allowEmptyArchive: false + } + } + } + post { + success { echo 'macOS universal 本地测试包已构建并归档;未上传 OSS、未正式签名/公证。' } + } +} -- 2.52.0 From 762f037150e9305cfc8048562ddb75ff8630e07a Mon Sep 17 00:00:00 2001 From: suzmii Date: Fri, 18 Sep 2026 21:57:52 +0800 Subject: [PATCH 02/16] =?UTF-8?q?=E4=BF=AE=E5=A4=8DMac=20Jenkins=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=B7=A5=E4=BD=9C=E5=8C=BA=E6=A0=B9=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除不兼容的节点环境变量配置依赖 在Jenkinsfile中使用专用Agent根目录默认值 避免节点分配阶段环境变量属性解析失败 --- jenkins/Jenkinsfile.ai-game-creator-shell-macos-build | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build index 672f2906e..248d78dd2 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build @@ -24,7 +24,7 @@ pipeline { // 由节点配置 AGC_AGENT_ROOT;不允许把开发工作树当作 Jenkins workspace。 sh ''' set -eu - test -n "$AGC_AGENT_ROOT" + 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 test "$(uname -s)" = Darwin git check-ref-format --branch "$SOURCE_BRANCH" >/dev/null -- 2.52.0 From fc46cabb755e7e2f8b8544835e0d60995663f726 Mon Sep 17 00:00:00 2001 From: suzmii Date: Fri, 18 Sep 2026 22:34:49 +0800 Subject: [PATCH 03/16] =?UTF-8?q?=E8=A1=A5=E5=85=85Mac=20Jenkins=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=B7=A5=E5=85=B7=E9=93=BE=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为LaunchAgent构建环境注入Node、npm、Cargo和Homebrew路径 --- jenkins/Jenkinsfile.ai-game-creator-shell-macos-build | 1 + 1 file changed, 1 insertion(+) diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build index 248d78dd2..bd83cd587 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build @@ -17,6 +17,7 @@ pipeline { AGC_UPDATE_CHANNEL = 'dev-mac' RUSTC_WRAPPER = '' CARGO_BUILD_JOBS = '4' + PATH = '/Users/suzmii/.local/bin:/Users/suzmii/.cargo/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin' } stages { stage('Checkout') { -- 2.52.0 From 237e440057f0a1ff71a3770366c659c1c98690ca Mon Sep 17 00:00:00 2001 From: suzmii Date: Sat, 19 Sep 2026 16:55:00 +0800 Subject: [PATCH 04/16] =?UTF-8?q?Mac=20Jenkins=E8=8A=82=E7=82=B9=E6=8E=A5?= =?UTF-8?q?=E5=85=A5sccache=E7=BC=96=E8=AF=91=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原因:macOS universal 构建此前在 Jenkinsfile 与 build-macos-ci.mjs 两处把 RUSTC_WRAPPER 清空,依赖 crate 每次从零编译且双架构各编译一遍,逼近 90 分钟超时。 改动: - Jenkinsfile 对齐 Linux 生产管线:RUSTC_WRAPPER=sccache、CARGO_INCREMENTAL=0、 SCCACHE_CACHE_SIZE=20G,SCCACHE_DIR 固定到稳定缓存根 ${HOME}/caches/genarrative-jenkins/agc-macos/sccache(可用 GENARRATIVE_AGC_MACOS_CACHE_ROOT 覆盖),并输出 sccache --show-stats 供判断命中率。 - build-macos-ci.mjs 不再清空 RUSTC_WRAPPER,wrapper 由管线显式声明。 - 保留 CARGO_TARGET_DIR 指向 workspace 内 target,跨构建继续复用增量产物。 - 文档补充缓存根、覆盖变量与回退语义。 验证:sh -n/bash -n 通过;管线同款 node --test 34 项全通过;本地探针证明空缓存 首次 miss 编译、清空 target 后再次构建命中缓存不再编译。 --- .../scripts/build-macos-ci.mjs | 4 +++- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 ++ ...kinsfile.ai-game-creator-shell-macos-build | 23 ++++++++++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) 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 acafcc0fd..be57f58a0 100644 --- a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs +++ b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs @@ -31,7 +31,9 @@ assert.ok( for (const key of Object.keys(process.env)) { if (/^(TAURI_SIGNING_|APPLE_|AGC_OSS_)/u.test(key)) delete process.env[key]; } -process.env.RUSTC_WRAPPER = ''; +// RUSTC_WRAPPER / SCCACHE_DIR / SCCACHE_CACHE_SIZE / CARGO_INCREMENTAL 由 Jenkinsfile 显式提供: +// 依赖 crate 命中 sccache 对象缓存,避免 universal 双架构每次从零编译; +// 本入口不再清空 wrapper,以免与管线声明冲突。若节点未装 sccache,由管线回退到真实 rustc。 process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target'); const context = resolveReleaseContext(['--target=universal-apple-darwin']); const args = [ diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index f20a47532..33268a13a 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -722,6 +722,8 @@ Mac universal 构建脚本位于 `jenkins/Jenkinsfile.ai-game-creator-shell-maco 该 Job 的目标仅为构建并归档:保留源码版本、不读取远端版本、不注入发布私钥/OSS 凭据、不运行 release upload。执行 `npm ci` 后使用锁文件校验并补齐两种 macOS Codex 原生依赖,再调用 `scripts/build-macos-ci.mjs`(AGC 应用目录下)生成 universal app 与 DMG、分别运行 arm64/x86_64 隔离 smoke。归档限 `artifacts/` 下的 DMG、SHA-256、非敏感构建清单和源码 commit;不归档用户 HOME、Jenkins secret、原始工作目录或全量日志。 +该 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 重建后无改动也触发近似冷构建。节点未安装 sccache 时管线打印提示并回退到真实 `rustc`,不阻断构建。universal 双架构各自独立编译,依赖 crate 的复用收益约为单架构的两倍;命中情况由构建末尾的 `sccache --show-stats` 输出,供判断是否需要预热或调整缓存上限。 + 当前用户的 LaunchAgent 受登录、休眠与局域网连通性影响,不能视为长期无人值守构建机。该用户进程也不是权限沙箱,只能承接受信任仓库和 Job;不能把匹配标签当作隔离恶意构建的措施。节点注册、上线和首个 Job 的成功必须以控制器实时状态确认,脚本入库不代表管线已经接通。 ### CI 宿主 CPU 上限(Jenkins 16 核 / Gitea Actions runner 12 核) diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build index bd83cd587..5e02d5872 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build @@ -15,7 +15,13 @@ pipeline { GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git' GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh' AGC_UPDATE_CHANNEL = 'dev-mac' - RUSTC_WRAPPER = '' + // 与 Linux 生产管线(api-build / stdb-module-build)对齐:Rust 编译走 sccache 对象缓存。 + // 本 Job 是 universal 双架构(aarch64 + x86_64)各自独立编译,缓存收益约为单架构的两倍。 + // 缓存根固定在 HOME 下的稳定目录,避免 WORKSPACE 重建导致近似冷构建;可用下面的变量覆盖。 + GENARRATIVE_AGC_MACOS_CACHE_ROOT = 'caches/genarrative-jenkins/agc-macos' + RUSTC_WRAPPER = 'sccache' + SCCACHE_CACHE_SIZE = '20G' + CARGO_INCREMENTAL = '0' CARGO_BUILD_JOBS = '4' PATH = '/Users/suzmii/.local/bin:/Users/suzmii/.cargo/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin' } @@ -67,6 +73,7 @@ pipeline { xcrun --find clang xcrun --find lipo arch -x86_64 /usr/bin/uname -m + if command -v sccache >/dev/null 2>&1; then sccache --version; else echo '[agc-macos] 未找到 sccache;本次回退到 rustc 直接构建。'; fi rustup target add aarch64-apple-darwin x86_64-apple-darwin npm ci --no-audit --no-fund node apps/ai-game-creator-shell/scripts/prepare-macos-codex.mjs @@ -77,9 +84,23 @@ pipeline { steps { sh ''' set -eu + cache_root="${GENARRATIVE_AGC_MACOS_CACHE_ROOT:-caches/genarrative-jenkins/agc-macos}" + case "${cache_root}" in /*) ;; *) cache_root="${HOME:?HOME 不能为空}/${cache_root}" ;; esac + if command -v sccache >/dev/null 2>&1; then + export SCCACHE_DIR="${cache_root}/sccache" + mkdir -p "${SCCACHE_DIR}" + echo "[agc-macos] sccache 缓存目录: ${SCCACHE_DIR}" + else + echo '[agc-macos] 未找到 sccache,改用 rustc 直接构建。' + unset RUSTC_WRAPPER + fi node --test apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs node apps/ai-game-creator-shell/scripts/build-macos-ci.mjs + if command -v sccache >/dev/null 2>&1; then + echo '[agc-macos] sccache 统计(自 server 启动累计):' + sccache --show-stats 2>&1 | sed -n '1,6p' + fi ''' } } -- 2.52.0 From 261228ed3f839784cfb9c9181c9d000b5ba51247 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sat, 19 Sep 2026 17:03:18 +0800 Subject: [PATCH 05/16] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20universal=20?= =?UTF-8?q?=E9=A6=96=E8=A3=85=20DMG=20=E9=80=89=E6=8B=A9=E4=BB=A5=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E4=B8=BB=E7=BA=BF=E6=96=B0=E5=A2=9E=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 origin/master 后,e3682fd06 新增的 selectFirstInstallArtifact 对 universal-apple-darwin 会推导出架构 x64,匹配不到本管线产出的 陶泥儿__universal.dmg,createUpdateManifest 直接抛 「首装 DMG 必须唯一匹配本次版本 x 和架构 x64,找到 0 个」。 - build-release.mjs:为 universal-apple-darwin 增加分支,按 __universal.dmg 唯一匹配(与 build-macos-ci.mjs 的命名一致) - build-release.test.mjs:createDmgFixture 支持 universal 命名;universal 用例 补首装包断言,锁定两个平台键共用同一个 DMG 验证:管线同款 node --test 33/33 通过(合并前 32/33);prepare-macos-codex 与 release-oss 用例同跑通过。 --- .../scripts/build-release.mjs | 12 +++++++++++ .../scripts/build-release.test.mjs | 20 +++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 8e77f8295..ea65c7028 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -510,6 +510,18 @@ export function selectFirstInstallArtifact( if (!selected?.endsWith('.exe')) { throw new Error('Windows 首装包必须复用本次 NSIS .exe 更新包'); } + } else if (target === 'universal-apple-darwin') { + // universal 主程序只产出一个 DMG,aarch64 与 x86_64 首装共用它(命名见 build-macos-ci.mjs)。 + const suffix = `_${version}_universal.dmg`; + const candidates = files.filter((file) => + path.basename(file).endsWith(suffix), + ); + if (candidates.length !== 1) { + throw new Error( + `首装 DMG 必须唯一匹配本次版本 ${version} 的 universal 产物,找到 ${candidates.length} 个`, + ); + } + selected = candidates[0]; } else { // Tauri DMG 文件名使用 aarch64 / x64,而 updater 的 Intel 平台键是 x86_64。 const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64'; diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index ec5455806..a3ee68627 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -44,7 +44,11 @@ const packageVersion = JSON.parse( ).version; function createDmgFixture(root, target, version = packageVersion) { - const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64'; + const architecture = target.startsWith('aarch64') + ? 'aarch64' + : target === universalTarget + ? 'universal' + : 'x64'; const dmg = path.join(root, `陶泥儿_${version}_${architecture}.dmg`); writeFileSync(dmg, 'first installation disk image'); return dmg; @@ -440,7 +444,13 @@ test('universal uses the Mac channel and the same signed artifact for both archi assert.equal(context.channel, 'dev-mac'); assert.ok(context.bundleRoot.includes(universalTarget)); withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => { - const manifest = createUpdateManifest(artifact, context); + const manifest = createUpdateManifest(artifact, { + ...context, + downloadArtifact: createDmgFixture( + path.dirname(artifact), + universalTarget, + ), + }); assert.deepEqual(Object.keys(manifest.platforms), [ 'darwin-aarch64', 'darwin-x86_64', @@ -450,6 +460,12 @@ test('universal uses the Mac channel and the same signed artifact for both archi manifest.platforms['darwin-x86_64'], ); assert.match(manifest.platforms['darwin-aarch64'].url, /\/dev-mac\//); + // 两个平台键共用同一个 universal 首装包,不能要求出两份架构 DMG。 + assert.deepEqual( + manifest.downloads['darwin-aarch64'].url, + manifest.downloads['darwin-x86_64'].url, + ); + assert.match(manifest.downloads['darwin-aarch64'].url, /_universal\.dmg$/u); }); }); -- 2.52.0 From b1cadd0cc817c411d791d2952cb9d7f44741fc34 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sat, 19 Sep 2026 17:49:25 +0800 Subject: [PATCH 06/16] =?UTF-8?q?Mac=20=E6=B8=A0=E9=81=93=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=9B=B4=E6=96=B0=E5=8C=85=E7=AD=BE=E5=90=8D=E3=80=81?= =?UTF-8?q?OSS=20=E4=B8=8A=E4=BC=A0=E4=B8=8E=E6=B8=A0=E9=81=93=E6=B8=85?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:mac 侧此前只有 archive-only 构建,dev-mac 渠道不产出更新包、不上传、无渠道 清单,客户端拿不到 macOS 更新(里程碑曾因签名/公证凭据未就绪而暂缓)。 改动: - build-macos-ci.mjs 改为 dev-mac 发布入口:开启 createUpdaterArtifacts 产出 .app.tar.gz + .sig,生成 universal DMG 与渠道清单 latest.json,构建后用产物内 烘焙的公钥复核签名,再按 AGC_RELEASE_DRY_RUN 决定是否上传 OSS - 新增 verify-updater-signature.mjs(minisign ED 预哈希校验)+ 单测:验签失败或 keyId 不一致立即失败关闭,绝不写 OSS - Jenkinsfile:新增 AGC_RELEASE_VERSION / AGC_RELEASE_DRY_RUN(默认开启)/ AGC_UPDATE_RELEASE_NOTES / OSSUTIL_BIN 参数;withCredentials 注入 AgcUpdaterSigningKey(+密码) 与 AliyunAccessKeyId/Secret;归档补 latest.json、 .sig 与更新摘要 - 既有单测口径更新:archive-only → 发布型,并新增「必须先验签再上传」守卫 - 文档:技术方案、里程碑、运维文档同步;Apple 签发与公证暂缺显式记录为 未验证项(--no-sign、appleSigned=false、notarized=false) 验证:单测 11/11 + 42/42;Jenkinsfile 四个 sh 块语法通过;伪造 bundle 端到端 验证清单(两平台同 URL/同签名、universal 首装包被正确选中)、真实 Tauri 签名 验签通过、错钥匙报 keyId 不一致、篡改报校验失败、dry-run 上传计划顺序正确。 --- .../scripts/build-macos-ci.mjs | 153 ++++++++++++--- .../scripts/prepare-macos-codex.test.mjs | 33 +++- .../scripts/verify-updater-signature.mjs | 177 +++++++++++++++++ .../scripts/verify-updater-signature.test.mjs | 182 ++++++++++++++++++ ...里程碑】AGC macOS渠道更新落地-2026-09-17.md | 6 +- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 7 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 6 +- ...kinsfile.ai-game-creator-shell-macos-build | 83 +++++--- 8 files changed, 589 insertions(+), 58 deletions(-) create mode 100644 apps/ai-game-creator-shell/scripts/verify-updater-signature.mjs create mode 100644 apps/ai-game-creator-shell/scripts/verify-updater-signature.test.mjs 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 be57f58a0..5e69101a8 100644 --- a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs +++ b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs @@ -6,8 +6,28 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { resolveReleaseContext, runTauriBuild } from './build-release.mjs'; +import { + generateUpdateManifest, + prepareReleaseVersion, + resolveReleaseContext, + runTauriBuild, +} from './build-release.mjs'; +import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs'; +import { + readUpdaterPubkey, + verifyUpdaterSignature, +} from './verify-updater-signature.mjs'; +/** + * AGC macOS 渠道(dev-mac)发布入口:构建 universal 包 → 双架构 smoke → 生成 universal DMG + * → 生成渠道清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。 + * + * 边界: + * - Apple 签名与公证暂缺,产物为未签名 + 未公证(`--no-sign`),必须显式记录而非静默通过; + * - 更新包签名(TAURI_SIGNING_PRIVATE_KEY,minisign)是硬需求:缺了客户端一律拒绝安装, + * 因此构建前要求凭据存在,构建后用内置公钥复核 `.sig` 才允许继续上传; + * - 未通过验签绝不写 OSS:上传顺序为更新包、签名、首装包,全部成功后才覆盖渠道清单指针。 + */ const appRoot = fileURLToPath(new URL('..', import.meta.url)); const repoRoot = path.resolve(appRoot, '../..'); assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行'); @@ -27,27 +47,44 @@ assert.ok( '构建前至少需要 8 GiB 可用空间;禁止自动清理开发缓存', ); -// 本入口永不发布,不使用 Agent 用户可能持有的发布或 Apple 认证环境。 +// 仅剥离 Apple 签名/公证变量:本节点没有证书,误用只会让构建失败; +// 更新包签名与 OSS 凭据必须保留,它们是本入口发布能力的组成部分。 for (const key of Object.keys(process.env)) { - if (/^(TAURI_SIGNING_|APPLE_|AGC_OSS_)/u.test(key)) delete process.env[key]; + if (/^APPLE_/u.test(key)) delete process.env[key]; } -// RUSTC_WRAPPER / SCCACHE_DIR / SCCACHE_CACHE_SIZE / CARGO_INCREMENTAL 由 Jenkinsfile 显式提供: -// 依赖 crate 命中 sccache 对象缓存,避免 universal 双架构每次从零编译; -// 本入口不再清空 wrapper,以免与管线声明冲突。若节点未装 sccache,由管线回退到真实 rustc。 +assert.ok( + process.env.TAURI_SIGNING_PRIVATE_KEY?.length > 0 || + process.env.TAURI_SIGNING_PRIVATE_KEY_PATH?.length > 0, + '缺少更新包签名私钥(TAURI_SIGNING_PRIVATE_KEY / _PATH):无签名的更新包会被客户端拒绝,禁止继续', +); + +const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev'; +const endpoint = + process.env.AGC_OSS_ENDPOINT?.trim() || 'oss-rg-china-mainland.aliyuncs.com'; +if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) { + throw new Error('OSS bucket 或 endpoint 配置无效'); +} +process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`; +const dryRun = readReleaseDryRun(); + process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target'); const context = resolveReleaseContext(['--target=universal-apple-darwin']); +const version = await prepareReleaseVersion(context); + const args = [ '--target=universal-apple-darwin', '--bundles', 'app', '--ci', '--no-sign', + // 基础配置已开启;这里显式声明,避免被其它配置来源关掉后静默失去更新能力。 '--config', - '{"bundle":{"createUpdaterArtifacts":false}}', + '{"bundle":{"createUpdaterArtifacts":true}}', ]; const command = (binary, argv, options = {}) => execFileSync(binary, argv, { cwd: repoRoot, stdio: 'inherit', ...options }); runTauriBuild(args, context); + const app = path.join(context.bundleRoot, 'macos/陶泥儿.app'); for (const architecture of ['arm64', 'x86_64']) { command(process.execPath, [ @@ -57,15 +94,11 @@ for (const architecture of ['arm64', 'x86_64']) { '--universal', ]); } -const version = JSON.parse( - fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'), -).version; -assert.match(version, /^\d+\.\d+\.\d+$/u); -const artifacts = path.join(repoRoot, 'artifacts'); -// 只清理本 Job 的归档输出,不能把上次 DMG 当成本次成功产物。 -fs.rmSync(artifacts, { recursive: true, force: true }); -fs.mkdirSync(artifacts, { recursive: true }); -const dmg = path.join(artifacts, `陶泥儿_${version}_universal.dmg`); + +// 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 stage = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-ci-dmg-')); try { command('ditto', [app, path.join(stage, '陶泥儿.app')]); @@ -84,12 +117,56 @@ try { } finally { fs.rmSync(stage, { recursive: true, force: true }); } -const hash = createHash('sha256'); -for await (const chunk of fs.createReadStream(dmg)) hash.update(chunk); -fs.writeFileSync( - `${dmg}.sha256`, - `${hash.digest('hex')} ${path.basename(dmg)}\n`, + +const release = await generateUpdateManifest(context); +assert.equal( + path.resolve(release.downloadArtifact), + path.resolve(dmg), + '首装包必须锁定本次生成的 universal DMG', ); + +// 上传前门禁:用产物里烘焙的公钥复核更新包签名。验不过就停在这里,绝不写 OSS。 +const signature = verifyUpdaterSignature({ + artifactPath: release.artifact, + signaturePath: `${release.artifact}.sig`, + pubkey: readUpdaterPubkey(), +}); +console.log( + `[agc-macos] 更新包签名校验通过:alg=${signature.algorithm},keyId=${signature.keyId}`, +); + +const artifacts = path.join(repoRoot, 'artifacts'); +// 只清理本 Job 的归档输出,不能把上次 DMG 当成本次成功产物。 +fs.rmSync(artifacts, { recursive: true, force: true }); +fs.mkdirSync(artifacts, { recursive: true }); +const sha256 = (file) => { + const hash = createHash('sha256'); + hash.update(fs.readFileSync(file)); + return hash.digest('hex'); +}; +const dmgHash = sha256(dmg); +fs.writeFileSync(`${dmg}.sha256`, `${dmgHash} ${path.basename(dmg)}\n`); + +const uploadPlan = uploadReleaseArtifacts(release, { + bucket, + endpoint, + binary: process.env.OSSUTIL_BIN?.trim() || 'ossutil', + accessKeyId: process.env.AGC_OSS_ACCESS_KEY_ID?.trim(), + accessKeySecret: process.env.AGC_OSS_ACCESS_KEY_SECRET, + dryRun, +}); + +const archived = [ + dmg, + `${dmg}.sha256`, + release.manifestPath, + release.notesPath, + `${release.artifact}.sig`, +]; +for (const file of archived) { + fs.copyFileSync(file, path.join(artifacts, path.basename(file))); +} + const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8', @@ -102,14 +179,40 @@ fs.writeFileSync( commit, target: context.target, channel: context.channel, - signed: false, + // Apple 签名与公证暂缺:显式记录为未验证项,不静默通过。 + appleSigned: false, notarized: false, - uploaded: false, - smoke: ['arm64', 'x86_64'], + dryRun, + uploaded: !dryRun, + updaterSignature: { + algorithm: signature.algorithm, + keyId: signature.keyId, + verified: true, + }, + oss: { + bucket, + endpoint, + latest: `oss://${bucket}/agc/${context.channel}/latest.json`, + objects: uploadPlan.map(({ destination }) => destination), + }, + artifacts: { + updater: path.basename(release.artifact), + updaterSha256: sha256(release.artifact), + updaterBytes: fs.statSync(release.artifact).size, + updaterSignature: path.basename(`${release.artifact}.sig`), + firstInstall: path.basename(dmg), + firstInstallSha256: dmgHash, + manifest: 'latest.json', + }, + smokes: ['arm64', 'x86_64'], intelSmoke: process.arch === 'arm64' ? 'Rosetta' : 'native', }, null, 2, )}\n`, ); -console.log('[macOS CI] universal 包与校验文件已生成;未发布、未签名或公证'); +console.log( + dryRun + ? `[agc-macos] dry-run 完成:${context.channel} 渠道产物与清单已生成,未写入 OSS` + : `[agc-macos] ${context.channel} 渠道更新包、签名、首装包与清单已上传 OSS`, +); 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 2fd93ddf1..547cff254 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 @@ -53,7 +53,7 @@ test('archive traversal and non-package entries fail closed', () => { } }); -test('CI pipeline is manual archive-only and does not reuse a developer workspace', () => { +test('CI pipeline is manual, publishes the dev-mac channel and never reuses a developer workspace', () => { const pipeline = fs.readFileSync( new URL( '../../../jenkins/Jenkinsfile.ai-game-creator-shell-macos-build', @@ -68,18 +68,43 @@ test('CI pipeline is manual archive-only and does not reuse a developer workspac 'StrictHostKeyChecking=yes', 'git merge-base --is-ancestor', 'allowEmptyArchive: false', + "AGC_UPDATE_CHANNEL = 'dev-mac'", + "string(credentialsId: 'AgcUpdaterSigningKey'", + "string(credentialsId: 'AgcUpdaterSigningKeyPassword'", + "string(credentialsId: 'AliyunAccessKeyId'", + "string(credentialsId: 'AliyunaccessKeySecret'", + 'AGC_RELEASE_VERSION', + 'OSSUTIL_BIN', ]) { assert.ok(pipeline.includes(required), required); } + // dry-run 必须是默认值:不显式取消勾选就不得写入 OSS。 + assert.match( + pipeline, + /booleanParam\(name: 'AGC_RELEASE_DRY_RUN', defaultValue: true/u, + ); for (const forbidden of [ 'triggers {', 'cron(', 'pollSCM(', - 'release:upload', - 'AgcUpdaterSigningKey', - 'AliyunAccessKeyId', 'git clean -fdx', + // release:upload 会重新触发一次完整构建,既翻倍耗时也绕过本 Job 的验签门禁。 + 'release:upload', ]) { assert.ok(!pipeline.includes(forbidden), forbidden); } }); + +test('macOS release entry verifies the updater signature before uploading', () => { + const entry = fs.readFileSync( + new URL('./build-macos-ci.mjs', import.meta.url), + 'utf8', + ); + const verifyIndex = entry.indexOf('verifyUpdaterSignature({'); + const uploadIndex = entry.indexOf('uploadReleaseArtifacts(release'); + assert.ok(verifyIndex > 0, '必须调用更新包验签'); + assert.ok(uploadIndex > 0, '必须调用 OSS 上传'); + assert.ok(verifyIndex < uploadIndex, '必须先验签再上传,验不过不得写 OSS'); + // 无签名私钥时禁止构建:未签名的更新包会被客户端一律拒绝。 + assert.ok(entry.includes('TAURI_SIGNING_PRIVATE_KEY')); +}); diff --git a/apps/ai-game-creator-shell/scripts/verify-updater-signature.mjs b/apps/ai-game-creator-shell/scripts/verify-updater-signature.mjs new file mode 100644 index 000000000..c55671369 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/verify-updater-signature.mjs @@ -0,0 +1,177 @@ +import { + createHash, + createPublicKey, + verify as cryptoVerify, +} from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * 更新包签名门禁:用产物里烘焙的 updater 公钥校验 `.sig`, + * 防止「发布出去的更新包没人装得上」——客户端校验失败会直接拒绝安装, + * 而且公钥发布后不可更换,所以必须在构建期、上传前就失败关闭。 + * + * 格式说明(与 Tauri 2 的实际产出对齐,均为实测): + * - `tauri.conf.json` 的 `plugins.updater.pubkey` 是「minisign 公钥文本」的 base64; + * - 产物旁的 `.sig` 是「minisign 签名文本」的 base64; + * - 公钥 blob 42 字节(alg `Ed` + 8 字节 keyId + 32 字节 Ed25519 公钥); + * - 签名 blob 74 字节(alg `Ed` 或 `ED` + 8 字节 keyId + 64 字节签名); + * - Tauri 产出的是 `ED`:先对文件做 BLAKE2b-512,再对摘要做 Ed25519 签名。 + */ +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const defaultTauriConfigPath = path.join(appRoot, 'src-tauri/tauri.conf.json'); +const defaultMacosConfigPath = path.join( + appRoot, + 'src-tauri/tauri.macos.conf.json', +); + +const PUBLIC_KEY_ALGORITHM = 'Ed'; +const RAW_ALGORITHM = 'Ed'; +const PREHASHED_ALGORITHM = 'ED'; + +function unwrapMinisignText(value, label) { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`${label} 为空`); + } + const trimmed = value.trim(); + if (trimmed.startsWith('untrusted comment:')) return trimmed; + const decoded = Buffer.from(trimmed, 'base64').toString('utf8'); + if (!decoded.startsWith('untrusted comment:')) { + throw new Error(`${label} 不是 minisign 内容(缺少 untrusted comment 头)`); + } + return decoded; +} + +function contentLines(text) { + return text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +/** 解析 updater 公钥(`tauri.conf.json` 里的 base64 值或 minisign 文本)。 */ +export function decodeUpdaterPublicKey(value, label = 'updater 公钥') { + const lines = contentLines(unwrapMinisignText(value, label)); + if (lines.length < 2) throw new Error(`${label} 缺少密钥内容行`); + const blob = Buffer.from(lines[1], 'base64'); + if (blob.length !== 42) { + throw new Error( + `${label} 长度异常:期望 42 字节,实际 ${blob.length} 字节`, + ); + } + const algorithm = blob.subarray(0, 2).toString('latin1'); + if (algorithm !== PUBLIC_KEY_ALGORITHM) { + throw new Error(`${label} 算法不受支持:${algorithm}`); + } + return { algorithm, keyId: blob.subarray(2, 10), key: blob.subarray(10) }; +} + +/** 解析 `.sig`(base64 值或 minisign 文本)。 */ +export function decodeUpdaterSignature(value, label = '更新包签名') { + const lines = contentLines(unwrapMinisignText(value, label)); + if (lines.length < 2) throw new Error(`${label} 缺少签名内容行`); + const blob = Buffer.from(lines[1], 'base64'); + if (blob.length !== 74) { + throw new Error( + `${label} 长度异常:期望 74 字节,实际 ${blob.length} 字节`, + ); + } + const algorithm = blob.subarray(0, 2).toString('latin1'); + if (algorithm !== RAW_ALGORITHM && algorithm !== PREHASHED_ALGORITHM) { + throw new Error(`${label} 算法不受支持:${algorithm}`); + } + return { + algorithm, + keyId: blob.subarray(2, 10), + signature: blob.subarray(10), + trustedComment: lines[2] ?? '', + }; +} + +function publicKeyObject(rawKey) { + return createPublicKey({ + key: { kty: 'OKP', crv: 'Ed25519', x: rawKey.toString('base64url') }, + format: 'jwk', + }); +} + +/** + * 校验更新包签名;任何不一致都抛错(调用方据此失败关闭)。 + */ +export function verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey, +}) { + const publicKey = decodeUpdaterPublicKey(pubkey); + const signature = decodeUpdaterSignature( + fs.readFileSync(signaturePath, 'utf8'), + ); + if (!publicKey.keyId.equals(signature.keyId)) { + throw new Error( + `更新包签名与内置公钥的 keyId 不一致:公钥 ${publicKey.keyId.toString('hex')},签名 ${signature.keyId.toString('hex')};` + + '签名私钥与产物内烘焙的公钥不是同一对,发布后客户端会拒绝安装', + ); + } + const payload = fs.readFileSync(artifactPath); + const message = + signature.algorithm === PREHASHED_ALGORITHM + ? createHash('blake2b512').update(payload).digest() + : payload; + if ( + !cryptoVerify( + null, + message, + publicKeyObject(publicKey.key), + signature.signature, + ) + ) { + throw new Error( + `更新包签名校验失败:${path.basename(artifactPath)};该产物无法被客户端接受`, + ); + } + return { + algorithm: signature.algorithm, + keyId: publicKey.keyId.toString('hex'), + trustedComment: signature.trustedComment, + }; +} + +/** + * 读取该平台生效的 updater 公钥:macOS 配置可覆盖基础配置,与构建期行为一致。 + */ +export function readUpdaterPubkey({ + configPath = defaultTauriConfigPath, + platformConfigPath = defaultMacosConfigPath, +} = {}) { + const readPubkey = (file) => { + if (!fs.existsSync(file)) return null; + const config = JSON.parse(fs.readFileSync(file, 'utf8')); + return config?.plugins?.updater?.pubkey ?? null; + }; + const pubkey = readPubkey(platformConfigPath) ?? readPubkey(configPath); + if (!pubkey) throw new Error('未在 Tauri 配置中找到 plugins.updater.pubkey'); + return pubkey; +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + const [artifactPath, signaturePath = `${artifactPath}.sig`] = + process.argv.slice(2); + if (!artifactPath) { + throw new Error( + '用法:node verify-updater-signature.mjs <更新包> [<签名文件>]', + ); + } + const result = verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: readUpdaterPubkey(), + }); + console.log( + `[agc-macos] 更新包签名校验通过:${path.basename(artifactPath)}(alg=${result.algorithm},keyId=${result.keyId})`, + ); +} diff --git a/apps/ai-game-creator-shell/scripts/verify-updater-signature.test.mjs b/apps/ai-game-creator-shell/scripts/verify-updater-signature.test.mjs new file mode 100644 index 000000000..c30ecc710 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/verify-updater-signature.test.mjs @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict'; +import { + createHash, + generateKeyPairSync, + randomBytes, + sign as cryptoSign, +} from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + decodeUpdaterPublicKey, + decodeUpdaterSignature, + readUpdaterPubkey, + verifyUpdaterSignature, +} from './verify-updater-signature.mjs'; + +/** + * 用进程内生成的 Ed25519 密钥自造 minisign 结构, + * 覆盖 Tauri 实际使用的 `ED`(BLAKE2b-512 预哈希)与 `Ed`(原文)两种模式。 + */ +function createKeyMaterial() { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const rawKey = Buffer.from( + publicKey.export({ format: 'jwk' }).x, + 'base64url', + ); + const keyId = randomBytes(8); + const pubkey = Buffer.from( + `untrusted comment: minisign public key: ${keyId.reverse().toString('hex').toUpperCase()}\n` + + `${Buffer.concat([Buffer.from('Ed'), keyId, rawKey]).toString('base64')}\n`, + ).toString('base64'); + return { privateKey, keyId, rawKey, pubkey }; +} + +function signFixture({ privateKey, keyId }, payload, algorithm) { + const message = + algorithm === 'ED' + ? createHash('blake2b512').update(payload).digest() + : payload; + const signature = cryptoSign(null, message, privateKey); + const blob = Buffer.concat([Buffer.from(algorithm), keyId, signature]); + const globalSignature = cryptoSign(null, blob, privateKey); + return Buffer.from( + 'untrusted comment: signature from tauri secret key\n' + + `${blob.toString('base64')}\n` + + 'trusted comment: timestamp:0\tfile:fixture\n' + + `${globalSignature.toString('base64')}\n`, + ).toString('base64'); +} + +function withFixture(run) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-sig-test-')); + try { + const artifactPath = path.join(directory, 'app.app.tar.gz'); + fs.writeFileSync(artifactPath, 'update payload'); + return run({ directory, artifactPath }); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +test('接受 Tauri 实际使用的 ED(BLAKE2b-512 预哈希)签名', () => { + withFixture(({ directory, artifactPath }) => { + const material = createKeyMaterial(); + const signaturePath = path.join(directory, 'app.app.tar.gz.sig'); + fs.writeFileSync( + signaturePath, + signFixture(material, fs.readFileSync(artifactPath), 'ED'), + ); + const result = verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: material.pubkey, + }); + assert.equal(result.algorithm, 'ED'); + assert.equal(result.keyId, material.keyId.toString('hex')); + }); +}); + +test('接受原文 Ed 签名,两种算法互不通用', () => { + withFixture(({ directory, artifactPath }) => { + const material = createKeyMaterial(); + const payload = fs.readFileSync(artifactPath); + const signaturePath = path.join(directory, 'app.app.tar.gz.sig'); + fs.writeFileSync(signaturePath, signFixture(material, payload, 'Ed')); + assert.equal( + verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: material.pubkey, + }).algorithm, + 'Ed', + ); + // 原文模式下签名的是别的载荷时必须失败:证明确实在校验内容而非只看结构。 + fs.writeFileSync( + signaturePath, + signFixture(material, Buffer.from('别的载荷'), 'Ed'), + ); + assert.throws( + () => + verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: material.pubkey, + }), + /签名校验失败/u, + ); + }); +}); + +test('产物被篡改时失败关闭', () => { + withFixture(({ directory, artifactPath }) => { + const material = createKeyMaterial(); + const signaturePath = path.join(directory, 'app.app.tar.gz.sig'); + fs.writeFileSync( + signaturePath, + signFixture(material, fs.readFileSync(artifactPath), 'ED'), + ); + fs.writeFileSync(artifactPath, 'tampered payload'); + assert.throws( + () => + verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: material.pubkey, + }), + /签名校验失败/u, + ); + }); +}); + +test('签名私钥与内置公钥不是同一对时给出明确错误', () => { + withFixture(({ directory, artifactPath }) => { + const signing = createKeyMaterial(); + const baked = createKeyMaterial(); + const signaturePath = path.join(directory, 'app.app.tar.gz.sig'); + fs.writeFileSync( + signaturePath, + signFixture(signing, fs.readFileSync(artifactPath), 'ED'), + ); + assert.throws( + () => + verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: baked.pubkey, + }), + /keyId 不一致/u, + ); + }); +}); + +test('公钥或签名格式非法时拒绝解析', () => { + assert.throws(() => decodeUpdaterPublicKey(''), /为空/u); + assert.throws( + () => decodeUpdaterPublicKey('bm90IGEgbWluaXNpZ24ga2V5'), + /不是 minisign 内容/u, + ); + assert.throws( + () => + decodeUpdaterPublicKey( + Buffer.from('untrusted comment: x\nAAAA\n').toString('base64'), + ), + /长度异常/u, + ); + assert.throws( + () => + decodeUpdaterSignature( + Buffer.from('untrusted comment: x\nAAAA\n').toString('base64'), + ), + /长度异常/u, + ); +}); + +test('仓库里配置的 updater 公钥可被解析(两平台共用)', () => { + const decoded = decodeUpdaterPublicKey(readUpdaterPubkey()); + assert.equal(decoded.algorithm, 'Ed'); + assert.equal(decoded.key.length, 32); +}); diff --git a/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md index b9b84ef2e..fd0a4fc32 100644 --- a/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md +++ b/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md @@ -3,7 +3,7 @@ | 字段 | 值 | | ----------- | ------------------------------------------------------------------ | | Version | 1.0 | -| Status | deferred | +| Status | in-progress | | Date | 2026-09-17 | | Parent Spec | `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md` | @@ -30,7 +30,9 @@ - macOS 签名证书与公证凭据可用;若不满足,本里程碑只能交付构建与清单能力,并明确标注未验证项。 - macOS 通用包所需的双架构工具链(两个 darwin 目标)在构建机上可用。 -本里程碑暂缓执行:macOS 构建机与签名 / 公证凭据尚未就绪,改由后续独立变更承接;暂缓期间 dev-mac 渠道不发布。 +构建与发布能力已落地:专用 macOS Jenkins 节点(label `genarrative-agc-macos`)+ `Jenkinsfile.ai-game-creator-shell-macos-build` + `scripts/build-macos-ci.mjs` 负责 universal 构建、双架构隔离 smoke、universal DMG、渠道清单 `latest.json`、更新包验签门禁与 OSS 上传(`AGC_RELEASE_DRY_RUN` 默认开启)。 + +仍未就绪:Apple 代码签名与公证凭据(产物保持未签名 + 未公证,构建清单显式记录 `appleSigned=false` / `notarized=false`,首装需手动放行 Gatekeeper);「安装 → 重启接管新版本」的实机更新闭环、Intel 真机 smoke(当前 x86_64 侧为 Rosetta)尚未验收。 ## 验收标准 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index ec2bf04aa..40ca10449 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -161,8 +161,11 @@ - macOS 采用 universal 包,两个平台键对应同一更新产物;主程序用 lipo 检查两种架构,Codex 资源分别做原生身份/摘要与启动验证。Rosetta 结果不代替 Intel 真机验收。 - 旧客户端迁移桥:保留一个版本周期。渠道清单上线后,发布管线同时把旧的 `agc/latest.json`(sha256 格式)指向 `dev-win` 最新安装包,让已发布客户端自动升级到新协议;下个周期整条删除。 - 签名密钥:由本仓库维护者生成并保管,私钥保存在仓库外(`%USERPROFILE%\.tauri\genarrative-agc-updater.key`),只有公钥进入客户端配置;Jenkins 用受保护凭据 `AgcUpdaterSigningKey` 与 `AgcUpdaterSigningKeyPassword` 注入为 Tauri 打包器读取的 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,本机可用 `TAURI_SIGNING_PRIVATE_KEY_PATH` 指向同一私钥。当前密钥不带密码;首次发布前仍可重新生成,首次发布后不可更换。 -- macOS 发布方式:`dev-mac` 产物在本机 mac 上执行发布入口上传,Jenkins 暂不新增 macOS 节点;macOS 代码签名与公证凭据未确认前,相关闭环记为未验证项,不静默通过。 +- macOS 发布方式:已接入专用 macOS Jenkins 节点(label `genarrative-agc-macos`,EXCLUSIVE 单 executor),由 `Jenkinsfile.ai-game-creator-shell-macos-build` 执行 `scripts/build-macos-ci.mjs` 完成 universal 构建、双架构隔离 smoke、universal DMG、渠道清单生成、更新包验签与 OSS 上传。`AGC_RELEASE_DRY_RUN` 默认为开,只有显式关闭才会写入 OSS。 +- macOS 代码签名与公证暂缺:产物为未签名 + 未公证,`--no-sign` 保留,构建清单与 `latest.json` 显式记录 `appleSigned=false` / `notarized=false`,首装需用户在 Gatekeeper 中手动放行。该限制作为已知未验证项记录,不静默通过;「安装 → 重启接管新版本」的自动更新闭环仍需实机验收。 +- 更新包验签门禁:构建完成、上传 OSS 之前,用产物内烘焙的 `plugins.updater.pubkey` 复核 `<更新包>.sig`(Tauri 使用 minisign 的 `ED` 预哈希模式)。keyId 不一致或校验失败立即失败关闭,禁止上传——客户端校验失败会直接拒绝安装,且公钥发布后不可更换。 待办: -- macOS `dev-mac` 渠道落地(macOS 构建机、签名与公证、安装后重启验证、是否接入 Jenkins macOS 节点)暂缓,由后续独立变更单独完成;在此之前 `dev-mac` 渠道只有构建与清单能力,不发布。 +- macOS `dev-mac` 渠道已落地构建与发布能力:Mac Jenkins 节点、release 入口(更新包 + 签名 + 首装包 + 渠道清单)、验签门禁与 dry-run 默认开启均已就绪。 +- 剩余待办:Apple 代码签名与公证凭据(未就绪期间以未验证项记录)、macOS 安装后重启接管新版本的实机验证、Intel 真机 smoke(当前 x86_64 侧为 Rosetta)。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 70ca24895..8927e54d9 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -724,7 +724,11 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. Mac universal 构建脚本位于 `jenkins/Jenkinsfile.ai-game-creator-shell-macos-build`,只对专用 `genarrative-agc-macos` 标签运行。节点按 EXCLUSIVE、单 executor 配置,Job 禁止并发且不设置 trigger;不接入现有每小时版本调度,不改 Windows 发布职责。它使用独立 Jenkins workspace,禁止指向开发 checkout 或共享其可写 target/node_modules。 -该 Job 的目标仅为构建并归档:保留源码版本、不读取远端版本、不注入发布私钥/OSS 凭据、不运行 release upload。执行 `npm ci` 后使用锁文件校验并补齐两种 macOS Codex 原生依赖,再调用 `scripts/build-macos-ci.mjs`(AGC 应用目录下)生成 universal app 与 DMG、分别运行 arm64/x86_64 隔离 smoke。归档限 `artifacts/` 下的 DMG、SHA-256、非敏感构建清单和源码 commit;不归档用户 HOME、Jenkins secret、原始工作目录或全量日志。 +该 Job 的职责是构建并发布 `dev-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、原始工作目录或全量日志。 + +发布凭据全部走 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/dev-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 代码签名与公证暂缺,构建保持 `--no-sign`,构建清单显式记录 `appleSigned=false` / `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 重建后无改动也触发近似冷构建。节点未安装 sccache 时管线打印提示并回退到真实 `rustc`,不阻断构建。universal 双架构各自独立编译,依赖 crate 的复用收益约为单架构的两倍;命中情况由构建末尾的 `sccache --show-stats` 输出,供判断是否需要预热或调整缓存上限。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build index 5e02d5872..b5ebdca9e 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build @@ -10,11 +10,17 @@ pipeline { parameters { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '必须含 universal 双架构依赖实现的受信任分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,固定属于源码分支的提交;不递增应用版本') + 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_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选单行更新摘要;留空则由发布脚本按提交自动汇总') + string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 命令名或绝对路径(Mac 节点默认装在 ~/.local/bin/ossutil)') } environment { GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git' GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh' AGC_UPDATE_CHANNEL = 'dev-mac' + AGC_OSS_BUCKET = 'agc-dev' + AGC_OSS_ENDPOINT = 'oss-rg-china-mainland.aliyuncs.com' // 与 Linux 生产管线(api-build / stdb-module-build)对齐:Rust 编译走 sccache 对象缓存。 // 本 Job 是 universal 双架构(aarch64 + x86_64)各自独立编译,缓存收益约为单架构的两倍。 // 缓存根固定在 HOME 下的稳定目录,避免 WORKSPACE 重建导致近似冷构建;可用下面的变量覆盖。 @@ -80,37 +86,66 @@ pipeline { ''' } } - stage('Validate and build universal') { + stage('Package, verify and publish') { steps { - sh ''' - set -eu - cache_root="${GENARRATIVE_AGC_MACOS_CACHE_ROOT:-caches/genarrative-jenkins/agc-macos}" - case "${cache_root}" in /*) ;; *) cache_root="${HOME:?HOME 不能为空}/${cache_root}" ;; esac - if command -v sccache >/dev/null 2>&1; then - export SCCACHE_DIR="${cache_root}/sccache" - mkdir -p "${SCCACHE_DIR}" - echo "[agc-macos] sccache 缓存目录: ${SCCACHE_DIR}" - else - echo '[agc-macos] 未找到 sccache,改用 rustc 直接构建。' - unset RUSTC_WRAPPER - fi - node --test apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs - node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs - node apps/ai-game-creator-shell/scripts/build-macos-ci.mjs - if command -v sccache >/dev/null 2>&1; then - echo '[agc-macos] sccache 统计(自 server 启动累计):' - sccache --show-stats 2>&1 | sed -n '1,6p' - fi - ''' + withCredentials([ + string(credentialsId: 'AliyunAccessKeyId', variable: 'AGC_OSS_ACCESS_KEY_ID'), + string(credentialsId: 'AliyunaccessKeySecret', variable: 'AGC_OSS_ACCESS_KEY_SECRET'), + string(credentialsId: 'AgcUpdaterSigningKey', variable: 'TAURI_SIGNING_PRIVATE_KEY'), + string(credentialsId: 'AgcUpdaterSigningKeyPassword', variable: 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD'), + ]) { + withEnv([ + "OSSUTIL_BIN=${params.OSSUTIL_BIN}", + "AGC_RELEASE_VERSION=${params.AGC_RELEASE_VERSION}", + "AGC_RELEASE_DRY_RUN=${params.AGC_RELEASE_DRY_RUN ? '1' : '0'}", + "AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}", + ]) { + sh ''' + set -eu + echo "[agc-macos] 渠道=${AGC_UPDATE_CHANNEL} 目标=universal-apple-darwin dry-run=${AGC_RELEASE_DRY_RUN}" + ossutil_bin="${OSSUTIL_BIN:-ossutil}" + if command -v "${ossutil_bin}" >/dev/null 2>&1; then + "${ossutil_bin}" --version | head -1 + elif [ "${AGC_RELEASE_DRY_RUN}" = "1" ]; then + echo "[agc-macos] dry-run:未找到 ${ossutil_bin},只打印上传计划,不写 OSS" + else + echo "[agc-macos] 正式发布缺少 ossutil:${ossutil_bin};请安装或指定 OSSUTIL_BIN" + exit 1 + fi + cache_root="${GENARRATIVE_AGC_MACOS_CACHE_ROOT:-caches/genarrative-jenkins/agc-macos}" + case "${cache_root}" in /*) ;; *) cache_root="${HOME:?HOME 不能为空}/${cache_root}" ;; esac + if command -v sccache >/dev/null 2>&1; then + export SCCACHE_DIR="${cache_root}/sccache" + mkdir -p "${SCCACHE_DIR}" + echo "[agc-macos] sccache 缓存目录: ${SCCACHE_DIR}" + else + echo '[agc-macos] 未找到 sccache,改用 rustc 直接构建。' + unset RUSTC_WRAPPER + fi + node --test apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs + node --test apps/ai-game-creator-shell/scripts/verify-updater-signature.test.mjs + node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs + node apps/ai-game-creator-shell/scripts/build-macos-ci.mjs + if command -v sccache >/dev/null 2>&1; then + echo '[agc-macos] sccache 统计(自 server 启动累计):' + sccache --show-stats 2>&1 | sed -n '1,6p' + fi + ''' + } + } } } - stage('Archive only') { + stage('Archive release') { steps { - archiveArtifacts artifacts: 'artifacts/*.dmg,artifacts/*.sha256,artifacts/build-manifest.json,.jenkins-source-commit', fingerprint: true, allowEmptyArchive: false + archiveArtifacts artifacts: 'artifacts/*.dmg,artifacts/*.sha256,artifacts/latest.json,artifacts/*.sig,artifacts/release-notes.txt,artifacts/build-manifest.json,.jenkins-source-commit', fingerprint: true, allowEmptyArchive: false } } } post { - success { echo 'macOS universal 本地测试包已构建并归档;未上传 OSS、未正式签名/公证。' } + success { + echo params.AGC_RELEASE_DRY_RUN + ? 'macOS universal 渠道演练完成:已构建、验签并生成清单,未写入 OSS;Apple 签名与公证暂缺。' + : 'macOS universal 更新包、签名、首装包与 dev-mac 渠道清单已上传 OSS;Apple 签名与公证暂缺,首装需手动放行。' + } } } -- 2.52.0 From bdf6d0f556b7a667843562086b6fd97e13a6cad8 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 20 Sep 2026 18:51:36 +0800 Subject: [PATCH 07/16] =?UTF-8?q?Mac=E6=9E=84=E5=BB=BA=E5=B9=B6=E8=A1=8C?= =?UTF-8?q?=E5=BA=A6=E6=94=B9=E4=B8=BA=E5=8F=AF=E8=B0=83=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E5=B9=B6=E8=AE=B0=E5=BD=95=E9=A6=96=E6=AC=A1=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E8=A1=A8=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CARGO_BUILD_JOBS从写死4改为Job参数默认6,适配8核共用机器 补充并行度与sccache首次全未命中的运维口径 增加Jenkinsfile并行度参数的回归断言 --- .../scripts/prepare-macos-codex.test.mjs | 3 +++ docs/【开发运维】本地开发验证与生产运维-2026-05-15.md | 4 ++-- jenkins/Jenkinsfile.ai-game-creator-shell-macos-build | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) 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 72c719a55..076dfef7c 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 @@ -76,6 +76,9 @@ test('CI pipeline is manual, publishes the macOS partition and never reuses a de "string(credentialsId: 'AliyunaccessKeySecret'", 'AGC_RELEASE_VERSION', 'OSSUTIL_BIN', + // 并行度必须可调:节点是共用机器,写死容易把整机压满或反过来浪费一半核心。 + "string(name: 'CARGO_BUILD_JOBS'", + 'CARGO_BUILD_JOBS=${params.CARGO_BUILD_JOBS}', ]) { assert.ok(pipeline.includes(required), required); } diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 37ce71013..a7b58241f 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -730,7 +730,7 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. Mac universal 构建脚本位于 `jenkins/Jenkinsfile.ai-game-creator-shell-macos-build`,只对专用 `genarrative-agc-macos` 标签运行。节点按 EXCLUSIVE、单 executor 配置,Job 禁止并发且不设置 trigger;不接入现有每小时版本调度,不改 Windows 发布职责。它使用独立 Jenkins workspace,禁止指向开发 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`。渠道参数是基础名(不含系统,默认 `dev`),脚本不接受 `dev-mac` 这类系统后缀,写入分区固定推导为 `-mac`:这与 Windows Job 的 `-win` 对称,也延续已发布客户端的端点。 +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、原始工作目录或全量日志。 @@ -738,7 +738,7 @@ Job 名为 `Genarrative-Agc-MacOS-Build`,SCM 直接读取仓库内上述 Jenki 产物边界:macOS 代码签名与公证暂缺,构建保持 `--no-sign`,构建清单显式记录 `appleSigned=false` / `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 重建后无改动也触发近似冷构建。节点未安装 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 参数(默认 `6`),既不全占也让出余量;首次构建 sccache 必然全未命中(实测 0 命中 / 407 未命中),此后的构建才逐步吃到缓存。节点未安装 sccache 时管线打印提示并回退到真实 `rustc`,不阻断构建。universal 双架构各自独立编译,依赖 crate 的复用收益约为单架构的两倍;命中情况由构建末尾的 `sccache --show-stats` 输出,供判断是否需要预热或调整缓存上限。 当前用户的 LaunchAgent 受登录、休眠与局域网连通性影响,不能视为长期无人值守构建机。该用户进程也不是权限沙箱,只能承接受信任仓库和 Job;不能把匹配标签当作隔离恶意构建的措施。节点注册、上线和首个 Job 的成功必须以控制器实时状态确认,脚本入库不代表管线已经接通。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build index 8c14a0717..765b2c476 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build @@ -15,6 +15,7 @@ pipeline { booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: true, description: '勾选后只构建、验签并打印将上传的对象,不写 OSS;取消勾选才真正发布到 dev-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 是经实测的折中值;只影响本次构建') } environment { GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git' @@ -28,7 +29,6 @@ pipeline { RUSTC_WRAPPER = 'sccache' SCCACHE_CACHE_SIZE = '20G' CARGO_INCREMENTAL = '0' - CARGO_BUILD_JOBS = '4' PATH = '/Users/suzmii/.local/bin:/Users/suzmii/.cargo/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin' } stages { @@ -96,6 +96,7 @@ pipeline { ]) { withEnv([ "OSSUTIL_BIN=${params.OSSUTIL_BIN}", + "CARGO_BUILD_JOBS=${params.CARGO_BUILD_JOBS}", "AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}", "AGC_RELEASE_VERSION=${params.AGC_RELEASE_VERSION}", "AGC_RELEASE_DRY_RUN=${params.AGC_RELEASE_DRY_RUN ? '1' : '0'}", -- 2.52.0 From 57017ee04678dc3e6de214025b7c44ebb0946582 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 20 Sep 2026 19:11:30 +0800 Subject: [PATCH 08/16] =?UTF-8?q?=E4=BF=AE=E5=A4=8DMac=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E8=AF=AF=E7=94=A8--no-sign=E5=AF=BC=E8=87=B4=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=8C=85=E6=97=A0=E7=AD=BE=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除build-macos-ci的--no-sign:该标志会连带跳过updater的minisign签名 改为剥离APPLE_*凭据跳过Apple签名,保持更新包签名能力 构建清单按codesign实测记录appleSigned与签名类型 补充回归断言与运维、更新规范口径 --- .../scripts/build-macos-ci.mjs | 24 +++++++++++++++---- .../scripts/prepare-macos-codex.test.mjs | 5 ++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- 4 files changed, 27 insertions(+), 6 deletions(-) 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 56cab0bd2..651e342d0 100644 --- a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs +++ b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; @@ -24,7 +24,9 @@ import { * → 生成分区清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。 * * 边界: - * - Apple 签名与公证暂缺,产物为未签名 + 未公证(`--no-sign`),必须显式记录而非静默通过; + * - Apple 签名与公证暂缺:本入口剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名,但**不能传 + * `--no-sign`** —— 该标志同时会跳过 updater 的 minisign 签名,产物就没有 `.sig`; + * 未签名 + 未公证必须显式记录而非静默通过; * - 更新包签名(TAURI_SIGNING_PRIVATE_KEY,minisign)是硬需求:缺了客户端一律拒绝安装, * 因此构建前要求凭据存在,构建后用内置公钥复核 `.sig` 才允许继续上传; * - 未通过验签绝不写 OSS:上传顺序为更新包、签名、首装包,全部成功后才覆盖渠道清单指针。 @@ -78,7 +80,8 @@ const args = [ '--bundles', 'app', '--ci', - '--no-sign', + // 刻意不传 `--no-sign`:它会连带跳过 updater 签名,而客户端强制校验更新包签名。 + // Apple 侧改为剥离 APPLE_* 凭据,未配置身份时 Tauri 不签名也不失败。 // 基础配置已开启;这里显式声明,避免被其它配置来源关掉后静默失去更新能力。 '--config', '{"bundle":{"createUpdaterArtifacts":true}}', @@ -173,6 +176,18 @@ const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8', }).trim(); +// Apple 签名状态必须实测:剥离 APPLE_* 后 Tauri 通常跳过签名,但节点若装了 Developer ID +// 证书仍可能签上,硬编码 appleSigned=false 会把「其实签了」写成假事实。 +const signatureProbe = spawnSync('codesign', ['-dv', '--verbose=2', app], { + encoding: 'utf8', +}); +const signatureText = `${signatureProbe.stdout ?? ''}${signatureProbe.stderr ?? ''}`; +const appleSigned = /Authority=Developer ID Application/u.test(signatureText); +const appleSignatureKind = appleSigned + ? 'developer-id' + : /Signature=adhoc/u.test(signatureText) + ? 'adhoc' + : 'unsigned'; fs.writeFileSync( path.join(artifacts, 'build-manifest.json'), `${JSON.stringify( @@ -182,7 +197,8 @@ fs.writeFileSync( target: context.target, channel: context.channel, // Apple 签名与公证暂缺:显式记录为未验证项,不静默通过。 - appleSigned: false, + appleSigned, + appleSignatureKind, notarized: false, dryRun, uploaded: !dryRun, 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 076dfef7c..4673179f7 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 @@ -111,4 +111,9 @@ test('macOS release entry verifies the updater signature before uploading', () = assert.ok(verifyIndex < uploadIndex, '必须先验签再上传,验不过不得写 OSS'); // 无签名私钥时禁止构建:未签名的更新包会被客户端一律拒绝。 assert.ok(entry.includes('TAURI_SIGNING_PRIVATE_KEY')); + // `--no-sign` 会连带跳过 updater 的 minisign 签名,产物将没有 .sig,入口不得传它。 + assert.ok( + !entry.includes("'--no-sign'"), + '--no-sign 会同时跳过 updater 签名,产物缺少 .sig', + ); }); diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 7b8c395ae..6ec06d077 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -172,7 +172,7 @@ - 旧客户端迁移桥:保留一个版本周期。渠道清单上线后,发布管线同时把旧的 `agc/latest.json`(sha256 格式)指向 `dev-win` 最新安装包,让已发布客户端自动升级到新协议;下个周期整条删除。 - 签名密钥:由本仓库维护者生成并保管,私钥保存在仓库外(`%USERPROFILE%\.tauri\genarrative-agc-updater.key`),只有公钥进入客户端配置;Jenkins 用受保护凭据 `AgcUpdaterSigningKey` 与 `AgcUpdaterSigningKeyPassword` 注入为 Tauri 打包器读取的 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,本机可用 `TAURI_SIGNING_PRIVATE_KEY_PATH` 指向同一私钥。当前密钥不带密码;首次发布前仍可重新生成,首次发布后不可更换。 - macOS 发布方式:已接入专用 macOS Jenkins 节点(label `genarrative-agc-macos`,EXCLUSIVE 单 executor),由 `Jenkinsfile.ai-game-creator-shell-macos-build` 执行 `scripts/build-macos-ci.mjs` 完成 universal 构建、双架构隔离 smoke、universal DMG、分区清单生成、更新包验签与 OSS 上传。`AGC_RELEASE_DRY_RUN` 默认为开,只有显式关闭才会写入 OSS。 -- macOS 代码签名与公证暂缺:产物为未签名 + 未公证,`--no-sign` 保留,构建清单与 `latest.json` 显式记录 `appleSigned=false` / `notarized=false`,首装需用户在 Gatekeeper 中手动放行。该限制作为已知未验证项记录,不静默通过;「安装 → 重启接管新版本」的自动更新闭环仍需实机验收。 +- macOS 代码签名与公证暂缺:产物为未签名 + 未公证,构建入口剥离 `APPLE_*` 凭据跳过 Apple 签名,不传 `--no-sign`(它还会跳过 updater 的 minisign 签名,产物将没有 `.sig`);构建清单实测记录 `appleSigned` 与签名类型,`latest.json` 侧固定记录 `notarized=false`,首装需用户在 Gatekeeper 中手动放行。该限制作为已知未验证项记录,不静默通过;「安装 → 重启接管新版本」的自动更新闭环仍需实机验收。 - 更新包验签门禁:构建完成、上传 OSS 之前,用产物内烘焙的 `plugins.updater.pubkey` 复核 `<更新包>.sig`(Tauri 使用 minisign 的 `ED` 预哈希模式)。keyId 不一致或校验失败立即失败关闭,禁止上传——客户端校验失败会直接拒绝安装,且公钥发布后不可更换。 待办: diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index a7b58241f..f3ca67a69 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -736,7 +736,7 @@ Job 名为 `Genarrative-Agc-MacOS-Build`,SCM 直接读取仓库内上述 Jenki 发布凭据全部走 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 代码签名与公证暂缺,构建保持 `--no-sign`,构建清单显式记录 `appleSigned=false` / `notarized=false`。用户首次安装需要在 Gatekeeper 中手动放行;更新包校验本身只依赖 minisign 签名,因此未签名不阻断自动更新的校验环节,但「安装 → 重启接管新版本」的实机闭环仍未验证,不得以构建成功替代。 +产物边界: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` 输出,供判断是否需要预热或调整缓存上限。 -- 2.52.0 From bd0f9f481a9c2372c2a49928790918065d96c153 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 20 Sep 2026 19:58:30 +0800 Subject: [PATCH 09/16] =?UTF-8?q?=E4=BF=AE=E5=A4=8DMac=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E4=BA=A7=E7=89=A9=E4=B8=8D=E5=B9=82=E7=AD=89=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84DMG=E9=87=8D=E5=BB=BA=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 构建前删除本次将写出的DMG、更新包与签名,避免同名文件让hdiutil失败 hdiutil create显式覆盖,杜绝残留对象影响本次产物 补充回归断言,防止旧签名让验签门禁误通过 --- .../scripts/build-macos-ci.mjs | 18 ++++++++++++++++++ .../scripts/prepare-macos-codex.test.mjs | 9 +++++++++ 2 files changed, 27 insertions(+) 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 651e342d0..eb5394595 100644 --- a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs +++ b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs @@ -75,6 +75,22 @@ const context = resolveReleaseContext(['--target=universal-apple-darwin']); const partition = resolveReleasePartition(context.channel, context.target); const version = await prepareReleaseVersion(context); +// 幂等边界:workspace 会保留上一轮产物。先删掉本次将要写出的对象,否则 +// 1) hdiutil 会因同名 DMG 已存在直接失败(首次实跑即命中); +// 2) 上一轮遗留的 `.sig` 会让验签门禁把「本轮其实没签」判成通过。 +// 只删本次要写出的确切路径,不动其它版本产物与编译缓存。 +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(context.bundleRoot, 'latest.json'), + path.join(context.bundleRoot, 'release-notes.txt'), +]) { + fs.rmSync(stale, { force: true }); +} + const args = [ '--target=universal-apple-darwin', '--bundles', @@ -110,6 +126,8 @@ try { fs.symlinkSync('/Applications', path.join(stage, 'Applications')); command('hdiutil', [ 'create', + // 前面已删除同名对象;这里再要求显式覆盖,避免残留文件让构建以「文件已存在」失败。 + '-ov', '-volname', '陶泥儿', '-srcfolder', 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 4673179f7..377d29a4b 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 @@ -116,4 +116,13 @@ test('macOS release entry verifies the updater signature before uploading', () = !entry.includes("'--no-sign'"), '--no-sign 会同时跳过 updater 签名,产物缺少 .sig', ); + // workspace 会跨构建保留产物:必须先删本次要写的对象,否则会因同名 DMG 失败, + // 或让上一轮遗留的 .sig 让验签门禁误通过。 + for (const required of [ + "path.join(macosBundle, '陶泥儿.app.tar.gz.sig')", + 'fs.rmSync(stale, { force: true })', + "'-ov'", + ]) { + assert.ok(entry.includes(required), required); + } }); -- 2.52.0 From c4b391cb8489c3881e0097c36baace89b9bad49a Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 20 Sep 2026 20:44:12 +0800 Subject: [PATCH 10/16] =?UTF-8?q?=E8=AE=B0=E5=BD=95Mac=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E7=AE=A1=E7=BA=BF=E9=A6=96=E6=AC=A1=E7=AB=AF=E5=88=B0=E7=AB=AF?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回填build3成功的产物、验签、dry-run与耗时证据 补充--no-sign连带跳过更新包签名、残留产物不幂等两处踩坑 记录主crate重复编译这一剩余耗时优化点 --- ...【里程碑】Mac本机构建节点接入-2026-09-18.md | 18 +++++++++++++++++- docs/project-memory/shared-memory/pitfalls.md | 12 ++++++++++++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 ++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/project-memory/plans/【里程碑】Mac本机构建节点接入-2026-09-18.md b/docs/project-memory/plans/【里程碑】Mac本机构建节点接入-2026-09-18.md index 728a3a31d..912ebad75 100644 --- a/docs/project-memory/plans/【里程碑】Mac本机构建节点接入-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】Mac本机构建节点接入-2026-09-18.md @@ -1,7 +1,7 @@ # Mac 本机构建节点接入 - Version: 1 -- Status: reviewed +- Status: accepted-with-open-items - Date: 2026-09-18 - Parent Spec: `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` @@ -32,3 +32,19 @@ Jenkinsfile、锁定双架构依赖预备脚本与 archive-only CI 打包入口已在本地准备。定向 Node 测试 37 项通过,类型/配置、编码、文档索引、定向 ESLint 与 diff 检查通过;未执行完整 Jenkins 构建或在线 Groovy 校验。 控制器直连多次超时,经当前代理请求返回 502;Java 21 已存在,无需安装新 JDK。尚未注册节点、创建 Job、保存 Agent secret、安装 LaunchAgent 或触发远端构建。需先恢复内网连接,再按本规范验收真实节点及构建状态;不把本地代码准备描述为已经接入成功。 + +## 验收结果(2026-09-20) + +| 验收项 | 结果 | +| --- | --- | +| 控制器匹配的 agent.jar + 已有 Java 21 | 通过(未安装新 JDK) | +| 节点实际 online、专用标签、EXCLUSIVE 单 executor | 通过(`genarrative-agc-macos-01`) | +| LaunchAgent 可重启/卸载、凭据在仓库外且权限受限 | 通过 | +| 固定 commit、锁文件装依赖、两种 macOS 原生包 integrity | 通过 | +| universal Release + 双架构 smoke + DMG verify | 通过(arm64 原生、x86_64 走 Rosetta) | +| 归档仅含安装包/摘要/非敏感来源信息 | 通过 | +| 真实 Jenkins build SUCCESS 且归档存在 | 通过(build #3,43 分钟) | + +同期修复:Mac 入口误传 `--no-sign` 导致更新包无签名;复用 workspace 的残留产物导致 DMG 重建失败、旧签名可能让验签误通过;并行度由写死 4 改为 Job 参数(默认 6)。 + +仍未验收:Apple 代码签名与公证(当前 `adhoc`,首装需 Gatekeeper 手动放行)、macOS 安装后重启接管新版本的实机闭环、Intel 真机 smoke、真实(非 dry-run)发布与渠道清单上线。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 6e76e482c..26c2c312c 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,17 @@ # 踩坑与排障记录 +## Tauri `--no-sign` 会连带跳过 updater 签名 + +AGC macOS 发布入口一度传入 `--no-sign`(目的是绕过没有 Apple 证书的代码签名),结果 Tauri 打印 `Warn Updater signing is skipped due to --no-sign flag.`,产物只有 `*​.app.tar.gz` 而没有 `.sig`,发布入口按设计在「缺少更新包签名」处失败关闭(2026-09-20 首次 Jenkins 实跑命中)。正确做法是不传 `--no-sign`,改为剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名——minisign 更新包签名与 Apple 代码签名这两个开关在 Tauri 里并不独立。Apple 签名状态要按 `codesign -dv` 实测记录,不能硬编码。 + +## 复用 workspace 的构建必须显式清理本次要写的产物 + +Jenkins workspace 跨构建保留:上一轮失败留下的同名 `陶泥儿__universal.dmg` 会让 `hdiutil create` 以「文件已经存在」失败,而上一轮遗留的 `*​.app.tar.gz.sig` 更危险——本轮即使没签出签名,验签门禁也会读到旧签名而误判通过。构建入口必须在构建前删除本次将写出的确切路径(更新包、签名、同版本 DMG 及其校验文件、`latest.json`、`release-notes.txt`),`hdiutil create` 同时用 `-ov`,让「归档里的产物来自本次构建」成为结构性事实而非假设。 + +## AGC macOS 单次构建耗时集中在主 crate 重复编译 + +AGC 主 crate(`genarrative_ai_game_creator_shell`)单架构 codegen 约 15–20 分钟,而每次 Tauri 构建都会重新生成前端 `dist`,`build.rs` 对 `dist` 目录的 `rerun-if-changed` 因此每次都判定变化,导致两个架构各重编一次主 crate。实测:`CARGO_BUILD_JOBS=4` 时首次 Jenkins 构建 78 分钟,提到 6 后为 41–43 分钟且成功;依赖 crate 走 sccache 与 target 缓存,首轮 0 命中属预期。剩余优化空间在「不必要地重建 dist」这一层,需单独设计(例如按内容摘要决定是否重跑前端构建),不要在发布入口里用假缓存换取速度。 + ## Rust 同步回调的测试记录按线程隔离 - `shared-contracts` 的资源 kind reporter 是进程级回调。仅给注册和断言加锁,无法阻止其他并行 manifest 测试触发该回调,导致日志数量和内容断言偶发混入其他测试记录。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index f3ca67a69..64c175fe3 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -740,6 +740,10 @@ Job 名为 `Genarrative-Agc-MacOS-Build`,SCM 直接读取仓库内上述 Jenki 该 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` 输出,供判断是否需要预热或调整缓存上限。 +首次端到端验证(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`,需单独设计。 + 当前用户的 LaunchAgent 受登录、休眠与局域网连通性影响,不能视为长期无人值守构建机。该用户进程也不是权限沙箱,只能承接受信任仓库和 Job;不能把匹配标签当作隔离恶意构建的措施。节点注册、上线和首个 Job 的成功必须以控制器实时状态确认,脚本入库不代表管线已经接通。 ### CI 宿主 CPU 上限(Jenkins 16 核 / Gitea Actions runner 12 核) -- 2.52.0 From c0154c2aed4dd15dfa77904564ee6e3fc94c4a52 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 20 Sep 2026 21:00:05 +0800 Subject: [PATCH 11/16] =?UTF-8?q?=E4=BC=98=E5=8C=96Mac=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E7=AE=A1=E7=BA=BF=E5=B9=B6=E8=A1=8C=E5=BA=A6=E4=B8=8E=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E5=81=A5=E5=A3=AE=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 并行度参数默认改为8以吃满节点,并在说明里记录其作为rustc jobserver令牌上限的影响 产品名统一从Tauri配置推导,校验脚本从Info.plist读取可执行名,改产品名不再静默失效 隔离smoke改用ditto --clone复制副本,实测整轮6.8秒 Agent工作区守卫改为按目录约定匹配,节点改名后仍成立 签到同时取master以便解析上次发布commit,失败仅降级更新摘要 补充上述行为的回归断言 --- .../scripts/build-macos-ci.mjs | 41 ++++++++++++---- .../scripts/check-macos-bundle.mjs | 48 ++++++++++++++++--- .../scripts/prepare-macos-codex.test.mjs | 41 +++++++++++++++- ...发运维】本地开发验证与生产运维-2026-05-15.md | 8 ++-- ...kinsfile.ai-game-creator-shell-macos-build | 21 +++++--- 5 files changed, 132 insertions(+), 27 deletions(-) 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 -- 2.52.0 From d4de81c1287bd71b00bacf35ae42dff8104884c9 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 20 Sep 2026 21:19:23 +0800 Subject: [PATCH 12/16] =?UTF-8?q?=E8=AE=B0=E5=BD=95Mac=20Job=E4=B8=8E?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E6=8F=8F=E8=BF=B0=E7=9A=84=E7=BB=B4=E6=8A=A4?= =?UTF-8?q?=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充Job描述与节点描述属Jenkins侧元数据、不随仓库同步的说明 记录该Job的config.xml POST返回500、须用脚本接口原地改描述以保留历史 同步并行度默认值与描述口径 --- docs/【开发运维】本地开发验证与生产运维-2026-05-15.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 7255a6304..191f45398 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -738,6 +738,8 @@ Job 名为 `Genarrative-Agc-MacOS-Build`,SCM 直接读取仓库内上述 Jenki 产物边界:macOS 代码签名与公证暂缺,构建通过剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名,**不得使用 `--no-sign`**——该标志会连带跳过 updater 的 minisign 签名,产物缺少 `.sig` 会直接卡在验签门禁(首次实跑即命中该坑)。构建清单按 `codesign -dv` 实测记录 `appleSigned` / `appleSignatureKind`(如 `adhoc`),并固定记录 `notarized=false`。用户首次安装需要在 Gatekeeper 中手动放行;更新包校验本身只依赖 minisign 签名,因此未签名不阻断自动更新的校验环节,但「安装 → 重启接管新版本」的实机闭环仍未验证,不得以构建成功替代。 +Job 描述与节点描述是 Jenkins 侧元数据,**不随仓库同步**:Jenkinsfile 只回写参数定义,描述必须手工维护,否则会停留在建 Job 时的口径(2026-09-20 就出现过描述还写着「只构建归档、不签名不上传」,而实际已经是含签名、分区清单、验签与 OSS 上传的发布管线)。当前口径:Job 描述说明「构建 universal → 双架构 smoke → DMG → `-mac` 分区清单 → 更新包验签 → 按 dry-run 决定上传,未做 Apple 签名与公证」;节点描述说明「`genarrative-agc-macos`、EXCLUSIVE 单 executor、仅手动触发、独立 workspace、sccache 在 HOME」。维护手法:该 Job 上 `POST job//config.xml` 会返回 500,节点侧同接口正常;因此改 Job 描述用脚本接口原地更新(保留构建历史),不要为了改描述删 Job 重建。 + 该 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`。 -- 2.52.0 From dbbbef8a0def4036b84456769be0cf8dd8cad126 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 20 Sep 2026 22:47:01 +0800 Subject: [PATCH 13/16] =?UTF-8?q?=E6=8A=8AmacOS=E6=B8=A0=E9=81=93=E7=BA=B3?= =?UTF-8?q?=E5=85=A5=E5=AE=9A=E6=97=B6=E5=8F=91=E5=B8=83=E5=B9=B6=E5=85=B1?= =?UTF-8?q?=E7=94=A8=E6=80=BB=E7=89=88=E6=9C=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调度器在发号后同时触发macOS渠道构建,透传同一个总版本号与固定revision macOS Job改为默认真实发布,演练需显式勾选 新增SKIP_IF_SUPERSEDED:节点离线期间排队的旧构建在恢复后自行让位 补充生产门禁规则与回归断言 同步调度与运维文档口径 --- .../scripts/prepare-macos-codex.test.mjs | 19 ++++++++++-- .../shared-memory/development-workflow.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +-- ...kinsfile.ai-game-creator-shell-macos-build | 29 ++++++++++++++++++- .../Jenkinsfile.scheduled-revision-trigger | 16 +++++++++- scripts/check-production-ops-guardrails.mjs | 12 ++++++++ 6 files changed, 75 insertions(+), 7 deletions(-) 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 c3aba538e..a0e46875b 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 @@ -90,10 +90,25 @@ test('CI pipeline is manual, publishes the macOS partition and never reuses a de !pipeline.includes('genarrative-agc-macos-local'), 'Jenkinsfile 不得写死具体节点名', ); - // dry-run 必须是默认值:不显式取消勾选就不得写入 OSS。 + // 这条管线是正式发布入口(与 Windows 对称):默认真发布,演练需显式勾选。 assert.match( pipeline, - /booleanParam\(name: 'AGC_RELEASE_DRY_RUN', defaultValue: true/u, + /booleanParam\(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false/u, + 'Channel 发布默认必须是真发布,演练只能显式勾选', + ); + // 节点是办公机:离线期间排队的旧构建必须自行让位,且跳过要覆盖后续全部阶段。 + assert.match( + pipeline, + /booleanParam\(name: 'SKIP_IF_SUPERSEDED', defaultValue: false/u, + ); + assert.ok( + pipeline.includes('.jenkins-superseded-by'), + '必须记录被推进的标记供后续阶段判定', + ); + assert.equal( + (pipeline.match(/env\.AGC_BUILD_SUPERSEDED != 'true'/gu) ?? []).length, + 3, + 'Toolchain / Package / Archive 三个阶段都必须按跳过标记收口', ); for (const forbidden of [ 'triggers {', diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index b7084db6c..65d4e4c47 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -90,7 +90,7 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m ## Jenkins 定时版本调度 -定时与版本比较只保留在 `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`。 +定时与版本比较只保留在 `Genarrative-Scheduled-Revision-Trigger` 一处:每小时用 `git ls-remote` 解析 `SOURCE_BRANCH` 远端 HEAD,与上一次触发过的 revision 比较,变化时才把同一个 `COMMIT_HASH` 传给 `Genarrative-Full-Build-And-Deploy`,并先经 `Genarrative-Agc-Global-Version-Issue` 发号、再把同一个总版本号透传给 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`,保证两个客户端的平台分区发布同一个版本。这三个下游 Job 都不得自带 `triggers` / `cron`,也不得在管线内再做一套版本去重;`npm run check:production-ops` 会拦住这两类回退。macOS 节点是日常办公机,调度触发它时置 `SKIP_IF_SUPERSEDED=true`:节点离线期间排队的旧构建在恢复后会自行让位,不发布过期版本。调度状态与生效步骤见 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## Gitea CI 依赖闭合 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 66e062526..dfb57b87c 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -143,7 +143,7 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去 `Genarrative-Scheduled-Revision-Trigger` 是唯一的定时入口,每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名散列,不等同于整点)。它只用 `git ls-remote` 解析 `SOURCE_BRANCH`(默认 `master`)的远端 HEAD,不 checkout 工作区;解析出的完整 commit 与上一次触发过的 revision 相同则标记 `NOT_BUILT` 并结束,不触发任何下游。 -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 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build`,纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。两条下游各自判定:AGC Windows Build 采用「客户端相关路径白名单」,Full Build 采用「与线上站点 / 后端无关的路径黑名单」(`docs/`、`.codex/`、`jenkins/`、`apps/ai-game-creator-shell/`、`apps/mobile-shell/`、`apps/desktop-shell/`、`apps/preview-deployer-web/`、`tools/`、根级 `*.md`),改动只要落在黑名单之外就会照常部署,避免漏发线上站点或后端;两条同时被判为跳过时调度管线只推进 revision 状态、不触发任何发布。AGC 客户端版本号不再由渠道各自递增:唯一发号源是 OSS 对象 `agc/global-version.json`,发号收口到 `Genarrative-Agc-Global-Version-Issue`(`disableConcurrentBuilds()` + 写后回读校验;集群未装 `lockable-resources` 插件)。调度管线与手动发布管线都先调用该 Job 发号,再用归档产物 `agc-global-version.txt` 读取总号并作为 `AGC_RELEASE_VERSION` 透传给客户端构建;统一构建只发一次号供各渠道共用,单渠道热修只把号传给该渠道。显式传入的号低于本渠道当前清单版本时构建失败关闭;`AGC_RELEASE_DRY_RUN` 只预览下一位,不写回、不烧号。一次性播种用 `SEED_ONLY`:基线取「仓库版本 / 各渠道清单 / 旧迁移指针」的最大值,播种本身不烧号。仓库里的 5 个版本文件仍由构建改写,只作构建输入参考,不是事实源。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `commit` 字段,把该提交到本次提交之间触及客户端相关路径的提交标题逐条写进 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准,缺少上一份 `commit` 时不写摘要。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`。 +revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy`、`Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`,各条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。这些下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`(两个平台分区发布同一个发号 Job 下发的总版本号),纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。两条下游各自判定:AGC Windows Build 采用「客户端相关路径白名单」,Full Build 采用「与线上站点 / 后端无关的路径黑名单」(`docs/`、`.codex/`、`jenkins/`、`apps/ai-game-creator-shell/`、`apps/mobile-shell/`、`apps/desktop-shell/`、`apps/preview-deployer-web/`、`tools/`、根级 `*.md`),改动只要落在黑名单之外就会照常部署,避免漏发线上站点或后端;两条同时被判为跳过时调度管线只推进 revision 状态、不触发任何发布。AGC 客户端版本号不再由渠道各自递增:唯一发号源是 OSS 对象 `agc/global-version.json`,发号收口到 `Genarrative-Agc-Global-Version-Issue`(`disableConcurrentBuilds()` + 写后回读校验;集群未装 `lockable-resources` 插件)。调度管线与手动发布管线都先调用该 Job 发号,再用归档产物 `agc-global-version.txt` 读取总号并作为 `AGC_RELEASE_VERSION` 透传给客户端构建;统一构建只发一次号供各渠道共用,单渠道热修只把号传给该渠道。显式传入的号低于本渠道当前清单版本时构建失败关闭;`AGC_RELEASE_DRY_RUN` 只预览下一位,不写回、不烧号。一次性播种用 `SEED_ONLY`:基线取「仓库版本 / 各渠道清单 / 旧迁移指针」的最大值,播种本身不烧号。仓库里的 5 个版本文件仍由构建改写,只作构建输入参考,不是事实源。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `commit` 字段,把该提交到本次提交之间触及客户端相关路径的提交标题逐条写进 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准,缺少上一份 `commit` 时不写摘要。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`。 调度状态是调度 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 仍会继续触发。 @@ -734,7 +734,7 @@ Job 名为 `Genarrative-Agc-MacOS-Build`,SCM 直接读取仓库内上述 Jenki 该 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`,避免按渠道高水位递增时出现版本链回退。 +发布凭据全部走 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` 与 Windows 渠道对称:默认关闭即真发布,勾选后才退化为演练(只打印将上传的对象、不写任何 OSS 对象)。本 Job 是正式发布入口,调度器在发号后与 Windows 一起触发它,并额外传 `SKIP_IF_SUPERSEDED=true`——Mac 节点是日常办公机,离线期间排队的旧构建在节点回来后若已被源码分支推进,直接跳过而不发布过期版本。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 签名,因此未签名不阻断自动更新的校验环节,但「安装 → 重启接管新版本」的实机闭环仍未验证,不得以构建成功替代。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build index 8a72c1550..4744d0d64 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build @@ -12,7 +12,8 @@ pipeline { 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: '可选三段版本号;留空则按 -mac 分区清单高水位递增 patch。首次发布建议显式指定,避免版本链回退') - booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: true, description: '勾选后只构建、验签并打印将上传的对象,不写 OSS;取消勾选才真正发布到 -mac 分区') + booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false, description: '默认直接发布到 -mac 分区;勾选后只构建、验签并打印将上传的对象,不写 OSS(演练)') + booleanParam(name: 'SKIP_IF_SUPERSEDED', defaultValue: false, description: '置真时:本次 COMMIT_HASH 若已被源码分支推进,则直接跳过而不构建。调度器触发本 Job 时置真,避免节点离线期间排队的旧构建在恢复后发布过期版本') 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: '8', description: '并行 rustc 任务数,默认吃满节点 8 核(4P+4E)。该值同时作为 rustc codegen 的 jobserver 令牌上限;节点只有 24 GB 内存且是日常办公机,若构建期间出现明显换页可临时调低。只影响本次构建') @@ -73,11 +74,31 @@ pipeline { # 不清除 node_modules/target 缓存;被跟踪内容始终来自上述 commit。 git clean -fd git rev-parse HEAD > .jenkins-source-commit + if [ "${SKIP_IF_SUPERSEDED}" = "true" ] && [ -n "$COMMIT_HASH" ]; then + latest="$(git rev-parse "refs/remotes/origin/$SOURCE_BRANCH")" + if [ "$latest" != "$(git rev-parse "$COMMIT_HASH^{commit}")" ]; then + # 只置标记,由后面的 when 条件统一收口;不在 shell 里 exit 0, + # 否则会带着旧 commit 继续往下构建。 + echo "[agc-macos] 本次 commit 已被 $SOURCE_BRANCH 推进:$COMMIT_HASH -> $latest,跳过发布" + printf '%s' "$latest" > .jenkins-superseded-by + fi + fi ''' } + script { + if (fileExists('.jenkins-superseded-by')) { + env.AGC_BUILD_SUPERSEDED = 'true' + currentBuild.result = 'NOT_BUILT' + currentBuild.description = "${env.COMMIT_HASH} 已被 ${env.SOURCE_BRANCH} 推进到 ${readFile('.jenkins-superseded-by').trim().take(12)},跳过本轮发布" + echo currentBuild.description + } + } } } stage('Toolchain and dependencies') { + when { + expression { return env.AGC_BUILD_SUPERSEDED != 'true' } + } steps { sh ''' set -eu @@ -96,6 +117,9 @@ pipeline { } } stage('Package, verify and publish') { + when { + expression { return env.AGC_BUILD_SUPERSEDED != 'true' } + } steps { withCredentials([ string(credentialsId: 'AliyunAccessKeyId', variable: 'AGC_OSS_ACCESS_KEY_ID'), @@ -147,6 +171,9 @@ pipeline { } } stage('Archive release') { + when { + expression { return env.AGC_BUILD_SUPERSEDED != 'true' } + } steps { archiveArtifacts artifacts: 'artifacts/*.dmg,artifacts/*.sha256,artifacts/latest.json,artifacts/*.sig,artifacts/release-notes.txt,artifacts/build-manifest.json,.jenkins-source-commit', fingerprint: true, allowEmptyArchive: false } diff --git a/jenkins/Jenkinsfile.scheduled-revision-trigger b/jenkins/Jenkinsfile.scheduled-revision-trigger index d3213b016..46aa861fe 100644 --- a/jenkins/Jenkinsfile.scheduled-revision-trigger +++ b/jenkins/Jenkinsfile.scheduled-revision-trigger @@ -1,5 +1,5 @@ // 定时版本调度管线:解析远端分支版本,只有和上一次触发过的版本不同时, -// 才用同一个固定 commit 触发 Full Build 与 AGC Windows Build 两个下游管线。 +// 才用同一个固定 commit 触发 Full Build、AGC 发号与 AGC Windows/macOS 渠道构建。 pipeline { agent { label 'linux && genarrative-build' @@ -20,6 +20,7 @@ pipeline { 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' + AGC_MACOS_BUILD_JOB_NAME = 'Genarrative-Agc-MacOS-Build' AGC_GLOBAL_VERSION_JOB_NAME = 'Genarrative-Agc-Global-Version-Issue' AGC_GLOBAL_VERSION_ARTIFACT = 'agc-global-version.txt' REVISION_STATE_FILE = '.jenkins-last-triggered-revision' @@ -177,6 +178,18 @@ pipeline { string(name: 'AGC_RELEASE_VERSION', value: env.AGC_GLOBAL_VERSION), ] build job: env.AGC_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcParameters + // macOS 与 Windows 共用发号 Job 下发的同一个总号:调度只透传号与固定 revision, + // 不允许 Mac 侧自行发号,否则同一轮两个平台会拿到不同的号。 + def agcMacosParameters = [ + string(name: 'SOURCE_BRANCH', value: env.SOURCE_BRANCH), + string(name: 'COMMIT_HASH', value: pinnedRevision), + string(name: 'AGC_UPDATE_CHANNEL', value: 'dev'), + string(name: 'AGC_RELEASE_VERSION', value: env.AGC_GLOBAL_VERSION), + // Mac 节点是办公机:离线期间排队的旧构建在节点回来后必须自行让位, + // 不允许发布一轮已经过期的版本。 + booleanParam(name: 'SKIP_IF_SUPERSEDED', value: true), + ] + build job: env.AGC_MACOS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcMacosParameters agcTriggered = true } else { echo "本轮提交不含 AGC 相关路径,跳过 ${env.AGC_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER" @@ -185,6 +198,7 @@ pipeline { def triggered = [] if (fullTriggered) { triggered.add(env.FULL_BUILD_JOB_NAME) } if (agcTriggered) { triggered.add(env.AGC_BUILD_JOB_NAME) } + if (agcTriggered) { triggered.add(env.AGC_MACOS_BUILD_JOB_NAME) } def target = "${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}" currentBuild.description = triggered.isEmpty() ? "本轮提交与两条下游都无关,未触发任何发布:${target}" diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 998b8dec2..4c2fe6f44 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -7723,6 +7723,18 @@ for (const [snippet, reason] of [ "AGC_GLOBAL_VERSION_ARTIFACT = 'agc-global-version.txt'", '必须从发号 Job 的归档产物读取总版本号', ], + [ + "AGC_MACOS_BUILD_JOB_NAME = 'Genarrative-Agc-MacOS-Build'", + '必须把 macOS 渠道构建纳入同一轮调度', + ], + [ + 'build job: env.AGC_MACOS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcMacosParameters', + '必须用独立参数列表触发 macOS 渠道构建(pinnedParameters 只允许 Full Build 使用一次)', + ], + [ + "booleanParam(name: 'SKIP_IF_SUPERSEDED', value: true)", + '必须让节点离线期间排队的 macOS 旧构建自行让位,不发布过期版本', + ], ]) { if (!scheduledRevisionTriggerContent.includes(snippet)) { failed = true; -- 2.52.0 From d354e5e7c3c7e19c09180b87c7df305a056a06ed Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 20 Sep 2026 22:58:39 +0800 Subject: [PATCH 14/16] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8F=91=E5=8F=B7Job?= =?UTF-8?q?=E6=9C=AA=E6=8E=88=E6=9D=83=E8=B0=83=E5=BA=A6=E5=99=A8=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E5=BD=92=E6=A1=A3=E5=AF=BC=E8=87=B4=E6=95=B4=E8=BD=AE?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发号Jenkinsfile显式copyArtifactPermission授权调度管线 生产门禁新增该授权规则避免回归 运维文档补充生产者到调度器的Copy Artifact映射与失败后果 --- docs/【开发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- jenkins/Jenkinsfile.agc-global-version-issue | 7 +++++++ scripts/check-production-ops-guardrails.mjs | 7 +++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index dfb57b87c..f8af79922 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -698,7 +698,7 @@ cat /var/lib/genarrative/health-patrol/status.json 如需接外部告警,可在 `/etc/genarrative/health-patrol.env` 配置 `GENARRATIVE_HEALTH_PATROL_WEBHOOK_URL`;脚本只会在 `WARNING` 或 `CRITICAL` 时向该 webhook 发送 JSON。未配置 webhook 时,告警来源是 systemd 失败状态、journal 和状态文件。 -Jenkins Copy Artifact 必须保持 `Production` 权限模式;产物生产者要在 Jenkinsfile 中用 `copyArtifactPermission` 精确授权消费者,不能依赖 Migration 模式或全局 `Job/Read`。固定映射为 `Genarrative-Stdb-Module-Build` → `Genarrative-Stdb-Module-Publish`、`Genarrative-Api-Build` → `Genarrative-Api-Deploy`、`Genarrative-Web-Build` → `Genarrative-Web-Deploy`、`Genarrative-Database-Export` → `Genarrative-Database-Import`。如果 `copyArtifacts` 报 `Unable to find project for artifact copy`,但来源 Job、指定构建号和归档都实际存在,先检查来源 Job 的 `CopyArtifactPermissionProperty`;修复 Jenkinsfile 后必须先运行一次产物生产者,让 Declarative Pipeline 把 Job property 写回 Jenkins,再重跑 Deploy / Publish / Import。`npm run check:production-ops` 会防止四条白名单再次丢失。 +Jenkins Copy Artifact 必须保持 `Production` 权限模式;产物生产者要在 Jenkinsfile 中用 `copyArtifactPermission` 精确授权消费者,不能依赖 Migration 模式或全局 `Job/Read`。固定映射为 `Genarrative-Stdb-Module-Build` → `Genarrative-Stdb-Module-Publish`、`Genarrative-Api-Build` → `Genarrative-Api-Deploy`、`Genarrative-Web-Build` → `Genarrative-Web-Deploy`、`Genarrative-Database-Export` → `Genarrative-Database-Import`、`Genarrative-Agc-Global-Version-Issue` → `Genarrative-Scheduled-Revision-Trigger`(发号 Job 必须授权调度器读 `agc-global-version.txt`;缺这条会让整轮调度在发号之后失败——号已烧、渠道构建没被触发,2026-09-20 #103 即为此)。如果 `copyArtifacts` 报 `Unable to find project for artifact copy`,但来源 Job、指定构建号和归档都实际存在,先检查来源 Job 的 `CopyArtifactPermissionProperty`;修复 Jenkinsfile 后必须先运行一次产物生产者,让 Declarative Pipeline 把 Job property 写回 Jenkins,再重跑 Deploy / Publish / Import。`npm run check:production-ops` 会防止四条白名单再次丢失。 `Genarrative-Web-Build` 的主站构建失败若出现 Rollup 报错 `"xxx" is not exported by "src/services/publicWorkCode.ts"`,优先按前端公开作品号工具缺失处理,而不是排查 Jenkins 节点环境。修复时要让 `publicWorkCode.ts` 的 `buildPublicWorkCode` 与 `isSamePublicWorkCode` 成对导出,并补 `src/services/publicWorkCode.test.ts` 覆盖对应玩法前缀;随后用 `npm run build:production-release -- --component web --name <临时名>` 复现 Jenkins web 构建路径。 diff --git a/jenkins/Jenkinsfile.agc-global-version-issue b/jenkins/Jenkinsfile.agc-global-version-issue index 62b43f220..b36fa6d19 100644 --- a/jenkins/Jenkinsfile.agc-global-version-issue +++ b/jenkins/Jenkinsfile.agc-global-version-issue @@ -15,6 +15,13 @@ pipeline { 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」, + // 整轮调度失败,且失败点在发号之后 —— 号已烧、Windows 也没被触发 + // (2026-09-20 首次统一发号 #103 即命中)。改完本文件后必须先单独跑一次本 Job, + // 让 Declarative Pipeline 把 Job property 写回 Jenkins,再重跑调度。 + copyArtifactPermission('Genarrative-Scheduled-Revision-Trigger') } environment { diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 4c2fe6f44..442768df5 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -42,6 +42,13 @@ const checks = [ reason: 'Copy Artifact Production 模式下,API Build 必须显式授权 API Deploy 读取归档。', }, + { + file: 'jenkins/Jenkinsfile.agc-global-version-issue', + includes: + "copyArtifactPermission('Genarrative-Scheduled-Revision-Trigger')", + reason: + 'Copy Artifact Production 模式下,AGC 发号 Job 必须显式授权调度管线读取 agc-global-version.txt,否则整轮调度会在 copyArtifacts 处失败。', + }, { file: 'jenkins/Jenkinsfile.production-stdb-module-build', includes: 'npm run check:rustfmt', -- 2.52.0 From 8cffbf61126c8e9451df00da97d739c966b9a3e1 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 21 Sep 2026 00:47:05 +0800 Subject: [PATCH 15/16] =?UTF-8?q?=E5=9B=9E=E9=80=80=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E6=97=B6=E8=A2=AB=E9=92=A9=E5=AD=90=E8=AF=AF=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=8C=96=E7=9A=84=E6=97=A0=E5=85=B3=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 过程中发现并修复:本地 pre-commit 钩子在提交合并时pipeline里prettier会重排无关文件,导致本PR携带与客户端无关的上下文 本提交把20个纯格式化文件恢复为主线内容,PR只保留macOS构建管线和发布链路相关改动 后续提交不再把这些文件纳入范围 --- .../scripts/agc-global-version.test.mjs | 7 +----- .../scripts/start-dev-stack.mjs | 2 +- .../app-shell/ActiveProjectRunsPanel.tsx | 16 ++++++------- .../generated/DirectThreadRequestKind.ts | 4 +--- .../resourceCanvasFocusModel.ts | 6 ++--- ...esourceCanvasGenerationPlaceholderModel.ts | 4 +--- .../runtime-config/RuntimeConfigDialog.tsx | 7 +----- .../ResourceModelViewer.tsx | 5 ++-- .../resourceBatchTagTargetModel.ts | 3 +-- .../resourceCanvasLayoutModel.ts | 8 +++---- .../project-development/resourceModelScene.ts | 13 ++++------ .../useProjectResourceCanvasLayout.ts | 4 +--- .../tests/appSurface/harness.ts | 6 ++--- .../tests/appUpdate.test.ts | 20 +++++++--------- .../tests/resourceVersionReplacement.test.tsx | 3 +-- .../tests/workspaceWindowSync.test.tsx | 3 +-- .../image-editor/ImageCanvasWorldView.tsx | 2 +- src/services/apiClient.ts | 4 ++-- .../image-editor/editorProjectClient.test.ts | 3 +-- .../image-editor/editorProjectClient.ts | 24 ++++--------------- 20 files changed, 49 insertions(+), 95 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/agc-global-version.test.mjs b/apps/ai-game-creator-shell/scripts/agc-global-version.test.mjs index abb218558..2d14f5963 100644 --- a/apps/ai-game-creator-shell/scripts/agc-global-version.test.mjs +++ b/apps/ai-game-creator-shell/scripts/agc-global-version.test.mjs @@ -185,12 +185,7 @@ test('nextVersion 只在 patch 位递增', () => { test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => { const base = { - args: [ - 'cp', - '--force', - '/tmp/a.json', - 'oss://agc-dev/agc/global-version.json', - ], + args: ['cp', '--force', '/tmp/a.json', 'oss://agc-dev/agc/global-version.json'], endpoint: 'oss-rg-china-mainland.aliyuncs.com', accessKeyId: 'id', accessKeySecret: 'secret', diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 20cf4a1ea..68fc0909a 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -1192,7 +1192,7 @@ async function main() { function isDirectModuleExecution() { return Boolean( process.argv[1] && - resolve(process.argv[1]) === fileURLToPath(import.meta.url), + resolve(process.argv[1]) === fileURLToPath(import.meta.url), ); } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx index 1df87f2bb..e4af6c8be 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx @@ -166,10 +166,10 @@ export function ActiveProjectRunsPanel({ const elapsed = formatActiveTurnElapsed(turn.startedAt, now); const isCurrent = Boolean( currentProjectPath && - projectPathsMatchForInvalidation( - turn.projectPath, - currentProjectPath, - ), + projectPathsMatchForInvalidation( + turn.projectPath, + currentProjectPath, + ), ); return (
  • @@ -215,10 +215,10 @@ export function ActiveProjectRunsPanel({ const elapsed = formatActiveTurnElapsed(turn.startedAt, now); const isCurrent = Boolean( currentProjectPath && - projectPathsMatchForInvalidation( - turn.projectPath, - currentProjectPath, - ), + projectPathsMatchForInvalidation( + turn.projectPath, + currentProjectPath, + ), ); return (
  • diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts index 13ee401f3..71de741e2 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts @@ -4,6 +4,4 @@ * 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。 */ export type DirectThreadRequestKind = - | 'approval.requested' - | 'ask.requested' - | 'request.resolved'; + 'approval.requested' | 'ask.requested' | 'request.resolved'; diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts index a61125c41..b08cf7620 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts @@ -48,9 +48,9 @@ export function isResourceCanvasPanTarget( if (!isResourceCanvasInteractionTarget(target)) return true; return Boolean( target.closest('.game-resource-card') && - !target.closest( - 'button:not(.game-resource-card-select), [role="button"], input, textarea, select, a, audio, video', - ), + !target.closest( + 'button:not(.game-resource-card-select), [role="button"], input, textarea, select, a, audio, video', + ), ); } diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPlaceholderModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPlaceholderModel.ts index 0f54be206..604ab94d5 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPlaceholderModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPlaceholderModel.ts @@ -40,9 +40,7 @@ export type ResourceCanvasGenerationPlaceholderPanel = * - `failed`:这次生成失败;占位留在画布上,点它可以用同一份输入与引用重试。 */ export type ResourceCanvasGenerationPlaceholderStatus = - | 'draft' - | 'submitted' - | 'failed'; + 'draft' | 'submitted' | 'failed'; /** * 工具点击后先在当前栏目创建的临时占位卡。 diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 37ebb7361..de412fabb 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -78,12 +78,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { }; type RuntimeSettingsSection = - | 'general' - | 'workspace' - | 'agents' - | 'extensions' - | 'advanced' - | 'about'; + 'general' | 'workspace' | 'agents' | 'extensions' | 'advanced' | 'about'; type RuntimeConfigToast = { tone: 'success' | 'error'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx index 4b06525cc..8b6e4ddca 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx @@ -61,9 +61,8 @@ export function ResourceModelViewer({ void (async () => { try { const THREE = await import('three'); - const { OrbitControls } = await import( - 'three/examples/jsm/controls/OrbitControls.js' - ); + const { OrbitControls } = + await import('three/examples/jsm/controls/OrbitControls.js'); if (disposed) { return; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceBatchTagTargetModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceBatchTagTargetModel.ts index d7829366a..ff3eed6ed 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceBatchTagTargetModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceBatchTagTargetModel.ts @@ -22,8 +22,7 @@ export type ResourceBatchTagTarget = { }; export type ResourceBatchTagTargetResolution = - | { ok: true; assetIds: string[] } - | { ok: false; reason: string }; + { ok: true; assetIds: string[] } | { ok: false; reason: string }; export function resolveResourceBatchTagTargets( resources: readonly { diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts index 97ba9a92b..9e49c99c5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts @@ -127,10 +127,10 @@ function validResourceCanvasCardSize( ): size is ResourceCanvasCardSize { return Boolean( size && - Number.isFinite(size.width) && - Number.isFinite(size.height) && - size.width > 0 && - size.height > 0, + Number.isFinite(size.width) && + Number.isFinite(size.height) && + size.width > 0 && + size.height > 0, ); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts index 6d74551b8..d81cfdd6a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts @@ -32,14 +32,12 @@ export async function loadResourceModelObject( source: ResourceModelSource, ): Promise { if (isResourceModelFbx(source)) { - const { FBXLoader } = await import( - 'three/examples/jsm/loaders/FBXLoader.js' - ); + const { FBXLoader } = + await import('three/examples/jsm/loaders/FBXLoader.js'); return new FBXLoader().loadAsync(source.sourceUrl); } - const { GLTFLoader } = await import( - 'three/examples/jsm/loaders/GLTFLoader.js' - ); + const { GLTFLoader } = + await import('three/examples/jsm/loaders/GLTFLoader.js'); const gltf = await new GLTFLoader().loadAsync(source.sourceUrl); if (!gltf.scene) { throw new Error('模型内容为空'); @@ -106,8 +104,7 @@ export function disposeResourceModelObject(object: ThreeTypes.Object3D) { entry as unknown as Record, )) { const texture = value as - | { isTexture?: boolean; dispose?: () => void } - | undefined; + { isTexture?: boolean; dispose?: () => void } | undefined; if (texture?.isTexture && typeof texture.dispose === 'function') { texture.dispose(); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index e028ca312..dea363f88 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -439,9 +439,7 @@ function reconcileLayout( * 做,磁盘上仍是旧栏目取值的坐标不会因为 `section` 字面量不同而漏出整理范围。 */ organizeSections: - | readonly ProjectResourceCanvasCategory[] - | null - | undefined = undefined, + readonly ProjectResourceCanvasCategory[] | null | undefined = undefined, ) { // `undefined` = 不是整理;`null` = 整理全部栏目;否则按集合判定。 const organizedSections = diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index a7464bfd7..3b38bd2fe 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -605,11 +605,9 @@ function createProjectSupervisorRuntimeHarness({ }) => void) | null = null; let designAgentUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; + ((event: { payload: Record }) => void) | null = null; let directThreadNotifyHandler: - | ((event: { payload: { subscriptionId: string } }) => void) - | null = null; + ((event: { payload: { subscriptionId: string } }) => void) | null = null; let directThreadSubscriptionId: string | null = null; let directThreadSubscriptionSequence = 0; // 未消费的运行态事件队列:`subscribe` 的 bootstrap 与 `consume` 都从这里取, diff --git a/apps/ai-game-creator-shell/tests/appUpdate.test.ts b/apps/ai-game-creator-shell/tests/appUpdate.test.ts index bd89c3a3a..026dcdc52 100644 --- a/apps/ai-game-creator-shell/tests/appUpdate.test.ts +++ b/apps/ai-game-creator-shell/tests/appUpdate.test.ts @@ -43,9 +43,8 @@ describe('AGC 客户端更新', () => { it('开发态开关关闭时不请求清单', async () => { vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '0'); vi.resetModules(); - const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import( - '../src/services/appUpdate' - ); + const { checkForAppUpdate, resetAppUpdateCheckForTests } = + await import('../src/services/appUpdate'); await expect(checkForAppUpdate()).resolves.toBeNull(); expect(checkMock).not.toHaveBeenCalled(); @@ -56,9 +55,8 @@ describe('AGC 客户端更新', () => { vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); vi.resetModules(); checkMock.mockResolvedValue(fakeUpdate() as never); - const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import( - '../src/services/appUpdate' - ); + const { checkForAppUpdate, resetAppUpdateCheckForTests } = + await import('../src/services/appUpdate'); await expect(checkForAppUpdate()).resolves.toEqual({ version: '99.0.0', @@ -74,9 +72,8 @@ describe('AGC 客户端更新', () => { vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); vi.resetModules(); checkMock.mockRejectedValue(new Error('updater: manifest 404')); - const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import( - '../src/services/appUpdate' - ); + const { checkForAppUpdate, resetAppUpdateCheckForTests } = + await import('../src/services/appUpdate'); await expect(checkForAppUpdate()).resolves.toBeNull(); resetAppUpdateCheckForTests(); @@ -110,9 +107,8 @@ describe('AGC 客户端更新', () => { it('没有待安装更新时安装请求失败关闭', async () => { vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); vi.resetModules(); - const { installAppUpdate, resetAppUpdateCheckForTests } = await import( - '../src/services/appUpdate' - ); + const { installAppUpdate, resetAppUpdateCheckForTests } = + await import('../src/services/appUpdate'); await expect(installAppUpdate()).rejects.toThrow('没有可安装的更新'); resetAppUpdateCheckForTests(); diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx index e8f2b5e4b..639ff14bd 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx @@ -297,8 +297,7 @@ function renderReplacementWorkbench(options: RenderOptions = {}) { } if (command === 'update_local_project_resource_classification') { const input = args?.input as - | { assetId?: string; category?: string; tags?: string[] } - | undefined; + { assetId?: string; category?: string; tags?: string[] } | undefined; return { asset: { id: input?.assetId, diff --git a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx index a8bd7b8cf..fef868db1 100644 --- a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx @@ -21,8 +21,7 @@ import { const homeProjectOverride = vi.hoisted(() => ({ openProject: null as - | null - | ((path: string, mode: 'open' | 'create') => Promise), + null | ((path: string, mode: 'open' | 'create') => Promise), })); vi.mock('../src/features/app-shell/useHomeProjectCreation', async () => { diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index a9e1e9622..70b8d4cde 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -1313,7 +1313,7 @@ export function ImageCanvasWorldView({ {canvasGenerationDialogs.map((dialog) => { const hasGeneratedLayer = Boolean( dialog.generatedLayerId && - layers.some((layer) => layer.id === dialog.generatedLayerId), + layers.some((layer) => layer.id === dialog.generatedLayerId), ); return dialog.placeholder && !hasGeneratedLayer ? (() => { diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index f594ca662..c393815a6 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -887,7 +887,7 @@ export async function fetchWithApiAuth( requestHeaders[REQUEST_ID_HEADER] = requestId; let hasAuthHeader = Boolean( requestHeaders.Authorization?.trim() || - requestHeaders.authorization?.trim(), + requestHeaders.authorization?.trim(), ); if ( @@ -903,7 +903,7 @@ export async function fetchWithApiAuth( requestHeaders[REQUEST_ID_HEADER] = requestId; hasAuthHeader = Boolean( requestHeaders.Authorization?.trim() || - requestHeaders.authorization?.trim(), + requestHeaders.authorization?.trim(), ); } catch (error) { if (requestSignal?.aborted) { diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 79e965d9f..2e5188b53 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -211,8 +211,7 @@ describe('editorProjectClient', () => { { deadlineAt: expect.any(Number) }, ); const requestOptions = requestJsonMock.mock.calls[0]?.[3] as - | { deadlineAt?: number } - | undefined; + { deadlineAt?: number } | undefined; expect(requestOptions?.deadlineAt).toBeGreaterThanOrEqual( startedAt + 60_000, ); diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 55003d328..4c5fae2bf 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -219,9 +219,7 @@ export type EditorAssetGenerationInputs = { }; export type EditorProjectResourceSourceType = - | 'uploaded' - | 'generated' - | 'mock_generated'; + 'uploaded' | 'generated' | 'mock_generated'; export type EditorProjectResourceSnapshot = { resourceId: string; @@ -330,11 +328,7 @@ export type EditorImageGenerationInput = { prompt: string; size?: string; kind?: - | 'spec' - | 'character' - | 'quick-edit' - | 'ui-design' - | 'publication-material'; + 'spec' | 'character' | 'quick-edit' | 'ui-design' | 'publication-material'; model?: string; screenColor?: string; segModel?: string; @@ -544,12 +538,7 @@ export type EditorIconSpritesheetSliceResult = { export type EditorCharacterAnimationResolution = '480p' | '720p'; export type EditorCharacterAnimationRatio = - | 'same' - | '1:1' - | '4:3' - | '16:9' - | '9:16' - | '3:4'; + 'same' | '1:1' | '4:3' | '16:9' | '9:16' | '3:4'; export type EditorCharacterAnimationFrameCount = 32 | 40 | 48; @@ -611,12 +600,7 @@ export type EditorVideoModel = export type EditorVideoResolution = '480p' | '720p' | '1080p'; export type EditorVideoSoundMode = 'on' | 'off'; export type EditorVideoAspectRatio = - | '16:9' - | '9:16' - | '1:1' - | '4:3' - | '3:4' - | '21:9'; + '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9'; export type EditorVideoGenerationInput = { prompt: string; -- 2.52.0 From 65b7ae0e2bb76c2d153c5211c0540ff99ef80b90 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 21 Sep 2026 00:48:55 +0800 Subject: [PATCH 16/16] =?UTF-8?q?=E4=BF=AE=E6=AD=A3Mac=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E7=9A=84=E4=B8=AA=E4=BA=BA=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E3=80=81=E8=B6=85=E6=97=B6=E4=BD=99=E9=87=8F=E4=B8=8E=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E6=96=87=E6=A1=A3=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jenkinsfile的PATH改为按$HOME展开,移除写死的节点用户名 构建超时90分钟提高到150分钟并补失败/中断诊断,避免冷构建被中断在链接阶段 同步更新检查技术方案里dry-run默认值与调度接入的过期描述 新增回归断言:禁止个人Home路径、锁定超时余量与诊断文案 --- .../scripts/prepare-macos-codex.test.mjs | 17 ++++++++++++++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 4 ++-- ...kinsfile.ai-game-creator-shell-macos-build | 20 +++++++++++++++---- 3 files changed, 35 insertions(+), 6 deletions(-) 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 a0e46875b..4caae3656 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 @@ -101,6 +101,23 @@ test('CI pipeline is manual, publishes the macOS partition and never reuses a de pipeline, /booleanParam\(name: 'SKIP_IF_SUPERSEDED', defaultValue: false/u, ); + // 仓库文件不得出现节点用户名/个人 Home 路径:换机或改名后必须仍然可用。 + assert.ok( + !pipeline.includes('/Users/'), + 'Jenkinsfile 不得写死个人 Home 路径,工具链位置应按 $HOME 展开', + ); + assert.ok( + pipeline.includes('export PATH="$HOME/'), + 'PATH 必须在 shell 步骤里按 $HOME 展开', + ); + // 超时必须高于实测最慢(78 分钟冷构建 + 共用机器),否则会被中断在链接阶段。 + assert.ok( + pipeline.includes('timeout(time: 150'), + '构建超时上限必须留出冷构建余量', + ); + for (const diagnostic of ['macOS 发布失败', '被中断']) { + assert.ok(pipeline.includes(diagnostic), diagnostic); + } assert.ok( pipeline.includes('.jenkins-superseded-by'), '必须记录被推进的标记供后续阶段判定', diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 6ec06d077..058cc2371 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -171,11 +171,11 @@ - macOS 采用 universal 包,两个平台键对应同一更新产物;主程序用 lipo 检查两种架构,Codex 资源分别做原生身份/摘要与启动验证。Rosetta 结果不代替 Intel 真机验收。 - 旧客户端迁移桥:保留一个版本周期。渠道清单上线后,发布管线同时把旧的 `agc/latest.json`(sha256 格式)指向 `dev-win` 最新安装包,让已发布客户端自动升级到新协议;下个周期整条删除。 - 签名密钥:由本仓库维护者生成并保管,私钥保存在仓库外(`%USERPROFILE%\.tauri\genarrative-agc-updater.key`),只有公钥进入客户端配置;Jenkins 用受保护凭据 `AgcUpdaterSigningKey` 与 `AgcUpdaterSigningKeyPassword` 注入为 Tauri 打包器读取的 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,本机可用 `TAURI_SIGNING_PRIVATE_KEY_PATH` 指向同一私钥。当前密钥不带密码;首次发布前仍可重新生成,首次发布后不可更换。 -- macOS 发布方式:已接入专用 macOS Jenkins 节点(label `genarrative-agc-macos`,EXCLUSIVE 单 executor),由 `Jenkinsfile.ai-game-creator-shell-macos-build` 执行 `scripts/build-macos-ci.mjs` 完成 universal 构建、双架构隔离 smoke、universal DMG、分区清单生成、更新包验签与 OSS 上传。`AGC_RELEASE_DRY_RUN` 默认为开,只有显式关闭才会写入 OSS。 +- macOS 发布方式:已接入专用 macOS Jenkins 节点(label `genarrative-agc-macos`,EXCLUSIVE 单 executor),由 `Jenkinsfile.ai-game-creator-shell-macos-build` 执行 `scripts/build-macos-ci.mjs` 完成 universal 构建、双架构隔离 smoke、universal DMG、分区清单生成、更新包验签与 OSS 上传。`AGC_RELEASE_DRY_RUN` 默认为关(与 Windows 渠道对称,即直接发布),只有勾选后才退化为「只打印上传计划、不写 OSS」的演练。 - macOS 代码签名与公证暂缺:产物为未签名 + 未公证,构建入口剥离 `APPLE_*` 凭据跳过 Apple 签名,不传 `--no-sign`(它还会跳过 updater 的 minisign 签名,产物将没有 `.sig`);构建清单实测记录 `appleSigned` 与签名类型,`latest.json` 侧固定记录 `notarized=false`,首装需用户在 Gatekeeper 中手动放行。该限制作为已知未验证项记录,不静默通过;「安装 → 重启接管新版本」的自动更新闭环仍需实机验收。 - 更新包验签门禁:构建完成、上传 OSS 之前,用产物内烘焙的 `plugins.updater.pubkey` 复核 `<更新包>.sig`(Tauri 使用 minisign 的 `ED` 预哈希模式)。keyId 不一致或校验失败立即失败关闭,禁止上传——客户端校验失败会直接拒绝安装,且公钥发布后不可更换。 待办: -- macOS 分区(`-mac`)已落地构建与发布能力:Mac Jenkins 节点、release 入口(更新包 + 签名 + 首装包 + 分区清单)、验签门禁与 dry-run 默认开启均已就绪。 +- macOS 分区(`-mac`)已落地构建与发布能力:Mac Jenkins 节点、release 入口(更新包 + 签名 + 首装包 + 分区清单)、验签门禁与调度接入均已就绪,默认直接发布。 - 剩余待办:Apple 代码签名与公证凭据(未就绪期间以未验证项记录)、macOS 安装后重启接管新版本的实机验证、Intel 真机 smoke(当前 x86_64 侧为 Rosetta)。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build index 4744d0d64..91bcfdc97 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build @@ -3,7 +3,9 @@ pipeline { options { disableConcurrentBuilds() skipDefaultCheckout(true) - timeout(time: 90, unit: 'MINUTES') + // 实测参考:合并主线后的双架构构建 68.5 分钟、更早一次 78 分钟;节点是共用办公机, + // 冷 sccache + 机器被占用时会更慢,因此上限放到 150 分钟而不是贴着观测值。 + timeout(time: 150, unit: 'MINUTES') buildDiscarder(logRotator(numToKeepStr: '10', artifactNumToKeepStr: '3')) timestamps() } @@ -30,7 +32,8 @@ pipeline { RUSTC_WRAPPER = 'sccache' SCCACHE_CACHE_SIZE = '20G' CARGO_INCREMENTAL = '0' - PATH = '/Users/suzmii/.local/bin:/Users/suzmii/.cargo/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin' + // 不把节点用户名写进仓库:PATH 在下面的 shell 步骤里按 $HOME 展开。 + AGC_EXTRA_PATH = '/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin' } stages { stage('Checkout') { @@ -102,6 +105,8 @@ pipeline { steps { sh ''' set -eu + # LaunchAgent 只给最小 PATH:按 $HOME 展开工具链位置,不把节点用户名写进仓库。 + export PATH="$HOME/.local/bin:$HOME/.cargo/bin:${AGC_EXTRA_PATH}" test "$(uname -m)" = arm64 node --version npm --version @@ -136,8 +141,9 @@ pipeline { "AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}", ]) { sh ''' - set -eu - echo "[agc-macos] 渠道=${AGC_UPDATE_CHANNEL} 分区=${AGC_UPDATE_CHANNEL}-mac 目标=universal-apple-darwin dry-run=${AGC_RELEASE_DRY_RUN}" + set -eu + export PATH="$HOME/.local/bin:$HOME/.cargo/bin:${AGC_EXTRA_PATH}" + echo "[agc-macos] 渠道=${AGC_UPDATE_CHANNEL} 分区=${AGC_UPDATE_CHANNEL}-mac 目标=universal-apple-darwin dry-run=${AGC_RELEASE_DRY_RUN}" ossutil_bin="${OSSUTIL_BIN:-ossutil}" if command -v "${ossutil_bin}" >/dev/null 2>&1; then "${ossutil_bin}" --version | head -1 @@ -180,6 +186,12 @@ pipeline { } } post { + failure { + echo 'macOS 发布失败:先看本构建控制台末尾。常见原因——更新包缺 .sig(误传 --no-sign 或签名私钥未注入)、DMG 幂等失败(workspace 残留同名产物)、可用空间低于 8 GiB、以及被 SKIP_IF_SUPERSEDED 之外的提交校验拒绝。' + } + aborted { + echo 'macOS 发布被中断(超时或人工中止):构建未走到归档阶段时不会有任何产物,也不会写 OSS。' + } success { echo params.AGC_RELEASE_DRY_RUN ? 'macOS universal 渠道演练完成:已构建、验签并生成清单,未写入 OSS;Apple 签名与公证暂缺。' -- 2.52.0