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/*', // 上一次发布的 commit 落在 master 上,取到它更新摘要才不会退化成「最近提交」。 'refs/heads/master:refs/remotes/origin/master', ]) { 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 失败, // 或让上一轮遗留的 .sig 让验签门禁误通过。 for (const required of [ // 清理对象用派生的产品名算出来,而不是写死某个名字。 '${updaterArtifactName}.sig', '${firstInstallName}.sha256', 'fs.rmSync(stale, { 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('readProductName'), '入口必须从 Tauri 配置读产品名'); assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名'); assert.ok( entry.includes('_${version}_universal.dmg'), '首装包名必须保留清单侧唯一匹配所需的后缀', ); 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); } });