diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 643723e57..b7737f2c9 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -16,11 +16,131 @@ const bundleRoot = path.join( 'release', 'bundle', ); -const packageJson = JSON.parse( - fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'), -); +const packageJsonPath = path.join(appRoot, 'package.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 nextVersion = nextPatchVersion(localVersion, remoteVersion); + + const packageSource = fs.readFileSync(packageJsonPath, 'utf8'); + fs.writeFileSync( + packageJsonPath, + replaceVersionLine( + packageSource, + nextVersion, + /("version"\s*:\s*")([^"]+)(")/u, + 'package.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( + `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`, + ); + return nextVersion; +} export function runTauriBuild(args = []) { const noBundle = args.includes('--no-bundle'); @@ -83,7 +203,7 @@ export function selectReleaseArtifact(files) { export function createUpdateManifest(artifactPath) { const bytes = fs.readFileSync(artifactPath); - const version = packageJson.version; + const version = readPackageJson().version; const fileName = path.basename(artifactPath); const baseUrl = ( process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl @@ -118,6 +238,7 @@ if ( 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(); } diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index e8bf9bb4b..ffc8bb8b2 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -2,7 +2,9 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { + compareVersions, createUpdateManifest, + nextPatchVersion, selectReleaseArtifact, } from './build-release.mjs'; @@ -29,8 +31,18 @@ test('manifest contains version, download URL and integrity fields', () => { const manifest = createUpdateManifest( new URL('../package.json', import.meta.url).pathname, ); - assert.equal(manifest.version, '0.1.12'); - assert.match(manifest.downloadUrl, /\/agc\/0\.1\.12\/package\.json$/u); + assert.match(manifest.version, /^\d+\.\d+\.\d+$/u); + assert.match( + manifest.downloadUrl, + new RegExp(`/agc/${manifest.version}/package\\.json$`, 'u'), + ); assert.equal(manifest.sha256.length, 64); assert.equal(typeof manifest.size, 'number'); }); + +test('next release version follows the higher local or OSS version', () => { + assert.equal(compareVersions('0.1.15', '0.1.12'), 1); + assert.equal(nextPatchVersion('0.1.12', '0.1.15'), '0.1.16'); + assert.equal(nextPatchVersion('0.1.18', '0.1.15'), '0.1.19'); + assert.equal(nextPatchVersion('0.1.12', null), '0.1.13'); +}); diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 6817d69bb..65cc17b1d 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1532,13 +1532,17 @@ if ( ); } +const releaseVersions = [ + packageConfig.version, + tauriConfig.version, + cargoPackageVersion, +]; if ( - tauriConfig.version !== '0.1.12' || - packageConfig.version !== '0.1.12' || - cargoPackageVersion !== '0.1.12' + releaseVersions.some((version) => !/^\d+\.\d+\.\d+$/u.test(version ?? '')) || + new Set(releaseVersions).size !== 1 ) { throw new Error( - 'AI game creator standard release must remain version 0.1.12', + 'AI game creator release versions must be valid three-part semver and synchronized', ); } diff --git a/apps/ai-game-creator-shell/scripts/release-upload.mjs b/apps/ai-game-creator-shell/scripts/release-upload.mjs index 5b602512a..43bd53520 100644 --- a/apps/ai-game-creator-shell/scripts/release-upload.mjs +++ b/apps/ai-game-creator-shell/scripts/release-upload.mjs @@ -9,9 +9,8 @@ if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) { } process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`; -const { generateUpdateManifest, runTauriBuild } = await import( - './build-release.mjs' -); +const { generateUpdateManifest, prepareReleaseVersion, runTauriBuild } = + await import('./build-release.mjs'); function runOssutil(args) { const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil'; @@ -25,6 +24,7 @@ function runOssutil(args) { if (result.status !== 0) process.exit(result.status ?? 1); } +await prepareReleaseVersion(); runTauriBuild([]); const { artifact, manifestPath, manifest } = generateUpdateManifest(); const artifactKey = `agc/${manifest.version}/${path.basename(artifact)}`; diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 9c5f26e0b..300ad78ea 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -29,8 +29,19 @@ AGC 每次启动时由根窗口检查一次公开 OSS 更新清单。清单默 ## 发布约定 -当前发布目标固定为 Windows x64 NSIS。执行 `npm run ai-game-creator-shell:build` 会向 Tauri 传入 `--target x86_64-pc-windows-msvc`,在构建完成后自动扫描 `.exe` 安装包,并在 `apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json` 生成包含版本、下载地址、大小和 SHA-256 的清单。可通过 `AGC_BUILD_TARGET` 显式覆盖目标(发布仍应使用 Windows x64),通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定 OSS 前缀,通过 `AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle` smoke 构建不会生成清单。 +当前发布目标固定为 Windows x64 NSIS。执行 `npm run ai-game-creator-shell:build` 会先读取 +`VITE_AGC_UPDATE_MANIFEST_URL`(默认 `https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/latest.json`)的 +`latest.json`,取本地与 OSS 的较高版本并递增一个 patch,然后同步更新 package、Tauri 和 Cargo +版本后再向 Tauri 传入 `--target x86_64-pc-windows-msvc` 构建。OSS 清单首次不存在时按本地版本递增; +OSS 请求失败、清单格式错误或版本无效会终止发布,避免覆盖线上版本。构建完成后自动扫描 `.exe` +安装包,并在 `apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json` +生成包含版本、下载地址、大小和 SHA-256 的清单。可通过 `AGC_BUILD_TARGET` 显式覆盖目标(发布仍应使用 +Windows x64),通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定 +OSS 前缀,通过 `AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle` smoke 构建不会读取 OSS、修改版本或生成清单。 每次发布安装包上传完成后,再上传同一目录生成的 `latest.json`,确保 `downloadUrl` 指向已存在的 OSS 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。构建脚本不自动上传 OSS,避免把 AccessKey 写入本地配置或 CI 日志。 -如需一键构建并上传,可执行 `npm run ai-game-creator-shell:release:upload`。该命令要求本机已安装并配置 `ossutil`,先构建 Windows x64 NSIS,再上传安装包和 `latest.json`。默认上传到 `agc-dev` / `oss-rg-china-mainland.aliyuncs.com`,也可用 `AGC_OSS_BUCKET`、`AGC_OSS_ENDPOINT` 和 `OSSUTIL_BIN` 覆盖;凭据由 ossutil 本机配置读取,不能写入仓库或命令行参数。 +如需一键构建并上传,可执行 `npm run ai-game-creator-shell:release:upload`。该命令要求本机已安装并配置 `ossutil`, +先按上述规则比较 OSS 版本、递增 patch、构建 Windows x64 NSIS,再上传安装包和 `latest.json`。默认上传到 +`agc-dev` / `oss-rg-china-mainland.aliyuncs.com`,也可用 `AGC_OSS_BUCKET`、`AGC_OSS_ENDPOINT` 和 `OSSUTIL_BIN` +覆盖;凭据由 ossutil 本机配置读取,不能写入仓库或命令行参数。 diff --git a/scripts/check-npm-workspaces.mjs b/scripts/check-npm-workspaces.mjs index 0c55b3b53..09bee0f04 100644 --- a/scripts/check-npm-workspaces.mjs +++ b/scripts/check-npm-workspaces.mjs @@ -173,12 +173,14 @@ export function collectNpmWorkspaceErrors(rootDir) { `${manifestPath}: name must be ${WORKSPACE_NAMES[workspacePath]}, received ${String(manifest.name)}`, ); } - const expectedWorkspaceVersion = - workspacePath === 'apps/ai-game-creator-shell' ? '0.1.12' : '0.1.0'; - if (manifest.version !== expectedWorkspaceVersion) { - errors.push( - `${manifestPath}: workspace version must be ${expectedWorkspaceVersion}`, - ); + if (workspacePath === 'apps/ai-game-creator-shell') { + if (!/^\d+\.\d+\.\d+$/u.test(manifest.version ?? '')) { + errors.push( + `${manifestPath}: workspace version must be a three-part semver`, + ); + } + } else if (manifest.version !== '0.1.0') { + errors.push(`${manifestPath}: workspace version must be 0.1.0`); } for (const nestedLockfile of findNestedLockfiles(rootDir, workspacePath)) {