From 5d045d532f3a57f9c7e21ccfce0685d9d1905880 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 21 Jul 2026 21:56:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=9A=94=E7=A6=BBCI=E7=9A=84?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E6=B2=99=E7=AE=B1=E7=8E=AF=E5=A2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 rustup 引导和工具链安装增加有界重试 显式安装并预检 bubblewrap 沙箱能力 将 Node 与 Rust 工具暴露到受信任系统路径 记录 Runner systempaths 空切片修补与验收口径 --- .gitea/workflows/project-ci.yml | 125 ++++++++++++++++-- .../shared-memory/development-workflow.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 6 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- 4 files changed, 121 insertions(+), 16 deletions(-) diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index a362e6eef..e36490c9a 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -17,6 +17,7 @@ env: CARGO_HTTP_MULTIPLEXING: 'false' CARGO_NET_RETRY: '10' CARGO_TERM_COLOR: always + RUSTUP_MAX_RETRIES: '10' RUSTC_WRAPPER: '' CARGO_BUILD_RUSTC_WRAPPER: '' @@ -86,14 +87,33 @@ jobs: run: | set -euo pipefail if ! command -v rustup >/dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --profile minimal --default-toolchain none + for attempt in $(seq 1 10); do + if curl --retry 3 --retry-all-errors --retry-delay 2 \ + --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain none; then + break + fi + if [[ "${attempt}" -eq 10 ]]; then + echo 'rustup bootstrap failed after 10 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done fi echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}" export PATH="${HOME}/.cargo/bin:${PATH}" toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" test -n "${toolchain}" - rustup toolchain install "${toolchain}" --profile minimal --component rustfmt + for attempt in $(seq 1 10); do + if rustup toolchain install "${toolchain}" --profile minimal --component rustfmt; then + break + fi + if [[ "${attempt}" -eq 10 ]]; then + echo 'Rust toolchain installation failed after 10 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done rustc --version cargo --version rustfmt --version @@ -216,14 +236,33 @@ jobs: run: | set -euo pipefail if ! command -v rustup >/dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --profile minimal --default-toolchain none + for attempt in $(seq 1 10); do + if curl --retry 3 --retry-all-errors --retry-delay 2 \ + --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain none; then + break + fi + if [[ "${attempt}" -eq 10 ]]; then + echo 'rustup bootstrap failed after 10 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done fi echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}" export PATH="${HOME}/.cargo/bin:${PATH}" toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" test -n "${toolchain}" - rustup toolchain install "${toolchain}" --profile minimal --component rustfmt + for attempt in $(seq 1 10); do + if rustup toolchain install "${toolchain}" --profile minimal --component rustfmt; then + break + fi + if [[ "${attempt}" -eq 10 ]]; then + echo 'Rust toolchain installation failed after 10 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done rustc --version cargo --version rustfmt --version @@ -272,6 +311,7 @@ jobs: "${sudo_command[@]}" apt-get update "${sudo_command[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ build-essential \ + bubblewrap \ ca-certificates \ clang \ cmake \ @@ -286,6 +326,18 @@ jobs: patchelf \ pkg-config \ wget + bwrap --die-with-parent \ + --unshare-all \ + --unshare-user \ + --disable-userns \ + --assert-userns-disabled \ + --cap-drop ALL \ + --clearenv \ + --ro-bind /usr /usr \ + --proc /proc \ + --dev /dev \ + --tmpfs /tmp \ + -- /usr/bin/true - name: Set up Node.js 22 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -300,6 +352,8 @@ jobs: npm_path="$(command -v npm)" test -x "${node_path}" test -x "${npm_path}" + node_root="$(cd "$(dirname "${node_path}")/.." && pwd -P)" + test -d "${node_root}/lib/node_modules/npm" sudo_command=() if [[ "$(id -u)" -ne 0 ]]; then command -v sudo >/dev/null 2>&1 || { @@ -308,8 +362,11 @@ jobs: } sudo_command=(sudo -E) fi - "${sudo_command[@]}" ln -sfn "${node_path}" /usr/local/bin/node - "${sudo_command[@]}" ln -sfn "${npm_path}" /usr/local/bin/npm + trusted_node_root='/usr/local/lib/genarrative-node' + "${sudo_command[@]}" install -d -m 0755 "${trusted_node_root}" + "${sudo_command[@]}" cp -a "${node_root}/." "${trusted_node_root}/" + "${sudo_command[@]}" ln -sfn "${trusted_node_root}/bin/node" /usr/local/bin/node + "${sudo_command[@]}" ln -sfn "${trusted_node_root}/bin/npm" /usr/local/bin/npm /usr/local/bin/node --version /usr/local/bin/npm --version @@ -318,18 +375,64 @@ jobs: run: | set -euo pipefail if ! command -v rustup >/dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --profile minimal --default-toolchain none + for attempt in $(seq 1 10); do + if curl --retry 3 --retry-all-errors --retry-delay 2 \ + --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain none; then + break + fi + if [[ "${attempt}" -eq 10 ]]; then + echo 'rustup bootstrap failed after 10 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done fi echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}" export PATH="${HOME}/.cargo/bin:${PATH}" toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" test -n "${toolchain}" - rustup toolchain install "${toolchain}" --profile minimal --component rustfmt + for attempt in $(seq 1 10); do + if rustup toolchain install "${toolchain}" --profile minimal --component rustfmt; then + break + fi + if [[ "${attempt}" -eq 10 ]]; then + echo 'Rust toolchain installation failed after 10 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done rustc --version cargo --version rustfmt --version + - name: Expose trusted Rust command paths + shell: bash + run: | + set -euo pipefail + rustup_path="$(command -v rustup)" + test -x "${rustup_path}" + rustup_home="$(rustup show home)" + test -d "${rustup_home}" + sudo_command=() + if [[ "$(id -u)" -ne 0 ]]; then + command -v sudo >/dev/null 2>&1 || { + echo 'non-root runner user requires sudo to expose trusted Rust paths.' >&2 + exit 1 + } + sudo_command=(sudo -E) + fi + if [[ "$(readlink -f "${rustup_path}")" != '/usr/local/bin/rustup' ]]; then + "${sudo_command[@]}" install -m 0755 "${rustup_path}" /usr/local/bin/rustup + fi + for command_name in cargo rustc rustdoc rustfmt; do + "${sudo_command[@]}" ln -sfn rustup "/usr/local/bin/${command_name}" + done + echo "RUSTUP_HOME=${rustup_home}" >> "${GITHUB_ENV}" + /usr/local/bin/cargo --version + /usr/local/bin/rustc --version + /usr/local/bin/rustfmt --version + - name: Install npm dependencies run: npm ci diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 506032c0c..b3e0893c1 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -458,7 +458,7 @@ npm run check:server-rs-ddd - 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;`ubuntu-latest` 标签只映射到固定 digest 的 Ubuntu 24.04 级 Docker/临时隔离镜像,不使用浮动镜像 tag,不映射 host,不向 job 暴露 Docker socket、业务 secret 或不必要内网。runner 能访问 Gitea、GitHub Actions 与 `actions/node-versions`、nodejs.org、npm、Rust 分发和 crates.io;workflow 的官方 action 固定完整 commit,若内网禁用 GitHub,先在当前 Gitea 镜像对应 commit 并改用绝对 URL。受控镜像优先预装 rustup。Gitea 1.26 的任务超时由 runner 全局配置控制;首次运行成功后,`master` 分支保护必须要求上述四个 job 全部成功。 -- `genarrative-station` 当前使用 Gitea `1.26.4` + Runner `2.0.0-dind-rootless`:runner/job image 固定 digest,外层非 privileged、无 `CAP_SYS_ADMIN`,内部 Docker 只监听 Unix socket,`docker_host: "-"` 阻止 socket进入 job。job 只在 `gitea-actions` internal network,通过 `/git` reverse gateway访问 Gitea,通过拒绝私网/保留地址/metadata 的 80/443 proxy访问公共依赖;直连公网和 Gitea 数据网必须失败。内层 bwrap 所需 namespace/proc 选项只能用于该 rootless DinD,不能放宽宿主 rootful runner。系统依赖步骤在 root job 中不调用 sudo,非 root 时用 `sudo -E` 保留受控 proxy;Cargo 关闭 HTTP multiplexing并设置 10 次网络重试。宿主 compose helper 必须把 `/opt/gitea-stack` 挂到同名绝对路径,避免相对 volume错误落到 `/stack` 空目录。 +- `genarrative-station` 当前使用 Gitea `1.26.4` + 基于 Runner `2.0.0-dind-rootless` 的固定 digest 修补镜像:只修复 `systempaths=unconfined` 的空 slice 被 `mergo` 丢失,真实 job 必须保持 `MaskedPaths=[]`、`ReadonlyPaths=[]`;外层仍非 privileged、无 `CAP_SYS_ADMIN`,内部 Docker 只监听 Unix socket,`docker_host: "-"` 阻止 socket 进入 job。job 只在 `gitea-actions` internal network,通过 `/git` reverse gateway 访问 Gitea,通过拒绝私网、保留地址和 metadata 的 80/443 proxy 访问公共依赖;直连公网和 Gitea 数据网必须失败。内层 bwrap 所需 namespace/proc 选项只能用于该 rootless DinD,不能放宽宿主 rootful runner。系统依赖步骤在 root job 中不调用 sudo,非 root 时用 `sudo -E` 保留受控 proxy;Cargo 关闭 HTTP multiplexing 并设置 10 次网络重试,rustup bootstrap/toolchain 安装也按有界次数重试。AI 原生壳 job 把 Node 发行目录与 rustup proxy 安装到 `/usr/local` 的受信任只读路径,并在测试前执行完整 bwrap canary。宿主 compose helper 必须把 `/opt/gitea-stack` 挂到同名绝对路径,避免相对 volume 错误落到 `/stack` 空目录。 ## 后端相关默认验证 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 969adf6d3..68917a015 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3479,8 +3479,8 @@ ## Gitea PR runner 不能把宿主 Docker socket 当普通 volume - 现象:`act_runner` 的 `container.docker_host` 留空时,job inspect 会出现 `/var/run/docker.sock:/var/run/docker.sock`;PR 脚本即使 `valid_volumes: []`,仍可直接调用 Docker API读取其它容器、挂宿主目录或取得 runner 配置。另一类迁移故障是 rootless daemon可以启动,但 bwrap 在 job 内报 `No permissions to create new namespace`、`Failed to make / slave` 或 `Mount too revealing`。 -- 原因:空 `docker_host` 会自动发现并把控制 socket传播到 job;rootful Docker 的 seccomp、AppArmor 与 system-path masks 又不支持完整 nested bwrap。直接使用 `--privileged`、外层 `CAP_SYS_ADMIN` 或 host executor 会把测试跑绿建立在破坏 PR 隔离的前提上。宿主启用 Clash fake-IP 时,简单按 DNS 的 `198.18.0.0/15` 判断公网还会误拒所有公共依赖。 -- 处理:先把 Gitea 升到至少 `1.26.4`,再用固定 digest 的 Runner 2.0.0 rootless DinD;外层保持非 privileged、无 `CAP_SYS_ADMIN`,内部 Docker 仅 Unix socket,runner 固定 `docker_host: "-"`。job 使用独立 internal network,Gitea 经 reverse gateway,公共 80/443 经使用公共 DoH 验证真实 IP、拒绝私网/保留地址/metadata 的 proxy;DoH 要缓存并合并同域并发,长下载 timeout 不能只有 60 秒。rootless job 内的 bwrap namespace/proc 选项不能复制到宿主 rootful runner。apt 步骤在 root 时直接执行,非 root 时用 `sudo -E`,否则 sudo `env_reset` 会让 apt 丢失 proxy。 +- 原因:空 `docker_host` 会自动发现并把控制 socket 传播到 job;rootful Docker 的 seccomp、AppArmor 与 system-path masks 又不支持完整 nested bwrap。Runner 2.0.0 还有一处独立合并缺陷:`parseSystemPaths` 把 `systempaths=unconfined` 转成显式空 slice 后,`mergo.WithOverride` 不覆盖 empty value,真实 job 又恢复 Docker 默认 masks,表现为配置文件写了 unconfined、手工 `docker run` canary 也成功,但 Actions job 仍在 `--proc /proc` 返回 EPERM。直接使用 `--privileged`、外层 `CAP_SYS_ADMIN` 或 host executor 会把测试跑绿建立在破坏 PR 隔离的前提上。宿主启用 Clash fake-IP 时,简单按 DNS 的 `198.18.0.0/15` 判断公网还会误拒所有公共依赖。 +- 处理:先把 Gitea 升到至少 `1.26.4`,再用固定 digest 的 Runner 2.0.0 rootless DinD;外层保持非 privileged、无 `CAP_SYS_ADMIN`,内部 Docker 仅 Unix socket,runner 固定 `docker_host: "-"`。若版本仍有上述 empty-slice 缺陷,只做 merge 后保留 `MaskedPaths=[]` / `ReadonlyPaths=[]` 的最小补丁并固定自有镜像 digest,不对整个 HostConfig 启用 overwrite-empty。job 使用独立 internal network,Gitea 经 reverse gateway,公共 80/443 经使用公共 DoH 验证真实 IP、拒绝私网/保留地址/metadata 的 proxy;DoH 要缓存并合并同域并发,长下载 timeout 不能只有 60 秒。rootless job 内的 bwrap namespace/proc 选项不能复制到宿主 rootful runner。apt 步骤在 root 时直接执行,非 root 时用 `sudo -E`,否则 sudo `env_reset` 会让 apt 丢失 proxy。 - 运维陷阱:从容器内运行 Compose 时,宿主 `/opt/gitea-stack` 必须挂到容器同名绝对路径;挂成 `/stack` 会让相对 bind source 被 daemon解析为宿主 `/stack/...`,表现为 Gitea进入空安装页、gateway 脚本“缺失”。发现后不要迁移空库,立即用同路径 mount 重建并核对原数据大小、installed 日志、仓库数和 API。切换前保留冷数据 tar、pg_dumpall 和原 compose/env/runner 配置,备份与 token 不提交 Git。 -- 验证:同时检查 Gitea 版本、runner declare、外层 `Privileged=false`/无 CapAdd/无宿主 socket、inner job `Binds=[]`、固定 image digest、`/var/run/docker.sock` 不存在、公共 proxy可用、直连公网/Postgres/metadata 失败,以及完整 bwrap canary。最后重跑四个 CI job;checkout成功但 apt/rustup/npm 同时失败时,先排 proxy/env,而不是改测试。 +- 验证:同时检查 Gitea 版本、runner declare、外层 `Privileged=false`/无 CapAdd/无宿主 socket、inner job `Binds=[]`、`MaskedPaths=[]`、`ReadonlyPaths=[]`、固定 image digest、`/var/run/docker.sock` 不存在、公共 proxy 可用、直连公网/Postgres/metadata 失败,以及完整 bwrap canary。最后重跑四个 CI job;checkout 成功但 apt/rustup/npm 同时失败时,先排 proxy/env,而不是改测试。 - 关联:`.gitea/workflows/project-ci.yml`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/project-memory/shared-memory/development-workflow.md`。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ebc0a881f..d6e52bbb6 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -213,7 +213,9 @@ PR checkout 必须保留完整 Git 历史,并把 PR base SHA 传给 `SPACETIME 启用或注册执行 PR job 的 runner 前,Gitea 服务端必须至少升级到 `1.26.4`;不得在 `1.26.2` 上执行不受信任 PR 代码。runner 必须提供 `ubuntu-latest` 标签,并将其映射到经验证且固定 digest 的 Ubuntu 24.04 级 Docker/临时隔离镜像;禁止使用浮动镜像 tag,禁止将该标签映射到 host 执行器,禁止向 job 暴露 Docker socket、业务环境变量、业务密钥或不必要的内网。workflow 会安装 Node 22、仓库 `rust-toolchain.toml` 固定的 Rust 1.96.0,以及 clang/lld 和 Tauri Linux 依赖;受控 runner 镜像应预装 rustup,fallback 下载只用于首次引导。runner 仍需能访问 Gitea、GitHub Actions 与 `actions/node-versions`、nodejs.org、npm registry、Rust 分发和 crates.io。workflow 中的 `actions/checkout` / `actions/setup-node` 固定到完整 commit;内网 runner 不允许访问 GitHub 时,先把对应 commit 镜像到当前 Gitea 并把 workflow 改为绝对 action URL。首版不使用 Actions cache,避免未配置 runner cache 网络时把缓存恢复错误变成 PR 失败。当前 Runner 2.0.0 已支持 job 级 `timeout-minutes`,但 runner 全局 `3h` 仍是所有任务的硬上限;若 workflow 以后新增更短 timeout,不能删除全局兜底。 -当前 `genarrative-station` 已于 2026-07-21 升级到 Gitea `1.26.4`,并使用固定 digest 的 Gitea Runner `2.0.0-dind-rootless` 与固定 digest 的 Ubuntu 24.04 job image。外层 runner 以 `rootless` 用户运行,`privileged=false`、不增加 `CAP_SYS_ADMIN`,只映射 `/dev/net/tun`,内部 Docker 只监听私有 Unix socket;runner 配置必须保持 `docker_host: "-"`、`valid_volumes: []` 和 `bind_workdir: 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。workflow 的系统依赖步骤在 UID 0 时不再调用 sudo;非 root runner 必须使用 `sudo -E` 保留受控 proxy 环境,避免 `sudo` 的 `env_reset` 让 apt 绕过 gateway。Cargo 通过 proxy 下载 sparse index/crate 时固定关闭 HTTP multiplexing 并设置 `CARGO_NET_RETRY=10`,降低代理链瞬时 TLS EOF 对后端测试的影响,但不能用重试掩盖持续不可达。 +当前 `genarrative-station` 已于 2026-07-21 升级到 Gitea `1.26.4`,并使用基于 Gitea Runner `2.0.0-dind-rootless` 的固定 digest 修补镜像与固定 digest 的 Ubuntu 24.04 job image。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`,防止内部 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。 + +workflow 的系统依赖步骤在 UID 0 时不再调用 sudo;非 root runner 必须使用 `sudo -E` 保留受控 proxy 环境,避免 `sudo` 的 `env_reset` 让 apt 绕过 gateway。Cargo 通过 proxy 下载 sparse index/crate 时固定关闭 HTTP multiplexing 并设置 `CARGO_NET_RETRY=10`;rustup bootstrap 与 toolchain 安装也执行有界重试,降低代理链瞬时 TLS EOF 对后端测试的影响,但不能用重试掩盖持续不可达。AI 原生壳 job 不能把 `$HOME/.cargo/bin` 或 setup-node toolcache 直接加入 `command.exec` 的受信任 PATH;应把完整 Node 发行目录复制到 `/usr/local/lib`,把 root-owned rustup proxy 安装到 `/usr/local/bin`,并通过 `RUSTUP_HOME` 只读挂载工具链。测试前先运行与应用一致的完整 bwrap canary,失败时停止测试,不允许跳过 sandbox 用例。 站点 stack 仍由宿主 `/opt/gitea-stack` 管理,`.env`、runner 注册文件和数据库凭据不进入仓库。Compose 必须在 helper/container 内把该目录挂到同一个绝对路径再执行;若挂成 `/stack`,相对 bind source 会被 Docker daemon 误解析为宿主 `/stack/...` 并启动空数据目录。升级或 runner 迁移前先停止 Gitea 写入并保留 `data/gitea` 冷快照、`pg_dumpall`、compose/.env 与 runner config/.runner;本次可回滚快照位于 `backups/gitea-ci-migration-20260721-211018`。备份文件、绝对宿主配置和注册 token 不得提交 Git。