724d94c95c
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 6m22s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m20s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 6m42s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 6m1s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m4s
Project CI / Native shell tests (push) Failing after 1m29s
Project CI / AI game creator shell Rust crates (push) Successful in 2m15s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend 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
- 摘要锚点读取失败时降级为无锚点并告警,不再让整个发布失败 - 无锚点时列出最近 8 条客户端相关提交,并注明可能与上一版重复 - 新增锚点读取失败降级与最近提交兜底的定向用例 - 技术方案补充摘要不阻断发布的约束
687 lines
22 KiB
JavaScript
687 lines
22 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 {
|
||
defaultEditorFeatures,
|
||
withDefaultCargoFeatures,
|
||
} from './cargo-features.mjs';
|
||
|
||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
|
||
const repoRoot = path.resolve(appRoot, '..', '..');
|
||
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
|
||
const releaseTarget =
|
||
process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget;
|
||
const bundleRoot = path.join(
|
||
appRoot,
|
||
'src-tauri',
|
||
'target',
|
||
releaseTarget,
|
||
'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';
|
||
|
||
/**
|
||
* 发布渠道 → 目标平台。渠道名会进入 OSS 路径并烘焙进客户端端点,
|
||
* 一旦发布就不能改名(改名等于已发布客户端再也找不到更新)。
|
||
*/
|
||
const releaseChannels = {
|
||
'dev-win': 'windows',
|
||
'dev-mac': 'darwin',
|
||
};
|
||
|
||
/**
|
||
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
|
||
* 保持一致 —— `build-release.test.mjs` 有守卫用例逐条比对两边。
|
||
*/
|
||
export const agcReleasePathPatterns = [
|
||
'apps/ai-game-creator-shell/',
|
||
'packages/',
|
||
'server-rs/crates/',
|
||
'plugins/agc-cocos-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 = releaseTarget) {
|
||
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,
|
||
target = releaseTarget,
|
||
) {
|
||
const platform = resolveReleasePlatform(target);
|
||
const requested = env.AGC_UPDATE_CHANNEL?.trim();
|
||
if (requested) {
|
||
const channelPlatform = releaseChannels[requested];
|
||
if (!channelPlatform) {
|
||
throw new Error(
|
||
`未知发布渠道 ${requested};当前支持:${Object.keys(releaseChannels).join('、')}`,
|
||
);
|
||
}
|
||
if (channelPlatform !== platform) {
|
||
throw new Error(
|
||
`渠道 ${requested} 只能用于 ${channelPlatform} 目标,当前构建目标为 ${target}`,
|
||
);
|
||
}
|
||
return requested;
|
||
}
|
||
const defaultChannel = Object.entries(releaseChannels).find(
|
||
([, channelPlatform]) => channelPlatform === platform,
|
||
)?.[0];
|
||
if (!defaultChannel) {
|
||
throw new Error(
|
||
`目标 ${target} 没有默认发布渠道,请显式设置 AGC_UPDATE_CHANNEL`,
|
||
);
|
||
}
|
||
return defaultChannel;
|
||
}
|
||
|
||
export function updateManifestUrl(channel = resolveReleaseChannel()) {
|
||
return `${ossBaseUrl()}/${channel}/latest.json`;
|
||
}
|
||
|
||
/**
|
||
* 更新插件按运行时平台键查找清单条目:universal macOS 包同时挂
|
||
* `darwin-aarch64` 与 `darwin-x86_64`,单架构目标只挂对应键。
|
||
*/
|
||
export function resolveManifestPlatformKeys(target = releaseTarget) {
|
||
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 = resolveReleaseChannel()) {
|
||
return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单');
|
||
}
|
||
|
||
/**
|
||
* 摘要锚点:上次发布对应的提交。
|
||
*
|
||
* 首选渠道清单里的 `commit`(发布产物自己的事实来源);清单缺该字段时(首次启用
|
||
* 摘要、或更换渠道后清单还没带过 commit)回退到 CI 传入的 `AGC_UPDATE_PREVIOUS_COMMIT`
|
||
* —— 它是上一次成功构建的 COMMIT_HASH,同样指向用户拿到的那个版本。
|
||
*/
|
||
export async function resolvePreviousReleaseCommit(
|
||
channel = resolveReleaseChannel(),
|
||
{ override = process.env.AGC_UPDATE_PREVIOUS_COMMIT } = {},
|
||
) {
|
||
const explicit = override?.trim();
|
||
if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) {
|
||
return explicit;
|
||
}
|
||
try {
|
||
const manifest = await readRemoteChannelManifest(channel);
|
||
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(),
|
||
) {
|
||
const channelVersion = await readManifestVersion(
|
||
updateManifestUrl(channel),
|
||
'OSS 渠道清单',
|
||
);
|
||
if (channel !== 'dev-win') 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`);
|
||
}
|
||
|
||
export async function prepareReleaseVersion() {
|
||
const channel = resolveReleaseChannel();
|
||
const localVersion = parseVersion(readPackageJson().version, '本地版本');
|
||
const remoteVersion = await resolveRemoteHighWaterVersion(channel);
|
||
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
|
||
const nextVersion = requestedVersion
|
||
? parseVersion(requestedVersion, '指定版本')
|
||
: nextPatchVersion(localVersion, remoteVersion);
|
||
|
||
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} 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})`
|
||
: `[ai-game-creator-shell] 渠道 ${channel} 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`,
|
||
);
|
||
return nextVersion;
|
||
}
|
||
|
||
export function buildTauriBuildArguments(
|
||
args = [],
|
||
target = releaseTarget,
|
||
platform = process.platform,
|
||
) {
|
||
const noBundle = args.includes('--no-bundle');
|
||
const targetIndex = args.indexOf('--target');
|
||
const explicitTarget =
|
||
targetIndex >= 0
|
||
? args[targetIndex + 1]
|
||
: args
|
||
.find((value) => value.startsWith('--target='))
|
||
?.slice('--target='.length);
|
||
const targetArgs = noBundle || explicitTarget ? [] : ['--target', target];
|
||
const features = defaultEditorFeatures(
|
||
explicitTarget || (noBundle ? platform : target),
|
||
);
|
||
return [
|
||
'build',
|
||
...withDefaultCargoFeatures([...targetArgs, ...args], features),
|
||
];
|
||
}
|
||
|
||
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
|
||
export function createChannelConfig(channel = resolveReleaseChannel()) {
|
||
return {
|
||
plugins: {
|
||
updater: {
|
||
endpoints: [updateManifestUrl(channel)],
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function writeChannelConfigFile(channel) {
|
||
const configPath = path.join(
|
||
os.tmpdir(),
|
||
`agc-tauri-channel-${channel}.json`,
|
||
);
|
||
fs.writeFileSync(
|
||
configPath,
|
||
`${JSON.stringify(createChannelConfig(channel), null, 2)}\n`,
|
||
);
|
||
return configPath;
|
||
}
|
||
|
||
export function runTauriBuild(args = []) {
|
||
const tauriArguments = buildTauriBuildArguments(args);
|
||
if (!tauriArguments.includes('--config') && !tauriArguments.includes('-c')) {
|
||
const channel = resolveReleaseChannel();
|
||
const configPath = writeChannelConfigFile(channel);
|
||
console.log(
|
||
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
|
||
);
|
||
tauriArguments.push('--config', configPath);
|
||
}
|
||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||
const result = spawnSync(
|
||
npmCommand,
|
||
['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments],
|
||
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
|
||
);
|
||
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) {
|
||
const name = path.basename(filePath).toLowerCase();
|
||
if (releaseTarget.includes('windows')) return name.endsWith('.exe') ? 0 : 99;
|
||
// 更新链路要的是 updater 产物(macOS 为 .app.tar.gz),dmg 只作人工分发。
|
||
if (releaseTarget.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) {
|
||
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) < 99)
|
||
.sort((left, right) => {
|
||
const priority = artifactPriority(left) - artifactPriority(right);
|
||
return priority || left.localeCompare(right);
|
||
})[0] ?? null
|
||
);
|
||
}
|
||
|
||
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,
|
||
{
|
||
channel = resolveReleaseChannel(),
|
||
target = releaseTarget,
|
||
publishedAt = new Date().toISOString(),
|
||
notes = readReleaseNotes(),
|
||
commit = readHeadCommit(),
|
||
} = {},
|
||
) {
|
||
const signature = readUpdaterSignature(artifactPath);
|
||
const version = readPackageJson().version;
|
||
const fileName = path.basename(artifactPath);
|
||
const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
|
||
const platforms = {};
|
||
for (const key of resolveManifestPlatformKeys(target)) {
|
||
platforms[key] = { signature, url };
|
||
}
|
||
return {
|
||
version,
|
||
...(notes ? { notes } : {}),
|
||
pub_date: publishedAt,
|
||
platforms,
|
||
// 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。
|
||
...(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(), notes = readReleaseNotes() } = {},
|
||
) {
|
||
const bytes = fs.readFileSync(artifactPath);
|
||
const version = readPackageJson().version;
|
||
const fileName = path.basename(artifactPath);
|
||
return {
|
||
version,
|
||
downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`,
|
||
sha256: createHash('sha256').update(bytes).digest('hex'),
|
||
size: bytes.length,
|
||
...(notes ? { releaseNotes: notes } : {}),
|
||
};
|
||
}
|
||
|
||
export async function generateUpdateManifest() {
|
||
const channel = resolveReleaseChannel();
|
||
const artifact = selectReleaseArtifact(listFiles(bundleRoot));
|
||
if (!artifact) {
|
||
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
|
||
}
|
||
const manualNotes = readReleaseNotes();
|
||
const previousCommit = await resolvePreviousReleaseCommit(channel);
|
||
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, notes });
|
||
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-win'
|
||
? createLegacyUpdateManifest(artifact, { channel, 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(
|
||
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,
|
||
artifact,
|
||
manifest,
|
||
manifestPath,
|
||
notes,
|
||
notesPath,
|
||
previousCommit,
|
||
commits,
|
||
legacyManifest,
|
||
legacyManifestPath,
|
||
};
|
||
}
|
||
|
||
if (
|
||
process.argv[1] &&
|
||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||
) {
|
||
const args = process.argv.slice(2);
|
||
if (!args.includes('--no-bundle')) await prepareReleaseVersion();
|
||
runTauriBuild(args);
|
||
if (!args.includes('--no-bundle')) await generateUpdateManifest();
|
||
}
|