From f4269447016012e9500ba6b923b2cbc4d5041448 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 23 Sep 2026 16:18:04 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=20AGC=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E5=B9=B6=E5=81=9C=E6=AD=A2=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=9B=B4=E6=96=B0=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新弹窗改为窗口居中纵向排版 - 停止自动生成渠道清单更新摘要 - 仅保留 AGC_UPDATE_RELEASE_NOTES 手动更新说明 - 清理 Jenkins 摘要锚点与 macOS 额外拉取 master 逻辑 - 同步开发运维、技术方案和回归测试 --- .../scripts/build-release.mjs | 191 +-------------- .../scripts/build-release.test.mjs | 218 +----------------- .../scripts/prepare-macos-codex.test.mjs | 2 - .../src/components/AppUpdateNotice.tsx | 1 + .../runtime-config/RuntimeConfigDialog.tsx | 2 +- apps/ai-game-creator-shell/src/styles.css | 34 ++- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 6 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- .../Jenkinsfile.ai-game-creator-shell-build | 22 +- ...kinsfile.ai-game-creator-shell-macos-build | 6 +- 10 files changed, 49 insertions(+), 437 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 742e4f8d1..85d4b63a5 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -21,7 +21,7 @@ import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; import { stageNodeRuntime } from './stage-node-runtime.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); -// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。 +// Git 命令用于读取 release revision,必须在仓库根执行,不能在应用目录里执行。 const repoRoot = path.resolve(appRoot, '..', '..'); const defaultReleaseTarget = 'x86_64-pc-windows-msvc'; function defaultTarget() { @@ -96,8 +96,8 @@ const defaultOssBaseUrl = export { resolveReleaseChannel } from './channel-identity.mjs'; /** - * 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须 - * 保持一致 —— `build-release.test.mjs` 有守卫用例逐条比对两边。 + * 影响 Windows 客户端产物的路径。调度管线的发布范围判定必须与这里保持一致, + * `build-release.test.mjs` 有守卫用例逐条比对两边。 */ export const agcReleasePathPatterns = [ 'apps/ai-game-creator-shell/', @@ -228,43 +228,6 @@ async function readManifestVersion(manifestUrl, label) { : parseVersion(manifest?.version, `${label} version`); } -/** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */ -async function readRemoteChannelManifest(channel, target) { - return fetchManifest(updateManifestUrl(channel, target), 'OSS 渠道清单'); -} - -/** - * 摘要锚点:上次发布对应的提交。 - * - * 首选渠道清单里的 `commit`(发布产物自己的事实来源);清单缺该字段时(首次启用 - * 摘要、或更换渠道后清单还没带过 commit)回退到 CI 传入的 `AGC_UPDATE_PREVIOUS_COMMIT` - * —— 它是上一次成功构建的 COMMIT_HASH,同样指向用户拿到的那个版本。 - */ -export async function resolvePreviousReleaseCommit( - channel = resolveReleaseChannel(), - { - override = process.env.AGC_UPDATE_PREVIOUS_COMMIT, - target = defaultTarget(), - } = {}, -) { - const explicit = override?.trim(); - if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) { - return explicit; - } - try { - const manifest = await readRemoteChannelManifest(channel, target); - 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; - } -} - /** * 版本高水位:渠道清单与旧协议迁移指针取较大值。 * @@ -632,7 +595,7 @@ export function createUpdateManifest( pub_date: publishedAt, platforms, downloads, - // 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。 + // 非标准字段:更新插件会忽略,仅保留源码 revision 供线上排障。 ...(commit ? { commit } : {}), }; } @@ -648,129 +611,6 @@ function readHeadCommit() { } } -/** - * Git 的 %s 会把「标题后紧接说明行、没有空行」的整个首段拼成一行; - * 更新摘要只允许展示原始提交消息的第一行,避免把说明暴露给用户。 - */ -function commitMessageTitle(message) { - return String(message ?? '') - .split(/\r?\n/u, 1)[0] - .trim(); -} - -/** 解析 `git log -z --format=%h%x09%B`,只保留每个 commit 的消息首行。 */ -function parseReleaseCommitLog(output) { - return output - .split('\0') - .map((record) => record.trimEnd()) - .filter(Boolean) - .map((record) => { - const separator = record.indexOf('\t'); - if (separator < 0) return null; - const sha = record.slice(0, separator); - const subject = commitMessageTitle(record.slice(separator + 1)); - return sha && subject ? { sha, subject } : null; - }) - .filter(Boolean); -} - -/** - * 上一次发布到本次之间的客户端相关提交。 - * - * 返回 null 表示无法判定(没有上一次 commit,或本地没有该提交),此时不生成摘要。 - */ -export function collectReleaseCommits( - previousCommit, - headCommit = 'HEAD', - { cwd = repoRoot, paths = agcReleasePathPatterns } = {}, -) { - if (!previousCommit) return null; - try { - for (const revision of [previousCommit, headCommit]) { - execFileSync('git', ['rev-parse', '--verify', `${revision}^{commit}`], { - cwd, - stdio: 'pipe', - }); - } - } catch { - return null; - } - let output; - try { - output = execFileSync( - 'git', - [ - 'log', - '-z', - '--no-merges', - '--format=%h%x09%B', - `${previousCommit}..${headCommit}`, - '--', - ...paths, - ], - { cwd, encoding: 'utf8' }, - ); - } catch { - return null; - } - return parseReleaseCommitLog(output); -} - -/** 自动更新摘要:逐条列客户端相关改动标题,超过上限时折叠并整体截断。 */ -export function formatReleaseNotes( - commits, - { limit = 12, subjectLength = 80, maxLength = 900 } = {}, -) { - if (!commits || commits.length === 0) return ''; - const lines = commits.slice(0, limit).map(({ subject }) => { - const title = commitMessageTitle(subject); - const trimmed = - title.length > subjectLength - ? `${title.slice(0, subjectLength - 1)}…` - : title; - return `- ${trimmed}`; - }); - if (commits.length > limit) { - lines.push(`- 其余 ${commits.length - limit} 项客户端改动省略`); - } - const text = lines.join('\n'); - 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', - '-z', - '--no-merges', - `-n${limit}`, - '--format=%h%x09%B', - '--', - ...paths, - ], - { cwd, encoding: 'utf8' }, - ); - } catch { - return null; - } - const commits = parseReleaseCommitLog(output); - 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, @@ -810,19 +650,10 @@ export async function generateUpdateManifest( version: readPackageJson().version, artifact, }); - const manualNotes = readReleaseNotes(); - const previousCommit = await resolvePreviousReleaseCommit(channel, { - target, - }); - const commits = collectReleaseCommits(previousCommit); - const recentCommits = previousCommit ? null : collectRecentReleaseCommits(); - const notes = - manualNotes || - formatReleaseNotes(commits) || - formatRecentReleaseNotes(recentCommits); - if (!manualNotes && !notes) { + const notes = readReleaseNotes(); + if (!notes) { console.log( - `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`, + '[ai-game-creator-shell] 未生成更新摘要;如需携带说明,请设置 AGC_UPDATE_RELEASE_NOTES', ); } const manifest = createUpdateManifest(artifact, { @@ -857,11 +688,9 @@ export async function generateUpdateManifest( console.log(`[ai-game-creator-shell] 安装包:${artifact}`); console.log(`[ai-game-creator-shell] 首装包:${downloadArtifact}`); console.log( - manualNotes + notes ? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案' - : notes && !previousCommit - ? `[ai-game-creator-shell] 更新摘要:无锚点,列出最近 ${recentCommits ? recentCommits.length : 0} 条客户端相关提交` - : `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'})`, + : '[ai-game-creator-shell] 更新摘要:未生成', ); console.log(`[ai-game-creator-shell] 更新摘要文件:${notesPath}`); if (legacyManifestPath) { @@ -878,8 +707,6 @@ export async function generateUpdateManifest( manifestPath, notes, notesPath, - previousCommit, - commits, legacyManifest, 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 427166456..fefd89e67 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -1,12 +1,5 @@ import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; @@ -16,18 +9,13 @@ import { agcReleasePathPatterns, buildRelease, buildTauriBuildArguments, - collectRecentReleaseCommits, - collectReleaseCommits, compareVersions, createChannelConfig, createLegacyUpdateManifest, createUpdateManifest, - formatRecentReleaseNotes, - formatReleaseNotes, generateUpdateManifest, nextPatchVersion, resolveManifestPlatformKeys, - resolvePreviousReleaseCommit, resolveReleaseChannel, resolveReleaseContext, resolveReleasePartition, @@ -897,9 +885,15 @@ for (const channel of ['release', 'beta-2']) { assert.equal(result.channel, channel); assert.equal(result.target, target); assert.equal(result.manifest.version, packageVersion); + assert.equal(result.notes, ''); + assert.equal(result.manifest.notes, undefined); + assert.equal( + readFileSync(result.notesPath, 'utf8'), + '(本次没有可用的更新摘要)\n', + ); assert.equal(result.legacyManifestPath, null); assert.equal(result.legacyManifest, null); - assert.equal(requests.length, 2); + assert.equal(requests.length, 1); for (const entry of [ ...Object.values(result.manifest.platforms), ...Object.values(result.manifest.downloads), @@ -973,96 +967,6 @@ test('version high water ignores the windows migration pointer for other channel ); }); -test('release notes anchor prefers the explicit commit and falls back to the manifest', async () => { - await withStubbedFetch( - () => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }), - async () => { - assert.equal( - await resolvePreviousReleaseCommit('dev', { - override: '6017d46088c04199e99cf89f347b12d67591475e', - }), - '6017d46088c04199e99cf89f347b12d67591475e', - ); - // 覆盖值非法时忽略,继续用清单里的 commit。 - assert.equal( - await resolvePreviousReleaseCommit('dev', { - override: 'not-a-sha', - }), - 'abcdef1234567890', - ); - assert.equal( - await resolvePreviousReleaseCommit('dev', { override: ' ' }), - 'abcdef1234567890', - ); - }, - ); - - await withStubbedFetch( - () => jsonResponse({ version: '0.1.61' }), - async () => { - assert.equal( - await resolvePreviousReleaseCommit('dev', { override: undefined }), - null, - ); - }, - ); -}); - -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', { 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 entry forwards the built artifacts and dry-run mode to the uploader', () => { const source = readFileSync( new URL('./release-upload.mjs', import.meta.url), @@ -1077,112 +981,6 @@ test('release entry forwards the built artifacts and dry-run mode to the uploade assert.ok(source.includes('\n dryRun,\n')); }); -test('release notes list client commit subjects only and bound their size', () => { - const notes = formatReleaseNotes([ - { sha: 'a5fd25f1', subject: '客户端更新切换到官方更新插件' }, - { sha: '55af6014', subject: '修'.repeat(120) }, - ]); - const lines = notes.split('\n'); - assert.equal(lines.length, 2); - assert.equal(lines[0], '- 客户端更新切换到官方更新插件'); - const truncatedSubject = lines[1].replace(/^- /u, ''); - assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`); - assert.match(truncatedSubject, /…$/u); - - const many = formatReleaseNotes( - Array.from({ length: 20 }, (_, index) => ({ - sha: `sha${index}`, - subject: `改动 ${index}`, - })), - ); - assert.match(many, /- 其余 8 项客户端改动省略$/u); - assert.equal( - formatReleaseNotes([ - { - sha: 'ignored', - subject: '提交标题\n不应展示的说明一\n不应展示的说明二', - }, - ]), - '- 提交标题', - ); - assert.equal(formatReleaseNotes([]), ''); - assert.equal(formatReleaseNotes(null), ''); -}); - -test('release commits cover only client paths and skip merge commits', () => { - const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-changelog-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'); - git('commit', '--allow-empty', '--quiet', '-m', '基点'); - const base = git('rev-parse', 'HEAD').trim(); - - mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), { - recursive: true, - }); - mkdirSync(path.join(directory, 'docs'), { recursive: true }); - writeFileSync( - path.join(directory, 'apps/ai-game-creator-shell/main.rs'), - 'fn main() {}\n', - ); - const commitMessagePath = path.join( - directory, - '.git', - 'commit-message.txt', - ); - writeFileSync( - commitMessagePath, - '客户端:新增更新插件接入\n补充更新插件接入的详细说明\n', - ); - git('add', '.'); - git('commit', '--quiet', '-F', commitMessagePath); - - writeFileSync(path.join(directory, 'docs/readme.md'), '# 文档\n'); - git('add', '.'); - git('commit', '--quiet', '-m', '文档:补充说明'); - - writeFileSync( - path.join(directory, 'apps/ai-game-creator-shell/other.rs'), - 'fn other() {}\n', - ); - git('add', '.'); - git('commit', '--quiet', '-m', '客户端:修复版本回退'); - - git('checkout', '--quiet', '-b', 'side'); - writeFileSync( - path.join(directory, 'apps/ai-game-creator-shell/side.rs'), - 'fn side() {}\n', - ); - git('add', '.'); - git('commit', '--quiet', '-m', '客户端:侧分支改动'); - git('checkout', '--quiet', 'master'); - git('merge', '--quiet', '--no-ff', '--no-edit', 'side'); - - const commits = collectReleaseCommits(base, 'HEAD', { cwd: directory }); - assert.ok(commits, '应能在临时仓库里收集提交'); - const subjects = commits.map((entry) => entry.subject); - // 标题后没有空行时,git %s 会把说明行拼进标题;摘要必须取原始首行。 - // 合并提交本身被 --no-merges 排除,但它带入的客户端改动仍然计入。 - assert.deepEqual(subjects, [ - '客户端:侧分支改动', - '客户端:修复版本回退', - '客户端:新增更新插件接入', - ]); - assert.ok(commits.every((entry) => /^[0-9a-f]{7,}$/u.test(entry.sha))); - - assert.equal( - collectReleaseCommits('1234567890abcdef', 'HEAD', { cwd: directory }), - null, - ); - assert.equal(collectReleaseCommits(null, 'HEAD', { cwd: directory }), null); - } finally { - rmSync(directory, { recursive: true, force: true }); - } -}); - test('scheduler path filter stays in sync with client release paths', () => { const jenkinsfile = readFileSync( new URL( diff --git a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs index fc3546be7..5f4cee92c 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs @@ -81,8 +81,6 @@ test('CI pipeline is manual, publishes the macOS partition and never reuses a de 'CARGO_BUILD_JOBS=${params.CARGO_BUILD_JOBS}', // Agent 工作区按约定匹配,不写死节点名:节点改名(-local → -01)后守卫仍成立。 '"$HOME"/Library/Jenkins/agents/*/workspace/*', - // 上一次发布的 commit 落在 master 上,取到它更新摘要才不会退化成「最近提交」。 - 'refs/heads/master:refs/remotes/origin/master', ]) { assert.ok(pipeline.includes(required), required); } diff --git a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx index 421f50db0..27ce8c6e2 100644 --- a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx +++ b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx @@ -87,6 +87,7 @@ export function AppUpdateNotice() {