64b9ccfb9d
- 新增 assertVersionCommitted:发布前确认 5 个版本来源相对 HEAD 已提交,避免仅 bump 未提交就 release 导致本地与 Jenkins 检出版本不一致 - release-upload 在取版本、OSS 防降级前调用该守卫 - 更新 AGC 更新技术方案文档,并补发布校验回归测试
342 lines
11 KiB
JavaScript
342 lines
11 KiB
JavaScript
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 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';
|
||
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 bumpVersion(current, target = 'patch') {
|
||
const [major, minor, patch] =
|
||
parseVersion(current, '当前版本').split('.').map(Number);
|
||
if (target === 'major') return `${major + 1}.0.0`;
|
||
if (target === 'minor') return `${major}.${minor + 1}.0`;
|
||
if (target === 'patch' || target == null) {
|
||
if (patch === Number.MAX_SAFE_INTEGER) {
|
||
throw new Error(`版本号 patch 已达到上限:${current}`);
|
||
}
|
||
return `${major}.${minor}.${patch + 1}`;
|
||
}
|
||
if (typeof target === 'string' && /^\d+\.\d+\.\d+$/u.test(target)) {
|
||
return parseVersion(target, '指定版本');
|
||
}
|
||
throw new Error(
|
||
`无法识别的版本目标:${String(target)}(支持 patch / minor / major / 明确的三段版本号)`,
|
||
);
|
||
}
|
||
|
||
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`);
|
||
}
|
||
|
||
const versionFileSources = [
|
||
{
|
||
file: packageJsonPath,
|
||
pattern: /("version"\s*:\s*")([^"]+)(")/u,
|
||
label: 'package.json',
|
||
},
|
||
{
|
||
file: rootPackageLockPath,
|
||
pattern: /("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,
|
||
label: 'package-lock.json',
|
||
},
|
||
{
|
||
file: tauriConfigPath,
|
||
pattern: /("version"\s*:\s*")([^"]+)(")/u,
|
||
label: 'tauri.conf.json',
|
||
},
|
||
{
|
||
file: cargoManifestPath,
|
||
pattern: /(^\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/mu,
|
||
label: 'Cargo.toml',
|
||
},
|
||
{
|
||
file: cargoLockPath,
|
||
pattern: /(^name\s*=\s*"genarrative-ai-game-creator-shell"\s*\nversion\s*=\s*")([^"]+)(")/mu,
|
||
label: 'Cargo.lock',
|
||
},
|
||
];
|
||
|
||
export function readLocalVersion() {
|
||
return parseVersion(readPackageJson().version, '本地版本');
|
||
}
|
||
|
||
export function validateVersionConsistency(expectedVersion) {
|
||
for (const source of versionFileSources) {
|
||
const match = fs.readFileSync(source.file, 'utf8').match(source.pattern);
|
||
const actual = match ? match[2].trim() : '<未找到>';
|
||
if (actual !== expectedVersion) {
|
||
throw new Error(
|
||
`AGC 版本号不一致:${source.label} 应为 ${expectedVersion},实际 ${actual}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
export function writeVersionFiles(version) {
|
||
const nextVersion = parseVersion(version, '目标版本');
|
||
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',
|
||
),
|
||
);
|
||
|
||
return nextVersion;
|
||
}
|
||
|
||
export function assertVersionCommitted() {
|
||
const repoPaths = versionFileSources.map((source) =>
|
||
path.relative(repoRoot, source.file).replaceAll('\\', '/'),
|
||
);
|
||
const changed =
|
||
spawnSync(
|
||
'git',
|
||
['diff', '--quiet', 'HEAD', '--', ...repoPaths],
|
||
{ cwd: repoRoot },
|
||
).status === 1;
|
||
if (changed) {
|
||
throw new Error(
|
||
'版本文件相对 HEAD 存在未提交改动,请先提交后再发布:运行 npm --prefix apps/ai-game-creator-shell run bump-version -- --commit',
|
||
);
|
||
}
|
||
}
|
||
|
||
export function prepareReleaseVersion() {
|
||
const localVersion = readLocalVersion();
|
||
validateVersionConsistency(localVersion);
|
||
console.log(
|
||
`[ai-game-creator-shell] 使用仓库版本 ${localVersion}(不再自动递增或自由指定版本)`,
|
||
);
|
||
return localVersion;
|
||
}
|
||
|
||
export async function assertVersionNotBelowOss(version) {
|
||
const remoteVersion = await readRemoteVersion();
|
||
if (remoteVersion && compareVersions(version, remoteVersion) < 0) {
|
||
throw new Error(
|
||
`仓库版本 ${version} 低于线上 OSS 版本 ${remoteVersion}。请先运行 npm --prefix apps/ai-game-creator-shell run bump-version 提升版本后再发布。`,
|
||
);
|
||
}
|
||
console.log(
|
||
`[ai-game-creator-shell] 线上 OSS 版本 ${remoteVersion ?? '不存在'},发布版本 ${version}`,
|
||
);
|
||
}
|
||
|
||
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();
|
||
}
|