Mac 渠道接入更新包签名、OSS 上传与渠道清单
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m17s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m25s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m26s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m34s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m9s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m17s
Project CI / Backend tests (pull_request) Successful in 11m30s
Project CI / Repository checks (pull_request) Successful in 11m36s
Project CI / Frontend tests (pull_request) Successful in 13m46s
Project CI / Native shell tests (pull_request) Successful in 16m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m31s

问题: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 上传计划顺序正确。
This commit is contained in:
2026-09-19 17:49:25 +08:00
parent 261228ed3f
commit b1cadd0cc8
8 changed files with 589 additions and 58 deletions
@@ -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_KEYminisign)是硬需求:缺了客户端一律拒绝安装,
* 因此构建前要求凭据存在,构建后用内置公钥复核 `.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 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `_<version>_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`,
);
@@ -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'));
});
@@ -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
* - 产物旁的 `<artifact>.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}`,
);
}
@@ -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 实际使用的 EDBLAKE2b-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);
});
@@ -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)尚未验收。
## 验收标准
@@ -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)。
@@ -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 apparm64/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` 输出,供判断是否需要预热或调整缓存上限。
@@ -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 签名与公证暂缺,首装需手动放行。'
}
}
}