Files
Genarrative/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs
T
kdletters c52e0814d6 AGC渠道安装身份隔离:不同渠道包体可在同一台设备并存
- 新增 channel-identity.mjs 单点定义渠道安装身份,默认渠道保持基线 productName 与 identifier
- build-release.mjs 渠道 --config 同批注入 productName、identifier 与 updater 端点
- build-macos-ci.mjs 按发布渠道解析产品名,派生 .app、updater 归档与 DMG 名称
- main.rs 与 windows.rs 窗口标题取构建期产品名,同机并存的渠道客户端可区分
- config.rs 提权 ACL 的 managed 范围按目录名识别基线目录与渠道派生目录
- check-config.mjs 断言基线等于默认渠道身份且非默认渠道身份必须隔离
- build-release.test.mjs 与 prepare-macos-codex.test.mjs 补齐渠道身份与渠道首装包用例
- 更新 AGC 更新主规范、开发运维文档、共享记忆与渠道安装身份隔离的里程碑与实施计划
2026-09-21 17:06:03 +08:00

210 lines
7.6 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 { 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('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);
}
});