合并最新 master 分支

带入 Gitea CI 依赖缓存镜像刷新(SpacetimeDB 2.7.0 锁)与 LLM 原文日志
并行测试污染修复。冲突仅 decision-log 双方条目并存;BgFilter 分支代码
零冲突,bgfilter 46 测试全过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 08:28:49 +00:00
10 changed files with 83 additions and 47 deletions
+3 -3
View File
@@ -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。
更新顺序固定为:
+17 -7
View File
@@ -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 \
@@ -4444,6 +4444,14 @@
- 影响范围:`scripts/deploy/production-api-deploy.sh``scripts/check-production-api-deploy.mjs``jenkins/Jenkinsfile.production-api-deploy``jenkins/Jenkinsfile.production-full-build-and-deploy``scripts/check-production-ops-guardrails.mjs`、生产运维文档和 worker systemd 发布契约。
- 验证方式:`bash -n scripts/deploy/production-api-deploy.sh``node --check scripts/check-production-api-deploy.mjs``npm run check:production-api-deploy``npm run check:production-ops``npm run check:encoding``git diff --check`
## 2026-07-23 Gitea CI 镜像刷新到 SpacetimeDB 2.7.0 锁
- 背景:`server-rs/Cargo.lock` 已从镜像预热时的 SpacetimeDB 2.6.1 前移到 2.7.0runtime 校验因此报告 `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 会按纵向高度平衡卡片,少量素材或活动卡与普通素材高度差较大时,桌面首行会只放一到两张,后续卡片提前从左侧下一段开始,无法满足“每行填满三张再换行”。
@@ -276,8 +276,8 @@ npm run check:server-rs-ddd
- 四个 job 共同覆盖 `npm run check`,并追加 `npm run bgfilter-worker:smoke-test``npm run check:production-health-patrol``npm run check:production-api-release``npm run check:production-api-deploy``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`。BgFilter 的 `.test.mjs` 使用 Node test runner,必须由 workflow 显式调用;生产巡检 / 发布 / 部署行为检查只运行无密钥临时 fixture,不连接现场环境。`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 而非预合并 commitworkflow 必须拒绝不包含最新 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。
## 后端相关默认验证
@@ -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<Mutex<()>>` 时才使用全局锁。
- 验证:先精确运行目标用例,再以默认并行度重复运行完整 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('画布图片:...')` 偶发或稳定找不到图片;前一张图可能通过,紧接着添加的第二张失败。
@@ -3319,6 +3327,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 判断内容
@@ -225,14 +225,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 socketrunner 配置保持 `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
```
+1 -1
View File
@@ -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() {
@@ -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");
}
@@ -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(
+23 -14
View File
@@ -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<PathBuf>) -> Self {
self.raw_log_dir = raw_log_dir.into();
self
}
pub fn ark_default(api_key: String, model: String) -> Result<Self, LlmError> {
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<MockResponse>) -> String {