diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 2bfce8d02..579220319 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -143,27 +143,57 @@ export function resolveManifestPlatformKeys(target = releaseTarget) { throw new Error(`不支持的发布目标:${target}`); } -async function readRemoteVersion(channel = resolveReleaseChannel()) { - const manifestUrl = updateManifestUrl(channel); +/** 旧协议迁移指针:只在迁移窗口内存在,是历史版本高水位的来源。 */ +function legacyBridgeManifestUrl() { + return `${ossBaseUrl()}/latest.json`; +} + +async function readManifestVersion(manifestUrl, label) { let response; try { response = await fetch(manifestUrl, { headers: { Accept: 'application/json' }, }); } catch (error) { - throw new Error(`读取 OSS 渠道清单失败:${error.message}`); + throw new Error(`读取 ${label} 失败:${error.message}`); } if (response.status === 404) return null; if (!response.ok) { - throw new Error(`读取 OSS 渠道清单失败:HTTP ${response.status}`); + throw new Error(`读取 ${label} 失败:HTTP ${response.status}`); } let manifest; try { manifest = await response.json(); } catch (error) { - throw new Error(`OSS 渠道清单不是有效 JSON:${error.message}`); + throw new Error(`${label} 不是有效 JSON:${error.message}`); } - return parseVersion(manifest?.version, 'OSS渠道清单 version'); + return parseVersion(manifest?.version, `${label} version`); +} + +/** + * 版本高水位:渠道清单与旧协议迁移指针取较大值。 + * + * 只看渠道清单会在「渠道刚启用、旧指针还停在更高版本」时把版本链改小 —— + * 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) { @@ -174,7 +204,7 @@ function replaceVersionLine(source, version, pattern, label) { export async function prepareReleaseVersion() { const channel = resolveReleaseChannel(); const localVersion = parseVersion(readPackageJson().version, '本地版本'); - const remoteVersion = await readRemoteVersion(channel); + const remoteVersion = await resolveRemoteHighWaterVersion(channel); const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim(); const nextVersion = requestedVersion ? parseVersion(requestedVersion, '指定版本') 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 904bb079f..f085a10d0 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -13,6 +13,7 @@ import { nextPatchVersion, resolveManifestPlatformKeys, resolveReleaseChannel, + resolveRemoteHighWaterVersion, selectReleaseArtifact, updateManifestUrl, } from './build-release.mjs'; @@ -49,6 +50,22 @@ function withSignedArtifact(fileName, run) { } } +function jsonResponse(body, status = 200) { + return { + status, + ok: status >= 200 && status < 300, + json: async () => body, + }; +} + +function withStubbedFetch(handler, run) { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => handler(String(url)); + return Promise.resolve(run()).finally(() => { + globalThis.fetch = originalFetch; + }); +} + test('selects an explicit release artifact when configured', () => { const artifactPath = fileURLToPath( new URL('../package.json', import.meta.url), @@ -173,6 +190,47 @@ test('next release version follows the higher local or channel version', () => { assert.equal(nextPatchVersion('0.1.12', null), '0.1.13'); }); +test('version high water keeps the legacy pointer during the migration window', async () => { + await withStubbedFetch( + (url) => + url.endsWith('/agc/dev-win/latest.json') + ? jsonResponse({}, 404) + : jsonResponse({ version: '0.1.57' }), + async () => { + assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.57'); + // 旧指针 0.1.57 已是高水位,下一次发布必须是 0.1.58,不能退回渠道本地版本。 + assert.equal(nextPatchVersion('0.1.47', '0.1.57'), '0.1.58'); + }, + ); +}); + +test('version high water takes the higher of channel and legacy pointer', async () => { + await withStubbedFetch( + (url) => + url.endsWith('/agc/dev-win/latest.json') + ? jsonResponse({ version: '0.1.60' }) + : jsonResponse({ version: '0.1.57' }), + async () => { + assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.60'); + }, + ); +}); + +test('version high water ignores the windows migration pointer for other channels', async () => { + await withStubbedFetch( + (url) => { + assert.ok( + !url.endsWith('/agc/latest.json'), + 'non-windows channel must not read the windows migration pointer', + ); + return jsonResponse({ version: '0.1.12' }); + }, + async () => { + assert.equal(await resolveRemoteHighWaterVersion('dev-mac'), '0.1.12'); + }, + ); +}); + test('release upload forces overwrite for artifact, signature and channel pointers', () => { const source = readFileSync( new URL('./release-upload.mjs', import.meta.url), diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 61a738e77..314b98419 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -82,6 +82,7 @@ - 上一条的两个键不能合成单一 `darwin-universal` 键:更新插件按运行时实际架构解析清单键(Apple Silicon 命中 `darwin-aarch64`,Intel 命中 `darwin-x86_64`),不存在自动命中 `darwin-universal` 的情形。将来真要单独发该键,必须在客户端同时设置自定义 target,否则清单里这一项永远不会被读取。 - 构建期要求:打开 `bundle.createUpdaterArtifacts` 以生成 `.sig`;构建环境提供签名私钥与密码(私钥内容不得入库);公钥写入客户端配置。公钥在首个带更新能力的版本发布后不可更换,更换等于放弃自动更新(只能手动重装)。 - 版本递增按渠道独立进行:发布脚本读取该渠道远端 `latest.json` 的 `version`,与本地版本取较高者递增 patch;两个渠道的版本号互不影响。 +- 版本高水位:发布脚本取「渠道清单版本」与「旧协议迁移指针版本」(迁移窗口内)中的较大值再递增。只看渠道清单会在渠道启用初期把版本链改小 —— 2026-09-17 首次渠道发布即把旧指针的 0.1.57 退回 0.1.48,随后以显式 0.1.60 纠偏;迁移窗口结束(旧指针 404)后自动只剩渠道清单,`dev-mac` 不参与旧指针比较。 - 迁移(旧协议 → 渠道清单): - 迁移起点:已发布客户端(含当前线上版本)内置自研清单地址 `agc/latest.json`(sha256 格式),下载与安装由自研 Rust 命令完成。 - 迁移策略见「未决问题与决策」。迁移完成后,自研清单解析、下载命令、下载进度事件以及为此放行的 CSP / HTTP 白名单条目按「四不写」整条删除,不留兼容分支与墓碑说明。