diff --git a/deploy/container/README.md b/deploy/container/README.md index 188707ddc..f888c1add 100644 --- a/deploy/container/README.md +++ b/deploy/container/README.md @@ -75,11 +75,11 @@ Jenkins 分支预览构建固定从宿主 `/data/jenkins/preview-secrets/.env.lo ```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-20260807.1.tar.zst +bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/genarrative-gitea-project-ci-20260920.1.tar.zst bash scripts/gitea-ci-job-image.sh load-runner ``` -默认构建 tag 为 `genarrative/gitea-project-ci:20260807.1`。脚本通过 NUL 分隔白名单 tar 流只发送 Dockerfile、checkout 脚本、根 workspace 的唯一 npm lock 与全部 workspace manifest,以及 server-rs、桌面壳和 AI 游戏创作壳的 Cargo manifests/lock;不会把业务源码、素材或本地私密文件发送给 Docker daemon。新镜像显式安装并精确校验 `npm 10.9.7`,不依赖 Node 发行包隐含的 npm 版本;除固定工具链外,还按一份 npm workspace lock 与三份 Cargo lock 预热下载缓存。npm 只执行一次忽略 lifecycle scripts 的 workspace `npm ci`,三个 `cargo fetch --locked` 最多执行 5 次整命令级有界重试,再分别以断网 `cargo fetch --locked` 验证缓存闭合,镜像不包含 `node_modules` 或 Cargo `target`。`build` 完成后会自动运行环境校验,`load-runner` 还会比对宿主和 runner 内层的完整 Image ID,并在内层执行 bwrap 与 Chrome headless canary。workspace lock 或 manifest 变化落地后必须按下述顺序重建并装载镜像;过渡期旧固定镜像缺少 `GENARRATIVE_GITEA_CI_NPM_VERSION` 时,校验只输出 `npm_version=partial` 和 Actions warning,继续由当前 job 的根 `npm ci` 验证唯一 lock,不能据此宣称 npm 版本或新依赖缓存已经闭合。执行这些命令不要求必须使用 root,但执行账号必须有权访问宿主 Docker API 并管理 runner 容器;没有该权限时交给 runner 运维人员执行。 +默认构建 tag 为 `genarrative/gitea-project-ci:20260920.1`。脚本通过 NUL 分隔白名单 tar 流只发送 Dockerfile、checkout 脚本、根 workspace 的唯一 npm lock 与全部 workspace manifest,以及 server-rs、桌面壳和 AI 游戏创作壳的 Cargo manifests/lock;不会把业务源码、素材或本地私密文件发送给 Docker daemon。新镜像显式安装并精确校验 `npm 10.9.7`,不依赖 Node 发行包隐含的 npm 版本;除固定工具链外,还按一份 npm workspace lock 与三份 Cargo lock 预热下载缓存。npm 只执行一次忽略 lifecycle scripts 的 workspace `npm ci`,三个 `cargo fetch --locked` 最多执行 5 次整命令级有界重试,再分别以断网 `cargo fetch --locked` 验证缓存闭合,镜像不包含 `node_modules` 或 Cargo `target`。`build` 完成后会自动运行环境校验,`load-runner` 还会比对宿主和 runner 内层的完整 Image ID,并在内层执行 bwrap 与 Chrome headless canary。workspace lock 或 manifest 变化落地后必须按下述顺序重建并装载镜像;过渡期旧固定镜像缺少 `GENARRATIVE_GITEA_CI_NPM_VERSION` 时,校验只输出 `npm_version=partial` 和 Actions warning,继续由当前 job 的根 `npm ci` 验证唯一 lock,不能据此宣称 npm 版本或新依赖缓存已经闭合。执行这些命令不要求必须使用 root,但执行账号必须有权访问宿主 Docker API 并管理 runner 容器;没有该权限时交给 runner 运维人员执行。 runner 配置保留原 `ubuntu-latest` 映射,`genarrative-ci` 继续映射到经 `build / verify / load-runner` 验证并写入配置的完整 Image ID。内层 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`,同时校验工具链、一份 npm workspace 缓存锁、三份 Cargo 缓存锁、bwrap 和 Chrome headless。锁不匹配时校验会输出 `partial` 和醒目的 Actions warning,提示在可信分支落地后刷新镜像。各 job 仍各自运行一次干净的根 `npm ci`,以唯一 workspace lock 校验全部 App/package/tool 依赖并隔离 PR 依赖;统一通过 `scripts/ci-npm-ci-with-retry.sh` 最多执行 3 次整命令级有界重试,并使用镜像内 npm cache 和 `prefer-offline`。锁文件新增依赖时允许经受控网络补齐,本阶段不启用共享 Actions cache。 diff --git a/deploy/container/gitea-ci-job.Dockerfile b/deploy/container/gitea-ci-job.Dockerfile index 63f4cd7a6..4ffd55805 100644 --- a/deploy/container/gitea-ci-job.Dockerfile +++ b/deploy/container/gitea-ci-job.Dockerfile @@ -62,7 +62,7 @@ ARG GOOGLE_LINUX_SIGNING_KEY_FINGERPRINT=EB4C1BFD4F042F6DDDCCEC917721F63BD38B479 LABEL org.opencontainers.image.title="Genarrative Gitea CI job image" \ org.opencontainers.image.description="Ubuntu 24.04 CI image with fixed toolchains and prewarmed npm/Cargo caches" \ - org.opencontainers.image.version="2026.07.23.1" + org.opencontainers.image.version="2026.09.20.1" RUN test "$(dpkg --print-architecture)" = "amd64" \ && find /etc/apt/sources.list.d -maxdepth 1 -type f \ @@ -208,7 +208,7 @@ ENV CARGO_HOME=/usr/local/cargo \ ARG IMAGE_REVISION=uncommitted LABEL org.opencontainers.image.vendor="GenarrativeAI" \ - org.opencontainers.image.version="2026.08.07.1" \ + org.opencontainers.image.version="2026.09.20.1" \ 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" \ diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7a88ccf05..5eee8c534 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8986,3 +8986,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:`pick_local_project_directory` 增加可选 `title`(限 24 字符、无控制字符,其余回退默认标题),使「选择项目创建目录」不再冒用「选择游戏项目目录」文案。 - 关联规范:`docs/project-memory/plans/【实施计划】AGC项目创建目录可选-2026-09-17.md`、`docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`。 - 验证:`cargo test --bin genarrative-ai-game-creator-shell creation_root`(2 项)、模板库定向单测 14 项、偏好模型 3 项、`appSurface` 472 项(含设置页「工作区」选择/恢复目录与「设置里选目录后建项带 `projectsRoot`」两条场景)、`tsc`、`check:encoding` 与 `check-doc-index` 通过。 + +## 2026-09-20 Rust 工具链升到 1.98.1:仓库侧四处同步 + runner 镜像必须重建 + +- 背景:macOS 27(CLT for Xcode 27.0)冷构建时 proc-macro dylib 被链接成畸形 Mach-O(`mis-aligned LINKEDIT string pool`),上游 rust-lang/rust#157750 已修复,而仓库锁的 stable `1.96.0` 仍复现(Issue #431)。 +- 决策:根 `rust-toolchain.toml` 的 channel 由 `1.96.0` 改为 `1.98.1`;`deploy/container/gitea-ci-job.Dockerfile` 的 Rust stage 由 `rust:1.96-bookworm@sha256:19817ead…` 换成 `rust:1.98-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e`(按 registry 配置实测该 digest 的 `RUST_VERSION=1.98.1`,与 channel 逐字一致)。 +- 决策(版本标签):镜像 tag / label 一并从 `20260807.1`、`2026.08.07.1` 升为 `20260920.1`、`2026.09.20.1`(同一次核对发现 Dockerfile 里有两处 `org.opencontainers.image.version`:一处 `2026.07.23.1`、一处 `2026.08.07.1`,后写的覆盖先写的,本次一并统一为 `2026.09.20.1`)(`scripts/gitea-ci-job-image.sh`、`deploy/container/gitea-ci-job.Dockerfile`),`deploy/container/README.md` 与开发运维文档同步 digest、Rust 版本和归档示例名。镜像内容变了却沿用旧 tag,排障和回滚都会认错版本。 +- 不变口径:镜像继续 `RUSTUP_AUTO_INSTALL=0`,job 现场不下载工具链;`scripts/check-gitea-ci-job-image.sh` 仍按 `rust-toolchain.toml` 的 channel 逐字校验镜像内工具链名,所以基础镜像的 `RUST_VERSION` 必须与 channel 完全相同。`std::os::windows::fs::MetadataExt::number_of_links`(rust-lang#63010)在 `1.98.1` 上实测仍未稳定,`#[cfg(windows)]` 侧继续使用自行声明 `ByHandleFileInformation` 的实现。 +- 新增护栏:`scripts/project-ci-workflow.test.ts` 增加一条一致性用例——Dockerfile 的 `ARG RUST_IMAGE` 版本段必须等于 `rust-toolchain.toml` 的 channel 主次版本、其 digest 必须同时出现在两份运维文档里、镜像 tag 日期戳必须与 Dockerfile 的 `org.opencontainers.image.version` 一致,避免本次这种「改了 Dockerfile 忘了文档」的漂移。 +- 待办(不在本次仓库改动内):在 station 上执行 `build / verify / export / load-runner`,备份 runner config 后把 `genarrative-ci` 映射切到新 Image ID 并 `docker restart --timeout 660 gitea-runner`;内层只有 1.96 时 PR 的 job 必然失败。macOS 与 Windows AGC 构建机(`jenkins/Jenkinsfile.ai-game-creator-shell-build`,preflight 只校验 rustc/cargo 是否存在)需确认已装 `1.98.1`,macOS 冷构建是本次问题的原始验证目标。`deploy/container/api-server.Dockerfile` 的 `FROM rust:1.93-bookworm` 是另一处未加 digest 的 Rust 版本 pin,本次未动。 +- 验证:分支 `chore/rust-toolchain-1-98` / PR #432;`npx vitest run scripts/project-ci-workflow.test.ts`、`npm run check:encoding`、`git diff --check` 通过。Windows:`1.98.1` 下 `npm run agc:build -- --debug` 的前端构建、Rust 编译与 NSIS 安装包生成成功(见 PR 描述记录);macOS 与镜像重建后的 CI 结果仍待验证。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0399df004..182f08e51 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -360,7 +360,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` ## 2026-08-15 `#[cfg(windows)]` 里的代码不参与 Linux CI 编译,CI 绿不代表能构建 - 现象:把 master(`9f5c84ee7`)合进 `feat/five_min_design` 后,`cargo check --all-targets` 在 Windows 上直接 `error[E0658]: use of unstable library feature 'windows_by_handle'`,位置是 `apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs` 的 `metadata.number_of_links()`。该文件与 `origin/master` **逐字节相同**,即 master 自身在 Windows 上就构建不过。 -- 原因:`std::os::windows::fs::MetadataExt::number_of_links` 至今未稳定(rust-lang#63010),而 `rust-toolchain.toml` 锁的是 stable `1.96.0`。引入它的提交是 `578f8019f`(优化 AGC 项目入口并识别 Godot 工作区),其中 unix 分支用 `MetadataExt::nlink()`(已稳定)、windows 分支用了未稳定的对应物。**Linux CI 上 `#[cfg(windows)]` 整块不参与编译,所以 CI 全绿。** +- 原因:`std::os::windows::fs::MetadataExt::number_of_links` 至今未稳定(rust-lang#63010);当时 `rust-toolchain.toml` 锁的 stable 是 `1.96.0`,2026-09-20 升到 `1.98.1` 后在同一台 Windows 机器上用该 stable 实测仍报 `error[E0658]: use of unstable library feature 'windows_by_handle'`(见 decision-log 同日条),因此本条的处置口径不变。引入它的提交是 `578f8019f`(优化 AGC 项目入口并识别 Godot 工作区),其中 unix 分支用 `MetadataExt::nlink()`(已稳定)、windows 分支用了未稳定的对应物。**Linux CI 上 `#[cfg(windows)]` 整块不参与编译,所以 CI 全绿。** - 更普遍的形状:只要一段代码只在某个 `#[cfg(target_os)]` 下编译,它就完全绕过了其它平台的 CI——不只是 unstable feature,还包括类型错误、借用错误、缺失 import。跨平台分支是「双写」,两侧都得有人真的编译过。 - 处理:本仓库对「文件是不是无硬链接普通文件」统一自行声明 `ByHandleFileInformation` 并调用 `GetFileInformationByHandle`,见 `runner/endpoint.rs`、`tool_plan_handoff/storage_windows.rs`、`project/agent_db.rs`、`git_inspect.rs`、`image_inspect.rs`、`agent/generation/canvas_generation.rs`。`manifest.rs` 当前已采用同一实现,并保留 fail-closed 语义:无法取得句柄信息或确认存在硬链接时均拒绝,同时拒绝 directory / reparse point。 - 验证:改后 `cargo check --offline --all-targets` 通过、`cargo fmt --check` 通过、`project::manifest` 与 godot 相关定向测试 65 passed / 0 failed。判断「是不是本次合并引入」的通用手法:`git diff origin/master -- ` 为空即说明该文件就是 master 原样,问题不在合并。 @@ -4630,7 +4630,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 现象:`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、固定 npm、Rust 1.96、`rustfmt`、Chrome、`bwrap`、`rg`、`ffmpeg`、`clang/lld` 和 Tauri / 后端系统依赖,并按锁预热唯一根 npm workspace、server-rs、桌面壳与 AI 游戏创作壳 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。 +- 处理:用 `deploy/container/gitea-ci-job.Dockerfile` 预装 Node 22、固定 npm、Rust 1.98.1、`rustfmt`、Chrome、`bwrap`、`rg`、`ffmpeg`、`clang/lld` 和 Tauri / 后端系统依赖,并按锁预热唯一根 npm workspace、server-rs、桌面壳与 AI 游戏创作壳 Cargo 四份下载缓存。四个 job 统一 `runs-on: genarrative-ci`,先用镜像内脚本直接从 Gitea checkout,再以 runtime 模式运行 `scripts/check-gitea-ci-job-image.sh`,同时检查四份缓存锁、工具链、完整 bwrap 与 Chrome headless。`RUSTUP_AUTO_INSTALL=0`;`rust-toolchain.toml` 变更时先重建镜像,不把下载 fallback 放回 job。 - 依赖边界:每个 job 仍必须各自执行 `npm ci`,让当前 lockfile 和 PR 依赖在干净环境中验证;区别是命中镜像 cache 时只做本地解包,锁新增依赖时才走受控网络。不要把 `node_modules` 或 Cargo `target` 烘进镜像,也不要向不受信任 PR 挂载跨 job 可写 cache。 - 锁漂移边界:runtime 校验输出任一 `*_cache_lock=partial` 说明镜像内 lock 与当前 checkout 不同,不代表新增依赖已经缓存;必须同时输出 Actions warning,提示可信分支落地后刷新镜像。必须在新镜像中对 server-rs、桌面壳和 AI 游戏创作壳当前 lock 执行真实 `cargo fetch --locked --offline`;`cargo metadata --no-deps` 不会证明依赖 archive 可用,不能作为替代。 - 构建网络边界:`CARGO_NET_RETRY` 只覆盖部分 crate 下载,registry `config.json` / index TLS 握手仍可能直接终止整次 fetch。Dockerfile 对每个 `cargo fetch --locked` 再做最多 5 次整命令级有界重试,最终仍执行断网 fetch,不能降低为无锁重试或省略离线闭合验证。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 4f18e12a8..ba0febb2a 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -310,7 +310,7 @@ CI job 镜像由 `deploy/container/gitea-ci-job.Dockerfile` 定义:Ubuntu job ```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-20260807.1.tar.zst +bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/genarrative-gitea-project-ci-20260920.1.tar.zst bash scripts/gitea-ci-job-image.sh load-runner ``` diff --git a/scripts/gitea-ci-job-image.sh b/scripts/gitea-ci-job-image.sh index 1b3205109..494af67e5 100644 --- a/scripts/gitea-ci-job-image.sh +++ b/scripts/gitea-ci-job-image.sh @@ -4,7 +4,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" dockerfile_context_path="deploy/container/gitea-ci-job.Dockerfile" -image_tag="${GENARRATIVE_GITEA_CI_IMAGE_TAG:-genarrative/gitea-project-ci:20260807.1}" +image_tag="${GENARRATIVE_GITEA_CI_IMAGE_TAG:-genarrative/gitea-project-ci:20260920.1}" runner_container="${GENARRATIVE_GITEA_RUNNER_CONTAINER:-gitea-runner}" write_build_context_file_list() { diff --git a/scripts/project-ci-workflow.test.ts b/scripts/project-ci-workflow.test.ts index 1c0f15423..e6b46dcbf 100644 --- a/scripts/project-ci-workflow.test.ts +++ b/scripts/project-ci-workflow.test.ts @@ -35,6 +35,21 @@ const apiServerDockerfile = readFileSync( 'utf8', ); +const deployContainerReadme = readFileSync( + resolve(process.cwd(), 'deploy/container/README.md'), + 'utf8', +); +const operationsDoc = readFileSync( + resolve( + process.cwd(), + 'docs/【开发运维】本地开发验证与生产运维-2026-05-15.md', + ), + 'utf8', +); +const rustToolchainToml = readFileSync( + resolve(process.cwd(), 'rust-toolchain.toml'), + 'utf8', +); const jobNames = [ 'repository-checks', 'frontend-tests', @@ -551,4 +566,54 @@ describe('project CI workflow', () => { cratesJob.indexOf('run: npm run check:native-shells:agc-rust-crates'), ); }); + // 预构建镜像的 Rust 版本分散在四处:rust-toolchain.toml 的 channel、Dockerfile 的 + // RUST_IMAGE digest、构建脚本的默认 tag,以及两份运维文档。漏改任何一处都会让 job + // 在 runtime 校验里失败(`RUSTUP_AUTO_INSTALL=0` 不允许现场补装),或者让文档指向 + // 已经不存在的镜像,所以这里把它们钉成同一个事实。 + it('keeps the prebuilt CI job image Rust version, digest and tag in sync with the ops docs', () => { + const channel = rustToolchainToml.match(/^channel = "([^"]+)"$/mu)?.[1]; + expect(channel).toBeTruthy(); + const channelValue = channel ?? ''; + + const rustImage = imageDockerfile.match( + /^ARG RUST_IMAGE=([^\s@]+)@(sha256:[a-f0-9]{64})$/mu, + ); + expect(rustImage).not.toBeNull(); + const rustImageRef = rustImage?.[1] ?? ''; + const rustImageDigest = rustImage?.[2] ?? ''; + const [major, minor] = channelValue.split('.'); + expect(rustImageRef).toBe(`rust:${major}.${minor}-bookworm`); + + // 同一 Dockerfile 里可能出现在多个 LABEL 块中(后写的覆盖前者),必须同值, + // 否则镜像真实版本会和脚本、文档的说法分叉。 + const labelVersions = [ + ...imageDockerfile.matchAll( + /org\.opencontainers\.image\.version="([0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+)"/gu, + ), + ].map((match) => match[1] ?? ''); + expect(labelVersions.length).toBeGreaterThan(0); + expect(new Set(labelVersions).size).toBe(1); + const stampMatch = (labelVersions.at(-1) ?? '').match( + /^(\d{4})\.(\d{2})\.(\d{2})\.(\d+)$/u, + ); + expect(stampMatch).not.toBeNull(); + const imageTagStamp = `${stampMatch?.[1] ?? ''}${stampMatch?.[2] ?? ''}${ + stampMatch?.[3] ?? '' + }.${stampMatch?.[4] ?? ''}`; + expect(imageBuildScript).toContain( + `genarrative/gitea-project-ci:${imageTagStamp}`, + ); + + for (const doc of [deployContainerReadme, operationsDoc]) { + expect(doc).toContain(rustImageDigest); + expect(doc).toContain(`Rust \`${channelValue}\``); + expect(doc).toContain(imageTagStamp); + } + + // runtime 校验按 rust-toolchain.toml 的 channel 逐字比对镜像内工具链名, + // 所以基础镜像 digest 里的 RUST_VERSION 必须与 channel 完全相同。 + expect(imageCheckScript).toContain( + 'rustup toolchain list | rg -q "^${expected_toolchain}(-[^ ]+)?( |$)"', + ); + }); });