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
- 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 记下「只带宿主架构的通用包」陷阱,避免再次用过渡实现糊过去
286 lines
11 KiB
JavaScript
286 lines
11 KiB
JavaScript
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`,
|
||
);
|