From b1cadd0cc817c411d791d2952cb9d7f44741fc34 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sat, 19 Sep 2026 17:49:25 +0800 Subject: [PATCH] =?UTF-8?q?Mac=20=E6=B8=A0=E9=81=93=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=8C=85=E7=AD=BE=E5=90=8D=E3=80=81OSS=20?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E4=B8=8E=E6=B8=A0=E9=81=93=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:mac 侧此前只有 archive-only 构建,dev-mac 渠道不产出更新包、不上传、无渠道 清单,客户端拿不到 macOS 更新(里程碑曾因签名/公证凭据未就绪而暂缓)。 改动: - build-macos-ci.mjs 改为 dev-mac 发布入口:开启 createUpdaterArtifacts 产出 .app.tar.gz + .sig,生成 universal DMG 与渠道清单 latest.json,构建后用产物内 烘焙的公钥复核签名,再按 AGC_RELEASE_DRY_RUN 决定是否上传 OSS - 新增 verify-updater-signature.mjs(minisign ED 预哈希校验)+ 单测:验签失败或 keyId 不一致立即失败关闭,绝不写 OSS - Jenkinsfile:新增 AGC_RELEASE_VERSION / AGC_RELEASE_DRY_RUN(默认开启)/ AGC_UPDATE_RELEASE_NOTES / OSSUTIL_BIN 参数;withCredentials 注入 AgcUpdaterSigningKey(+密码) 与 AliyunAccessKeyId/Secret;归档补 latest.json、 .sig 与更新摘要 - 既有单测口径更新:archive-only → 发布型,并新增「必须先验签再上传」守卫 - 文档:技术方案、里程碑、运维文档同步;Apple 签发与公证暂缺显式记录为 未验证项(--no-sign、appleSigned=false、notarized=false) 验证:单测 11/11 + 42/42;Jenkinsfile 四个 sh 块语法通过;伪造 bundle 端到端 验证清单(两平台同 URL/同签名、universal 首装包被正确选中)、真实 Tauri 签名 验签通过、错钥匙报 keyId 不一致、篡改报校验失败、dry-run 上传计划顺序正确。 --- .../scripts/build-macos-ci.mjs | 153 ++++++++++++--- .../scripts/prepare-macos-codex.test.mjs | 33 +++- .../scripts/verify-updater-signature.mjs | 177 +++++++++++++++++ .../scripts/verify-updater-signature.test.mjs | 182 ++++++++++++++++++ ...里程碑】AGC macOS渠道更新落地-2026-09-17.md | 6 +- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 7 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 6 +- ...kinsfile.ai-game-creator-shell-macos-build | 83 +++++--- 8 files changed, 589 insertions(+), 58 deletions(-) create mode 100644 apps/ai-game-creator-shell/scripts/verify-updater-signature.mjs create mode 100644 apps/ai-game-creator-shell/scripts/verify-updater-signature.test.mjs diff --git a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs index be57f58a0..5e69101a8 100644 --- a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs +++ b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs @@ -6,8 +6,28 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { resolveReleaseContext, runTauriBuild } from './build-release.mjs'; +import { + generateUpdateManifest, + prepareReleaseVersion, + resolveReleaseContext, + runTauriBuild, +} from './build-release.mjs'; +import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs'; +import { + readUpdaterPubkey, + verifyUpdaterSignature, +} from './verify-updater-signature.mjs'; +/** + * AGC macOS 渠道(dev-mac)发布入口:构建 universal 包 → 双架构 smoke → 生成 universal DMG + * → 生成渠道清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。 + * + * 边界: + * - Apple 签名与公证暂缺,产物为未签名 + 未公证(`--no-sign`),必须显式记录而非静默通过; + * - 更新包签名(TAURI_SIGNING_PRIVATE_KEY,minisign)是硬需求:缺了客户端一律拒绝安装, + * 因此构建前要求凭据存在,构建后用内置公钥复核 `.sig` 才允许继续上传; + * - 未通过验签绝不写 OSS:上传顺序为更新包、签名、首装包,全部成功后才覆盖渠道清单指针。 + */ const appRoot = fileURLToPath(new URL('..', import.meta.url)); const repoRoot = path.resolve(appRoot, '../..'); assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行'); @@ -27,27 +47,44 @@ assert.ok( '构建前至少需要 8 GiB 可用空间;禁止自动清理开发缓存', ); -// 本入口永不发布,不使用 Agent 用户可能持有的发布或 Apple 认证环境。 +// 仅剥离 Apple 签名/公证变量:本节点没有证书,误用只会让构建失败; +// 更新包签名与 OSS 凭据必须保留,它们是本入口发布能力的组成部分。 for (const key of Object.keys(process.env)) { - if (/^(TAURI_SIGNING_|APPLE_|AGC_OSS_)/u.test(key)) delete process.env[key]; + if (/^APPLE_/u.test(key)) delete process.env[key]; } -// RUSTC_WRAPPER / SCCACHE_DIR / SCCACHE_CACHE_SIZE / CARGO_INCREMENTAL 由 Jenkinsfile 显式提供: -// 依赖 crate 命中 sccache 对象缓存,避免 universal 双架构每次从零编译; -// 本入口不再清空 wrapper,以免与管线声明冲突。若节点未装 sccache,由管线回退到真实 rustc。 +assert.ok( + process.env.TAURI_SIGNING_PRIVATE_KEY?.length > 0 || + process.env.TAURI_SIGNING_PRIVATE_KEY_PATH?.length > 0, + '缺少更新包签名私钥(TAURI_SIGNING_PRIVATE_KEY / _PATH):无签名的更新包会被客户端拒绝,禁止继续', +); + +const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev'; +const endpoint = + process.env.AGC_OSS_ENDPOINT?.trim() || 'oss-rg-china-mainland.aliyuncs.com'; +if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) { + throw new Error('OSS bucket 或 endpoint 配置无效'); +} +process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`; +const dryRun = readReleaseDryRun(); + process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target'); const context = resolveReleaseContext(['--target=universal-apple-darwin']); +const version = await prepareReleaseVersion(context); + const args = [ '--target=universal-apple-darwin', '--bundles', 'app', '--ci', '--no-sign', + // 基础配置已开启;这里显式声明,避免被其它配置来源关掉后静默失去更新能力。 '--config', - '{"bundle":{"createUpdaterArtifacts":false}}', + '{"bundle":{"createUpdaterArtifacts":true}}', ]; const command = (binary, argv, options = {}) => execFileSync(binary, argv, { cwd: repoRoot, stdio: 'inherit', ...options }); runTauriBuild(args, context); + const app = path.join(context.bundleRoot, 'macos/陶泥儿.app'); for (const architecture of ['arm64', 'x86_64']) { command(process.execPath, [ @@ -57,15 +94,11 @@ for (const architecture of ['arm64', 'x86_64']) { '--universal', ]); } -const version = JSON.parse( - fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'), -).version; -assert.match(version, /^\d+\.\d+\.\d+$/u); -const artifacts = path.join(repoRoot, 'artifacts'); -// 只清理本 Job 的归档输出,不能把上次 DMG 当成本次成功产物。 -fs.rmSync(artifacts, { recursive: true, force: true }); -fs.mkdirSync(artifacts, { recursive: true }); -const dmg = path.join(artifacts, `陶泥儿_${version}_universal.dmg`); + +// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `__universal.dmg`。 +const dmgDirectory = path.join(context.bundleRoot, 'macos'); +fs.mkdirSync(dmgDirectory, { recursive: true }); +const dmg = path.join(dmgDirectory, `陶泥儿_${version}_universal.dmg`); const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-ci-dmg-')); try { command('ditto', [app, path.join(stage, '陶泥儿.app')]); @@ -84,12 +117,56 @@ try { } finally { fs.rmSync(stage, { recursive: true, force: true }); } -const hash = createHash('sha256'); -for await (const chunk of fs.createReadStream(dmg)) hash.update(chunk); -fs.writeFileSync( - `${dmg}.sha256`, - `${hash.digest('hex')} ${path.basename(dmg)}\n`, + +const release = await generateUpdateManifest(context); +assert.equal( + path.resolve(release.downloadArtifact), + path.resolve(dmg), + '首装包必须锁定本次生成的 universal DMG', ); + +// 上传前门禁:用产物里烘焙的公钥复核更新包签名。验不过就停在这里,绝不写 OSS。 +const signature = verifyUpdaterSignature({ + artifactPath: release.artifact, + signaturePath: `${release.artifact}.sig`, + pubkey: readUpdaterPubkey(), +}); +console.log( + `[agc-macos] 更新包签名校验通过:alg=${signature.algorithm},keyId=${signature.keyId}`, +); + +const artifacts = path.join(repoRoot, 'artifacts'); +// 只清理本 Job 的归档输出,不能把上次 DMG 当成本次成功产物。 +fs.rmSync(artifacts, { recursive: true, force: true }); +fs.mkdirSync(artifacts, { recursive: true }); +const sha256 = (file) => { + const hash = createHash('sha256'); + hash.update(fs.readFileSync(file)); + return hash.digest('hex'); +}; +const dmgHash = sha256(dmg); +fs.writeFileSync(`${dmg}.sha256`, `${dmgHash} ${path.basename(dmg)}\n`); + +const uploadPlan = uploadReleaseArtifacts(release, { + bucket, + endpoint, + binary: process.env.OSSUTIL_BIN?.trim() || 'ossutil', + accessKeyId: process.env.AGC_OSS_ACCESS_KEY_ID?.trim(), + accessKeySecret: process.env.AGC_OSS_ACCESS_KEY_SECRET, + dryRun, +}); + +const archived = [ + dmg, + `${dmg}.sha256`, + release.manifestPath, + release.notesPath, + `${release.artifact}.sig`, +]; +for (const file of archived) { + fs.copyFileSync(file, path.join(artifacts, path.basename(file))); +} + const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8', @@ -102,14 +179,40 @@ fs.writeFileSync( commit, target: context.target, channel: context.channel, - signed: false, + // Apple 签名与公证暂缺:显式记录为未验证项,不静默通过。 + appleSigned: false, notarized: false, - uploaded: false, - smoke: ['arm64', 'x86_64'], + dryRun, + uploaded: !dryRun, + updaterSignature: { + algorithm: signature.algorithm, + keyId: signature.keyId, + verified: true, + }, + oss: { + bucket, + endpoint, + latest: `oss://${bucket}/agc/${context.channel}/latest.json`, + objects: uploadPlan.map(({ destination }) => destination), + }, + artifacts: { + updater: path.basename(release.artifact), + updaterSha256: sha256(release.artifact), + updaterBytes: fs.statSync(release.artifact).size, + updaterSignature: path.basename(`${release.artifact}.sig`), + firstInstall: path.basename(dmg), + firstInstallSha256: dmgHash, + manifest: 'latest.json', + }, + smokes: ['arm64', 'x86_64'], intelSmoke: process.arch === 'arm64' ? 'Rosetta' : 'native', }, null, 2, )}\n`, ); -console.log('[macOS CI] universal 包与校验文件已生成;未发布、未签名或公证'); +console.log( + dryRun + ? `[agc-macos] dry-run 完成:${context.channel} 渠道产物与清单已生成,未写入 OSS` + : `[agc-macos] ${context.channel} 渠道更新包、签名、首装包与清单已上传 OSS`, +); diff --git a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs index 2fd93ddf1..547cff254 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs @@ -53,7 +53,7 @@ test('archive traversal and non-package entries fail closed', () => { } }); -test('CI pipeline is manual archive-only and does not reuse a developer workspace', () => { +test('CI pipeline is manual, publishes the dev-mac channel and never reuses a developer workspace', () => { const pipeline = fs.readFileSync( new URL( '../../../jenkins/Jenkinsfile.ai-game-creator-shell-macos-build', @@ -68,18 +68,43 @@ test('CI pipeline is manual archive-only and does not reuse a developer workspac 'StrictHostKeyChecking=yes', 'git merge-base --is-ancestor', 'allowEmptyArchive: false', + "AGC_UPDATE_CHANNEL = 'dev-mac'", + "string(credentialsId: 'AgcUpdaterSigningKey'", + "string(credentialsId: 'AgcUpdaterSigningKeyPassword'", + "string(credentialsId: 'AliyunAccessKeyId'", + "string(credentialsId: 'AliyunaccessKeySecret'", + 'AGC_RELEASE_VERSION', + 'OSSUTIL_BIN', ]) { assert.ok(pipeline.includes(required), required); } + // dry-run 必须是默认值:不显式取消勾选就不得写入 OSS。 + assert.match( + pipeline, + /booleanParam\(name: 'AGC_RELEASE_DRY_RUN', defaultValue: true/u, + ); for (const forbidden of [ 'triggers {', 'cron(', 'pollSCM(', - 'release:upload', - 'AgcUpdaterSigningKey', - 'AliyunAccessKeyId', 'git clean -fdx', + // release:upload 会重新触发一次完整构建,既翻倍耗时也绕过本 Job 的验签门禁。 + 'release:upload', ]) { assert.ok(!pipeline.includes(forbidden), forbidden); } }); + +test('macOS release entry verifies the updater signature before uploading', () => { + const entry = fs.readFileSync( + new URL('./build-macos-ci.mjs', import.meta.url), + 'utf8', + ); + const verifyIndex = entry.indexOf('verifyUpdaterSignature({'); + const uploadIndex = entry.indexOf('uploadReleaseArtifacts(release'); + assert.ok(verifyIndex > 0, '必须调用更新包验签'); + assert.ok(uploadIndex > 0, '必须调用 OSS 上传'); + assert.ok(verifyIndex < uploadIndex, '必须先验签再上传,验不过不得写 OSS'); + // 无签名私钥时禁止构建:未签名的更新包会被客户端一律拒绝。 + assert.ok(entry.includes('TAURI_SIGNING_PRIVATE_KEY')); +}); diff --git a/apps/ai-game-creator-shell/scripts/verify-updater-signature.mjs b/apps/ai-game-creator-shell/scripts/verify-updater-signature.mjs new file mode 100644 index 000000000..c55671369 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/verify-updater-signature.mjs @@ -0,0 +1,177 @@ +import { + createHash, + createPublicKey, + verify as cryptoVerify, +} from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * 更新包签名门禁:用产物里烘焙的 updater 公钥校验 `.sig`, + * 防止「发布出去的更新包没人装得上」——客户端校验失败会直接拒绝安装, + * 而且公钥发布后不可更换,所以必须在构建期、上传前就失败关闭。 + * + * 格式说明(与 Tauri 2 的实际产出对齐,均为实测): + * - `tauri.conf.json` 的 `plugins.updater.pubkey` 是「minisign 公钥文本」的 base64; + * - 产物旁的 `.sig` 是「minisign 签名文本」的 base64; + * - 公钥 blob 42 字节(alg `Ed` + 8 字节 keyId + 32 字节 Ed25519 公钥); + * - 签名 blob 74 字节(alg `Ed` 或 `ED` + 8 字节 keyId + 64 字节签名); + * - Tauri 产出的是 `ED`:先对文件做 BLAKE2b-512,再对摘要做 Ed25519 签名。 + */ +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const defaultTauriConfigPath = path.join(appRoot, 'src-tauri/tauri.conf.json'); +const defaultMacosConfigPath = path.join( + appRoot, + 'src-tauri/tauri.macos.conf.json', +); + +const PUBLIC_KEY_ALGORITHM = 'Ed'; +const RAW_ALGORITHM = 'Ed'; +const PREHASHED_ALGORITHM = 'ED'; + +function unwrapMinisignText(value, label) { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`${label} 为空`); + } + const trimmed = value.trim(); + if (trimmed.startsWith('untrusted comment:')) return trimmed; + const decoded = Buffer.from(trimmed, 'base64').toString('utf8'); + if (!decoded.startsWith('untrusted comment:')) { + throw new Error(`${label} 不是 minisign 内容(缺少 untrusted comment 头)`); + } + return decoded; +} + +function contentLines(text) { + return text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +/** 解析 updater 公钥(`tauri.conf.json` 里的 base64 值或 minisign 文本)。 */ +export function decodeUpdaterPublicKey(value, label = 'updater 公钥') { + const lines = contentLines(unwrapMinisignText(value, label)); + if (lines.length < 2) throw new Error(`${label} 缺少密钥内容行`); + const blob = Buffer.from(lines[1], 'base64'); + if (blob.length !== 42) { + throw new Error( + `${label} 长度异常:期望 42 字节,实际 ${blob.length} 字节`, + ); + } + const algorithm = blob.subarray(0, 2).toString('latin1'); + if (algorithm !== PUBLIC_KEY_ALGORITHM) { + throw new Error(`${label} 算法不受支持:${algorithm}`); + } + return { algorithm, keyId: blob.subarray(2, 10), key: blob.subarray(10) }; +} + +/** 解析 `.sig`(base64 值或 minisign 文本)。 */ +export function decodeUpdaterSignature(value, label = '更新包签名') { + const lines = contentLines(unwrapMinisignText(value, label)); + if (lines.length < 2) throw new Error(`${label} 缺少签名内容行`); + const blob = Buffer.from(lines[1], 'base64'); + if (blob.length !== 74) { + throw new Error( + `${label} 长度异常:期望 74 字节,实际 ${blob.length} 字节`, + ); + } + const algorithm = blob.subarray(0, 2).toString('latin1'); + if (algorithm !== RAW_ALGORITHM && algorithm !== PREHASHED_ALGORITHM) { + throw new Error(`${label} 算法不受支持:${algorithm}`); + } + return { + algorithm, + keyId: blob.subarray(2, 10), + signature: blob.subarray(10), + trustedComment: lines[2] ?? '', + }; +} + +function publicKeyObject(rawKey) { + return createPublicKey({ + key: { kty: 'OKP', crv: 'Ed25519', x: rawKey.toString('base64url') }, + format: 'jwk', + }); +} + +/** + * 校验更新包签名;任何不一致都抛错(调用方据此失败关闭)。 + */ +export function verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey, +}) { + const publicKey = decodeUpdaterPublicKey(pubkey); + const signature = decodeUpdaterSignature( + fs.readFileSync(signaturePath, 'utf8'), + ); + if (!publicKey.keyId.equals(signature.keyId)) { + throw new Error( + `更新包签名与内置公钥的 keyId 不一致:公钥 ${publicKey.keyId.toString('hex')},签名 ${signature.keyId.toString('hex')};` + + '签名私钥与产物内烘焙的公钥不是同一对,发布后客户端会拒绝安装', + ); + } + const payload = fs.readFileSync(artifactPath); + const message = + signature.algorithm === PREHASHED_ALGORITHM + ? createHash('blake2b512').update(payload).digest() + : payload; + if ( + !cryptoVerify( + null, + message, + publicKeyObject(publicKey.key), + signature.signature, + ) + ) { + throw new Error( + `更新包签名校验失败:${path.basename(artifactPath)};该产物无法被客户端接受`, + ); + } + return { + algorithm: signature.algorithm, + keyId: publicKey.keyId.toString('hex'), + trustedComment: signature.trustedComment, + }; +} + +/** + * 读取该平台生效的 updater 公钥:macOS 配置可覆盖基础配置,与构建期行为一致。 + */ +export function readUpdaterPubkey({ + configPath = defaultTauriConfigPath, + platformConfigPath = defaultMacosConfigPath, +} = {}) { + const readPubkey = (file) => { + if (!fs.existsSync(file)) return null; + const config = JSON.parse(fs.readFileSync(file, 'utf8')); + return config?.plugins?.updater?.pubkey ?? null; + }; + const pubkey = readPubkey(platformConfigPath) ?? readPubkey(configPath); + if (!pubkey) throw new Error('未在 Tauri 配置中找到 plugins.updater.pubkey'); + return pubkey; +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + const [artifactPath, signaturePath = `${artifactPath}.sig`] = + process.argv.slice(2); + if (!artifactPath) { + throw new Error( + '用法:node verify-updater-signature.mjs <更新包> [<签名文件>]', + ); + } + const result = verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: readUpdaterPubkey(), + }); + console.log( + `[agc-macos] 更新包签名校验通过:${path.basename(artifactPath)}(alg=${result.algorithm},keyId=${result.keyId})`, + ); +} diff --git a/apps/ai-game-creator-shell/scripts/verify-updater-signature.test.mjs b/apps/ai-game-creator-shell/scripts/verify-updater-signature.test.mjs new file mode 100644 index 000000000..c30ecc710 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/verify-updater-signature.test.mjs @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict'; +import { + createHash, + generateKeyPairSync, + randomBytes, + sign as cryptoSign, +} from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + decodeUpdaterPublicKey, + decodeUpdaterSignature, + readUpdaterPubkey, + verifyUpdaterSignature, +} from './verify-updater-signature.mjs'; + +/** + * 用进程内生成的 Ed25519 密钥自造 minisign 结构, + * 覆盖 Tauri 实际使用的 `ED`(BLAKE2b-512 预哈希)与 `Ed`(原文)两种模式。 + */ +function createKeyMaterial() { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const rawKey = Buffer.from( + publicKey.export({ format: 'jwk' }).x, + 'base64url', + ); + const keyId = randomBytes(8); + const pubkey = Buffer.from( + `untrusted comment: minisign public key: ${keyId.reverse().toString('hex').toUpperCase()}\n` + + `${Buffer.concat([Buffer.from('Ed'), keyId, rawKey]).toString('base64')}\n`, + ).toString('base64'); + return { privateKey, keyId, rawKey, pubkey }; +} + +function signFixture({ privateKey, keyId }, payload, algorithm) { + const message = + algorithm === 'ED' + ? createHash('blake2b512').update(payload).digest() + : payload; + const signature = cryptoSign(null, message, privateKey); + const blob = Buffer.concat([Buffer.from(algorithm), keyId, signature]); + const globalSignature = cryptoSign(null, blob, privateKey); + return Buffer.from( + 'untrusted comment: signature from tauri secret key\n' + + `${blob.toString('base64')}\n` + + 'trusted comment: timestamp:0\tfile:fixture\n' + + `${globalSignature.toString('base64')}\n`, + ).toString('base64'); +} + +function withFixture(run) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-sig-test-')); + try { + const artifactPath = path.join(directory, 'app.app.tar.gz'); + fs.writeFileSync(artifactPath, 'update payload'); + return run({ directory, artifactPath }); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +test('接受 Tauri 实际使用的 ED(BLAKE2b-512 预哈希)签名', () => { + withFixture(({ directory, artifactPath }) => { + const material = createKeyMaterial(); + const signaturePath = path.join(directory, 'app.app.tar.gz.sig'); + fs.writeFileSync( + signaturePath, + signFixture(material, fs.readFileSync(artifactPath), 'ED'), + ); + const result = verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: material.pubkey, + }); + assert.equal(result.algorithm, 'ED'); + assert.equal(result.keyId, material.keyId.toString('hex')); + }); +}); + +test('接受原文 Ed 签名,两种算法互不通用', () => { + withFixture(({ directory, artifactPath }) => { + const material = createKeyMaterial(); + const payload = fs.readFileSync(artifactPath); + const signaturePath = path.join(directory, 'app.app.tar.gz.sig'); + fs.writeFileSync(signaturePath, signFixture(material, payload, 'Ed')); + assert.equal( + verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: material.pubkey, + }).algorithm, + 'Ed', + ); + // 原文模式下签名的是别的载荷时必须失败:证明确实在校验内容而非只看结构。 + fs.writeFileSync( + signaturePath, + signFixture(material, Buffer.from('别的载荷'), 'Ed'), + ); + assert.throws( + () => + verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: material.pubkey, + }), + /签名校验失败/u, + ); + }); +}); + +test('产物被篡改时失败关闭', () => { + withFixture(({ directory, artifactPath }) => { + const material = createKeyMaterial(); + const signaturePath = path.join(directory, 'app.app.tar.gz.sig'); + fs.writeFileSync( + signaturePath, + signFixture(material, fs.readFileSync(artifactPath), 'ED'), + ); + fs.writeFileSync(artifactPath, 'tampered payload'); + assert.throws( + () => + verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: material.pubkey, + }), + /签名校验失败/u, + ); + }); +}); + +test('签名私钥与内置公钥不是同一对时给出明确错误', () => { + withFixture(({ directory, artifactPath }) => { + const signing = createKeyMaterial(); + const baked = createKeyMaterial(); + const signaturePath = path.join(directory, 'app.app.tar.gz.sig'); + fs.writeFileSync( + signaturePath, + signFixture(signing, fs.readFileSync(artifactPath), 'ED'), + ); + assert.throws( + () => + verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: baked.pubkey, + }), + /keyId 不一致/u, + ); + }); +}); + +test('公钥或签名格式非法时拒绝解析', () => { + assert.throws(() => decodeUpdaterPublicKey(''), /为空/u); + assert.throws( + () => decodeUpdaterPublicKey('bm90IGEgbWluaXNpZ24ga2V5'), + /不是 minisign 内容/u, + ); + assert.throws( + () => + decodeUpdaterPublicKey( + Buffer.from('untrusted comment: x\nAAAA\n').toString('base64'), + ), + /长度异常/u, + ); + assert.throws( + () => + decodeUpdaterSignature( + Buffer.from('untrusted comment: x\nAAAA\n').toString('base64'), + ), + /长度异常/u, + ); +}); + +test('仓库里配置的 updater 公钥可被解析(两平台共用)', () => { + const decoded = decodeUpdaterPublicKey(readUpdaterPubkey()); + assert.equal(decoded.algorithm, 'Ed'); + assert.equal(decoded.key.length, 32); +}); diff --git a/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md index b9b84ef2e..fd0a4fc32 100644 --- a/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md +++ b/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md @@ -3,7 +3,7 @@ | 字段 | 值 | | ----------- | ------------------------------------------------------------------ | | Version | 1.0 | -| Status | deferred | +| Status | in-progress | | Date | 2026-09-17 | | Parent Spec | `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md` | @@ -30,7 +30,9 @@ - macOS 签名证书与公证凭据可用;若不满足,本里程碑只能交付构建与清单能力,并明确标注未验证项。 - macOS 通用包所需的双架构工具链(两个 darwin 目标)在构建机上可用。 -本里程碑暂缓执行:macOS 构建机与签名 / 公证凭据尚未就绪,改由后续独立变更承接;暂缓期间 dev-mac 渠道不发布。 +构建与发布能力已落地:专用 macOS Jenkins 节点(label `genarrative-agc-macos`)+ `Jenkinsfile.ai-game-creator-shell-macos-build` + `scripts/build-macos-ci.mjs` 负责 universal 构建、双架构隔离 smoke、universal DMG、渠道清单 `latest.json`、更新包验签门禁与 OSS 上传(`AGC_RELEASE_DRY_RUN` 默认开启)。 + +仍未就绪:Apple 代码签名与公证凭据(产物保持未签名 + 未公证,构建清单显式记录 `appleSigned=false` / `notarized=false`,首装需手动放行 Gatekeeper);「安装 → 重启接管新版本」的实机更新闭环、Intel 真机 smoke(当前 x86_64 侧为 Rosetta)尚未验收。 ## 验收标准 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index ec2bf04aa..40ca10449 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -161,8 +161,11 @@ - macOS 采用 universal 包,两个平台键对应同一更新产物;主程序用 lipo 检查两种架构,Codex 资源分别做原生身份/摘要与启动验证。Rosetta 结果不代替 Intel 真机验收。 - 旧客户端迁移桥:保留一个版本周期。渠道清单上线后,发布管线同时把旧的 `agc/latest.json`(sha256 格式)指向 `dev-win` 最新安装包,让已发布客户端自动升级到新协议;下个周期整条删除。 - 签名密钥:由本仓库维护者生成并保管,私钥保存在仓库外(`%USERPROFILE%\.tauri\genarrative-agc-updater.key`),只有公钥进入客户端配置;Jenkins 用受保护凭据 `AgcUpdaterSigningKey` 与 `AgcUpdaterSigningKeyPassword` 注入为 Tauri 打包器读取的 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,本机可用 `TAURI_SIGNING_PRIVATE_KEY_PATH` 指向同一私钥。当前密钥不带密码;首次发布前仍可重新生成,首次发布后不可更换。 -- macOS 发布方式:`dev-mac` 产物在本机 mac 上执行发布入口上传,Jenkins 暂不新增 macOS 节点;macOS 代码签名与公证凭据未确认前,相关闭环记为未验证项,不静默通过。 +- macOS 发布方式:已接入专用 macOS Jenkins 节点(label `genarrative-agc-macos`,EXCLUSIVE 单 executor),由 `Jenkinsfile.ai-game-creator-shell-macos-build` 执行 `scripts/build-macos-ci.mjs` 完成 universal 构建、双架构隔离 smoke、universal DMG、渠道清单生成、更新包验签与 OSS 上传。`AGC_RELEASE_DRY_RUN` 默认为开,只有显式关闭才会写入 OSS。 +- macOS 代码签名与公证暂缺:产物为未签名 + 未公证,`--no-sign` 保留,构建清单与 `latest.json` 显式记录 `appleSigned=false` / `notarized=false`,首装需用户在 Gatekeeper 中手动放行。该限制作为已知未验证项记录,不静默通过;「安装 → 重启接管新版本」的自动更新闭环仍需实机验收。 +- 更新包验签门禁:构建完成、上传 OSS 之前,用产物内烘焙的 `plugins.updater.pubkey` 复核 `<更新包>.sig`(Tauri 使用 minisign 的 `ED` 预哈希模式)。keyId 不一致或校验失败立即失败关闭,禁止上传——客户端校验失败会直接拒绝安装,且公钥发布后不可更换。 待办: -- macOS `dev-mac` 渠道落地(macOS 构建机、签名与公证、安装后重启验证、是否接入 Jenkins macOS 节点)暂缓,由后续独立变更单独完成;在此之前 `dev-mac` 渠道只有构建与清单能力,不发布。 +- macOS `dev-mac` 渠道已落地构建与发布能力:Mac Jenkins 节点、release 入口(更新包 + 签名 + 首装包 + 渠道清单)、验签门禁与 dry-run 默认开启均已就绪。 +- 剩余待办:Apple 代码签名与公证凭据(未就绪期间以未验证项记录)、macOS 安装后重启接管新版本的实机验证、Intel 真机 smoke(当前 x86_64 侧为 Rosetta)。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 70ca24895..8927e54d9 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -724,7 +724,11 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. Mac universal 构建脚本位于 `jenkins/Jenkinsfile.ai-game-creator-shell-macos-build`,只对专用 `genarrative-agc-macos` 标签运行。节点按 EXCLUSIVE、单 executor 配置,Job 禁止并发且不设置 trigger;不接入现有每小时版本调度,不改 Windows 发布职责。它使用独立 Jenkins workspace,禁止指向开发 checkout 或共享其可写 target/node_modules。 -该 Job 的目标仅为构建并归档:保留源码版本、不读取远端版本、不注入发布私钥/OSS 凭据、不运行 release upload。执行 `npm ci` 后使用锁文件校验并补齐两种 macOS Codex 原生依赖,再调用 `scripts/build-macos-ci.mjs`(AGC 应用目录下)生成 universal app 与 DMG、分别运行 arm64/x86_64 隔离 smoke。归档限 `artifacts/` 下的 DMG、SHA-256、非敏感构建清单和源码 commit;不归档用户 HOME、Jenkins secret、原始工作目录或全量日志。 +该 Job 的职责是构建并发布 `dev-mac` 渠道更新:执行 `npm ci` 后使用锁文件校验并补齐两种 macOS Codex 原生依赖,再调用 `scripts/build-macos-ci.mjs`(AGC 应用目录下)生成 universal app、arm64/x86_64 隔离 smoke、universal DMG 与渠道清单 `latest.json`,用产物内烘焙的公钥复核更新包签名(`verify-updater-signature.mjs`),最后按 `AGC_RELEASE_DRY_RUN` 决定是否上传 OSS。归档限 `artifacts/` 下的 DMG、SHA-256、`latest.json`、更新包签名、更新摘要、非敏感构建清单和源码 commit;不归档用户 HOME、Jenkins secret、原始工作目录或全量日志。 + +发布凭据全部走 Jenkins 全局凭据,并在 `withCredentials` 内注入当前进程:`AgcUpdaterSigningKey`(与 `AgcUpdaterSigningKeyPassword`)映射为 `TAURI_SIGNING_PRIVATE_KEY` / `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,`AliyunAccessKeyId` / `AliyunaccessKeySecret` 映射为 `AGC_OSS_ACCESS_KEY_ID` / `AGC_OSS_ACCESS_KEY_SECRET`;私钥与凭据不写入 workspace、日志或归档产物。上传顺序为更新包、签名、首装包,三者全部成功后才覆盖 `agc/dev-mac/latest.json` 指针;`AGC_RELEASE_DRY_RUN` 默认开启,dry-run 只打印将上传的对象、不写任何 OSS 对象。Mac 节点需要 `ossutil`(实测 1.7.19 原生 arm64 可用,装在 `~/.local/bin`,已在 Job 的 PATH 内),可用 `OSSUTIL_BIN` 指定命令名或绝对路径。首次发布建议显式指定 `AGC_RELEASE_VERSION`,避免按渠道高水位递增时出现版本链回退。 + +产物边界:macOS 代码签名与公证暂缺,构建保持 `--no-sign`,构建清单显式记录 `appleSigned=false` / `notarized=false`。用户首次安装需要在 Gatekeeper 中手动放行;更新包校验本身只依赖 minisign 签名,因此未签名不阻断自动更新的校验环节,但「安装 → 重启接管新版本」的实机闭环仍未验证,不得以构建成功替代。 该 Job 的 Rust 编译走 sccache 对象缓存,与 `Genarrative-Api-Build`、`Genarrative-Stdb-Module-Build` 一致:`RUSTC_WRAPPER=sccache`、`CARGO_INCREMENTAL=0`、`SCCACHE_CACHE_SIZE=20G`。`SCCACHE_DIR` 默认落在稳定缓存根 `~/caches/genarrative-jenkins/agc-macos/sccache`,可用 `GENARRATIVE_AGC_MACOS_CACHE_ROOT` 覆盖;缓存根固定在 HOME 下而不是 `WORKSPACE` 内,避免 workspace 重建后无改动也触发近似冷构建。节点未安装 sccache 时管线打印提示并回退到真实 `rustc`,不阻断构建。universal 双架构各自独立编译,依赖 crate 的复用收益约为单架构的两倍;命中情况由构建末尾的 `sccache --show-stats` 输出,供判断是否需要预热或调整缓存上限。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build index 5e02d5872..b5ebdca9e 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build @@ -10,11 +10,17 @@ pipeline { parameters { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '必须含 universal 双架构依赖实现的受信任分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,固定属于源码分支的提交;不递增应用版本') + string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选三段版本号;留空则按 dev-mac 渠道清单高水位递增 patch。首次发布建议显式指定,避免版本链回退') + booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: true, description: '勾选后只构建、验签并打印将上传的对象,不写 OSS;取消勾选才真正发布到 dev-mac 渠道') + string(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选单行更新摘要;留空则由发布脚本按提交自动汇总') + string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 命令名或绝对路径(Mac 节点默认装在 ~/.local/bin/ossutil)') } environment { GIT_REMOTE_URL = 'ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git' GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh' AGC_UPDATE_CHANNEL = 'dev-mac' + AGC_OSS_BUCKET = 'agc-dev' + AGC_OSS_ENDPOINT = 'oss-rg-china-mainland.aliyuncs.com' // 与 Linux 生产管线(api-build / stdb-module-build)对齐:Rust 编译走 sccache 对象缓存。 // 本 Job 是 universal 双架构(aarch64 + x86_64)各自独立编译,缓存收益约为单架构的两倍。 // 缓存根固定在 HOME 下的稳定目录,避免 WORKSPACE 重建导致近似冷构建;可用下面的变量覆盖。 @@ -80,37 +86,66 @@ pipeline { ''' } } - stage('Validate and build universal') { + stage('Package, verify and publish') { steps { - sh ''' - set -eu - cache_root="${GENARRATIVE_AGC_MACOS_CACHE_ROOT:-caches/genarrative-jenkins/agc-macos}" - case "${cache_root}" in /*) ;; *) cache_root="${HOME:?HOME 不能为空}/${cache_root}" ;; esac - if command -v sccache >/dev/null 2>&1; then - export SCCACHE_DIR="${cache_root}/sccache" - mkdir -p "${SCCACHE_DIR}" - echo "[agc-macos] sccache 缓存目录: ${SCCACHE_DIR}" - else - echo '[agc-macos] 未找到 sccache,改用 rustc 直接构建。' - unset RUSTC_WRAPPER - fi - node --test apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs - node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs - node apps/ai-game-creator-shell/scripts/build-macos-ci.mjs - if command -v sccache >/dev/null 2>&1; then - echo '[agc-macos] sccache 统计(自 server 启动累计):' - sccache --show-stats 2>&1 | sed -n '1,6p' - fi - ''' + withCredentials([ + string(credentialsId: 'AliyunAccessKeyId', variable: 'AGC_OSS_ACCESS_KEY_ID'), + string(credentialsId: 'AliyunaccessKeySecret', variable: 'AGC_OSS_ACCESS_KEY_SECRET'), + string(credentialsId: 'AgcUpdaterSigningKey', variable: 'TAURI_SIGNING_PRIVATE_KEY'), + string(credentialsId: 'AgcUpdaterSigningKeyPassword', variable: 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD'), + ]) { + withEnv([ + "OSSUTIL_BIN=${params.OSSUTIL_BIN}", + "AGC_RELEASE_VERSION=${params.AGC_RELEASE_VERSION}", + "AGC_RELEASE_DRY_RUN=${params.AGC_RELEASE_DRY_RUN ? '1' : '0'}", + "AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}", + ]) { + sh ''' + set -eu + echo "[agc-macos] 渠道=${AGC_UPDATE_CHANNEL} 目标=universal-apple-darwin dry-run=${AGC_RELEASE_DRY_RUN}" + ossutil_bin="${OSSUTIL_BIN:-ossutil}" + if command -v "${ossutil_bin}" >/dev/null 2>&1; then + "${ossutil_bin}" --version | head -1 + elif [ "${AGC_RELEASE_DRY_RUN}" = "1" ]; then + echo "[agc-macos] dry-run:未找到 ${ossutil_bin},只打印上传计划,不写 OSS" + else + echo "[agc-macos] 正式发布缺少 ossutil:${ossutil_bin};请安装或指定 OSSUTIL_BIN" + exit 1 + fi + cache_root="${GENARRATIVE_AGC_MACOS_CACHE_ROOT:-caches/genarrative-jenkins/agc-macos}" + case "${cache_root}" in /*) ;; *) cache_root="${HOME:?HOME 不能为空}/${cache_root}" ;; esac + if command -v sccache >/dev/null 2>&1; then + export SCCACHE_DIR="${cache_root}/sccache" + mkdir -p "${SCCACHE_DIR}" + echo "[agc-macos] sccache 缓存目录: ${SCCACHE_DIR}" + else + echo '[agc-macos] 未找到 sccache,改用 rustc 直接构建。' + unset RUSTC_WRAPPER + fi + node --test apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs + node --test apps/ai-game-creator-shell/scripts/verify-updater-signature.test.mjs + node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs + node apps/ai-game-creator-shell/scripts/build-macos-ci.mjs + if command -v sccache >/dev/null 2>&1; then + echo '[agc-macos] sccache 统计(自 server 启动累计):' + sccache --show-stats 2>&1 | sed -n '1,6p' + fi + ''' + } + } } } - stage('Archive only') { + stage('Archive release') { steps { - archiveArtifacts artifacts: 'artifacts/*.dmg,artifacts/*.sha256,artifacts/build-manifest.json,.jenkins-source-commit', fingerprint: true, allowEmptyArchive: false + archiveArtifacts artifacts: 'artifacts/*.dmg,artifacts/*.sha256,artifacts/latest.json,artifacts/*.sig,artifacts/release-notes.txt,artifacts/build-manifest.json,.jenkins-source-commit', fingerprint: true, allowEmptyArchive: false } } } post { - success { echo 'macOS universal 本地测试包已构建并归档;未上传 OSS、未正式签名/公证。' } + success { + echo params.AGC_RELEASE_DRY_RUN + ? 'macOS universal 渠道演练完成:已构建、验签并生成清单,未写入 OSS;Apple 签名与公证暂缺。' + : 'macOS universal 更新包、签名、首装包与 dev-mac 渠道清单已上传 OSS;Apple 签名与公证暂缺,首装需手动放行。' + } } }