import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import { test } from 'node:test'; import { lockedMacPackage, validateArchiveListing, verifyPackageIntegrity, } from './prepare-macos-codex.mjs'; const lock = JSON.parse( fs.readFileSync(new URL('../../../package-lock.json', import.meta.url)), ); const version = JSON.parse( fs.readFileSync(new URL('../package.json', import.meta.url)), ).devDependencies['@openai/codex']; test('both macOS dependencies resolve from the lockfile without floating versions', () => { assert.equal( lockedMacPackage(lock, 'arm64', version).target, 'aarch64-apple-darwin', ); assert.equal( lockedMacPackage(lock, 'x64', version).target, 'x86_64-apple-darwin', ); assert.throws(() => lockedMacPackage(lock, 'other', version)); assert.throws(() => lockedMacPackage(lock, 'x64', '0.0.0')); }); test('native package integrity rejects tampering', () => { const bytes = Buffer.from('pinned package'); const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; verifyPackageIntegrity(bytes, integrity); assert.throws(() => verifyPackageIntegrity(Buffer.from('modified'), integrity), ); }); test('archive traversal and non-package entries fail closed', () => { validateArchiveListing( 'package/package.json\npackage/vendor/target/bin/codex\n', ); for (const listing of [ '', '/tmp/payload', 'package/../private', 'other/file', 'package/..\\file', ]) { assert.throws(() => validateArchiveListing(listing)); } }); test('CI pipeline is manual, publishes the macOS partition and never reuses a developer workspace', () => { const pipeline = fs.readFileSync( new URL( '../../../jenkins/Jenkinsfile.ai-game-creator-shell-macos-build', import.meta.url, ), 'utf8', ); for (const required of [ 'genarrative-agc-macos', 'disableConcurrentBuilds()', '$AGC_AGENT_ROOT', 'StrictHostKeyChecking=yes', 'git merge-base --is-ancestor', 'allowEmptyArchive: false', "string(name: 'AGC_UPDATE_CHANNEL', defaultValue: 'dev'", 'AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}', "string(credentialsId: 'AgcUpdaterSigningKey'", "string(credentialsId: 'AgcUpdaterSigningKeyPassword'", "string(credentialsId: 'AliyunAccessKeyId'", "string(credentialsId: 'AliyunaccessKeySecret'", 'AGC_RELEASE_VERSION', 'OSSUTIL_BIN', // 并行度必须可调:节点是共用机器,写死容易把整机压满或反过来浪费一半核心。 "string(name: 'CARGO_BUILD_JOBS', defaultValue: '8'", 'CARGO_BUILD_JOBS=${params.CARGO_BUILD_JOBS}', // Agent 工作区按约定匹配,不写死节点名:节点改名(-local → -01)后守卫仍成立。 '"$HOME"/Library/Jenkins/agents/*/workspace/*', ]) { assert.ok(pipeline.includes(required), required); } assert.ok( !pipeline.includes('genarrative-agc-macos-local'), 'Jenkinsfile 不得写死具体节点名', ); // 这条管线是正式发布入口(与 Windows 对称):默认真发布,演练需显式勾选。 assert.match( pipeline, /booleanParam\(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false/u, 'Channel 发布默认必须是真发布,演练只能显式勾选', ); // 节点是办公机:离线期间排队的旧构建必须自行让位,且跳过要覆盖后续全部阶段。 assert.match( pipeline, /booleanParam\(name: 'SKIP_IF_SUPERSEDED', defaultValue: false/u, ); // 仓库文件不得出现节点用户名/个人 Home 路径:换机或改名后必须仍然可用。 assert.ok( !pipeline.includes('/Users/'), 'Jenkinsfile 不得写死个人 Home 路径,工具链位置应按 $HOME 展开', ); assert.ok( pipeline.includes('export PATH="$HOME/'), 'PATH 必须在 shell 步骤里按 $HOME 展开', ); // 超时必须高于实测最慢(78 分钟冷构建 + 共用机器),否则会被中断在链接阶段。 assert.ok( pipeline.includes('timeout(time: 150'), '构建超时上限必须留出冷构建余量', ); for (const diagnostic of ['macOS 发布失败', '被中断']) { assert.ok(pipeline.includes(diagnostic), diagnostic); } assert.ok( pipeline.includes('.jenkins-superseded-by'), '必须记录被推进的标记供后续阶段判定', ); assert.equal( (pipeline.match(/env\.AGC_BUILD_SUPERSEDED != 'true'/gu) ?? []).length, 3, 'Toolchain / Package / Archive 三个阶段都必须按跳过标记收口', ); for (const forbidden of [ 'triggers {', 'cron(', 'pollSCM(', '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')); // `--no-sign` 会连带跳过 updater 的 minisign 签名,产物将没有 .sig,入口不得传它。 assert.ok( !entry.includes("'--no-sign'"), '--no-sign 会同时跳过 updater 签名,产物缺少 .sig', ); // workspace 会跨构建保留产物:必须在构建前清掉构建目录里所有更新包/签名/DMG—— // 只删「本轮要写的名字」会漏掉其它渠道身份的残留(2026-09-28 线上 dev-mac/0.1.142 // 就是被 0.1.139 的 release 身份 *.app.tar.gz 顶掉的),清理必须按后缀全覆盖。 const cleanupIndex = entry.indexOf( 'listStaleMacosArtifacts(existingBundleFiles)', ); const buildIndex = entry.indexOf('runTauriBuild(args, context)'); const identityIndex = entry.indexOf('assertMacosAppMatchesChannelIdentity({'); const manifestArtifactIndex = entry.indexOf( 'assertManifestArtifactMatchesExpected({', ); assert.ok(cleanupIndex > 0, '必须清理构建目录里的残留产物'); assert.ok(cleanupIndex < buildIndex, '清理必须发生在构建之前'); assert.ok(identityIndex > buildIndex, '构建之后必须核对产物身份'); assert.ok(identityIndex < verifyIndex, '身份核对必须在验签与上传之前'); assert.ok( manifestArtifactIndex > 0 && manifestArtifactIndex < uploadIndex, '必须在清单生成后核对它选中的就是本轮更新包', ); for (const required of [ 'fs.rmSync(stale, { recursive: true, force: true })', "'-ov'", ]) { assert.ok(entry.includes(required), required); } }); test('macOS release entry and smoke script derive product names from config and the bundle', () => { const entry = fs.readFileSync( new URL('./build-macos-ci.mjs', import.meta.url), 'utf8', ); // 产品名决定 *.app、updater 归档与 DMG 卷名:它必须从渠道安装身份派生, // 写死会在换渠道或改名后静默找错对象。 assert.ok( entry.includes('resolveChannelInstallIdentity'), '入口必须从渠道安装身份派生产品名', ); assert.ok( entry.includes('resolveProductName(context.channel)'), '产品名必须按当前发布渠道解析', ); assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名'); assert.ok( entry.includes("const macTarget = 'aarch64-apple-darwin'"), 'macOS 发布入口必须固定单架构目标', ); assert.ok( entry.includes('_${version}_aarch64.dmg'), '首装包名必须保留清单侧单架构分支唯一匹配所需的后缀(Tauri 口径 aarch64)', ); const smoke = fs.readFileSync( new URL('./check-macos-bundle.mjs', import.meta.url), 'utf8', ); assert.ok(!smoke.includes('陶泥儿'), '校验脚本不得写死产品名'); for (const required of [ 'path.basename(source)', 'Print :CFBundleExecutable', "'--clone'", ]) { assert.ok(smoke.includes(required), required); } });