485ed50b26
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
## 背景 净月潭案例(2026-09-19 21:53 → 09-20 01:26,3 小时 33 分)的问题不是模型慢,而是环境未就绪、验收靠模型自述、预算只约束单个工具入口、工具调用被 SDK 全局串行闸门卡住。本 PR 落地确认后的七项交付效率合同,并把捆绑 Codex 升到当前 npm latest。 ## 结果 - 新建 Web 游戏在正式生成前由宿主自动预检:捆绑 Node/npm、真实 Vite 构建、受限浏览器桌面/移动截图;失败不启动生成或付费素材。 - 首个副作用前冻结交付合同,宿主保存权威证据与预算;证据齐全后先封口、排空并取得执行器完整退出证明,再产出交付报告,模型回复不再当作验收。 - 原生 shell、内置浏览器与托管命令、第三方 MCP 共用同一执行许可和累计执行时间;`validation.maxRuns` 改为执行/返修批次语义,另设 `maxTurnSeconds` 墙钟上限。 - 所有工具可并发:移除 SDK 全局串行的 `apply_patch`/`update_plan` 注册,改由宿主 `agc_apply_patch`(官方 parser + 当前回合写许可 + 受控进程树 + 短项目事务)和 `agc_update_plan` 提供等价能力;真实请求目录里已无串行注册,长 MCP 与补丁/计划实测在同一响应内重叠。 - 付费提交与本地写入绑定原回合原租约:容量与同动作锁等待可取消,封口、取消或预算耗尽后零新增提交;已越过提交边界的请求保留 operation ID 走 GET 对账,不自动重放。 - 新增请求与工具分段计时、有界并行批读和首轮上下文预取,未知耗时不补零。 - 捆绑 Codex 0.147.0 → 0.155.1:固定版本收敛到 `build_support/codex_bundle.rs` 单一声明,vendor 解析源码按 `rust-v0.155.1` 逐字节重取并更新 UPSTREAM 证据,适配 0.155 统一 exec(`exec_command`/`write_stdin`);macOS 侧车最小系统版本仍为 15.0。 ## 验证 - 生产 CLI → 捆绑 Codex 0.155.1 → 本地 Responses/MCP 夹具 9/9:补丁、完成收尾、批次、只读/可写 MCP 并发、原生命令并发、原生资源并发、统一 exec 会话、期限终止(真实重叠 775 / 764 / 999 ms)。 - 真实模型目录 2/2;vendor 上游库 111 项;宿主定向与回归 106 项;真实 Windows 进程与取消 21 项;Node 侧门禁 52 项。 - 发行载荷:artifact-only 重建 NSIS,解包验证侧车清单 `codex-cli 0.155.1`、6 个组件哈希、打包后 `bin/codex.exe --version`、Node 运行时 2130 文件与双端 PNG、`--environment-check` ready。 - `check-config`、TypeScript、编码(4991 文件)、文档索引、`git diff --check`、`cargo fmt --check` 全部通过。 ## 未覆盖 - 真实陶泥儿登录态 Provider 生成尚未执行(本机 `--llm-status` 返回 `authentication-required`)。 - macOS 侧车只在本机做静态 Mach-O 与清单解析,未在 macOS 上跑 `check-macos-bundle.mjs`。 - 全仓库聚合套件在本机仍因负载敏感的后台 mock-provider 用例而红,与本次改动无关:改动前的旧二进制同样失败,换单线程后相关用例 3/3 通过。 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/439
897 lines
29 KiB
JavaScript
897 lines
29 KiB
JavaScript
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 {
|
||
assertRequestedVersionNotBelowChannel,
|
||
issueGlobalVersion as issueAgcGlobalVersion,
|
||
} from './agc-global-version.mjs';
|
||
import {
|
||
defaultEditorFeatures,
|
||
withDefaultCargoFeatures,
|
||
} from './cargo-features.mjs';
|
||
import { stageNodeRuntime } from './stage-node-runtime.mjs';
|
||
|
||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
|
||
const repoRoot = path.resolve(appRoot, '..', '..');
|
||
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
|
||
function defaultTarget() {
|
||
return process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget;
|
||
}
|
||
|
||
function explicitBuildTarget(args) {
|
||
let target;
|
||
const separator = args.indexOf('--');
|
||
const options = separator < 0 ? args : args.slice(0, separator);
|
||
for (let index = 0; index < options.length; index += 1) {
|
||
const argument = options[index];
|
||
let value;
|
||
if (argument === '--target' || argument === '-t') {
|
||
value = options[++index];
|
||
} else if (argument.startsWith('--target=')) {
|
||
value = argument.slice('--target='.length);
|
||
} else {
|
||
continue;
|
||
}
|
||
if (!value?.trim() || value.startsWith('-')) {
|
||
throw new Error('--target 缺少有效目标');
|
||
}
|
||
if (target !== undefined) throw new Error('不能重复指定 --target');
|
||
target = value.trim();
|
||
}
|
||
return target;
|
||
}
|
||
|
||
function validateReleaseTarget(target) {
|
||
if (
|
||
![
|
||
'x86_64-pc-windows-msvc',
|
||
'aarch64-apple-darwin',
|
||
'x86_64-apple-darwin',
|
||
'universal-apple-darwin',
|
||
].includes(target)
|
||
) {
|
||
throw new Error(`不支持的发布目标:${target}`);
|
||
}
|
||
return target;
|
||
}
|
||
|
||
/** 在入口冻结目标;所有发布步骤共享同一上下文,不再各自读取默认目标。 */
|
||
export function resolveReleaseContext(args = [], env = process.env) {
|
||
const target = validateReleaseTarget(
|
||
explicitBuildTarget(args) ||
|
||
env.AGC_BUILD_TARGET?.trim() ||
|
||
defaultReleaseTarget,
|
||
);
|
||
return Object.freeze({
|
||
target,
|
||
channel: resolveReleaseChannel(env),
|
||
bundleRoot: path.join(
|
||
appRoot,
|
||
'src-tauri',
|
||
'target',
|
||
target,
|
||
'release',
|
||
'bundle',
|
||
),
|
||
});
|
||
}
|
||
const packageJsonPath = path.join(appRoot, 'package.json');
|
||
const rootPackageLockPath = path.resolve(appRoot, '../..', 'package-lock.json');
|
||
const tauriConfigPath = path.join(appRoot, 'src-tauri', 'tauri.conf.json');
|
||
const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml');
|
||
const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock');
|
||
const defaultOssBaseUrl =
|
||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
|
||
|
||
const reservedChannelNames = new Set([
|
||
'win',
|
||
'mac',
|
||
'windows',
|
||
'macos',
|
||
'darwin',
|
||
'linux',
|
||
]);
|
||
|
||
/**
|
||
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
|
||
* 保持一致 —— `build-release.test.mjs` 有守卫用例逐条比对两边。
|
||
*/
|
||
export const agcReleasePathPatterns = [
|
||
'apps/ai-game-creator-shell/',
|
||
'packages/',
|
||
'server-rs/crates/',
|
||
'plugins/agc-cocos-editor/',
|
||
'plugins/agc-unity-editor/',
|
||
'plugins/agc-godot-editor/',
|
||
'apps/desktop-shell/src-tauri/icons/',
|
||
'package.json',
|
||
'package-lock.json',
|
||
];
|
||
|
||
function ossBaseUrl() {
|
||
return (
|
||
process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl
|
||
).replace(/\/+$/u, '');
|
||
}
|
||
|
||
function readPackageJson() {
|
||
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||
}
|
||
|
||
function readReleaseNotes() {
|
||
return process.env.AGC_UPDATE_RELEASE_NOTES?.trim() || '';
|
||
}
|
||
|
||
export function compareVersions(left, right) {
|
||
const leftParts = left.split('.').map(Number);
|
||
const rightParts = right.split('.').map(Number);
|
||
for (let index = 0; index < 3; index += 1) {
|
||
if (leftParts[index] !== rightParts[index]) {
|
||
return leftParts[index] > rightParts[index] ? 1 : -1;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function parseVersion(value, label) {
|
||
if (typeof value !== 'string' || !/^\d+\.\d+\.\d+$/u.test(value)) {
|
||
throw new Error(`${label} 不是有效的三段版本号:${String(value)}`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
export function nextPatchVersion(localVersion, remoteVersion) {
|
||
const local = parseVersion(localVersion, '本地版本');
|
||
const remote =
|
||
remoteVersion == null ? null : parseVersion(remoteVersion, 'OSS版本');
|
||
const base = remote && compareVersions(remote, local) > 0 ? remote : local;
|
||
const [major, minor, patch] = base.split('.').map(Number);
|
||
if (patch === Number.MAX_SAFE_INTEGER) {
|
||
throw new Error(`版本号 patch 已达到上限:${base}`);
|
||
}
|
||
return `${major}.${minor}.${patch + 1}`;
|
||
}
|
||
|
||
export function resolveReleasePlatform(target = defaultTarget()) {
|
||
if (target.includes('windows')) return 'windows';
|
||
if (target.includes('apple-darwin')) return 'darwin';
|
||
if (target.includes('linux')) return 'linux';
|
||
throw new Error(`不支持的发布目标:${target}`);
|
||
}
|
||
|
||
export function resolveReleaseChannel(env = process.env) {
|
||
const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev';
|
||
if (
|
||
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
|
||
channel.endsWith('-') ||
|
||
reservedChannelNames.has(channel) ||
|
||
/-(win|mac)$/u.test(channel)
|
||
) {
|
||
throw new Error(
|
||
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
|
||
);
|
||
}
|
||
return channel;
|
||
}
|
||
|
||
/** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */
|
||
export function resolveReleasePartition(
|
||
channel = resolveReleaseChannel(),
|
||
target = defaultTarget(),
|
||
) {
|
||
channel = resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel });
|
||
validateReleaseTarget(target);
|
||
return `${channel}-${resolveReleasePlatform(target) === 'windows' ? 'win' : 'mac'}`;
|
||
}
|
||
|
||
export function updateManifestUrl(
|
||
channel = resolveReleaseChannel(),
|
||
target = defaultTarget(),
|
||
) {
|
||
return `${ossBaseUrl()}/${resolveReleasePartition(channel, target)}/latest.json`;
|
||
}
|
||
|
||
/**
|
||
* universal 主程序与双目录原生资源共用一个更新包;单架构只登记实际目标。
|
||
*/
|
||
export function resolveManifestPlatformKeys(target = defaultTarget()) {
|
||
validateReleaseTarget(target);
|
||
if (target === 'universal-apple-darwin')
|
||
return ['darwin-aarch64', 'darwin-x86_64'];
|
||
if (target === 'aarch64-apple-darwin') return ['darwin-aarch64'];
|
||
if (target === 'x86_64-apple-darwin') return ['darwin-x86_64'];
|
||
if (target.includes('windows')) {
|
||
return [
|
||
target.startsWith('aarch64') ? 'windows-aarch64' : 'windows-x86_64',
|
||
];
|
||
}
|
||
throw new Error(`不支持的发布目标:${target}`);
|
||
}
|
||
|
||
/** 旧协议迁移指针:只在迁移窗口内存在,是历史版本高水位的来源。 */
|
||
function legacyBridgeManifestUrl() {
|
||
return `${ossBaseUrl()}/latest.json`;
|
||
}
|
||
|
||
async function fetchManifest(manifestUrl, label) {
|
||
let response;
|
||
try {
|
||
response = await fetch(manifestUrl, {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
} catch (error) {
|
||
throw new Error(`读取 ${label} 失败:${error.message}`);
|
||
}
|
||
if (response.status === 404) return null;
|
||
if (!response.ok) {
|
||
throw new Error(`读取 ${label} 失败:HTTP ${response.status}`);
|
||
}
|
||
try {
|
||
return await response.json();
|
||
} catch (error) {
|
||
throw new Error(`${label} 不是有效 JSON:${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function readManifestVersion(manifestUrl, label) {
|
||
const manifest = await fetchManifest(manifestUrl, label);
|
||
return manifest == null
|
||
? null
|
||
: parseVersion(manifest?.version, `${label} version`);
|
||
}
|
||
|
||
/** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */
|
||
async function readRemoteChannelManifest(channel, target) {
|
||
return fetchManifest(updateManifestUrl(channel, target), 'OSS 渠道清单');
|
||
}
|
||
|
||
/**
|
||
* 摘要锚点:上次发布对应的提交。
|
||
*
|
||
* 首选渠道清单里的 `commit`(发布产物自己的事实来源);清单缺该字段时(首次启用
|
||
* 摘要、或更换渠道后清单还没带过 commit)回退到 CI 传入的 `AGC_UPDATE_PREVIOUS_COMMIT`
|
||
* —— 它是上一次成功构建的 COMMIT_HASH,同样指向用户拿到的那个版本。
|
||
*/
|
||
export async function resolvePreviousReleaseCommit(
|
||
channel = resolveReleaseChannel(),
|
||
{
|
||
override = process.env.AGC_UPDATE_PREVIOUS_COMMIT,
|
||
target = defaultTarget(),
|
||
} = {},
|
||
) {
|
||
const explicit = override?.trim();
|
||
if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) {
|
||
return explicit;
|
||
}
|
||
try {
|
||
const manifest = await readRemoteChannelManifest(channel, target);
|
||
const commit =
|
||
typeof manifest?.commit === 'string' ? manifest.commit.trim() : '';
|
||
return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null;
|
||
} catch (error) {
|
||
// 摘要只是附注:清单读不到(网络抖动等)不能把发布带崩,降级为「没有锚点」。
|
||
console.warn(
|
||
`[ai-game-creator-shell] 读取摘要锚点失败,本次不写自动摘要:${error.message}`,
|
||
);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 版本高水位:渠道清单与旧协议迁移指针取较大值。
|
||
*
|
||
* 只看渠道清单会在「渠道刚启用、旧指针还停在更高版本」时把版本链改小 ——
|
||
* 2026-09-17 首次渠道发布就是这样把 0.1.57 退回 0.1.48 的。旧指针只服务
|
||
* Windows 渠道,其它渠道不参与比较;旧指针 404(迁移窗口结束)后自动只剩渠道清单。
|
||
*/
|
||
export async function resolveRemoteHighWaterVersion(
|
||
channel = resolveReleaseChannel(),
|
||
target = defaultTarget(),
|
||
) {
|
||
const channelVersion = await readManifestVersion(
|
||
updateManifestUrl(channel, target),
|
||
'OSS 渠道清单',
|
||
);
|
||
if (channel !== 'dev' || resolveReleasePlatform(target) !== 'windows')
|
||
return channelVersion;
|
||
const legacyVersion = await readManifestVersion(
|
||
legacyBridgeManifestUrl(),
|
||
'OSS 迁移指针',
|
||
);
|
||
if (channelVersion == null) return legacyVersion;
|
||
if (legacyVersion == null) return channelVersion;
|
||
return compareVersions(channelVersion, legacyVersion) >= 0
|
||
? channelVersion
|
||
: legacyVersion;
|
||
}
|
||
|
||
function replaceVersionLine(source, version, pattern, label) {
|
||
if (!pattern.test(source)) throw new Error(`未找到${label}版本字段`);
|
||
return source.replace(pattern, `$1${version}$3`);
|
||
}
|
||
|
||
/**
|
||
* 版本来源固定为 OSS 总版本号(`agc/global-version.json`):
|
||
* - CI 统一构建由发号 Job 先发号,再通过 AGC_RELEASE_VERSION 透传给各渠道;
|
||
* - 未传入时(本地手工兜底)由本函数现场发号并写回总号;
|
||
* - 渠道高水位只做断言:传入号低于本渠道清单版本即失败关闭。
|
||
*/
|
||
export async function prepareReleaseVersion(context = resolveReleaseContext()) {
|
||
const { channel, target } = context;
|
||
const localVersion = parseVersion(readPackageJson().version, '本地版本');
|
||
const remoteVersion = await resolveRemoteHighWaterVersion(channel, target);
|
||
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
|
||
const nextVersion = requestedVersion
|
||
? assertRequestedVersionNotBelowChannel({
|
||
requested: requestedVersion,
|
||
channelVersion: remoteVersion,
|
||
channel,
|
||
})
|
||
: await issueAgcGlobalVersion({
|
||
channel,
|
||
commit:
|
||
process.env.COMMIT_HASH?.trim() ||
|
||
process.env.GIT_COMMIT?.trim() ||
|
||
null,
|
||
buildId:
|
||
process.env.BUILD_NUMBER?.trim() ||
|
||
process.env.AGC_BUILD_ID?.trim() ||
|
||
null,
|
||
repoVersion: localVersion,
|
||
});
|
||
|
||
const packageSource = fs.readFileSync(packageJsonPath, 'utf8');
|
||
fs.writeFileSync(
|
||
packageJsonPath,
|
||
replaceVersionLine(
|
||
packageSource,
|
||
nextVersion,
|
||
/("version"\s*:\s*")([^"]+)(")/u,
|
||
'package.json',
|
||
),
|
||
);
|
||
|
||
const rootPackageLockSource = fs.readFileSync(rootPackageLockPath, 'utf8');
|
||
fs.writeFileSync(
|
||
rootPackageLockPath,
|
||
replaceVersionLine(
|
||
rootPackageLockSource,
|
||
nextVersion,
|
||
/("apps\/ai-game-creator-shell"\s*:\s*\{\s*\n\s*"name"\s*:\s*"@genarrative\/ai-game-creator-shell"\s*,\s*\n\s*"version"\s*:\s*")([^"]+)(")/u,
|
||
'root package-lock.json',
|
||
),
|
||
);
|
||
|
||
const tauriSource = fs.readFileSync(tauriConfigPath, 'utf8');
|
||
fs.writeFileSync(
|
||
tauriConfigPath,
|
||
replaceVersionLine(
|
||
tauriSource,
|
||
nextVersion,
|
||
/("version"\s*:\s*")([^"]+)(")/u,
|
||
'tauri.conf.json',
|
||
),
|
||
);
|
||
|
||
const cargoSource = fs.readFileSync(cargoManifestPath, 'utf8');
|
||
fs.writeFileSync(
|
||
cargoManifestPath,
|
||
replaceVersionLine(
|
||
cargoSource,
|
||
nextVersion,
|
||
/(^\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/mu,
|
||
'Cargo.toml',
|
||
),
|
||
);
|
||
|
||
const cargoLockSource = fs.readFileSync(cargoLockPath, 'utf8');
|
||
fs.writeFileSync(
|
||
cargoLockPath,
|
||
replaceVersionLine(
|
||
cargoLockSource,
|
||
nextVersion,
|
||
/(^name\s*=\s*"genarrative-ai-game-creator-shell"\s*\nversion\s*=\s*")([^"]+)(")/mu,
|
||
'Cargo.lock',
|
||
),
|
||
);
|
||
|
||
console.log(
|
||
requestedVersion
|
||
? `[ai-game-creator-shell] 渠道 ${channel} 使用发号 Job 下发的总号 ${nextVersion}(本渠道清单 ${remoteVersion ?? '不存在'} / 仓库 ${localVersion})`
|
||
: `[ai-game-creator-shell] 渠道 ${channel} 本地兜底发号 ${nextVersion}(本渠道清单 ${remoteVersion ?? '不存在'} / 仓库 ${localVersion})`,
|
||
);
|
||
return nextVersion;
|
||
}
|
||
|
||
export function buildTauriBuildArguments(
|
||
args = [],
|
||
target = defaultTarget(),
|
||
platform = process.platform,
|
||
) {
|
||
const noBundle = args.includes('--no-bundle');
|
||
const explicitTarget = explicitBuildTarget(args);
|
||
const targetArgs = noBundle || explicitTarget ? [] : ['--target', target];
|
||
if (!noBundle || explicitTarget)
|
||
validateReleaseTarget(explicitTarget || target);
|
||
const features = defaultEditorFeatures(
|
||
explicitTarget || (noBundle ? platform : target),
|
||
);
|
||
return [
|
||
'build',
|
||
...withDefaultCargoFeatures([...targetArgs, ...args], features),
|
||
];
|
||
}
|
||
|
||
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
|
||
export function createChannelConfig(
|
||
channel = resolveReleaseChannel(),
|
||
target = defaultTarget(),
|
||
) {
|
||
return {
|
||
plugins: {
|
||
updater: {
|
||
endpoints: [updateManifestUrl(channel, target)],
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
|
||
const configPath = path.join(
|
||
os.tmpdir(),
|
||
`agc-tauri-channel-${channel}-${target}.json`,
|
||
);
|
||
const config = createChannelConfig(channel, target);
|
||
// 普通 cargo test/dev 不要求发行资源;只有完成 staging 的发行构建加入映射。
|
||
if (includeNodeRuntime)
|
||
config.bundle = {
|
||
resources: { 'resources/node-runtime': 'game-runtime/node' },
|
||
};
|
||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||
return configPath;
|
||
}
|
||
|
||
export function runTauriBuild(
|
||
args = [],
|
||
context = resolveReleaseContext(args),
|
||
{ spawn = spawnSync, stageRuntime = stageNodeRuntime } = {},
|
||
) {
|
||
if (
|
||
explicitBuildTarget(args) &&
|
||
explicitBuildTarget(args) !== context.target
|
||
) {
|
||
throw new Error('构建参数与发布上下文目标不一致');
|
||
}
|
||
const tauriArguments = buildTauriBuildArguments(args, context.target);
|
||
const { channel, target } = context;
|
||
if (!args.includes('--no-bundle')) stageRuntime(target);
|
||
const configPath = writeChannelConfigFile(
|
||
channel,
|
||
target,
|
||
!args.includes('--no-bundle'),
|
||
);
|
||
console.log(
|
||
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
|
||
);
|
||
// 最后合并渠道配置,防止用户配置中的端点与实际发布目标分叉。
|
||
const separator = tauriArguments.indexOf('--');
|
||
tauriArguments.splice(
|
||
separator < 0 ? tauriArguments.length : separator,
|
||
0,
|
||
'--config',
|
||
configPath,
|
||
);
|
||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||
const result = spawn(
|
||
npmCommand,
|
||
['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments],
|
||
{
|
||
cwd: appRoot,
|
||
stdio: 'inherit',
|
||
shell: process.platform === 'win32',
|
||
env: {
|
||
...process.env,
|
||
// Vite embeds the platform API origin in the packaged renderer. The
|
||
// release channel and updater channel therefore cannot drift apart.
|
||
VITE_AGC_PLATFORM_CHANNEL: channel,
|
||
},
|
||
},
|
||
);
|
||
if (result.error) throw result.error;
|
||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||
}
|
||
|
||
function listFiles(root) {
|
||
if (!fs.existsSync(root)) return [];
|
||
return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
||
const fullPath = path.join(root, entry.name);
|
||
return entry.isDirectory() ? listFiles(fullPath) : [fullPath];
|
||
});
|
||
}
|
||
|
||
function artifactPriority(filePath, target) {
|
||
const name = path.basename(filePath).toLowerCase();
|
||
if (target.includes('windows')) return name.endsWith('.exe') ? 0 : 99;
|
||
// 更新链路要的是 updater 产物(macOS 为 .app.tar.gz),dmg 只作人工分发。
|
||
if (target.includes('apple-darwin')) {
|
||
return name.endsWith('.app.tar.gz') ? 0 : 99;
|
||
}
|
||
if (name.endsWith('.appimage.tar.gz')) return 0;
|
||
if (name.endsWith('.appimage')) return 1;
|
||
if (name.endsWith('.deb')) return 2;
|
||
if (name.endsWith('.rpm')) return 3;
|
||
return 99;
|
||
}
|
||
|
||
export function selectReleaseArtifact(files, target = defaultTarget()) {
|
||
validateReleaseTarget(target);
|
||
const explicit = process.env.AGC_UPDATE_ARTIFACT?.trim();
|
||
if (explicit) {
|
||
const resolved = path.resolve(explicit);
|
||
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
||
throw new Error(`AGC_UPDATE_ARTIFACT 不是有效文件:${resolved}`);
|
||
}
|
||
return resolved;
|
||
}
|
||
return (
|
||
[...files]
|
||
.filter((filePath) => artifactPriority(filePath, target) < 99)
|
||
.sort((left, right) => {
|
||
const priority =
|
||
artifactPriority(left, target) - artifactPriority(right, target);
|
||
return priority || left.localeCompare(right);
|
||
})[0] ?? null
|
||
);
|
||
}
|
||
|
||
export function selectFirstInstallArtifact(
|
||
files,
|
||
{ target, version, artifact },
|
||
) {
|
||
validateReleaseTarget(target);
|
||
let selected;
|
||
if (target.includes('windows')) {
|
||
selected = artifact;
|
||
if (!selected?.endsWith('.exe')) {
|
||
throw new Error('Windows 首装包必须复用本次 NSIS .exe 更新包');
|
||
}
|
||
} else if (target === 'universal-apple-darwin') {
|
||
// universal 主程序只产出一个 DMG,aarch64 与 x86_64 首装共用它(命名见 build-macos-ci.mjs)。
|
||
const suffix = `_${version}_universal.dmg`;
|
||
const candidates = files.filter((file) =>
|
||
path.basename(file).endsWith(suffix),
|
||
);
|
||
if (candidates.length !== 1) {
|
||
throw new Error(
|
||
`首装 DMG 必须唯一匹配本次版本 ${version} 的 universal 产物,找到 ${candidates.length} 个`,
|
||
);
|
||
}
|
||
selected = candidates[0];
|
||
} else {
|
||
// Tauri DMG 文件名使用 aarch64 / x64,而 updater 的 Intel 平台键是 x86_64。
|
||
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
|
||
const suffix = `_${version}_${architecture}.dmg`;
|
||
const candidates = files.filter((file) =>
|
||
path.basename(file).endsWith(suffix),
|
||
);
|
||
if (candidates.length !== 1) {
|
||
throw new Error(
|
||
`首装 DMG 必须唯一匹配本次版本 ${version} 和架构 ${architecture},找到 ${candidates.length} 个`,
|
||
);
|
||
}
|
||
selected = candidates[0];
|
||
}
|
||
if (
|
||
!fs.existsSync(selected) ||
|
||
!fs.statSync(selected).isFile() ||
|
||
fs.statSync(selected).size === 0
|
||
) {
|
||
throw new Error(`首装包不存在或为空:${selected}`);
|
||
}
|
||
return selected;
|
||
}
|
||
|
||
function readUpdaterSignature(artifactPath) {
|
||
const signaturePath = `${artifactPath}.sig`;
|
||
if (!fs.existsSync(signaturePath)) {
|
||
throw new Error(
|
||
`缺少更新包签名:${signaturePath};需要 bundle.createUpdaterArtifacts 与签名私钥(TAURI_SIGNING_PRIVATE_KEY / TAURI_SIGNING_PRIVATE_KEY_PATH)`,
|
||
);
|
||
}
|
||
const signature = fs.readFileSync(signaturePath, 'utf8').trim();
|
||
if (!signature) throw new Error(`更新包签名为空:${signaturePath}`);
|
||
return signature;
|
||
}
|
||
|
||
export function createUpdateManifest(
|
||
artifactPath,
|
||
{
|
||
target = defaultTarget(),
|
||
channel = resolveReleaseChannel(),
|
||
publishedAt = new Date().toISOString(),
|
||
notes = readReleaseNotes(),
|
||
commit = readHeadCommit(),
|
||
downloadArtifact,
|
||
} = {},
|
||
) {
|
||
validateReleaseTarget(target);
|
||
const partition = resolveReleasePartition(channel, target);
|
||
const signature = readUpdaterSignature(artifactPath);
|
||
const version = readPackageJson().version;
|
||
const firstInstallArtifact = selectFirstInstallArtifact(
|
||
downloadArtifact ? [downloadArtifact] : [],
|
||
{ target, version, artifact: artifactPath },
|
||
);
|
||
const fileName = path.basename(artifactPath);
|
||
const url = `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
|
||
const downloadUrl = `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(path.basename(firstInstallArtifact))}`;
|
||
const platforms = {};
|
||
const downloads = {};
|
||
for (const key of resolveManifestPlatformKeys(target)) {
|
||
platforms[key] = { signature, url };
|
||
downloads[key] = { url: downloadUrl };
|
||
}
|
||
return {
|
||
version,
|
||
...(notes ? { notes } : {}),
|
||
pub_date: publishedAt,
|
||
platforms,
|
||
downloads,
|
||
// 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。
|
||
...(commit ? { commit } : {}),
|
||
};
|
||
}
|
||
|
||
function readHeadCommit() {
|
||
try {
|
||
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
||
cwd: repoRoot,
|
||
encoding: 'utf8',
|
||
}).trim();
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上一次发布到本次之间的客户端相关提交。
|
||
*
|
||
* 返回 null 表示无法判定(没有上一次 commit,或本地没有该提交),此时不生成摘要。
|
||
*/
|
||
export function collectReleaseCommits(
|
||
previousCommit,
|
||
headCommit = 'HEAD',
|
||
{ cwd = repoRoot, paths = agcReleasePathPatterns } = {},
|
||
) {
|
||
if (!previousCommit) return null;
|
||
try {
|
||
for (const revision of [previousCommit, headCommit]) {
|
||
execFileSync('git', ['rev-parse', '--verify', `${revision}^{commit}`], {
|
||
cwd,
|
||
stdio: 'pipe',
|
||
});
|
||
}
|
||
} catch {
|
||
return null;
|
||
}
|
||
let output;
|
||
try {
|
||
output = execFileSync(
|
||
'git',
|
||
[
|
||
'log',
|
||
'--no-merges',
|
||
'--format=%h%x09%s',
|
||
`${previousCommit}..${headCommit}`,
|
||
'--',
|
||
...paths,
|
||
],
|
||
{ cwd, encoding: 'utf8' },
|
||
);
|
||
} catch {
|
||
return null;
|
||
}
|
||
return output
|
||
.split(/\r?\n/u)
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
.map((line) => {
|
||
const [sha = '', ...subject] = line.split('\t');
|
||
return { sha, subject: subject.join('\t') };
|
||
});
|
||
}
|
||
|
||
/** 自动更新摘要:逐条列客户端相关改动,超过上限时折叠并整体截断。 */
|
||
export function formatReleaseNotes(
|
||
commits,
|
||
{ limit = 12, subjectLength = 80, maxLength = 900 } = {},
|
||
) {
|
||
if (!commits || commits.length === 0) return '';
|
||
const lines = commits.slice(0, limit).map(({ sha, subject }) => {
|
||
const trimmed =
|
||
subject.length > subjectLength
|
||
? `${subject.slice(0, subjectLength - 1)}…`
|
||
: subject;
|
||
return `- ${trimmed}(${sha})`;
|
||
});
|
||
if (commits.length > limit) {
|
||
lines.push(`- 其余 ${commits.length - limit} 项客户端改动省略`);
|
||
}
|
||
const text = lines.join('\n');
|
||
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
|
||
}
|
||
|
||
/** 无锚点时的兜底:列出最近的客户端相关提交,并注明可能与上一版重复。 */
|
||
export function collectRecentReleaseCommits({
|
||
cwd = repoRoot,
|
||
paths = agcReleasePathPatterns,
|
||
limit = 8,
|
||
} = {}) {
|
||
let output;
|
||
try {
|
||
output = execFileSync(
|
||
'git',
|
||
['log', '--no-merges', `-n${limit}`, '--format=%h%x09%s', '--', ...paths],
|
||
{ cwd, encoding: 'utf8' },
|
||
);
|
||
} catch {
|
||
return null;
|
||
}
|
||
const commits = output
|
||
.split(/\r?\n/u)
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
.map((line) => {
|
||
const [sha = '', ...subject] = line.split('\t');
|
||
return { sha, subject: subject.join('\t') };
|
||
});
|
||
return commits.length > 0 ? commits : null;
|
||
}
|
||
|
||
export function formatRecentReleaseNotes(commits) {
|
||
const notes = formatReleaseNotes(commits, { limit: 8 });
|
||
if (!notes) return '';
|
||
return `最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n${notes}`;
|
||
}
|
||
|
||
/** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */
|
||
export function createLegacyUpdateManifest(
|
||
artifactPath,
|
||
{
|
||
channel = resolveReleaseChannel(),
|
||
target = defaultTarget(),
|
||
notes = readReleaseNotes(),
|
||
} = {},
|
||
) {
|
||
const partition = resolveReleasePartition(channel, target);
|
||
if (partition !== 'dev-win') {
|
||
throw new Error('旧协议迁移清单只属于 dev 渠道的 Windows 系统');
|
||
}
|
||
const bytes = fs.readFileSync(artifactPath);
|
||
const version = readPackageJson().version;
|
||
const fileName = path.basename(artifactPath);
|
||
return {
|
||
version,
|
||
downloadUrl: `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`,
|
||
sha256: createHash('sha256').update(bytes).digest('hex'),
|
||
size: bytes.length,
|
||
...(notes ? { releaseNotes: notes } : {}),
|
||
};
|
||
}
|
||
|
||
export async function generateUpdateManifest(
|
||
context = resolveReleaseContext(),
|
||
) {
|
||
const { channel, target, bundleRoot } = context;
|
||
const files = listFiles(bundleRoot);
|
||
const artifact = selectReleaseArtifact(files, target);
|
||
if (!artifact) {
|
||
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
|
||
}
|
||
const downloadArtifact = selectFirstInstallArtifact(files, {
|
||
target,
|
||
version: readPackageJson().version,
|
||
artifact,
|
||
});
|
||
const manualNotes = readReleaseNotes();
|
||
const previousCommit = await resolvePreviousReleaseCommit(channel, {
|
||
target,
|
||
});
|
||
const commits = collectReleaseCommits(previousCommit);
|
||
const recentCommits = previousCommit ? null : collectRecentReleaseCommits();
|
||
const notes =
|
||
manualNotes ||
|
||
formatReleaseNotes(commits) ||
|
||
formatRecentReleaseNotes(recentCommits);
|
||
if (!manualNotes && !notes) {
|
||
console.log(
|
||
`[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`,
|
||
);
|
||
}
|
||
const manifest = createUpdateManifest(artifact, {
|
||
channel,
|
||
target,
|
||
notes,
|
||
downloadArtifact,
|
||
});
|
||
const manifestPath = path.join(bundleRoot, 'latest.json');
|
||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||
const notesPath = path.join(bundleRoot, 'release-notes.txt');
|
||
fs.writeFileSync(
|
||
notesPath,
|
||
notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n',
|
||
);
|
||
const legacyManifest =
|
||
channel === 'dev' && resolveReleasePlatform(target) === 'windows'
|
||
? createLegacyUpdateManifest(artifact, { channel, target, notes })
|
||
: null;
|
||
const legacyManifestPath = legacyManifest
|
||
? path.join(bundleRoot, 'legacy-latest.json')
|
||
: null;
|
||
if (legacyManifest && legacyManifestPath) {
|
||
fs.writeFileSync(
|
||
legacyManifestPath,
|
||
`${JSON.stringify(legacyManifest, null, 2)}\n`,
|
||
);
|
||
}
|
||
console.log(
|
||
`[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
|
||
);
|
||
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
|
||
console.log(`[ai-game-creator-shell] 首装包:${downloadArtifact}`);
|
||
console.log(
|
||
manualNotes
|
||
? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案'
|
||
: notes && !previousCommit
|
||
? `[ai-game-creator-shell] 更新摘要:无锚点,列出最近 ${recentCommits ? recentCommits.length : 0} 条客户端相关提交`
|
||
: `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'})`,
|
||
);
|
||
console.log(`[ai-game-creator-shell] 更新摘要文件:${notesPath}`);
|
||
if (legacyManifestPath) {
|
||
console.log(
|
||
`[ai-game-creator-shell] 旧协议迁移清单:${legacyManifestPath}`,
|
||
);
|
||
}
|
||
return {
|
||
channel,
|
||
target,
|
||
artifact,
|
||
downloadArtifact,
|
||
manifest,
|
||
manifestPath,
|
||
notes,
|
||
notesPath,
|
||
previousCommit,
|
||
commits,
|
||
legacyManifest,
|
||
legacyManifestPath,
|
||
};
|
||
}
|
||
|
||
export async function buildRelease(
|
||
args = [],
|
||
{
|
||
prepareVersion = prepareReleaseVersion,
|
||
build = runTauriBuild,
|
||
generateManifest = generateUpdateManifest,
|
||
} = {},
|
||
) {
|
||
const context = resolveReleaseContext(args);
|
||
if (!args.includes('--no-bundle')) await prepareVersion(context);
|
||
build(args, context);
|
||
if (!args.includes('--no-bundle')) return generateManifest(context);
|
||
}
|
||
|
||
if (
|
||
process.argv[1] &&
|
||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||
) {
|
||
const args = process.argv.slice(2);
|
||
await buildRelease(args);
|
||
}
|