Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3a115d794 | |||
| 31aa169a0c | |||
| ad8579b053 | |||
| bfc0692014 | |||
| 4da1494a1d | |||
| b3e9d0a906 | |||
| a60328623d | |||
| 9e63b76991 | |||
| a98ebcf68f | |||
| 576ff07a5e | |||
| 5aa616134c | |||
| 187b66c3b1 |
@@ -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
|
||||
|
||||
@@ -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 -- "$@"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@genarrative/ai-game-creator-shell",
|
||||
"private": true,
|
||||
"version": "0.1.29",
|
||||
"version": "0.1.45",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start-tauri-dev.mjs",
|
||||
|
||||
+1
-1
@@ -1725,7 +1725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.29"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"agent-runtime-core",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.29"
|
||||
version = "0.1.45"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "陶泥儿",
|
||||
"version": "0.1.29",
|
||||
"version": "0.1.45",
|
||||
"identifier": "world.genarrative.ai-game-creator",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm --prefix ../.. run agc:serve",
|
||||
|
||||
@@ -605,16 +605,17 @@ function isPersistableDirectCodexConversationMessage(message: ChatMessage) {
|
||||
return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId);
|
||||
}
|
||||
|
||||
function claimInitialSupervisorMessageForPage(projectPath: string) {
|
||||
function claimInitialSupervisorMessageForPage(projectPath: string, scope = '') {
|
||||
let claimedProjectPaths = initialSupervisorMessageClaimsByPage.get(window);
|
||||
if (!claimedProjectPaths) {
|
||||
claimedProjectPaths = new Set<string>();
|
||||
initialSupervisorMessageClaimsByPage.set(window, claimedProjectPaths);
|
||||
}
|
||||
if (claimedProjectPaths.has(projectPath)) {
|
||||
const claimKey = `${projectPath}\u0000${scope}`;
|
||||
if (claimedProjectPaths.has(claimKey)) {
|
||||
return false;
|
||||
}
|
||||
claimedProjectPaths.add(projectPath);
|
||||
claimedProjectPaths.add(claimKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -683,6 +684,7 @@ type AppProps = {
|
||||
activeVersionId?: ProjectSupervisorComponentProps['activeVersionId'];
|
||||
supervisorChatOnly?: boolean;
|
||||
initialSupervisorMessage?: string;
|
||||
initialSupervisorMessageClaimScope?: string;
|
||||
initialCreationType?: HomeCreationType | null;
|
||||
initialAttachments?: LauncherImportedAttachment[];
|
||||
playRequest?: ProjectSupervisorComponentProps['playRequest'];
|
||||
@@ -733,6 +735,7 @@ export function App({
|
||||
activeVersionId = null,
|
||||
supervisorChatOnly = false,
|
||||
initialSupervisorMessage = '',
|
||||
initialSupervisorMessageClaimScope = '',
|
||||
initialCreationType = null,
|
||||
initialAttachments = [],
|
||||
playRequest = null,
|
||||
@@ -870,6 +873,7 @@ export function App({
|
||||
const initialSupervisorMessageLatchRef = useRef({
|
||||
projectPath: initialProjectPath,
|
||||
prompt: initialSupervisorMessage.trim(),
|
||||
claimScope: initialSupervisorMessageClaimScope,
|
||||
creationType: initialCreationType,
|
||||
attachments: toDirectCodexTurnAttachments(initialAttachments),
|
||||
});
|
||||
@@ -7501,7 +7505,7 @@ export function App({
|
||||
}
|
||||
if (
|
||||
chatAgentBusy ||
|
||||
!claimInitialSupervisorMessageForPage(latch.projectPath)
|
||||
!claimInitialSupervisorMessageForPage(latch.projectPath, latch.claimScope)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@ import type { WorkspaceLauncherShellProps } from './model';
|
||||
import { NonEmptyProjectDialog, ProjectsPage } from './ProjectCreation';
|
||||
import { useAccountWallet } from './useAccountWallet';
|
||||
import { useDeveloperAgentPanel } from './useDeveloperAgentPanel';
|
||||
import { useHomeProjectCreation } from './useHomeProjectCreation';
|
||||
import {
|
||||
DESIGN_ARTIFACTS_BUILD_PROMPT,
|
||||
useHomeProjectCreation,
|
||||
} from './useHomeProjectCreation';
|
||||
import { useRecentProjects } from './useRecentProjects';
|
||||
|
||||
export function WorkspaceLauncherShell({
|
||||
@@ -113,7 +116,7 @@ export function WorkspaceLauncherShell({
|
||||
const agentRuntimeMode: 'design' | 'game' = planningStartMode
|
||||
? 'design'
|
||||
: 'game';
|
||||
const suppressInitialGameTurn = switchedToGameRuntime;
|
||||
const omitOriginalPlanningTurnInputs = switchedToGameRuntime;
|
||||
const activeProjectContextRef = useRef(currentProjectContext);
|
||||
const manifestMergeRef = useRef<ProjectManifestMergeState | null>(null);
|
||||
activeProjectContextRef.current = currentProjectContext;
|
||||
@@ -661,20 +664,27 @@ export function WorkspaceLauncherShell({
|
||||
initialProjectManifest={currentProjectContext.manifest}
|
||||
initialProjectKind={currentProjectContext.projectKind}
|
||||
initialSupervisorMessage={
|
||||
!suppressInitialGameTurn
|
||||
? currentProjectContext.initialPrompt
|
||||
: ''
|
||||
switchedToGameRuntime
|
||||
? DESIGN_ARTIFACTS_BUILD_PROMPT
|
||||
: !omitOriginalPlanningTurnInputs
|
||||
? currentProjectContext.initialPrompt
|
||||
: ''
|
||||
}
|
||||
initialCreationType={
|
||||
!suppressInitialGameTurn
|
||||
? currentProjectContext.creationType
|
||||
: null
|
||||
switchedToGameRuntime
|
||||
? 'game'
|
||||
: !omitOriginalPlanningTurnInputs
|
||||
? currentProjectContext.creationType
|
||||
: null
|
||||
}
|
||||
initialAttachments={
|
||||
!suppressInitialGameTurn
|
||||
!omitOriginalPlanningTurnInputs
|
||||
? currentProjectContext.attachments
|
||||
: []
|
||||
}
|
||||
initialSupervisorMessageClaimScope={
|
||||
switchedToGameRuntime ? 'approved-design-build' : ''
|
||||
}
|
||||
activeVersionId={activeVersionId}
|
||||
orchestrationMode="single-supervisor"
|
||||
projectSupervisorOnly
|
||||
|
||||
@@ -35,6 +35,7 @@ export type ProjectSupervisorComponentProps = {
|
||||
initialProjectManifest?: GameCreationAppManifest;
|
||||
initialProjectKind?: 'web' | 'godot' | 'cocos';
|
||||
initialSupervisorMessage?: string;
|
||||
initialSupervisorMessageClaimScope?: string;
|
||||
initialCreationType?: HomeCreationType | null;
|
||||
initialAttachments?: LauncherImportedAttachment[];
|
||||
orchestrationMode?: 'single-supervisor' | 'professional-dag';
|
||||
|
||||
@@ -66,7 +66,7 @@ type UseHomeProjectCreationOptions = {
|
||||
rememberRecentWorkspace: (projectPath: string) => void;
|
||||
};
|
||||
|
||||
const APPROVED_GDD_BUILD_PROMPT = [
|
||||
export const APPROVED_GDD_BUILD_PROMPT = [
|
||||
'请按照附件中的已批准 GDD 开始建造这款游戏。',
|
||||
'',
|
||||
'这份 GDD 已覆盖游戏定位与一句话概念、类型与美术方向、游戏支柱、核心循环、目标用户、平台与输入事实、MVP 系统、暂不纳入范围、创作者提示和原型验证项。',
|
||||
@@ -74,6 +74,9 @@ const APPROVED_GDD_BUILD_PROMPT = [
|
||||
'请先阅读并理解附件中的 fast_gdd.md,以它作为本次建造的主要依据,优先实现其中 MVP 范围内的可运行游戏原型。',
|
||||
].join('\n');
|
||||
|
||||
export const DESIGN_ARTIFACTS_BUILD_PROMPT =
|
||||
'查看当前项目 design_artifacts/ 目录及其子目录下的文档,理解其游戏设计,并按照这些文档将游戏实现出来。';
|
||||
|
||||
function createTextAttachmentFile(content: string) {
|
||||
const file = new File([content], 'fast_gdd.md', {
|
||||
type: 'text/markdown',
|
||||
|
||||
@@ -918,7 +918,8 @@ export function ProjectSupervisorView({
|
||||
rows={3}
|
||||
value={chatInput}
|
||||
references={chatReferences}
|
||||
showTriggerButton={!directCodex}
|
||||
showTriggerButton={!directCodex && !planningSurfaceActive}
|
||||
showPolishAction={!planningSurfaceActive}
|
||||
placeholder={
|
||||
directCodex
|
||||
? '描述你的想法,或 @ 引用素材'
|
||||
|
||||
@@ -10271,6 +10271,15 @@ button.design-workspace-tree__entry:hover,
|
||||
|
||||
/* 策划聊天区包含阶段控制卡、消息、Runtime 状态和输入框。GameAgent 资源工作台的
|
||||
消息列表默认占满整个聊天区,策划模式需要单独恢复五行布局,避免输入框被推到视口外。 */
|
||||
/* 策划工作台保留标题行,避免共用跨行规则将标题挤到底部。 */
|
||||
.game-workbench-layout--design .game-workbench-chat {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.game-workbench-layout--design .game-workbench-chat .project-supervisor-surface {
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.game-workbench-layout--design .project-supervisor-surface {
|
||||
display: block;
|
||||
height: 100%;
|
||||
|
||||
@@ -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 镜像缓存,再重跑门禁。
|
||||
|
||||
@@ -3875,7 +3875,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 +5614,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`
|
||||
@@ -133,7 +135,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 并将其状态纳入批次 hash,layout 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'
|
||||
@@ -35,7 +31,7 @@ pipeline {
|
||||
choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent')
|
||||
booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机')
|
||||
string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: '生产 SpacetimeDB database')
|
||||
choice(name: 'DATABASE_BACKUP_MODE', choices: ['async', 'sync', 'skip'], description: 'Stdb publish 备份策略;人工 release 必须显式选择,已有验真冷备时才可选 skip')
|
||||
choice(name: 'DATABASE_BACKUP_MODE', choices: ['async', 'sync', 'skip'], description: 'Stdb publish 备份策略;dev 默认跳过 OSS 上传;需要冷备时显式选择 async,人工 release 必须显式选择')
|
||||
string(name: 'SPACETIME_SERVER_URL', defaultValue: 'http://127.0.0.1:3101', description: 'Stdb 发布目标 URL;默认避开本机 Git/Web 使用的 3000 端口')
|
||||
string(name: 'SPACETIME_ROOT_DIR', defaultValue: '/stdb', description: 'Stdb 发布使用的 spacetime CLI root-dir')
|
||||
string(name: 'SPACETIME_RUN_AS_USER', defaultValue: 'spacetimedb', description: 'Stdb 发布使用的本机用户')
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// 定时版本调度管线:解析远端分支版本,只有和上一次触发过的版本不同时,
|
||||
// 才用同一个固定 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),
|
||||
string(name: 'DATABASE_BACKUP_MODE', value: 'skip'),
|
||||
]
|
||||
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>
|
||||
Generated
+1
-1
@@ -95,7 +95,7 @@
|
||||
},
|
||||
"apps/ai-game-creator-shell": {
|
||||
"name": "@genarrative/ai-game-creator-shell",
|
||||
"version": "0.1.29",
|
||||
"version": "0.1.45",
|
||||
"dependencies": {
|
||||
"@cubone/react-file-manager": "^1.35.0",
|
||||
"@genarrative/image-canvas-core": "0.1.0",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user