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-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index 214b3f16d..6b66c9420 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -27,15 +27,7 @@ pub(crate) fn export_local_project_package_at( ) -> Result { validate_project_root(root)?; super::verification::validate_project_game_entry(root)?; - ensure_project_export_package_dir(root, "exports")?; - let readme_path = resolve_local_project_path(root, "exports/README.md")?; - if !readme_path.is_file() { - return Err("导出试玩包前需要先生成 exports/README.md".to_string()); - } - let readme_metadata = checked_export_package_metadata(&readme_path, "exports/README.md")?; - if !readme_metadata.is_file() { - return Err("导出试玩包前需要先生成 exports/README.md".to_string()); - } + ensure_project_export_readme(root)?; let files = collect_project_export_package_files(root)?; let total_bytes = files.iter().map(|(_, _, size)| *size).sum::(); @@ -140,6 +132,20 @@ pub(crate) fn export_local_project_package_at( /// 给足时间但必须有界),避免发布路径越过校验器允许的区间。 pub(crate) const PUBLISH_BUILD_TIMEOUT_SECONDS: u64 = 300; +fn project_has_runnable_prototype(manifest: &GameCreationAppManifest) -> bool { + let has_completed_prototype_task = manifest.tasks.iter().any(|task| { + task.id == "code-prototype" && task.status == GameCreationAppTaskStatus::Completed + }); + let has_running_preview = manifest.preview.as_ref().is_some_and(|preview| { + preview.status == GameCreationAppPreviewStatus::Running + && preview + .url + .as_deref() + .is_some_and(|url| !url.trim().is_empty()) + }); + has_completed_prototype_task || has_running_preview +} + /// 找到声明了 `scripts.build` 的 npm 工作目录(项目根或 `game/` 子工程)。 /// /// 只读 `package.json`,不执行任何东西;真正的执行交给 `project.verify` 的受控 @@ -256,6 +262,12 @@ pub(crate) async fn export_local_project_package_for_publish_at( if validate_project_game_entry(root).is_ok() { return export_local_project_package_at(root); } + let manifest = read_manifest_for_project(root)?; + if !project_has_runnable_prototype(&manifest) { + return Err( + "首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。".to_string(), + ); + } let plan = resolve_publish_build_plan(root)?; // `game/` 子工程构建前必须先有依赖:缺 node_modules 时由发布流程自己补一次安装, // 否则作者要点两次(先 bootstrap 再发布)。 @@ -477,6 +489,54 @@ pub(crate) fn list_local_project_export_packages_at( }) } +/// 发布导出前确保 `exports/README.md` 存在。 +/// +/// 缺失时按项目 manifest 生成最小发布说明;已有文件原样保留,避免覆盖作者或 Agent 的正式文案。 +/// README 本身仍沿用试玩包的普通文件 / 符号链接安全口径。 +fn ensure_project_export_readme(root: &Path) -> Result { + let readme_path = resolve_local_project_path(root, "exports/README.md")?; + match fs::symlink_metadata(&readme_path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err("试玩包不能包含符号链接:exports/README.md".to_string()); + } + if !metadata.is_file() { + return Err("exports/README.md 必须是普通文件".to_string()); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let manifest = read_manifest_for_project(root)?; + let content = render_project_export_readme(&manifest); + write_game_creator_private_file(&readme_path, content.as_bytes(), "试玩包发布说明")?; + } + Err(error) => { + return Err(format!( + "读取 exports/README.md 元数据失败:{}: {error}", + readme_path.display() + )); + } + } + Ok(readme_path) +} + +fn render_project_export_readme(manifest: &GameCreationAppManifest) -> String { + let title = manifest.name.trim(); + let title = if title.is_empty() { + "试玩游戏" + } else { + title + }; + let summary = manifest + .goal + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("这是一个可直接在浏览器中试玩的游戏原型。"); + format!( + "# {title} 试玩说明\n\n## 简介\n\n{summary}\n\n## 试玩方式\n\n- 解压试玩包后打开根目录的 `index.html`。\n- 使用游戏内提示开始试玩。\n\n## 反馈\n\n请记录问题发生步骤、预期结果和实际结果,便于快速定位。\n" + ) +} + pub(crate) fn collect_project_export_package_files( root: &Path, ) -> Result, String> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 9b0f1a480..4a1a034fb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -4018,6 +4018,12 @@ writeFileSync('dist/assets/app.js', 'document.documentElement.dataset.autoBuild "#, ) .expect("write build script"); + update_manifest_task_status_at( + &root, + "code-prototype", + GameCreationAppTaskStatus::Completed, + ) + .expect("mark prototype completed"); write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme"); let result = export_local_project_package_for_publish_at(&root) @@ -4045,13 +4051,21 @@ async fn publish_export_skips_build_when_playable_entry_exists() { .expect("project init"); write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) .expect("write playable html"); - write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme"); let result = export_local_project_package_for_publish_at(&root) .await - .expect("已有可玩入口时直接导出"); + .expect("已有可玩入口且缺少 README 时应自动生成并直接导出"); assert!(result.package_relative_path.ends_with(".zip")); + let readme = fs::read_to_string(root.join("exports/README.md")).expect("read generated readme"); + assert!(readme.contains("# 已构建发布项目 试玩说明"), "{readme}"); + let payload = read_local_project_export_package_at(&root, &result.package_relative_path) + .expect("read publishable package"); + assert!(payload.files.iter().any(|file| file.path == "index.html")); + assert!(payload + .files + .iter() + .any(|file| file.path == "exports/README.md")); let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default(); assert!( !log.contains("project.verify build"), @@ -4061,11 +4075,56 @@ async fn publish_export_skips_build_when_playable_entry_exists() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn publish_export_blocks_build_until_runnable_prototype_is_completed() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-prototype-pending", "原型未完成项目") + .expect("project init"); + fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "publish-prototype-pending-fixture", + "private": true, + "scripts": { "build": "node build-should-not-run.mjs" } + })) + .expect("serialize package json"), + ) + .expect("write package json"); + fs::write( + root.join("build-should-not-run.mjs"), + "import { writeFileSync } from 'node:fs'; writeFileSync('build-ran.txt', 'yes');", + ) + .expect("write build marker script"); + + let error = export_local_project_package_for_publish_at(&root) + .await + .expect_err("原型未完成时禁止触发用户项目构建"); + + assert!( + error.contains("首个可运行原型尚未完成"), + "错误应说明原型未完成:{error}" + ); + assert!(!root.join("build-ran.txt").exists()); + let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default(); + assert!( + !log.contains("project.verify build"), + "原型未完成时不应执行项目构建:{log}" + ); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn publish_export_reports_actionable_error_without_entry_or_build_script() { let root = unique_project_path(); init_local_game_project_at(&root, "project-no-entry", "缺少可玩入口项目") .expect("project init"); + update_manifest_task_status_at( + &root, + "code-prototype", + GameCreationAppTaskStatus::Completed, + ) + .expect("mark prototype completed"); // 脚手架默认带 build 脚本;这里改成只有 check 脚本,模拟“没有可玩产物且没有构建脚本”。 fs::write( root.join("game/package.json"), @@ -4103,6 +4162,10 @@ fn local_project_export_package_publish_payload_contains_bytes_and_file_digests( write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme"); let exported = export_local_project_package_at(&root).expect("export package"); + assert_eq!( + fs::read_to_string(root.join("exports/README.md")).unwrap(), + "publish notes" + ); let payload = read_local_project_export_package_at(&root, &exported.package_relative_path) .expect("read publish payload"); @@ -4251,18 +4314,29 @@ fn local_project_export_package_rejects_symlink_runtime_dirs_and_readme() { } #[test] -fn local_project_export_package_requires_playable_html_and_readme() { +fn local_project_export_package_generates_missing_readme_after_playable_html() { let root = unique_project_path(); init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init"); - let missing_readme = + let missing_entry = export_local_project_package_at(&root).expect_err("default html is not playable"); - assert!(missing_readme.contains("游戏入口必须包含可渲染画布")); + assert!(missing_entry.contains("游戏入口必须包含可渲染画布")); write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) .expect("write playable html"); - let missing_readme = - export_local_project_package_at(&root).expect_err("readme should be required"); - assert!(missing_readme.contains("exports/README.md")); + fs::remove_dir_all(root.join("exports")).expect("remove exports dir"); + + let exported = + export_local_project_package_at(&root).expect("missing readme should be generated"); + let readme = fs::read_to_string(root.join("exports/README.md")).expect("read generated readme"); + assert!(readme.contains("# 像素动作原型 试玩说明"), "{readme}"); + assert!(readme.contains("解压试玩包后打开根目录"), "{readme}"); + + let payload = read_local_project_export_package_at(&root, &exported.package_relative_path) + .expect("read generated package"); + assert!(payload + .files + .iter() + .any(|file| file.path == "exports/README.md")); fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index d3f1a814a..0351188c0 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -48,6 +48,10 @@ import type { TauriInvoke, } from './app/types'; import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel'; +import { + GamePublishProgressDialog, + type GamePublishProgressState, +} from './components/game-distribution/GamePublishProgressDialog'; import { agentConversationId, agentRuntimeStateFromResult, @@ -368,6 +372,8 @@ export function App({ const [publishPackageResult, setPublishPackageResult] = useState(null); const [publishPanelOpen, setPublishPanelOpen] = useState(false); + const [publishProgress, setPublishProgress] = + useState(null); // 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。 const [gamePublishAllowed, setGamePublishAllowed] = useState(false); const [projectChatError, setProjectChatError] = useState(''); @@ -1126,40 +1132,6 @@ export function App({ } } - async function queueProjectPolicyConfirmationIfNeeded( - invoke: TauriInvoke, - commandId: GameCreationAppCommandDescriptor['id'], - projectPath: string, - detail: string, - readyMessage: string, - onConfirm: () => void, - ) { - const policyView = await invoke( - 'read_project_permission_policy', - { projectPath }, - ); - if (policyView.policy.deniedCommands.includes(commandId)) { - const message = `项目权限策略拒绝执行:${commandId}`; - markProjectPolicyDenied(commandId, message); - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - directProjectChatRef.current?.announce(message); - return true; - } - if (!policyView.policy.confirmCommands.includes(commandId)) { - return false; - } - requestProjectPolicyConfirmation(commandId, projectPath, detail, onConfirm); - setMessages((current) => [ - ...current, - { role: 'assistant', text: readyMessage }, - ]); - directProjectChatRef.current?.announce(readyMessage); - return true; - } - async function denyPendingCommandIfNeeded( commandId: GameCreationAppCommandDescriptor['id'], projectPath: string | null, @@ -1206,8 +1178,8 @@ export function App({ /** * 导出试玩包并打开发布面板。 * - * 权限口径沿用本地命令:`project.export_package` 需要确认时先入队,确认后再导出; - * 导出结果只留在壳里,发布面板关闭即丢弃,不写入项目。 + * 发布不再走旧的聊天确认卡:点击后立即打开全屏进度弹窗并锁住工作区, + * 导出失败在弹窗内回显;成功后才切换到发布资料面板。 */ /** * 发布相关提示同时写工作台状态与 DirectProject 对话。 @@ -1232,52 +1204,42 @@ export function App({ announcePublishMessage('先打开一个项目再发布'); return; } - // 权限查询也可能慢或挂住,先回一条即时反馈,别让按钮看起来没反应。 - announcePublishMessage('正在检查发布权限…'); - const runExport = async () => { - // 导出可能包含构建步骤,再回一条即时反馈。 - announcePublishMessage('正在构建并打包试玩包,请稍候…'); - try { - const result = await invoke( - 'export_local_project_package', - { projectPath: nextProjectPath }, - ); - announcePublishMessage( - `已构建并打包试玩包:${result.packageRelativePath}`, - ); - setPublishPackageResult(result); - setPublishPanelOpen(true); - appendLocalPermissionLog( - nextProjectPath, - 'command.auto', - 'project.export_package', - ); - } catch (error) { - announcePublishMessage( - error instanceof Error ? error.message : String(error), - ); - } - }; - try { - const queued = await queueProjectPolicyConfirmationIfNeeded( - invoke, - 'project.export_package', - nextProjectPath, - '导出试玩包并打开「发布到游戏广场」面板。', - '导出试玩包需要确认,确认后继续。', - () => void runExport(), - ); - if (!queued) { - await runExport(); - } - } catch (error) { - // 权限查询失败也必须回话:onClick 的 Promise 没有 catch 时用户只会看到 - // 「点了没反应」,这里把它收敛成聊天里的可读错误。 + const publishManifest = manifestRef.current; + const hasCompletedPrototype = publishManifest.tasks.some( + (task) => task.id === 'code-prototype' && task.status === 'completed', + ); + const hasRunningPreview = + publishManifest.preview?.status === 'running' && + Boolean(publishManifest.preview.url?.trim()); + if (!hasCompletedPrototype && !hasRunningPreview) { announcePublishMessage( - `发布前权限检查失败:${ - error instanceof Error ? error.message : String(error) - }`, + '首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。', ); + return; + } + setPublishProgress({ + phase: 'running', + message: '正在构建并打包试玩包,请稍候…', + }); + try { + const result = await invoke( + 'export_local_project_package', + { projectPath: nextProjectPath }, + ); + setPublishProgress(null); + setPublishPackageResult(result); + setPublishPanelOpen(true); + appendLocalPermissionLog( + nextProjectPath, + 'command.auto', + 'project.export_package', + ); + } catch (error) { + setPublishProgress({ + phase: 'failed', + message: '发布失败', + detail: error instanceof Error ? error.message : String(error), + }); } } @@ -1723,20 +1685,40 @@ export function App({ return true; } try { - const paused = await queueProjectPolicyConfirmationIfNeeded( - invoke, + const policyView = await invoke( + 'read_project_permission_policy', + { projectPath: input.projectPath }, + ); + if (policyView.policy.deniedCommands.includes('conversation.write')) { + const message = '项目权限策略拒绝执行:conversation.write'; + markProjectPolicyDenied('conversation.write', message); + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + directProjectChatRef.current?.announce(message); + return false; + } + if (!policyView.policy.confirmCommands.includes('conversation.write')) { + projectConversationWriteConfirmedRef.current = input.projectPath; + return true; + } + requestProjectPolicyConfirmation( 'conversation.write', input.projectPath, '写入 DirectProject 对话历史', - 'DirectProject 对话写入需要确认。', () => { projectConversationWriteConfirmedRef.current = input.projectPath; input.onConfirmed(); }, ); - if (paused) return false; - projectConversationWriteConfirmedRef.current = input.projectPath; - return true; + const message = 'DirectProject 对话写入需要确认。'; + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + directProjectChatRef.current?.announce(message); + return false; } catch (error) { if (localProjectPathRef.current === input.projectPath) { setProjectChatError( @@ -2351,6 +2333,10 @@ export function App({ packageResult={publishPackageResult} onClose={() => setPublishPanelOpen(false)} /> + setPublishProgress(null)} + /> ); } @@ -2502,6 +2488,10 @@ export function App({ packageResult={publishPackageResult} onClose={() => setPublishPanelOpen(false)} /> + setPublishProgress(null)} + /> ); } 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() { + ) : ( + 发布完成前请保持客户端开启,页面暂时不可操作。 + )} + + ); +} diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 57bff26e3..38d3edcea 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -678,7 +678,7 @@ export function RuntimeConfigDialog({ const update = await checkForAppUpdate({ force: true }); setAppUpdateStatus( update - ? `发现新版本 v${update.version},可在右上角下载` + ? `发现新版本 v${update.version},可在更新弹窗中下载` : '当前已是最新版本', ); } catch { diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 41a36fceb..7c5a85554 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -30,41 +30,50 @@ body { .app-update-notice { position: fixed; z-index: 40; - top: calc(var(--window-chrome-height) + 12px); - right: 16px; + top: 50%; + left: 50%; display: flex; - align-items: center; - gap: 16px; - max-width: min(520px, calc(100vw - 32px)); - padding: 12px 14px; + width: min(360px, calc(100vw - 32px)); + flex-direction: column; + align-items: stretch; + gap: 14px; + padding: 24px 22px 20px; border: 1px solid #efc9ae; border-radius: 12px; background: #fffaf5; box-shadow: 0 8px 24px rgb(100 49 26 / 16%); + text-align: center; + transform: translate(-50%, -50%); } .app-update-notice > div { display: grid; - gap: 3px; + justify-items: center; + gap: 5px; min-width: 0; } .app-update-notice strong { color: #4a220f; - font-size: 13px; + font-size: 15px; } .app-update-notice span, .app-update-notice p { margin: 0; color: #8d6a58; - font-size: 11px; + font-size: 12px; } .app-update-notice p { - max-width: 320px; - max-height: 120px; + width: 100%; + max-height: 180px; overflow-y: auto; overflow-wrap: anywhere; + text-align: left; white-space: pre-wrap; } +.app-update-notice .app-update-download { + align-self: stretch; + padding: 9px 14px; +} .app-update-notice button { flex: 0 0 auto; padding: 7px 12px; @@ -81,6 +90,9 @@ body { opacity: 0.65; } .app-update-notice .app-update-close { + position: absolute; + top: 8px; + right: 8px; display: inline-flex; align-items: center; justify-content: center; @@ -9588,6 +9600,88 @@ iframe.preview-frame { gap: 4px; } +.game-publish-progress-overlay { + position: fixed; + z-index: 500; + inset: 0; + background: rgb(35 24 19 / 62%); + backdrop-filter: blur(3px); + pointer-events: auto; +} + +.game-publish-progress-dialog { + display: grid; + width: min(440px, calc(100vw - 40px)); + outline: none; + justify-items: center; + gap: 12px; + padding: 28px; + border: 1px solid #e4c8ba; + border-radius: 18px; + box-shadow: 0 24px 64px rgb(62 37 27 / 28%); + text-align: center; +} + +.game-publish-progress-dialog h2, +.game-publish-progress-dialog p, +.game-publish-progress-dialog small { + margin: 0; +} + +.game-publish-progress-dialog h2 { + font-size: 18px; +} + +.game-publish-progress-dialog p, +.game-publish-progress-dialog small { + color: #8f7367; + font-size: 12px; +} + +.game-publish-progress-dialog p[role='alert'] { + max-height: 180px; + overflow: auto; + color: #a33f2a; + text-align: left; + white-space: pre-wrap; +} + +.game-publish-progress-icon { + display: grid; + width: 52px; + height: 52px; + border-radius: 16px; + background: #f6dfd0; + color: #c7653d; + place-items: center; +} + +.game-publish-progress-icon.is-failed { + background: #f8d8d0; + color: #b43c25; +} + +.game-publish-progress-icon .is-spinning { + animation: app-update-spin 0.9s linear infinite; +} + +.game-publish-progress-dialog button { + min-width: 96px; + padding: 8px 16px; + border: 0; + border-radius: 8px; + outline: none; + background: #c7653d; + box-shadow: 0 0 0 0 rgb(199 101 61 / 0%); + color: #fff; + cursor: pointer; + font-weight: 700; +} + +.game-publish-progress-dialog button:focus-visible { + box-shadow: 0 0 0 3px rgb(199 101 61 / 28%); +} + .game-approval-backdrop { position: fixed; top: var(--window-chrome-height); diff --git a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx index baa020d5b..a05b8ce65 100644 --- a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx +++ b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx @@ -2,9 +2,11 @@ /** * 客户端「发布」入口的可见反馈。 * - * DirectProject 项目不发工作台状态行,发布动作的提示必须回到项目对话; - * 权限要求确认时也要在聊天里给出确认/取消,否则表现就是「点了没反应」。 + * 发布动作使用独立的全屏进度弹窗,不回到旧的聊天确认卡片; + * 失败必须留在弹窗内可见,成功后自动切换到发布资料面板。 */ +import { readFileSync } from 'node:fs'; + import { beforeEach, describe, it, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; @@ -20,6 +22,12 @@ import { waitFor, within, } from './appSurface/harness'; +import { repoPath } from './repoPath'; +import { + declaration, + parseStyleSheet, + resolveDeclarations, +} from './styleCascade'; const readGamePublishAvailabilityMock = vi.hoisted(() => vi.fn(async () => true), @@ -39,18 +47,41 @@ vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => { const PROJECT_PATH = '/tmp/game-publish-feedback-project'; const PROJECT_ID = 'game-publish-feedback-project'; +function withPrototypeStatus( + manifest: GameCreationAppManifest, + status: 'pending' | 'completed', +): GameCreationAppManifest { + return { + ...manifest, + tasks: manifest.tasks.map((task) => + task.id === 'code-prototype' ? { ...task, status } : task, + ), + }; +} + function createFixtureManifest(): GameCreationAppManifest { - return createGameCreationAppManifest(PROJECT_ID, '发布反馈项目'); + return withPrototypeStatus( + createGameCreationAppManifest(PROJECT_ID, '发布反馈项目'), + 'completed', + ); +} + +function createPendingPrototypeManifest(): GameCreationAppManifest { + return withPrototypeStatus( + createGameCreationAppManifest(PROJECT_ID, '原型未完成项目'), + 'pending', + ); } function installTauri( options: { exportPackage?: () => unknown; + manifest?: GameCreationAppManifest; policy?: ReturnType; readPolicy?: () => unknown; } = {}, ) { - const manifest = createFixtureManifest(); + const manifest = options.manifest ?? createFixtureManifest(); const chatHarness = createProjectChatRuntimeHarness({ projectPath: PROJECT_PATH, }); @@ -82,12 +113,11 @@ function installTauri( return { invoke }; } -function renderPublishProject() { +function renderPublishProject( + manifest: GameCreationAppManifest = createFixtureManifest(), +) { return render( - , + , ); } @@ -125,76 +155,115 @@ function createConfirmPolicy() { }; } +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + describe('客户端发布入口的可见反馈', () => { - it('导出成功时先回即时反馈,再回结果并打开发布面板', async () => { - installTauri({ exportPackage: createExportPackageResult }); - renderPublishProject(); + it('首个可运行原型未完成时直接阻止发布,不触发用户项目构建', async () => { + const exportPackage = vi.fn(createExportPackageResult); + const manifest = createPendingPrototypeManifest(); + installTauri({ exportPackage, manifest }); + + renderPublishProject(manifest); const surface = await clickPublish(); await waitFor(() => { - expect(surface.textContent ?? '').toContain('正在检查发布权限…'); expect(surface.textContent ?? '').toContain( - '正在构建并打包试玩包,请稍候…', - ); - expect(surface.textContent ?? '').toContain( - '已构建并打包试玩包:exports/game.zip', + '首个可运行原型尚未完成,暂不能发布', ); }); + expect(surface.textContent ?? '').not.toContain('正在检查发布权限…'); + expect(exportPackage).not.toHaveBeenCalled(); + }); + + it('发布时显示全屏进度遮罩,成功后收起进度并打开发布面板', async () => { + const deferred = + createDeferred>(); + const exportPackage = vi.fn(() => deferred.promise); + installTauri({ exportPackage }); + renderPublishProject(); + + fireEvent.click( + await screen.findByRole('button', { name: '发布到游戏广场' }), + ); + + const progressDialog = await screen.findByRole('dialog', { + name: '发布进度', + }); + expect(progressDialog.textContent ?? '').toContain( + '正在构建并打包试玩包,请稍候…', + ); + expect(progressDialog.textContent ?? '').toContain( + '发布完成前请保持客户端开启,页面暂时不可操作。', + ); + expect( + progressDialog.closest('.game-publish-progress-overlay'), + ).not.toBeNull(); + expect(exportPackage).toHaveBeenCalledTimes(1); + expect(screen.queryByText('导出试玩包需要确认,确认后继续。')).toBeNull(); + + deferred.resolve(createExportPackageResult()); + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: '发布进度' })).toBeNull(); + }); expect( await screen.findByRole('dialog', { name: '发布到游戏广场' }), ).not.toBeNull(); }); - it('导出失败时把可读错误写回项目对话', async () => { + it('发布失败时在进度弹窗里显示错误并允许关闭', async () => { installTauri({ exportPackage: () => { - throw new Error('导出试玩包前需要先生成 exports/README.md'); + throw new Error('构建可玩版本失败:缺少入口'); }, }); renderPublishProject(); - const surface = await clickPublish(); + fireEvent.click( + await screen.findByRole('button', { name: '发布到游戏广场' }), + ); + + const failureDialog = await screen.findByRole('dialog', { + name: '发布失败', + }); + expect(failureDialog.textContent ?? '').toContain( + '构建可玩版本失败:缺少入口', + ); + fireEvent.click( + within(failureDialog).getByRole('button', { name: '关闭' }), + ); await waitFor(() => { - expect(surface.textContent ?? '').toContain( - '导出试玩包前需要先生成 exports/README.md', - ); + expect(screen.queryByRole('dialog', { name: '发布失败' })).toBeNull(); }); }); - it('策略要求确认时在 DirectProject 里展示确认卡片,确认后继续导出', async () => { - const exportPackage = vi.fn(createExportPackageResult); - installTauri({ exportPackage, policy: createConfirmPolicy() }); - renderPublishProject(); - - const publish = await screen.findByRole('button', { - name: '发布到游戏广场', - }); - fireEvent.click(publish); - - const commandLabel = await screen.findByText('project.export_package'); - const card = commandLabel.closest('.pending-command'); - expect(card).not.toBeNull(); - expect(exportPackage).not.toHaveBeenCalled(); - expect( - within(card as HTMLElement).getByText( - '导出试玩包并打开「发布到游戏广场」面板。', + it('发布进度遮罩固定覆盖整个工作区并压暗背景', () => { + const rules = parseStyleSheet( + readFileSync( + repoPath('apps/ai-game-creator-shell/src/styles.css'), + 'utf8', ), - ).not.toBeNull(); - - fireEvent.click( - within(card as HTMLElement).getByRole('button', { name: '确认' }), ); - await waitFor(() => expect(exportPackage).toHaveBeenCalledTimes(1)); - const surface = await screen.findByLabelText('陶泥儿项目对话'); - expect(surface.textContent ?? '').toContain( - '正在构建并打包试玩包,请稍候…', - ); - expect(surface.textContent ?? '').toContain( - '已构建并打包试玩包:exports/game.zip', + const overlay = resolveDeclarations( + rules, + ['.game-publish-progress-overlay'], + 1440, ); + expect(declaration(overlay, 'position')).toBe('fixed'); + expect(declaration(overlay, 'inset')).toBe('0'); + expect(declaration(overlay, 'z-index')).toBe('500'); + expect(declaration(overlay, 'pointer-events')).toBe('auto'); + expect(declaration(overlay, 'background')).toBe('rgb(35 24 19 / 62%)'); }); - it('取消权限确认时把取消结果写回项目对话', async () => { + it('策略要求确认时不再回到聊天确认卡片,直接进入进度弹窗', async () => { const exportPackage = vi.fn(createExportPackageResult); installTauri({ exportPackage, policy: createConfirmPolicy() }); renderPublishProject(); @@ -202,71 +271,9 @@ describe('客户端发布入口的可见反馈', () => { fireEvent.click( await screen.findByRole('button', { name: '发布到游戏广场' }), ); - const commandLabel = await screen.findByText('project.export_package'); - const card = commandLabel.closest('.pending-command'); - expect(card).not.toBeNull(); - fireEvent.click( - within(card as HTMLElement).getByRole('button', { name: '取消' }), - ); - const surface = await screen.findByLabelText('陶泥儿项目对话'); - await waitFor(() => { - expect(surface.textContent ?? '').toContain('已取消导出本地试玩包'); - }); - expect(exportPackage).not.toHaveBeenCalled(); - }); - - it('确认时策略已改为拒绝,也要把拒绝原因写回项目对话', async () => { - const exportPackage = vi.fn(createExportPackageResult); - let denyOnNextPolicyRead = false; - installTauri({ - exportPackage, - readPolicy: () => { - if (!denyOnNextPolicyRead) return createConfirmPolicy(); - return { - path: '.agent/policy.json', - policy: { - deniedCommands: ['project.export_package'], - confirmCommands: [], - }, - }; - }, - }); - renderPublishProject(); - - fireEvent.click( - await screen.findByRole('button', { name: '发布到游戏广场' }), - ); - const commandLabel = await screen.findByText('project.export_package'); - const card = commandLabel.closest('.pending-command'); - expect(card).not.toBeNull(); - denyOnNextPolicyRead = true; - fireEvent.click( - within(card as HTMLElement).getByRole('button', { name: '确认' }), - ); - - const surface = await screen.findByLabelText('陶泥儿项目对话'); - await waitFor(() => { - expect(surface.textContent ?? '').toContain( - '项目权限策略拒绝执行:project.export_package', - ); - }); - expect(exportPackage).not.toHaveBeenCalled(); - }); - - it('权限查询失败时也在项目对话里回显错误', async () => { - installTauri({ - readPolicy: () => { - throw new Error('策略文件损坏'); - }, - }); - renderPublishProject(); - - const surface = await clickPublish(); - await waitFor(() => { - expect(surface.textContent ?? '').toContain( - '发布前权限检查失败:策略文件损坏', - ); - }); + await waitFor(() => expect(exportPackage).toHaveBeenCalledTimes(1)); + expect(screen.queryByText('project.export_package')).toBeNull(); + expect(screen.queryByText('导出试玩包需要确认,确认后继续。')).toBeNull(); }); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e35ac4bff..f1b42991f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,12 @@ # 决策记录 +## 2026-09-23 AGC 发布前先守可运行原型门禁 + +- 背景:客户端已经显示“首个可运行原型尚未完成,运行视图暂不可用”,但发布入口仍会先执行用户项目的 `build`,导致未完成原型也进入构建并在后续失败。 +- 决策:`project.export_package` 的发布专用导出链路先检查可玩入口;没有入口时,只有 `code-prototype` 已完成或存在运行中的预览才允许执行 `build`,否则直接返回“首个可运行原型尚未完成,暂不能发布”。发布不再进入聊天确认卡,改为独立全屏进度弹窗;运行中遮罩覆盖整个工作区并阻止交互,失败留在弹窗内,成功后切换到发布资料面板。 +- 边界:已有可运行入口仍直接打包;原型已完成但缺构建产物时保留原有自动构建;缺失 `exports/README.md` 仍在导出前自动生成。 +- 验证:Rust `publish_export` 4/4、前端发布相关测试 16/16、AGC `tsc`、编码检查和 `git diff --check` 通过。 + ## 2026-09-22 Direct 埋点与业务持久化锁隔离 - Direct 采集身份和最新成果编号改由独立纯内存状态保存,初始化时从最终执行账本冻结项目与原 run 身份;成果采集、预览采集上下文和终态成果读取不再争用业务落盘锁。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 7e5f202f8..66fe3e5cb 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -138,8 +138,8 @@ - 渠道由 `AGC_UPDATE_CHANNEL` 显式指定,默认 dev;Windows 与 macOS 目标均支持 dev、release 和自定义渠道,目标校验独立进行。 - 渠道 `--config` 在 Tauri 构建前最后合并,同时注入 `productName`、`identifier` 与 updater 端点:安装身份与更新端点必须来自同一个渠道,不能各自回读默认值。macOS 发布入口构建 `*.app`、updater 归档与 DMG 前先按发布渠道解析产品名,产物名一律派生而不写死。 - 定时调度分别判断服务端与客户端 scope:dev 小时调度在提交含 AGC 相关路径时发布对应渠道,纯文档或流水线自身的提交仍只跑 Full Build;release 每日调度在服务端相关路径变化时发布正式 Full Build,在 AGC 相关路径变化时发布 release 客户端,并在同一调度内等待、汇总各 lane 结果,失败 lane 下一轮补发。判定失败或勾选强制触发时按"需要发布"处理。 -- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题`,标题只取 commit message 第一行并忽略后续说明行;最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。 -- 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。 +- 更新摘要不再自动生成:发布脚本不读取提交记录生成 `notes`;只有 `AGC_UPDATE_RELEASE_NOTES` 非空时,才把显式手动文案写入渠道清单和旧协议清单的 `releaseNotes`。未设置时清单不携带更新说明,归档文件 `release-notes.txt` 记录“本次没有可用的更新摘要”。 +- 清单里的 `commit` 是非标准字段:更新插件忽略未知字段;发布脚本只为线上排障保留源码 revision,不驱动更新摘要。 - 上传:安装包与 `.sig` 上传到 `agc/-win|mac//`,清单以 `--force` 覆盖上传到对应分区的 `latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。 - 首装发布:发布脚本生成 `downloads`,Windows 复用已选 NSIS `.exe`,Mac 选择本次版本和目标架构匹配的非空 `.dmg`;缺失、歧义或版本/架构不匹配时失败,不发布带悬空地址的清单。上传顺序为更新包、签名及首装包全部成功后再更新渠道清单,Windows 相同对象只上传一次。`dry-run` 不写 OSS。各渠道独立写自己的清单,由 BFF 汇总,Windows 与 Mac 发布不会覆盖彼此的下载项;Mac 跨架构合并仍遵循现有单架构发布约束。 - Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。 @@ -177,7 +177,7 @@ | 安装包与清单登记一致 | 下载安装包实算 SHA-256 与尺寸后与迁移桥清单比对 | 通过(size `104678031`、sha256 `1f67…4fd0` 一致) | | 旧协议迁移桥 | 公网读取 `agc/latest.json` | 通过(0.1.48,`downloadUrl` 指向同一对象,含 `sha256` / `size`) | | 真实更新闭环(含升级后重启) | 0.1.47 客户端按提示下载安装并重启 | 通过(2026-09-17 用户实测:提示 → 下载 → 安装 → 关于页显示新版本,再次检查为已是最新) | -| 更新摘要端到端展示 | 公网读取渠道清单 `notes` 与客户端更新提示 | 通过(2026-09-17 用户实测:0.1.62 清单带 8 条自动摘要,客户端提示正常显示多行内容) | +| 更新摘要按显式配置写入 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过(未设置 `AGC_UPDATE_RELEASE_NOTES` 时渠道清单不写入 `notes`) | 渠道安装身份隔离已于 `2026-09-21` 完成源码验收: diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a311db87c..10b7897e9 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1334,7 +1334,7 @@ game-project/ - 聊天输入 `/commands` 会只读列出 Tauri runtime 暴露的受限命令白名单,读取失败或非 Tauri 环境下回退到共享契约默认列表;该命令不执行白名单命令,也不要求先初始化项目。 - 聊天输入 `/smoke` 会生成待确认的 `command.run_limited` 内置命令,当前只映射到白名单 `game.static_smoke`,不开放任意命令解析。 - 聊天输入 `/run` 会生成待确认的 `game.run_local` 内置命令,确认后复用白名单 `game.static_smoke` 运行当前 `game/index.html`,通过后启动只读本地 HTTP 预览并切换到客户端内运行视图;该命令不开放任意 shell。 -- 聊天输入 `/export` 会生成待确认的 `project.export_package` 内置命令,确认后只把 `game/**`、`assets/**` 和 `exports/README.md` 打包到 `exports/playtest-package-*.zip`;导出前重新校验 `game/index.html` 是可试玩自包含 HTML,拒绝符号链接和越界路径,不把 `.agent/`、`memory/`、日志、trace、运行时配置或密钥文件写入 ZIP。 +- 聊天输入 `/export` 会生成待确认的 `project.export_package` 内置命令,确认后只把 `game/**`、`assets/**` 和 `exports/README.md` 打包到 `exports/playtest-package-*.zip`;缺少 `exports/README.md` 时先按项目 manifest 生成最小试玩说明,已有文件原样保留。发布前若 `code-prototype` 未完成且没有运行中的预览,直接阻止发布,不触发用户项目构建;可运行原型完成后才允许按 `build` 脚本补齐产物。发布进度使用独立模态弹窗展示,遮罩覆盖整个工作区并阻止交互,不再使用聊天确认卡;导出前重新校验可玩入口,拒绝符号链接和越界路径,不把 `.agent/`、`memory/`、日志、trace、运行时配置或密钥文件写入 ZIP。 - 聊天输入 `/exports` 会只读执行 `project.export_list`,列出当前项目 `exports/playtest-package-*.zip` 历史试玩包,并提供显示目录或继续 `/export` 的草稿;该命令不删除文件、不分享文件、不新增面板。 - 聊天输入 `/preview` 会生成待确认的 `preview.start` 内置命令,确认后启动只读本地 HTTP 预览并切换到客户端内运行视图;`/open-preview` 在本地项目已初始化后生成待确认的 `preview.open`,只激活当前授权项目对应的 `127.0.0.1` 运行容器;`/preview-status` 只查询当前授权项目的本地 HTTP 预览并写入 `preview.status` 命令日志;`/preview-stop` 只停止当前项目预览,不展示或停止其它项目遗留的全局预览。 - 聊天输入 `/memory [short|long|blackboard]` 读取短期、长期或黑板记忆;`/remember [short|long|blackboard] 内容` 生成待确认的 `memory.write` 并追加短期、长期或黑板记忆,未写 scope 时默认追加长期记忆;主窗口“记到黑板”“覆盖黑板”“清空黑板”只填入 `/remember blackboard `、`/memory-set blackboard ` 或 `/forget-memory blackboard` 草稿,仍由用户补内容并走聊天确认;`/memory-set [short|long|blackboard] 内容` 生成待确认的 `memory.write` 并覆盖保存对应记忆;`/forget-memory [short|long|blackboard]` 生成待确认的 `memory.delete`。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index d9e05e6f5..e622eed9f 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -143,7 +143,7 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去 `Genarrative-Scheduled-Revision-Trigger` 是 dev 渠道的小时调度入口,每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名散列,不等同于整点)。`Genarrative-Scheduled-Release-Trigger` 是 release 渠道服务端与客户端的每日调度入口,固定在每天 04:00(`0 4 * * *`)检查同一个源码分支。两条调度器都只用 `git ls-remote` 解析 `SOURCE_BRANCH`(默认 `master`)的远端 HEAD,不 checkout 工作区;解析出的完整 commit 与对应 lane 上一次成功的 revision 相同则标记 `NOT_BUILT` 并结束,不触发任何下游。 -dev 调度器发现 revision 变化时,把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy`、`Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-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` 与 `Genarrative-Agc-MacOS-Build`(两个平台分区发布同一个发号 Job 下发的总版本号),纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。每日 release 调度器按服务端/客户端两个独立 scope 决定下游,固定使用 `DEPLOY_TARGET=release` 和 `CONFIRM_RELEASE_DEPLOY_AGENT=true`:服务端相关路径变化时触发 Full Build,`DATABASE_BACKUP_MODE` 默认 `async`,Stdb/API rollout 模式与审批人由调度参数显式透传;客户端相关路径变化时先经发号 Job 获取统一总号,再以同一个固定 commit 触发 AGC Windows 与 macOS 的 `release` 分区构建,macOS 仍带 `SKIP_IF_SUPERSEDED=true`。三类下游均 `wait: true` 并汇总结果,只有成功 lane 才推进 `.jenkins-last-release-full-revision` 或 `.jenkins-last-release-agc-revision`,失败 lane 下一轮单独补发,不重复已成功的服务端发布。dev 调度器的下游各自判定: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 状态、不触发任何发布。AGC 客户端版本号不再由渠道各自递增:唯一发号源是 OSS 对象 `agc/global-version.json`,发号收口到 `Genarrative-Agc-Global-Version-Issue`(`disableConcurrentBuilds()` + 写后回读校验;集群未装 `lockable-resources` 插件)。dev / release 两条调度管线与手动发布管线都先调用该 Job 发号,再用归档产物 `agc-global-version.txt` 读取总号并作为 `AGC_RELEASE_VERSION` 透传给客户端构建;统一构建只发一次号供各渠道共用,单渠道热修只把号传给该渠道。显式传入的号低于本渠道当前清单版本时构建失败关闭;`AGC_RELEASE_DRY_RUN` 只预览下一位,不写回、不烧号。一次性播种用 `SEED_ONLY`:基线取「仓库版本 / 各渠道清单 / 旧迁移指针」的最大值,播种本身不烧号。仓库里的 5 个版本文件仍由构建改写,只作构建输入参考,不是事实源。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `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;release 调度显式覆盖为 `release` 目标并透传备份、rollout、Pingora 和维护退出参数。三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build` 结束后都会触发 `Genarrative-Notify-Email`;只有正式 OSS 发布成功时,通知正文才附加本次生成的 `latest.json` 首装包 URL,演练、失败或跳过构建时留空,避免把未写入或不完整的对象地址发给收件人。 +dev 调度器发现 revision 变化时,把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy`、`Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-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` 与 `Genarrative-Agc-MacOS-Build`(两个平台分区发布同一个发号 Job 下发的总版本号),纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。每日 release 调度器按服务端/客户端两个独立 scope 决定下游,固定使用 `DEPLOY_TARGET=release` 和 `CONFIRM_RELEASE_DEPLOY_AGENT=true`:服务端相关路径变化时触发 Full Build,`DATABASE_BACKUP_MODE` 默认 `async`,Stdb/API rollout 模式与审批人由调度参数显式透传;客户端相关路径变化时先经发号 Job 获取统一总号,再以同一个固定 commit 触发 AGC Windows 与 macOS 的 `release` 分区构建,macOS 仍带 `SKIP_IF_SUPERSEDED=true`。三类下游均 `wait: true` 并汇总结果,只有成功 lane 才推进 `.jenkins-last-release-full-revision` 或 `.jenkins-last-release-agc-revision`,失败 lane 下一轮单独补发,不重复已成功的服务端发布。dev 调度器的下游各自判定: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 状态、不触发任何发布。AGC 客户端版本号不再由渠道各自递增:唯一发号源是 OSS 对象 `agc/global-version.json`,发号收口到 `Genarrative-Agc-Global-Version-Issue`(`disableConcurrentBuilds()` + 写后回读校验;集群未装 `lockable-resources` 插件)。dev / release 两条调度管线与手动发布管线都先调用该 Job 发号,再用归档产物 `agc-global-version.txt` 读取总号并作为 `AGC_RELEASE_VERSION` 透传给客户端构建;统一构建只发一次号供各渠道共用,单渠道热修只把号传给该渠道。显式传入的号低于本渠道当前清单版本时构建失败关闭;`AGC_RELEASE_DRY_RUN` 只预览下一位,不写回、不烧号。一次性播种用 `SEED_ONLY`:基线取「仓库版本 / 各渠道清单 / 旧迁移指针」的最大值,播种本身不烧号。仓库里的 5 个版本文件仍由构建改写,只作构建输入参考,不是事实源。客户端渠道清单不再自动生成更新摘要:发布脚本不会读取提交记录生成 `notes`;只有 `AGC_UPDATE_RELEASE_NOTES` 非空时,才把显式手动文案写入 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);未设置时两个清单都不携带更新说明,归档文件记录“本次没有可用的更新摘要”。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;release 调度显式覆盖为 `release` 目标并透传备份、rollout、Pingora 和维护退出参数。三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build` 结束后都会触发 `Genarrative-Notify-Email`;只有正式 OSS 发布成功时,通知正文才附加本次生成的 `latest.json` 首装包 URL,演练、失败或跳过构建时留空,避免把未写入或不完整的对象地址发给收件人。 手工发布入口 `Genarrative-Manual-Build-And-Deploy` 的 `DEPLOY_TARGET` 与 AGC 更新渠道是两个独立维度。手工入口必须把 `release` 映射为 `AGC_UPDATE_CHANNEL=release`、把 `development` 映射为 `dev`,并把该参数同时透传给 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`;只传 `AGC_RELEASE_VERSION` 时下游会落回各自默认 `dev`。统一号 `0.1.95` 的 release 包应分别位于 `agc/release-win/0.1.95/` 与 `agc/release-mac/0.1.95/`,渠道清单为对应目录下的 `latest.json`,不存在 `agc/release/` 这一层。补发本轮已烧号的版本时,直接以相同 `AGC_RELEASE_VERSION` 重跑两条 AGC Job,不重新发号。 @@ -810,7 +810,7 @@ Mac 单架构(arm64)构建脚本位于 `jenkins/Jenkinsfile.ai-game-creator- Job 名为 `Genarrative-Agc-MacOS-Build`,SCM 直接读取仓库内上述 Jenkinsfile,参数为 `SOURCE_BRANCH`、`COMMIT_HASH`、`AGC_UPDATE_CHANNEL`、`AGC_RELEASE_VERSION`、`AGC_RELEASE_DRY_RUN`、`AGC_UPDATE_RELEASE_NOTES`、`OSSUTIL_BIN`、`CARGO_BUILD_JOBS`、`NOTIFICATION_EMAILS`。渠道参数是基础名(不含系统,默认 `dev`),脚本不接受 `dev-mac` 这类系统后缀,写入分区固定推导为 `-mac`:这与 Windows Job 的 `-win` 对称,也延续已发布客户端的端点。 -该 Job 的职责是构建并发布 `-mac` 分区更新:执行 `npm ci` 后使用锁文件校验并补齐两种 macOS Codex 原生依赖,再调用 `scripts/build-macos-ci.mjs`(AGC 应用目录下)生成 arm64 单架构 app、arm64 隔离 smoke、arm64 DMG(`<产品名>_<版本>_aarch64.dmg`)与分区清单 `latest.json`,用产物内烘焙的公钥复核更新包签名(`verify-updater-signature.mjs`),最后按 `AGC_RELEASE_DRY_RUN` 决定是否上传 OSS。签到会同时取 `master`,让渠道清单里上一次发布的 commit 可解析——缺了它更新摘要会退化成「最近客户端改动」(该步失败只降级摘要,不阻断发布)。产物名(`*.app`、updater 归档、DMG、卷名)一律从渠道安装身份派生的产品名推导(默认渠道即基线 `productName`,`release` 等渠道带渠道后缀),校验脚本从包内 `Info.plist` 读取可执行名,改产品名或换渠道都不会让入口静默找错对象;隔离 smoke 用 `ditto --clone` 复制副本(实测整轮 6.8 秒,此前整包复制约 1 分钟),并在构建前删除本次将写出的 DMG/更新包/签名,保证归档产物一定来自本次构建。归档限 `artifacts/` 下的 DMG、SHA-256、`latest.json`、更新包签名、更新摘要、非敏感构建清单和源码 commit;不归档用户 HOME、Jenkins secret、原始工作目录或全量日志。 +该 Job 的职责是构建并发布 `-mac` 分区更新:执行 `npm ci` 后使用锁文件校验并补齐两种 macOS Codex 原生依赖,再调用 `scripts/build-macos-ci.mjs`(AGC 应用目录下)生成 arm64 单架构 app、arm64 隔离 smoke、arm64 DMG(`<产品名>_<版本>_aarch64.dmg`)与分区清单 `latest.json`,用产物内烘焙的公钥复核更新包签名(`verify-updater-signature.mjs`),最后按 `AGC_RELEASE_DRY_RUN` 决定是否上传 OSS。产物名(`*.app`、updater 归档、DMG、卷名)一律从渠道安装身份派生的产品名推导(默认渠道即基线 `productName`,`release` 等渠道带渠道后缀),校验脚本从包内 `Info.plist` 读取可执行名,改产品名或换渠道都不会让入口静默找错对象;隔离 smoke 用 `ditto --clone` 复制副本(实测整轮 6.8 秒,此前整包复制约 1 分钟),并在构建前删除本次将写出的 DMG/更新包/签名,保证归档产物一定来自本次构建。归档限 `artifacts/` 下的 DMG、SHA-256、`latest.json`、更新包签名、更新摘要、非敏感构建清单和源码 commit;不归档用户 HOME、Jenkins secret、原始工作目录或全量日志。 发布凭据全部走 Jenkins 全局凭据,并在 `withCredentials` 内注入当前进程:`AgcUpdaterSigningKey`(与 `AgcUpdaterSigningKeyPassword`)映射为 `TAURI_SIGNING_PRIVATE_KEY` / `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,`AliyunAccessKeyId` / `AliyunaccessKeySecret` 映射为 `AGC_OSS_ACCESS_KEY_ID` / `AGC_OSS_ACCESS_KEY_SECRET`;私钥与凭据不写入 workspace、日志或归档产物。上传顺序为更新包、签名、首装包,三者全部成功后才覆盖 `agc/-mac/latest.json` 指针;`AGC_RELEASE_DRY_RUN` 与 Windows 渠道对称:默认关闭即真发布,勾选后才退化为演练(只打印将上传的对象、不写任何 OSS 对象)。本 Job 是正式发布入口,调度器在发号后与 Windows 一起触发它,并额外传 `SKIP_IF_SUPERSEDED=true`——Mac 节点是日常办公机,离线期间排队的旧构建在节点回来后若已被源码分支推进,直接跳过而不发布过期版本。Mac 节点需要 `ossutil`(实测 1.7.19 原生 arm64 可用,装在 `~/.local/bin`,已在 Job 的 PATH 内),可用 `OSSUTIL_BIN` 指定命令名或绝对路径。首次发布建议显式指定 `AGC_RELEASE_VERSION`,避免按渠道高水位递增时出现版本链回退。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index 11ce61f3b..5541fc1e1 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: '三段版本号;统一构建由 Genarrative-Agc-Global-Version-Issue 发号后透传;留空时本地兜底发号(读 OSS 总号 +1 并写回)') string(name: 'AGC_UPDATE_CHANNEL', defaultValue: 'dev', description: 'AGC 发布渠道:dev、release 或自定义小写名称;此 Job 构建 Windows,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 的绝对路径/命令名') string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加邮件通知收件人;会与持久收件人凭据合并发送') } @@ -170,25 +170,6 @@ pipeline { stage('Build and upload') { steps { - script { - // 摘要锚点兜底:清单里还没有 commit 字段时(首次启用摘要 / 换渠道), - // 用上一次成功构建的 COMMIT_HASH 作为「上次发布提交」。读取失败保持为空, - // 发布脚本会退回清单锚点或干脆不写摘要。 - def anchor = '' - try { - def previousBuild = currentBuild.previousSuccessfulBuild - def previousChannel = (previousBuild?.buildVariables?.AGC_UPDATE_CHANNEL ?: '').toString().trim() - if (previousChannel == params.AGC_UPDATE_CHANNEL.trim()) { - 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'), @@ -202,7 +183,6 @@ pipeline { "AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}", 'AGC_BUILD_TARGET=x86_64-pc-windows-msvc', "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 ''' diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build index 7048f0241..496332140 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build @@ -16,7 +16,7 @@ pipeline { string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选三段版本号;留空则按 -mac 分区清单高水位递增 patch。首次发布建议显式指定,避免版本链回退') booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false, description: '默认直接发布到 -mac 分区;勾选后只构建、验签并打印将上传的对象,不写 OSS(演练)') booleanParam(name: 'SKIP_IF_SUPERSEDED', defaultValue: false, description: '置真时:本次 COMMIT_HASH 若已被源码分支推进,则直接跳过而不构建。调度器触发本 Job 时置真,避免节点离线期间排队的旧构建在恢复后发布过期版本') - string(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选单行更新摘要;留空则由发布脚本按提交自动汇总') + string(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选更新摘要;留空则本次发布不携带更新摘要') string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 命令名或绝对路径(Mac 节点默认装在 ~/.local/bin/ossutil)') string(name: 'CARGO_BUILD_JOBS', defaultValue: '8', description: '并行 rustc 任务数,默认吃满节点 8 核(4P+4E)。该值同时作为 rustc codegen 的 jobserver 令牌上限;节点只有 24 GB 内存且是日常办公机,若构建期间出现明显换页可临时调低。只影响本次构建') string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加邮件通知收件人;会与持久收件人凭据合并发送') @@ -69,10 +69,6 @@ pipeline { fi test "$(git remote get-url origin)" = "$GIT_REMOTE_URL" git fetch --no-tags origin "+refs/heads/$SOURCE_BRANCH:refs/remotes/origin/$SOURCE_BRANCH" - # 同时取 master:渠道清单里的上一次发布 commit 落在 master 上,缺了它更新摘要会退化成 - # 「最近客户端改动」。这一步只是摘要质量,失败不阻断发布。 - git fetch --no-tags origin "+refs/heads/master:refs/remotes/origin/master" || - echo '[agc-macos] 拉取 master 失败:本次更新摘要可能退化为最近提交列表。' ref="refs/remotes/origin/$SOURCE_BRANCH" if [ -n "$COMMIT_HASH" ]; then case "$COMMIT_HASH" in *[!0-9a-fA-F]* ) echo 'COMMIT_HASH 必须为十六进制'; exit 1;; esac