From ba089980b1d635ea1a9c35d63d92c574f8b81537 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:26:11 +0800 Subject: [PATCH 1/4] =?UTF-8?q?AGC=20=E5=8F=91=E5=B8=83=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=9B=B4=E6=96=B0=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 发布脚本读取渠道清单的 commit 字段,把上一次发布到本次之间客户端相关路径的提交标题写进清单 notes - 更新摘要同步写入旧协议清单 releaseNotes,并落盘 release-notes.txt 供 Jenkins 归档 - AGC_UPDATE_RELEASE_NOTES 非空时以手动文案为准,缺少上一次 commit 时不写摘要 - 提交取数固定在仓库根执行,避免 pathspec 相对应用目录解析导致漏取 - 新增摘要格式、真实临时仓库取数与调度管线路径表一致性用例 - 同步开发运维文档与技术方案的构建发布条款 --- .../scripts/build-release.mjs | 164 ++++++++++++++++-- .../scripts/build-release.test.mjs | 123 ++++++++++++- .../scripts/release-upload.mjs | 2 +- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 2 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../Jenkinsfile.ai-game-creator-shell-build | 4 +- 6 files changed, 280 insertions(+), 17 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 579220319..e8c8e026d 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; @@ -11,6 +11,8 @@ import { } from './cargo-features.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); +// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。 +const repoRoot = path.resolve(appRoot, '..', '..'); const defaultReleaseTarget = 'x86_64-pc-windows-msvc'; const releaseTarget = process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget; @@ -39,6 +41,20 @@ const releaseChannels = { 'dev-mac': 'darwin', }; +/** + * 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须 + * 保持一致 —— `build-release.test.mjs` 有守卫用例逐条比对两边。 + */ +export const agcReleasePathPatterns = [ + 'apps/ai-game-creator-shell/', + 'packages/', + 'server-rs/crates/', + 'plugins/agc-cocos-editor/', + 'apps/desktop-shell/src-tauri/icons/', + 'package.json', + 'package-lock.json', +]; + function ossBaseUrl() { return ( process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl @@ -148,7 +164,7 @@ function legacyBridgeManifestUrl() { return `${ossBaseUrl()}/latest.json`; } -async function readManifestVersion(manifestUrl, label) { +async function fetchManifest(manifestUrl, label) { let response; try { response = await fetch(manifestUrl, { @@ -161,13 +177,32 @@ async function readManifestVersion(manifestUrl, label) { if (!response.ok) { throw new Error(`读取 ${label} 失败:HTTP ${response.status}`); } - let manifest; try { - manifest = await response.json(); + return await response.json(); } catch (error) { throw new Error(`${label} 不是有效 JSON:${error.message}`); } - return parseVersion(manifest?.version, `${label} version`); +} + +async function readManifestVersion(manifestUrl, label) { + const manifest = await fetchManifest(manifestUrl, label); + return manifest == null + ? null + : parseVersion(manifest?.version, `${label} version`); +} + +/** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */ +async function readRemoteChannelManifest(channel = resolveReleaseChannel()) { + return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单'); +} + +export async function resolvePreviousReleaseCommit( + channel = resolveReleaseChannel(), +) { + 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; } /** @@ -398,6 +433,8 @@ export function createUpdateManifest( channel = resolveReleaseChannel(), target = releaseTarget, publishedAt = new Date().toISOString(), + notes = readReleaseNotes(), + commit = readHeadCommit(), } = {}, ) { const signature = readUpdaterSignature(artifactPath); @@ -408,24 +445,103 @@ export function createUpdateManifest( for (const key of resolveManifestPlatformKeys(target)) { platforms[key] = { signature, url }; } - const notes = readReleaseNotes(); return { version, ...(notes ? { notes } : {}), pub_date: publishedAt, platforms, + // 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。 + ...(commit ? { commit } : {}), }; } +function readHeadCommit() { + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', + }).trim(); + } catch { + return ''; + } +} + +/** + * 上一次发布到本次之间的客户端相关提交。 + * + * 返回 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', + '--no-merges', + '--format=%h%x09%s', + `${previousCommit}..${headCommit}`, + '--', + ...paths, + ], + { cwd, encoding: 'utf8' }, + ); + } catch { + return null; + } + return 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') }; + }); +} + +/** 自动更新摘要:逐条列客户端相关改动,超过上限时折叠并整体截断。 */ +export function formatReleaseNotes( + commits, + { limit = 12, subjectLength = 80, maxLength = 900 } = {}, +) { + if (!commits || commits.length === 0) return ''; + const lines = commits.slice(0, limit).map(({ sha, subject }) => { + const trimmed = + subject.length > subjectLength + ? `${subject.slice(0, subjectLength - 1)}…` + : subject; + return `- ${trimmed}(${sha})`; + }); + if (commits.length > limit) { + lines.push(`- 其余 ${commits.length - limit} 项客户端改动省略`); + } + const text = lines.join('\n'); + return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text; +} + /** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */ export function createLegacyUpdateManifest( artifactPath, - { channel = resolveReleaseChannel() } = {}, + { channel = resolveReleaseChannel(), notes = readReleaseNotes() } = {}, ) { const bytes = fs.readFileSync(artifactPath); const version = readPackageJson().version; const fileName = path.basename(artifactPath); - const notes = readReleaseNotes(); return { version, downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`, @@ -435,18 +551,32 @@ export function createLegacyUpdateManifest( }; } -export function generateUpdateManifest() { +export async function generateUpdateManifest() { const channel = resolveReleaseChannel(); const artifact = selectReleaseArtifact(listFiles(bundleRoot)); if (!artifact) { throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`); } - const manifest = createUpdateManifest(artifact, { channel }); + const manualNotes = readReleaseNotes(); + const previousCommit = await resolvePreviousReleaseCommit(channel); + const commits = collectReleaseCommits(previousCommit); + const notes = manualNotes || formatReleaseNotes(commits); + if (!manualNotes && !notes) { + console.log( + `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'})`, + ); + } + const manifest = createUpdateManifest(artifact, { channel, notes }); const manifestPath = path.join(bundleRoot, 'latest.json'); fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + const notesPath = path.join(bundleRoot, 'release-notes.txt'); + fs.writeFileSync( + notesPath, + notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n', + ); const legacyManifest = channel === 'dev-win' - ? createLegacyUpdateManifest(artifact, { channel }) + ? createLegacyUpdateManifest(artifact, { channel, notes }) : null; const legacyManifestPath = legacyManifest ? path.join(bundleRoot, 'legacy-latest.json') @@ -461,6 +591,12 @@ export function generateUpdateManifest() { `[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`, ); console.log(`[ai-game-creator-shell] 安装包:${artifact}`); + console.log( + manualNotes + ? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案' + : `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'})`, + ); + console.log(`[ai-game-creator-shell] 更新摘要文件:${notesPath}`); if (legacyManifestPath) { console.log( `[ai-game-creator-shell] 旧协议迁移清单:${legacyManifestPath}`, @@ -471,6 +607,10 @@ export function generateUpdateManifest() { artifact, manifest, manifestPath, + notes, + notesPath, + previousCommit, + commits, legacyManifest, legacyManifestPath, }; @@ -483,5 +623,5 @@ if ( const args = process.argv.slice(2); if (!args.includes('--no-bundle')) await prepareReleaseVersion(); runTauriBuild(args); - if (!args.includes('--no-bundle')) generateUpdateManifest(); + if (!args.includes('--no-bundle')) await 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 f085a10d0..911bfb22f 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -1,15 +1,25 @@ import assert from 'node:assert/strict'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { + agcReleasePathPatterns, + collectReleaseCommits, compareVersions, createChannelConfig, createLegacyUpdateManifest, createUpdateManifest, + formatReleaseNotes, nextPatchVersion, resolveManifestPlatformKeys, resolveReleaseChannel, @@ -243,3 +253,114 @@ test('release upload forces overwrite for artifact, signature and channel pointe assert.match(source, /agc\/\$\{channel\}\/latest\.json/u); assert.match(source, /agc\/latest\.json/u); }); + +test('release notes list client commits with short sha 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.match(lines[0], /^- 客户端更新切换到官方更新插件(a5fd25f1)$/u); + const truncatedSubject = lines[1] + .replace(/^- /u, '') + .replace(/(55af6014)$/u, ''); + assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`); + assert.match(truncatedSubject, /…$/u); + assert.match(lines[1], /(55af6014)$/u); + + const many = formatReleaseNotes( + Array.from({ length: 20 }, (_, index) => ({ + sha: `sha${index}`, + subject: `改动 ${index}`, + })), + ); + assert.match(many, /- 其余 8 项客户端改动省略$/u); + 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', + ); + git('add', '.'); + git('commit', '--quiet', '-m', '客户端:新增更新插件接入'); + + 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); + // 合并提交本身被 --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( + '../../../jenkins/Jenkinsfile.scheduled-revision-trigger', + import.meta.url, + ), + 'utf8', + ); + const filterLine = jenkinsfile + .split('\n') + .find((line) => line.includes('apps/ai-game-creator-shell/*|')); + assert.ok(filterLine, '调度管线里应存在发布范围过滤模式'); + for (const pattern of agcReleasePathPatterns) { + const bashPattern = pattern.includes('/') ? `${pattern}*` : pattern; + assert.ok( + filterLine.includes(bashPattern), + `调度管线过滤缺少 ${bashPattern}`, + ); + } +}); diff --git a/apps/ai-game-creator-shell/scripts/release-upload.mjs b/apps/ai-game-creator-shell/scripts/release-upload.mjs index 08c83c5dd..3d1cff921 100644 --- a/apps/ai-game-creator-shell/scripts/release-upload.mjs +++ b/apps/ai-game-creator-shell/scripts/release-upload.mjs @@ -54,7 +54,7 @@ function runOssutil(args) { await prepareReleaseVersion(); runTauriBuild([]); const { artifact, channel, legacyManifestPath, manifest, manifestPath } = - generateUpdateManifest(); + await generateUpdateManifest(); const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`; // Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过; // 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index ec42456a8..53d215a59 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -92,6 +92,8 @@ - 发布入口:`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` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。 - 上传:安装包与 `.sig` 上传到 `agc///`,清单以 `--force` 覆盖上传到 `agc//latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。 - Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。 - 归档证据:安装包、`.sig`、渠道清单与源码 commit。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 5784a9a5c..acbaf6f82 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -137,7 +137,7 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去 `Genarrative-Scheduled-Revision-Trigger` 是唯一的定时入口,每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名散列,不等同于整点)。它只用 `git ls-remote` 解析 `SOURCE_BRANCH`(默认 `master`)的远端 HEAD,不 checkout 工作区;解析出的完整 commit 与上一次触发过的 revision 相同则标记 `NOT_BUILT` 并结束,不触发任何下游。 -revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy` 与 `Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build`,纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 +revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy` 与 `Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build`,纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `commit` 字段,把该提交到本次提交之间触及客户端相关路径的提交标题逐条写进 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准,缺少上一份 `commit` 时不写摘要。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 调度状态是调度 Job 工作区里的 `.jenkins-last-triggered-revision`,构建描述同时回显本次 revision 与结果。工作区被清理(例如 `Wipe Out Workspace`)或状态文件缺失时,下一次运行按“版本变化”处理并触发一次,之后恢复稳定;需要重建同一版本时勾选 `FORCE_TRIGGER`。Job 按仓库内 `jenkins/scheduled-revision-trigger-job-config.xml` 创建:`scriptPath=jenkins/Jenkinsfile.scheduled-revision-trigger`、Git 入口 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`、凭据 `genarrative-local-gitea-ssh`、`` 留空(定时器写在 Jenkinsfile 里)。推送后必须让三个 live Job 各自加载一次新 Jenkinsfile,并只读核对 `config.xml`:Full 与 AGC 不再有 cron,定时只来自新调度 Job;只改 Jenkinsfile 而不确认 live 配置时,旧 cron 仍会继续触发。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index 8ac2484e2..db0bbf23b 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -24,7 +24,7 @@ pipeline { string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选,指定三段版本号;留空则按该渠道 OSS 与本地版本自动递增 patch') choice(name: 'AGC_UPDATE_CHANNEL', choices: ['dev-win', 'dev-mac'], description: 'AGC 发布渠道;dev-win 在 Windows 节点执行,dev-mac 需在 macOS 构建机本地执行') booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false, description: '勾选后只构建并打印将要执行的上传命令,不写入 OSS') - text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本,写入渠道清单的发布说明') + text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本;留空则由本次发布的客户端相关提交自动生成更新摘要') string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 或 ossutil.exe 的绝对路径/命令名') } @@ -159,7 +159,7 @@ pipeline { stage('Archive release') { steps { - archiveArtifacts artifacts: 'apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.sig,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/legacy-latest.json,.jenkins-source-commit', fingerprint: true + archiveArtifacts artifacts: 'apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.sig,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/legacy-latest.json,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/release-notes.txt,.jenkins-source-commit', fingerprint: true } } } From 5ccc46ac1ae3f6b6029d9388da63d21b6cdf317c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:31:19 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E8=B0=83=E5=BA=A6=E7=AE=A1=E7=BA=BF?= =?UTF-8?q?=E6=8C=89=E8=B7=AF=E5=BE=84=E8=BF=87=E6=BB=A4=20Full=20Build=20?= =?UTF-8?q?=E5=B9=B6=E5=9B=9E=E5=A1=AB=E5=8D=87=E7=BA=A7=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调度管线同时判定 AGC 与 Full Build 两条下游的发布范围,并分别决定是否触发 - Full Build 采用与线上站点/后端无关的路径黑名单,改动落在黑名单外照常部署,避免漏发 - 两条同时跳过时只推进 revision 状态、不触发任何发布,构建描述写明实际触发的下游 - 新增 Full Build 跳过模式守卫用例,并补齐开发运维与技术方案说明 - 主规范回填真实更新闭环证据:0.1.47 客户端完成提示、下载、安装与重启 --- .../scripts/build-release.test.mjs | 27 ++++++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 31 ++++----- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../Jenkinsfile.scheduled-revision-trigger | 66 +++++++++++++------ .../scheduled-revision-trigger-job-config.xml | 2 +- 5 files changed, 91 insertions(+), 37 deletions(-) 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 911bfb22f..cc2379f01 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -364,3 +364,30 @@ test('scheduler path filter stays in sync with client release paths', () => { ); } }); + +test('scheduler skips the full build only for non-deploy paths', () => { + const jenkinsfile = readFileSync( + new URL( + '../../../jenkins/Jenkinsfile.scheduled-revision-trigger', + import.meta.url, + ), + 'utf8', + ); + const skipLine = jenkinsfile + .split('\n') + .find((line) => line.includes('docs/*|.codex/*|jenkins/*')); + assert.ok(skipLine, '调度管线里应存在 Full Build 跳过模式'); + for (const pattern of [ + 'docs/*', + '.codex/*', + 'jenkins/*', + 'apps/ai-game-creator-shell/*', + 'apps/mobile-shell/*', + 'apps/desktop-shell/*', + 'apps/preview-deployer-web/*', + 'tools/*', + '*.md', + ]) { + assert.ok(skipLine.includes(pattern), `Full Build 跳过模式缺少 ${pattern}`); + } +}); diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 53d215a59..6b2f0841b 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -102,24 +102,25 @@ 已获得的证据: -| 条款 | 验收方式 | 证据 | -| -------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| 渠道与端点映射、渠道校验 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过(默认渠道、错配失败关闭、未知渠道失败关闭) | -| universal 包挂两个平台键 | 同上 + 本地发布烟测(伪造 bundle) | 通过(两键同 URL 同签名,不生成迁移清单) | -| 缺签名时失败关闭 | 同上 | 通过 | -| 开发态不检查更新 | `vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts` | 通过(开关关闭时不请求清单) | -| 旧自研链路整条删除 | 代码检索无残留命令、事件与白名单条目 | 通过(`download_agc_update` / 下载事件 / 清单常量均无残留) | -| 清单与对象布局符合渠道约定 | `Genarrative-Agc-Windows-Build` #68(2026-09-17,SUCCESS) | 通过:`agc/dev-win/latest.json` = 0.1.48 + `windows-x86_64`;`agc/dev-win/0.1.48/陶泥儿_0.1.48_x64-setup.exe` 与同名 `.sig` 公网可读 | -| 清单签名与签名对象一致 | 取回 `.sig` 对象与渠道清单 `signature` 比对 | 通过(逐字相同,420 字节) | -| 安装包与清单登记一致 | 下载安装包实算 SHA-256 与尺寸后与迁移桥清单比对 | 通过(size `104678031`、sha256 `1f67…4fd0` 一致) | -| 旧协议迁移桥 | 公网读取 `agc/latest.json` | 通过(0.1.48,`downloadUrl` 指向同一对象,含 `sha256` / `size`) | +| 条款 | 验收方式 | 证据 | +| ---------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| 渠道与端点映射、渠道校验 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过(默认渠道、错配失败关闭、未知渠道失败关闭) | +| universal 包挂两个平台键 | 同上 + 本地发布烟测(伪造 bundle) | 通过(两键同 URL 同签名,不生成迁移清单) | +| 缺签名时失败关闭 | 同上 | 通过 | +| 开发态不检查更新 | `vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts` | 通过(开关关闭时不请求清单) | +| 旧自研链路整条删除 | 代码检索无残留命令、事件与白名单条目 | 通过(`download_agc_update` / 下载事件 / 清单常量均无残留) | +| 清单与对象布局符合渠道约定 | `Genarrative-Agc-Windows-Build` #68(2026-09-17,SUCCESS) | 通过:`agc/dev-win/latest.json` = 0.1.48 + `windows-x86_64`;`agc/dev-win/0.1.48/陶泥儿_0.1.48_x64-setup.exe` 与同名 `.sig` 公网可读 | +| 清单签名与签名对象一致 | 取回 `.sig` 对象与渠道清单 `signature` 比对 | 通过(逐字相同,420 字节) | +| 安装包与清单登记一致 | 下载安装包实算 SHA-256 与尺寸后与迁移桥清单比对 | 通过(size `104678031`、sha256 `1f67…4fd0` 一致) | +| 旧协议迁移桥 | 公网读取 `agc/latest.json` | 通过(0.1.48,`downloadUrl` 指向同一对象,含 `sha256` / `size`) | +| 真实更新闭环(含升级后重启) | 0.1.47 客户端按提示下载安装并重启 | 通过(2026-09-17 用户实测:提示 → 下载 → 安装 → 关于页显示新版本,再次检查为已是最新) | 待执行证据(首次渠道发布后回填): -| 条款 | 验收方式 | 证据 | -| ---------------------------- | --------------------------------------------------------------------- | ------ | -| 真实更新闭环(含升级后重启) | 0.1.47 客户端升级到新版本,再启动不再提示;`npm run agc` 仍无更新入口 | 待执行 | -| 签名校验失败拒绝安装 | 篡改渠道清单 `signature` 后观察客户端拒绝安装的表现 | 待执行 | +| 条款 | 验收方式 | 证据 | +| -------------------- | --------------------------------------------------- | ------ | +| 签名校验失败拒绝安装 | 篡改渠道清单 `signature` 后观察客户端拒绝安装的表现 | 待执行 | +| 签名校验失败拒绝安装 | 篡改渠道清单 `signature` 后观察客户端拒绝安装的表现 | 待执行 | ## 未决问题与决策 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index acbaf6f82..0b5d624d2 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -137,7 +137,7 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去 `Genarrative-Scheduled-Revision-Trigger` 是唯一的定时入口,每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名散列,不等同于整点)。它只用 `git ls-remote` 解析 `SOURCE_BRANCH`(默认 `master`)的远端 HEAD,不 checkout 工作区;解析出的完整 commit 与上一次触发过的 revision 相同则标记 `NOT_BUILT` 并结束,不触发任何下游。 -revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy` 与 `Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build`,纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `commit` 字段,把该提交到本次提交之间触及客户端相关路径的提交标题逐条写进 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准,缺少上一份 `commit` 时不写摘要。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 +revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy` 与 `Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build`,纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。两条下游各自判定:AGC Windows Build 采用「客户端相关路径白名单」,Full Build 采用「与线上站点 / 后端无关的路径黑名单」(`docs/`、`.codex/`、`jenkins/`、`apps/ai-game-creator-shell/`、`apps/mobile-shell/`、`apps/desktop-shell/`、`apps/preview-deployer-web/`、`tools/`、根级 `*.md`),改动只要落在黑名单之外就会照常部署,避免漏发线上站点或后端;两条同时被判为跳过时调度管线只推进 revision 状态、不触发任何发布。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `commit` 字段,把该提交到本次提交之间触及客户端相关路径的提交标题逐条写进 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准,缺少上一份 `commit` 时不写摘要。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 调度状态是调度 Job 工作区里的 `.jenkins-last-triggered-revision`,构建描述同时回显本次 revision 与结果。工作区被清理(例如 `Wipe Out Workspace`)或状态文件缺失时,下一次运行按“版本变化”处理并触发一次,之后恢复稳定;需要重建同一版本时勾选 `FORCE_TRIGGER`。Job 按仓库内 `jenkins/scheduled-revision-trigger-job-config.xml` 创建:`scriptPath=jenkins/Jenkinsfile.scheduled-revision-trigger`、Git 入口 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`、凭据 `genarrative-local-gitea-ssh`、`` 留空(定时器写在 Jenkinsfile 里)。推送后必须让三个 live Job 各自加载一次新 Jenkinsfile,并只读核对 `config.xml`:Full 与 AGC 不再有 cron,定时只来自新调度 Job;只改 Jenkinsfile 而不确认 live 配置时,旧 cron 仍会继续触发。 diff --git a/jenkins/Jenkinsfile.scheduled-revision-trigger b/jenkins/Jenkinsfile.scheduled-revision-trigger index e403d407a..4c9173c30 100644 --- a/jenkins/Jenkinsfile.scheduled-revision-trigger +++ b/jenkins/Jenkinsfile.scheduled-revision-trigger @@ -58,24 +58,25 @@ pipeline { } } - // 只有在「本轮到达的提交」里出现 AGC 相关路径时,Windows 客户端才发布新版本; - // 纯文档或流水线自身的提交仍然触发 Full Build,但不再推高客户端版本号。 - stage('Resolve AGC Release Scope') { + // 按「本轮到达的提交」判定两条下游各自是否需要跑: + // - AGC:只有出现客户端相关路径才发布 Windows 客户端(避免纯文档提交推高版本号)。 + // - Full Build:只在改动全部落在「与线上站点/后端无关」的路径时跳过(fail-open 到部署)。 + stage('Resolve Release Scope') { when { expression { return env.REVISION_CHANGED == 'true' } } steps { withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) { script { - // 判定失败一律按「需要发布」处理,避免这段逻辑影响其它下游管线。 - def scope = 'changed' + // 判定失败一律按「两条都要跑」处理,避免这段逻辑影响下游发布。 + def output = 'agc=changed\nfull=changed' try { - scope = sh(script: '''#!/usr/bin/env bash + output = sh(script: '''#!/usr/bin/env bash set -uo pipefail export GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?缺少 Git SSH 凭据} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" previous="$(cat "${REVISION_STATE_FILE}" 2>/dev/null || true)" if [[ -z "${previous}" ]]; then - echo changed + printf 'agc=changed\nfull=changed\n' exit 0 fi mkdir -p "${AGC_SCOPE_CACHE_DIR}" @@ -85,31 +86,46 @@ pipeline { fi refspec="+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" if ! git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags --filter=blob:none origin "${refspec}"; then - git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags origin "${refspec}" || { echo changed; exit 0; } + git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags origin "${refspec}" || { printf 'agc=changed\nfull=changed\n'; exit 0; } fi if ! git -C "${AGC_SCOPE_CACHE_DIR}" cat-file -e "${previous}^{commit}" 2>/dev/null; then echo "浅取窗口内没有 ${previous},按需要发布处理" >&2 - echo changed + printf 'agc=changed\nfull=changed\n' exit 0 fi changed_paths="$(git -C "${AGC_SCOPE_CACHE_DIR}" diff --name-only "${previous}" "${REMOTE_REVISION}" 2>/dev/null || true)" + agc_scope=unchanged + full_scope=unchanged while IFS= read -r changed_path; do [[ -z "${changed_path}" ]] && continue case "${changed_path}" in apps/ai-game-creator-shell/*|packages/*|server-rs/crates/*|plugins/agc-cocos-editor/*|apps/desktop-shell/src-tauri/icons/*|package.json|package-lock.json) - echo changed - exit 0 + agc_scope=changed + ;; + esac + case "${changed_path}" in + docs/*|.codex/*|jenkins/*|apps/ai-game-creator-shell/*|apps/mobile-shell/*|apps/desktop-shell/*|apps/preview-deployer-web/*|tools/*|*.md) + ;; + *) + full_scope=changed ;; esac done <<< "${changed_paths}" - echo unchanged + printf 'agc=%s\nfull=%s\n' "${agc_scope}" "${full_scope}" ''', returnStdout: true).trim() } catch (error) { - echo "AGC 发布范围判定失败,按需要发布处理:${error}" - scope = 'changed' + echo "发布范围判定失败,按需要发布处理:${error}" + output = 'agc=changed\nfull=changed' } - env.AGC_RELEASE_SCOPE = (scope == 'unchanged') ? 'unchanged' : 'changed' - echo "AGC 发布范围:${env.AGC_RELEASE_SCOPE}(上一轮已触发 revision=${env.LAST_TRIGGERED_REVISION ?: '无'})" + def values = output.split('\n').collect { it.trim() }.findAll { it } + def readScope = { String key -> + def entry = values.find { it.startsWith(key + '=') } + def value = entry == null ? '' : entry.split('=')[1] + return (value == 'unchanged') ? 'unchanged' : 'changed' + } + env.AGC_RELEASE_SCOPE = readScope('agc') + env.FULL_BUILD_SCOPE = readScope('full') + echo "发布范围:AGC=${env.AGC_RELEASE_SCOPE} FullBuild=${env.FULL_BUILD_SCOPE}(上一轮已触发 revision=${env.LAST_TRIGGERED_REVISION ?: '无'})" } } } @@ -128,7 +144,13 @@ pipeline { string(name: 'COMMIT_HASH', value: pinnedRevision), string(name: 'DATABASE_BACKUP_MODE', value: 'skip'), ] - build job: env.FULL_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters + def fullTriggered = false + if (params.FORCE_TRIGGER || env.FULL_BUILD_SCOPE != 'unchanged') { + build job: env.FULL_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters + fullTriggered = true + } else { + echo "本轮提交全部与线上站点/后端无关,跳过 ${env.FULL_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER" + } def agcTriggered = false if (params.FORCE_TRIGGER || env.AGC_RELEASE_SCOPE != 'unchanged') { build job: env.AGC_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters @@ -137,9 +159,13 @@ pipeline { echo "本轮提交不含 AGC 相关路径,跳过 ${env.AGC_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER" } writeFile file: env.REVISION_STATE_FILE, text: pinnedRevision - currentBuild.description = agcTriggered - ? "已触发 ${env.FULL_BUILD_JOB_NAME} 与 ${env.AGC_BUILD_JOB_NAME}:${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}" - : "已触发 ${env.FULL_BUILD_JOB_NAME}(AGC 渠道未发布:本次提交不含 AGC 相关路径):${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}" + def triggered = [] + if (fullTriggered) { triggered.add(env.FULL_BUILD_JOB_NAME) } + if (agcTriggered) { triggered.add(env.AGC_BUILD_JOB_NAME) } + def target = "${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}" + currentBuild.description = triggered.isEmpty() + ? "本轮提交与两条下游都无关,未触发任何发布:${target}" + : "已触发 ${triggered.join(' 与 ')}:${target}" echo currentBuild.description } } diff --git a/jenkins/scheduled-revision-trigger-job-config.xml b/jenkins/scheduled-revision-trigger-job-config.xml index 5b9e25f3a..fba181962 100644 --- a/jenkins/scheduled-revision-trigger-job-config.xml +++ b/jenkins/scheduled-revision-trigger-job-config.xml @@ -1,7 +1,7 @@ - 按小时检查源码分支版本,只有版本变化时用同一个 commit 触发 Full Build;AGC Windows Build 额外按变更路径过滤,只有本轮提交触及客户端相关路径时才触发。 + 按小时检查源码分支版本,只有版本变化时用同一个 commit 触发下游;两条下游各自按变更路径过滤:AGC Windows Build 仅在本轮提交触及客户端相关路径时触发,Full Build 仅在本轮提交不只是文档 / 流水线 / 客户端改动时触发。 false From e146859ea27eb2054f6098dfdf98badf0dfc2863 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:42:10 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=91=98=E8=A6=81?= =?UTF-8?q?=E9=94=9A=E7=82=B9=E6=94=AF=E6=8C=81=20CI=20=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 摘要锚点优先取渠道清单 commit,缺失时回退 CI 传入的上一次成功构建 COMMIT_HASH - Jenkins 构建阶段解析上一次成功构建的 COMMIT_HASH 并注入 AGC_UPDATE_PREVIOUS_COMMIT,读取失败保持为空 - 新增锚点优先级与非法值忽略的定向用例 - 技术方案补充锚点兜底说明 --- .../scripts/build-release.mjs | 12 +++++++ .../scripts/build-release.test.mjs | 36 +++++++++++++++++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 2 +- .../Jenkinsfile.ai-game-creator-shell-build | 17 +++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index e8c8e026d..4459fbe92 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -196,9 +196,21 @@ async function readRemoteChannelManifest(channel = resolveReleaseChannel()) { return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单'); } +/** + * 摘要锚点:上次发布对应的提交。 + * + * 首选渠道清单里的 `commit`(发布产物自己的事实来源);清单缺该字段时(首次启用 + * 摘要、或更换渠道后清单还没带过 commit)回退到 CI 传入的 `AGC_UPDATE_PREVIOUS_COMMIT` + * —— 它是上一次成功构建的 COMMIT_HASH,同样指向用户拿到的那个版本。 + */ export async function resolvePreviousReleaseCommit( channel = resolveReleaseChannel(), + { override = process.env.AGC_UPDATE_PREVIOUS_COMMIT } = {}, ) { + const explicit = override?.trim(); + 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() : ''; 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 cc2379f01..eb583e380 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -22,6 +22,7 @@ import { formatReleaseNotes, nextPatchVersion, resolveManifestPlatformKeys, + resolvePreviousReleaseCommit, resolveReleaseChannel, resolveRemoteHighWaterVersion, selectReleaseArtifact, @@ -241,6 +242,41 @@ 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-win', { + override: '6017d46088c04199e99cf89f347b12d67591475e', + }), + '6017d46088c04199e99cf89f347b12d67591475e', + ); + // 覆盖值非法时忽略,继续用清单里的 commit。 + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { + override: 'not-a-sha', + }), + 'abcdef1234567890', + ); + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { override: ' ' }), + 'abcdef1234567890', + ); + }, + ); + + await withStubbedFetch( + () => jsonResponse({ version: '0.1.61' }), + async () => { + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { override: undefined }), + null, + ); + }, + ); +}); + 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 6b2f0841b..3c7201a22 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` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。 - 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。 - 上传:安装包与 `.sig` 上传到 `agc///`,清单以 `--force` 覆盖上传到 `agc//latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。 - Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index db0bbf23b..11010b29b 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -122,6 +122,22 @@ pipeline { stage('Build and upload') { steps { + script { + // 摘要锚点兜底:清单里还没有 commit 字段时(首次启用摘要 / 换渠道), + // 用上一次成功构建的 COMMIT_HASH 作为「上次发布提交」。读取失败保持为空, + // 发布脚本会退回清单锚点或干脆不写摘要。 + def anchor = '' + try { + def previousBuild = currentBuild.previousSuccessfulBuild + anchor = (previousBuild?.buildVariables?.COMMIT_HASH ?: '').toString().trim() + } catch (error) { + echo "读取上一次成功构建的 commit 失败,跳过摘要锚点兜底:${error}" + } + env.AGC_UPDATE_PREVIOUS_COMMIT = anchor + if (anchor) { + echo "更新摘要锚点兜底:${anchor.take(12)}" + } + } withCredentials([ string(credentialsId: 'AliyunAccessKeyId', variable: 'AGC_OSS_ACCESS_KEY_ID'), string(credentialsId: 'AliyunaccessKeySecret', variable: 'AGC_OSS_ACCESS_KEY_SECRET'), @@ -134,6 +150,7 @@ pipeline { "AGC_RELEASE_VERSION=${params.AGC_RELEASE_VERSION}", "AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}", "AGC_RELEASE_DRY_RUN=${params.AGC_RELEASE_DRY_RUN ? '1' : '0'}", + "AGC_UPDATE_PREVIOUS_COMMIT=${env.AGC_UPDATE_PREVIOUS_COMMIT ?: ''}", "AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}", ]) { powershell ''' From 724d94c95c77132bf0e084da6d7494465e005fee Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:00:50 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=91=98=E8=A6=81?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E4=B8=BA=E4=B8=8D=E9=98=BB=E6=96=AD=E5=8F=91?= =?UTF-8?q?=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 摘要锚点读取失败时降级为无锚点并告警,不再让整个发布失败 - 无锚点时列出最近 8 条客户端相关提交,并注明可能与上一版重复 - 新增锚点读取失败降级与最近提交兜底的定向用例 - 技术方案补充摘要不阻断发布的约束 --- .../scripts/build-release.mjs | 61 ++++++++++++++++--- .../scripts/build-release.test.mjs | 57 +++++++++++++++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 2 +- 3 files changed, 112 insertions(+), 8 deletions(-) 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、日志或归档产物。