试点启用隔离 Rust 编译缓存并防止镜像叠层
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m23s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m23s
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled

仅在 AGC Rust lane 1 消费可信缓存快照,保持独立 target 和关闭增量编译
从远端 master 的确定提交构建快照,拒绝以已有对象缓存的镜像继续叠层
隔离缓存配置与进程状态,缓存故障回退真实编译并保留失败状态
增加命中统计、编译耗时和缓存行为验证
同步缓存来源、镜像保留及不中断运行中 CI 的运维约定
This commit is contained in:
2026-09-22 04:24:58 +00:00
parent aeeb49f259
commit 5e57fdff62
11 changed files with 496 additions and 0 deletions
+11
View File
@@ -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
@@ -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(
+21
View File
@@ -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
@@ -96,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=<i>` 只跑自己那片,片内保持 `--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 镜像缓存,再重跑门禁。
@@ -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` 这类标记,**整行**就被换成 `<sensitive diagnostic details redacted>`,只留下时间戳与 `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 包含编译 cwdsccache `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 使用的镜像。
@@ -332,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 <base> <head>` 后回读 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` 启用 sccachelane 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
+85
View File
@@ -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 <verified-base-image> <candidate-tag>}"
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" <<EOF
FROM ${base_id}
COPY snapshot/ /opt/genarrative-ci/rust-cache/
LABEL world.genarrative.ci.rust-cache-source="${source_commit}"
LABEL world.genarrative.ci.rust-cache-base="${base_id}"
EOF
printf '**\n!Dockerfile\n!snapshot/\n!snapshot/**\n' > "${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.'
+13
View File
@@ -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"
+86
View File
@@ -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
+217
View File
@@ -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);
});
+32
View File
@@ -16,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',
@@ -138,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,