预构建Gitea CI工具链镜像 #106
+40
-233
@@ -17,46 +17,30 @@ env:
|
||||
CARGO_HTTP_MULTIPLEXING: 'false'
|
||||
CARGO_NET_RETRY: '10'
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTUP_MAX_RETRIES: '10'
|
||||
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: ''
|
||||
|
||||
jobs:
|
||||
repository-checks:
|
||||
name: Repository checks
|
||||
runs-on: ubuntu-latest
|
||||
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: Install base tools
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v apt-get >/dev/null 2>&1 || {
|
||||
echo 'ubuntu-latest runner must provide an Ubuntu or Debian environment.' >&2
|
||||
exit 1
|
||||
}
|
||||
sudo_command=()
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
command -v sudo >/dev/null 2>&1 || {
|
||||
echo 'non-root runner user requires sudo for system dependencies.' >&2
|
||||
exit 1
|
||||
}
|
||||
sudo_command=(sudo -E)
|
||||
fi
|
||||
"${sudo_command[@]}" apt-get update
|
||||
"${sudo_command[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Resolve comparison base
|
||||
shell: bash
|
||||
@@ -82,42 +66,6 @@ jobs:
|
||||
fi
|
||||
echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Set up repository Rust toolchain
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
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}"
|
||||
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: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -141,17 +89,16 @@ jobs:
|
||||
|
||||
frontend-tests:
|
||||
name: Frontend tests
|
||||
runs-on: ubuntu-latest
|
||||
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: Set up Node.js 22
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
@@ -161,48 +108,16 @@ jobs:
|
||||
|
||||
backend-tests:
|
||||
name: Backend tests
|
||||
runs-on: ubuntu-latest
|
||||
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: Install backend build dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v apt-get >/dev/null 2>&1 || {
|
||||
echo 'ubuntu-latest runner must provide an Ubuntu or Debian environment.' >&2
|
||||
exit 1
|
||||
}
|
||||
sudo_command=()
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
command -v sudo >/dev/null 2>&1 || {
|
||||
echo 'non-root runner user requires sudo for system dependencies.' >&2
|
||||
exit 1
|
||||
}
|
||||
sudo_command=(sudo -E)
|
||||
fi
|
||||
"${sudo_command[@]}" apt-get update
|
||||
"${sudo_command[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
clang \
|
||||
cmake \
|
||||
curl \
|
||||
ffmpeg \
|
||||
libclang-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libssl-dev \
|
||||
lld \
|
||||
pkg-config
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Resolve comparison base
|
||||
shell: bash
|
||||
@@ -228,42 +143,6 @@ jobs:
|
||||
fi
|
||||
echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Set up repository Rust toolchain
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
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}"
|
||||
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: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -281,88 +160,16 @@ jobs:
|
||||
|
||||
native-shell-tests:
|
||||
name: Native shell tests
|
||||
runs-on: ubuntu-latest
|
||||
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: Install native shell build dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v apt-get >/dev/null 2>&1 || {
|
||||
echo 'ubuntu-latest runner must provide an Ubuntu or Debian environment.' >&2
|
||||
exit 1
|
||||
}
|
||||
sudo_command=()
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
command -v sudo >/dev/null 2>&1 || {
|
||||
echo 'non-root runner user requires sudo for system dependencies.' >&2
|
||||
exit 1
|
||||
}
|
||||
sudo_command=(sudo -E)
|
||||
fi
|
||||
"${sudo_command[@]}" apt-get update
|
||||
"${sudo_command[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
clang \
|
||||
cmake \
|
||||
curl \
|
||||
file \
|
||||
libayatana-appindicator3-dev \
|
||||
libssl-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libxdo-dev \
|
||||
librsvg2-dev \
|
||||
lld \
|
||||
patchelf \
|
||||
pkg-config \
|
||||
wget
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up repository Rust toolchain
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
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}"
|
||||
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: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Genarrative 容器化压测与隔离部署方案
|
||||
# Genarrative 容器化压测、隔离部署与 CI Job 镜像
|
||||
|
||||
本目录只服务本机或预发的容器化模拟压测,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。生产服务器仍以 `deploy/systemd/`、`deploy/nginx/`、`scripts/jenkins-*.sh` 和 `scripts/deploy/production-api-deploy.sh` 为准。
|
||||
本目录同时保存两类互不替代的容器资产:本机或预发的容器化模拟压测,以及 Gitea Actions 使用的预构建 CI job 镜像。它们都不替换当前生产 `systemd + Nginx + Jenkins` 发布路径;生产服务器仍以 `deploy/systemd/`、`deploy/nginx/`、`scripts/jenkins-*.sh` 和 `scripts/deploy/production-api-deploy.sh` 为准。
|
||||
|
||||
## 拓扑
|
||||
|
||||
@@ -58,6 +58,35 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht
|
||||
|
||||
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.6.1` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。镜像构建阶段会同时复制 `public/`,用于满足 API 二进制里 `include_bytes!` 引用的内置素材;不要把 `public/generated-*` 放入镜像上下文。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
|
||||
|
||||
### Gitea CI 预构建 Job 镜像
|
||||
|
||||
`.gitea/workflows/project-ci.yml` 的四个 job 统一使用 `deploy/container/gitea-ci-job.Dockerfile` 构建的 `genarrative-ci` 环境。镜像固定 Ubuntu job base digest `sha256:58ea92624c7c09582e05594d95488331045053d3a3f34cf09649f2a32313a614` 和 Rust stage digest `sha256:19817ead3289c8c631c73df281e18b59b172f6a31f4f563290f69cddd06c30e9`;Node `22.23.1` 发行包在解压前执行 SHA-256 校验,Google Linux 主签名指纹固定为 `EB4C1BFD4F042F6DDDCCEC917721F63BD38B4796`,Chrome 固定为 `150.0.7871.181-1`。镜像预装 Rust `1.96.0`、`rustfmt`、Chrome、`bwrap`、`rg`、`ffmpeg`、`clang/lld` 和 Tauri / 后端系统依赖,并设置 `RUSTUP_AUTO_INSTALL=0`;仓库工具链变更时必须先重建镜像,不允许 job 现场下载补齐。
|
||||
|
||||
构建、校验和装入 runner 内层 Docker:
|
||||
|
||||
```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 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 运维人员执行。
|
||||
|
||||
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。
|
||||
|
||||
更新顺序固定为:
|
||||
|
||||
1. 执行 `build` / `verify`,记录输出的完整 Image ID;用 `export` 将镜像和便携 SHA-256 sidecar 保存到仓库外受控位置。
|
||||
2. 执行 `load-runner`,确认该 ID 已进入 runner 内层 Docker。
|
||||
3. 确认没有活跃 job,将当前 runner config 备份到仓库外的受控位置;备份不得进入 Git,也不得在文档或日志中回显注册信息。
|
||||
4. 增加或替换 `genarrative-ci` 的精确 `docker://<Image ID>` 映射,然后执行 `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 验证前删除旧映射或旧镜像。
|
||||
|
||||
## 启动与验证
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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,,}"
|
||||
@@ -0,0 +1,190 @@
|
||||
ARG RUST_IMAGE=rust:1.96-bookworm@sha256:19817ead3289c8c631c73df281e18b59b172f6a31f4f563290f69cddd06c30e9
|
||||
ARG RUNNER_IMAGE=docker.gitea.com/runner-images:ubuntu-latest@sha256:58ea92624c7c09582e05594d95488331045053d3a3f34cf09649f2a32313a614
|
||||
|
||||
FROM ${RUST_IMAGE} AS rust-toolchain
|
||||
|
||||
RUN rustup component add rustfmt \
|
||||
&& rustc --version \
|
||||
&& 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
|
||||
ARG NODE_LINUX_X64_SHA256=9749e988f437343b7fa832c69ded82a312e41a03116d766797ac14f6f9eee578
|
||||
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 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 \
|
||||
! -name ubuntu.sources -delete \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bubblewrap \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
clang \
|
||||
cmake \
|
||||
curl \
|
||||
ffmpeg \
|
||||
file \
|
||||
libayatana-appindicator3-dev \
|
||||
libclang-dev \
|
||||
libcurl4-openssl-dev \
|
||||
librsvg2-dev \
|
||||
libssl-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libxdo-dev \
|
||||
lld \
|
||||
patchelf \
|
||||
pkg-config \
|
||||
ripgrep \
|
||||
wget \
|
||||
xz-utils \
|
||||
&& google_key="$(mktemp)" \
|
||||
&& curl --retry 3 --retry-all-errors --retry-delay 2 \
|
||||
--proto '=https' --tlsv1.2 -fsSL \
|
||||
https://dl.google.com/linux/linux_signing_key.pub \
|
||||
-o "${google_key}" \
|
||||
&& test "$(gpg --batch --show-keys --with-colons "${google_key}" \
|
||||
| awk -F: '$1 == "fpr" { print $10; exit }')" \
|
||||
= "${GOOGLE_LINUX_SIGNING_KEY_FINGERPRINT}" \
|
||||
&& install -d -m 0755 /etc/apt/keyrings \
|
||||
&& install -m 0644 "${google_key}" /etc/apt/keyrings/google-chrome.asc \
|
||||
&& printf '%s\n' \
|
||||
'deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.asc] https://dl.google.com/linux/chrome/deb/ stable main' \
|
||||
> /etc/apt/sources.list.d/google-chrome.list \
|
||||
&& rm -f "${google_key}" \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
"google-chrome-stable=${GOOGLE_CHROME_VERSION}" \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN node_archive="node-v${NODE_VERSION}-linux-x64.tar.xz" \
|
||||
&& curl --retry 3 --retry-all-errors --retry-delay 2 \
|
||||
--proto '=https' --tlsv1.2 -fsSLO \
|
||||
"https://nodejs.org/dist/v${NODE_VERSION}/${node_archive}" \
|
||||
&& printf '%s %s\n' "${NODE_LINUX_X64_SHA256}" "${node_archive}" \
|
||||
| sha256sum --check --strict \
|
||||
&& install -d -m 0755 /usr/local/lib/genarrative-node \
|
||||
&& tar -xJf "${node_archive}" \
|
||||
--strip-components=1 \
|
||||
--directory /usr/local/lib/genarrative-node \
|
||||
&& rm -f "${node_archive}" \
|
||||
&& ln -sfn /usr/local/lib/genarrative-node/bin/node /usr/local/bin/node \
|
||||
&& ln -sfn /usr/local/lib/genarrative-node/bin/npm /usr/local/bin/npm \
|
||||
&& 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-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 \
|
||||
ln -sfn rustup "/usr/local/bin/${command_name}"; \
|
||||
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
|
||||
|
||||
ARG IMAGE_REVISION=uncommitted
|
||||
LABEL org.opencontainers.image.vendor="GenarrativeAI" \
|
||||
org.opencontainers.image.source="https://git.genarrative.world/GenarrativeAI/Genarrative" \
|
||||
org.opencontainers.image.revision="${IMAGE_REVISION}" \
|
||||
org.opencontainers.image.base.name="docker.gitea.com/runner-images:ubuntu-latest@sha256:58ea92624c7c09582e05594d95488331045053d3a3f34cf09649f2a32313a614" \
|
||||
com.genarrative.ci.dockerfile="deploy/container/gitea-ci-job.Dockerfile" \
|
||||
com.genarrative.ci.definition-sha256="${IMAGE_REVISION}"
|
||||
|
||||
RUN node --version \
|
||||
&& npm --version \
|
||||
&& rustup show active-toolchain \
|
||||
&& rustc --version \
|
||||
&& cargo --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
|
||||
@@ -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
|
||||
@@ -4379,3 +4379,13 @@
|
||||
- 背景:画布中键拖拽的平移模型已同时计算 X / Y,但普通滚轮分支只消费 `deltaY`,横向滚轮或触控板的 `deltaX` 被丢弃,且缺少中键横向拖动的状态机回归覆盖。
|
||||
- 决策:普通滚轮原样消费设备上报的 `deltaX / deltaY` 二维平移 viewport;当按住 Shift 且设备上报 `deltaX = 0` 时,输入适配层把 `deltaY` 映射为横向位移并将纵向位移置零,核心平移模型不感知修饰键。`Ctrl / Cmd + 滚轮` 继续只负责围绕指针缩放;中键和抓手拖拽继续同时更新 X / Y。
|
||||
- 验证:交互模型单测覆盖原始 `deltaX / deltaY` 和缩放边界;viewport hook 单测覆盖二维滚轮、Shift 横向适配与 Ctrl 缩放;stage 状态机单测覆盖中键水平、垂直同时移动。
|
||||
|
||||
## 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 / 后端系统依赖,按当前锁预热根 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=[]` 和独立网络。
|
||||
|
||||
@@ -266,11 +266,12 @@ npm run check:server-rs-ddd
|
||||
|
||||
- 仓库 CI 入口是 `.gitea/workflows/project-ci.yml`,向 `master`、`codex/ai-game-creator-app` 推送和所有 PR 创建、更新时必须运行,也允许手工触发。
|
||||
- CI 固定拆分为 `Repository checks`、`Frontend tests`、`Backend tests`、`Native shell tests` 四个 required job;对应 PR context 完整名称是 `Project CI / Repository checks (pull_request)`、`Project CI / Frontend tests (pull_request)`、`Project CI / Backend tests (pull_request)`、`Project CI / Native shell tests (pull_request)`,首次运行后仍须从 Gitea 最近一周 context 表复核。测试使用独立 job,不能只藏在综合检查 step 中;原生壳验收单独运行以便定位重型构建失败。
|
||||
- 四个 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`。后端 runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。`codex/ai-game-creator-app` 分支的原生壳入口还必须覆盖 `npm run ai-game-creator-shell:check` 和 release build smoke。
|
||||
- 四个 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;`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。Runner 2.0.0 支持 job 级 `timeout-minutes`,但 runner 全局 `3h` 仍是所有任务的硬上限;首次运行成功后,`master` 分支保护必须要求上述四个 job 全部成功。
|
||||
- `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` 空目录。
|
||||
- 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。
|
||||
|
||||
## 后端相关默认验证
|
||||
|
||||
|
||||
@@ -3271,3 +3271,28 @@
|
||||
- 运维陷阱:从容器内运行 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=[]`、`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`。
|
||||
|
||||
## 固定 digest 不等于每个 CI job 都要强制拉镜像
|
||||
|
||||
- 现象:四个 Gitea Actions job 在约 20 秒内同时失败,checkout 和测试都没开始;setup 日志显示 Docker 对已固定的 `docker.gitea.com/runner-images@sha256:...` 发起 manifest HEAD,随后以 `net/http: TLS handshake timeout` 结束。
|
||||
- 原因:镜像 label 固定 digest 只防止内容漂移;`container.force_pull: true` 仍会让每个 job 调用 Docker image create/pull 并依赖 registry 即时可用,即使 rootless Docker 本地已有该精确 RepoDigest。并发四个 job 还会同时放大同一外网 TLS 故障。
|
||||
- 处理:继续使用完整 digest,把 Runner 设为 `force_pull: false`;首次部署或变更 digest 时,在切换 label 前对精确 digest 执行有界重试拉取,并用内层 `docker image inspect` 核对 RepoDigest。保留上一份 runner 配置和已验证镜像备份,切换失败时回滚配置,不改用浮动 tag。
|
||||
- 验证:重启 runner 后先等待内层 `docker info` 就绪,再 inspect 精确 digest 并确认 runner declare;重跑真实 PR 的四个 job,必须越过原先的启动失败窗口。运行中 job 仍要复核 `Privileged=false`、`Binds=[]`、`MaskedPaths=[]`、`ReadonlyPaths=[]` 和独立网络,防止稳定性修正意外放宽隔离。
|
||||
- 关联:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/project-memory/shared-memory/development-workflow.md`。
|
||||
|
||||
## 四个 Gitea CI job 不要重复现场安装固定工具链
|
||||
|
||||
- 现象:`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 / 后端系统依赖,并按锁预热根 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 判断内容
|
||||
|
||||
- 现象:宿主已重建带日期修订 tag 的 `genarrative/gitea-project-ci` 镜像,但 `genarrative-ci` job 仍跑旧内容,或直接报 image not found;另一种危险操作是只改 runner label,没把对应镜像装入 rootless runner 的内层 Docker。
|
||||
- 原因:宿主 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`。
|
||||
|
||||
@@ -211,15 +211,28 @@ 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 级 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,不能删除全局兜底。
|
||||
启用或注册执行 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` 已于 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。
|
||||
当前 `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。
|
||||
|
||||
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 用例。
|
||||
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。
|
||||
|
||||
站点 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。
|
||||
镜像更新命令:
|
||||
|
||||
workflow 首次成功运行后,在 Gitea `master` 分支保护中把 `Project CI / Repository checks (pull_request)`、`Project CI / Frontend tests (pull_request)`、`Project CI / Backend tests (pull_request)`、`Project CI / Native shell tests (pull_request)` 四个完整 context 都设为合并必需检查,并从最近一周已上报 context 表复核名称后再保存。不能只填裸 job 名,否则无法匹配 Gitea 实际上报的 `<workflow> / <job> (<event>)`。只提交 workflow 文件不会自动创建 runner,也不会自动修改分支保护;如果 Actions 长时间停留在等待状态,先到仓库或组织的 Actions runner 页面确认存在在线、带 `ubuntu-latest` 标签的 runner。
|
||||
```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 load-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 先运行镜像内 `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,也不在共享文档中记录具体路径或注册内容。
|
||||
|
||||
workflow 首次成功运行后,在 Gitea `master` 分支保护中把 `Project CI / Repository checks (pull_request)`、`Project CI / Frontend tests (pull_request)`、`Project CI / Backend tests (pull_request)`、`Project CI / Native shell tests (pull_request)` 四个完整 context 都设为合并必需检查,并从最近一周已上报 context 表复核名称后再保存。不能只填裸 job 名,否则无法匹配 Gitea 实际上报的 `<workflow> / <job> (<event>)`。只提交 workflow 文件不会自动创建 runner,也不会自动修改分支保护;如果 Actions 长时间停留在等待状态,先到仓库或组织的 Actions runner 页面确认存在在线、带 `genarrative-ci` 标签的 runner,再检查精确 Image ID 是否已装入内层 Docker。
|
||||
|
||||
视觉小说负向扫描与验收门禁:
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
expected_toolchain="$(
|
||||
sed -n 's/^channel = "\([^"]*\)"/\1/p' "${repo_root}/rust-toolchain.toml"
|
||||
)"
|
||||
|
||||
test -n "${expected_toolchain}"
|
||||
[[ "$(node --version)" == v22.* ]]
|
||||
rustup toolchain list | rg -q "^${expected_toolchain}(-[^ ]+)?( |$)"
|
||||
[[ "$(rustup run "${expected_toolchain}" rustc --version)" == "rustc ${expected_toolchain} "* ]]
|
||||
test "$(readlink -f "$(command -v node)")" = "/usr/local/lib/genarrative-node/bin/node"
|
||||
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 \
|
||||
cargo \
|
||||
clang \
|
||||
cmake \
|
||||
curl \
|
||||
ffmpeg \
|
||||
file \
|
||||
google-chrome \
|
||||
lld \
|
||||
npm \
|
||||
patchelf \
|
||||
pkg-config \
|
||||
rg \
|
||||
rustfmt \
|
||||
rustup \
|
||||
wget; do
|
||||
command -v "${command_name}" >/dev/null
|
||||
done
|
||||
|
||||
pkg-config --exists \
|
||||
ayatana-appindicator3-0.1 \
|
||||
libcurl \
|
||||
openssl \
|
||||
webkit2gtk-4.1
|
||||
|
||||
node --version
|
||||
npm --version
|
||||
rustup run "${expected_toolchain}" rustc --version
|
||||
rustup run "${expected_toolchain}" cargo --version
|
||||
rustup run "${expected_toolchain}" rustfmt --version
|
||||
google-chrome --version
|
||||
bwrap --version
|
||||
ffmpeg -version | head -n 1
|
||||
|
||||
if [[ "${GENARRATIVE_GITEA_CI_CHECK_RUNTIME:-0}" == '1' ]]; then
|
||||
bash "${repo_root}/scripts/check-gitea-ci-job-runtime.sh"
|
||||
fi
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
bwrap_args=(
|
||||
--die-with-parent
|
||||
--unshare-all
|
||||
--unshare-user
|
||||
--disable-userns
|
||||
--assert-userns-disabled
|
||||
--cap-drop ALL
|
||||
--clearenv
|
||||
--ro-bind /usr /usr
|
||||
)
|
||||
for merged_path in /bin /sbin /lib /lib64; do
|
||||
if [[ -L "${merged_path}" ]]; then
|
||||
bwrap_args+=(--symlink "$(readlink "${merged_path}")" "${merged_path}")
|
||||
fi
|
||||
done
|
||||
bwrap_args+=(
|
||||
--proc /proc
|
||||
--dev /dev
|
||||
--tmpfs /tmp
|
||||
--
|
||||
/usr/bin/true
|
||||
)
|
||||
bwrap "${bwrap_args[@]}"
|
||||
|
||||
chrome_output="$(
|
||||
timeout 30 google-chrome \
|
||||
--headless=new \
|
||||
--no-sandbox \
|
||||
--disable-dev-shm-usage \
|
||||
--disable-gpu \
|
||||
--dump-dom \
|
||||
'data:text/html,<title>genarrative-ci</title>' \
|
||||
2>/dev/null
|
||||
)"
|
||||
rg -q '<title>genarrative-ci</title>' <<< "${chrome_output}"
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
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}"
|
||||
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'
|
||||
用法:
|
||||
bash scripts/gitea-ci-job-image.sh build
|
||||
bash scripts/gitea-ci-job-image.sh verify [镜像引用]
|
||||
bash scripts/gitea-ci-job-image.sh load-runner [镜像引用]
|
||||
bash scripts/gitea-ci-job-image.sh export <归档路径> [镜像引用]
|
||||
|
||||
环境变量:
|
||||
GENARRATIVE_GITEA_CI_IMAGE_TAG 覆盖本地构建 tag
|
||||
GENARRATIVE_GITEA_RUNNER_CONTAINER 覆盖 Runner 容器名
|
||||
EOF
|
||||
}
|
||||
|
||||
image_id() {
|
||||
docker image inspect --format '{{.Id}}' "$1"
|
||||
}
|
||||
|
||||
verify_image() {
|
||||
local image_ref="$1"
|
||||
docker run --rm \
|
||||
--mount "type=bind,src=${repo_root}/scripts,dst=/usr/local/scripts,readonly" \
|
||||
--mount "type=bind,src=${repo_root}/scripts/check-gitea-ci-job-image.sh,dst=/usr/local/bin/check-gitea-ci-job-image.sh,readonly" \
|
||||
--mount "type=bind,src=${repo_root}/rust-toolchain.toml,dst=/usr/local/rust-toolchain.toml,readonly" \
|
||||
"${image_ref}" \
|
||||
bash /usr/local/bin/check-gitea-ci-job-image.sh
|
||||
}
|
||||
|
||||
command_name="${1:-}"
|
||||
case "${command_name}" in
|
||||
build)
|
||||
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}")"
|
||||
;;
|
||||
verify)
|
||||
image_ref="${2:-${image_tag}}"
|
||||
verify_image "${image_ref}"
|
||||
printf 'image_ref=%s\n' "${image_ref}"
|
||||
printf 'image_id=%s\n' "$(image_id "${image_ref}")"
|
||||
;;
|
||||
load-runner)
|
||||
image_ref="${2:-${image_tag}}"
|
||||
verify_image "${image_ref}"
|
||||
expected_image_id="$(image_id "${image_ref}")"
|
||||
docker save "${image_ref}" | docker exec -i "${runner_container}" docker load
|
||||
loaded_image_id="$(
|
||||
docker exec "${runner_container}" \
|
||||
docker image inspect --format '{{.Id}}' "${image_ref}"
|
||||
)"
|
||||
test "${loaded_image_id}" = "${expected_image_id}"
|
||||
docker exec -i "${runner_container}" \
|
||||
docker run --rm --interactive \
|
||||
--security-opt seccomp=unconfined \
|
||||
--security-opt systempaths=unconfined \
|
||||
"${loaded_image_id}" bash -s \
|
||||
< "${repo_root}/scripts/check-gitea-ci-job-runtime.sh"
|
||||
printf 'runner_image_tag=%s\n' "${image_ref}"
|
||||
printf 'runner_image_id=%s\n' "${loaded_image_id}"
|
||||
printf 'runner_label_image=docker://%s\n' "${loaded_image_id}"
|
||||
;;
|
||||
export)
|
||||
archive_path="${2:-}"
|
||||
image_ref="${3:-${image_tag}}"
|
||||
test -n "${archive_path}"
|
||||
verify_image "${image_ref}"
|
||||
docker save "${image_ref}" | zstd --threads=0 --ultra -10 -o "${archive_path}"
|
||||
archive_dir="$(cd "$(dirname "${archive_path}")" && pwd -P)"
|
||||
archive_name="$(basename "${archive_path}")"
|
||||
(
|
||||
cd "${archive_dir}"
|
||||
sha256sum "${archive_name}" > "${archive_name}.sha256"
|
||||
)
|
||||
printf 'archive=%s\n' "${archive_path}"
|
||||
printf 'image_id=%s\n' "$(image_id "${image_ref}")"
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user