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 { resolveChannelInstallIdentity } from './channel-identity.mjs'; import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs'; import { readUpdaterPubkey, verifyUpdaterSignature, } from './verify-updater-signature.mjs'; /** * AGC macOS 分区(`-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, '../..'); /** * 产品名只从渠道安装身份派生(渠道身份由构建期 `--config` 注入 Tauri 配置): * 它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。写死会在改名或换渠道后 * 让入口静默找错对象(清理、打包、归档三处一起失效)。 */ function resolveProductName(channel) { const { productName } = resolveChannelInstallIdentity(channel); assert.ok( typeof productName === 'string' && productName.trim().length > 0, '渠道安装身份缺少 productName', ); return productName; } 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 productName = resolveProductName(context.channel); const appBundleName = `${productName}.app`; const updaterArtifactName = `${productName}.app.tar.gz`; 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 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `__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`, );