Files
Genarrative/apps/ai-game-creator-shell/scripts/build-release.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

892 lines
29 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 { 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 {
resolveChannelInstallIdentity,
resolveReleaseChannel,
} from './channel-identity.mjs';
import { prepareNsisToolsetForRelease } from './nsis-toolset.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';
export { resolveReleaseChannel } from './channel-identity.mjs';
/**
* 影响 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 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),
];
}
/**
* 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道,
* 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录,
* 不同渠道必须在同一台设备上并存而不是互相顶掉。
*/
export function createChannelConfig(
channel = resolveReleaseChannel(),
target = defaultTarget(),
) {
const { productName, identifier } = resolveChannelInstallIdentity(channel);
return {
productName,
identifier,
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 主程序只产出一个 DMGaarch64 与 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,
prepareToolset = prepareNsisToolsetForRelease,
build = runTauriBuild,
generateManifest = generateUpdateManifest,
} = {},
) {
const context = resolveReleaseContext(args);
const bundling = !args.includes('--no-bundle');
if (bundling) await prepareVersion(context);
// Tauri bundler 下载 NSIS 工具链时不重试,网络截断会直接毁掉整次打包;
// 因此打包前先在 Windows 目标上预置(详见 nsis-toolset.mjs)。
if (bundling) await prepareToolset(context, { bundling });
build(args, context);
if (bundling) 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);
}