From 48eb3d5007810bcd3f1ec908dc11f0de22136096 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 22 Sep 2026 12:42:27 +0800 Subject: [PATCH 1/3] Opt/ci (#456) Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/456 Co-authored-by: Linghong Co-committed-by: Linghong --- .gitea/workflows/project-ci.yml | 13 +- .../scripts/run-rust-shell-test-shards.mjs | 4 + .../src-tauri/src/process_session/tests.rs | 29 +-- .../tests/owner_fixture_cleanup.rs | 127 ++++++++++ deploy/container/README.md | 21 ++ .../shared-memory/development-workflow.md | 6 +- docs/project-memory/shared-memory/pitfalls.md | 9 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 20 +- package.json | 1 + scripts/build-gitea-rust-cache.sh | 85 +++++++ scripts/check-gitea-ci-job-image.sh | 13 ++ scripts/ci-rust-cache.sh | 86 +++++++ scripts/ci-rust-cache.test.mjs | 217 ++++++++++++++++++ scripts/project-ci-workflow.test.ts | 71 +++++- vitest.frontend-ci.config.ts | 10 + 15 files changed, 691 insertions(+), 21 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/process_session/tests/owner_fixture_cleanup.rs create mode 100644 scripts/build-gitea-rust-cache.sh create mode 100644 scripts/ci-rust-cache.sh create mode 100644 scripts/ci-rust-cache.test.mjs create mode 100644 vitest.frontend-ci.config.ts diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 52394b9a1..78f48200b 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -82,12 +82,23 @@ jobs: sleep $((attempt * 2)) done + - name: Prepare isolated Rust compilation cache + shell: bash + run: | + set -euo pipefail + node --test scripts/ci-rust-cache.test.mjs + bash scripts/ci-rust-cache.sh prepare + - name: Run AI game creator shell Rust shard 1/4 run: npm run check:native-shells:agc-rust-shard-1 - name: Run AI game creator shell Rust shard 2/4 run: npm run check:native-shells:agc-rust-shard-2 + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + ai-game-creator-shell-rust-lane-2: name: AI game creator shell Rust lane 2/2 runs-on: genarrative-ci @@ -410,7 +421,7 @@ jobs: run: bash scripts/ci-npm-ci-with-retry.sh - name: Run frontend and script tests - run: npm run test + run: npm run test:ci:frontend - name: Run BgFilter worker smoke harness tests run: npm run bgfilter-worker:smoke-test diff --git a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs index 86c70601a..ecd2a50b3 100644 --- a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs +++ b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs @@ -150,6 +150,7 @@ function formatDuration(milliseconds) { // 编译一次,直接拿到测试可执行文件:后续每片都运行同一个二进制,不再各自调用 cargo, // 免得 N 个 cargo 去争 package cache 与 target 目录锁。 function resolveTestExecutable() { + const startedAt = Date.now(); return new Promise((resolve, reject) => { const cargoArguments = buildCargoArguments({ kind: options.targetKind, @@ -194,6 +195,9 @@ function resolveTestExecutable() { reject(new Error(`unable to start cargo: ${error.message}`)); }); child.on('close', (code) => { + console.log( + `[rust-shards] compile duration=${formatDuration(Date.now() - startedAt)} exit=${code}`, + ); if (code !== 0) { reject( new Error( diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs index 3a890ead3..d963ea811 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs @@ -2,6 +2,11 @@ use super::*; #[cfg(target_os = "linux")] use std::process::Stdio; +#[cfg(target_os = "linux")] +mod owner_fixture_cleanup; +#[cfg(target_os = "linux")] +use owner_fixture_cleanup::{project_processes, OwnerFixtureCleanup}; + static PROCESS_SESSION_TEST_LOCK: OnceLock> = OnceLock::new(); fn process_session_test_guard() -> std::sync::MutexGuard<'static, ()> { @@ -1743,20 +1748,6 @@ fn process_session_runner_owner_fixture() { #[cfg(target_os = "linux")] #[test] fn process_session_owner_sigkill_leaves_no_child_process() { - fn project_processes(root: &Path) -> Vec { - let canonical_root = fs::canonicalize(root).expect("canonical test project"); - fs::read_dir("/proc") - .into_iter() - .flatten() - .flatten() - .filter_map(|entry| { - let process_id = entry.file_name().to_string_lossy().parse::().ok()?; - let cwd = fs::read_link(entry.path().join("cwd")).ok()?; - (cwd == canonical_root).then_some(process_id) - }) - .collect() - } - let directory = tempfile::tempdir().expect("temp project"); let root = directory.path(); init_local_game_project_at(root, "owner-process-project", "Owner Process Project") @@ -1787,6 +1778,10 @@ setInterval(() => {}, 1000); .stderr(Stdio::null()) .spawn() .expect("spawn owner fixture test process"); + let cleanup = OwnerFixtureCleanup { + owner: &mut owner, + root, + }; let deadline = std::time::Instant::now() + Duration::from_secs(10); while (!root.join("owner-ready").is_file() || project_processes(root).is_empty()) && std::time::Instant::now() < deadline @@ -1799,9 +1794,9 @@ setInterval(() => {}, 1000); "sandbox child should be visible from host /proc" ); - let owner_pid = i32::try_from(owner.id()).expect("owner pid"); - assert_eq!(unsafe { libc::kill(owner_pid, libc::SIGKILL) }, 0); - owner.wait().expect("reap owner fixture"); + // Linux Child::kill 发送 SIGKILL;先检查真实子树回收,再由 guard 兜底。 + cleanup.owner.kill().expect("SIGKILL owner fixture"); + cleanup.owner.wait().expect("reap owner fixture"); let deadline = std::time::Instant::now() + Duration::from_secs(5); loop { let remaining = project_processes(root); diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests/owner_fixture_cleanup.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests/owner_fixture_cleanup.rs new file mode 100644 index 000000000..af07502cc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests/owner_fixture_cleanup.rs @@ -0,0 +1,127 @@ +use std::fs; +use std::path::Path; +use std::process::Child; +use std::thread; +use std::time::{Duration, Instant}; + +pub(super) fn project_processes(root: &Path) -> Vec { + let Ok(canonical_root) = fs::canonicalize(root) else { + return Vec::new(); + }; + fs::read_dir("/proc") + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| { + let process_id = entry.file_name().to_string_lossy().parse::().ok()?; + if process_id <= 1 || process_id == std::process::id() as i32 { + return None; + } + let cwd = fs::read_link(entry.path().join("cwd")).ok()?; + (cwd == canonical_root).then_some(process_id) + }) + .collect() +} + +pub(super) struct OwnerFixtureCleanup<'a> { + pub(super) owner: &'a mut Child, + pub(super) root: &'a Path, +} + +impl Drop for OwnerFixtureCleanup<'_> { + fn drop(&mut self) { + let _ = self.owner.kill(); + let _ = self.owner.wait(); + + // 正常路径先验证子进程自行退出;这里只兜底作用域退出(包括 panic)后的残留。 + // 项目目录由每条用例独占,不能按进程名清理其他用例或开发进程。 + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let remaining = project_processes(self.root); + if remaining.is_empty() { + return; + } + for process_id in &remaining { + unsafe { + libc::kill(*process_id, libc::SIGKILL); + } + } + if Instant::now() >= deadline { + // Drop 可能在 panic 展开期间执行,不能再次 panic。 + use std::io::Write; + let _ = writeln!( + std::io::stderr(), + "owner fixture cleanup timed out: pids={remaining:?}" + ); + return; + } + thread::sleep(Duration::from_millis(25)); + } + } +} + +#[test] +fn owner_fixture_cleanup_reaps_processes_on_panic_without_touching_other_projects() { + use std::panic::{catch_unwind, AssertUnwindSafe}; + use std::process::{Command, Stdio}; + + // 回归夹具自己的回收不能依赖被测 guard,否则 guard 回归时测试也会泄漏。 + struct Sleeper(Child); + impl Drop for Sleeper { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + fn sleeper(root: &Path) -> Sleeper { + Sleeper( + Command::new("sleep") + .arg("60") + .current_dir(root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn cleanup fixture"), + ) + } + + // 同时覆盖 owner 刚启动就失败,以及已有残留进程时失败。 + for has_residual in [false, true] { + let project = tempfile::tempdir().expect("cleanup project"); + let other_project = tempfile::tempdir().expect("unrelated project"); + let mut owner = sleeper(project.path()); + let cleanup = OwnerFixtureCleanup { + owner: &mut owner.0, + root: project.path(), + }; + // 故意不依赖 owner 退出监测,验证兜底能清理仍留在项目目录的进程。 + let mut residual = has_residual.then(|| sleeper(project.path())); + let mut other = sleeper(other_project.path()); + let result = catch_unwind(AssertUnwindSafe(move || { + let _cleanup = cleanup; + panic!("simulate an assertion failure before owner shutdown"); + })); + + let owner_status = owner.0.try_wait(); + let residual_status = residual.as_mut().map(|child| child.0.try_wait()); + let other_status = other.0.try_wait(); + // 即使 guard 回归,先收口本测试持有的进程再断言,避免回归用例自身泄漏。 + drop(owner); + drop(residual); + drop(other); + + assert!(result.is_err()); + assert!(matches!(owner_status, Ok(Some(_))), "owner must exit"); + if has_residual { + assert!( + matches!(residual_status, Some(Ok(Some(_)))), + "residual process must exit" + ); + } + assert!( + matches!(other_status, Ok(None)), + "other project must survive" + ); + } +} diff --git a/deploy/container/README.md b/deploy/container/README.md index ee883f084..62d3c5904 100644 --- a/deploy/container/README.md +++ b/deploy/container/README.md @@ -95,6 +95,27 @@ runner 配置保留原 `ubuntu-latest` 映射,`genarrative-ci` 继续映射到 回滚时先把 workflow 的 `runs-on` 改回 `ubuntu-latest`,再恢复备份的 runner config 并用同一超时重启 runner。不要在真实 CI 验证前删除旧映射或旧镜像。 +### 可选的 AGC Rust 编译对象快照 + +`scripts/build-gitea-rust-cache.sh` 在已验证的 job 镜像上生成候选镜像,供 AGC Rust lane 1 试验;lane 2 保持直接 rustc。固定 sccache `0.18.0` 的 Linux x64 musl 归档并校验 SHA-256,维护者从 origin/master 的确定提交预热编译对象,PR job 没有生成/发布公共快照的权限。 + +基础镜像必须不含 `/opt/genarrative-ci/rust-cache`;脚本在拉取源码、下载工具和预热前执行只读、断网检查,发现已有对象快照就拒绝构建。不能在旧缓存镜像上删除目录再叠加新快照,删除操作不会释放旧镜像层。切换且真实 CI 验证通过后,人工定向清理更旧的缓存镜像与导出归档,保留当前版、一个回滚版及运行中 CI 使用的版本;2 GiB 对象缓存上限不覆盖这些宿主文件,不使用全局 prune。 + +```bash +bash scripts/build-gitea-rust-cache.sh genarrative/gitea-project-ci:20260920.2 genarrative/gitea-project-ci:rust-cache-candidate +bash scripts/gitea-ci-job-image.sh verify genarrative/gitea-project-ci:rust-cache-candidate +bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/ci-rust-cache.tar.zst genarrative/gitea-project-ci:rust-cache-candidate +bash scripts/gitea-ci-job-image.sh load-runner genarrative/gitea-project-ci:rust-cache-candidate +``` + +预热容器上限为 4 核、12 GiB,移除 capabilities,不挂宿主目录/socket,也不注入 Git/OSS/Jenkins 凭据。源码通过 `git archive` 复制,当前工作区、ignored 文件和 `.git` 不进入容器。最终从原镜像重新组装,仅复制 `/opt/genarrative-ci/rust-cache` 的 sccache、对象和来源元数据,不提交含源码/target 的预热容器;镜像本身的下载缓存与工具链校验保持原样。 + +快照由固定 Image ID 分发,每个 job 仅修改容器自己的写时复制层,缓存上限 2 GiB,结束后不回传。`ci-rust-cache.sh prepare` 清空继承的 `SCCACHE_*` 远程配置,使用独立配置和 Unix socket;旧镜像、工具链不匹配或限时 wrapper 探测失败时使用直接 rustc,正式编译启用 sccache 的 server IO 错误回退。真实编译/测试失败保留非零退出码。`report` 输出命中统计并停止本 job daemon,分片日志输出独立编译耗时。 + +生成候选不会改变 runner 配置。线上有活跃 CI 时禁止停止 job、重启 runner 或切换标签;只在确认空闲后按上节流程切换固定 Image ID。先用相同源码、独立干净 target 比较无缓存、冷缓存、热缓存,真实 CI 验收通过后再考虑推广。回滚缓存试验可以移除 lane 1 的 prepare/report,或恢复原镜像 ID,均不需要改变 incremental 或测试分片。 + +预热路径固定为实际 Gitea checkout 的 `/workspace/GenarrativeAI/Genarrative`,Cargo 从 AGC `src-tauri` 目录启动;`workspace.txt` 不匹配时直接编译。Rust cache key 对 cwd 敏感,不能假定 `SCCACHE_BASEDIRS` 足以跨路径复用。固定 sccache `0.18.0` 返回基础设施错误码 `2` 时 wrapper 只回退这一次 rustc 调用,其它状态原样返回,不重跑测试。 + ## 启动与验证 ```bash diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 7a3006746..78ec2adfa 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -4,7 +4,7 @@ ## 标准流程 -前端测试稳定性验证使用根目录 `npm test`(与 Frontend tests job 相同),保留 Vitest 的 8 worker 上限。涉及异步资源展示时,组件测试必须 mock 所有会触发的网络请求,每次调用创建独立 `Response`,并等待最终 DOM 状态而非仅等待 fetch 被调用。换签 Hook 的测试通过 `vitest.config.ts` 的 include 纳入全量运行;新增测试文件后需确认实际执行名单,命令参数指定文件不会绕过 include 白名单。排查顺序依赖可使用 `npm test -- --sequence.shuffle --sequence.seed=9467`,但不能以重试成功替代失败原因分析。 +前端测试稳定性验证使用根目录 `npm test` 执行全量集合,保留 Vitest 的 8 worker 上限。CI 的 `Frontend tests` 使用 `npm run test:ci:frontend`,继承根配置并排除 `apps/ai-game-creator-shell/tests/**`;该目录由 `AI game creator shell web tests` 执行,两个 job 的 Vitest 文件集合互斥且并集等于本地全量。原生壳定向检查与 Repository checks 的 AppSurface 检查仍保留。涉及异步资源展示时,组件测试必须 mock 所有会触发的网络请求,每次调用创建独立 `Response`,并等待最终 DOM 状态而非仅等待 fetch 被调用。换签 Hook 的测试通过 `vitest.config.ts` 的 include 纳入全量运行;新增测试文件后需确认实际执行名单,命令参数指定文件不会绕过 include 白名单。排查顺序依赖可使用 `npm test -- --sequence.shuffle --sequence.seed=9467`,但不能以重试成功替代失败原因分析。 用例隔离必须包括浏览器状态与 mock 实现:修改 `window.history` 后恢复基线路由;`spyOn(window, 'getSelection')` 等 spy 在用例结束后 restore;`clearAllMocks` 仅清调用记录,不能恢复被上一个用例替换的返回值。顺序打乱暴露的失败应修复泄漏来源,保留原有业务断言。 @@ -61,6 +61,8 @@ AGC 预览快捷操作的界面测试按独立命令或有状态短流程注册 Rust 分片失败日志保留有界的失败详情,包括 panic 位置、断言和最终通过/失败数量;分片选中数量标为 selected,避免误读为失败数量。修改分片日志时运行 `node --test apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.test.mjs`,用最小 Rust fixture 验证失败详情和成功摘要。 +Linux process-session 的 owner SIGKILL 测试在启动 owner 后立即建立清理 guard,正常结束和 panic 展开都必须终止并回收 owner,再按独立临时项目目录清理残留进程。先完成「杀掉 owner 后子进程自行退出」的原有断言,guard 只在退出测试作用域时兜底,不得提前清理子树使生命周期回归假绿;清理本身不得 panic 或无限等待。 + AGC 运行时配置默认值调整时,同步核对 Rust 默认值、分发配置模板、设置弹窗默认草稿和 `runtime-settings.suite.ts` 的恢复默认断言;显式传入旧值的配置读取用例仍验证原值保留,不批量替换测试数据。 AGC 测试构造单 HTML 项目时,必须在初始化之前写入 HTML,避免自动建立 npm 工程;npm 预览和导出测试应提供 dist 产物。已有图片生成 pending/operation 属于持久化恢复合同,修改工具默认参数后仍须验证旧动作恢复不重复提交、不因默认值变化被误判为新意图。 @@ -94,4 +96,6 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m ## Gitea CI 依赖闭合 +AGC Rust lane 1 试点镜像内可信 sccache 对象快照,lane 2 保持直接 rustc 作为对照。维护者通过 `scripts/build-gitea-rust-cache.sh` 从远端 master 在限额、无宿主挂载的临时容器中生成快照,最终镜像只追加 sccache、对象和来源元数据,不包含源码或 target。PR 只写当前容器层、不回传,不开放 Docker API/发布权限;继续禁用 incremental。`ci-rust-cache.sh` 在快照缺失、工具链不符或 wrapper 探测失败时直接编译,并隔离远程缓存配置和 daemon。分片日志记录编译耗时,收尾输出命中统计。线上存在活跃 CI 时不得重启 runner 或切换标签;候选镜像和真实无缓存/冷/热对照验收见开发运维文档。 + `.gitea/workflows/project-ci.yml` 的客户端门禁拆成 lane 与功能 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust lane 1/2`、`lane 2/2` 各自预取一次 AGC 壳 manifest,并顺序运行两片 Rust bin 单测;`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。两条 Rust lane、smoke job 与 crates job 只用 cargo 与 node 内建模块,因此不执行 `npm ci`。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译后按 `--list` 名单分 4 片:每次分片调用用 `--shard-index=` 只跑自己那片,片内保持 `--test-threads=1` 并使用独立 `TMPDIR`;两条 lane 之间并发,lane 内顺序运行两片,避免重复依赖预热和同一容器内多进程争抢。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片调用都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 9de45201a..fe0dd8996 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5942,3 +5942,12 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` ## 2026-09-21 应用日志整行凭据脱敏会吃掉整条结构化诊断 `append_application_log_line` 在落盘前对整行做 `sanitize_diagnostic_message`:行内只要出现 `token=`、`bearer `、`authorization`、`credential`、`api key` / `apikey` / `api_key` 这类标记,**整行**就被换成 ``,只留下时间戳与 `RUST module:` 前缀;同时每行还会被截到 2048 字符。于是把“身份字段 + 诊断正文”拼成一行 `app_log!` 时,正文里一个凭据词就可能让整条记录连 `eventId`、`code` 一起消失(2026-09-21 加统一错误事件的日志投影时按两行落:身份行只放程序生成与调用方常量字段,summary / hint / detail 等自由文本一律只放详情行,且自由文本先自行压平换行——裸词标记脱敏消不掉,自由文本放错行会把 eventId、code 一起带走)。 + +## 2026-09-22 Rust 对象快照必须对齐 CI 编译目录和 Cargo 环境 + +- 现象:sccache 快照已包含数百 MiB 对象,但全新 target 的“热缓存”仍然全部 miss,甚至比直接 rustc 更慢。 +- 原因:Rust cache key 包含编译 cwd;sccache `0.18.0` 还会 hash `CARGO_*` 环境(jobserver、jobs 等少数例外除外)。不同 checkout 根目录、随机的 `CARGO_BUILD_RUSTC_WRAPPER` 路径、预热遗漏 workflow 的 HTTP/retry/color 环境都可能让整套缓存 miss。小型真实 Rust 实验显示仅配置 `SCCACHE_BASEDIRS` 不能消除 cwd 差异。快照存在不等于缓存有效。 +- 处理:预热使用已核实的 Gitea 路径 `/workspace/GenarrativeAI/Genarrative`,并与分片运行器一样从 AGC `src-tauri` 启动 Cargo;保存 `workspace.txt`,路径不符时回退直接编译。wrapper 放在容器内固定路径,daemon 状态与 Unix socket 仍使用随机私有目录;预热环境与 workflow 的 Cargo 环境由定向契约测试核对。不要为命中率随意增加 `RUSTFLAGS`、改写源码路径或恢复共享可写 target。 +- 验证:相同源码、资源上限和独立干净 target 下分别记录无缓存、冷缓存、热缓存的编译耗时和 hit/miss;只有真实热命中有净收益才切换候选镜像。PR 的写入始终留在 job 容器层,公共快照仍由可信维护流程生成。 +- 统计:job 私有 daemon 设置 `SCCACHE_IDLE_TIMEOUT=0`,由 `report` 显式停止;最终测试 bin 的不可缓存编译或测试可能超过一分钟,短 idle timeout 会让 daemon 提前退出,结尾查询启动新 daemon 后误报零次请求。容器销毁仍会回收该 job 的全部进程。 +- 磁盘:快照构建拒绝含 `/opt/genarrative-ci/rust-cache` 的基础镜像,始终从无对象缓存的镜像重建;容器内删除旧对象不能释放 Docker 底层。2 GiB 上限不涵盖宿主旧镜像及导出归档,切换验证后按运维文档人工保留当前版和一个回滚版,同时保护运行中 CI 使用的镜像。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 23d4a3cd5..98b728ab8 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -288,12 +288,14 @@ npm run check ### Gitea Actions PR 门禁 +Linux process-session 的 owner SIGKILL 用例必须在启动 owner 后立即建立测试清理 guard:正常退出或断言 panic 时终止、回收 owner,并在有界时间内清理其独立临时项目目录中的残留进程。原有「owner 退出后子进程自行消失」断言在兜底清理之前执行,不能由 guard 代替生产生命周期验证。清理覆盖 panic 路径及临时项目间隔离,且不得因清理失败再次 panic。 + 仓库级 Gitea Actions 工作流固定为 `.gitea/workflows/project-ci.yml`,在向 `master` 推送、创建或更新 PR,以及手工触发时运行。工作流拆成九个必须通过的 job。job 声明顺序就是 runner 领取顺序,因此把两条 AGC 壳 Rust lane 排在最前:并发槽位不足时它们必须最先开始,AGC 侧的关键路径才由自己而不是由排队决定。 所有 CI job 和 Jenkins Web Build 在根 workspace 安装前都必须确认 `npm --version` 为 `10.9.7`。Gitea job 使用预构建镜像内的固定版本;Jenkins Web Build 在每个独立 `bash -lc` 中 source `scripts/jenkins-prepare-npm-env.sh`,首次为 Jenkins 运行用户的版本隔离目录引导同版 npm,后续复用并把该 `bin` 放到 `PATH` 首位。旧固定镜像缺少版本元数据时只能报告 `npm_version=partial` 并由当前 job 的根 `npm ci` 继续校验 lock,不能把过渡状态当作工具链已闭合。 - `Repository checks`:调用唯一入口 `npm run check:repository-ci`,执行 `npm run lint`、AI 游戏创作壳 AppSurface 定向测试、主站与后台生产构建和提交差异空白检查。本地 master `pre-push` 复用同一入口,禁止在 workflow 与 hook 中维护两份近似命令。 -- `Frontend tests`:按唯一根 workspace lockfile 执行一次干净的 `npm ci`,再独立执行根 `npm run test`、`npm run bgfilter-worker:smoke-test`、`npm run check:production-health-patrol`、`npm run check:production-api-release` 和 `npm run check:production-api-deploy`,让 Vitest、Node test smoke harness 及不依赖真实服务的生产巡检 / 发布 / 部署行为 fixture 在 Gitea job 中持续执行;其中 `.test.mjs` 使用 Node test runner,不依赖 Vitest 的 `scripts/**/*.test.ts` 收集规则。 +- `Frontend tests`:按唯一根 workspace lockfile 执行一次干净的 `npm ci`,再独立执行根 `npm run test:ci:frontend`、`npm run bgfilter-worker:smoke-test`、`npm run check:production-health-patrol`、`npm run check:production-api-release` 和 `npm run check:production-api-deploy`,让 Vitest、Node test smoke harness 及不依赖真实服务的生产巡检 / 发布 / 部署行为 fixture 在 Gitea job 中持续执行。`test:ci:frontend` 使用 `vitest.frontend-ci.config.ts` 继承根配置,仅排除 `apps/ai-game-creator-shell/tests/**`,由 `AI game creator shell web tests` 执行该目录,避免两个 job 重复运行 AGC 前端测试;本地 `npm test` 仍执行原有全量集合。CI 契约测试使用 Vitest 实际文件发现验证两组互斥、并集等于全量;原生壳定向检查与 Repository checks 的 AppSurface 检查仍保留。其中 `.test.mjs` 使用 Node test runner,不依赖 Vitest 的 `scripts/**/*.test.ts` 收集规则。 - `Backend tests`:先对 `server-rs/Cargo.lock` 执行带 5 次整命令级有界重试的 `cargo fetch --locked`,再执行 `npm run check:server-rs-ddd`、`cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`、`cargo test --locked -p spacetime-module --no-fail-fast`、`api-server --all-targets` 编译和 `cargo check --locked -p spacetime-module`;普通 workspace host 测试排除 `spacetime-module` 以避免其 `spacetime-types` feature 统一污染领域 crate,模块自身的纯单元测试通过独立 package test 纳入门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,不能把 host 链接支持当作运行时替身。依赖准备必须位于会触发 Cargo build 的 DDD / 产物边界门禁之前,避免锁新增依赖未命中镜像缓存时绕过既有下载重试。runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。依赖真实服务或密钥的测试必须显式 `ignored`,不能让普通 PR job访问现场环境。 - `Native shell tests`:按唯一根 workspace lockfile 安装全部 App 依赖后,用 `npm run check:native-shells:contract`、`npm run check:native-shells:shells` 和 `npm run check:native-shells:release` 分别执行静态契约、H5 / 微信 / Expo / Tauri 桌面壳运行时门禁,以及依赖发布产物的构建 smoke,最后确认桌面壳与 AI 游戏创作壳的 `Cargo.lock` 都没有被构建过程改写。 - `AI game creator shell web tests`:执行 `npm run check:native-shells:agc-web`(即 `npm run ai-game-creator-shell:check:web`:AGC 壳 typecheck 与壳内测试)。该分组不触碰 Cargo,因此不预热 Rust 依赖。 @@ -330,6 +332,22 @@ workflow 首次成功运行后,**不**把 Project CI 的 job 配成 Gitea `mas master 日常交付必须禁止直接 push,只允许经 PR 在最近一次 Project CI 全绿后合并;本地 `pre-commit` 的 staged ESLint/Prettier 和 master `pre-push` 的 Repository checks parity 只用于提前发现问题,可被 `--no-verify` 绕过,不能充当服务端权威门禁。紧急直推白名单如需保留,应按人员和时限最小化,并要求执行同一 `npm run check:repository-ci ` 后回读 push CI。 +#### AGC Rust lane 1 的隔离编译缓存试验 + +每次生成快照必须使用不含对象缓存的原始 CI 基础镜像;构建脚本在拉取源码和预热前拒绝已存在 `/opt/genarrative-ci/rust-cache` 的基础镜像,避免重复叠加不可释放的旧对象层。切换并完成真实 CI 验证后,按 `deploy/container/README.md` 的保留规则人工定向清理旧缓存镜像及归档,保护运行中 CI 使用的版本。 + +只在 `AI game creator shell Rust lane 1/2` 启用 sccache,lane 2 保持直接 rustc。继续设置 `CARGO_INCREMENTAL=0`,不共享 target、不恢复 Actions 可写缓存。可信快照通过已有固定 Image ID 分发:镜像只增加固定版本的 sccache、编译对象和来源元数据,不包含源码、target 或凭据;容器写时复制层承接本 job 的新增对象,job 删除后丢弃,PR 没有 Docker API 或快照发布权限。该权限边界由 runner 基础设施保证,不能仅用 workflow 的分支条件替代。 + +维护者在受信任 checkout 中运行 `bash scripts/build-gitea-rust-cache.sh <已验证基础镜像> <候选镜像tag>`。脚本从 origin 获取 master 的确定提交,在无宿主目录挂载、无凭据且有 CPU/内存上限的临时容器中编译 AGC bin 测试,仅导出 sccache 对象;最终镜像从原基础镜像重新组装,不提交预热容器。公共快照不接受 PR 上传,也不复用 Jenkins 发布缓存。工具链或系统依赖变化时重新生成快照,缓存命中仍由 sccache 的编译输入校验决定,不能省略 Cargo 构建。 + +lane 1 在编译前执行 `scripts/ci-rust-cache.sh prepare`:检查快照与 rustc 身份,隔离 sccache 配置和 daemon,限时探测真实 wrapper。旧镜像没有快照或探测失败时保留空 wrapper,输出 fallback 原因;缓存故障不得把真实编译/测试失败改成成功,也不允许重跑整个测试组掩盖失败。结束时 `report` 输出命中统计;分片日志单独记录 Cargo 编译耗时。对象缓存上限为 2 GiB,最终测试 bin 的链接仍须执行。 + +候选镜像沿用 `gitea-ci-job-image.sh verify/export/load-runner` 验证和装载。现役 CI 正在运行时只允许准备候选镜像,不停止 job、不重启 runner、不切换标签;待确认无运行中的 job 后再按上述镜像更新顺序切换。验收分别记录独立 target 的无缓存、冷缓存和热缓存构建耗时及命中率,并检查失效/故障回退、两容器写入互不影响。未完成真实 CI 验证前不能宣称提速或推广到其它 job。 + +Rust 对象 key 包含编译工作目录。预热必须使用已核实的 Gitea checkout 路径 `/workspace/GenarrativeAI/Genarrative`,并像分片运行器一样从 `apps/ai-game-creator-shell/src-tauri` 启动 Cargo;快照保存 `workspace.txt`,job 根路径不符时直接编译,避免零命中的缓存开销。不要仅设置 `SCCACHE_BASEDIRS` 就假定 Rust 可以跨 cwd 命中,也不为缓存改写 `RUSTFLAGS` 或源码路径语义。固定 sccache `0.18.0` 的基础设施错误码 `2` 会由 wrapper 回退执行本次 rustc;其它退出码原样返回,升级 sccache/Rust 时须复核此约定。 + +sccache `0.18.0` 还会 hash 大部分 `CARGO_*` 环境变量。因此 wrapper 固定在每个容器自己的 `/opt/genarrative-ci/rust-cache/rustc-wrapper`,prepare 重写 launcher 后才启用;daemon 状态与 socket 仍各自随机隔离。预热同步 workflow 的 `CARGO_INCREMENTAL`、`CARGO_HTTP_MULTIPLEXING`、`CARGO_NET_RETRY` 和 `CARGO_TERM_COLOR`,由现有 CI 契约测试验证一致;新增 Cargo 环境配置时须同步评估 cache key,不能只看快照文件是否存在。 + SpacetimeDB bindings: ```bash diff --git a/package.json b/package.json index 57405379c..929a974d3 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "format:staged": "lint-staged", "check:pre-push-master": "bash scripts/pre-push-master.sh", "test": "vitest run", + "test:ci:frontend": "vitest run --config vitest.frontend-ci.config.ts", "test:watch": "vitest", "container:init": "node scripts/container-compose.mjs init", "container:build": "node scripts/container-compose.mjs build", diff --git a/scripts/build-gitea-rust-cache.sh b/scripts/build-gitea-rust-cache.sh new file mode 100644 index 000000000..6ad7d893b --- /dev/null +++ b/scripts/build-gitea-rust-cache.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# 由能管理 CI 镜像的维护者运行;不得在 PR job 内提供 Docker API/发布权限。 +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +base_ref="${1:?usage: build-gitea-rust-cache.sh }" +candidate_tag="${2:?candidate image tag is required}" +# 与实际 Gitea checkout 路径一致;Rust 对象 key 包含编译 cwd,不能随意换临时根。 +workspace=/workspace/GenarrativeAI/Genarrative +[[ "${CI:-}" != true ]] || { echo 'Run on the trusted image builder, outside CI jobs.' >&2; exit 1; } +base_id="$(docker image inspect --format '{{.Id}}' "${base_ref}")" +[[ "${base_id}" =~ ^sha256:[a-f0-9]{64}$ ]] +# 删除容器内旧对象不能释放镜像底层;每次必须从不含对象快照的基础镜像重建。 +docker run --rm --network none --read-only --cap-drop=ALL \ + --security-opt=no-new-privileges --entrypoint /bin/bash "${base_id}" -c ' + if [[ -e /opt/genarrative-ci/rust-cache ]]; then + echo "基础镜像已包含 Rust 对象缓存;请使用不含对象快照的原始 CI 镜像,禁止叠层。" >&2 + exit 1 + fi + ' +bash "${repo_root}/scripts/gitea-ci-job-image.sh" verify "${base_id}" + +# 只归档远端 master 的确定提交;不复制当前工作区或本地凭据。 +git -C "${repo_root}" fetch --no-tags origin refs/heads/master +source_commit="$(git -C "${repo_root}" rev-parse FETCH_HEAD^{commit})" +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/gitea-rust-cache.XXXXXX")" +container_id='' +cleanup() { + if [[ -n "${container_id}" ]]; then docker rm -f "${container_id}" >/dev/null; fi + rm -rf -- "${work_dir}" +} +trap cleanup EXIT + +archive=sccache-v0.18.0-x86_64-unknown-linux-musl.tar.gz +curl --fail --location --retry 3 --connect-timeout 15 --max-time 180 \ + "https://github.com/mozilla/sccache/releases/download/v0.18.0/${archive}" \ + --output "${work_dir}/${archive}" +printf '45f1447fbe231e3037bde351ef70677dd212216c8d62ae7ca409fecc4d6acc89 %s\n' "${work_dir}/${archive}" | sha256sum --check +tar -xzf "${work_dir}/${archive}" --directory "${work_dir}" +mkdir "${work_dir}/snapshot" +cp "${work_dir}/sccache-v0.18.0-x86_64-unknown-linux-musl/sccache" "${work_dir}/snapshot/sccache" +printf '%s\n' "${source_commit}" > "${work_dir}/snapshot/source-commit.txt" +printf '%s\n' "${base_id}" > "${work_dir}/snapshot/base-image.txt" +printf '%s\n' "${workspace}" > "${work_dir}/snapshot/workspace.txt" + +# 临时容器不挂载宿主目录/socket,不携带 Git/OSS/Jenkins 凭据,限制资源占用。 +container_id="$(docker run --detach --cpus=4 --memory=12g --pids-limit=1024 \ + --cap-drop=ALL --security-opt=no-new-privileges \ + --entrypoint /bin/bash "${base_id}" -c 'sleep infinity')" +docker exec "${container_id}" mkdir -p "${workspace}" /opt/genarrative-ci/rust-cache/objects +git -C "${repo_root}" archive "${source_commit}" | docker cp - "${container_id}:${workspace}" +docker cp "${work_dir}/snapshot/." "${container_id}:/opt/genarrative-ci/rust-cache/" +docker cp "${repo_root}/scripts/ci-rust-cache.sh" "${container_id}:/tmp/ci-rust-cache.sh" +docker exec --interactive --workdir "${workspace}" "${container_id}" bash -s <<'WARM' +set -euo pipefail +rustc -vV > /opt/genarrative-ci/rust-cache/rustc.txt +export GITHUB_ENV=/tmp/rust-cache.env CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=4 CI=true +# sccache 对 CARGO_*(除 jobserver/jobs 等特例)参与 hash,必须与 workflow 对齐。 +export CARGO_HTTP_MULTIPLEXING=false CARGO_NET_RETRY=10 CARGO_TERM_COLOR=always +bash /tmp/ci-rust-cache.sh prepare +set -a +source "${GITHUB_ENV}" +set +a +test -n "${RUSTC_WRAPPER}" +trap 'bash /tmp/ci-rust-cache.sh report' EXIT +cd apps/ai-game-creator-shell/src-tauri +cargo test --locked --manifest-path Cargo.toml \ + --bin genarrative-ai-game-creator-shell --no-run +WARM +docker cp "${container_id}:/opt/genarrative-ci/rust-cache/." "${work_dir}/snapshot/" +docker rm -f "${container_id}" >/dev/null +container_id='' + +# 从原基础镜像重新组装,只 COPY 对象快照;不 commit 含源码/target 的预热容器。 +cat > "${work_dir}/Dockerfile" < "${work_dir}/.dockerignore" +docker build --pull=false --tag "${candidate_tag}" "${work_dir}" +bash "${repo_root}/scripts/gitea-ci-job-image.sh" verify "${candidate_tag}" +printf 'snapshot_source=%s\ncandidate_image=%s\n' "${source_commit}" "$(docker image inspect --format '{{.Id}}' "${candidate_tag}")" +echo 'Candidate only: the runner configuration and running jobs have not been changed.' diff --git a/scripts/check-gitea-ci-job-image.sh b/scripts/check-gitea-ci-job-image.sh index 38a0f9ce2..3ac390a67 100644 --- a/scripts/check-gitea-ci-job-image.sh +++ b/scripts/check-gitea-ci-job-image.sh @@ -28,6 +28,19 @@ bash -n /usr/local/bin/genarrative-gitea-checkout test -d /root/.npm/_cacache test -d /usr/local/cargo/registry/cache +# 对象快照是可选镜像层;构建/装载前验证,运行时故障由 prepare 回退直接 rustc。 +rust_cache_root=/opt/genarrative-ci/rust-cache +if [[ -d "${rust_cache_root}" && "${GENARRATIVE_GITEA_CI_CHECK_RUNTIME:-0}" != 1 ]]; then + test "$("${rust_cache_root}/sccache" --version)" = 'sccache 0.18.0' + rustc -vV | cmp -s - "${rust_cache_root}/rustc.txt" + rg -q '^[a-f0-9]{40}$' "${rust_cache_root}/source-commit.txt" + rg -q '^sha256:[a-f0-9]{64}$' "${rust_cache_root}/base-image.txt" + test "$(cat "${rust_cache_root}/workspace.txt")" = '/workspace/GenarrativeAI/Genarrative' + test -n "$(find "${rust_cache_root}/objects" -type f -print -quit)" + test ! -e /workspace/GenarrativeAI/Genarrative + printf 'rust_object_snapshot=verified\n' +fi + verify_cache_lock() { local cache_name="$1" local expected_sha256="$2" diff --git a/scripts/ci-rust-cache.sh b/scripts/ci-rust-cache.sh new file mode 100644 index 000000000..83d3108fa --- /dev/null +++ b/scripts/ci-rust-cache.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# 仅消费镜像内的可信快照;所有写入留在当前容器的可写层。 +set -euo pipefail + +cache_root="${GENARRATIVE_CI_RUST_CACHE_ROOT:-/opt/genarrative-ci/rust-cache}" +cache_binary="${cache_root}/sccache" +state="${GENARRATIVE_CI_RUST_CACHE_STATE:-}" + +configure_local_cache() { + # 不继承开发机/其它流水线的 OSS、S3、GHA 或 daemon 配置。 + local variable + for variable in ${!SCCACHE_@}; do unset "${variable}"; done + export SCCACHE_CONF="${state}/config" + export SCCACHE_DIR="${cache_root}/objects" + export SCCACHE_CACHE_SIZE=2G + export SCCACHE_SERVER_UDS="${state}/server.sock" + # 单个不可缓存的链接/测试阶段可能超过一分钟;保持统计直到 report 显式停止。 + export SCCACHE_IDLE_TIMEOUT=0 + export SCCACHE_IGNORE_SERVER_IO_ERROR=1 +} + +case "${1:-}" in + prepare) + : "${GITHUB_ENV:?GITHUB_ENV is required}" + printf 'RUSTC_WRAPPER=\nCARGO_BUILD_RUSTC_WRAPPER=\nGENARRATIVE_CI_RUST_CACHE_STATE=\n' >> "${GITHUB_ENV}" + fallback() { printf '[rust-cache] mode=direct reason=%s\n' "$1"; exit 0; } + [[ -x "${cache_binary}" && -d "${cache_root}/objects" && -f "${cache_root}/rustc.txt" && -f "${cache_root}/source-commit.txt" && -f "${cache_root}/workspace.txt" ]] \ + || fallback snapshot-unavailable + if ! rustc -vV | cmp -s - "${cache_root}/rustc.txt"; then + fallback toolchain-mismatch + fi + if [[ "$(pwd -P)" != "$(cat "${cache_root}/workspace.txt")" ]]; then + fallback workspace-mismatch + fi + # 使用短 Unix socket 路径,避免共享固定端口或超出 sockaddr_un 长度。 + state="$(mktemp -d /tmp/ci-rust-cache.XXXXXX)" + printf 'server_startup_timeout_ms = 5000\n' > "${state}/config" + configure_local_cache + # 探测必须暴露 daemon 故障;只有正式编译允许 sccache 的 IO 回退。 + unset SCCACHE_IGNORE_SERVER_IO_ERROR + if ! timeout --kill-after=2 15 "${cache_binary}" "$(command -v rustc)" -vV > "${state}/probe.log" 2>&1; then + timeout --kill-after=2 5 "${cache_binary}" --stop-server >/dev/null 2>&1 || true + rm -rf -- "${state}" + fallback wrapper-probe-failed + fi + script_path="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/$(basename "${BASH_SOURCE[0]}")" + # wrapper 路径也参与 Rust cache key;固定容器内路径,隔离由 job 容器保证。 + wrapper_path="${cache_root}/rustc-wrapper" + printf '#!/usr/bin/env bash\nexec bash %q "$@"\n' "${script_path}" > "${wrapper_path}" + chmod 700 "${wrapper_path}" + { + printf 'GENARRATIVE_CI_RUST_CACHE_ROOT=%s\n' "${cache_root}" + printf 'GENARRATIVE_CI_RUST_CACHE_STATE=%s\n' "${state}" + printf 'RUSTC_WRAPPER=%s\nCARGO_BUILD_RUSTC_WRAPPER=%s\n' "${wrapper_path}" "${wrapper_path}" + } >> "${GITHUB_ENV}" + printf '[rust-cache] mode=sccache snapshot=%s\n' "$(cat "${cache_root}/source-commit.txt")" + ;; + report) + if [[ -n "${state}" && -d "${state}" ]]; then + configure_local_cache + timeout --kill-after=2 5 "${cache_binary}" --show-stats || true + timeout --kill-after=2 5 "${cache_binary}" --stop-server >/dev/null 2>&1 || true + rm -rf -- "${state}" + else + printf '[rust-cache] mode=direct\n' + fi + ;; + *) + # Cargo wrapper 协议:第一个参数是真实 rustc。失去缓存状态时仍执行原编译。 + if [[ -z "${state}" || ! -f "${state}/config" || -f "${state}/disabled" || ! -x "${cache_binary}" ]]; then + exec "$@" + fi + configure_local_cache + status=0 + "${cache_binary}" "$@" || status=$? + if [[ "${status}" == 2 ]]; then + # 固定 sccache 版本的自身错误码为 2;实际 rustc 失败通常为 1/101。 + # 只重试这次编译,不重跑 Cargo 或测试,真实编译错误仍按 rustc 状态返回。 + echo '[rust-cache] cache infrastructure failed; compiling directly' >&2 + # 后续 crate 直接编译,避免坏 daemon 让每个 crate 都支付一次启动超时。 + : > "${state}/disabled" + exec "$@" + fi + exit "${status}" + ;; +esac diff --git a/scripts/ci-rust-cache.test.mjs b/scripts/ci-rust-cache.test.mjs new file mode 100644 index 000000000..2b4f4f0ad --- /dev/null +++ b/scripts/ci-rust-cache.test.mjs @@ -0,0 +1,217 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { test } from 'node:test'; + +const script = resolve('scripts/ci-rust-cache.sh'); +const linuxTest = process.platform === 'linux' ? test : test.skip; + +function fixture(t) { + const directory = mkdtempSync(join(tmpdir(), 'ci-rust-cache-test-')); + const root = join(directory, 'snapshot'); + const bin = join(directory, 'bin'); + mkdirSync(join(root, 'objects'), { recursive: true }); + mkdirSync(bin); + const envFile = join(directory, 'github-env'); + writeFileSync(envFile, ''); + writeFileSync(join(root, 'rustc.txt'), 'fixture rustc identity\n'); + writeFileSync(join(root, 'source-commit.txt'), 'trusted-master-commit\n'); + writeFileSync(join(root, 'workspace.txt'), `${process.cwd()}\n`); + writeFileSync( + join(bin, 'rustc'), + '#!/bin/bash\necho "fixture rustc identity"\n', + { mode: 0o755 }, + ); + writeFileSync( + join(root, 'sccache'), + `#!/bin/bash +set -eu +case "$1" in + --stop-server|--show-stats) exit 0 ;; +esac +if [[ "$*" == *-vV ]]; then + [[ "\${PROBE_FAILURE:-}" != 1 ]] || exit 1 + exec "$@" +fi +printf '%s\\n' "\${SCCACHE_OSS_BUCKET-unset}" "\${SCCACHE_CONF}" "\${SCCACHE_DIR}" "\${SCCACHE_SERVER_UDS}" "\${SCCACHE_IGNORE_SERVER_IO_ERROR}" "\${SCCACHE_IDLE_TIMEOUT}" >> "\${TRACE}" +[[ "\${CACHE_FAILURE:-}" != 2 ]] || exit 2 +exec "$@" +`, + { mode: 0o755 }, + ); + const env = { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + GITHUB_ENV: envFile, + GENARRATIVE_CI_RUST_CACHE_ROOT: root, + GENARRATIVE_CI_RUST_CACHE_STATE: '', + RUSTC_WRAPPER: 'bad-inherited-wrapper', + CARGO_BUILD_RUSTC_WRAPPER: 'bad-inherited-wrapper', + SCCACHE_OSS_BUCKET: 'must-not-use-publishing-cache', + TRACE: join(directory, 'trace'), + }; + function run(args, extraEnv = {}) { + return spawnSync('bash', [script, ...args], { + env: { ...env, ...extraEnv }, + encoding: 'utf8', + }); + } + function preparedEnv() { + return Object.fromEntries( + readFileSync(envFile, 'utf8') + .trim() + .split('\n') + .map((line) => { + const separator = line.indexOf('='); + return [line.slice(0, separator), line.slice(separator + 1)]; + }), + ); + } + t.after(() => { + run(['report'], preparedEnv()); + rmSync(directory, { recursive: true, force: true }); + }); + return { directory, root, env, run, preparedEnv }; +} + +linuxTest( + 'missing snapshot and toolchain mismatch retain direct rustc', + (t) => { + const f = fixture(t); + const missing = f.run(['prepare'], { + GENARRATIVE_CI_RUST_CACHE_ROOT: join(f.directory, 'absent'), + }); + assert.equal(missing.status, 0, missing.stderr); + assert.match(missing.stdout, /reason=snapshot-unavailable/); + assert.equal(f.preparedEnv().RUSTC_WRAPPER, ''); + writeFileSync(join(f.root, 'rustc.txt'), 'different compiler\n'); + const mismatch = f.run(['prepare']); + assert.equal(mismatch.status, 0, mismatch.stderr); + assert.match(mismatch.stdout, /reason=toolchain-mismatch/); + assert.equal(f.preparedEnv().CARGO_BUILD_RUSTC_WRAPPER, ''); + writeFileSync(join(f.root, 'rustc.txt'), 'fixture rustc identity\n'); + writeFileSync(join(f.root, 'workspace.txt'), '/different-checkout\n'); + const moved = f.run(['prepare']); + assert.equal(moved.status, 0, moved.stderr); + assert.match(moved.stdout, /reason=workspace-mismatch/); + assert.equal(f.preparedEnv().RUSTC_WRAPPER, ''); + }, +); + +linuxTest( + 'preparations keep the compiler wrapper path stable with separate daemons', + (t) => { + const f = fixture(t); + assert.equal(f.run(['prepare']).status, 0); + const first = f.preparedEnv(); + f.run(['report'], first); + assert.equal(f.run(['prepare']).status, 0); + const second = f.preparedEnv(); + assert.equal( + first.CARGO_BUILD_RUSTC_WRAPPER, + second.CARGO_BUILD_RUSTC_WRAPPER, + ); + assert.equal(first.RUSTC_WRAPPER, second.RUSTC_WRAPPER); + assert.notEqual( + first.GENARRATIVE_CI_RUST_CACHE_STATE, + second.GENARRATIVE_CI_RUST_CACHE_STATE, + ); + }, +); + +linuxTest('failed wrapper probe falls back without enabling the cache', (t) => { + const f = fixture(t); + const result = f.run(['prepare'], { PROBE_FAILURE: '1' }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /reason=wrapper-probe-failed/); + assert.equal(f.preparedEnv().RUSTC_WRAPPER, ''); + assert.equal(f.preparedEnv().GENARRATIVE_CI_RUST_CACHE_STATE, ''); +}); + +linuxTest( + 'wrapper isolates remote settings and preserves compiler failures without retry', + (t) => { + const f = fixture(t); + const prepared = f.run(['prepare']); + assert.equal(prepared.status, 0, prepared.stderr); + const cachedEnv = f.preparedEnv(); + const result = spawnSync( + cachedEnv.RUSTC_WRAPPER, + ['/bin/bash', '-c', 'exit 42'], + { + env: { ...f.env, ...cachedEnv }, + encoding: 'utf8', + }, + ); + assert.equal(result.status, 42, result.stderr); + const trace = readFileSync(f.env.TRACE, 'utf8').trim().split('\n'); + assert.deepEqual(trace, [ + 'unset', + `${cachedEnv.GENARRATIVE_CI_RUST_CACHE_STATE}/config`, + `${f.root}/objects`, + `${cachedEnv.GENARRATIVE_CI_RUST_CACHE_STATE}/server.sock`, + '1', + '0', + ]); + }, +); + +linuxTest( + 'job copies use different cache directories and daemon sockets', + (t) => { + const first = fixture(t); + const second = fixture(t); + for (const f of [first, second]) { + const prepared = f.run(['prepare']); + assert.equal(prepared.status, 0, prepared.stderr); + const result = spawnSync(f.preparedEnv().RUSTC_WRAPPER, ['/bin/true'], { + env: { ...f.env, ...f.preparedEnv() }, + encoding: 'utf8', + }); + assert.equal(result.status, 0, result.stderr); + } + const a = readFileSync(first.env.TRACE, 'utf8').split('\n'); + const b = readFileSync(second.env.TRACE, 'utf8').split('\n'); + assert.notEqual(a[2], b[2]); + assert.notEqual(a[3], b[3]); + }, +); + +linuxTest( + 'sccache infrastructure failure invokes rustc and preserves its result', + (t) => { + const f = fixture(t); + const prepared = f.run(['prepare']); + assert.equal(prepared.status, 0, prepared.stderr); + for (const [compiler, status] of [ + ['/bin/false', 1], + ['/bin/true', 0], + ]) { + const result = spawnSync(f.preparedEnv().RUSTC_WRAPPER, [compiler], { + env: { ...f.env, ...f.preparedEnv(), CACHE_FAILURE: '2' }, + encoding: 'utf8', + }); + assert.equal(result.status, status, result.stderr); + if (status === 1) + assert.match(result.stderr, /cache infrastructure failed/); + else assert.equal(result.stderr, ''); + } + assert.equal( + readFileSync(f.env.TRACE, 'utf8').trim().split('\n').length, + 6, + ); + }, +); + +linuxTest('lost local cache state still invokes the real compiler', (t) => { + const f = fixture(t); + assert.equal(f.run(['/bin/bash', '-c', 'exit 43']).status, 43); +}); diff --git a/scripts/project-ci-workflow.test.ts b/scripts/project-ci-workflow.test.ts index f55375b98..2dedd7980 100644 --- a/scripts/project-ci-workflow.test.ts +++ b/scripts/project-ci-workflow.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { createVitest } from 'vitest/node'; const workflow = readFileSync( resolve(process.cwd(), '.gitea/workflows/project-ci.yml'), @@ -15,6 +16,10 @@ const imageCheckScript = readFileSync( resolve(process.cwd(), 'scripts/check-gitea-ci-job-image.sh'), 'utf8', ); +const rustCacheBuildScript = readFileSync( + resolve(process.cwd(), 'scripts/build-gitea-rust-cache.sh'), + 'utf8', +); const npmCiRetryScript = readFileSync( resolve(process.cwd(), 'scripts/ci-npm-ci-with-retry.sh'), 'utf8', @@ -137,6 +142,34 @@ function backendStepIndex(stepName: string) { } describe('project CI workflow', () => { + it('trials isolated compilation caching only in AGC Rust lane 1', () => { + const lane = jobSection('ai-game-creator-shell-rust-lane-1'); + expect(lane).toContain('node --test scripts/ci-rust-cache.test.mjs'); + expect(lane.indexOf('bash scripts/ci-rust-cache.sh prepare')).toBeLessThan( + lane.indexOf('npm run check:native-shells:agc-rust-shard-1'), + ); + expect( + stepSection( + 'ai-game-creator-shell-rust-lane-1', + 'Report isolated Rust compilation cache', + ), + ).toContain('if: always()'); + for (const job of jobNames.filter( + (name) => name !== 'ai-game-creator-shell-rust-lane-1', + )) { + expect(jobSection(job)).not.toContain('ci-rust-cache.sh'); + } + expect(workflow).toContain("CARGO_INCREMENTAL: '0'"); + expect(workflow).toContain("RUSTC_WRAPPER: ''"); + for (const [, name, value] of workflow + .slice(0, workflow.indexOf('\njobs:')) + .matchAll(/^ {2}(CARGO_[A-Z0-9_]+): '?([^'\n]*)'?$/gm)) { + // wrapper 在 prepare 中选择,其余 Cargo 环境必须和预热一致。 + if (name === 'CARGO_BUILD_RUSTC_WRAPPER') continue; + expect(rustCacheBuildScript).toContain(`${name}=${value}`); + } + }); + it('runs for master pushes, pull requests, and manual dispatch only', () => { expect(workflow).toMatch( /on:\n {2}push:\n {4}branches:\n {6}- master\n {2}pull_request:\n {2}workflow_dispatch:/u, @@ -397,7 +430,10 @@ describe('project CI workflow', () => { it('keeps frontend, operations fixture, and native shell gates in dedicated jobs', () => { const frontendJob = jobSection('frontend-tests'); - expect(frontendJob).toContain('run: npm run test'); + expect(frontendJob).toMatch(/^ {8}run: npm run test:ci:frontend$/mu); + expect(rootPackageJson.scripts?.['test:ci:frontend']).toBe( + 'vitest run --config vitest.frontend-ci.config.ts', + ); expect(frontendJob).toContain('run: npm run bgfilter-worker:smoke-test'); expect(frontendJob).toContain( 'run: npm run check:production-health-patrol', @@ -419,6 +455,39 @@ describe('project CI workflow', () => { expect(nativeJob).toContain('cargo fetch --locked'); }); + it('partitions frontend and AGC test files without gaps or duplicates', async () => { + expect(rootPackageJson.scripts?.test).toBe('vitest run'); + const full = await createVitest('test', { + config: resolve('vitest.config.ts'), + watch: false, + }); + try { + const allFiles = (await full.globTestFiles()).map(([, file]) => file); + const agcFiles = ( + await full.globTestFiles(['apps/ai-game-creator-shell/tests']) + ).map(([, file]) => file); + const frontend = await createVitest('test', { + config: resolve('vitest.frontend-ci.config.ts'), + watch: false, + }); + try { + const frontendFiles = (await frontend.globTestFiles()).map( + ([, file]) => file, + ); + expect(agcFiles.length).toBeGreaterThan(0); + expect(frontendFiles.length).toBeGreaterThan(0); + expect(frontendFiles.filter((file) => agcFiles.includes(file))).toEqual( + [], + ); + expect([...frontendFiles, ...agcFiles].sort()).toEqual(allFiles.sort()); + } finally { + await frontend.close(); + } + } finally { + await full.close(); + } + }, 30_000); + it('runs every native shell gate group exactly once across the split jobs', () => { for (const [group, script] of Object.entries(nativeShellGateGroupScripts)) { expect(rootPackageJson.scripts?.[`check:native-shells:${group}`]).toBe( diff --git a/vitest.frontend-ci.config.ts b/vitest.frontend-ci.config.ts new file mode 100644 index 000000000..207404bf6 --- /dev/null +++ b/vitest.frontend-ci.config.ts @@ -0,0 +1,10 @@ +import { mergeConfig } from 'vitest/config'; + +import config from './vitest.config'; + +// AGC 前端测试由专属 CI job 执行;本地全量入口继续使用根配置。 +export default mergeConfig(config, { + test: { + exclude: ['apps/ai-game-creator-shell/tests/**'], + }, +}); From 292e42cf5ecbe98b906214f39813451fd7d8098c Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 22 Sep 2026 05:52:16 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=E4=B8=BA=E5=85=A8=E9=83=A8=20Rust=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=BB=84=E5=90=AF=E7=94=A8=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E7=BC=96=E8=AF=91=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扩展缓存消费到 AGC 双 lane、crates、smoke、后端及桌面壳测试 补齐各组实际编译目标和工作目录的可信快照预热 保留 SpacetimeDB 独立编译边界并排除发布构建缓存 根据完整快照体积将对象缓存上限调整为 4 GiB 更新工作流契约测试、运维文档及项目共享记忆 --- .gitea/workflows/project-ci.yml | 39 ++++++++++ deploy/container/README.md | 12 +-- .../shared-memory/development-workflow.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 3 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 10 +-- scripts/build-gitea-rust-cache.sh | 46 ++++++++++- scripts/ci-rust-cache.sh | 2 +- scripts/project-ci-workflow.test.ts | 78 +++++++++++++++---- 8 files changed, 159 insertions(+), 33 deletions(-) diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 78f48200b..707e5f7a6 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -129,12 +129,19 @@ jobs: sleep $((attempt * 2)) done + - name: Prepare isolated Rust compilation cache + run: bash scripts/ci-rust-cache.sh prepare + - name: Run AI game creator shell Rust shard 3/4 run: npm run check:native-shells:agc-rust-shard-3 - name: Run AI game creator shell Rust shard 4/4 run: npm run check:native-shells:agc-rust-shard-4 + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + # agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖, # 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。 ai-game-creator-shell-rust-smoke: @@ -167,9 +174,16 @@ jobs: sleep $((attempt * 2)) done + - name: Prepare isolated Rust compilation cache + run: bash scripts/ci-rust-cache.sh prepare + - name: Run AI game creator shell agent-run smoke run: npm run check:native-shells:agc-rust-smoke + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + # AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。 ai-game-creator-shell-rust-crates: name: AI game creator shell Rust crates @@ -268,9 +282,16 @@ jobs: sleep $((attempt * 2)) done + - name: Prepare isolated Rust compilation cache + run: bash scripts/ci-rust-cache.sh prepare + - name: Run AI game creator shell shared crate gates run: npm run check:native-shells:agc-rust-crates + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + backend-tests: name: Backend tests runs-on: genarrative-ci @@ -341,6 +362,9 @@ jobs: - name: Check server-rs boundaries run: npm run check:server-rs-ddd + - name: Prepare isolated Rust compilation cache + run: bash scripts/ci-rust-cache.sh prepare + - name: Run server-rs workspace tests run: cargo test --locked --workspace --exclude spacetime-module --no-fail-fast --manifest-path server-rs/Cargo.toml @@ -353,6 +377,10 @@ jobs: - name: Check SpacetimeDB module run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + # 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁, # 以及依赖发布产物的构建 smoke。 native-shell-tests: @@ -395,15 +423,26 @@ jobs: - name: Run native shell contract gates run: npm run check:native-shells:contract + - name: Prepare isolated Rust compilation cache + run: bash scripts/ci-rust-cache.sh prepare + - name: Run native shell gates run: npm run check:native-shells:shells - name: Run native shell release build smoke + # 发布构建有独立 profile/features 和资源 staging,不消费测试对象快照。 + env: + RUSTC_WRAPPER: '' + CARGO_BUILD_RUSTC_WRAPPER: '' run: npm run check:native-shells:release - name: Ensure native lockfiles are unchanged run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock + - name: Report isolated Rust compilation cache + if: always() + run: bash scripts/ci-rust-cache.sh report + frontend-tests: name: Frontend tests runs-on: genarrative-ci diff --git a/deploy/container/README.md b/deploy/container/README.md index 62d3c5904..a129d2d1d 100644 --- a/deploy/container/README.md +++ b/deploy/container/README.md @@ -95,11 +95,11 @@ runner 配置保留原 `ubuntu-latest` 映射,`genarrative-ci` 继续映射到 回滚时先把 workflow 的 `runs-on` 改回 `ubuntu-latest`,再恢复备份的 runner config 并用同一超时重启 runner。不要在真实 CI 验证前删除旧映射或旧镜像。 -### 可选的 AGC Rust 编译对象快照 +### Rust 测试组编译对象快照 -`scripts/build-gitea-rust-cache.sh` 在已验证的 job 镜像上生成候选镜像,供 AGC Rust lane 1 试验;lane 2 保持直接 rustc。固定 sccache `0.18.0` 的 Linux x64 musl 归档并校验 SHA-256,维护者从 origin/master 的确定提交预热编译对象,PR job 没有生成/发布公共快照的权限。 +`scripts/build-gitea-rust-cache.sh` 在已验证的 job 镜像上生成候选镜像,覆盖 AGC Rust 两条 lane、crates、agent-run smoke、Backend 和 Native shell 的桌面壳测试。Native shell 的 release build smoke 显式清空两个 wrapper,不消费测试快照。固定 sccache `0.18.0` 的 Linux x64 musl 归档并校验 SHA-256,维护者从 origin/master 的确定提交预热编译对象,PR job 没有生成/发布公共快照的权限。 -基础镜像必须不含 `/opt/genarrative-ci/rust-cache`;脚本在拉取源码、下载工具和预热前执行只读、断网检查,发现已有对象快照就拒绝构建。不能在旧缓存镜像上删除目录再叠加新快照,删除操作不会释放旧镜像层。切换且真实 CI 验证通过后,人工定向清理更旧的缓存镜像与导出归档,保留当前版、一个回滚版及运行中 CI 使用的版本;2 GiB 对象缓存上限不覆盖这些宿主文件,不使用全局 prune。 +基础镜像必须不含 `/opt/genarrative-ci/rust-cache`;脚本在拉取源码、下载工具和预热前执行只读、断网检查,发现已有对象快照就拒绝构建。不能在旧缓存镜像上删除目录再叠加新快照,删除操作不会释放旧镜像层。切换且真实 CI 验证通过后,人工定向清理更旧的缓存镜像与导出归档,保留当前版、一个回滚版及运行中 CI 使用的版本;对象缓存上限不覆盖这些宿主文件,不使用全局 prune。 ```bash bash scripts/build-gitea-rust-cache.sh genarrative/gitea-project-ci:20260920.2 genarrative/gitea-project-ci:rust-cache-candidate @@ -110,11 +110,11 @@ bash scripts/gitea-ci-job-image.sh load-runner genarrative/gitea-project-ci:rust 预热容器上限为 4 核、12 GiB,移除 capabilities,不挂宿主目录/socket,也不注入 Git/OSS/Jenkins 凭据。源码通过 `git archive` 复制,当前工作区、ignored 文件和 `.git` 不进入容器。最终从原镜像重新组装,仅复制 `/opt/genarrative-ci/rust-cache` 的 sccache、对象和来源元数据,不提交含源码/target 的预热容器;镜像本身的下载缓存与工具链校验保持原样。 -快照由固定 Image ID 分发,每个 job 仅修改容器自己的写时复制层,缓存上限 2 GiB,结束后不回传。`ci-rust-cache.sh prepare` 清空继承的 `SCCACHE_*` 远程配置,使用独立配置和 Unix socket;旧镜像、工具链不匹配或限时 wrapper 探测失败时使用直接 rustc,正式编译启用 sccache 的 server IO 错误回退。真实编译/测试失败保留非零退出码。`report` 输出命中统计并停止本 job daemon,分片日志输出独立编译耗时。 +快照由固定 Image ID 分发,每个 job 仅修改容器自己的写时复制层,缓存上限 4 GiB,构建末尾输出实际对象体积,结束后不回传。`ci-rust-cache.sh prepare` 清空继承的 `SCCACHE_*` 远程配置,使用独立配置和 Unix socket;旧镜像、工具链不匹配或限时 wrapper 探测失败时使用直接 rustc,正式编译启用 sccache 的 server IO 错误回退。真实编译/测试失败保留非零退出码。`report` 输出命中统计并停止本 job daemon,分片日志输出独立编译耗时。sccache 0.18.0 的只读模式在 miss 后仍打包再拒绝写入,不能用它宣称零 miss 开销;普通 CI 继续只向容器层写入。 -生成候选不会改变 runner 配置。线上有活跃 CI 时禁止停止 job、重启 runner 或切换标签;只在确认空闲后按上节流程切换固定 Image ID。先用相同源码、独立干净 target 比较无缓存、冷缓存、热缓存,真实 CI 验收通过后再考虑推广。回滚缓存试验可以移除 lane 1 的 prepare/report,或恢复原镜像 ID,均不需要改变 incremental 或测试分片。 +生成候选不会改变 runner 配置。线上有活跃 CI 时禁止停止 job、重启 runner 或切换标签;只在确认空闲后按上节流程切换固定 Image ID。全组启用须重建完整快照;旧 AGC 单目标快照不能作为其它组预热成功的证据。按组用相同源码、独立干净 target 比较编译命中与耗时;分片测试内容和前置检查不同,不能直接用两条 lane 总耗时推断缓存收益。回滚时可移除各 job 的 prepare/report,或恢复无对象缓存的原基础镜像 ID,均不需要改变 incremental 或测试分片。 -预热路径固定为实际 Gitea checkout 的 `/workspace/GenarrativeAI/Genarrative`,Cargo 从 AGC `src-tauri` 目录启动;`workspace.txt` 不匹配时直接编译。Rust cache key 对 cwd 敏感,不能假定 `SCCACHE_BASEDIRS` 足以跨路径复用。固定 sccache `0.18.0` 返回基础设施错误码 `2` 时 wrapper 只回退这一次 rustc 调用,其它状态原样返回,不重跑测试。 +预热路径固定为实际 Gitea checkout 的 `/workspace/GenarrativeAI/Genarrative`:后端、独立 crate、插件、桌面壳和提示词契约从仓库根目录启动 Cargo;AGC 分片从 `src-tauri` 启动;agent-run smoke 从 AGC App 根目录启动。切换 AGC cwd 前仅清理预热容器内的 AGC target,避免 Cargo 的 fresh 判断跳过新 cwd 的对象。后端 workspace 与 spacetime-module 保持分开编译,测试仅 `--no-run`,smoke 仅 `cargo build`。`workspace.txt` 不匹配时直接编译。Rust cache key 对 cwd 敏感,不能假定 `SCCACHE_BASEDIRS` 足以跨路径复用。固定 sccache `0.18.0` 返回基础设施错误码 `2` 时 wrapper 只回退这一次 rustc 调用,其它状态原样返回,不重跑测试。 ## 启动与验证 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 78ec2adfa..fc9354c63 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -96,6 +96,6 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m ## Gitea CI 依赖闭合 -AGC Rust lane 1 试点镜像内可信 sccache 对象快照,lane 2 保持直接 rustc 作为对照。维护者通过 `scripts/build-gitea-rust-cache.sh` 从远端 master 在限额、无宿主挂载的临时容器中生成快照,最终镜像只追加 sccache、对象和来源元数据,不包含源码或 target。PR 只写当前容器层、不回传,不开放 Docker API/发布权限;继续禁用 incremental。`ci-rust-cache.sh` 在快照缺失、工具链不符或 wrapper 探测失败时直接编译,并隔离远程缓存配置和 daemon。分片日志记录编译耗时,收尾输出命中统计。线上存在活跃 CI 时不得重启 runner 或切换标签;候选镜像和真实无缓存/冷/热对照验收见开发运维文档。 +AGC Rust 两条 lane、crates、smoke、Backend 和桌面壳测试使用镜像内可信 sccache 对象快照;Native shell release step 显式清空双 wrapper,前端/repository checks 不启用。维护者通过 `scripts/build-gitea-rust-cache.sh` 从远端 master 在限额、无宿主挂载的临时容器中按实际 cwd/profile/目标预热全部测试组,仅编译、不执行测试/应用;后端 workspace 与 spacetime-module 保持独立,AGC 的三个 cwd 入口之间清理预热 target,防止 fresh 判断漏产缓存键。最终镜像只追加 sccache、对象和来源元数据,不包含源码或 target。容量上限 4 GiB,不替代宿主旧镜像/归档清理。PR 只写当前容器层、不回传,不开放 Docker API/发布权限;继续禁用 incremental。`ci-rust-cache.sh` 在快照缺失、工具链不符或 wrapper 探测失败时直接编译,并隔离远程缓存配置和 daemon。分片日志记录编译耗时,收尾输出命中统计;两个 lane 的测试和前置检查不同,耗时差不是严格 A/B。线上存在活跃 CI 时不得重启 runner 或切换标签;全组启用前须刷新完整快照并逐组验证,详见开发运维文档。 `.gitea/workflows/project-ci.yml` 的客户端门禁拆成 lane 与功能 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust lane 1/2`、`lane 2/2` 各自预取一次 AGC 壳 manifest,并顺序运行两片 Rust bin 单测;`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。两条 Rust lane、smoke job 与 crates job 只用 cargo 与 node 内建模块,因此不执行 `npm ci`。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译后按 `--list` 名单分 4 片:每次分片调用用 `--shard-index=` 只跑自己那片,片内保持 `--test-threads=1` 并使用独立 `TMPDIR`;两条 lane 之间并发,lane 内顺序运行两片,避免重复依赖预热和同一容器内多进程争抢。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片调用都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index fe0dd8996..0e5823240 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5950,4 +5950,5 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 处理:预热使用已核实的 Gitea 路径 `/workspace/GenarrativeAI/Genarrative`,并与分片运行器一样从 AGC `src-tauri` 启动 Cargo;保存 `workspace.txt`,路径不符时回退直接编译。wrapper 放在容器内固定路径,daemon 状态与 Unix socket 仍使用随机私有目录;预热环境与 workflow 的 Cargo 环境由定向契约测试核对。不要为命中率随意增加 `RUSTFLAGS`、改写源码路径或恢复共享可写 target。 - 验证:相同源码、资源上限和独立干净 target 下分别记录无缓存、冷缓存、热缓存的编译耗时和 hit/miss;只有真实热命中有净收益才切换候选镜像。PR 的写入始终留在 job 容器层,公共快照仍由可信维护流程生成。 - 统计:job 私有 daemon 设置 `SCCACHE_IDLE_TIMEOUT=0`,由 `report` 显式停止;最终测试 bin 的不可缓存编译或测试可能超过一分钟,短 idle timeout 会让 daemon 提前退出,结尾查询启动新 daemon 后误报零次请求。容器销毁仍会回收该 job 的全部进程。 -- 磁盘:快照构建拒绝含 `/opt/genarrative-ci/rust-cache` 的基础镜像,始终从无对象缓存的镜像重建;容器内删除旧对象不能释放 Docker 底层。2 GiB 上限不涵盖宿主旧镜像及导出归档,切换验证后按运维文档人工保留当前版和一个回滚版,同时保护运行中 CI 使用的镜像。 +- 磁盘:快照构建拒绝含 `/opt/genarrative-ci/rust-cache` 的基础镜像,始终从无对象缓存的镜像重建;容器内删除旧对象不能释放 Docker 底层。对象缓存容量上限不涵盖宿主旧镜像及导出归档,切换验证后按运维文档人工保留当前版和一个回滚版,同时保护运行中 CI 使用的镜像。 +- 扩展:预热所有 Rust 测试组时保留各自 cwd、profile、features 和锁策略;同一临时 target 的 Cargo fresh 不代表不同 cwd 都已生成缓存键,AGC 提示词契约、分片和 smoke 切换入口前清理预热 target。不要把 workspace 与 spacetime-module 合并成一次编译;Native shell release step 清空双 wrapper,避免将测试缓存扩展成发布缓存。当前 sccache 0.18.0 的 READ_ONLY 在 miss 后仍打包产物并产生 cache write error,不适合用来承诺“未命中无开销”。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 98b728ab8..60f267a61 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -332,19 +332,19 @@ workflow 首次成功运行后,**不**把 Project CI 的 job 配成 Gitea `mas master 日常交付必须禁止直接 push,只允许经 PR 在最近一次 Project CI 全绿后合并;本地 `pre-commit` 的 staged ESLint/Prettier 和 master `pre-push` 的 Repository checks parity 只用于提前发现问题,可被 `--no-verify` 绕过,不能充当服务端权威门禁。紧急直推白名单如需保留,应按人员和时限最小化,并要求执行同一 `npm run check:repository-ci ` 后回读 push CI。 -#### AGC Rust lane 1 的隔离编译缓存试验 +#### Rust 测试组的隔离编译缓存 每次生成快照必须使用不含对象缓存的原始 CI 基础镜像;构建脚本在拉取源码和预热前拒绝已存在 `/opt/genarrative-ci/rust-cache` 的基础镜像,避免重复叠加不可释放的旧对象层。切换并完成真实 CI 验证后,按 `deploy/container/README.md` 的保留规则人工定向清理旧缓存镜像及归档,保护运行中 CI 使用的版本。 -只在 `AI game creator shell Rust lane 1/2` 启用 sccache,lane 2 保持直接 rustc。继续设置 `CARGO_INCREMENTAL=0`,不共享 target、不恢复 Actions 可写缓存。可信快照通过已有固定 Image ID 分发:镜像只增加固定版本的 sccache、编译对象和来源元数据,不包含源码、target 或凭据;容器写时复制层承接本 job 的新增对象,job 删除后丢弃,PR 没有 Docker API 或快照发布权限。该权限边界由 runner 基础设施保证,不能仅用 workflow 的分支条件替代。 +AGC Rust 两条 lane、crates、agent-run smoke、Backend 和 Native shell 的桌面壳测试均启用 sccache。Native shell 的 release build smoke 显式清空两个 wrapper,保持发布构建原有 profile/features 与资源 staging;前端和 repository checks 不启用对象缓存。继续设置 `CARGO_INCREMENTAL=0`,不共享 target、不恢复 Actions 可写缓存。可信快照通过已有固定 Image ID 分发:镜像只增加固定版本的 sccache、编译对象和来源元数据,不包含源码、target 或凭据;容器写时复制层承接本 job 的新增对象,job 删除后丢弃,PR 没有 Docker API 或快照发布权限。该权限边界由 runner 基础设施保证,不能仅用 workflow 的分支条件替代。 -维护者在受信任 checkout 中运行 `bash scripts/build-gitea-rust-cache.sh <已验证基础镜像> <候选镜像tag>`。脚本从 origin 获取 master 的确定提交,在无宿主目录挂载、无凭据且有 CPU/内存上限的临时容器中编译 AGC bin 测试,仅导出 sccache 对象;最终镜像从原基础镜像重新组装,不提交预热容器。公共快照不接受 PR 上传,也不复用 Jenkins 发布缓存。工具链或系统依赖变化时重新生成快照,缓存命中仍由 sccache 的编译输入校验决定,不能省略 Cargo 构建。 +维护者在受信任 checkout 中运行 `bash scripts/build-gitea-rust-cache.sh <已验证基础镜像> <候选镜像tag>`。脚本从 origin 获取 master 的确定提交,在无宿主目录挂载、无凭据且有 CPU/内存上限的临时容器中预热各组实际 Cargo 目标,只编译、不执行测试或应用。后端 workspace 仍排除 spacetime-module,再单独预热该模块,禁止为缓存合并 features。AGC 独立 crate 与插件沿用各自 manifest/锁文件策略;未提交锁文件的 crate 仍可能因新版本解析产生 miss。最终镜像从原基础镜像重新组装,只导出 sccache 对象,不提交预热容器。公共快照不接受 PR 上传,也不复用 Jenkins 发布缓存。工具链或系统依赖变化时重新生成快照,缓存命中仍由 sccache 的编译输入校验决定,不能省略 Cargo 构建。 -lane 1 在编译前执行 `scripts/ci-rust-cache.sh prepare`:检查快照与 rustc 身份,隔离 sccache 配置和 daemon,限时探测真实 wrapper。旧镜像没有快照或探测失败时保留空 wrapper,输出 fallback 原因;缓存故障不得把真实编译/测试失败改成成功,也不允许重跑整个测试组掩盖失败。结束时 `report` 输出命中统计;分片日志单独记录 Cargo 编译耗时。对象缓存上限为 2 GiB,最终测试 bin 的链接仍须执行。 +各 Rust job 在编译前执行 `scripts/ci-rust-cache.sh prepare`:检查快照与 rustc 身份,隔离 sccache 配置和 daemon,限时探测真实 wrapper。旧镜像没有快照或探测失败时保留空 wrapper,输出 fallback 原因;缓存故障不得把真实编译/测试失败改成成功,也不允许重跑整个测试组掩盖失败。结束时 `report` 输出命中统计;分片日志单独记录 Cargo 编译耗时。对象缓存上限为 4 GiB,快照构建完成后输出实际体积;最终测试 bin 的链接仍须执行。全组开启时必须生成覆盖全部目标的新快照,不能把旧 AGC 单目标快照当作后端/桌面壳的预热验收。 候选镜像沿用 `gitea-ci-job-image.sh verify/export/load-runner` 验证和装载。现役 CI 正在运行时只允许准备候选镜像,不停止 job、不重启 runner、不切换标签;待确认无运行中的 job 后再按上述镜像更新顺序切换。验收分别记录独立 target 的无缓存、冷缓存和热缓存构建耗时及命中率,并检查失效/故障回退、两容器写入互不影响。未完成真实 CI 验证前不能宣称提速或推广到其它 job。 -Rust 对象 key 包含编译工作目录。预热必须使用已核实的 Gitea checkout 路径 `/workspace/GenarrativeAI/Genarrative`,并像分片运行器一样从 `apps/ai-game-creator-shell/src-tauri` 启动 Cargo;快照保存 `workspace.txt`,job 根路径不符时直接编译,避免零命中的缓存开销。不要仅设置 `SCCACHE_BASEDIRS` 就假定 Rust 可以跨 cwd 命中,也不为缓存改写 `RUSTFLAGS` 或源码路径语义。固定 sccache `0.18.0` 的基础设施错误码 `2` 会由 wrapper 回退执行本次 rustc;其它退出码原样返回,升级 sccache/Rust 时须复核此约定。 +Rust 对象 key 包含编译工作目录。预热必须使用已核实的 Gitea checkout 路径 `/workspace/GenarrativeAI/Genarrative`:后端、独立 crate、插件、桌面壳及提示词契约从仓库根目录启动 Cargo,AGC 分片从 `apps/ai-game-creator-shell/src-tauri` 启动,agent-run smoke 从 `apps/ai-game-creator-shell` 启动。切换 AGC 编译入口前清理该临时容器的 AGC target,防止 Cargo 的 fresh 判断跳过新 cwd 所需对象;CI 各 job 的 target 本来就独立。快照保存 `workspace.txt`,job 根路径不符时直接编译。不要仅设置 `SCCACHE_BASEDIRS` 就假定 Rust 可以跨 cwd 命中,也不为缓存改写 `RUSTFLAGS` 或源码路径语义。固定 sccache `0.18.0` 的基础设施错误码 `2` 会由 wrapper 回退执行本次 rustc;其它退出码原样返回,升级 sccache/Rust 时须复核此约定。 sccache `0.18.0` 还会 hash 大部分 `CARGO_*` 环境变量。因此 wrapper 固定在每个容器自己的 `/opt/genarrative-ci/rust-cache/rustc-wrapper`,prepare 重写 launcher 后才启用;daemon 状态与 socket 仍各自随机隔离。预热同步 workflow 的 `CARGO_INCREMENTAL`、`CARGO_HTTP_MULTIPLEXING`、`CARGO_NET_RETRY` 和 `CARGO_TERM_COLOR`,由现有 CI 契约测试验证一致;新增 Cargo 环境配置时须同步评估 cache key,不能只看快照文件是否存在。 diff --git a/scripts/build-gitea-rust-cache.sh b/scripts/build-gitea-rust-cache.sh index 6ad7d893b..46f87d800 100644 --- a/scripts/build-gitea-rust-cache.sh +++ b/scripts/build-gitea-rust-cache.sh @@ -63,9 +63,49 @@ source "${GITHUB_ENV}" set +a test -n "${RUSTC_WRAPPER}" trap 'bash /tmp/ci-rust-cache.sh report' EXIT -cd apps/ai-game-creator-shell/src-tauri -cargo test --locked --manifest-path Cargo.toml \ - --bin genarrative-ai-game-creator-shell --no-run +# 与 CI 的工作目录、features 和目标逐项对齐,只编译,不运行测试/应用。 +echo '[rust-cache] warming backend tests and checks' +cargo test --locked --workspace --exclude spacetime-module --no-fail-fast \ + --manifest-path server-rs/Cargo.toml --no-run +cargo test --locked -p spacetime-module --no-fail-fast \ + --manifest-path server-rs/Cargo.toml --no-run +cargo check --locked -p api-server --all-targets --manifest-path server-rs/Cargo.toml +cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml + +echo '[rust-cache] warming AGC standalone, shared and plugin crates' +for manifest in \ + server-rs/crates/agent-runtime-core/Cargo.toml \ + server-rs/crates/agent-runtime-orchestration/Cargo.toml \ + plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml; do + # 与 CI 一样,这些 crate 没有已提交的独立 Cargo.lock。 + cargo test --manifest-path "${manifest}" --no-run +done +cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml --no-run +cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml --no-run +for editor in unity godot; do + cargo test --locked --manifest-path "plugins/agc-${editor}-editor/native/${editor}-editor-bridge/Cargo.toml" --no-run +done + +echo '[rust-cache] warming desktop tests and AGC prompt contracts' +cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml --no-run +cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml \ + --test runtime_prompt_bundle_build --test prompt_source_boundaries --no-run + +# Cargo 的 fresh 判断不保证不同 cwd 都经过 sccache;先清掉临时容器中的 target。 +cargo clean --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml +echo '[rust-cache] warming AGC Rust lanes' +( + cd apps/ai-game-creator-shell/src-tauri + cargo test --locked --manifest-path Cargo.toml \ + --bin genarrative-ai-game-creator-shell --no-run +) +cargo clean --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml +echo '[rust-cache] warming AGC agent-run smoke executable' +( + cd apps/ai-game-creator-shell + cargo build --manifest-path src-tauri/Cargo.toml +) +du -sh /opt/genarrative-ci/rust-cache/objects WARM docker cp "${container_id}:/opt/genarrative-ci/rust-cache/." "${work_dir}/snapshot/" docker rm -f "${container_id}" >/dev/null diff --git a/scripts/ci-rust-cache.sh b/scripts/ci-rust-cache.sh index 83d3108fa..f93aacfc0 100644 --- a/scripts/ci-rust-cache.sh +++ b/scripts/ci-rust-cache.sh @@ -12,7 +12,7 @@ configure_local_cache() { for variable in ${!SCCACHE_@}; do unset "${variable}"; done export SCCACHE_CONF="${state}/config" export SCCACHE_DIR="${cache_root}/objects" - export SCCACHE_CACHE_SIZE=2G + export SCCACHE_CACHE_SIZE=4G export SCCACHE_SERVER_UDS="${state}/server.sock" # 单个不可缓存的链接/测试阶段可能超过一分钟;保持统计直到 report 显式停止。 export SCCACHE_IDLE_TIMEOUT=0 diff --git a/scripts/project-ci-workflow.test.ts b/scripts/project-ci-workflow.test.ts index 2dedd7980..9db340345 100644 --- a/scripts/project-ci-workflow.test.ts +++ b/scripts/project-ci-workflow.test.ts @@ -142,23 +142,45 @@ function backendStepIndex(stepName: string) { } describe('project CI workflow', () => { - it('trials isolated compilation caching only in AGC Rust lane 1', () => { - const lane = jobSection('ai-game-creator-shell-rust-lane-1'); - expect(lane).toContain('node --test scripts/ci-rust-cache.test.mjs'); - expect(lane.indexOf('bash scripts/ci-rust-cache.sh prepare')).toBeLessThan( - lane.indexOf('npm run check:native-shells:agc-rust-shard-1'), - ); - expect( - stepSection( - 'ai-game-creator-shell-rust-lane-1', - 'Report isolated Rust compilation cache', - ), - ).toContain('if: always()'); - for (const job of jobNames.filter( - (name) => name !== 'ai-game-creator-shell-rust-lane-1', - )) { - expect(jobSection(job)).not.toContain('ci-rust-cache.sh'); + it('uses isolated compilation caching for every Rust test job', () => { + const firstRustSteps = { + 'ai-game-creator-shell-rust-lane-1': + 'Run AI game creator shell Rust shard 1/4', + 'ai-game-creator-shell-rust-lane-2': + 'Run AI game creator shell Rust shard 3/4', + 'ai-game-creator-shell-rust-smoke': + 'Run AI game creator shell agent-run smoke', + 'ai-game-creator-shell-rust-crates': + 'Run AI game creator shell shared crate gates', + 'backend-tests': 'Run server-rs workspace tests', + 'native-shell-tests': 'Run native shell gates', + }; + for (const job of jobNames) { + const section = jobSection(job); + if (!(job in firstRustSteps)) { + expect(section).not.toContain('ci-rust-cache.sh'); + continue; + } + const firstRustStep = firstRustSteps[job as keyof typeof firstRustSteps]; + const prepare = section.indexOf('bash scripts/ci-rust-cache.sh prepare'); + expect(prepare).toBeGreaterThan( + section.indexOf('Checkout full history from Gitea'), + ); + expect(prepare).toBeLessThan(section.indexOf(`- name: ${firstRustStep}`)); + expect( + stepSection(job, 'Report isolated Rust compilation cache'), + ).toContain('if: always()'); } + // 缓存自身的行为测试只执行一次,避免随 job 数量重复运行。 + expect( + workflow.match(/node --test scripts\/ci-rust-cache.test.mjs/g), + ).toHaveLength(1); + const releaseStep = stepSection( + 'native-shell-tests', + 'Run native shell release build smoke', + ); + expect(releaseStep).toContain("RUSTC_WRAPPER: ''"); + expect(releaseStep).toContain("CARGO_BUILD_RUSTC_WRAPPER: ''"); expect(workflow).toContain("CARGO_INCREMENTAL: '0'"); expect(workflow).toContain("RUSTC_WRAPPER: ''"); for (const [, name, value] of workflow @@ -170,6 +192,30 @@ describe('project CI workflow', () => { } }); + it('warms backend targets without merging the SpacetimeDB feature boundary', () => { + const warmCommands = rustCacheBuildScript + .replace(/\\\r?\n/g, '') + .replace(/\s+/g, ' '); + for (const [, command] of jobSection('backend-tests').matchAll( + /run: (cargo [^\n]+)/g, + )) { + expect(warmCommands).toContain(command.trim()); + } + expect(warmCommands).toContain( + 'cargo test --locked --workspace --exclude spacetime-module --no-fail-fast --manifest-path server-rs/Cargo.toml --no-run', + ); + expect(warmCommands).toContain( + 'cargo test --locked -p spacetime-module --no-fail-fast --manifest-path server-rs/Cargo.toml --no-run', + ); + expect(warmCommands).toContain('cd apps/ai-game-creator-shell/src-tauri'); + expect(warmCommands).toContain( + 'cd apps/ai-game-creator-shell cargo build --manifest-path src-tauri/Cargo.toml', + ); + expect(warmCommands).toContain( + 'cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml --no-run', + ); + }); + it('runs for master pushes, pull requests, and manual dispatch only', () => { expect(workflow).toMatch( /on:\n {2}push:\n {4}branches:\n {6}- master\n {2}pull_request:\n {2}workflow_dispatch:/u, From 500835407a6208b7de51e2ceaba841b4335075de Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 22 Sep 2026 15:33:18 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20Windows=20?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E7=9A=84=20NSIS=20=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=B7=AF=E5=BE=84=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NSIS 缓存目录解析改为按目标平台使用 win32/posix path,避免 Windows 将 /tmp 解析到当前盘 回归测试改用 defaultAppRoot,并补齐 Windows/POSIX 路径断言,消除 C:\\C:\\ 拼坏与分隔符误判 同步 shared-memory,记录跨平台路径测试约束 --- .../scripts/nsis-toolset.mjs | 12 ++++++++--- .../scripts/nsis-toolset.test.mjs | 20 ++++++++++--------- docs/project-memory/shared-memory/pitfalls.md | 2 +- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs b/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs index d79c9f8a7..f52c82836 100644 --- a/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs +++ b/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs @@ -88,15 +88,21 @@ export function resolveNsisCacheDir( env = process.env, platform = process.platform, ) { + const pathImpl = platform === 'win32' ? path.win32 : path.posix; const explicit = env.AGC_TAURI_NSIS_CACHE_DIR?.trim(); - if (explicit) return path.resolve(explicit); + if (explicit) return pathImpl.resolve(explicit); // Jenkins Windows 节点以 SYSTEM 运行,ProgramData 稳定可写且不受工作区清理影响; // 缓存里只有待解压的原始归档,不会从该目录执行任何程序。 if (platform === 'win32') { const programData = env.ProgramData?.trim() || 'C:\\ProgramData'; - return path.join(programData, 'genarrative', 'tauri-nsis-cache'); + return pathImpl.join(programData, 'genarrative', 'tauri-nsis-cache'); } - return path.join(os.homedir(), '.cache', 'genarrative', 'tauri-nsis-cache'); + return pathImpl.join( + os.homedir(), + '.cache', + 'genarrative', + 'tauri-nsis-cache', + ); } /** 与 tauri-bundler 相同的镜像开关语义,便于构建机绕过不可达的 GitHub。 */ diff --git a/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs b/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs index 26faca27e..d5ce2fbf0 100644 --- a/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs +++ b/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs @@ -7,6 +7,7 @@ import { test } from 'node:test'; import JSZip from 'jszip'; import { + defaultAppRoot, ensureNsisToolset, extractNsisArchive, NSIS_ARCHIVE_ASSET_NAME, @@ -23,10 +24,7 @@ import { verifyNsisToolset, } from './nsis-toolset.mjs'; -const appRoot = path.resolve( - path.dirname(new URL(import.meta.url).pathname), - '..', -); +const appRoot = defaultAppRoot(); const silentLogger = { log() {}, warn() {} }; function createSandbox() { @@ -98,6 +96,14 @@ test('NSIS 工具链目录与 Tauri useLocalToolsDir 配置保持一致', () => }); test('缓存目录默认落在工作区之外并支持环境变量覆盖', () => { + assert.equal( + resolveNsisCacheDir({ AGC_TAURI_NSIS_CACHE_DIR: 'D:\\agc-cache' }, 'win32'), + 'D:\\agc-cache', + ); + assert.equal( + resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'), + path.win32.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'), + ); assert.equal( resolveNsisCacheDir( { AGC_TAURI_NSIS_CACHE_DIR: '/tmp/agc-cache' }, @@ -105,13 +111,9 @@ test('缓存目录默认落在工作区之外并支持环境变量覆盖', () => ), '/tmp/agc-cache', ); - assert.equal( - resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'), - path.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'), - ); assert.ok( resolveNsisCacheDir({}, 'linux').endsWith( - path.join('.cache', 'genarrative', 'tauri-nsis-cache'), + path.posix.join('.cache', 'genarrative', 'tauri-nsis-cache'), ), ); }); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0e5823240..b229b2883 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5455,7 +5455,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 原因:tauri-bundler 的 `download_and_verify` 现场从 GitHub 取 NSIS 工具链,只有一次机会、没有重试;响应体被截断即报 `io: unexpected end of file`,看起来像打包错误其实是网络问题。Checkout 阶段的 `git clean -fdx` 每次都会清掉 `target/.tauri`,所以每个构建都要重新下载,在受限网络下必然反复失败。 - 处理:新增 `apps/ai-game-creator-shell/scripts/nsis-toolset.mjs` 与 `ensure-nsis-toolset.mjs`,在 `buildRelease`(Windows 目标且需要打包时)与 Jenkins `Tauri NSIS toolchain` 阶段按固定 SHA1 预置 `target/.tauri/NSIS`:原始归档带 4 次重试,缓存在工作区外的 `%ProgramData%\genarrative\tauri-nsis-cache`(可用 `AGC_TAURI_NSIS_CACHE_DIR` 覆盖),镜像开关沿用 bundler 的 `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE` / `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR`。该阶段同时执行 `makensis.exe -VERSION`,把 2026-09-02 记录的缓存目录不可执行问题也提前到编译之前暴露。Checkout 阶段改为 `git clean -fdx -e apps/ai-game-creator-shell/src-tauri/target/.tauri`:Tauri 的工具缓存位于工作区内,裸 `git clean -fdx` 会连它一起删,排除后同一节点的稳态构建不再需要联网,只有冷缓存(新节点、工作区重建)才下载。 - 验证:`node --test apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs`(已就绪零下载复用、缓存离线还原、失败重试、哈希不符与归档越界失败关闭)与 `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs`;真实节点上该阶段必须早于 Rust 编译失败关闭。 -- 注意:不要改回 `bundle.useLocalToolsDir: false` 去用 `%LOCALAPPDATA%`,也不要依赖 PATH 里预装的 `makensis`;升级 `@tauri-apps/cli` 时同步核对归档 URL、SHA1 与必需文件清单。 +- 注意:不要改回 `bundle.useLocalToolsDir: false` 去用 `%LOCALAPPDATA%`,也不要依赖 PATH 里预装的 `makensis`;升级 `@tauri-apps/cli` 时同步核对归档 URL、SHA1 与必需文件清单。回归测试自身也必须跨平台:仓库根目录用 `fileURLToPath(new URL(...))` / `defaultAppRoot()` 解析,不能用 `URL.pathname` 得到 `/C:/...` 后再 `path.resolve`;模拟 Windows 与 POSIX 缓存路径时要分别使用 `path.win32` 与 `path.posix`,不要用当前节点的原生分隔符断言另一平台。 ## AGC 登录态续期必须同步本地运行时