From 24b10f066095ffe0ab20a995ca395faee46b37ab Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 23 Jul 2026 15:38:45 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8DLLM=E5=8E=9F=E6=96=87?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=B9=B6=E8=A1=8C=E6=B5=8B=E8=AF=95=E6=B1=A1?= =?UTF-8?q?=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将原文日志目录收敛为LlmConfig实例配置 迁移platform-llm与api-server测试到独立日志目录 补充Rust异步测试环境变量隔离规范 --- docs/project-memory/shared-memory/pitfalls.md | 8 ++++ .../api-server/src/creation_agent_llm_turn.rs | 9 +---- .../src/custom_world_foundation_draft.rs | 26 +++++++------ server-rs/crates/platform-llm/src/lib.rs | 37 ++++++++++++------- 4 files changed, 48 insertions(+), 32 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e31c1c8cd..a3eb37fbe 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -63,6 +63,14 @@ - 验证:运行触达文件的定向 `vitest`,必要时追加 `npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。 - 关联:`docs/technical/【前端测试】React组件测试准则-2026-06-26.md`、`src/components/image-editor/useCanvasGenerationDialogs.test.tsx`、`src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx`。 +## Rust 并行测试不要在 await 跨度内修改进程环境变量 + +- 现象:单独运行的异步测试稳定通过,默认并行运行整个 crate 时却看到临时目录多出其它测试的文件、文件对被拆散,或目录清理与并发写入互相竞争;Gitea Backend CI 可能表现为日志数量断言偶发增加。 +- 原因:`std::env::set_var` / `remove_var` 修改整个测试进程,不属于当前 async task。测试在 `await` 前设置目录、结束后恢复时,同一 test binary 的其它用例会在中间窗口读取该值;只锁修改环境变量的测试也无效,除非所有间接读取方都参与同一把锁。 +- 处理:文件、队列、缓存等副作用目录进入实例配置,在构造时一次性解析环境默认值,并允许测试显式注入唯一临时目录。不要靠 `--test-threads=1`、固定 sleep 或只过滤自己的文件名掩盖错误路由;纯环境解析测试只有在全部相关读写都封闭于同一 `OnceLock>` 时才使用全局锁。 +- 验证:先精确运行目标用例,再以默认并行度重复运行完整 crate;失败类测试同时执行时,各实例目录只能包含自己的输入 / 输出日志,测试结束后临时目录必须清理。 +- 关联:`server-rs/crates/platform-llm/src/lib.rs`、`server-rs/crates/api-server/src/creation_agent_llm_turn.rs`、`server-rs/crates/api-server/src/custom_world_foundation_draft.rs`。 + ## 带 objectKey 的画布图片测试要等待换签后可见 - 现象:测试点击“添加素材”后,图层状态已经写入,但立即用 `getByAltText('画布图片:...')` 偶发或稳定找不到图片;前一张图可能通过,紧接着添加的第二张失败。 diff --git a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs index 86bbfa93a..8c9d12c41 100644 --- a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs +++ b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs @@ -359,9 +359,6 @@ mod tests { .expect("system time should be after epoch") .as_nanos() )); - unsafe { - std::env::set_var("LLM_RAW_LOG_DIR", &log_dir); - } let success_json = serde_json::json!({ "replyText": "好,我们先把玩具王国定住。", "progressPercent": 12, @@ -405,7 +402,8 @@ mod tests { 0, 1, ) - .expect("LLM config should build"); + .expect("LLM config should build") + .with_raw_log_dir(log_dir.clone()); let llm_client = platform_llm::LlmClient::new(config).expect("LLM client should build"); let mut visible_replies = Vec::new(); @@ -437,9 +435,6 @@ mod tests { assert!(requests[0].contains("\"web_search\"")); assert!(!requests[1].contains("\"tools\"")); - unsafe { - std::env::remove_var("LLM_RAW_LOG_DIR"); - } if log_dir.exists() { fs::remove_dir_all(log_dir).expect("temporary LLM raw log dir should be removed"); } diff --git a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs index fb5a93e14..c022db3d8 100644 --- a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs +++ b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs @@ -2587,9 +2587,6 @@ mod tests { .expect("system time should be after epoch") .as_nanos() )); - unsafe { - std::env::set_var("LLM_RAW_LOG_DIR", &log_dir); - } let request_capture = Arc::new(Mutex::new(Vec::new())); let server_url = spawn_mock_server_with_statuses( request_capture.clone(), @@ -2604,7 +2601,7 @@ mod tests { }, ], ); - let llm_client = build_test_llm_client(server_url); + let llm_client = build_test_llm_client_with_raw_log_dir(server_url, log_dir.clone()); let parsed = request_foundation_json_stage( &llm_client, @@ -2628,9 +2625,6 @@ mod tests { assert!(requests[0].contains("\"web_search\"")); assert!(!requests[1].contains("\"tools\"")); - unsafe { - std::env::remove_var("LLM_RAW_LOG_DIR"); - } if log_dir.exists() { std::fs::remove_dir_all(log_dir).expect("temporary LLM raw log dir should be removed"); } @@ -3256,7 +3250,19 @@ mod tests { } fn build_test_llm_client(base_url: String) -> LlmClient { - let config = LlmConfig::new( + LlmClient::new(build_test_llm_config(base_url)).expect("llm client should build") + } + + fn build_test_llm_client_with_raw_log_dir( + base_url: String, + raw_log_dir: std::path::PathBuf, + ) -> LlmClient { + let config = build_test_llm_config(base_url).with_raw_log_dir(raw_log_dir); + LlmClient::new(config).expect("llm client should build") + } + + fn build_test_llm_config(base_url: String) -> LlmConfig { + LlmConfig::new( LlmProvider::Ark, base_url, "test-key".to_string(), @@ -3265,9 +3271,7 @@ mod tests { 0, 1, ) - .expect("llm config should build"); - - LlmClient::new(config).expect("llm client should build") + .expect("llm config should build") } fn spawn_mock_server( diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 8c5e41aae..fbfd0f54d 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -40,6 +40,7 @@ pub struct LlmConfig { base_url: String, api_key: String, model: String, + raw_log_dir: PathBuf, request_timeout_ms: u64, max_retries: u32, retry_backoff_ms: u64, @@ -426,6 +427,9 @@ impl LlmConfig { let base_url = normalize_non_empty(base_url, "LLM base_url 不能为空")?; let api_key = normalize_non_empty(api_key, "LLM api_key 不能为空")?; let model = normalize_non_empty(model, "LLM model 不能为空")?; + let raw_log_dir = env::var("LLM_RAW_LOG_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_LLM_RAW_LOG_DIR)); if request_timeout_ms == 0 { return Err(LlmError::InvalidConfig( @@ -438,6 +442,7 @@ impl LlmConfig { base_url, api_key, model, + raw_log_dir, request_timeout_ms, max_retries, retry_backoff_ms, @@ -450,6 +455,11 @@ impl LlmConfig { self } + pub fn with_raw_log_dir(mut self, raw_log_dir: impl Into) -> Self { + self.raw_log_dir = raw_log_dir.into(); + self + } + pub fn ark_default(api_key: String, model: String) -> Result { Self::new( LlmProvider::Ark, @@ -478,6 +488,10 @@ impl LlmConfig { &self.model } + fn raw_log_dir(&self) -> &PathBuf { + &self.raw_log_dir + } + pub fn request_timeout_ms(&self) -> u64 { self.request_timeout_ms } @@ -1368,9 +1382,7 @@ fn write_llm_raw_failure( failure_stage: &str, raw_output: &str, ) -> Result<(), String> { - let log_dir = env::var("LLM_RAW_LOG_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(DEFAULT_LLM_RAW_LOG_DIR)); + let log_dir = config.raw_log_dir(); fs::create_dir_all(&log_dir).map_err(|error| format!("创建日志目录失败:{error}"))?; let prefix = build_llm_raw_log_prefix(failure_stage); @@ -2354,9 +2366,6 @@ mod tests { "platform-llm-raw-log-test-{}", build_llm_raw_log_prefix("parse_error") )); - unsafe { - std::env::set_var("LLM_RAW_LOG_DIR", &log_dir); - } let server_url = spawn_mock_server(vec![MockResponse { status_line: "200 OK", @@ -2365,7 +2374,8 @@ mod tests { extra_headers: Vec::new(), }]); - let client = build_test_client(server_url, 0); + let config = build_test_config(server_url, 0).with_raw_log_dir(log_dir.clone()); + let client = LlmClient::new(config).expect("client should be created"); let error = client .request_single_message_text("系统原文", "用户原文") .await @@ -2398,14 +2408,15 @@ mod tests { assert!(!input_text.contains("test-key")); assert_eq!(output_text, "不是合法 JSON"); - unsafe { - std::env::remove_var("LLM_RAW_LOG_DIR"); - } fs::remove_dir_all(log_dir).expect("log dir should be removed"); } fn build_test_client(base_url: String, max_retries: u32) -> LlmClient { - let config = LlmConfig::new( + LlmClient::new(build_test_config(base_url, max_retries)).expect("client should be created") + } + + fn build_test_config(base_url: String, max_retries: u32) -> LlmConfig { + LlmConfig::new( LlmProvider::Ark, base_url, "test-key".to_string(), @@ -2414,9 +2425,7 @@ mod tests { max_retries, 1, ) - .expect("config should be valid"); - - LlmClient::new(config).expect("client should be created") + .expect("config should be valid") } fn spawn_mock_server(responses: Vec) -> String { From 00da0dd1c8c9b23a49b0df2bc69525e4c25dda7f Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 23 Jul 2026 16:06:56 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=88=B7=E6=96=B0Gitea=20CI=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E7=BC=93=E5=AD=98=E9=95=9C=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 预热当前server-rs的SpacetimeDB 2.7.0依赖 为Cargo锁定依赖下载增加整命令级有界重试 更新Runner镜像事实和运维文档 --- deploy/container/README.md | 6 ++--- deploy/container/gitea-ci-job.Dockerfile | 24 +++++++++++++------ .../shared-memory/decision-log.md | 8 +++++++ .../shared-memory/development-workflow.md | 4 ++-- docs/project-memory/shared-memory/pitfalls.md | 2 ++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 ++-- scripts/gitea-ci-job-image.sh | 2 +- 7 files changed, 35 insertions(+), 15 deletions(-) diff --git a/deploy/container/README.md b/deploy/container/README.md index 0b246dc5e..67c4dded3 100644 --- a/deploy/container/README.md +++ b/deploy/container/README.md @@ -67,13 +67,13 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht ```bash bash scripts/gitea-ci-job-image.sh build bash scripts/gitea-ci-job-image.sh verify -bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/genarrative-gitea-project-ci-20260722.2.tar.zst +bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/genarrative-gitea-project-ci-20260723.1.tar.zst bash scripts/gitea-ci-job-image.sh load-runner ``` -默认构建 tag 为 `genarrative/gitea-project-ci:20260722.2`。脚本通过 NUL 分隔白名单 tar 流只发送 Dockerfile、checkout 脚本、npm manifests/lock 和 Cargo manifests/lock;当前构建 context 约 `1.638 MB`,不会把业务源码、素材或本地私密文件发送给 Docker daemon。镜像除固定工具链外,还按当前根 `package-lock.json`、`server-rs/Cargo.lock` 和桌面壳 `Cargo.lock` 预热 npm / Cargo 下载缓存,并在构建阶段执行离线完整性校验;不包含 `node_modules` 或 Cargo `target`。`build` 完成后会自动运行环境校验,`load-runner` 还会比对宿主和 runner 内层的完整 Image ID,并在内层执行 bwrap 与 Chrome headless canary。当前验证镜像约 `1.788 GB`,完整 Image ID 为 `sha256:548431a2529d325b5ab546f242799a4076f979779ee832a0871ac1af881a4946`。执行这些命令不要求必须使用 root,但执行账号必须有权访问宿主 Docker API 并管理 runner 容器;没有该权限时交给 runner 运维人员执行。 +默认构建 tag 为 `genarrative/gitea-project-ci:20260723.1`。脚本通过 NUL 分隔白名单 tar 流只发送 Dockerfile、checkout 脚本、npm manifests/lock 和 Cargo manifests/lock;当前构建 context 约 `1.638 MB`,不会把业务源码、素材或本地私密文件发送给 Docker daemon。镜像除固定工具链外,还按当前根 `package-lock.json`、`server-rs/Cargo.lock` 和桌面壳 `Cargo.lock` 预热 npm / Cargo 下载缓存;两个 `cargo fetch --locked` 最多执行 5 次整命令级有界重试,再以断网 `cargo fetch --locked` 验证缓存闭合,不包含 `node_modules` 或 Cargo `target`。`build` 完成后会自动运行环境校验,`load-runner` 还会比对宿主和 runner 内层的完整 Image ID,并在内层执行 bwrap 与 Chrome headless canary。当前验证镜像约 `1.788 GB`,完整 Image ID 为 `sha256:c04b114b1f145072c9df7842c4c974e1bb2eaaf391d95d84c9212a460546b7d5`。执行这些命令不要求必须使用 root,但执行账号必须有权访问宿主 Docker API 并管理 runner 容器;没有该权限时交给 runner 运维人员执行。 -runner 配置保留原 `ubuntu-latest` 映射,另外增加 `genarrative-ci:docker://sha256:548431a2529d325b5ab546f242799a4076f979779ee832a0871ac1af881a4946`。内层 Docker 数据必须持久化,`force_pull` 保持 `false`;该精确 Image ID 在内层不存在时 job 应直接失败,不回退到浮动 tag 或现场拉取。四个 job 使用镜像内 `genarrative-gitea-checkout` 直接从当前 Gitea 拉取事件 commit,带 5 次有界重试,不再运行时下载 GitHub checkout action;随后以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,同时校验工具链、缓存锁命中状态、bwrap 和 Chrome headless。它们仍各自运行干净的 `npm ci` 以校验当前 lockfile 并隔离 PR 依赖,但使用镜像内 npm cache 和 `prefer-offline`;锁文件新增依赖时允许经受控网络补齐,本阶段不启用共享 Actions cache。 +runner 配置保留原 `ubuntu-latest` 映射,另外增加 `genarrative-ci:docker://sha256:c04b114b1f145072c9df7842c4c974e1bb2eaaf391d95d84c9212a460546b7d5`。内层 Docker 数据必须持久化,`force_pull` 保持 `false`;该精确 Image ID 在内层不存在时 job 应直接失败,不回退到浮动 tag 或现场拉取。四个 job 使用镜像内 `genarrative-gitea-checkout` 直接从当前 Gitea 拉取事件 commit,带 5 次有界重试,不再运行时下载 GitHub checkout action;随后以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,同时校验工具链、缓存锁命中状态、bwrap 和 Chrome headless。它们仍各自运行干净的 `npm ci` 以校验当前 lockfile 并隔离 PR 依赖,但使用镜像内 npm cache 和 `prefer-offline`;锁文件新增依赖时允许经受控网络补齐,本阶段不启用共享 Actions cache。 更新顺序固定为: diff --git a/deploy/container/gitea-ci-job.Dockerfile b/deploy/container/gitea-ci-job.Dockerfile index 2954c0f3c..fdb050e08 100644 --- a/deploy/container/gitea-ci-job.Dockerfile +++ b/deploy/container/gitea-ci-job.Dockerfile @@ -23,12 +23,22 @@ RUN find /tmp/genarrative-cargo-cache -name Cargo.toml -exec dirname {} \; \ : > "${crate_dir}/src/main.rs"; \ : > "${crate_dir}/src/active.rs"; \ done \ - && cargo fetch --locked \ - --target x86_64-unknown-linux-gnu \ - --manifest-path /tmp/genarrative-cargo-cache/server-rs/Cargo.toml \ - && cargo fetch --locked \ - --target x86_64-unknown-linux-gnu \ - --manifest-path /tmp/genarrative-cargo-cache/desktop-shell/Cargo.toml \ + && cargo_fetch_with_retry() { \ + manifest_path="$1"; \ + for attempt in 1 2 3 4 5; do \ + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path "${manifest_path}"; then \ + return 0; \ + fi; \ + if [ "${attempt}" -eq 5 ]; then \ + return 1; \ + fi; \ + sleep "$((attempt * 5))"; \ + done; \ + } \ + && cargo_fetch_with_retry /tmp/genarrative-cargo-cache/server-rs/Cargo.toml \ + && cargo_fetch_with_retry /tmp/genarrative-cargo-cache/desktop-shell/Cargo.toml \ && CARGO_NET_OFFLINE=true cargo fetch --locked \ --target x86_64-unknown-linux-gnu \ --manifest-path /tmp/genarrative-cargo-cache/server-rs/Cargo.toml \ @@ -46,7 +56,7 @@ ARG GOOGLE_LINUX_SIGNING_KEY_FINGERPRINT=EB4C1BFD4F042F6DDDCCEC917721F63BD38B479 LABEL org.opencontainers.image.title="Genarrative Gitea CI job image" \ org.opencontainers.image.description="Ubuntu 24.04 CI image with fixed toolchains and prewarmed npm/Cargo caches" \ - org.opencontainers.image.version="2026.07.22.2" + org.opencontainers.image.version="2026.07.23.1" RUN test "$(dpkg --print-architecture)" = "amd64" \ && find /etc/apt/sources.list.d -maxdepth 1 -type f \ diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 82b99b8be..c15f60da9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4398,6 +4398,14 @@ - 影响范围:`.gitea/workflows/project-ci.yml`、`deploy/container/gitea-ci-job.Dockerfile`、`scripts/gitea-ci-job-image.sh`、`scripts/check-gitea-ci-job-image.sh`、`scripts/check-gitea-ci-job-runtime.sh`、runner label/config 和 Gitea CI 运维文档。 - 验证方式:构建脚本校验宿主与 runner 内层 Image ID 一致;环境脚本校验 Node、Rust、`rustfmt`、Chrome、bwrap、原生命令与 pkg-config 依赖;runtime 脚本执行完整 bwrap 和 Chrome headless canary;真实 PR 的四个 job 全部通过,同时复核 `Privileged=false`、`Binds=[]`、`MaskedPaths=[]`、`ReadonlyPaths=[]` 和独立网络。 +## 2026-07-23 Gitea CI 镜像刷新到 SpacetimeDB 2.7.0 锁 + +- 背景:`server-rs/Cargo.lock` 已从镜像预热时的 SpacetimeDB 2.6.1 前移到 2.7.0,runtime 校验因此报告 `server_rust_cache_lock=partial`。受控 Cargo egress proxy 连续返回 CONNECT tunnel 502 时,Backend job 在 `check:module-runtime-artifact` 依赖解析阶段失败,尚未进入 workspace tests。 +- 决策:刷新默认镜像 tag 为 `genarrative/gitea-project-ci:20260723.1`,完整 Image ID 为 `sha256:c04b114b1f145072c9df7842c4c974e1bb2eaaf391d95d84c9212a460546b7d5`,并将 Runner `genarrative-ci` label 映射到该精确 ID。镜像内 server-rs lock SHA-256 为 `ab1e07479b5716a98ddab9824bf95664121935aea30ab719f76f0705e1f96bbb`,包含 `spacetimedb`、`spacetimedb-lib` 和 `spacetimedb-sdk` 2.7.0 缓存。 +- 构建边界:两个 `cargo fetch --locked` 在 Cargo 自身网络重试外再执行最多 5 次整命令级重试,处理 registry index / config TLS 握手直接失败;版本解析仍受 lockfile 固定,构建末尾继续以 `CARGO_NET_OFFLINE=true cargo fetch --locked` 证明缓存闭合。 +- 运维边界:镜像归档、SHA-256 sidecar、切换前 Runner config 与注册文件备份只保存到仓库外受控目录。切换前连续确认 Gitea 无 `in_progress` run 且内层 Docker 无容器,切换后等待 rootless Docker socket 恢复,再验证 Image ID、label 注册、bwrap 与 Chrome canary;旧镜像在真实 CI 通过前保留。 +- 验证方式:新镜像在 `--network none` 下按当前锁完成 `cargo fetch --offline`,并完成 `module-runtime`、`platform-auth`、`platform-wechat` 构建;宿主与 runner 内层 Image ID 一致。真实 master CI 还必须确认 cache lock 命中并完成原 Backend workspace tests。 + ## 2026-07-22 陶泥儿精选改为顺序循环分列 Masonry - 背景:CSS multi-column 会按纵向高度平衡卡片,少量素材或活动卡与普通素材高度差较大时,桌面首行会只放一到两张,后续卡片提前从左侧下一段开始,无法满足“每行填满三张再换行”。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 188c7fab9..30e2cadd6 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -269,8 +269,8 @@ npm run check:server-rs-ddd - 四个 job 共同覆盖 `npm run check`,并追加 `npm run check:server-rs-ddd`、`cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --all-targets --manifest-path server-rs/Cargo.toml` 和 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。`ffmpeg` 由预构建 job 镜像提供,避免视频抽帧测试因工具缺失提前返回。`codex/ai-game-creator-app` 分支的原生壳入口还必须覆盖 `npm run ai-game-creator-shell:check` 和 release build smoke。 - checkout 必须使用完整历史。PR 将 base SHA 写入 `SPACETIME_SCHEMA_BASE_REF`,直接推送 `master` 使用 before SHA;事件基线不可解析时直接失败。Gitea 检查的是 PR head 而非预合并 commit,workflow 必须拒绝不包含最新 base commit 的过期 PR,分支保护同时保持“PR 过期禁止合并”。 - 普通 PR job 不读取业务 secret,不运行真实 API/SpacetimeDB/OSS/支付/生成/live smoke,也不执行会修改外部状态的维护、迁移、发布或备份命令。 -- Gitea 至少升级到 `1.26.4` 后才能注册执行 PR job 的 runner。runner 保留 `ubuntu-latest` 固定 digest 映射,并把 workflow 使用的 `genarrative-ci` 映射到 runner 内层 Docker 已装入的完整 Image ID;不映射 host,不向 job 暴露 Docker socket、业务 secret 或不必要内网。当前 CI 镜像约 `1.788 GB`,Image ID 为 `sha256:548431a2529d325b5ab546f242799a4076f979779ee832a0871ac1af881a4946`,标签映射为 `genarrative-ci:docker://sha256:548431a2529d325b5ab546f242799a4076f979779ee832a0871ac1af881a4946`。内层 Docker 数据持久化且 `force_pull: false`,该 ID 缺失时失败关闭,不现场拉取或回退浮动 tag。Runner 2.0.0 支持 job 级 `timeout-minutes`,但 runner 全局 `3h` 仍是硬上限;首次运行成功后,`master` 分支保护必须要求上述四个 job 全部成功。 -- `deploy/container/gitea-ci-job.Dockerfile` 固定 Ubuntu base digest `sha256:58ea92624c7c09582e05594d95488331045053d3a3f34cf09649f2a32313a614`、Rust stage digest `sha256:19817ead3289c8c631c73df281e18b59b172f6a31f4f563290f69cddd06c30e9`、带 SHA-256 校验的 Node `22.23.1`、Google Linux 主签名指纹和 Chrome `150.0.7871.181-1`,预装 Rust 1.96、`rustfmt`、Chrome、`bwrap`、`rg`、`ffmpeg`、`clang/lld` 与 Tauri / 后端系统依赖,并预热根 npm、server-rs 与桌面壳 Cargo 下载缓存。构建脚本使用约 `1.638 MB` 的白名单 tar context,不发送源码、素材或本地私密文件。更新按 `scripts/gitea-ci-job-image.sh build/verify -> export 仓库外镜像归档 -> load-runner -> 确认无活跃 job -> 仓库外备份 config -> 替换 label -> docker restart --timeout 660` 执行;真实 CI 全部通过后才清旧镜像。回滚先把 workflow 改回 `ubuntu-latest`,再恢复 config 备份并重启。备份不进 Git,不在共享文档记录宿主私密路径或注册信息。 +- Gitea 至少升级到 `1.26.4` 后才能注册执行 PR job 的 runner。runner 保留 `ubuntu-latest` 固定 digest 映射,并把 workflow 使用的 `genarrative-ci` 映射到 runner 内层 Docker 已装入的完整 Image ID;不映射 host,不向 job 暴露 Docker socket、业务 secret 或不必要内网。当前 CI 镜像约 `1.788 GB`,Image ID 为 `sha256:c04b114b1f145072c9df7842c4c974e1bb2eaaf391d95d84c9212a460546b7d5`,标签映射为 `genarrative-ci:docker://sha256:c04b114b1f145072c9df7842c4c974e1bb2eaaf391d95d84c9212a460546b7d5`。内层 Docker 数据持久化且 `force_pull: false`,该 ID 缺失时失败关闭,不现场拉取或回退浮动 tag。Runner 2.0.0 支持 job 级 `timeout-minutes`,但 runner 全局 `3h` 仍是硬上限;首次运行成功后,`master` 分支保护必须要求上述四个 job 全部成功。 +- `deploy/container/gitea-ci-job.Dockerfile` 固定 Ubuntu base digest `sha256:58ea92624c7c09582e05594d95488331045053d3a3f34cf09649f2a32313a614`、Rust stage digest `sha256:19817ead3289c8c631c73df281e18b59b172f6a31f4f563290f69cddd06c30e9`、带 SHA-256 校验的 Node `22.23.1`、Google Linux 主签名指纹和 Chrome `150.0.7871.181-1`,预装 Rust 1.96、`rustfmt`、Chrome、`bwrap`、`rg`、`ffmpeg`、`clang/lld` 与 Tauri / 后端系统依赖,并预热根 npm、server-rs 与桌面壳 Cargo 下载缓存。构建脚本使用约 `1.638 MB` 的白名单 tar context,不发送源码、素材或本地私密文件;`cargo fetch --locked` 同时受 Cargo 网络重试和最多 5 次整命令级重试保护,最后必须通过断网 fetch。更新按 `scripts/gitea-ci-job-image.sh build/verify -> export 仓库外镜像归档 -> load-runner -> 确认无活跃 job -> 仓库外备份 config -> 替换 label -> docker restart --timeout 660` 执行;真实 CI 全部通过后才清旧镜像。回滚先把 workflow 改回 `ubuntu-latest`,再恢复 config 备份并重启。备份不进 Git,不在共享文档记录宿主私密路径或注册信息。 - 四个 workflow job 都使用 `runs-on: genarrative-ci`,先用镜像内脚本直接从 Gitea checkout,再以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 运行 `scripts/check-gitea-ci-job-image.sh`,同时校验缓存锁、工具链、完整 bwrap sandbox 和 Chrome headless。workflow 不再包含 GitHub checkout action、apt、setup-node 或 rustup 安装,并设置 `RUSTUP_AUTO_INSTALL=0`;工具链变更时先重建镜像。每个 job 仍各自执行 `npm ci` 以校验 lockfile 和隔离 PR 依赖,但优先复用镜像只读 cache,不烘入 `node_modules`,不挂载跨 PR 可写 Actions cache。`genarrative-station` 继续使用固定 digest 的 Runner `2.0.0-dind-rootless` 修补镜像,真实 job 保持 `MaskedPaths=[]`、`ReadonlyPaths=[]`、`Privileged=false`、`Binds=[]`,`docker_host: "-"` 阻止 socket 进入 job。job 只在 `gitea-actions` internal network,锁文件差量依赖只经拒绝私网、保留地址和 metadata 的 80/443 proxy,直连公网和 Gitea 数据网必须失败;npm 与 Cargo 都设置 10 次网络重试,Cargo 继续关闭 HTTP multiplexing。 ## 后端相关默认验证 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a3eb37fbe..e69a0ac3a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3319,6 +3319,8 @@ - 原因:Gitea Actions job 彼此隔离,上一个 job 在容器内安装的包不会自动进入下一个 job;把同一套不随 PR 变化的工具链写在 workflow step 中,必然每次重做。 - 处理:用 `deploy/container/gitea-ci-job.Dockerfile` 预装 Node 22、Rust 1.96、`rustfmt`、Chrome、`bwrap`、`rg`、`ffmpeg`、`clang/lld` 和 Tauri / 后端系统依赖,并按锁预热根 npm、server-rs 与桌面壳 Cargo 下载缓存。四个 job 统一 `runs-on: genarrative-ci`,先用镜像内脚本直接从 Gitea checkout,再以 runtime 模式运行 `scripts/check-gitea-ci-job-image.sh`,同时检查缓存锁、工具链、完整 bwrap 与 Chrome headless。`RUSTUP_AUTO_INSTALL=0`;`rust-toolchain.toml` 变更时先重建镜像,不把下载 fallback 放回 job。 - 依赖边界:每个 job 仍必须各自执行 `npm ci`,让当前 lockfile 和 PR 依赖在干净环境中验证;区别是命中镜像 cache 时只做本地解包,锁新增依赖时才走受控网络。不要把 `node_modules` 或 Cargo `target` 烘进镜像,也不要向不受信任 PR 挂载跨 job 可写 cache。 +- 锁漂移边界:runtime 校验输出 `server_rust_cache_lock=partial` 说明镜像内 Cargo lock 与当前 checkout 不同,不代表新增 crate 已经缓存。必须在新镜像中以 `--network none` 对当前 lock 执行真实 `cargo fetch/build --offline`;`cargo metadata --no-deps` 不会证明依赖 archive 可用,不能作为替代。 +- 构建网络边界:`CARGO_NET_RETRY` 只覆盖部分 crate 下载,registry `config.json` / index TLS 握手仍可能直接终止整次 fetch。Dockerfile 对每个 `cargo fetch --locked` 再做最多 5 次整命令级有界重试,最终仍执行断网 fetch,不能降低为无锁重试或省略离线闭合验证。 - 验证:workflow 不再出现 GitHub checkout action、apt、setup-node 或 rustup 安装 step;镜像在 `--network none` 下能按当前 npm / Cargo lock 完成依赖准备,四个 job 的环境校验、干净 `npm ci` 和原有测试门禁仍全部执行。 ## Gitea CI 预构建镜像不能只靠 tag 判断内容 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index e2e89ea99..43b7caa38 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -215,14 +215,14 @@ PR checkout 必须保留完整 Git 历史,并把 PR base SHA 传给 `SPACETIME 当前 `genarrative-station` 使用 Gitea `1.26.4` 和基于 Gitea Runner `2.0.0-dind-rootless` 的固定 digest 修补镜像。Runner 2.0.0 会先把 `systempaths=unconfined` 解析为空 `MaskedPaths` / `ReadonlyPaths`,再被 `mergo.WithOverride` 当成 empty value 丢失;站点修补只在 merge 后保留这两个显式空 slice,不改其它 runner 行为。真实 job inspect 必须看到 `MaskedPaths=[]`、`ReadonlyPaths=[]`、`SecurityOpt=[seccomp=unconfined]`、`Privileged=false`、无 CapAdd 且 `Binds=[]`。外层 runner 以 `rootless` 用户运行,`privileged=false`、不增加 `CAP_SYS_ADMIN`,只映射 `/dev/net/tun`,内部 Docker 只监听私有 Unix socket;runner 配置保持 `docker_host: "-"`、`valid_volumes: []`、`bind_workdir: false` 和 `force_pull: false`,防止内部 Docker socket 或宿主 bind mount 进入 job。job 只连接 `gitea-actions` internal network:`genarrative-station` 由只转发 `/git` 到 Gitea 的内部 gateway 解析,公网依赖只经拒绝私网、保留地址和 metadata 的 80/443 egress proxy;绕过 proxy 的公网和 Postgres/Redis 数据网都必须不可达。完整 bwrap canary 需要 rootless DinD 外层的 rootlesskit AppArmor/userns 边界,以及内层 job 的 namespace/proc 挂载支持;相关 `seccomp/systempaths` 放宽只允许存在于这个无宿主 socket 的 rootless DinD 内层,禁止复制回控制宿主 rootful Docker 的 runner。 -CI job 镜像由 `deploy/container/gitea-ci-job.Dockerfile` 定义:Ubuntu job base 固定为 `sha256:58ea92624c7c09582e05594d95488331045053d3a3f34cf09649f2a32313a614`,Rust stage 固定为 `sha256:19817ead3289c8c631c73df281e18b59b172f6a31f4f563290f69cddd06c30e9`,Node `22.23.1` 发行包执行 SHA-256 校验,Google Linux 主签名指纹固定,Chrome 固定为 `150.0.7871.181-1`。构建脚本以 NUL 分隔白名单 tar 流只发送 Dockerfile、checkout 脚本和 npm / Cargo manifests/lock;当前 context 约 `1.638 MB`。镜像按根 npm 锁、server-rs 锁和桌面壳锁预热下载缓存,不包含 `node_modules` 或 Cargo `target`。当前验证镜像约 `1.788 GB`,默认 tag 为 `genarrative/gitea-project-ci:20260722.2`,完整 Image ID 为 `sha256:548431a2529d325b5ab546f242799a4076f979779ee832a0871ac1af881a4946`;runner 标签保留 `ubuntu-latest`,并新增 `genarrative-ci:docker://sha256:548431a2529d325b5ab546f242799a4076f979779ee832a0871ac1af881a4946`。内层 Docker 数据必须持久化;`force_pull: false` 表示只使用这个已装载的精确内容,Image ID 缺失时 job 必须失败关闭,不得回退浮动 tag 或临时连 registry。 +CI job 镜像由 `deploy/container/gitea-ci-job.Dockerfile` 定义:Ubuntu job base 固定为 `sha256:58ea92624c7c09582e05594d95488331045053d3a3f34cf09649f2a32313a614`,Rust stage 固定为 `sha256:19817ead3289c8c631c73df281e18b59b172f6a31f4f563290f69cddd06c30e9`,Node `22.23.1` 发行包执行 SHA-256 校验,Google Linux 主签名指纹固定,Chrome 固定为 `150.0.7871.181-1`。构建脚本以 NUL 分隔白名单 tar 流只发送 Dockerfile、checkout 脚本和 npm / Cargo manifests/lock;当前 context 约 `1.638 MB`。镜像按根 npm 锁、server-rs 锁和桌面壳锁预热下载缓存,不包含 `node_modules` 或 Cargo `target`;两个 `cargo fetch --locked` 在 Cargo 自身重试之外再执行最多 5 次整命令级有界重试,处理 registry index 握手失败,最终仍以断网 `cargo fetch --locked` 关闭验证。当前验证镜像约 `1.788 GB`,默认 tag 为 `genarrative/gitea-project-ci:20260723.1`,完整 Image ID 为 `sha256:c04b114b1f145072c9df7842c4c974e1bb2eaaf391d95d84c9212a460546b7d5`;runner 标签保留 `ubuntu-latest`,并将 `genarrative-ci` 映射到 `docker://sha256:c04b114b1f145072c9df7842c4c974e1bb2eaaf391d95d84c9212a460546b7d5`。内层 Docker 数据必须持久化;`force_pull: false` 表示只使用这个已装载的精确内容,Image ID 缺失时 job 必须失败关闭,不得回退浮动 tag 或临时连 registry。 镜像更新命令: ```bash bash scripts/gitea-ci-job-image.sh build bash scripts/gitea-ci-job-image.sh verify -bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/genarrative-gitea-project-ci-20260722.2.tar.zst +bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/genarrative-gitea-project-ci-20260723.1.tar.zst bash scripts/gitea-ci-job-image.sh load-runner ``` diff --git a/scripts/gitea-ci-job-image.sh b/scripts/gitea-ci-job-image.sh index 2f361f4b1..bda4584e8 100644 --- a/scripts/gitea-ci-job-image.sh +++ b/scripts/gitea-ci-job-image.sh @@ -4,7 +4,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" dockerfile_context_path="deploy/container/gitea-ci-job.Dockerfile" -image_tag="${GENARRATIVE_GITEA_CI_IMAGE_TAG:-genarrative/gitea-project-ci:20260722.2}" +image_tag="${GENARRATIVE_GITEA_CI_IMAGE_TAG:-genarrative/gitea-project-ci:20260723.1}" runner_container="${GENARRATIVE_GITEA_RUNNER_CONTAINER:-gitea-runner}" write_build_context_file_list() {