修复 AGC dev 渠道改名后旧快捷方式仍指向旧安装
Project CI / AI game creator shell Rust crates (push) Successful in 1m33s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m6s
Project CI / Backend tests (push) Failing after 3m7s
Project CI / Frontend tests (push) Successful in 1m40s
Project CI / Native shell tests (push) Successful in 5m43s
Project CI / Repository checks (push) Successful in 2m28s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m50s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m53s
Project CI / AI game creator shell web tests (push) Successful in 1m37s
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled

- 新增 src-tauri/windows/installer-hooks.nsh:安装结束时按旧展示名(陶泥儿、Genarrative AI Game Creator)清理旧快捷方式(先用 IsShortcutTarget 校验目标)、旧安装目录与旧卸载项
- 同一钩子在用户原本就有旧桌面图标时补建当前身份的桌面图标、并始终补建开始菜单图标,解决 /UPDATE 下模板不创建快捷方式导致「点旧快捷方式→更新→自动启动新版」的循环
- build-release.mjs 只在 dev 渠道 + Windows 目标注入 bundle.windows.nsis.installerHooks,并让 node runtime 资源映射与钩子共存
- build-release.test.mjs 新增用例:只有 dev+Windows 注入、钩子文件存在且旧身份表完整、JSON Merge Patch 后钩子不被资源映射顶掉
- pitfalls 与 decision-log 记录改名迁移口径、删除边界与「再改展示名必须追加旧身份」的纪律
- AGC 渠道安装身份隔离里程碑补 2026-09-29 修正小节与真机升级验收项
This commit is contained in:
kdletters
2026-09-29 12:23:14 +08:00
parent 75ca16739a
commit 80bf54c39d
6 changed files with 200 additions and 2 deletions
@@ -14,6 +14,7 @@ import {
withDefaultCargoFeatures,
} from './cargo-features.mjs';
import {
AGC_DEFAULT_CHANNEL,
resolveChannelInstallIdentity,
resolveReleaseChannel,
} from './channel-identity.mjs';
@@ -21,6 +22,16 @@ import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
import { stageNodeRuntime } from './stage-node-runtime.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
/**
* dev 渠道改名迁移钩子(NSIS installer hooks)。
*
* dev 渠道展示名改过两次(Genarrative AI Game Creator → 陶泥儿 → 陶泥儿开发版),
* identifier 没变:更新路径不会重建快捷方式,改名后的包必须自己清理旧身份,
* 否则旧快捷方式会一直指向旧安装目录里的旧 exe。只有 dev 渠道注入这份钩子。
*/
const devChannelInstallerHooks = fileURLToPath(
new URL('../src-tauri/windows/installer-hooks.nsh', import.meta.url),
);
// Git 命令用于读取 release revision,必须在仓库根执行,不能在应用目录里执行。
const repoRoot = path.resolve(appRoot, '..', '..');
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
@@ -422,7 +433,7 @@ export function createChannelConfig(
baseClientWindow = readBaseClientWindow(),
) {
const { productName, identifier } = resolveChannelInstallIdentity(channel);
return {
const config = {
productName,
identifier,
app: {
@@ -434,6 +445,12 @@ export function createChannelConfig(
},
},
};
// 只有 dev 渠道改过展示名:它的包需要自带旧身份迁移;其它渠道不能拿这份旧名表去删安装。
if (channel === AGC_DEFAULT_CHANNEL && target.includes('windows'))
config.bundle = {
windows: { nsis: { installerHooks: devChannelInstallerHooks } },
};
return config;
}
function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
@@ -445,6 +462,7 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
// 普通 cargo test/dev 不要求发行资源;只有完成 staging 的发行构建加入映射。
if (includeNodeRuntime)
config.bundle = {
...config.bundle,
resources: { 'resources/node-runtime': 'game-runtime/node' },
};
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
@@ -263,6 +264,52 @@ test('channel install identity is baked into the same build-time config as the e
});
});
/**
* dev 渠道展示名改过两次(Genarrative AI Game Creator → 陶泥儿 → 陶泥儿开发版),而更新路径
* (/UPDATE)不会重建快捷方式:改名后的包必须自带旧身份迁移,否则桌面快捷方式会一直指向旧安装。
*/
test('dev 渠道的 Windows 包注入改名迁移钩子,其它渠道与目标不注入', () => {
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
const devWindows = createChannelConfig('dev', windowsTarget);
const hooksPath = devWindows.bundle.windows.nsis.installerHooks;
assert.equal(existsSync(hooksPath), true);
assert.match(hooksPath, /src-tauri[\\/]windows[\\/]installer-hooks\.nsh$/u);
const hooksSource = readFileSync(hooksPath, 'utf8');
assert.match(hooksSource, /NSIS_HOOK_POSTINSTALL/u);
// 改名史必须逐条留在钩子里:漏掉一条,那批机器升级后旧快捷方式就继续指向旧安装。
for (const legacyIdentity of ['陶泥儿', 'Genarrative AI Game Creator']) {
assert.ok(
hooksSource.includes(legacyIdentity),
`迁移钩子缺少旧身份:${legacyIdentity}`,
);
}
// 改名只发生在 dev:其它渠道注入会拿 dev 的旧名表去删别人的安装。
assert.equal(
createChannelConfig('release', windowsTarget).bundle,
undefined,
);
// macOS 包没有 NSIS 安装器,不能带上 Windows 钩子。
assert.equal(
createChannelConfig('dev', 'aarch64-apple-darwin').bundle,
undefined,
);
// 渠道配置与发行资源一起走 JSON Merge Patch:加 node runtime 资源不能顶掉钩子。
const base = readBaseTauriConfig();
const merged = applyJsonMergePatch(base, {
...devWindows,
version: base.version,
bundle: {
...devWindows.bundle,
resources: { 'resources/node-runtime': 'game-runtime/node' },
},
});
assert.equal(merged.bundle.windows.nsis.installerHooks, hooksPath);
});
});
/**
* RFC 7386(tauri-utils 用 `json_patch::merge`)语义:对象递归合并,数组整体替换。
* 这里按同样语义复现 Tauri CLI 的 `--config` 合并,用来守住"渠道配置不得丢窗口契约"。
@@ -836,6 +883,10 @@ test('release stages Node before Tauri and injects its resource mapping only for
assert.deepEqual(config.bundle.resources, {
'resources/node-runtime': 'game-runtime/node',
});
assert.match(
config.bundle.windows.nsis.installerHooks,
/installer-hooks\.nsh$/u,
);
return { status: 0 };
},
});
@@ -848,7 +899,12 @@ test('release stages Node before Tauri and injects its resource mapping only for
const config = JSON.parse(
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
);
assert.equal(config.bundle, undefined);
// --no-bundle 只是不加发行资源;渠道安装身份(dev 改名钩子)不属于资源映射。
assert.equal(config.bundle.resources, undefined);
assert.match(
config.bundle.windows.nsis.installerHooks,
/installer-hooks\.nsh$/u,
);
return { status: 0 };
},
});
@@ -0,0 +1,94 @@
; AGC dev 渠道安装身份改名的迁移钩子(NSIS installer hooks,随 Windows 包体编译)。
;
; 背景:dev 渠道的展示名改过两次(Genarrative AI Game Creator → 陶泥儿 → 陶泥儿开发版),
; identifier 保持不变。Tauri 的 NSIS 模板在更新模式(/UPDATE)与 /NS 下**不创建快捷方式**,
; 只会把「同名」快捷方式的目标改写回主程序名(见 installer.nsi 的 CreateOrUpdate*Shortcut)。
; 展示名一改,旧快捷方式既不会被改写、也不会被新建,于是升级后:
; - 旧安装目录与旧快捷方式留在原地,桌面快捷方式继续拉起旧 exe;
; - 旧 exe 的更新器仍然指向同一份 latest.json,又一次把新版本装进新目录并自动启动。
; 用户看到的就是「更新后自动启动的是新版,但桌面快捷方式打开的还是旧的」这个循环。
;
; 因此安装结束时做两件事:
; 1. 旧身份(改名前的展示名)的快捷方式、安装目录与卸载项整体清理,旧 exe 不再能被拉起;
; 2. 被迁移过的机器按当前身份补建快捷方式:桌面图标只在用户原本就有旧图标时迁移,
; 不替用户新增;开始菜单图标始终补建,避免自更新后应用失去入口。
;
; 该文件只注入 dev 渠道(改名发生在 dev 渠道)的 Windows 包;其它渠道的展示名从未变过,
; 不能拿这份旧身份表去删它们的安装。
; 旧展示名表:以后再改展示名,必须在这里追加旧名,否则升级后旧快捷方式会继续指向旧安装。
!define AGC_LEGACY_IDENTITY_1 "陶泥儿"
!define AGC_LEGACY_IDENTITY_2 "Genarrative AI Game Creator"
Var AgcLegacyInstallDir
Var AgcMigratedLegacyDesktopShortcut
; 清理一个旧身份。调用方把旧展示名压栈(Push),本函数不返回错误。
; 只在「旧展示名目录下确实存在我们的主程序」时才动手,避免误删同名目录;
; 当前安装目录 $INSTDIR 永不进入删除路径。
Function AgcMigrateLegacyIdentity
Pop $R0
StrCpy $AgcLegacyInstallDir ""
${If} ${FileExists} "$LOCALAPPDATA\$R0\${MAINBINARYNAME}.exe"
StrCpy $AgcLegacyInstallDir "$LOCALAPPDATA\$R0"
${ElseIf} ${FileExists} "$PROGRAMFILES64\$R0\${MAINBINARYNAME}.exe"
StrCpy $AgcLegacyInstallDir "$PROGRAMFILES64\$R0"
${Else}
Return
${EndIf}
${If} $AgcLegacyInstallDir == $INSTDIR
Return
${EndIf}
; 桌面图标:只有目标正好指向旧目录的主程序时才删,避免误删同名的用户快捷方式。
!insertmacro IsShortcutTarget "$DESKTOP\$R0.lnk" "$AgcLegacyInstallDir\${MAINBINARYNAME}.exe"
Pop $0
${If} $0 = 1
StrCpy $AgcMigratedLegacyDesktopShortcut 1
!insertmacro UnpinShortcut "$DESKTOP\$R0.lnk"
Delete "$DESKTOP\$R0.lnk"
${EndIf}
; 开始菜单图标:必须先删掉,否则它会在当前身份图标补建后继续指向旧 exe。
!insertmacro IsShortcutTarget "$SMPROGRAMS\$R0.lnk" "$AgcLegacyInstallDir\${MAINBINARYNAME}.exe"
Pop $0
${If} $0 = 1
!insertmacro UnpinShortcut "$SMPROGRAMS\$R0.lnk"
Delete "$SMPROGRAMS\$R0.lnk"
${EndIf}
; 旧安装目录与卸载项整体清理:旧 exe 留着就会继续把新版本装进新目录。
; 先结束残留的旧实例(同名主程序):更新路径下调用方通常已退出,这里兜住手动启动的情况。
!if "${INSTALLMODE}" == "currentUser"
nsis_tauri_utils::KillProcessCurrentUser "${MAINBINARYNAME}.exe"
Pop $0
!else
nsis_tauri_utils::KillProcess "${MAINBINARYNAME}.exe"
Pop $0
!endif
RMDir /r "$AgcLegacyInstallDir"
; 卸载项按展示名登记,旧名与当前名不同,必须按旧名清掉,否则「应用和功能」里会留下孤儿条目。
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\$R0"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\$R0"
DeleteRegKey HKCU "Software\${MANUFACTURER}\$R0"
FunctionEnd
!macro NSIS_HOOK_POSTINSTALL
Push "${AGC_LEGACY_IDENTITY_1}"
Call AgcMigrateLegacyIdentity
Push "${AGC_LEGACY_IDENTITY_2}"
Call AgcMigrateLegacyIdentity
; 迁移过的机器补建当前身份的快捷方式(更新模式下模板不会创建)。
StrCpy $R0 "$INSTDIR\${MAINBINARYNAME}.exe"
${IfNot} ${FileExists} "$SMPROGRAMS\${PRODUCTNAME}.lnk"
CreateShortCut "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$R0"
!insertmacro SetLnkAppUserModelId "$SMPROGRAMS\${PRODUCTNAME}.lnk"
${EndIf}
${If} $AgcMigratedLegacyDesktopShortcut = 1
CreateShortCut "$DESKTOP\${PRODUCTNAME}.lnk" "$R0"
!insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk"
${EndIf}
!macroend
@@ -73,3 +73,13 @@
- **两个已发布安装包的资源串互不串身份**:`dev-win` 的 `陶泥儿开发版_0.1.158_x64-setup.exe` 只命中 `陶泥儿开发版`,`release-win` 的 `陶泥儿 Release_0.1.159_x64-setup.exe` 只命中 `陶泥儿 Release`(安装包里的应用载荷是压缩的,所以这一层只能证安装器自身的身份串,应用载荷的身份由上一轮的 PE `RT_VERSION` 与 mac `Info.plist` 断言覆盖)。
- **本条的真机条件已经具备**:本机装着 dev `0.1.154`,`dev-win` 已发到 `0.1.158`;`release-win` 在 `0.1.159`,且 OSS 上保留 `agc/release-win/0.1.150/…_x64-setup.exe`(HEAD 200)可用来造出「release 有更新可装」的状态。也就是说「dev + release 同时装、各自原地更新」现在只差一次桌面操作。
- **环境约束(本轮实测,会影响后续排期)**:本次 Codex 会话**无法启动用户可写目录下的可执行文件**——`%LOCALAPPDATA%`、`%TEMP%` 与工作区里的 exe 都被执行策略拒绝(`Start-Process` 只放行系统目录,如 `C:\Windows\System32\cmd.exe`),因此「装包 → 起客户端 → 点更新 → 看版本」这一段必须在桌面会话里执行,不能由本会话代跑。
## 本轮修正(2026-09-29,改名后的旧身份迁移)
上面那节核对确认了本机同时存在两个 dev 身份:改名前的 `陶泥儿`(0.1.109/0.1.126)与改名后的 `陶泥儿开发版`。这个状态本身暴露了一个升级链缺陷:
- **缺陷**:Tauri 的 NSIS 模板在更新模式(`/UPDATE`)与 `/NS` 下不创建快捷方式,只改写「同名」快捷方式的目标;dev 渠道展示名改过两次(`Genarrative AI Game Creator` → `陶泥儿` → `陶泥儿开发版`)而 `identifier` 不变。结果是升级到改名后的包时,旧快捷方式(桌面 `陶泥儿.lnk`、开始菜单 `陶泥儿.lnk`)继续指向 `%LOCALAPPDATA%\陶泥儿\genarrative-ai-game-creator-shell.exe`,新版一份快捷方式都没有;旧 exe 的更新器又指向同一份 `latest.json`,于是「点旧快捷方式 → 更新 → 自动启动新版」会反复发生。
- **修正**:dev 渠道的 Windows 包注入 NSIS installer hooks(`apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh`,由 `createChannelConfig` 在 `channel === 'dev'` 且目标含 `windows` 时下发 `bundle.windows.nsis.installerHooks`)。安装结束时按旧展示名清理旧快捷方式(`IsShortcutTarget` 校验目标)、旧安装目录与旧卸载项,并在用户原本就有旧桌面图标时补建当前身份的桌面图标、总是补建开始菜单图标。
- **验证**:`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` → 41 passed(新增用例断言只有 dev+Windows 注入、钩子文件存在且旧身份表完整、渠道配置经 JSON Merge Patch 后钩子不被 node runtime 资源映射顶掉)。
- **仍未验证**:钩子只在真实 NSIS 打包 + 真机升级时执行。本条需要一次 dev 渠道发布 + 真机升级,才能勾掉下面这条验收项:
- [ ] 真机:改名前的 dev 安装(`%LOCALAPPDATA%\陶泥儿`)升级到改名后的包后,旧安装目录、旧快捷方式与旧卸载项被清理,`陶泥儿开发版` 的桌面/开始菜单图标出现且指向新安装。
@@ -1,5 +1,16 @@
# 决策记录
## 2026-09-29 dev 渠道改名迁移放进安装器钩子,且只注入 dev
- 背景:dev 渠道展示名从 `陶泥儿` 改成 `陶泥儿开发版`(`identifier` 不变)后,更新路径不会重建快捷方式,旧桌面图标继续指向旧安装目录里的旧 exe,旧 exe 的更新器又把新版本装进新目录,于是用户看到「更新后自动启动新版、桌面快捷方式打开的还是旧的」。
- 决策(迁移位置):改名迁移由**安装器**承担,不做运行期清理。Tauri 的 NSIS 模板在更新模式(`/UPDATE`)与 `/NS` 下跳过快捷方式创建(`CreateOrUpdate{StartMenu,Desktop}Shortcut` 都在 `$UpdateMode = 1` 时提前返回),只有安装器能在「新版刚落地、应用还没启动」的时机把旧身份清干净并补齐当前身份的图标;旧包已经发出去,改不了旧客户端的运行期行为。
- 决策(注入范围):只有 `dev` 渠道 + Windows 目标注入 `bundle.windows.nsis.installerHooks`。旧身份表(`陶泥儿`、`Genarrative AI Game Creator`)属于 dev 的改名史,其它渠道注入会删掉 dev 的安装;macOS 包没有 NSIS 安装器,不下发。
- 决策(删除边界):只清理「旧展示名目录下确实存在我们的主程序」且不等于 `$INSTDIR` 的安装;旧快捷方式先用 `IsShortcutTarget` 校验目标命中旧安装目录再删,不按文件名裸删;桌面图标只为原本就有旧图标的用户迁移,不替用户新增桌面图标。
- 决策(改名纪律):dev 渠道以后再改展示名,必须同步往钩子的旧身份表追加旧名,否则升级后旧快捷方式继续指向旧安装。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh`(新增)、`apps/ai-game-creator-shell/scripts/build-release.mjs`(`createChannelConfig` / `writeChannelConfigFile`)、`apps/ai-game-creator-shell/scripts/build-release.test.mjs`、pitfalls、AGC 渠道安装身份隔离里程碑。
- 验证:`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` → 41 passed(含新增「dev 渠道的 Windows 包注入改名迁移钩子」);`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。
- 边界(未验证):钩子只在真实 NSIS 打包 + 真机升级时执行,本轮既没有出包也没有执行安装;下一次 dev 渠道发布后才能验证「旧目录被清、当前身份的图标出现、旧图标消失」。
## 2026-09-28 速览卡只承载概览、范围与设计入口
- 速览卡帮助读者快速理解游戏及本次范围,分类、支柱、循环与目标玩家可融入概述;不复制系统拆分、具体参数、素材数量、完整排除清单或验证计划,不设固定字数或必填章节。
@@ -1,5 +1,14 @@
# 踩坑与排障记录
## 2026-09-29 dev 渠道改名后,更新路径不会重建快捷方式(旧图标一直指向旧安装)
- **现象**:改名前的 dev 客户端(展示名 `陶泥儿`,0.1.126)点更新后自动启动的是新版(`陶泥儿开发版`,0.1.160),但桌面/开始菜单里的 `陶泥儿.lnk` 打开的还是旧版。`%LOCALAPPDATA%` 下同时留着 `陶泥儿` 与 `陶泥儿开发版` 两个安装目录,`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall` 下同时留着 `陶泥儿`(0.1.109)与 `陶泥儿开发版`(0.1.160)两个卸载项,而新版一份快捷方式都没有。
- **原因**:Tauri 的 NSIS 模板在更新模式(`/UPDATE`)与 `/NS` 下**不创建快捷方式**,只会把「同名」快捷方式的目标改回主程序名(`installer.nsi` 里的 `CreateOrUpdate{StartMenu,Desktop}Shortcut`,两者都在 `$UpdateMode = 1` 时 `Return`)。dev 渠道展示名改过两次(`Genarrative AI Game Creator` → `陶泥儿` → `陶泥儿开发版`)而 `identifier` 保持不变,换成新名后旧快捷方式既不会被改写也不会被替代,旧安装目录同样留在原地;旧 exe 的更新器又指向同一份 `latest.json`,于是变成「点旧快捷方式 → 更新 → 自动启动新版」的循环。本机取证:`E:\Desktop\陶泥儿.lnk` 与开始菜单 `陶泥儿.lnk` 的目标都是 `%LOCALAPPDATA%\陶泥儿\genarrative-ai-game-creator-shell.exe`,全盘不存在 `陶泥儿开发版.lnk`。
- **处理(现行口径)**:dev 渠道的 Windows 包注入 NSIS installer hooks(`apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh`;由 `createChannelConfig` 只在 `channel === 'dev'` 且目标含 `windows` 时下发 `bundle.windows.nsis.installerHooks`)。安装结束时:按旧展示名清理旧快捷方式(先用 `IsShortcutTarget` 校验目标命中旧安装目录)、旧安装目录与旧卸载项;用户原本就有旧桌面图标时按当前身份补建桌面图标,开始菜单图标始终补建。**以后再改展示名,必须往钩子的旧身份表里追加旧名**,否则那批机器升级后旧快捷方式继续指向旧安装。
- **不要踩的坑**:① 其它渠道不能注入这份钩子——旧名表属于 dev 的改名史,注进去等于删别人的安装;② 旧快捷方式必须先校验目标再删,不能按文件名裸删;③ 当前安装目录 `$INSTDIR` 永不进入删除路径;④ 只在检测到旧身份时补建图标,不要替用户新增桌面图标;⑤ 钩子文件里有中文身份字面量,必须存成 **UTF-8 with BOM**,否则 makensis 按 ANSI 解码会得到错误的旧安装路径。
- **判据/取证**:`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs`(新增「dev 渠道的 Windows 包注入改名迁移钩子,其它渠道与目标不注入」用例,含钩子文件存在性、旧身份表完整性、JSON Merge Patch 后钩子不被资源映射顶掉)。本机真实安装状态见 `docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md` 的 2026-09-29 核对小节。
- **关联**:`apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh`、`apps/ai-game-creator-shell/scripts/build-release.mjs`、`apps/ai-game-creator-shell/scripts/build-release.test.mjs`、`apps/ai-game-creator-shell/src-tauri/tauri.conf.json`(`productName = 陶泥儿开发版`)、`apps/ai-game-creator-shell/scripts/channel-identity.mjs`。
## 2026-09-29 历史 release 与 Jenkins 构建暂存必须显式设置保留上限
- **现象**:dev 服务器根盘写满(`/dev/vda2` 47G/50G,可用 960M,99%)。两侧都只写不回收:`Genarrative-Api-Deploy` / `Genarrative-Web-Deploy` / `Genarrative-Stdb-Module-Publish` 三个 Job 工作区的 `build/<n>/` 暂存 9.8G,`/opt/genarrative/releases/` 下 68 个历史发布目录 9.2G;另有 journald 归档 700M 和轮转 syslog 437M。