diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index d1d14803f..3ceaddaf8 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -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`); diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index da832d50e..6f4c3315d 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -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 }; }, }); diff --git a/apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh b/apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh new file mode 100644 index 000000000..dee01e1d4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/windows/installer-hooks.nsh @@ -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 diff --git a/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md b/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md index a92878f1f..959a49308 100644 --- a/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md +++ b/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md @@ -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%\陶泥儿`)升级到改名后的包后,旧安装目录、旧快捷方式与旧卸载项被清理,`陶泥儿开发版` 的桌面/开始菜单图标出现且指向新安装。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 39dba38cf..7f411537f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 速览卡只承载概览、范围与设计入口 - 速览卡帮助读者快速理解游戏及本次范围,分类、支柱、循环与目标玩家可融入概述;不复制系统拆分、具体参数、素材数量、完整排除清单或验证计划,不设固定字数或必填章节。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index bafe5b766..802069341 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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//` 暂存 9.8G,`/opt/genarrative/releases/` 下 68 个历史发布目录 9.2G;另有 journald 归档 700M 和轮转 syslog 437M。