diff --git a/.gitignore b/.gitignore index f66e67ded..f34fd1bd3 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/ /plugins/agc-unity-editor/dotnet/**/bin/ /plugins/agc-unity-editor/dotnet/**/obj/ 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..442df54d1 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs @@ -0,0 +1,280 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } 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 { + generateUpdateManifest, + prepareReleaseVersion, + resolveReleaseContext, + resolveReleasePartition, + runTauriBuild, +} from './build-release.mjs'; +import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs'; +import { + readUpdaterPubkey, + verifyUpdaterSignature, +} from './verify-updater-signature.mjs'; + +/** + * AGC macOS 分区(`-mac`)发布入口:构建 universal 包 → 双架构 smoke → 生成 universal DMG + * → 生成分区清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。 + * + * 边界: + * - Apple 签名与公证暂缺:本入口剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名,但**不能传 + * `--no-sign`** —— 该标志同时会跳过 updater 的 minisign 签名,产物就没有 `.sig`; + * 未签名 + 未公证必须显式记录而非静默通过; + * - 更新包签名(TAURI_SIGNING_PRIVATE_KEY,minisign)是硬需求:缺了客户端一律拒绝安装, + * 因此构建前要求凭据存在,构建后用内置公钥复核 `.sig` 才允许继续上传; + * - 未通过验签绝不写 OSS:上传顺序为更新包、签名、首装包,全部成功后才覆盖渠道清单指针。 + */ +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, + 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 可用空间;禁止自动清理开发缓存', +); + +// 仅剥离 Apple 签名/公证变量:本节点没有证书,误用只会让构建失败; +// 更新包签名与 OSS 凭据必须保留,它们是本入口发布能力的组成部分。 +for (const key of Object.keys(process.env)) { + if (/^APPLE_/u.test(key)) delete process.env[key]; +} +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 partition = resolveReleasePartition(context.channel, context.target); +const version = await prepareReleaseVersion(context); +// 首装包名必须保持 `<产品名>_<版本>_universal.dmg`:清单侧按该后缀唯一匹配本次产物。 +const firstInstallName = `${productName}_${version}_universal.dmg`; + +// 幂等边界:workspace 会保留上一轮产物。先删掉本次将要写出的对象,否则 +// 1) hdiutil 会因同名 DMG 已存在直接失败(首次实跑即命中); +// 2) 上一轮遗留的 `.sig` 会让验签门禁把「本轮其实没签」判成通过。 +// 只删本次要写出的确切路径,不动其它版本产物与编译缓存。 +const macosBundle = path.join(context.bundleRoot, 'macos'); +for (const stale of [ + 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'), +]) { + fs.rmSync(stale, { force: true }); +} + +const args = [ + '--target=universal-apple-darwin', + '--bundles', + 'app', + '--ci', + // 刻意不传 `--no-sign`:它会连带跳过 updater 签名,而客户端强制校验更新包签名。 + // Apple 侧改为剥离 APPLE_* 凭据,未配置身份时 Tauri 不签名也不失败。 + // 基础配置已开启;这里显式声明,避免被其它配置来源关掉后静默失去更新能力。 + '--config', + '{"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', appBundleName); +for (const architecture of ['arm64', 'x86_64']) { + command(process.execPath, [ + path.join(appRoot, 'scripts/check-macos-bundle.mjs'), + app, + architecture, + '--universal', + ]); +} + +// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `__universal.dmg`。 +const dmgDirectory = path.join(context.bundleRoot, 'macos'); +fs.mkdirSync(dmgDirectory, { recursive: true }); +const dmg = path.join(dmgDirectory, firstInstallName); +const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-ci-dmg-')); +try { + command('ditto', [app, path.join(stage, appBundleName)]); + fs.symlinkSync('/Applications', path.join(stage, 'Applications')); + command('hdiutil', [ + 'create', + // 前面已删除同名对象;这里再要求显式覆盖,避免残留文件让构建以「文件已存在」失败。 + '-ov', + '-volname', + productName, + '-srcfolder', + stage, + '-format', + 'UDZO', + dmg, + ]); + command('hdiutil', ['verify', dmg]); +} finally { + fs.rmSync(stage, { recursive: true, force: true }); +} + +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', +}).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( + { + version, + commit, + target: context.target, + channel: context.channel, + // Apple 签名与公证暂缺:显式记录为未验证项,不静默通过。 + appleSigned, + appleSignatureKind, + notarized: false, + dryRun, + uploaded: !dryRun, + updaterSignature: { + algorithm: signature.algorithm, + keyId: signature.keyId, + verified: true, + }, + oss: { + bucket, + endpoint, + partition, + latest: `oss://${bucket}/agc/${partition}/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( + dryRun + ? `[agc-macos] dry-run 完成:${partition} 分区产物与清单已生成,未写入 OSS` + : `[agc-macos] ${partition} 分区更新包、签名、首装包与清单已上传 OSS`, +); diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 7c20bbbda..a73383973 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -46,16 +46,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}`); @@ -200,10 +196,12 @@ export function updateManifestUrl( } /** - * 单架构产物只登记实际目标,不能把同一原生资源映射为另一架构。 + * 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')) { @@ -543,6 +541,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 1dc02ca3d..3f457e0e0 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -45,19 +45,22 @@ 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; } -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', @@ -201,8 +204,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', ]); @@ -246,7 +252,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, {})); @@ -461,8 +466,8 @@ test('invalid target or platform used as channel fails before any release side e }, }; await assert.rejects( - () => buildRelease(['--target', universalTarget], sideEffects), - /单架构/, + () => buildRelease(['--target', 'unknown'], sideEffects), + /不支持的发布目标/, ); await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () => assert.rejects( @@ -473,6 +478,43 @@ test('invalid target or platform used as channel fails before any release side e 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'); + assert.equal( + resolveReleasePartition(context.channel, context.target), + 'dev-mac', + ); + assert.ok(context.bundleRoot.includes(universalTarget)); + withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => { + const manifest = createUpdateManifest(artifact, { + ...context, + downloadArtifact: createDmgFixture( + path.dirname(artifact), + universalTarget, + ), + }); + 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\//); + // 两个平台键共用同一个 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); + }); +}); + 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 cc77c7b23..98931b12a 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1365,18 +1365,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..a3f60765e 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 绝对路径', @@ -15,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'); @@ -31,17 +40,58 @@ 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; } +/** + * 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); @@ -60,7 +110,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); @@ -127,22 +177,38 @@ async function handshake(executable) { } try { - fs.cpSync(source, app, { recursive: true }); + const copiedWith = copyBundle(source, app); 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 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)); + 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 +225,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 +274,7 @@ try { assert.notEqual(broken.status, 0); assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/); console.log( - 'PASS: 隔离安装包资源、架构、摘要、权限、正式 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.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..4caae3656 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs @@ -0,0 +1,197 @@ +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, publishes the macOS partition and never reuses 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', + "string(name: 'AGC_UPDATE_CHANNEL', defaultValue: 'dev'", + 'AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}', + "string(credentialsId: 'AgcUpdaterSigningKey'", + "string(credentialsId: 'AgcUpdaterSigningKeyPassword'", + "string(credentialsId: 'AliyunAccessKeyId'", + "string(credentialsId: 'AliyunaccessKeySecret'", + 'AGC_RELEASE_VERSION', + 'OSSUTIL_BIN', + // 并行度必须可调:节点是共用机器,写死容易把整机压满或反过来浪费一半核心。 + "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 不得写死具体节点名', + ); + // 这条管线是正式发布入口(与 Windows 对称):默认真发布,演练需显式勾选。 + assert.match( + pipeline, + /booleanParam\(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false/u, + 'Channel 发布默认必须是真发布,演练只能显式勾选', + ); + // 节点是办公机:离线期间排队的旧构建必须自行让位,且跳过要覆盖后续全部阶段。 + assert.match( + 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'), + '必须记录被推进的标记供后续阶段判定', + ); + assert.equal( + (pipeline.match(/env\.AGC_BUILD_SUPERSEDED != 'true'/gu) ?? []).length, + 3, + 'Toolchain / Package / Archive 三个阶段都必须按跳过标记收口', + ); + for (const forbidden of [ + 'triggers {', + 'cron(', + 'pollSCM(', + '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')); + // `--no-sign` 会连带跳过 updater 的 minisign 签名,产物将没有 .sig,入口不得传它。 + assert.ok( + !entry.includes("'--no-sign'"), + '--no-sign 会同时跳过 updater 签名,产物缺少 .sig', + ); + // workspace 会跨构建保留产物:必须先删本次要写的对象,否则会因同名 DMG 失败, + // 或让上一轮遗留的 .sig 让验签门禁误通过。 + for (const required of [ + // 清理对象用派生的产品名算出来,而不是写死某个名字。 + '${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/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/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 2cc8e48ec..be4436c86 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -33,7 +33,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}" @@ -83,7 +99,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/【里程碑】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/project-memory/plans/【里程碑】Mac本机构建节点接入-2026-09-18.md b/docs/project-memory/plans/【里程碑】Mac本机构建节点接入-2026-09-18.md new file mode 100644 index 000000000..912ebad75 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Mac本机构建节点接入-2026-09-18.md @@ -0,0 +1,50 @@ +# Mac 本机构建节点接入 + +- Version: 1 +- Status: accepted-with-open-items +- 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 或触发远端构建。需先恢复内网连接,再按本规范验收真实节点及构建状态;不把本地代码准备描述为已经接入成功。 + +## 验收结果(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/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/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/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index c9b655161..3e14be3b9 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」这一层,需单独设计(例如按内容摘要决定是否重跑前端构建),不要在发布入口里用假缓存换取速度。 + ## Godot C++ 扩展构建与对象生命周期 - 原生引导通过官方 `godot-cpp` 管理 Variant、String 和 Ref,不自行维护 ABI 存储。Godot 类型必须在扩展终止回调内释放,不能依赖 DLL 静态对象析构;桥节点可能已经退出,应按实例 ID 核验存活再回调。 @@ -28,6 +40,11 @@ - **现象**:`GameCreationAppAssetKind` 的 ts-rs `export_to` 从 `apps/ai-game-creator-shell/src/contracts/generated/` 换到 `packages/shared/src/contracts/generated/` 后,任何 `cargo build` / `cargo test` 都会重写生成文件;若新目录没进 `.prettierignore` 与 `.eslintrc.cjs` 的 `ignorePatterns`,lint-staged / prettier 会把生成物重新格式化,于是每次提交都出现「生成物被改」,`cargo test export_bindings` 也不再幂等(跑完 `git diff` 不为空)。 - **处理(现行口径)**:生成目录一律成对登记 `.prettierignore` + eslint `ignorePatterns`;改 `export_to` 时同步改这两处,并用 `cargo test --locked -p shared-contracts --features ts-bindings export_bindings --manifest-path server-rs/Cargo.toml` 后 `git diff` 为空来验证幂等。 - **易错点**:旧的 `apps/ai-game-creator-shell/src/contracts/generated/` 目录下的同名文件不会自动删除,换目录后必须显式删除旧文件,否则会出现「两个同名 union,改动只落在一个目录」的假绿。 + +## 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 14f2ab8dc..058cc2371 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -98,11 +98,11 @@ | 系统 | 构建目标 | 清单平台键 | 更新包 | 清单地址 | | --------- | ------------------------ | ---------------------------------------------- | ------------------------ | ------------------------------------ | | Windows | `x86_64-pc-windows-msvc` | `windows-x86_64` | NSIS `.exe` + `.exe.sig` | `/agc/-win/latest.json` | -| macOS | `aarch64-apple-darwin` 或 `x86_64-apple-darwin` | 对应 `darwin-aarch64` 或 `darwin-x86_64` | `*.app.tar.gz` + `.sig` | `/agc/-mac/latest.json` | +| macOS | `universal-apple-darwin` | `darwin-aarch64` + `darwin-x86_64`(同一对象) | `*.app.tar.gz` + `.sig` | `/agc/-mac/latest.json` | - 对象布局:清单固定写成 `agc/-win|mac/latest.json`;安装包与签名写成同一分区的 `/` 与 `.sig`。 -- macOS 当前采用单架构包:Apple Silicon 使用 `aarch64-apple-darwin`,Intel 使用 `x86_64-apple-darwin`;每次生成的清单只登记本次实际构建的架构,不把单架构原生 Codex 资源挂到另一架构。`universal-apple-darwin` 在版本读取/写入、构建和清单生成之前拒绝。 -- 渠道清单以实际运行架构为键。两种单架构构建不可轮流覆盖同一个 `latest.json` 并宣称双架构均可更新;当前不实现跨构建合并,Intel 发布需先完成其构建验证与多架构清单发布方案。 +- macOS 正式交付使用 universal 主程序:两个平台键指向同一个 `.app.tar.gz` 与签名,一份产物同时服务 Apple Silicon 与 Intel。单架构目标(`aarch64-apple-darwin` / `x86_64-apple-darwin`)只用于本机诊断,不登记正式分区清单——单架构构建不可轮流覆盖同一个 `latest.json` 并宣称双架构均可更新。 +- universal 主程序同时携带分目录的 arm64/x64 原生 Codex 组件:每个组件保持上游单架构布局与独立 SHA-256 清单,运行中的主程序切片只选择同架构目录,不得把两套原生包的元数据或辅助程序混装。 - 构建期要求:打开 `bundle.createUpdaterArtifacts` 以生成 `.sig`;构建环境提供签名私钥与密码(私钥内容不得入库);公钥写入客户端配置。公钥在首个带更新能力的版本发布后不可更换,更换等于放弃自动更新(只能手动重装)。 - 版本递增按渠道及系统分区独立进行:发布脚本读取该分区远端 `latest.json` 的 `version`,与本地版本取较高者递增 patch;不同分区的远端版本互不影响。 - 版本高水位:仅 dev 的 Windows 分区在迁移窗口内取「分区清单版本」与「旧协议迁移指针版本」较大值再递增,避免已发布旧客户端版本倒退。迁移窗口结束(旧指针 404)后只读分区清单;release、自定义渠道与所有 Mac 分区均不参与旧指针比较。 @@ -146,7 +146,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` / 下载事件 / 清单常量均无残留) | @@ -168,11 +168,14 @@ 已决策: -- 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 发布方式:对应渠道的 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` 默认为关(与 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 实际发布(macOS 构建机、签名与公证、安装后重启验证、是否接入 Jenkins macOS 节点)仍需独立验证;代码中的渠道与分区支持不等于已有 Mac 安装包发布。 +- macOS 分区(`-mac`)已落地构建与发布能力:Mac Jenkins 节点、release 入口(更新包 + 签名 + 首装包 + 分区清单)、验签门禁与调度接入均已就绪,默认直接发布。 +- 剩余待办:Apple 代码签名与公证凭据(未就绪期间以未验证项记录)、macOS 安装后重启接管新版本的实机验证、Intel 真机 smoke(当前 x86_64 侧为 Rosetta)。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 4d1498caf..3d51c888c 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -455,11 +455,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 34ce87c6d..f8af79922 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 仍会继续触发。 @@ -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 构建路径。 @@ -726,6 +726,28 @@ 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 发布职责。它使它只在专用 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。签到会同时取 `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` 与 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 签名,因此未签名不阻断自动更新的校验环节,但「安装 → 重启接管新版本」的实机闭环仍未验证,不得以构建成功替代。 + +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`。 + +耗时构成:`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 的成功必须以控制器实时状态确认,脚本入库不代表管线已经接通。 + ### 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.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/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build new file mode 100644 index 000000000..91bcfdc97 --- /dev/null +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build @@ -0,0 +1,201 @@ +pipeline { + agent { label 'genarrative-agc-macos' } + options { + disableConcurrentBuilds() + skipDefaultCheckout(true) + // 实测参考:合并主线后的双架构构建 68.5 分钟、更早一次 78 分钟;节点是共用办公机, + // 冷 sccache + 机器被占用时会更慢,因此上限放到 150 分钟而不是贴着观测值。 + timeout(time: 150, unit: 'MINUTES') + buildDiscarder(logRotator(numToKeepStr: '10', artifactNumToKeepStr: '3')) + timestamps() + } + parameters { + 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: '可选三段版本号;留空则按 -mac 分区清单高水位递增 patch。首次发布建议显式指定,避免版本链回退') + 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 内存且是日常办公机,若构建期间出现明显换页可临时调低。只影响本次构建') + } + environment { + GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git' + GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh' + 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 重建导致近似冷构建;可用下面的变量覆盖。 + GENARRATIVE_AGC_MACOS_CACHE_ROOT = 'caches/genarrative-jenkins/agc-macos' + RUSTC_WRAPPER = 'sccache' + SCCACHE_CACHE_SIZE = '20G' + CARGO_INCREMENTAL = '0' + // 不把节点用户名写进仓库:PATH 在下面的 shell 步骤里按 $HOME 展开。 + AGC_EXTRA_PATH = '/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin' + } + stages { + stage('Checkout') { + steps { + // 只允许在专用 Agent 目录下构建:默认按约定匹配 $HOME/Library/Jenkins/agents//workspace/, + // 不写死某个节点名(节点改名后仍成立),也可用 AGC_AGENT_ROOT 显式覆盖。 + // 目的是防止把开发 checkout 当成 Jenkins workspace。 + sh ''' + set -eu + 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 + ''' + 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" + # 同时取 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 + 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 + 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 + # LaunchAgent 只给最小 PATH:按 $HOME 展开工具链位置,不把节点用户名写进仓库。 + export PATH="$HOME/.local/bin:$HOME/.cargo/bin:${AGC_EXTRA_PATH}" + test "$(uname -m)" = arm64 + node --version + npm --version + cargo --version + 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 + ''' + } + } + stage('Package, verify and publish') { + when { + expression { return env.AGC_BUILD_SUPERSEDED != 'true' } + } + steps { + 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}", + "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'}", + "AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}", + ]) { + sh ''' + 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 + 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 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 + } + } + } + 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 签名与公证暂缺。' + : "macOS universal 更新包、签名、首装包与 ${params.AGC_UPDATE_CHANNEL}-mac 分区清单已上传 OSS;Apple 签名与公证暂缺,首装需手动放行。" + } + } +} 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..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', @@ -7723,6 +7730,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;