Compare commits

...

4 Commits

Author SHA1 Message Date
suzmii c0db298449 修复 AGC 开发态监听 Rust 构建目录导致的加载缓慢
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m12s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m29s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m46s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m43s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m38s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m31s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m35s
Project CI / Frontend tests (pull_request) Successful in 6m38s
Project CI / Native shell tests (pull_request) Successful in 8m25s
在 Vite 中排除 src-tauri/target,保留业务源码与共享组件热更新。
增加实际 Vite watcher 回归,验证构建目录排除及源码变更通知。
同步开发运维文档和共享排障记录,关联 Issue #324。
2026-09-16 11:27:07 +08:00
kdletters 576ff07a5e 新增定时版本调度管线并收回两条下游管线的定时与去重
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m9s
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 5m11s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m1s
Project CI / AI game creator shell Rust crates (push) Successful in 2m36s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m20s
Project CI / Frontend tests (push) Successful in 4m23s
Project CI / Repository checks (push) Successful in 4m13s
Project CI / Native shell tests (push) Successful in 6m44s
Project CI / Backend tests (push) Successful in 8m22s
Project CI / AI game creator shell web tests (push) Successful in 3m28s
- 新增 Genarrative-Scheduled-Revision-Trigger:每小时用 git ls-remote 解析分支版本,与上一次触发过的 revision 不同才继续
- 触发时把同一个固定 COMMIT_HASH 同时传给 Full Build 与 AGC Windows Build,保证两条管线构建同一个版本
- Full Build 与 AGC Windows Build 去掉自带定时触发器与管线内版本去重
- 新增 scheduled-revision-trigger Job 配置 XML,使用本机 Git 入口与既有 SSH 凭据,定时器留在 Jenkinsfile
- 生产运维门禁改为校验调度管线约束,并拦住两条下游管线重新加 cron 或自行去重
- 同步开发运维文档、共享开发工作流与踩坑记录
2026-09-16 10:55:16 +08:00
kdletters 5aa616134c 修复 Hook 链继承仓库定位变量导致真实仓库被夹具污染
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m30s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m47s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m53s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m8s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m48s
Project CI / AI game creator shell Rust crates (push) Successful in 2m46s
Project CI / Frontend tests (push) Failing after 4m6s
Project CI / Repository checks (push) Successful in 3m57s
Project CI / Native shell tests (push) Successful in 7m40s
Project CI / Backend tests (push) Successful in 8m27s
Project CI / AI game creator shell web tests (push) Successful in 12m53s
- .husky/pre-commit 与 .husky/pre-push 入口清除 GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE 等仓库定位变量

- scripts/check-repository-ci.sh 增加同样的清理,保证本地门禁与 CI 走同一套隔离

- scripts/git-hooks.test.mjs 夹具 Git 调用前自检仓库归属并禁用 Hook,命令落到外部仓库时直接失败

- 守卫用例的子进程必须真的继承 GIT_DIR,避免隔离断言空转

- 更新开发运维文档与 shared-memory/pitfalls.md,记录链接工作树注入形态与配置修复步骤
2026-09-16 10:35:07 +08:00
kdletters 187b66c3b1 Jenkins 定时构建增加版本未变化去重
- Full Build 改为每小时触发,并按已解析 commit 与全部构建参数判断是否重复
- AGC Windows Build 增加每小时触发与同一套去重逻辑,跳过时不再安装依赖、构建和上传
- 跳过记录为 NOT_BUILT,越过跳过记录后最近一次实际构建成功才允许跳过,失败或中断可重试
- 生产运维门禁补充两个 Job 的去重约束与各阶段跳过检查
- 同步开发运维文档与共享开发工作流说明
2026-09-16 10:32:03 +08:00
13 changed files with 465 additions and 12 deletions
+3
View File
@@ -1 +1,4 @@
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
# 子进程(npm、lint-staged、测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
npm run format:staged
+3
View File
@@ -1 +1,4 @@
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
# 钩子链(npm → check:repository-ci → 测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
npm run check:pre-push-master -- "$@"
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { test } from 'node:test';
import { setTimeout as delay } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { createServer, loadConfigFromFile, normalizePath } from 'vite';
test(
'AGC 排除 Rust 构建目录且保留源码与共享组件监听',
{ timeout: 30_000 },
async () => {
const loaded = await loadConfigFromFile(
{ command: 'serve', mode: 'development' },
fileURLToPath(new URL('../vite.config.ts', import.meta.url)),
);
assert.ok(loaded);
assert.notEqual(loaded.config.server?.watch, null);
assert.notEqual(loaded.config.server?.hmr, false);
assert.ok(
[loaded.config.server?.watch?.ignored]
.flat()
.includes('**/src-tauri/target/**'),
);
const fixture = await mkdtemp(join(tmpdir(), 'agc-vite-watch-'));
const root = join(fixture, 'apps', 'ai-game-creator-shell');
const source = join(root, 'src', 'main.js');
const css = join(root, 'src', 'styles.css');
const shared = join(fixture, 'packages', 'shared', 'src', 'component.js');
const target = join(root, 'src-tauri', 'target');
const artifact = join(target, 'debug', 'incremental', 'cache.bin');
let server;
try {
for (const file of [source, css, shared, artifact]) {
await mkdir(dirname(file), { recursive: true });
await writeFile(
file,
file === css ? 'body { color: red; }' : 'export default 1;',
);
}
// 使用真实 Vite watcher 和实际配置,仅将扫描根替换为小型夹具;
// 不加载业务插件、后端或原生窗口,也不扫描开发机上的大型 target。
server = await createServer({
configFile: false,
envFile: false,
root,
logLevel: 'silent',
server: {
watch: loaded.config.server?.watch,
middlewareMode: true,
hmr: false,
fs: { allow: [fixture] },
},
optimizeDeps: { noDiscovery: true, include: [] },
});
const waitForWatchedFile = async (file) => {
const normalized = normalizePath(file);
for (let attempt = 0; attempt < 100; attempt += 1) {
if (
Object.entries(server.watcher.getWatched()).some(
([directory, names]) =>
names.some(
(name) => normalizePath(join(directory, name)) === normalized,
),
)
)
return;
await delay(50);
}
assert.fail(`源码必须仍被监听:${normalized}`);
};
await waitForWatchedFile(source);
// 真实模块转换应将 root 外的共享源码加入监听。
await server.transformRequest(`/@fs/${normalizePath(shared)}`);
for (const file of [source, css, shared]) {
const normalized = normalizePath(file);
await waitForWatchedFile(file);
const changed = new Promise((resolve, reject) => {
const timer = setTimeout(() => {
server.watcher.off('change', onChange);
reject(new Error(`未收到源码变更:${normalized}`));
}, 5_000);
function onChange(path) {
if (normalizePath(path) !== normalized) return;
clearTimeout(timer);
server.watcher.off('change', onChange);
resolve();
}
server.watcher.on('change', onChange);
});
await writeFile(
file,
file === css ? 'body { color: blue; }' : 'export default 2;',
);
await changed;
}
const targetPath = normalizePath(target);
const targetDirectories = Object.keys(server.watcher.getWatched())
.map(normalizePath)
.filter(
(path) => path === targetPath || path.startsWith(`${targetPath}/`),
);
assert.deepEqual(targetDirectories, [], 'Rust target 不应创建目录监听器');
} finally {
await server?.close();
await rm(fixture, { recursive: true, force: true });
}
},
);
@@ -154,6 +154,9 @@ export default defineConfig({
host: '127.0.0.1',
port: 3080,
strictPort: true,
watch: {
ignored: ['**/src-tauri/target/**'],
},
fs: {
allow: [repoRoot],
},
@@ -72,6 +72,10 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m
3. 确认相关当前文档与共享记忆已同步,且 docs 入口没有指向已删除或退役实现依据。
4. 提交标题使用中文,标题后逐行写明本次变更。
## Jenkins 定时版本调度
定时与版本比较只保留在 `Genarrative-Scheduled-Revision-Trigger` 一处:每小时用 `git ls-remote` 解析 `SOURCE_BRANCH` 远端 HEAD,与上一次触发过的 revision 比较,变化时才把同一个 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy``Genarrative-Agc-Windows-Build`,保证两条管线构建同一个版本。`Genarrative-Full-Build-And-Deploy``Genarrative-Agc-Windows-Build` 不得自带 `triggers` / `cron`,也不得在管线内再做一套版本去重;`npm run check:production-ops` 会拦住这两类回退。调度状态与生效步骤见 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Gitea CI 依赖闭合
`.gitea/workflows/project-ci.yml` 的客户端门禁拆成八个 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust shard 1/4``4/4` 各只预取 AGC 壳 manifest 并各跑一片(AGC 壳那份 `Cargo.lock` 的 path 依赖已含 `platform-llm``platform-agent``agent-runtime-core``shared-contracts`),`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与两个独立 crate`Native shell tests` 预取桌面壳与 AGC 壳 manifest`AI game creator shell web tests` 不触碰 Cargo,不预热。AGC 壳的 4 个分片 job、smoke job 与 crates job 只用 cargo 与 node 内建模块,因此不执行 `npm ci`。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate`agent-runtime-core``agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译一次后按 `--list` 名单分 4 片:CI 的每个分片 job 用 `--shard-index=<i>` 只跑自己那片,片内保持 `--test-threads=1` 并各自使用独立 `TMPDIR`,片与片之间靠 job 级并发摊开;本地不传 `--shard-index` 时仍是同一条命令把 4 片放进程里并行。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片 job 都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。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,9 @@
# 踩坑与排障记录
## AGC Windows 开发态首次页面加载缓慢
Vite 默认监听应用根下的 Rust `src-tauri/target`,构建产物较多时会创建大量 Windows 文件监听器。AGC 配置通过 `server.watch.ignored: ['**/src-tauri/target/**']` 排除此目录,不关闭业务源码、CSS、共享组件监听或 HMR。排查时区分后端就绪、Vite 扫描和原生窗口首绘;监听目录回归不能代替实机首绘测量,验证入口见本地开发运维文档。
## Windows 已登记生图资产未刷新
Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 `\\?\` / `\\?\UNC\`,而前端项目路径仍是普通盘符或 UNC。失效监听不能直接比较原始字符串;识别为同一项目后,用当前项目路径重读 manifest,保留项目切换与 revision 门禁。普通 `agc_generate_image` 成功提交也必须发出失效通知,不能依赖整轮 Agent 结束。回归需覆盖两种 Windows 前缀、其它项目事件拒收,以及 Agent 尚未结束和后续失败时已登记图片卡片仍可见。
@@ -3875,7 +3879,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- 现象:在 Jenkins Job 页面给 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 配了默认值,下一次加载 Declarative Pipeline 后又变空或恢复旧描述;04:00 Full Job 还可能因默认选择 `pause-after-stdb` 且 approvers 为空而失败。
- 原因:这些 Job 使用 Pipeline script from SCM`parameters {}``triggers {}` 会作为 Job property 回写现场配置;只改 UI 不是持久修复。构建编排如果不显式关闭下游 `PUBLISH_AFTER_BUILD`,还会受下游默认值漂移影响。
- 处理:credential ID 和参数默认值写回三个 Jenkinsfile;仅供开发使用的 dev 定时 Full Job 默认 `STDB_API_ROLLOUT_MODE=normal`,三路 Build 调用显式传 `PUBLISH_AFTER_BUILD=false`,再由 Full Job 统一按 Stdb → API → Web 发布。Secret 原文只放 Jenkins Secret File,旧 Secret Text 保留给 Import / Export。
- 验证:推送后让 Full / Stdb Build 用不存在的源码分支在 checkout 阶段 fail-closed,让 Stdb Publish 用空构建版本在 Prepare 阶段 fail-closed,以安全刷新参数 schema;随后只读检查三个 live `config.xml` 的参数描述和默认值,确认 Full timer 仍为 `0 4 * * *`rollout 默认值为 `normal`,并确认刷新运行未进入 publish / deploy stage。
- 验证:推送后让 Full / Stdb Build 用不存在的源码分支在 checkout 阶段 fail-closed,让 Stdb Publish 用空构建版本在 Prepare 阶段 fail-closed,以安全刷新参数 schema;随后只读检查三个 live `config.xml` 的参数描述和默认值,确认 rollout 默认值为 `normal`、Full 与 AGC Job 都不再带 cron(定时只来自 `Genarrative-Scheduled-Revision-Trigger`,并确认刷新运行未进入 publish / deploy stage。
- 关联:`jenkins/Jenkinsfile.production-full-build-and-deploy``jenkins/Jenkinsfile.production-stdb-module-build``jenkins/Jenkinsfile.production-stdb-module-publish``scripts/check-production-ops-guardrails.mjs`
## 维护模式内网全站放行不能信任 X-Forwarded-For
@@ -5614,5 +5618,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- 在 hook 内运行临时仓库测试时,`cwd` 不会覆盖继承的 `GIT_DIR``GIT_WORK_TREE``GIT_INDEX_FILE`。未隔离的 Git/lint-staged 子进程可能向真实仓库提交 fixture,甚至把测试版 ESLint、Prettier 配置带入主分支。
- fixture 子进程统一清除 `GIT_*` 环境,并用一次性外层 linked worktree 验证引用、索引、配置不变;原有工程检查规则保持完整,不能用逐项关闭规则修复 fixture 污染。
- 2026-09-16 复核:从链接工作树 `git push`/`git commit` 时,Git 注入 `GIT_DIR=<主仓库>/.git/worktrees/<name>``GIT_WORK_TREE``GIT_INDEX_FILE`husky → npm → `check:repository-ci` → 夹具测试整链条继承。夹具 `git config user.name "Git Hooks Test"` 会写进共享 `.git/config`(此后所有提交 author 变成 `Git Hooks Test`);夹具 `git init` 按是否带 `GIT_WORK_TREE` 分别写成 `core.bare=true``fatal: this operation must be run in a work tree`)或 `core.worktree=<临时夹具目录>``git status` 实际在操作临时目录)。
- 处理:钩子与门禁入口先 `unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES`;夹具 Git 调用在命令前自检 `rev-parse --show-toplevel` 等于夹具目录,落到外部仓库立即失败;守卫用例的子进程必须真的继承 `GIT_DIR`,否则断言会空转。
- 验证:`git config --show-origin --get user.name` 出现 `file:.git/config Git Hooks Test``git rev-parse --show-toplevel` 指向 `%TEMP%\genarrative-pre-push-*\repo` 都是被污染的确定性证据;被 `core.worktree` 劫持期间执行的 `git pull` 会把检出写进临时目录,真实工作树整体落后(本次 93 个文件),配置修好后用 `git checkout HEAD -- .` 回填。
- Vitest 的 `toHaveBeenCalledWith` 匹配任意一次调用,失败输出会列出其它命令;应先定位相同命令的真实参数差异,不能由其它调用的序号推断时序故障。
- 存在后台轮询的 IPC mock 不应要求目标命令占据全局最后一次调用。验证刷新时先记录调用边界,再筛选该边界之后的目标命令,严格核对其最后一次参数,避免后台查询影响断言,也避免旧调用掩盖刷新未执行。
@@ -4,6 +4,8 @@
Git hook 的临时仓库测试必须清除子进程继承的仓库定位环境(例如 `GIT_DIR``GIT_WORK_TREE``GIT_INDEX_FILE`);仅设置 `cwd` 不能隔离 Git。回归应从带这些变量的外层仓库运行,验证外层引用、索引与配置不变。临时测试文件必须留在独立目录并清理,不得通过测试生成主仓库提交或覆盖 ESLint、Prettier 配置。修复 lint 配置时保留原有规则、忽略范围与零警告门禁,不以关闭规则代替排障。
链接工作树(`git worktree`)里执行 `git push``git commit` 时,Git 会把 `GIT_DIR`(指向该工作树的管理目录)、`GIT_WORK_TREE``GIT_INDEX_FILE` 注入 Hook`npm``lint-staged` 与测试夹具整链条继承;夹具的 `git config` 会写到共享 `.git/config`(此后所有提交 author 变成夹具身份),夹具的 `git init` 会重写 `core.bare``core.worktree`。因此 `.husky/pre-commit``.husky/pre-push``scripts/check-repository-ci.sh` 入口必须 `unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES`,夹具 Git 调用还要自检 `git rev-parse --show-toplevel` 等于夹具目录,落到外部仓库时直接失败。真实仓库被污染后的修复顺序:备份 `.git/config``git config --unset core.worktree` → 确认 `core.bare=false``git config --unset user.name``user.email` 恢复全局身份 → 核对 `.git/worktrees/*/config.worktree` → 用 `git checkout HEAD -- .` 回填被劫持期间漏掉的检出(未跟踪文件不动)。
Stdb 发布以 root 准备文件、再切换 `spacetimedb` 用户执行时,WASM 必须放在服务用户可遍历的父目录下。生产脚本在 `/var/tmp` 创建随机私有目录并移交给发布用户,退出时清理;不能仅修改子目录所有权后继续从 `/root` 下读取。发布成功的判据仍是完整 Full Build 的 Stdb、API、Web 发布及退出维护模式全部成功。
更新时间:`2026-08-05`
@@ -18,6 +20,8 @@ Stdb 发布以 root 准备文件、再切换 `spacetimedb` 用户执行时,WAS
## 本地启动
AGC Vite 的 `server.watch.ignored` 排除 `**/src-tauri/target/**`,避免递归监听 Rust 构建产物、在 Windows 上创建大量文件监听器并拖慢首次页面加载。保留业务源码、CSS 与仓库共享组件的监听及热更新;不通过关闭 watcher 或 HMR 规避问题。监听回归使用 `node --test apps/ai-game-creator-shell/scripts/vite-watch.test.mjs`,验证构建目录被排除、应用源码及根目录外的共享源码仍能触发变更;原生窗口首绘耗时另行实测,不把监听测试耗时当作启动性能指标。
AGC `backend` 模式与 `all` / `api-server` 一样,必须同时探测 API 和 BgFilter worker 端口,漂移后的 worker 地址同时传给 API、worker 和 readiness 检查。不能因为旧 worker 的 `/readyz` 可访问,就把新启动失败的同端口 worker 视为就绪;AGC 前端会等待完整配套后端,worker 失败可能最终表现为 Tauri 等待前端 180 秒超时。
`npm run agc` 外层启动器先执行 `agc:serve` 并等待前端与配套后端就绪,再启动 Tauri,同时清空本次 CLI 的 `beforeDevCommand`,避免重复拉起服务和把数据库发布时间计入 Tauri 的 180 秒前端等待。准备阶段最多等待 660 秒(后端门禁仍为 600 秒),退出时清理本次启动的服务树,不停止复用的服务。AGC 自动发布显式使用 `--preserve-database`,schema 冲突须人工确认迁移,不自动清空数据。
@@ -133,7 +137,11 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去
普通图片错误素材类型清理使用 `npm run spacetime:editor-image-asset-kind:clean -- --database <database> --server-url <url>`,只能由已授权 migration operator 执行。先进入维护模式并发布包含 `clean_editor_image_asset_kind_and_return` 的 SpacetimeDB module,并保持旧版本 API / controller / worker 停止;随后运行默认全量 dry-run,核对 `asset → project-resource → showcase → canvas` 各 scope 的扫描数、命中行数、字段数和 blocker 均符合预期,再追加 `--apply`。脚本对每批重新 dry-run、绑定包含画布迁移摘要、结构化 layer 与 generation-dialog 权威 JSON 的 SHA-256,最后自动从头复核零命中;任一画布数据异常都会只输出哈希化 ID、scope 与原因并停止,不能跳过。清理只处理精确业务旧值,不修改 `asset_object.asset_kind`、MIME 或媒体类型;project-resource scope 在清行前验证同工程 migration 并将其状态纳入批次 hashlayout version 0 的 legacy 画布可以没有 migration,但 structured 画布缺 migration 必须立即形成 blocker,资源行不得先被清空;清行后能保持原 status 不变量时立即刷新摘要,否则只允许留给后续精确 canvas 字段清理收口。canvas scope 在任何布局写入前再次按 active / backfilled / rolled_back 状态验证原 migration 凭证和双份 legacy / structured 不变量,将 `editor_canvas_generation_dialog.dialog_json` 与 layer rows 一并扫描并在同一事务 patch;只允许本批资源清零及精确字段删除造成的差异,写入后从全部结构化权威行重建 layout、再次复核新状态才受控重签摘要,同时保持业务 revision、migration status 与全部时间戳不变。新版本 API、SpacetimeDB storage 创建入口、legacy 画布元数据提取和项目资源落表边界都会将 trim 后精确等于 `image``assetKind` 归一为 `NULL`,防止旧页面、滞留请求或 legacy 保存重新制造废弃值。完成零残留复核,并分别确认 cleaned backfilled 可激活、active 可继续保存、rolled_back 可重复复检后恢复应用版本,最后退出维护。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-clean-editor-image-asset-kind.mjs``scripts/spacetime-migration-common.mjs`
自 2026-07-11 起,`Genarrative-Full-Build-And-Deploy` 的每日 04:00 timer 默认以 `DEPLOY_TARGET=development``STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate。三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,不得依赖下游 Job 默认值或提前各自发布;统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。上文“定时构建缺少审批人时失败”的旧口径不再作为当前 dev 定时发布行为
`Genarrative-Scheduled-Revision-Trigger` 是唯一的定时入口,每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名散列,不等同于整点)。它只用 `git ls-remote` 解析 `SOURCE_BRANCH`(默认 `master`)的远端 HEAD,不 checkout 工作区;解析出的完整 commit 与上一次触发过的 revision 相同则标记 `NOT_BUILT` 并结束,不触发任何下游
revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy``Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Full Job 默认以 `DEPLOY_TARGET=development``STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`
调度状态是调度 Job 工作区里的 `.jenkins-last-triggered-revision`,构建描述同时回显本次 revision 与结果。工作区被清理(例如 `Wipe Out Workspace`)或状态文件缺失时,下一次运行按“版本变化”处理并触发一次,之后恢复稳定;需要重建同一版本时勾选 `FORCE_TRIGGER`。Job 按仓库内 `jenkins/scheduled-revision-trigger-job-config.xml` 创建:`scriptPath=jenkins/Jenkinsfile.scheduled-revision-trigger`、Git 入口 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`、凭据 `genarrative-local-gitea-ssh``<triggers/>` 留空(定时器写在 Jenkinsfile 里)。推送后必须让三个 live Job 各自加载一次新 Jenkinsfile,并只读核对 `config.xml`Full 与 AGC 不再有 cron,定时只来自新调度 Job;只改 Jenkinsfile 而不确认 live 配置时,旧 cron 仍会继续触发。
Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发布成功后是否退出维护,默认勾选以保持历史行为。Full 对 Stdb Publish 和 API Deploy 两个下游阶段都固定传 `KEEP_MAINTENANCE_MODE=true`,让 maintenance marker 持续覆盖 Stdb → API → Web 整段发布;Web Deploy 成功后才进入独立 `Exit Maintenance` 阶段。该阶段只能通过 `agent none` 和显式 `node(...)` 分配目标机,直接执行 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`;目标机不得 checkout Git、挂载 Git SSH 凭据或依赖 Jenkins workspace 源码。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。
@@ -7,10 +7,6 @@ pipeline {
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
}
triggers {
cron('0 4 * * *')
}
environment {
GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
@@ -0,0 +1,87 @@
// 定时版本调度管线:解析远端分支版本,只有和上一次触发过的版本不同时,
// 才用同一个固定 commit 触发 Full Build 与 AGC Windows Build 两个下游管线。
pipeline {
agent {
label 'linux && genarrative-build'
}
options {
disableConcurrentBuilds()
skipDefaultCheckout(true)
buildDiscarder(logRotator(numToKeepStr: '200', artifactNumToKeepStr: '10'))
}
triggers {
cron('H * * * *')
}
environment {
GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
FULL_BUILD_JOB_NAME = 'Genarrative-Full-Build-And-Deploy'
AGC_BUILD_JOB_NAME = 'Genarrative-Agc-Windows-Build'
REVISION_STATE_FILE = '.jenkins-last-triggered-revision'
}
parameters {
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '被检查的源码分支;默认 master')
booleanParam(name: 'FORCE_TRIGGER', defaultValue: false, description: '勾选后忽略版本比较,强制触发两个下游管线')
}
stages {
stage('Resolve Revision') {
steps {
withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) {
sh '''#!/usr/bin/env bash
set -euo pipefail
export GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?缺少 Git SSH 凭据} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
revision="$(git ls-remote "${GIT_REMOTE_URL:?缺少 GIT_REMOTE_URL}" "refs/heads/${SOURCE_BRANCH}" | awk 'NR == 1 { print $1 }')"
if [[ ! "${revision}" =~ ^[0-9a-f]{40}$ ]]; then
echo "无法解析远端分支版本: branch=${SOURCE_BRANCH} revision=${revision:-<empty>}" >&2
exit 1
fi
printf '%s' "${revision}" > .jenkins-remote-revision
echo "远端版本: branch=${SOURCE_BRANCH} revision=${revision}"
'''
}
script {
env.REMOTE_REVISION = readFile('.jenkins-remote-revision').trim()
env.LAST_TRIGGERED_REVISION = fileExists(env.REVISION_STATE_FILE) ? readFile(env.REVISION_STATE_FILE).trim() : ''
env.REVISION_CHANGED = (params.FORCE_TRIGGER || env.LAST_TRIGGERED_REVISION != env.REMOTE_REVISION) ? 'true' : 'false'
if (env.REVISION_CHANGED != 'true') {
currentBuild.result = 'NOT_BUILT'
currentBuild.description = "版本未变化,跳过:${env.SOURCE_BRANCH} 仍是 ${env.REMOTE_REVISION.take(12)}"
echo currentBuild.description
}
}
}
}
stage('Trigger Downstream Pipelines') {
when {
expression { return env.REVISION_CHANGED == 'true' }
}
steps {
script {
// 两个下游管线必须拿到同一个固定 revision,避免各自解析分支 HEAD 造成版本漂移。
def pinnedRevision = env.REMOTE_REVISION
def pinnedParameters = [
string(name: 'SOURCE_BRANCH', value: env.SOURCE_BRANCH),
string(name: 'COMMIT_HASH', value: pinnedRevision),
]
build job: env.FULL_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters
build job: env.AGC_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters
writeFile file: env.REVISION_STATE_FILE, text: pinnedRevision
currentBuild.description = "已触发 ${env.FULL_BUILD_JOB_NAME} 与 ${env.AGC_BUILD_JOB_NAME}${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}"
echo currentBuild.description
}
}
}
}
post {
always {
echo "调度结束: branch=${env.SOURCE_BRANCH} revision=${env.REMOTE_REVISION ?: ''} changed=${env.REVISION_CHANGED ?: ''}"
}
}
}
@@ -0,0 +1,55 @@
<?xml version='1.1' encoding='UTF-8'?>
<flow-definition plugin="workflow-job">
<actions/>
<description>按小时检查源码分支版本,只有版本变化时用同一个 commit 触发 Full Build 与 AGC Windows Build。</description>
<keepDependencies>false</keepDependencies>
<properties>
<hudson.model.ParametersDefinitionProperty>
<parameterDefinitions>
<hudson.model.StringParameterDefinition>
<name>SOURCE_BRANCH</name>
<description>被检查的源码分支;默认 master</description>
<defaultValue>master</defaultValue>
<trim>true</trim>
</hudson.model.StringParameterDefinition>
<hudson.model.BooleanParameterDefinition>
<name>FORCE_TRIGGER</name>
<description>勾选后忽略版本比较,强制触发两个下游管线</description>
<defaultValue>false</defaultValue>
</hudson.model.BooleanParameterDefinition>
</parameterDefinitions>
</hudson.model.ParametersDefinitionProperty>
</properties>
<definition class="org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition" plugin="workflow-cps">
<scm class="hudson.plugins.git.GitSCM" plugin="git">
<configVersion>2</configVersion>
<userRemoteConfigs>
<hudson.plugins.git.UserRemoteConfig>
<url>ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git</url>
<credentialsId>genarrative-local-gitea-ssh</credentialsId>
<refspec>+refs/heads/master:refs/remotes/origin/master</refspec>
</hudson.plugins.git.UserRemoteConfig>
</userRemoteConfigs>
<branches>
<hudson.plugins.git.BranchSpec>
<name>*/master</name>
</hudson.plugins.git.BranchSpec>
</branches>
<doGenerateSubmoduleConfigurations>false</doGenerateSubmoduleConfigurations>
<submoduleCfg class="empty-list"/>
<extensions>
<hudson.plugins.git.extensions.impl.CloneOption>
<shallow>true</shallow>
<noTags>true</noTags>
<reference></reference>
<depth>1</depth>
<honorRefspec>true</honorRefspec>
</hudson.plugins.git.extensions.impl.CloneOption>
</extensions>
</scm>
<scriptPath>jenkins/Jenkinsfile.scheduled-revision-trigger</scriptPath>
<lightweight>false</lightweight>
</definition>
<triggers/>
<disabled>false</disabled>
</flow-definition>
+93 -1
View File
@@ -605,7 +605,7 @@ const checks = [
includes:
"choice(name: 'STDB_API_ROLLOUT_MODE', choices: ['normal', 'pause-after-stdb']",
reason:
'Full Build 的 04:00 定时任务必须默认 normal 完整发布仅供开发使用的 dev 服务器。',
'Full Build 的每小时定时任务必须默认 normal 完整发布仅供开发使用的 dev 服务器。',
},
{
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
@@ -7639,6 +7639,98 @@ const fullPipelineContent = readFileSync(
'jenkins/Jenkinsfile.production-full-build-and-deploy',
'utf8',
);
const agcPipelineContent = readFileSync(
'jenkins/Jenkinsfile.ai-game-creator-shell-build',
'utf8',
);
const scheduledRevisionTriggerContent = readFileSync(
'jenkins/Jenkinsfile.scheduled-revision-trigger',
'utf8',
);
const scheduledRevisionTriggerJobConfig = readFileSync(
'jenkins/scheduled-revision-trigger-job-config.xml',
'utf8',
);
// 定时与版本比较统一收敛到调度管线,两个下游流水线不得再自带触发器。
for (const [file, content] of [
['jenkins/Jenkinsfile.production-full-build-and-deploy', fullPipelineContent],
['jenkins/Jenkinsfile.ai-game-creator-shell-build', agcPipelineContent],
]) {
if (/\btriggers\s*\{/u.test(content) || content.includes('cron(')) {
failed = true;
console.error(
`[check:production-ops] ${file} 不得自带定时触发器;版本检查与触发必须由 Genarrative-Scheduled-Revision-Trigger 统一负责。`,
);
}
}
for (const [snippet, reason] of [
["cron('H * * * *')", '必须每小时检查一次远端版本'],
['disableConcurrentBuilds()', '必须禁止并发触发,避免同一版本重复触发下游'],
['skipDefaultCheckout(true)', '不得依赖本地 SCM 工作区'],
['git ls-remote', '必须直接从远端解析分支版本'],
['> .jenkins-remote-revision', '解析出的版本必须先落盘再参与比较'],
['fileExists(env.REVISION_STATE_FILE)', '必须读取上一次触发过的版本'],
[
"currentBuild.result = 'NOT_BUILT'",
'版本未变化时必须标记为未触发,不能静默成功',
],
[
"FULL_BUILD_JOB_NAME = 'Genarrative-Full-Build-And-Deploy'",
'必须声明 Full Build 目标 Job',
],
[
"AGC_BUILD_JOB_NAME = 'Genarrative-Agc-Windows-Build'",
'必须声明 AGC Windows Build 目标 Job',
],
[
"string(name: 'COMMIT_HASH', value: pinnedRevision)",
'必须把同一个固定 revision 传给两个下游管线',
],
]) {
if (!scheduledRevisionTriggerContent.includes(snippet)) {
failed = true;
console.error(`[check:production-ops] 调度管线${reason}`);
}
}
const scheduledPinnedParameterCalls = scheduledRevisionTriggerContent.match(
/parameters: pinnedParameters/gu,
);
if ((scheduledPinnedParameterCalls?.length ?? 0) !== 2) {
failed = true;
console.error(
'[check:production-ops] 调度管线必须用同一份 pinnedParameters 同时触发 Full Build 与 AGC Windows Build。',
);
}
if (
!scheduledRevisionTriggerJobConfig.includes(
'<scriptPath>jenkins/Jenkinsfile.scheduled-revision-trigger</scriptPath>',
) ||
!scheduledRevisionTriggerJobConfig.includes(
'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git',
) ||
!scheduledRevisionTriggerJobConfig.includes(
'<credentialsId>genarrative-local-gitea-ssh</credentialsId>',
) ||
!scheduledRevisionTriggerJobConfig.includes('<triggers/>')
) {
failed = true;
console.error(
'[check:production-ops] scheduled-revision-trigger Job 必须指向调度 Jenkinsfile、使用本机 Git 入口与既有 SSH 凭据,并把定时器留在 Jenkinsfile。',
);
}
if (
!fullPipelineContent.includes(
"string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT)",
) ||
!agcPipelineContent.includes('checkout --detach $commit')
) {
failed = true;
console.error(
'[check:production-ops] Full Build 与 AGC Windows Build 必须按上游传入的固定 commit 构建同一个版本。',
);
}
const forcedBuildOnlyCalls = fullPipelineContent.match(
/booleanParam\(name: 'PUBLISH_AFTER_BUILD', value: false\)/gu,
);
+4
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
# 链接工作树推送时 Git 会向 Hook 注入 GIT_DIR 等仓库定位变量;npm 链上的测试夹具会继承
# 它们并绕过 cwd 写到真实仓库,这里统一清除,保证门禁只作用于当前工作树。
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
base_ref="${SPACETIME_SCHEMA_BASE_REF:-${1:-}}"
head_ref="${REPOSITORY_CI_HEAD_REF:-${2:-HEAD}}"
+83 -5
View File
@@ -5,6 +5,7 @@ import {
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
@@ -20,6 +21,8 @@ const packageJson = JSON.parse(
);
test('pre-commit hook fixes staged imports and formatting without swallowing unstaged work', () => {
assertGuardChildInheritsPoisonedEnvironment();
assert.equal(packageJson.scripts.prepare, 'husky');
assert.equal(packageJson.scripts['format:staged'], 'lint-staged');
assert.deepEqual(packageJson['lint-staged'], {
@@ -29,9 +32,10 @@ test('pre-commit hook fixes staged imports and formatting without swallowing uns
],
'*.rs': ['node scripts/lint-staged-rustfmt.mjs'],
});
assert.equal(
assertHookClearsGitEnvironment(
'pre-commit',
readFileSync(join(repoRoot, '.husky', 'pre-commit'), 'utf8'),
'npm run format:staged\n',
'npm run format:staged',
);
const tempRepo = createTempDirectory('genarrative-git-hooks-');
@@ -162,6 +166,8 @@ test('pre-commit hook fixes staged imports and formatting without swallowing uns
});
test('pre-push runs repository parity only for master updates', () => {
assertGuardChildInheritsPoisonedEnvironment();
const tempDir = createTempDirectory('genarrative-pre-push-');
try {
const npmLog = join(tempDir, 'repo', 'npm.log');
@@ -291,10 +297,12 @@ test('hook fixtures do not mutate the calling linked worktree or its index', ()
env: {
...isolatedGitEnvironment(),
NODE_TEST_CONTEXT: undefined,
GENARRATIVE_HOOK_GUARD_CHILD: '1',
GIT_DIR: gitDir,
GIT_COMMON_DIR: join(outerRepo, '.git'),
GIT_WORK_TREE: worktree,
GIT_INDEX_FILE: indexPath,
GIT_PREFIX: '',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.worktree',
GIT_CONFIG_VALUE_0: worktree,
@@ -353,7 +361,15 @@ test('Gitea Repository checks and master pre-push share the same repository comm
);
assert.match(repositoryScript, /npm run build/u);
assert.match(repositoryScript, /git diff --check/u);
assert.equal(prePushHook, 'npm run check:pre-push-master -- "$@"\n');
assertHookClearsGitEnvironment(
'pre-push',
prePushHook,
'npm run check:pre-push-master -- "$@"',
);
assert.match(
repositoryScript,
/^unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR/mu,
);
});
function readFileOrEmpty(path) {
@@ -376,11 +392,38 @@ function toBashPath(path) {
}
function git(cwd, ...args) {
return execFileSync('git', args, {
const env = isolatedGitEnvironment();
assertFixtureRepository(cwd, env);
return execFileSync(
'git',
['--no-pager', '-c', `core.hooksPath=${nullDevice}`, ...args],
{
cwd,
encoding: 'utf8',
env,
},
);
}
// 夹具命令必须落在夹具自己的仓库:继承的 GIT_DIR/GIT_WORK_TREE 优先级高于 cwd,曾让夹具把
// 身份、core.bare 与 core.worktree 写进调用方的真实仓库。
function assertFixtureRepository(cwd, env) {
const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], {
cwd,
encoding: 'utf8',
env: isolatedGitEnvironment(),
env,
});
if (probe.status !== 0) {
// 此时夹具目录还不是仓库(例如首次 git init),命令本身会把它建起来。
return;
}
const toplevel = realpathSync.native(probe.stdout.trim());
const expected = realpathSync.native(resolve(cwd));
assert.equal(
toplevel,
expected,
`夹具 Git 命令会落到外部仓库:cwd=${expected} toplevel=${toplevel}`,
);
}
function isolatedGitEnvironment() {
@@ -391,6 +434,41 @@ function isolatedGitEnvironment() {
);
}
// 守卫用例把本文件跑在毒化环境里;子进程必须真的继承 GIT_DIR,否则断言会空转。
function assertGuardChildInheritsPoisonedEnvironment() {
if (process.env.GENARRATIVE_HOOK_GUARD_CHILD !== '1') {
return;
}
assert.ok(process.env.GIT_DIR, '守卫子进程必须继承 GIT_DIR');
assert.equal(
Object.hasOwn(isolatedGitEnvironment(), 'GIT_DIR'),
false,
'夹具子进程环境必须清除 GIT_DIR',
);
}
function assertHookClearsGitEnvironment(hookName, contents, command) {
const lines = contents.trimEnd().split('\n');
assert.equal(
lines.at(-1),
command,
`${hookName} 最后一行必须保持原有钩子命令`,
);
const unsetLine = lines.find((line) => line.startsWith('unset '));
assert.ok(unsetLine, `${hookName} 必须先清除 Git 注入的仓库定位变量`);
const cleared = new Set(unsetLine.split(/\s+/u));
for (const variable of [
'GIT_DIR',
'GIT_WORK_TREE',
'GIT_INDEX_FILE',
'GIT_COMMON_DIR',
]) {
assert.ok(cleared.has(variable), `${hookName} 必须清除 ${variable}`);
}
}
const nullDevice = process.platform === 'win32' ? 'NUL' : '/dev/null';
function createTempDirectory(prefix) {
const tempRoot = join(homedir(), 'data', 'tmp');
mkdirSync(tempRoot, { recursive: true });