Files
Genarrative/apps/ai-game-creator-shell/scripts/build-release.mjs
T
kdletters de3dbd330e
Project CI / Repository checks (pull_request) Successful in 3m12s
Project CI / Frontend tests (pull_request) Successful in 3m58s
Project CI / Backend tests (pull_request) Successful in 6m48s
Project CI / Native shell tests (pull_request) Failing after 18m57s
客户端更新下载改为自动安装
下载时显示全屏进度遮罩

更新完成后从临时目录静默安装

关于页支持手动检查更新

构建流水线支持指定版本号
2026-09-01 16:25:40 +08:00

263 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
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';
const updateManifestUrl =
process.env.VITE_AGC_UPDATE_MANIFEST_URL?.trim() ||
`${process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl}/latest.json`;
function readPackageJson() {
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
}
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}`;
}
async function readRemoteVersion() {
let response;
try {
response = await fetch(updateManifestUrl, {
headers: { Accept: 'application/json' },
});
} catch (error) {
throw new Error(`读取 OSS 版本清单失败:${error.message}`);
}
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`读取 OSS 版本清单失败:HTTP ${response.status}`);
}
let manifest;
try {
manifest = await response.json();
} catch (error) {
throw new Error(`OSS 版本清单不是有效 JSON:${error.message}`);
}
return parseVersion(manifest?.version, 'OSS版本清单 version');
}
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 localVersion = parseVersion(readPackageJson().version, '本地版本');
const remoteVersion = await readRemoteVersion();
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] 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})`
: `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`,
);
return nextVersion;
}
export function runTauriBuild(args = []) {
const noBundle = args.includes('--no-bundle');
const hasTarget = args.includes('--target');
const targetArgs = noBundle || hasTarget ? [] : ['--target', releaseTarget];
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const result = spawnSync(
npmCommand,
[
'--prefix',
'../..',
'exec',
'tauri',
'--',
'build',
...targetArgs,
...args,
],
{ 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;
if (process.platform === 'darwin') return name.endsWith('.dmg') ? 0 : 99;
if (name.endsWith('.appimage')) return 0;
if (name.endsWith('.deb')) return 1;
if (name.endsWith('.rpm')) return 2;
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
);
}
export function createUpdateManifest(artifactPath) {
const bytes = fs.readFileSync(artifactPath);
const version = readPackageJson().version;
const fileName = path.basename(artifactPath);
const baseUrl = (
process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl
).replace(/\/+$/u, '');
const encodedFileName = encodeURIComponent(fileName).replace(/%2F/giu, '/');
return {
version,
downloadUrl: `${baseUrl}/${encodeURIComponent(version)}/${encodedFileName}`,
sha256: createHash('sha256').update(bytes).digest('hex'),
size: bytes.length,
...(process.env.AGC_UPDATE_RELEASE_NOTES?.trim()
? { releaseNotes: process.env.AGC_UPDATE_RELEASE_NOTES.trim() }
: {}),
};
}
export function generateUpdateManifest() {
const artifact = selectReleaseArtifact(listFiles(bundleRoot));
if (!artifact) {
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
}
const manifest = createUpdateManifest(artifact);
const manifestPath = path.join(bundleRoot, 'latest.json');
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
console.log(`[ai-game-creator-shell] 已生成 ${manifestPath}`);
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
return { artifact, manifestPath, manifest };
}
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')) generateUpdateManifest();
}