From f4f7d8ac20ef54f84d0c3bc29273a5086a09c56f Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 22 Jul 2026 15:44:40 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A2=84=E7=83=ADGitea=20CI=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将npm与Rust依赖下载缓存预热到固定CI镜像 改为直接从Gitea checkout并增加网络重试 收窄镜像构建上下文并补齐离线与运行时校验 同步Runner切换边界和运维文档 --- .gitea/workflows/project-ci.yml | 46 +++++---- deploy/container/README.md | 8 +- deploy/container/gitea-ci-checkout.sh | 95 +++++++++++++++++++ deploy/container/gitea-ci-job.Dockerfile | 85 ++++++++++++++++- .../gitea-ci-job.Dockerfile.dockerignore | 18 ++++ .../shared-memory/decision-log.md | 6 +- .../shared-memory/development-workflow.md | 6 +- docs/project-memory/shared-memory/pitfalls.md | 7 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 10 +- scripts/check-gitea-ci-job-image.sh | 51 ++++++++++ scripts/gitea-ci-job-image.sh | 67 ++++++++++--- 11 files changed, 348 insertions(+), 51 deletions(-) create mode 100644 deploy/container/gitea-ci-checkout.sh create mode 100644 deploy/container/gitea-ci-job.Dockerfile.dockerignore diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 3ea01b5f9..b728bda03 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -17,6 +17,13 @@ env: CARGO_HTTP_MULTIPLEXING: 'false' CARGO_NET_RETRY: '10' CARGO_TERM_COLOR: always + NPM_CONFIG_AUDIT: 'false' + NPM_CONFIG_FETCH_RETRIES: '10' + NPM_CONFIG_FETCH_RETRY_FACTOR: '2' + NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: '60000' + NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: '2000' + NPM_CONFIG_FUND: 'false' + NPM_CONFIG_PREFER_OFFLINE: 'true' RUSTUP_AUTO_INSTALL: '0' RUSTC_WRAPPER: '' CARGO_BUILD_RUSTC_WRAPPER: '' @@ -26,11 +33,11 @@ jobs: name: Repository checks runs-on: genarrative-ci steps: - - name: Checkout full history - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - fetch-depth: 0 - persist-credentials: false + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh @@ -84,10 +91,11 @@ jobs: name: Frontend tests runs-on: genarrative-ci steps: - - name: Checkout source - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - persist-credentials: false + - name: Checkout source from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '1' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh @@ -102,11 +110,11 @@ jobs: name: Backend tests runs-on: genarrative-ci steps: - - name: Checkout full history - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - fetch-depth: 0 - persist-credentials: false + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh @@ -154,11 +162,11 @@ jobs: name: Native shell tests runs-on: genarrative-ci steps: - - name: Checkout full history - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - fetch-depth: 0 - persist-credentials: false + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh diff --git a/deploy/container/README.md b/deploy/container/README.md index a447b6bb2..6517b9ab6 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.1.tar.zst +bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/genarrative-gitea-project-ci-20260722.2.tar.zst bash scripts/gitea-ci-job-image.sh load-runner ``` -默认构建 tag 为 `genarrative/gitea-project-ci:20260722.1`。脚本将 build context 固定为 `deploy/container`,不会把整个工作区发送给 Docker daemon;`build` 完成后会自动运行环境校验,`load-runner` 还会比对宿主和 runner 内层的完整 Image ID,并在内层执行 bwrap 与 Chrome headless canary。当前验证镜像约 `1.416 GB`,完整 Image ID 为 `sha256:62304a788905cec805bf0ae3638c2811bf530b2f5709523277d816edb4772942`。执行这些命令不要求必须使用 root,但执行账号必须有权访问宿主 Docker API 并管理 runner 容器;没有该权限时交给 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 运维人员执行。 -runner 配置保留原 `ubuntu-latest` 映射,另外增加 `genarrative-ci:docker://sha256:62304a788905cec805bf0ae3638c2811bf530b2f5709523277d816edb4772942`。内层 Docker 数据必须持久化,`force_pull` 保持 `false`;该精确 Image ID 在内层不存在时 job 应直接失败,不回退到浮动 tag 或现场拉取。四个 job 开头均以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,同时验工具链、bwrap 和 Chrome headless;它们仍各自运行 `npm ci` 以校验当前 lockfile 并隔离 PR 依赖,不把 `node_modules` 烘进镜像,本阶段也不启用 Actions cache。 +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。 更新顺序固定为: @@ -83,6 +83,8 @@ runner 配置保留原 `ubuntu-latest` 映射,另外增加 `genarrative-ci:doc 4. 增加或替换 `genarrative-ci` 的精确 `docker://` 映射,然后执行 `docker restart --timeout 660 gitea-runner`。 5. 重跑真实 PR 的四个 CI job;全部通过且隔离边界复核完成后,才能清理旧镜像。 +`docker restart --timeout 660` 只提供容器停止宽限,不是 Runner drain API;rootless DinD 的 supervisor 可能与 runner 同时停止内层 dockerd。重启前必须同时确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空,不能依赖该 timeout 等待活跃 job。 + 回滚时先把 workflow 的 `runs-on` 改回 `ubuntu-latest`,再恢复备份的 runner config 并用同一超时重启 runner。不要在真实 CI 验证前删除旧映射或旧镜像。 ## 启动与验证 diff --git a/deploy/container/gitea-ci-checkout.sh b/deploy/container/gitea-ci-checkout.sh new file mode 100644 index 000000000..9e45be938 --- /dev/null +++ b/deploy/container/gitea-ci-checkout.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +set -euo pipefail + +workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" +repository="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +server_url="${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}" +event_path="${GITHUB_EVENT_PATH:?GITHUB_EVENT_PATH is required}" +fetch_depth="${GENARRATIVE_GITEA_FETCH_DEPTH:-1}" +repository_url="${GENARRATIVE_GITEA_REPOSITORY_URL:-${server_url%/}/${repository}.git}" + +if [[ ! "${fetch_depth}" =~ ^[0-9]+$ ]]; then + echo "GENARRATIVE_GITEA_FETCH_DEPTH must be a non-negative integer: ${fetch_depth}" >&2 + exit 2 +fi +if [[ -z "${workspace}" || "${workspace}" == '/' ]]; then + echo "refusing to clean unsafe workspace: ${workspace}" >&2 + exit 2 +fi + +readarray -t event_checkout < <( + node - "${event_path}" <<'NODE' +const fs = require('node:fs'); + +const event = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +const targetSha = event.pull_request?.head?.sha ?? event.after ?? process.env.GITHUB_SHA ?? ''; +const baseRef = event.pull_request?.base?.ref ?? process.env.GITHUB_BASE_REF ?? 'master'; +process.stdout.write(`${targetSha}\n${baseRef}\n`); +NODE +) +target_sha="${event_checkout[0]:-}" +base_ref="${event_checkout[1]:-master}" + +if [[ ! "${target_sha}" =~ ^[0-9a-fA-F]{40,64}$ ]]; then + echo "event did not provide a valid checkout commit: ${target_sha}" >&2 + exit 1 +fi +git check-ref-format --branch "${base_ref}" >/dev/null + +mkdir -p "${workspace}" +find "${workspace}" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + +git config --global --add safe.directory "${workspace}" +git -c init.defaultBranch=master init "${workspace}" +git -C "${workspace}" remote add origin "${repository_url}" +git -C "${workspace}" config gc.auto 0 +git -C "${workspace}" config advice.detachedHead false + +fetch_args=( + -c protocol.version=2 + fetch + --no-tags + --prune + --no-recurse-submodules +) +if [[ "${fetch_depth}" -gt 0 ]]; then + fetch_args+=(--depth="${fetch_depth}") +fi +fetch_refspecs=( + "+${target_sha}:refs/remotes/ci/head" +) +if [[ "${fetch_depth}" -eq 0 ]]; then + fetch_refspecs+=( + "+refs/heads/${base_ref}:refs/remotes/origin/${base_ref}" + ) +fi + +auth_header='' +if [[ -n "${GENARRATIVE_GITEA_TOKEN:-}" ]]; then + auth_header="AUTHORIZATION: basic $( + printf 'x-access-token:%s' "${GENARRATIVE_GITEA_TOKEN}" | base64 -w 0 + )" +fi + +for attempt in $(seq 1 5); do + if [[ -n "${auth_header}" ]]; then + if GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0="http.${repository_url%/}.extraheader" \ + GIT_CONFIG_VALUE_0="${auth_header}" \ + git -C "${workspace}" "${fetch_args[@]}" origin "${fetch_refspecs[@]}"; then + break + fi + elif git -C "${workspace}" "${fetch_args[@]}" origin "${fetch_refspecs[@]}"; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'Gitea checkout fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) +done + +git -C "${workspace}" checkout --force refs/remotes/ci/head +git -C "${workspace}" clean -ffdx +test "$(git -C "${workspace}" rev-parse HEAD)" = "${target_sha,,}" +printf 'checked_out_commit=%s\n' "${target_sha,,}" diff --git a/deploy/container/gitea-ci-job.Dockerfile b/deploy/container/gitea-ci-job.Dockerfile index bc197fe82..2954c0f3c 100644 --- a/deploy/container/gitea-ci-job.Dockerfile +++ b/deploy/container/gitea-ci-job.Dockerfile @@ -8,6 +8,35 @@ RUN rustup component add rustfmt \ && cargo --version \ && rustfmt --version +FROM rust-toolchain AS rust-dependency-cache + +ENV CARGO_HTTP_MULTIPLEXING=false \ + CARGO_NET_RETRY=10 + +COPY server-rs /tmp/genarrative-cargo-cache/server-rs +COPY apps/desktop-shell/src-tauri /tmp/genarrative-cargo-cache/desktop-shell + +RUN find /tmp/genarrative-cargo-cache -name Cargo.toml -exec dirname {} \; \ + | while IFS= read -r crate_dir; do \ + mkdir -p "${crate_dir}/src"; \ + : > "${crate_dir}/src/lib.rs"; \ + : > "${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_NET_OFFLINE=true cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path /tmp/genarrative-cargo-cache/server-rs/Cargo.toml \ + && CARGO_NET_OFFLINE=true cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path /tmp/genarrative-cargo-cache/desktop-shell/Cargo.toml \ + && rm -rf /tmp/genarrative-cargo-cache + FROM ${RUNNER_IMAGE} ARG NODE_VERSION=22.23.1 @@ -16,8 +45,8 @@ ARG GOOGLE_CHROME_VERSION=150.0.7871.181-1 ARG GOOGLE_LINUX_SIGNING_KEY_FINGERPRINT=EB4C1BFD4F042F6DDDCCEC917721F63BD38B4796 LABEL org.opencontainers.image.title="Genarrative Gitea CI job image" \ - org.opencontainers.image.description="Ubuntu 24.04 CI image with Node 22, Rust 1.96, Chrome, and native build dependencies" \ - org.opencontainers.image.version="2026.07.22.1" + 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" RUN test "$(dpkg --print-architecture)" = "amd64" \ && find /etc/apt/sources.list.d -maxdepth 1 -type f \ @@ -80,8 +109,42 @@ RUN node_archive="node-v${NODE_VERSION}-linux-x64.tar.xz" \ && ln -sfn /usr/local/lib/genarrative-node/bin/npx /usr/local/bin/npx \ && ln -sfn /usr/local/lib/genarrative-node/bin/corepack /usr/local/bin/corepack -COPY --from=rust-toolchain /usr/local/cargo /usr/local/cargo -COPY --from=rust-toolchain /usr/local/rustup /usr/local/rustup +COPY --from=rust-dependency-cache /usr/local/cargo /usr/local/cargo +COPY --from=rust-dependency-cache /usr/local/rustup /usr/local/rustup + +ARG NPM_LOCK_SHA256 +ARG SERVER_RUST_LOCK_SHA256 +ARG DESKTOP_RUST_LOCK_SHA256 + +COPY package.json package-lock.json /usr/local/share/genarrative-ci/npm/ +COPY server-rs/Cargo.lock /usr/local/share/genarrative-ci/locks/server-rs.Cargo.lock +COPY apps/desktop-shell/src-tauri/Cargo.lock /usr/local/share/genarrative-ci/locks/desktop-shell.Cargo.lock +COPY deploy/container/gitea-ci-checkout.sh /usr/local/bin/genarrative-gitea-checkout + +RUN test -n "${NPM_LOCK_SHA256}" \ + && test -n "${SERVER_RUST_LOCK_SHA256}" \ + && test -n "${DESKTOP_RUST_LOCK_SHA256}" \ + && printf '%s %s\n' \ + "${NPM_LOCK_SHA256}" \ + /usr/local/share/genarrative-ci/npm/package-lock.json \ + | sha256sum --check --strict \ + && printf '%s %s\n' \ + "${SERVER_RUST_LOCK_SHA256}" \ + /usr/local/share/genarrative-ci/locks/server-rs.Cargo.lock \ + | sha256sum --check --strict \ + && printf '%s %s\n' \ + "${DESKTOP_RUST_LOCK_SHA256}" \ + /usr/local/share/genarrative-ci/locks/desktop-shell.Cargo.lock \ + | sha256sum --check --strict \ + && chmod 0755 /usr/local/bin/genarrative-gitea-checkout \ + && npm ci \ + --ignore-scripts \ + --no-audit \ + --no-fund \ + --prefer-offline \ + --prefix /usr/local/share/genarrative-ci/npm \ + && rm -rf /usr/local/share/genarrative-ci/npm/node_modules \ + && npm cache verify RUN install -m 0755 /usr/local/cargo/bin/rustup /usr/local/bin/rustup \ && for command_name in cargo cargo-fmt rustc rustdoc rustfmt; do \ @@ -89,6 +152,16 @@ RUN install -m 0755 /usr/local/cargo/bin/rustup /usr/local/bin/rustup \ done ENV CARGO_HOME=/usr/local/cargo \ + GENARRATIVE_GITEA_CI_DESKTOP_RUST_LOCK_SHA256=${DESKTOP_RUST_LOCK_SHA256} \ + GENARRATIVE_GITEA_CI_NPM_LOCK_SHA256=${NPM_LOCK_SHA256} \ + GENARRATIVE_GITEA_CI_SERVER_RUST_LOCK_SHA256=${SERVER_RUST_LOCK_SHA256} \ + NPM_CONFIG_AUDIT=false \ + NPM_CONFIG_FETCH_RETRIES=10 \ + NPM_CONFIG_FETCH_RETRY_FACTOR=2 \ + NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT=60000 \ + NPM_CONFIG_FETCH_RETRY_MINTIMEOUT=2000 \ + NPM_CONFIG_FUND=false \ + NPM_CONFIG_PREFER_OFFLINE=true \ RUSTUP_AUTO_INSTALL=0 \ RUSTUP_HOME=/usr/local/rustup \ PATH=/usr/local/lib/genarrative-node/bin:/usr/local/bin:/usr/local/cargo/bin:/opt/acttoolcache/node/24.18.0/x64/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin @@ -109,5 +182,9 @@ RUN node --version \ && rustfmt --version \ && google-chrome --version \ && bwrap --version \ + && test -x /usr/local/bin/genarrative-gitea-checkout \ + && bash -n /usr/local/bin/genarrative-gitea-checkout \ + && test -d /root/.npm/_cacache \ + && test -d /usr/local/cargo/registry/cache \ && rg --version \ && ffmpeg -version | head -n 1 diff --git a/deploy/container/gitea-ci-job.Dockerfile.dockerignore b/deploy/container/gitea-ci-job.Dockerfile.dockerignore new file mode 100644 index 000000000..7a5cb6b15 --- /dev/null +++ b/deploy/container/gitea-ci-job.Dockerfile.dockerignore @@ -0,0 +1,18 @@ +** +!deploy/ +!deploy/container/ +!deploy/container/gitea-ci-job.Dockerfile +!deploy/container/gitea-ci-checkout.sh +!package.json +!package-lock.json +!server-rs/ +!server-rs/Cargo.toml +!server-rs/Cargo.lock +!server-rs/crates/ +!server-rs/crates/*/ +!server-rs/crates/*/Cargo.toml +!apps/ +!apps/desktop-shell/ +!apps/desktop-shell/src-tauri/ +!apps/desktop-shell/src-tauri/Cargo.toml +!apps/desktop-shell/src-tauri/Cargo.lock diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1a794da98..fa72d4cdc 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4383,9 +4383,9 @@ ## 2026-07-22 Gitea CI 使用预构建工具链 Job 镜像 - 背景:Gitea Actions 的四个 job 彼此隔离,原 workflow 在每个 job 内重复运行 apt、setup-node、rustup 和原生系统依赖安装,后端与原生壳仅安装阶段就消耗数分钟,并重复承受软件源和代理瞬时失败。 -- 决策:新增 `deploy/container/gitea-ci-job.Dockerfile`,固定 Ubuntu job 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 / 后端系统依赖,并设置 `RUSTUP_AUTO_INSTALL=0`。构建 context 固定为 `deploy/container`,不把约 5 GB 工作区发送给 Docker daemon。 -- 镜像事实:当前验证镜像约 `1.416 GB`,默认 tag 为 `genarrative/gitea-project-ci:20260722.1`,完整 Image ID 为 `sha256:62304a788905cec805bf0ae3638c2811bf530b2f5709523277d816edb4772942`。runner config 保留 `ubuntu-latest`,并新增 `genarrative-ci:docker://sha256:62304a788905cec805bf0ae3638c2811bf530b2f5709523277d816edb4772942`;内层 Docker 数据持久化,`force_pull: false`,精确 ID 缺失时失败关闭,不回退浮动 tag 或现场拉取。 -- workflow 边界:`Repository checks`、`Frontend tests`、`Backend tests` 和 `Native shell tests` 统一 `runs-on: genarrative-ci`,删除 apt、setup-node 和 rustup 安装 step,checkout 后以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,同时校验工具链、bwrap sandbox 与 Chrome headless。每个 job 仍各自执行 `npm ci`,以当前 lockfile 为准隔离 PR 依赖;不烘入 `node_modules`,本阶段不启用 Actions cache。仓库 toolchain 变更时先重建镜像,不允许 job 现场下载 Rust。 +- 决策:新增 `deploy/container/gitea-ci-job.Dockerfile`,固定 Ubuntu job 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 下载缓存,并设置 `RUSTUP_AUTO_INSTALL=0`。构建脚本通过 NUL 分隔白名单 tar 流只发送约 `1.638 MB` 的 Dockerfile、checkout 脚本与依赖 manifests/lock,不发送业务源码、素材或本地私密文件。 +- 镜像事实:当前验证镜像约 `1.788 GB`,默认 tag 为 `genarrative/gitea-project-ci:20260722.2`,完整 Image ID 为 `sha256:548431a2529d325b5ab546f242799a4076f979779ee832a0871ac1af881a4946`。runner config 保留 `ubuntu-latest`,并新增 `genarrative-ci:docker://sha256:548431a2529d325b5ab546f242799a4076f979779ee832a0871ac1af881a4946`;内层 Docker 数据持久化,`force_pull: false`,精确 ID 缺失时失败关闭,不回退浮动 tag 或现场拉取。 +- workflow 边界:`Repository checks`、`Frontend tests`、`Backend tests` 和 `Native shell tests` 统一 `runs-on: genarrative-ci`,删除 GitHub checkout action、apt、setup-node 和 rustup 安装 step;镜像内 checkout 直接从当前 Gitea 拉事件 commit,并带 5 次有界重试。随后以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,同时校验缓存锁、工具链、bwrap sandbox 与 Chrome headless。每个 job 仍各自执行干净的 `npm ci`,以当前 lockfile 为准隔离 PR 依赖;命中镜像 cache 时只做本地解包,锁新增依赖时经受控网络补齐。不烘入 `node_modules` / Cargo `target`,不挂载跨 PR 可写 cache。仓库 toolchain 变更时先重建镜像,不允许 job 现场下载 Rust。 - 运维与回滚:用 `scripts/gitea-ci-job-image.sh build|verify|export|load-runner` 管理镜像,按 `build/verify -> export 仓库外镜像归档和 SHA-256 sidecar -> load-runner -> 确认无活跃 job -> 备份 config -> 增加或替换 label -> docker restart --timeout 660` 切换。config 与镜像归档只放仓库外受控位置;共享文档只记录通用备份规则,不记录宿主绝对路径、注册信息或 token。重启后先验证真实 CI 再清理旧镜像;回滚先把 workflow `runs-on` 改回 `ubuntu-latest`,再恢复 config 备份并重启 runner。 - 影响范围:`.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=[]` 和独立网络。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 5eb9b28e9..188c7fab9 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -269,9 +269,9 @@ 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.416 GB`,Image ID 为 `sha256:62304a788905cec805bf0ae3638c2811bf530b2f5709523277d816edb4772942`,标签映射为 `genarrative-ci:docker://sha256:62304a788905cec805bf0ae3638c2811bf530b2f5709523277d816edb4772942`。内层 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 / 后端系统依赖。构建 context 固定为 `deploy/container`。更新按 `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`,checkout 后以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 运行 `scripts/check-gitea-ci-job-image.sh`,同时校验工具链、完整 bwrap sandbox 和 Chrome headless。workflow 不再包含 apt、setup-node 或 rustup 安装,并设置 `RUSTUP_AUTO_INSTALL=0`;工具链变更时先重建镜像。每个 job 仍各自执行 `npm ci` 以校验 lockfile 和隔离 PR 依赖,不烘入 `node_modules`,本阶段不启用 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 数据网必须失败;Cargo 保持关闭 HTTP multiplexing 并设置 10 次网络重试。 +- 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,不在共享文档记录宿主私密路径或注册信息。 +- 四个 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 6c557c37d..0e2288e37 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3284,9 +3284,9 @@ - 现象:`Repository checks`、`Frontend tests`、`Backend tests` 和 `Native shell tests` 都从全新 job 容器开始,apt、setup-node、rustup 和原生系统库在不同 job 里重复安装;后端与原生壳的安装时间可达数分钟,并把软件源和代理瞬时失败放大为四份。 - 原因: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 / 后端系统依赖,四个 job 统一 `runs-on: genarrative-ci`,并在 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 依赖在干净环境中验证。不要把 `node_modules` 烘进工具链镜像,也不要在尚未完成 cache 服务验收时直接开 Actions cache。 -- 验证:workflow 不再出现 apt、setup-node 或 rustup 安装 step;四个 job 的环境校验均通过,且 `npm ci` 和原有测试门禁仍全部执行。 +- 处理:用 `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。 +- 验证:workflow 不再出现 GitHub checkout action、apt、setup-node 或 rustup 安装 step;镜像在 `--network none` 下能按当前 npm / Cargo lock 完成依赖准备,四个 job 的环境校验、干净 `npm ci` 和原有测试门禁仍全部执行。 ## Gitea CI 预构建镜像不能只靠 tag 判断内容 @@ -3294,4 +3294,5 @@ - 原因:宿主 Docker 和 runner 内层 Docker 是两个镜像库,同名 tag 可指向不同 Image ID。基础镜像 digest 和 Node tarball 哈希能锁定关键输入,但重建后仍必须把最终完整 Image ID 当作 runner 映射的事实源,不能从 tag 名推断二进制内容。 - 处理:使用 `scripts/gitea-ci-job-image.sh build/verify`,用 `export` 在仓库外保存镜像归档与便携 SHA-256 sidecar,再用 `load-runner` 将镜像导入内层、比对两侧 Image ID 并执行 bwrap / Chrome canary。确认无活跃 job 后,把当前 config 备份到仓库外受控位置,再将 `genarrative-ci` 映射到新的 `docker://sha256:...` 并执行 `docker restart --timeout 660 gitea-runner`。保持内层 Docker 持久化和 `force_pull: false`;精确 ID 缺失时失败关闭,不回退浮动 tag。 - 验证与回滚:重启后先跑真实 PR 的四个 job,再清理旧镜像。失败时先把 workflow `runs-on` 改回 `ubuntu-latest`,再恢复 runner config 备份并重启;不在 Git、共享文档或日志中记录 config 备份路径、注册信息或 token。 +- 重启边界:`docker restart --timeout 660` 只设置容器停止宽限,不能替代 Runner drain。rootless DinD supervisor 可能与 runner 同时停止内层 dockerd,使仍在收尾的 job 因连接关闭被标记失败;切换前必须同时确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空。误触发时只重跑受影响的失败 job,不重跑已成功项。 - 关联:`deploy/container/README.md`、`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 f64f2be2f..d111c8100 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -211,24 +211,24 @@ npm run check PR checkout 必须保留完整 Git 历史,并把 PR base SHA 传给 `SPACETIME_SCHEMA_BASE_REF`。`check:spacetime-schema` 依赖该基线识别已有表字段删除、改名、重排和改类型;事件给出的基线缺失或本地不可解析时必须直接失败,不能退化为空差异检查。Gitea 的 PR checkout 是 PR head,不是与目标分支的预合并 commit,因此 workflow 还会验证 PR head 包含事件中的最新 base commit;分支保护必须继续开启“PR 过期禁止合并”,过期分支先更新再重跑。向 `master` 直接推送时使用 push before SHA,手工触发时回退到 `origin/master`。 -启用或注册执行 PR job 的 runner 前,Gitea 服务端必须至少升级到 `1.26.4`;不得在 `1.26.2` 上执行不受信任 PR 代码。runner 保留 `ubuntu-latest` 标签作为已有固定 digest Ubuntu 24.04 级环境,同时提供专用 `genarrative-ci` 标签,并将后者映射到已装入 runner 内层 Docker 的完整 Image ID;两者均不得映射到 host 执行器,job 不得获得 Docker socket、业务环境变量、业务密钥或不必要的内网。workflow 不再现场运行 apt、`actions/setup-node` 或 rustup 安装;Node 22、Rust 1.96.0、`rustfmt`、Chrome、`bwrap`、`rg`、`ffmpeg`、`clang/lld` 和 Tauri / 后端系统依赖都由预构建镜像提供。工作流中的 `actions/checkout` 固定到完整 commit;内网 runner 不允许访问 GitHub 时,先把对应 commit 镜像到当前 Gitea 并改用绝对 action URL。job 运行时仍需访问 Gitea、npm registry 和 crates.io;构建 CI 镜像时另需访问固定基础镜像、Ubuntu / Google Chrome 软件源和 nodejs.org。本阶段不使用 Actions cache,避免未配置 runner cache 网络时把缓存恢复错误变成 PR 失败。当前 Runner 2.0.0 已支持 job 级 `timeout-minutes`,但 runner 全局 `3h` 仍是所有任务的硬上限。 +启用或注册执行 PR job 的 runner 前,Gitea 服务端必须至少升级到 `1.26.4`;不得在 `1.26.2` 上执行不受信任 PR 代码。runner 保留 `ubuntu-latest` 标签作为已有固定 digest Ubuntu 24.04 级环境,同时提供专用 `genarrative-ci` 标签,并将后者映射到已装入 runner 内层 Docker 的完整 Image ID;两者均不得映射到 host 执行器,job 不得获得 Docker socket、业务环境变量、业务密钥或不必要的内网。workflow 不再现场运行 apt、`actions/setup-node` 或 rustup 安装;Node 22、Rust 1.96.0、`rustfmt`、Chrome、`bwrap`、`rg`、`ffmpeg`、`clang/lld` 和 Tauri / 后端系统依赖都由预构建镜像提供。checkout 由镜像内 `genarrative-gitea-checkout` 直接从当前 Gitea 拉取事件 commit,并执行 5 次有界重试;不得恢复为运行时从 GitHub 克隆 action。当前锁命中时,npm 与 Linux 目标 Cargo 下载可完全使用镜像内预热缓存;锁文件新增依赖时才经受控 proxy 补齐。构建 CI 镜像仍需访问固定基础镜像、Ubuntu / Google Chrome 软件源、nodejs.org、npm registry 和 crates.io。本阶段不使用共享 Actions cache,避免不受信任 PR 污染跨 job 可写缓存。当前 Runner 2.0.0 已支持 job 级 `timeout-minutes`,但 runner 全局 `3h` 仍是所有任务的硬上限。 当前 `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`。构建上下文只使用 `deploy/container`,避免发送整个工作区。当前验证镜像约 `1.416 GB`,默认 tag 为 `genarrative/gitea-project-ci:20260722.1`,完整 Image ID 为 `sha256:62304a788905cec805bf0ae3638c2811bf530b2f5709523277d816edb4772942`;runner 标签保留 `ubuntu-latest`,并新增 `genarrative-ci:docker://sha256:62304a788905cec805bf0ae3638c2811bf530b2f5709523277d816edb4772942`。内层 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`。当前验证镜像约 `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。 镜像更新命令: ```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.1.tar.zst +bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/genarrative-gitea-project-ci-20260722.2.tar.zst bash scripts/gitea-ci-job-image.sh load-runner ``` -执行账号只要有权访问宿主 Docker API 并管理 runner 容器即可,不强制使用 root;无该权限时由 runner 运维人员执行。更新顺序必须是 `build/verify -> export 仓库外镜像归档与 SHA-256 sidecar -> load-runner -> 确认无活跃 job -> 备份当前 config -> 增加或替换 label -> docker restart --timeout 660 gitea-runner`。config 和镜像归档只保存到仓库外受控位置,不在文档、仓库或日志中记录注册信息。重启后先重跑真实 PR 的四个 job,复核隔离边界并确认全部通过,再清理旧镜像。回滚时先把 workflow 的 `runs-on` 改回 `ubuntu-latest`,再恢复 config 备份并重启 runner。 +执行账号只要有权访问宿主 Docker API 并管理 runner 容器即可,不强制使用 root;无该权限时由 runner 运维人员执行。更新顺序必须是 `build/verify -> export 仓库外镜像归档与 SHA-256 sidecar -> load-runner -> 确认无活跃 job -> 备份当前 config -> 增加或替换 label -> docker restart --timeout 660 gitea-runner`。`--timeout 660` 只是停止宽限,不是 drain API;rootless DinD supervisor 可能同时停止内层 dockerd,因此重启前必须确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空。config 和镜像归档只保存到仓库外受控位置,不在文档、仓库或日志中记录注册信息。重启后先重跑真实 PR 的四个 job,复核隔离边界并确认全部通过,再清理旧镜像。回滚时先把 workflow 的 `runs-on` 改回 `ubuntu-latest`,再恢复 config 备份并重启 runner。 -四个 job 在 checkout 后立即以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,校验 Node 主版本、仓库 Rust toolchain、受信任 PATH、原生命令、pkg-config 依赖、完整 bwrap sandbox 和 Chrome headless。`RUSTUP_AUTO_INSTALL=0`,因此仓库 `rust-toolchain.toml` 变更必须先更新镜像,不能让 job 现场下载。每个 job 仍独立运行 `npm ci`,以当前 lockfile 为准验证 PR 依赖;不在镜像内烘入 `node_modules`,本阶段不启用 Actions cache。任何 job 的 sandbox canary 失败都必须停止,不允许跳过。Cargo 通过受控 proxy 下载 sparse index/crate 时继续关闭 HTTP multiplexing 并设置 `CARGO_NET_RETRY=10`。 +四个 job 先运行镜像内 `genarrative-gitea-checkout`,再以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,校验 Node 主版本、仓库 Rust toolchain、受信任 PATH、缓存锁命中状态、原生命令、pkg-config 依赖、完整 bwrap sandbox 和 Chrome headless。`RUSTUP_AUTO_INSTALL=0`,因此仓库 `rust-toolchain.toml` 变更必须先更新镜像,不能让 job 现场下载。每个 job 仍独立运行 `npm ci`,以当前 lockfile 为准验证 PR 依赖;`NPM_CONFIG_PREFER_OFFLINE=true` 且网络重试为 10 次,命中镜像 cache 时只做干净解包,lock 变化时允许补齐差量。不在镜像内烘入 `node_modules`,也不挂载跨 PR 可写缓存。任何 job 的 sandbox canary 失败都必须停止,不允许跳过。Cargo 通过受控 proxy 下载 lock 差量时继续关闭 HTTP multiplexing 并设置 `CARGO_NET_RETRY=10`。 站点 stack 仍由宿主受控目录管理,`.env`、runner 注册文件和数据库凭据不进入仓库。Compose 必须在 helper/container 内把该目录挂到与宿主相同的绝对路径再执行;挂载到不同路径会让相对 bind source 被 Docker daemon 解析到错误的宿主目录并启动空数据。升级或 runner 迁移前先停止 Gitea 写入,并把 Gitea 冷快照、数据库导出、compose/env 与 runner config/.runner 保存到仓库外受控备份位置。备份文件、绝对宿主配置和注册 token 不得提交 Git,也不在共享文档中记录具体路径或注册内容。 diff --git a/scripts/check-gitea-ci-job-image.sh b/scripts/check-gitea-ci-job-image.sh index 738096920..88753b32d 100644 --- a/scripts/check-gitea-ci-job-image.sh +++ b/scripts/check-gitea-ci-job-image.sh @@ -16,6 +16,57 @@ test "$(rustup show home)" = "/usr/local/rustup" for trusted_command in cargo rustc rustdoc rustfmt rustup; do test "$(command -v "${trusted_command}")" = "/usr/local/bin/${trusted_command}" done +test "$(command -v genarrative-gitea-checkout)" = "/usr/local/bin/genarrative-gitea-checkout" +bash -n /usr/local/bin/genarrative-gitea-checkout +test -d /root/.npm/_cacache +test -d /usr/local/cargo/registry/cache + +verify_cache_lock() { + local cache_name="$1" + local expected_sha256="$2" + local lock_path="$3" + local actual_sha256 + + test -n "${expected_sha256}" + actual_sha256="$(sha256sum "${lock_path}")" + actual_sha256="${actual_sha256%% *}" + if [[ "${actual_sha256}" == "${expected_sha256}" ]]; then + printf '%s_cache_lock=hit\n' "${cache_name}" + return + fi + if [[ "${GENARRATIVE_GITEA_CI_CHECK_RUNTIME:-0}" == '1' ]]; then + printf '%s_cache_lock=partial\n' "${cache_name}" + return + fi + echo "${cache_name} cache lock does not match the verification checkout." >&2 + exit 1 +} + +npm_lock_path="${repo_root}/package-lock.json" +server_rust_lock_path="${repo_root}/server-rs/Cargo.lock" +desktop_rust_lock_path="${repo_root}/apps/desktop-shell/src-tauri/Cargo.lock" +if [[ ! -f "${npm_lock_path}" ]]; then + npm_lock_path='/usr/local/share/genarrative-ci/npm/package-lock.json' +fi +if [[ ! -f "${server_rust_lock_path}" ]]; then + server_rust_lock_path='/usr/local/share/genarrative-ci/locks/server-rs.Cargo.lock' +fi +if [[ ! -f "${desktop_rust_lock_path}" ]]; then + desktop_rust_lock_path='/usr/local/share/genarrative-ci/locks/desktop-shell.Cargo.lock' +fi + +verify_cache_lock \ + npm \ + "${GENARRATIVE_GITEA_CI_NPM_LOCK_SHA256:-}" \ + "${npm_lock_path}" +verify_cache_lock \ + server_rust \ + "${GENARRATIVE_GITEA_CI_SERVER_RUST_LOCK_SHA256:-}" \ + "${server_rust_lock_path}" +verify_cache_lock \ + desktop_rust \ + "${GENARRATIVE_GITEA_CI_DESKTOP_RUST_LOCK_SHA256:-}" \ + "${desktop_rust_lock_path}" for command_name in \ bwrap \ diff --git a/scripts/gitea-ci-job-image.sh b/scripts/gitea-ci-job-image.sh index 455a37349..2f361f4b1 100644 --- a/scripts/gitea-ci-job-image.sh +++ b/scripts/gitea-ci-job-image.sh @@ -3,11 +3,24 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" -dockerfile="${repo_root}/deploy/container/gitea-ci-job.Dockerfile" -build_context="${repo_root}/deploy/container" -image_tag="${GENARRATIVE_GITEA_CI_IMAGE_TAG:-genarrative/gitea-project-ci:20260722.1}" +dockerfile_context_path="deploy/container/gitea-ci-job.Dockerfile" +image_tag="${GENARRATIVE_GITEA_CI_IMAGE_TAG:-genarrative/gitea-project-ci:20260722.2}" runner_container="${GENARRATIVE_GITEA_RUNNER_CONTAINER:-gitea-runner}" +write_build_context_file_list() { + printf '%s\0' \ + deploy/container/gitea-ci-job.Dockerfile \ + deploy/container/gitea-ci-job.Dockerfile.dockerignore \ + deploy/container/gitea-ci-checkout.sh \ + package.json \ + package-lock.json \ + server-rs/Cargo.toml \ + server-rs/Cargo.lock \ + apps/desktop-shell/src-tauri/Cargo.toml \ + apps/desktop-shell/src-tauri/Cargo.lock + find server-rs/crates -name Cargo.toml -print0 | sort -z +} + usage() { cat <<'EOF' 用法: @@ -39,14 +52,46 @@ verify_image() { command_name="${1:-}" case "${command_name}" in build) - image_revision="$(sha256sum "${dockerfile}")" - image_revision="${image_revision%% *}" - docker build \ - --pull=false \ - --build-arg "IMAGE_REVISION=${image_revision}" \ - --file "${dockerfile}" \ - --tag "${image_tag}" \ - "${build_context}" + image_revision="$( + cd "${repo_root}" + { + sha256sum \ + deploy/container/gitea-ci-job.Dockerfile \ + deploy/container/gitea-ci-job.Dockerfile.dockerignore \ + deploy/container/gitea-ci-checkout.sh \ + package.json \ + package-lock.json \ + server-rs/Cargo.toml \ + server-rs/Cargo.lock \ + apps/desktop-shell/src-tauri/Cargo.toml \ + apps/desktop-shell/src-tauri/Cargo.lock + find server-rs/crates -name Cargo.toml -print0 \ + | sort -z \ + | xargs -0 -r sha256sum + } \ + | sha256sum \ + | awk '{ print $1 }' + )" + npm_lock_sha256="$(sha256sum "${repo_root}/package-lock.json")" + npm_lock_sha256="${npm_lock_sha256%% *}" + server_rust_lock_sha256="$(sha256sum "${repo_root}/server-rs/Cargo.lock")" + server_rust_lock_sha256="${server_rust_lock_sha256%% *}" + desktop_rust_lock_sha256="$(sha256sum "${repo_root}/apps/desktop-shell/src-tauri/Cargo.lock")" + desktop_rust_lock_sha256="${desktop_rust_lock_sha256%% *}" + ( + cd "${repo_root}" + write_build_context_file_list \ + | tar --null --create --file - --files-from=- \ + | docker build \ + --pull=false \ + --build-arg "IMAGE_REVISION=${image_revision}" \ + --build-arg "NPM_LOCK_SHA256=${npm_lock_sha256}" \ + --build-arg "SERVER_RUST_LOCK_SHA256=${server_rust_lock_sha256}" \ + --build-arg "DESKTOP_RUST_LOCK_SHA256=${desktop_rust_lock_sha256}" \ + --file "${dockerfile_context_path}" \ + --tag "${image_tag}" \ + - + ) verify_image "${image_tag}" printf 'image_tag=%s\n' "${image_tag}" printf 'image_id=%s\n' "$(image_id "${image_tag}")"