diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 4459fbe92..fc4bbc8f4 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -211,10 +211,18 @@ export async function resolvePreviousReleaseCommit( if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) { return explicit; } - 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; + 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; + } } /** @@ -546,6 +554,39 @@ export function formatReleaseNotes( 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, @@ -572,10 +613,14 @@ export async function generateUpdateManifest() { const manualNotes = readReleaseNotes(); const previousCommit = await resolvePreviousReleaseCommit(channel); const commits = collectReleaseCommits(previousCommit); - const notes = manualNotes || formatReleaseNotes(commits); + 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 : '不可判定'})`, + `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`, ); } const manifest = createUpdateManifest(artifact, { channel, notes }); @@ -606,7 +651,9 @@ export async function generateUpdateManifest() { console.log( manualNotes ? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案' - : `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'})`, + : 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) { 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 eb583e380..de0134267 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -14,11 +14,13 @@ import { fileURLToPath } from 'node:url'; import { agcReleasePathPatterns, + collectRecentReleaseCommits, collectReleaseCommits, compareVersions, createChannelConfig, createLegacyUpdateManifest, createUpdateManifest, + formatRecentReleaseNotes, formatReleaseNotes, nextPatchVersion, resolveManifestPlatformKeys, @@ -277,6 +279,61 @@ test('release notes anchor prefers the explicit commit and falls back to the man ); }); +test('release notes anchor degrades to null when the manifest cannot be read', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + throw new Error('fetch failed'); + }; + try { + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { override: undefined }), + null, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('recent commit fallback marks that entries may repeat the previous release', () => { + const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-recent-git-')); + const git = (...args) => + execFileSync('git', args, { cwd: directory, encoding: 'utf8' }); + try { + git('init', '--quiet'); + git('config', 'user.email', 'release@example.test'); + git('config', 'user.name', 'release test'); + mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), { + recursive: true, + }); + for (const name of ['one', 'two']) { + writeFileSync( + path.join(directory, `apps/ai-game-creator-shell/${name}.rs`), + `fn ${name}() {}\n`, + ); + git('add', '.'); + git('commit', '--quiet', '-m', `客户端:${name}`); + } + writeFileSync(path.join(directory, 'README.md'), '# 文档\n'); + git('add', '.'); + git('commit', '--quiet', '-m', '文档:说明'); + + const recent = collectRecentReleaseCommits({ cwd: directory, limit: 5 }); + assert.deepEqual( + recent.map((entry) => entry.subject), + ['客户端:two', '客户端:one'], + ); + const notes = formatRecentReleaseNotes(recent); + assert.match( + notes, + /^最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n- 客户端:two/u, + ); + assert.equal(formatRecentReleaseNotes([]), ''); + assert.equal(formatRecentReleaseNotes(null), ''); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + 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 3c7201a22..97c7474ea 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -92,7 +92,7 @@ - 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。 - 渠道由构建参数显式指定,并按目标平台校验:Windows 目标只允许 `dev-win`,macOS 目标只允许 `dev-mac`;未显式指定时按目标平台取默认渠道。 - 定时调度只在本轮到达的提交包含 AGC 相关路径(客户端、共享包、`server-rs/crates`、AGC 插件、桌面壳图标、根依赖清单)时才触发渠道发布;纯文档或流水线自身的提交只跑 Full Build,不推高客户端版本号。判定失败或勾选强制触发时按"需要发布"处理。 -- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。 +- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。 - 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。 - 上传:安装包与 `.sig` 上传到 `agc///`,清单以 `--force` 覆盖上传到 `agc//latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。 - Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。