按门禁组拆分客户端 CI,AGC 的 web / rust 两段并行
Project CI / Native shell tests (push) Failing after 3m36s
Project CI / Frontend tests (push) Successful in 4m13s
Project CI / Repository checks (push) Successful in 3m3s
Project CI / AI game creator shell web tests (push) Successful in 2m31s
Project CI / Backend tests (push) Successful in 7m49s
Project CI / AI game creator shell Rust tests (push) Successful in 14m10s

原生壳门禁原本挤在同一个 job 里串行执行,跑一遍 18 分 37 秒,其中 AI 游戏创作
壳独占约 15 分钟(壳内 Rust 套件 2451 条用例串行 441 秒),而微信 / 移动 / 桌面 /
H5 的全部门禁加起来不到 50 秒。长尾拖住短门禁,runner 也无法并行。

- scripts/check-native-shells.mjs 支持 `--groups=`(contract / shells / agc-web /
  agc-rust / release):每个步骤与静态断言归属且只归属一个分组,不带参数时仍按
  原顺序串行跑全部分组,本地 `npm run check:native-shells` 语义不变。
- 顺带修掉 H5 HostBridge 调用链扫描在 Windows 上恒红的缺陷:collectFiles 返回
  反斜杠路径而期望清单是 POSIX 写法,scannedFiles.has() 永远为假。新增
  normalizeScannedFilePath 只统一分隔符(不能复用会去后缀的 normalizeModulePath),
  在 Linux 上是恒等变换。
- package.json 增加 5 个分组脚本,并把 ai-game-creator-shell:check 拆成
  :check:web 与 :check:rust;聚合脚本保持 web && rust && agent-run:smoke 同序。
  agent-run smoke 会 spawn cargo,因此归入 agc-rust。
- .gitea/workflows/project-ci.yml 拆成 6 个 job:新增 native-shell-tests(contract +
  shells + release)、ai-game-creator-shell-web-tests、ai-game-creator-shell-rust-tests
  三个门禁 job,与 backend-tests / frontend-tests / repository-checks 并列。
- scripts/project-ci-workflow.test.ts 增加 3 条结构测试:分组恰好被一个 job 调用且
  与脚本内声明一致、CI 不再调用全量入口、AGC web/rust 拆分与聚合脚本等价、独立
  crate 预热必须发生在 AGC Rust 门禁之前。
- 同步运维文档、development-workflow、decision-log、pitfalls。

验证:`--groups=contract` 本地通过;vitest 11 passed;eslint 与 prettier 通过;
check:encoding 13329 文件通过;check:doc-index 103 份通过。shells / agc-web /
agc-rust / release 分组只能由 Linux CI 执行(Windows 上 spawnSync npm.cmd 报
EINVAL,属既有平台限制,非本次引入)。分支保护需补两个新 required context:
`Project CI / AI game creator shell web tests (pull_request)` 与
`Project CI / AI game creator shell Rust tests (pull_request)`。

Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com>
This commit is contained in:
2026-09-14 16:13:11 +08:00
parent 4e553bb15d
commit 20f109027a
8 changed files with 554 additions and 168 deletions
+175 -99
View File
@@ -27,9 +27,18 @@ env:
RUSTC_WRAPPER: ''
CARGO_BUILD_RUSTC_WRAPPER: ''
# job 声明顺序就是 runner 领取顺序,因此把最长尾的客户端 Rust 门禁排在前面,
# 让它在最少的等待下占用并发槽位;其余 job 按时长递减排列。
#
# 客户端(微信壳 / Expo 移动壳 / Tauri 桌面壳 / AI 游戏创作壳)门禁原先全部串在
# `Native shell tests` 一个 job 里,实测 18 分 37 秒,其中 AI 游戏创作壳的串行
# Rust 套件(2451 个用例,`--test-threads=1`)单独占 533 秒。现在按门禁组拆成
# `Native shell tests`、`AI game creator shell web tests` 与
# `AI game creator shell Rust tests` 三个 job,各自的命令与拆分前逐一对应。
jobs:
repository-checks:
name: Repository checks
# 该 job 最长:AI 游戏创作壳的共享 / 平台 crate 测试加串行壳测试。
ai-game-creator-shell-rust-tests:
name: AI game creator shell Rust tests
runs-on: genarrative-ci
steps:
- name: Checkout full history from Gitea
@@ -41,76 +50,63 @@ jobs:
- 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
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Prepare AI game creator shell Rust dependencies
shell: bash
run: |
set -euo pipefail
base_ref="$(node -e '
const fs = require("node:fs");
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? "");
')"
if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then
git cat-file -e "${base_ref}^{commit}" 2>/dev/null || {
echo "comparison base commit is unavailable: ${base_ref}" >&2
exit 1
}
else
base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)"
fi
resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)"
head_ref="$(git rev-parse HEAD)"
if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then
resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)"
fi
if [[ -z "${resolved_base_ref}" ]]; then
echo 'comparison base must resolve to a commit distinct from HEAD.' >&2
exit 1
fi
base_ref="${resolved_base_ref}"
if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \
&& ! git merge-base --is-ancestor "${base_ref}" HEAD; then
echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2
exit 1
fi
echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}"
for manifest_path in \
server-rs/Cargo.toml \
apps/ai-game-creator-shell/src-tauri/Cargo.toml; do
for attempt in $(seq 1 5); do
if cargo fetch --locked \
--target x86_64-unknown-linux-gnu \
--manifest-path "${manifest_path}"; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo "Cargo dependency fetch failed after 5 attempts: ${manifest_path}" >&2
exit 1
fi
sleep $((attempt * 2))
done
done
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Prepare standalone Rust crate dependencies
shell: bash
run: |
set -euo pipefail
# agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的
# exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们;
# 而 `npm run ai-game-creator-shell:check:rust` 会用
# `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试
# 会在测试阶段自己 `Updating crates.io index`crates.io 一抖动整条 job 就红
# (见 #327 / PR #316 run 1950)。
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
# 解析,不再触碰 registry index。
for manifest_path in \
server-rs/crates/agent-runtime-core/Cargo.toml \
server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
for attempt in $(seq 1 5); do
if cargo fetch \
--target x86_64-unknown-linux-gnu \
--manifest-path "${manifest_path}"; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo "standalone crate dependency fetch failed after 5 attempts: ${manifest_path}" >&2
exit 1
fi
sleep $((attempt * 2))
done
done
- name: Run repository checks
run: npm run check:repository-ci
frontend-tests:
name: Frontend tests
runs-on: genarrative-ci
steps:
- 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
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Run frontend and script tests
run: npm run test
- name: Run BgFilter worker smoke harness tests
run: npm run bgfilter-worker:smoke-test
- name: Validate production health patrol behavior
run: npm run check:production-health-patrol
- name: Validate production API release behavior
run: npm run check:production-api-release
- name: Validate production API deploy behavior
run: npm run check:production-api-deploy
- name: Run AI game creator shell Rust gates
run: npm run check:native-shells:agc-rust
backend-tests:
name: Backend tests
@@ -194,6 +190,8 @@ jobs:
- name: Check SpacetimeDB module
run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml
# 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁,
# 以及依赖发布产物的构建 smoke。
native-shell-tests:
name: Native shell tests
runs-on: genarrative-ci
@@ -215,7 +213,6 @@ jobs:
run: |
set -euo pipefail
for manifest_path in \
server-rs/Cargo.toml \
apps/desktop-shell/src-tauri/Cargo.toml \
apps/ai-game-creator-shell/src-tauri/Cargo.toml; do
for attempt in $(seq 1 5); do
@@ -232,39 +229,118 @@ jobs:
done
done
- name: Prepare standalone Rust crate dependencies
shell: bash
run: |
set -euo pipefail
# agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的
# exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们;
# 而 check:native-shells 会经 agent-runtime-*:check 用 `cargo test --manifest-path`
# 单独跑这两个 crate。不在这里预热的话,这两条测试会在测试阶段自己
# `Updating crates.io index`crates.io 一抖动整条 native shell 作业就红
# (见 #327 / PR #316 run 1950)。
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
# 解析,不再触碰 registry index。
for manifest_path in \
server-rs/crates/agent-runtime-core/Cargo.toml \
server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
for attempt in $(seq 1 5); do
if cargo fetch \
--target x86_64-unknown-linux-gnu \
--manifest-path "${manifest_path}"; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo "standalone crate dependency fetch failed after 5 attempts: ${manifest_path}" >&2
exit 1
fi
sleep $((attempt * 2))
done
done
- name: Run native shell contract gates
run: npm run check:native-shells:contract
- name: Run native shell gates
run: npm run check:native-shells
run: npm run check:native-shells:shells
- name: Run native shell release build smoke
run: npm run check:native-shells:release
- name: Ensure native lockfiles are unchanged
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock
frontend-tests:
name: Frontend tests
runs-on: genarrative-ci
steps:
- 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
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Run frontend and script tests
run: npm run test
- name: Run BgFilter worker smoke harness tests
run: npm run bgfilter-worker:smoke-test
- name: Validate production health patrol behavior
run: npm run check:production-health-patrol
- name: Validate production API release behavior
run: npm run check:production-api-release
- name: Validate production API deploy behavior
run: npm run check:production-api-deploy
repository-checks:
name: Repository checks
runs-on: genarrative-ci
steps:
- 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
- name: Resolve comparison base
shell: bash
run: |
set -euo pipefail
base_ref="$(node -e '
const fs = require("node:fs");
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? "");
')"
if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then
git cat-file -e "${base_ref}^{commit}" 2>/dev/null || {
echo "comparison base commit is unavailable: ${base_ref}" >&2
exit 1
}
else
base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)"
fi
resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)"
head_ref="$(git rev-parse HEAD)"
if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then
resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)"
fi
if [[ -z "${resolved_base_ref}" ]]; then
echo 'comparison base must resolve to a commit distinct from HEAD.' >&2
exit 1
fi
base_ref="${resolved_base_ref}"
if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \
&& ! git merge-base --is-ancestor "${base_ref}" HEAD; then
echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2
exit 1
fi
echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}"
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Run repository checks
run: npm run check:repository-ci
# 客户端的 AI 游戏创作壳前端门禁:typecheck、壳内测试与本地 provider agent-run smoke。
ai-game-creator-shell-web-tests:
name: AI game creator shell web tests
runs-on: genarrative-ci
steps:
- 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
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Run AI game creator shell web gates
run: npm run check:native-shells:agc-web
@@ -3,6 +3,15 @@
> 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。
> 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。
## 2026-09-14 客户端 CI 按门禁组拆成三个 jobAGC 的 web / rust 两段并行
- 背景:`Project CI / Native shell tests` 把微信壳、Expo 移动壳、Tauri 桌面壳、H5 HostBridge 与 AI 游戏创作壳的全部门禁串在一个 job 里,实测 18 分 37 秒;同一次运行的 Repository / Frontend / Backend 分别只要 3 分 21 秒、4 分 16 秒、6 分 14 秒,其余三个 job 结束后客户端 job 还要再跑十几分钟。日志时间戳显示门禁段 932 秒里:AGC `ai-game-creator-shell:check` 占 654 秒(其中壳内 Rust 套件 2451 个用例 `--test-threads=1` 单跑 441.58 秒、编译 79 秒),AGC vitest 75 秒,两个发布构建 smoke 加落盘断言 230 秒,而 h5 / 微信 / 移动 / 桌面壳的全部运行时门禁加起来不到 50 秒。
- 决策:`scripts/check-native-shells.mjs` 引入 `--groups=`,把门禁分成 `contract`(静态契约断言)、`shells`H5 / 微信 / Expo / 桌面壳运行时门禁)、`agc-web`AGC typecheck 与壳内测试)、`agc-rust`(共享 / 平台 crate 测试、AGC 串行壳测试、agent-run smoke)、`release`(AGC 与桌面壳发布构建 smoke、落盘产物断言)五组,每组暴露一个 `check:native-shells:<group>` 根脚本;不带 `--groups=` 时仍然串行跑全部分组,本地 `npm run check:native-shells` 语义不变。CI 据此把原客户端 job 拆成 `Native shell tests`contract + shells + release)、`AI game creator shell web tests`agc-web)、`AI game creator shell Rust tests`agc-rust)三个 job,并把最长的 AGC Rust job 声明在最前,使 runner 领取顺序与关键路径一致。
- 命令等价:`npm run ai-game-creator-shell:check` 拆成 `:check:web`typecheck + 壳内测试)与 `:check:rust`agent-runtime 两个独立 crate + `platform-llm` + `shared-contracts` + AGC 壳串行测试),聚合脚本仍是 `web && rust && agent-run:smoke` 同序同命令,本地与文档入口不变。`agent-run:smoke` 会用 `src-tauri/Cargo.toml` spawn `cargo`,因此归入 `agc-rust` 分组,与 AGC 依赖预热同 job。
- 影响范围:`.gitea/workflows/project-ci.yml`(六个 job)、`scripts/check-native-shells.mjs`、根 `package.json` 门禁脚本、`scripts/project-ci-workflow.test.ts`(校验分组清单、根脚本内容与 job 覆盖,防止新增分组时静默漏跑)、开发运维文档与开发流程记忆。门禁覆盖不变,只有执行位置改变;Gitea `master` 分支保护的 required context 是追加式的(旧四个继续上报,需补上两个新 AGC context)。
- 验证方式:`npx vitest run scripts/project-ci-workflow.test.ts`11 条);`node scripts/check-native-shells.mjs --groups=contract` 本地 0.6 秒通过;`--groups=` 未知组与空组都要报错关闭。实测耗时按拆分前同一 run 的日志时间戳折算:关键路径从 18 分 37 秒收敛到 AGC Rust job 的约 13 分钟量级(若 runner 并发槽位 ≥ 6,可压缩到约 10.5 分钟)。
- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[踩坑记录](pitfalls.md)。
## 2026-09-10 策划 Agent 迁移只复用生产基建
- 决策:待实施的生产迁移以自由协作策划原型为行为基线,仅复用 Provider、恢复、文件操作、审计和 UI 通信;不继承旧 Planning V2 的强制工具、问询轮数、GDD 内容校验和版本审批。保留五阶段与顾问态、当前阶段资源注入和产物存在性检查,系统阶段空必需清单不增加解析或登记功能。
@@ -74,4 +74,4 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m
## Gitea CI 依赖闭合
`.gitea/workflows/project-ci.yml` `Native shell tests` 在运行原生壳门禁前,必须使`cargo fetch --locked` 预取 `server-rs/Cargo.toml`桌面壳 AGC 壳三份依赖。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module``spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm``shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。
`.gitea/workflows/project-ci.yml`客户端门禁拆成三个 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust tests``cargo fetch --locked` 预取 `server-rs/Cargo.toml` 与 AGC 壳 manifest`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo`,因此必须同 job),`Native shell tests` 预取桌面壳 AGC 壳 manifest`AI game creator shell web tests` 不触碰 Cargo,不预热。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate`agent-runtime-core``agent-runtime-orchestration`)只能在 `AI game creator shell Rust tests` 里用不带锁标志的 fetch。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module``spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm``shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。
@@ -1,5 +1,22 @@
# 踩坑与排障记录
## 2026-09-14 客户端 CI 拆分后,选组运行会跳过未选分组,且必须同步分支保护
- **现象**:把 `Native shell tests` 拆成客户端三个 job 后,如果只跑 `npm run check:native-shells:release`,静态契约和壳运行时门禁都不会执行;如果只跑 `--groups=contract``desktop-release-binary-artifact` 又会因为缺少 `build/native/desktop/` 产物而失败。
- **原因**:分组是执行范围,不是"额外检查"。`desktop-release-binary-artifact` 断言依赖同 job 内的 `desktop-shell-stage-release-binary` 步骤,所以它归 `release` 组,不能放进 `contract`;反过来,任何"只跑一组"的命令都不能被当成完整门禁。
- **处理**:分组与 job 的对应关系固定为 `contract`+`shells`+`release``Native shell tests``agc-web``AI game creator shell web tests``agc-rust``AI game creator shell Rust tests``scripts/project-ci-workflow.test.ts` 校验"每个分组恰好被一个 job 调用一次"和"CI 不再调用全量 `npm run check:native-shells`",新增分组必须同步门禁脚本、根脚本与 workflow 三处。
- **易错点**:① 拆 job 后 Gitea `master` 分支保护的 required context 要补齐两个新 AGC context,只改 workflow 不改分支保护会让新门禁在合并前不生效;② 每个 job 只预热自己会构建的 Cargo 依赖,`agent-run:smoke` 因为会 spawn `cargo` 必须留在 `agc-rust` 所在 job;③ 本地全量 `npm run check:native-shells` 仍会串行跑完所有分组,用它作为本地完整门禁,不要用单组脚本冒充。
- **关联**`.gitea/workflows/project-ci.yml``scripts/check-native-shells.mjs``scripts/project-ci-workflow.test.ts``.gitea` 分支保护设置。
## 2026-09-14 `check:native-shells` 的调用链扫描在 Windows 上恒假
- **现象**Windows 本机运行 `npm run check:native-shells:contract` 时,`production-shell-dev-scaffold-scan``H5 HostBridge call chain scan is missing required files: src/ActiveApp.tsx, ...`,而仓库里这些文件都存在,Linux CI 从不报。
- **原因**`collectFiles` 在 Windows 上返回 `src\ActiveApp.tsx`,调用链扫描把该路径原样放进 `scannedFiles`,再与 POSIX 写法的期望清单(`h5HostBridgeRequiredCallChainFiles``src/ActiveApp.tsx`)比较,成员判断恒假。注意 `normalizeModulePath` 不能直接复用:它还会去掉 `.ts/.tsx` 后缀。
- **处理**:新增 `normalizeScannedFilePath`(只统一分隔符、保留后缀)用于 `scannedFiles` 的登记;Linux 上 `split('/').join('/')` 是恒等变换,行为不变。
- **验证**`node scripts/check-native-shells.mjs --groups=contract` 在 Windows 上 0.6 秒通过(修复前同一条命令必红)。
- **同一类限制(未改)**Windows 本机跑 `shells` / `agc-web` / `agc-rust` / `release` 这些**带步骤**的分组会在第一条 npm 步骤直接失败:`spawnSync npm.cmd EINVAL`Node 24 起不能不带 shell 直接执行 `.cmd`;而根 `npm run test` 另有 chmod/0600 语义的 Windows 专属失败)。因此 Windows 本机可用的只有 `--groups=contract`,完整门禁交给 Linux CI;不要为此把 `spawnSync` 改成 `shell: true`(步骤参数里含空格与中文字符串,会被 shell 重新解析)。
- **关联**`scripts/check-native-shells.mjs``collectH5HostBridgeCallChainFiles` / `normalizeScannedFilePath`
## 2026-09-14 项目写锁的同进程复用判据不能只看 pid
- **现象**`master``Project CI / Native shell tests` 红在 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1`12 条用例失败(`2439 passed; 12 failed`)。断言分三类:① 另一线程持锁时快照读 / `project.diff` / `action_history` / `command.output_read` / steer 不再等待(`... must wait for the project consistency lock`);② 并发写不再串行化——4 路并行直写撞项目 revision 侧车报 `File exists (os error 17)`8 线程并发 steer 拿到 `[1, 1, 1, 1, 1, 1, 1, 2]`;③ 别的写通道持锁时 `file.write` 与恢复安装必须失败关闭,实测变成 `ok` / 不再报占用。
@@ -262,16 +262,18 @@ npm run check
### Gitea Actions PR 门禁
仓库级 Gitea Actions 工作流固定为 `.gitea/workflows/project-ci.yml`,在向 `master` 推送、创建或更新 PR,以及手工触发时运行。工作流拆成个必须通过的 job
仓库级 Gitea Actions 工作流固定为 `.gitea/workflows/project-ci.yml`,在向 `master` 推送、创建或更新 PR,以及手工触发时运行。工作流拆成个必须通过的 job。job 声明顺序就是 runner 领取顺序,因此最长尾的 `AI game creator shell Rust tests` 排在最前:并发槽位不足时,它必须最先开始,wall clock 才由它而不是由排队决定。
所有 CI job 和 Jenkins Web Build 在根 workspace 安装前都必须确认 `npm --version``10.9.7`。Gitea job 使用预构建镜像内的固定版本;Jenkins Web Build 在每个独立 `bash -lc` 中 source `scripts/jenkins-prepare-npm-env.sh`,首次为 Jenkins 运行用户的版本隔离目录引导同版 npm,后续复用并把该 `bin` 放到 `PATH` 首位。旧固定镜像缺少版本元数据时只能报告 `npm_version=partial` 并由当前 job 的根 `npm ci` 继续校验 lock,不能把过渡状态当作工具链已闭合。
- `Repository checks`:调用唯一入口 `npm run check:repository-ci`,执行 `npm run lint`、AI 游戏创作壳 AppSurface 定向测试、主站与后台生产构建和提交差异空白检查。本地 master `pre-push` 复用同一入口,禁止在 workflow 与 hook 中维护两份近似命令。
- `Frontend tests`:按唯一根 workspace lockfile 执行一次干净的 `npm ci`,再独立执行根 `npm run test``npm run bgfilter-worker:smoke-test``npm run check:production-health-patrol``npm run check:production-api-release``npm run check:production-api-deploy`,让 Vitest、Node test smoke harness 及不依赖真实服务的生产巡检 / 发布 / 部署行为 fixture 在 Gitea job 中持续执行;其中 `.test.mjs` 使用 Node test runner,不依赖 Vitest 的 `scripts/**/*.test.ts` 收集规则。
- `Backend tests`:先对 `server-rs/Cargo.lock` 执行带 5 次整命令级有界重试的 `cargo fetch --locked`,再执行 `npm run check:server-rs-ddd``cargo test --locked --workspace --exclude spacetime-module --no-fail-fast``cargo test --locked -p spacetime-module --no-fail-fast``api-server --all-targets` 编译和 `cargo check --locked -p spacetime-module`;普通 workspace host 测试排除 `spacetime-module` 以避免其 `spacetime-types` feature 统一污染领域 crate,模块自身的纯单元测试通过独立 package test 纳入门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,不能把 host 链接支持当作运行时替身。依赖准备必须位于会触发 Cargo build 的 DDD / 产物边界门禁之前,避免锁新增依赖未命中镜像缓存时绕过既有下载重试。runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。依赖真实服务或密钥的测试必须显式 `ignored`,不能让普通 PR job访问现场环境。
- `Native shell tests`:按唯一根 workspace lockfile 安装全部 App 依赖后执行 `npm run check:native-shells`,对所有触发方式一致覆盖微信壳、Expo 和 Tauri 的完整验收,并执行 `npm run ai-game-creator-shell:check` 与 AI 游戏创作壳 release build smoke最后确认桌面壳与 AI 游戏创作壳的 `Cargo.lock` 都没有被构建过程改写。共享 Agent Runtime 后台锁 suite 固定 `--test-threads=1`,不能用并行偶发失败后的逐项通过替代整套稳定门禁。
- `Native shell tests`:按唯一根 workspace lockfile 安装全部 App 依赖后,用 `npm run check:native-shells:contract``npm run check:native-shells:shells``npm run check:native-shells:release` 分别执行静态契约、H5 / 微信 / Expo / Tauri 桌面壳运行时门禁,以及依赖发布产物的构建 smoke最后确认桌面壳与 AI 游戏创作壳的 `Cargo.lock` 都没有被构建过程改写。
- `AI game creator shell web tests`:执行 `npm run check:native-shells:agc-web`(即 `npm run ai-game-creator-shell:check:web`AGC 壳 typecheck 与壳内测试)。该分组不触碰 Cargo,因此不预热 Rust 依赖。
- `AI game creator shell Rust tests`:预热 `server-rs/Cargo.toml`、AGC 壳 manifest 与两个无锁独立 crate 后执行 `npm run check:native-shells:agc-rust`(即 `npm run ai-game-creator-shell:check:rust``npm run ai-game-creator-shell:agent-run:smoke`),覆盖共享 / 平台 crate 测试、AGC 壳串行 Rust 套件和本地 provider agent-run smoke。它会用 `src-tauri/Cargo.toml` spawn `cargo`,所以必须与 Rust 依赖预热同 job。共享 Agent Runtime 后台锁 suite 固定 `--test-threads=1`,不能用并行偶发失败后的逐项通过替代整套稳定门禁。
个 job 合起来覆盖根 `npm run check`,并补齐根检查没有包含的 BgFilter worker smoke harness、无密钥生产巡检 / 发布 / 部署行为 fixture、server-rs DDD、正式 workspace Rust 测试与现役后端编译门禁。普通 PR CI 不注入业务密钥,不启动真实 API、SpacetimeDB、OSS、支付、图片生成或生产 live smoke;需要现场环境、可变外部状态、Docker 编排或发布凭据的 `check:*` 继续按对应专题和 Jenkins 发布流程执行,不能遍历所有同名前缀脚本冒充 PR 门禁。
个 job 合起来覆盖根 `npm run check`,并补齐根检查没有包含的 BgFilter worker smoke harness、无密钥生产巡检 / 发布 / 部署行为 fixture、server-rs DDD、正式 workspace Rust 测试与现役后端编译门禁。客户端门禁的拆分口径是 `scripts/check-native-shells.mjs``--groups=`:五个分组(`contract``shells``agc-web``agc-rust``release`)各自对应一个 `check:native-shells:<group>` 根脚本,并在 workflow 的某个 job 里被恰好调用一次;不带 `--groups=` 时脚本仍然串行跑全部分组,本地语义不变。`scripts/project-ci-workflow.test.ts` 会同时校验分组清单、根脚本内容与 job 覆盖,新增分组必须三处同步。普通 PR CI 不注入业务密钥,不启动真实 API、SpacetimeDB、OSS、支付、图片生成或生产 live smoke;需要现场环境、可变外部状态、Docker 编排或发布凭据的 `check:*` 继续按对应专题和 Jenkins 发布流程执行,不能遍历所有同名前缀脚本冒充 PR 门禁。
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`,若它与 `HEAD` 相同则改用 `HEAD^`,仍无法得到不同提交时失败关闭。
@@ -290,15 +292,15 @@ bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/genarrative-git
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 APIrootless DinD supervisor 可能同时停止内层 dockerd,因此重启前必须确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空。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 APIrootless 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 与 npm 固定版本、仓库 Rust toolchain、受信任 PATH、四份缓存锁命中状态、原生命令、pkg-config 依赖、完整 bwrap sandbox 和 Chrome headless。运行时发现锁不匹配时必须输出对应 `*_cache_lock=partial` 和 Actions warning,提示可信分支落地后刷新镜像,不能把陈旧缓存误报为闭合。`RUSTUP_AUTO_INSTALL=0`,因此仓库 `rust-toolchain.toml` 变更必须先更新镜像,不能让 job 现场下载。每个 job 仍独立运行一次根 `npm ci`,以唯一 workspace lock 验证 PR 的全部 App 依赖;统一通过 `scripts/ci-npm-ci-with-retry.sh` 做最多 3 次整命令级有界重试,同时保留 `NPM_CONFIG_PREFER_OFFLINE=true` 和 npm 自身 10 次 fetch retry。命中镜像 cache 时只做干净解包,lock 变化时允许补齐差量。不在镜像内烘入 `node_modules`,也不挂载跨 PR 可写缓存。任何 job 的 sandbox canary 失败都必须停止,不允许跳过。Cargo 通过受控 proxy 下载 lock 差量时继续关闭 HTTP multiplexing,并设置 `CARGO_NET_RETRY=10`
个 job 先运行镜像内 `genarrative-gitea-checkout`,再以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,校验 Node 与 npm 固定版本、仓库 Rust toolchain、受信任 PATH、四份缓存锁命中状态、原生命令、pkg-config 依赖、完整 bwrap sandbox 和 Chrome headless。运行时发现锁不匹配时必须输出对应 `*_cache_lock=partial` 和 Actions warning,提示可信分支落地后刷新镜像,不能把陈旧缓存误报为闭合。`RUSTUP_AUTO_INSTALL=0`,因此仓库 `rust-toolchain.toml` 变更必须先更新镜像,不能让 job 现场下载。每个 job 仍独立运行一次根 `npm ci`,以唯一 workspace lock 验证 PR 的全部 App 依赖;统一通过 `scripts/ci-npm-ci-with-retry.sh` 做最多 3 次整命令级有界重试,同时保留 `NPM_CONFIG_PREFER_OFFLINE=true` 和 npm 自身 10 次 fetch retry。命中镜像 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。
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)``Project CI / AI game creator shell web tests (pull_request)``Project CI / AI game creator shell Rust tests (pull_request)` 六个完整 context 都设为合并必需检查,并从最近一周已上报 context 表复核名称后再保存。客户端 CI 拆分的迁移是**追加式**的:旧四个 job 名继续上报,但 `Native shell tests` 的内容已收窄到壳级与发布构建门禁,因此新增的两个 AGC context 必须补进必需检查,否则 AGC 门禁在合并前不生效。不能只填裸 job 名,否则无法匹配 Gitea 实际上报的 `<workflow> / <job> (<event>)`。只提交 workflow 文件不会自动创建 runner,也不会自动修改分支保护;如果 Actions 长时间停留在等待状态,先到仓库或组织的 Actions runner 页面确认存在在线、带 `genarrative-ci` 标签的 runner,再检查精确 Image ID 是否已装入内层 Docker。
master 日常交付必须禁止直接 push,只允许经 PR 在当前 head 的个 required context 全绿后合并;本地 `pre-commit` 的 staged ESLint/Prettier 和 master `pre-push` 的 Repository checks parity 只用于提前发现问题,可被 `--no-verify` 绕过,不能充当服务端权威门禁。紧急直推白名单如需保留,应按人员和时限最小化,并要求执行同一 `npm run check:repository-ci <base> <head>` 后回读 push CI。
master 日常交付必须禁止直接 push,只允许经 PR 在当前 head 的个 required context 全绿后合并;本地 `pre-commit` 的 staged ESLint/Prettier 和 master `pre-push` 的 Repository checks parity 只用于提前发现问题,可被 `--no-verify` 绕过,不能充当服务端权威门禁。紧急直推白名单如需保留,应按人员和时限最小化,并要求执行同一 `npm run check:repository-ci <base> <head>` 后回读 push CI。
SpacetimeDB bindings
+9 -2
View File
@@ -197,8 +197,15 @@
"agent-runtime-core:check": "cargo test --manifest-path server-rs/crates/agent-runtime-core/Cargo.toml",
"agent-runtime-orchestration:check": "cargo test --manifest-path server-rs/crates/agent-runtime-orchestration/Cargo.toml",
"ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck",
"ai-game-creator-shell:check": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests && npm run agent-runtime-core:check && npm run agent-runtime-orchestration:check && cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1 && npm run ai-game-creator-shell:agent-run:smoke",
"check:native-shells": "node scripts/check-native-shells.mjs"
"ai-game-creator-shell:check:web": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests",
"ai-game-creator-shell:check:rust": "npm run agent-runtime-core:check && npm run agent-runtime-orchestration:check && cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1",
"ai-game-creator-shell:check": "npm run ai-game-creator-shell:check:web && npm run ai-game-creator-shell:check:rust && npm run ai-game-creator-shell:agent-run:smoke",
"check:native-shells": "node scripts/check-native-shells.mjs",
"check:native-shells:contract": "node scripts/check-native-shells.mjs --groups=contract",
"check:native-shells:shells": "node scripts/check-native-shells.mjs --groups=shells",
"check:native-shells:agc-web": "node scripts/check-native-shells.mjs --groups=agc-web",
"check:native-shells:agc-rust": "node scripts/check-native-shells.mjs --groups=agc-rust",
"check:native-shells:release": "node scripts/check-native-shells.mjs --groups=release"
},
"dependencies": {
"@genarrative/image-canvas-core": "0.1.0",
+238 -51
View File
@@ -79,6 +79,72 @@ const aiGameCreatorViteConfigSource = fs.readFileSync(
'utf8',
);
// 按门禁组运行:默认跑全部分组(本地语义不变),CI 用 `--groups=` 把互不依赖的
// 分组拆成独立 job。每个步骤和静态断言必须属于且只属于一个分组,分组名同时是
// 根 `check:native-shells:<group>` 脚本和 workflow job 的拆分口径。
// - contract:纯源码 / 契约 / 清单断言,不需要任何构建产物。
// - shells:微信壳、Expo 移动壳、Tauri 桌面壳与 H5 HostBridge 的现役运行时门禁。
// - agc-webAI 游戏创作壳的前端门禁(typecheck 与壳内测试,不触碰 Cargo)。
// - agc-rustAI 游戏创作壳的 Rust 门禁(共享 / 平台 crate 测试、串行壳测试和
// 会用 `src-tauri/Cargo.toml` spawn `cargo` 的 agent-run smoke)。
// - release:发布构建 smoke 和依赖发布产物的落盘断言。
const nativeShellGateGroups = [
'contract',
'shells',
'agc-web',
'agc-rust',
'release',
];
const requestedNativeShellGroups = readRequestedNativeShellGroups(
process.argv.slice(2),
);
function readRequestedNativeShellGroups(argv) {
const groupsFlag = argv.find((argument) => argument.startsWith('--groups='));
if (groupsFlag === undefined) {
return nativeShellGateGroups;
}
const requested = groupsFlag
.slice('--groups='.length)
.split(',')
.map((group) => group.trim())
.filter(Boolean);
if (requested.length === 0) {
throw new Error(
`--groups requires at least one of: ${nativeShellGateGroups.join(', ')}`,
);
}
const unknownGroups = requested.filter(
(group) => !nativeShellGateGroups.includes(group),
);
if (unknownGroups.length > 0) {
throw new Error(
`unknown native shell gate group(s): ${unknownGroups.join(', ')}; expected ${nativeShellGateGroups.join(', ')}`,
);
}
return nativeShellGateGroups.filter((group) => requested.includes(group));
}
function runsNativeShellGateGroup(group) {
if (!nativeShellGateGroups.includes(group)) {
throw new Error(`unknown native shell gate group: ${group}`);
}
return requestedNativeShellGroups.includes(group);
}
function runNativeShellGate(group, label, gate) {
if (!runsNativeShellGateGroup(group)) {
return;
}
console.log(`[check:native-shells] ${label}`);
gate();
}
const productionShellScanRoots = [
'apps/mobile-shell',
'apps/desktop-shell',
@@ -161,7 +227,11 @@ function assertRootNativeShellCheckScripts() {
}
}
assertRootNativeShellCheckScripts();
runNativeShellGate(
'contract',
'root-native-shell-check-scripts',
assertRootNativeShellCheckScripts,
);
function assertNativeShellDependencyVersionGuardrails() {
for (const snippet of [
"const rootPackageLockPath = new URL('../../../package-lock.json', import.meta.url)",
@@ -209,7 +279,11 @@ function assertNativeShellDependencyVersionGuardrails() {
}
}
assertNativeShellDependencyVersionGuardrails();
runNativeShellGate(
'contract',
'native-shell-dependency-version-guardrails',
assertNativeShellDependencyVersionGuardrails,
);
const h5HostBridgeCallChainWrapperFiles = [
'src/hooks/useHostNavigationCanGoBack.ts',
'src/components/platform-entry/platformProfileHostClipboard.ts',
@@ -2156,6 +2230,7 @@ const h5HostBridgeTests = [
const h5NativeAppRouteFlowTestSteps = h5NativeAppRouteFlowContracts.flatMap(
(contract) =>
(contract.targetedTests ?? []).map((test) => ({
group: 'shells',
label: `h5-native-app-route-${contract.route}`,
command: npmCommand,
args: ['run', 'test', '--', test.filePath, '-t', test.name],
@@ -2176,67 +2251,98 @@ const wechatShellTests = [
const steps = [
{
group: 'shells',
label: 'h5-host-bridge-tests',
command: npmCommand,
args: ['run', 'test', '--', ...h5HostBridgeTests],
},
...h5NativeAppRouteFlowTestSteps,
{
group: 'shells',
label: 'wechat-shell-tests',
command: npmCommand,
args: ['run', 'test', '--', ...wechatShellTests],
},
{
group: 'shells',
label: 'mobile-shell-typecheck',
command: npmCommand,
args: ['run', 'mobile-shell:typecheck'],
},
{
group: 'shells',
label: 'mobile-shell-test',
command: npmCommand,
args: ['run', 'mobile-shell:test'],
},
{
group: 'shells',
label: 'mobile-shell-eas-build-config-smoke',
command: npmCommand,
args: ['run', 'mobile-shell:build-config'],
},
{
group: 'shells',
label: 'mobile-shell-expo-config-smoke',
command: npmCommand,
args: ['run', 'mobile-shell:config'],
},
{
group: 'shells',
label: 'mobile-shell-expo-export-smoke',
command: npmCommand,
args: ['run', 'mobile-shell:export'],
},
{
group: 'shells',
label: 'desktop-shell-test',
command: npmCommand,
args: ['run', 'desktop-shell:test'],
},
{
group: 'shells',
label: 'desktop-shell-typecheck',
command: npmCommand,
args: ['run', 'desktop-shell:typecheck'],
},
// AI 游戏创作壳原先一步串完 typecheck、壳内测试、共享 / 平台 crate 测试和
// 串行壳测试,CI 因此只有一条 10 分钟以上的长尾。这里按同一组命令切成
// web 与 rust 两段,顺序与 `npm run ai-game-creator-shell:check` 完全一致,
// 但允许 CI 并行执行;本地全量运行仍然是 web -> rust -> smoke 原顺序。
{
label: 'ai-game-creator-shell-check',
group: 'agc-web',
label: 'ai-game-creator-shell-check-web',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:check'],
args: ['run', 'ai-game-creator-shell:check:web'],
},
{
group: 'agc-rust',
label: 'ai-game-creator-shell-check-rust',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:check:rust'],
},
// agent-run smoke 会用 `src-tauri/Cargo.toml` spawn `cargo`,因此与 Rust 段同组,
// 保证它落在已经预热 AGC Cargo 依赖的 job 里。
{
group: 'agc-rust',
label: 'ai-game-creator-shell-agent-run-smoke',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:agent-run:smoke'],
},
{
group: 'release',
label: 'ai-game-creator-shell-release-build-smoke',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:build', '--', '--no-bundle'],
},
{
group: 'release',
label: 'desktop-shell-release-build-smoke',
command: npmCommand,
args: ['run', 'desktop-shell:build', '--', '--no-bundle'],
},
{
group: 'release',
label: 'desktop-shell-stage-release-binary',
command: npmCommand,
args: ['run', 'desktop-shell:stage-release-binary'],
@@ -2664,6 +2770,12 @@ function normalizeModulePath(modulePath) {
.replace(/\.(jsx?|tsx?)$/, '');
}
// 调用链扫描用 POSIX 相对路径做集合成员比较,但 `collectFiles` 在 Windows 上返回
// 反斜杠路径。这里只统一分隔符、保留扩展名,Linux 上与原行为完全一致。
function normalizeScannedFilePath(filePath) {
return filePath.split(path.sep).join('/');
}
function importedModulePath(fromFile, specifier) {
if (specifier === '@') {
return '.';
@@ -2758,12 +2870,12 @@ function collectH5HostBridgeCallChainFiles() {
importsScannedFacadeCapability ||
imports.some((specifier) => wrapperModules.has(specifier))
) {
scannedFiles.add(file);
scannedFiles.add(normalizeScannedFilePath(file));
}
}
for (const wrapperFile of h5HostBridgeCallChainWrapperFiles) {
scannedFiles.add(wrapperFile);
scannedFiles.add(normalizeScannedFilePath(wrapperFile));
}
const missingRequiredFiles = h5HostBridgeRequiredCallChainFiles.filter(
@@ -5021,6 +5133,10 @@ function assertDesktopReleaseBinaryArtifact() {
}
for (const step of steps) {
if (!runsNativeShellGateGroup(step.group)) {
continue;
}
console.log(`[check:native-shells] ${step.label}`);
const result = spawnSync(step.command, step.args, {
cwd: process.cwd(),
@@ -5046,71 +5162,142 @@ for (const step of steps) {
}
}
console.log('[check:native-shells] desktop-release-binary-artifact');
assertDesktopReleaseBinaryArtifact();
runNativeShellGate(
'release',
'desktop-release-binary-artifact',
assertDesktopReleaseBinaryArtifact,
);
console.log('[check:native-shells] host-bridge-layer-layout');
assertHostBridgeLayerLayout();
runNativeShellGate(
'contract',
'host-bridge-layer-layout',
assertHostBridgeLayerLayout,
);
console.log('[check:native-shells] native-shell-capability-plan');
assertNativeShellCapabilityPlan();
runNativeShellGate(
'contract',
'native-shell-capability-plan',
assertNativeShellCapabilityPlan,
);
console.log('[check:native-shells] external-url-protocol-parity');
assertExternalUrlProtocolParity();
runNativeShellGate(
'contract',
'external-url-protocol-parity',
assertExternalUrlProtocolParity,
);
console.log('[check:native-shells] wechat-mini-program-route-parity');
assertWechatMiniProgramRouteParity();
runNativeShellGate(
'contract',
'wechat-mini-program-route-parity',
assertWechatMiniProgramRouteParity,
);
console.log('[check:native-shells] wechat-mini-program-capability-flows');
assertWechatMiniProgramCapabilityFlows();
runNativeShellGate(
'contract',
'wechat-mini-program-capability-flows',
assertWechatMiniProgramCapabilityFlows,
);
console.log('[check:native-shells] expo-mobile-capability-flows');
assertExpoMobileCapabilityFlows();
runNativeShellGate(
'contract',
'expo-mobile-capability-flows',
assertExpoMobileCapabilityFlows,
);
console.log('[check:native-shells] tauri-desktop-capability-flows');
assertTauriDesktopCapabilityFlows();
runNativeShellGate(
'contract',
'tauri-desktop-capability-flows',
assertTauriDesktopCapabilityFlows,
);
console.log('[check:native-shells] h5-native-app-route-flows');
assertH5NativeAppRouteFlows();
runNativeShellGate(
'contract',
'h5-native-app-route-flows',
assertH5NativeAppRouteFlows,
);
console.log('[check:native-shells] wechat-payment-result-boundaries');
assertWechatPaymentResultBoundaries();
runNativeShellGate(
'contract',
'wechat-payment-result-boundaries',
assertWechatPaymentResultBoundaries,
);
console.log('[check:native-shells] wechat-auth-failure-boundaries');
assertWechatAuthFailureBoundaries();
runNativeShellGate(
'contract',
'wechat-auth-failure-boundaries',
assertWechatAuthFailureBoundaries,
);
console.log('[check:native-shells] wechat-web-view-page-event-boundaries');
assertWechatWebViewPageEventBoundaries();
runNativeShellGate(
'contract',
'wechat-web-view-page-event-boundaries',
assertWechatWebViewPageEventBoundaries,
);
console.log('[check:native-shells] wechat-share-grid-failure-boundaries');
assertWechatShareGridFailureBoundaries();
runNativeShellGate(
'contract',
'wechat-share-grid-failure-boundaries',
assertWechatShareGridFailureBoundaries,
);
console.log('[check:native-shells] desktop-navigation-event-boundaries');
assertDesktopNavigationEventBoundaries();
runNativeShellGate(
'contract',
'desktop-navigation-event-boundaries',
assertDesktopNavigationEventBoundaries,
);
console.log('[check:native-shells] h5-host-bridge-event-subscription-gates');
assertH5HostBridgeEventSubscriptionGates();
runNativeShellGate(
'contract',
'h5-host-bridge-event-subscription-gates',
assertH5HostBridgeEventSubscriptionGates,
);
console.log('[check:native-shells] h5-host-bridge-payload-boundaries');
assertH5HostBridgePayloadBoundaries();
runNativeShellGate(
'contract',
'h5-host-bridge-payload-boundaries',
assertH5HostBridgePayloadBoundaries,
);
console.log('[check:native-shells] h5-native-app-transport-timeout-boundaries');
assertH5NativeAppTransportTimeoutBoundaries();
runNativeShellGate(
'contract',
'h5-native-app-transport-timeout-boundaries',
assertH5NativeAppTransportTimeoutBoundaries,
);
console.log('[check:native-shells] h5-native-app-message-source-boundaries');
assertH5NativeAppMessageSourceBoundaries();
runNativeShellGate(
'contract',
'h5-native-app-message-source-boundaries',
assertH5NativeAppMessageSourceBoundaries,
);
console.log('[check:native-shells] h5-native-app-transport-facade-boundary');
assertH5NativeAppTransportFacadeBoundary();
runNativeShellGate(
'contract',
'h5-native-app-transport-facade-boundary',
assertH5NativeAppTransportFacadeBoundary,
);
console.log('[check:native-shells] generated-native-shell-artifact-boundary');
assertNoTrackedGeneratedNativeShellArtifacts();
assertGeneratedNativeShellArtifactsAreIgnored();
runNativeShellGate(
'contract',
'generated-native-shell-artifact-boundary',
() => {
assertNoTrackedGeneratedNativeShellArtifacts();
assertGeneratedNativeShellArtifactsAreIgnored();
},
);
console.log('[check:native-shells] ai-game-creator-shell-user-dev-boundary');
assertAiGameCreatorShellUserDevBoundary();
runNativeShellGate(
'contract',
'ai-game-creator-shell-user-dev-boundary',
assertAiGameCreatorShellUserDevBoundary,
);
console.log('[check:native-shells] production-shell-dev-scaffold-scan');
assertNoProductionShellDevScaffoldTerms();
runNativeShellGate(
'contract',
'production-shell-dev-scaffold-scan',
assertNoProductionShellDevScaffoldTerms,
);
console.log(
`[check:native-shells] groups=${requestedNativeShellGroups.join(',')}`,
);
console.log('[check:native-shells] OK');
+96 -8
View File
@@ -40,8 +40,28 @@ const jobNames = [
'frontend-tests',
'backend-tests',
'native-shell-tests',
'ai-game-creator-shell-web-tests',
'ai-game-creator-shell-rust-tests',
] as const;
const rootPackageJson = JSON.parse(
readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'),
) as { scripts?: Record<string, string> };
const nativeShellGateScript = readFileSync(
resolve(process.cwd(), 'scripts/check-native-shells.mjs'),
'utf8',
);
// 客户端门禁拆分口径:门禁脚本里的每个分组都由一个根 npm 脚本暴露,并在 workflow
// 的某个 job 里被恰好调用一次。新增分组时必须同步这三处,否则拆分就会静默漏跑门禁。
const nativeShellGateGroupScripts = {
contract: 'npm run check:native-shells:contract',
shells: 'npm run check:native-shells:shells',
'agc-web': 'npm run check:native-shells:agc-web',
'agc-rust': 'npm run check:native-shells:agc-rust',
release: 'npm run check:native-shells:release',
} as const;
function jobSection(jobName: (typeof jobNames)[number]) {
const jobStart = workflow.indexOf(` ${jobName}:`);
expect(jobStart).toBeGreaterThanOrEqual(0);
@@ -67,6 +87,10 @@ function stepSection(jobName: (typeof jobNames)[number], stepName: string) {
);
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
}
function backendStepIndex(stepName: string) {
const backendJobStart = workflow.indexOf(' backend-tests:');
const nativeShellJobStart = workflow.indexOf(' native-shell-tests:');
@@ -87,7 +111,9 @@ describe('project CI workflow', () => {
});
it('keeps every job on the isolated preinstalled CI image boundary', () => {
expect(workflow.match(/^ {4}runs-on: genarrative-ci$/gm)).toHaveLength(4);
expect(workflow.match(/^ {4}runs-on: genarrative-ci$/gm)).toHaveLength(
jobNames.length,
);
expect(workflow).not.toContain('actions/checkout');
expect(workflow).not.toContain('actions/setup-node');
expect(workflow).not.toMatch(/^\s+run: .*\b(?:apt|rustup)\b/m);
@@ -314,17 +340,77 @@ describe('project CI workflow', () => {
expect(frontendJob).toContain('run: npm run check:production-api-deploy');
const nativeJob = jobSection('native-shell-tests');
expect(nativeJob).toContain('run: npm run check:native-shells');
expect(nativeJob).toContain('run: npm run check:native-shells:contract');
expect(nativeJob).toContain('run: npm run check:native-shells:shells');
expect(nativeJob).toContain('run: npm run check:native-shells:release');
expect(nativeJob).toContain(
'git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock',
);
expect(nativeJob).toContain('server-rs/Cargo.toml');
expect(nativeJob).toContain('apps/desktop-shell/src-tauri/Cargo.toml');
expect(nativeJob).toContain(
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
);
expect(nativeJob).toContain('cargo fetch --locked');
});
it('prefetches the excluded standalone Rust crates before the native shell gates', () => {
it('runs every native shell gate group exactly once across the split jobs', () => {
for (const [group, script] of Object.entries(nativeShellGateGroupScripts)) {
expect(rootPackageJson.scripts?.[`check:native-shells:${group}`]).toBe(
`node scripts/check-native-shells.mjs --groups=${group}`,
);
expect(
workflow.match(new RegExp(`^ {8}run: ${escapeRegExp(script)}$`, 'mu')),
).toHaveLength(1);
}
const declaredGroups = [
...nativeShellGateScript
.slice(
nativeShellGateScript.indexOf('const nativeShellGateGroups = ['),
nativeShellGateScript.indexOf(
'];',
nativeShellGateScript.indexOf('const nativeShellGateGroups = ['),
),
)
.matchAll(/'([a-z-]+)'/gu),
].map((match) => match[1]);
expect(declaredGroups).toEqual(Object.keys(nativeShellGateGroupScripts));
// 全量分组脚本只允许本地使用:CI 必须走拆分后的分组脚本,避免整套门禁再被
// 串行跑一遍。
expect(workflow).not.toMatch(/^ {8}run: npm run check:native-shells$/mu);
});
it('splits the AI game creator shell gates into web and Rust jobs', () => {
const webJob = jobSection('ai-game-creator-shell-web-tests');
expect(webJob).toContain('run: npm run check:native-shells:agc-web');
expect(webJob).not.toContain('cargo fetch');
const rustJob = jobSection('ai-game-creator-shell-rust-tests');
expect(rustJob).toContain('run: npm run check:native-shells:agc-rust');
expect(rustJob).toContain('server-rs/Cargo.toml');
expect(rustJob).toContain(
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
);
expect(rustJob).toContain('cargo fetch --locked');
// 拆开的 web / rust 两段必须还是原 `ai-game-creator-shell:check` 的同一条命令序列。
expect(rootPackageJson.scripts?.['ai-game-creator-shell:check']).toBe(
'npm run ai-game-creator-shell:check:web && npm run ai-game-creator-shell:check:rust && npm run ai-game-creator-shell:agent-run:smoke',
);
expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:web']).toBe(
'npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests',
);
expect(
rootPackageJson.scripts?.['ai-game-creator-shell:check:rust'],
).toContain(
'cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1',
);
});
it('prefetches the excluded standalone Rust crates before the AI game creator shell Rust gates', () => {
const standaloneStep = stepSection(
'native-shell-tests',
'ai-game-creator-shell-rust-tests',
'Prepare standalone Rust crate dependencies',
);
for (const manifest of [
@@ -338,9 +424,11 @@ describe('project CI workflow', () => {
expect(standaloneStep).toContain('cargo fetch \\');
expect(standaloneStep).not.toContain('cargo fetch --locked');
const nativeJob = jobSection('native-shell-tests');
const rustJob = jobSection('ai-game-creator-shell-rust-tests');
expect(
nativeJob.indexOf('Prepare standalone Rust crate dependencies'),
).toBeLessThan(nativeJob.indexOf('run: npm run check:native-shells'));
rustJob.indexOf('Prepare standalone Rust crate dependencies'),
).toBeLessThan(
rustJob.indexOf('run: npm run check:native-shells:agc-rust'),
);
});
});