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'; 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 (target === 'universal-apple-darwin') { throw new Error( '内置 Codex 资源仅支持 macOS 单架构构建,请使用 aarch64-apple-darwin 或 x86_64-apple-darwin', ); } if ( ![ 'x86_64-pc-windows-msvc', 'aarch64-apple-darwin', 'x86_64-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, target), 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'; /** * 发布渠道 → 目标平台。渠道名会进入 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 = 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, target = defaultTarget(), ) { 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`; } /** * 单架构产物只登记实际目标,不能把同一原生资源映射为另一架构。 */ export function resolveManifestPlatformKeys(target = defaultTarget()) { validateReleaseTarget(target); 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(context = resolveReleaseContext()) { const { channel } = context; 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 = 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()) { 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 = [], context = resolveReleaseContext(args), { spawn = spawnSync } = {}, ) { if ( explicitBuildTarget(args) && explicitBuildTarget(args) !== context.target ) { throw new Error('构建参数与发布上下文目标不一致'); } const tauriArguments = buildTauriBuildArguments(args, context.target); const { channel } = context; const configPath = writeChannelConfigFile(channel); 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' }, ); 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 ); } 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(process.env, target), publishedAt = new Date().toISOString(), notes = readReleaseNotes(), commit = readHeadCommit(), } = {}, ) { validateReleaseTarget(target); resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target); 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( context = resolveReleaseContext(), ) { const { channel, target, bundleRoot } = context; const artifact = selectReleaseArtifact(listFiles(bundleRoot), target); 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, target, 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, }; } 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); }