Files
Genarrative/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs
T
suzmii e53992fdc2
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
macOS 发布改为只出 arm64 单架构
- build-macos-ci 固定 aarch64-apple-darwin:单架构构建、只跑 arm64 隔离 smoke、首装包命名 <产品名>_<版本>_aarch64.dmg,构建清单记录 smokes 与实际清单平台键
- stage-node-runtime 的 universal-apple-darwin 恢复失败关闭并说明原因:随包 Node 只有单架构官方发行版,按宿主架构放行会让 Intel 上这份侧车不可执行,且通用包自检按 process.arch 校验抓不到
- 守卫用例改为断言入口固定 arm64 目标与 _aarch64.dmg 后缀、universal 目标必须抛错
- Jenkinsfile 与三份技术/运维文档同步单架构口径:清单只登记 darwin-aarch64,Intel 非目标,恢复 universal 的前置条件写在文档里
- 共享记忆补 2026-09-21 决策记录,pitfalls 记下「只带宿主架构的通用包」陷阱,避免再次用过渡实现糊过去
2026-09-21 12:30:56 +08:00

286 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
resolveManifestPlatformKeys,
resolveReleaseContext,
resolveReleasePartition,
runTauriBuild,
} from './build-release.mjs';
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
import {
readUpdaterPubkey,
verifyUpdaterSignature,
} from './verify-updater-signature.mjs';
/**
* AGC macOS 分区(`<channel>-mac`)发布入口:构建 arm64 单架构包 → arm64 隔离 smoke → 生成 arm64 DMG
* → 生成分区清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。
*
* 边界:
* - 只出 Apple Silicon(arm64)单架构:清单只登记 `darwin-aarch64`。Intel 侧要可用,前提是随包 Node
* 也能按架构各带一份(`stage-node-runtime.mjs` 对 universal 目标失败关闭);在实现之前**不得**
* 把 arm64 产物登记成 `darwin-x86_64`,否则 Intel 客户端会装到跑不起来的包。
* - 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');
// 单架构目标:清单侧 `resolveManifestPlatformKeys` 只为它登记 darwin-aarch64。
const macTarget = 'aarch64-apple-darwin';
const context = resolveReleaseContext([`--target=${macTarget}`]);
const partition = resolveReleasePartition(context.channel, context.target);
const version = await prepareReleaseVersion(context);
// 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`,
// 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。
const firstInstallName = `${productName}_${version}_aarch64.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=${macTarget}`,
'--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);
command(process.execPath, [
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
app,
'arm64',
]);
// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `_<version>_aarch64.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),
'首装包必须锁定本次生成的 arm64 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',
},
// 单架构发布:只跑 arm64 隔离 smoke;Intel 未支持(清单里没有 darwin-x86_64 键)。
smokes: ['arm64'],
manifestPlatformKeys: resolveManifestPlatformKeys(context.target),
},
null,
2,
)}\n`,
);
console.log(
dryRun
? `[agc-macos] dry-run 完成:${partition} 分区产物与清单已生成,未写入 OSS`
: `[agc-macos] ${partition} 分区更新包、签名、首装包与清单已上传 OSS`,
);