本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/待办事宜/FlintHub_发布流程手册.md。
FlintHub 发布流程手册(发布端)
适用:FlintHub 1.1.0 起的内置升级体系
读者:站长本人 / 任何代跑发布流程的 agent
本文件位置:
待办事宜/—— ⚠️ 该目录不在发布包内(打包器白名单只有app/ assets/ lang/ cli/ docs/+ 18 个根级文件),可自由编辑、不会被分发给用户。
最后更新:2026-10-04(新增 §12 插件发布)
0. 30 秒速查
cd /d/phpstudy_pro/WWW
PHP="D:/phpstudy_pro/Extensions/php/php8.2.9nts/php.exe"
PASS=$(tr -d '\r\n' < /d/keys/flinthub/.fh_passphrase) # 口令文件在站点根之外
# ⓪ 先写「更新内容」(必做!首行 `## <一句话主题>` 会自动成为后台升级页的那行摘要)
# 片段建议放 .workbuddy-ai/tmp/notes-.md,写法见步骤 2
# ① 预演(只报告,不写盘)—— 必跑,先看 8 项门禁
FH_RELEASE_KEY=D:/keys/flinthub/flinthub-2026.key FH_RELEASE_KEY_PASS="$PASS" \
"$PHP" cli/release_build.php --type=core --version=1.1.1 \
--notes=.workbuddy-ai/tmp/notes-1.1.1.md --dry-run
# ② 正式构建(含 RSA 签名)
FH_RELEASE_KEY=D:/keys/flinthub/flinthub-2026.key FH_RELEASE_KEY_PASS="$PASS" \
"$PHP" cli/release_build.php --type=core --version=1.1.1 \
--notes=.workbuddy-ai/tmp/notes-1.1.1.md --sign --key-id=flinthub-2026
# ③ 构建后必做(两条,缺一不可)
"$PHP" -r '$f="data/runtime/update_check.json"; if(is_file($f)){unlink($f);echo "已清 6h 缓存\n";}else{echo "无缓存\n";}'
"$PHP" .workbuddy-ai/tmp/fh_pkg_vs_site.php # 必须输出「包与站点代码逐字节一致」👉 出插件包(
--type=plugin)口径不同,别套上面这段 —— 见 §12 插件发布:多了「
activated翻转 → 出包 → 重建索引 → 还原」一步,且不需要清update_check.json。
1. 环境前提
| 项 | 值 |
|---|---|
| 站点根(同时就是仓库) | D:/phpstudy_pro/WWW |
| 本机 CLI PHP | D:/phpstudy_pro/Extensions/php/php8.2.9nts/php.exe(无 pcntl / sodium / opcache) |
| 生产环境 | 2 核 2G VPS + 宝塔 + Nginx + Linux + PHP 8.2.33 |
| 发布源目录 | release/core/(在站点根内,必须对公网可读,见 §9) |
| 私钥目录 | D:/keys/flinthub/(站点根之外,绝不进包) |
| 公钥表 | app/Helpers/release_keys.php(随包分发,站点端用它验签) |
⚠️ 站点根不是 git 仓库。没有版本控制兜底,所有留档靠 release/ 里的包与 update/ 里的历史目录。
2. 目录与产物布局
D:/phpstudy_pro/WWW/ ← 站点根 = 仓库
├── cli/
│ ├── release_build.php ★ 发布端打包器(本流程主角)
│ ├── release_keygen.php 密钥生成器(一次性)
│ ├── update_probe.php 只读差异探针(CLI)
│ ├── update_fetch_probe.php 取包 + 预检探针(CLI)
│ └── env_check.php 环境自检(站点 500 也能跑)
├── release/ 发布源(对公网可读)
│ └── core/
│ ├── flinthub-core-.zip 扁平 zip(顶层 = 站点根相对路径)
│ ├── manifest-.json 清单(含每文件 sha256 + 签名)
│ ├── latest.json ★ 当前发行版(**扁平结构**,见 §5.3)
│ └── flinthub/ 镜像目录(包内容的解包副本,审计凭证)
│ ├── ...(站点根相对结构)
│ ├── version.php ← 打包器写入**目标版本**
│ └── 改动说明.md ← 打包器生成
├── update/ 老式 updateN 手工包留档(**已停止维护**,见 §6)
│ └── 99/ 最后一次维护:1.0.46 → 1.1.0
└── 待办事宜/ 计划与文档(**不在包内**)
└── FlintHub_发布流程手册.md ← 本文件
D:/keys/flinthub/ ← ⚠️ 站点根之外
├── flinthub-2026.key 加密 PKCS#8 私钥
├── flinthub-2026.pub.pem 公钥备份
└── README-keys.txt 备份与口令管理提醒2.1 产物说明
| 产物 | 作用 | 谁读它 |
|---|---|---|
flinthub-core-<ver>.zip | 站点端下载的升级包;解压到站点根即到位 | Updater::fetch() |
manifest-<ver>.json | 逐文件 sha256 + 差异依据 + 签名 | Updater::fetchManifest() |
latest.json | 「当前发行版是哪个」;站点端先读它再决定要不要拉包 | Updater::fetchLatest() |
flinthub<ver>/ | 镜像(审计凭证);⚠️ 内含 install.php,不要上传到生产 | 人工审计 |
2.2 插件发布相关目录(详见 §12)
D:/phpstudy_pro/WWW/
├── release/plugins/index.json ★ 插件市场公开清单(对公网可读,schema 2)
└── data/market/ ⚠️ 受控目录(公网不可读)
├── packages/flinthub-plugin--.zip 包体(经 /api/market/download 鉴权下发)
├── manifests/manifest--.json 清单(已签名)
└── mirrors/flinthub-plugin--/ 本地镜像(审计凭证,**不上传**)⚠️ 插件包体绝不进 release/ —— release/ 公开、data/ 受控,这是 F-1 硬边界(见 §12.5)。
3. 一次性准备(换发布机时才需要)
3.1 生成签名密钥
FH_RELEASE_KEY_PASS='<你的口令>' "$PHP" cli/release_keygen.php --key-id=flinthub-2026- 私钥写到
D:/keys/flinthub/<key-id>.key(加密 PKCS#8,脚本会断言目标目录在站点根之外)。 - 公钥合并写入
app/Helpers/release_keys.php→[key_id => PEM],随包分发。 - ⚠️ 口令只从环境变量读,绝不落盘、绝不走命令行参数(避免进 shell 历史 /
ps列表)。 - ⚠️ 私钥必须离线备份 2 份;口令存密码管理器。丢了口令 = 私钥作废。
- 查看当前内置了哪些 key_id:
"$PHP" cli/release_keygen.php --list
现有密钥:key_id = flinthub-2026,公钥指纹 sha256:5e95ba314481d19ab0a447d067e20598dcb31d4cb51d205f64a00caea01978b4。
3.2 口令怎么给
推荐写成站点根之外的单个文件,脚本里用 $(tr -d '\r\n' < <文件>) 读入环境变量:
PASS=$(tr -d '\r\n' < /d/keys/flinthub/.fh_passphrase)
FH_RELEASE_KEY_PASS="$PASS" ...⚠️ 该口令文件只是本机便利措施,与 README-keys.txt 里「本目录不含口令」的约定冲突 → 用完请删,
或改用密码管理器手工输入。
4. 标准发布流程(逐步)
步骤 1 · 确认「本次到底改了什么」
站点根不是 git 仓库,没有 git diff 可用。判定变更集的正确做法(三来源并集):
- 上一包内每个文件的 sha256 vs 站点根 → 「内容不同」= 必进包。
- mtime 扫描上一包建成时刻之后被改的文件(弱证据,用于发现新文件)。
- 新 API 反查:若本次新增了函数/常量/类,反查「谁引用它」补齐。
⚠️ 绝不能只用 mtime(会漏项)或只看 update/N 目录(它可能已过期)。
步骤 2 · 写「更新内容」(必做,别省)
每次发布都必须给 --notes=<file> —— 不给的话:
latest.json.core.notes为空 → 后台「升级」页远端版本下方那行「更新内容」什么都不显示,站长升级前看不到这次改了什么;- 包内
改动说明.md的「四、本版新增」只会写一句模板话(「本版为增量包…只含与基线有差异的文件」),等于没写。
插件流程(§8)一直带
--notes,核心流程这里以前漏了,别再有样学样地省掉。
准备一个 markdown 片段(建议放 .workbuddy-ai/tmp/notes-<ver>.md),要求:
- 3~6 条面向站长的祈使句,说清「这次多了什么 / 修了什么」,不写内部术语、不写文件名;
- 首行写
## <一句话主题>—— 它会自动成为latest.json.core.notes(后台升级页展示的那行,硬截断 600 字); - 想单独控制那行摘要,另用
--notes-brief=<file>(优先级高于##首标题;见打包器renderBrief())。
示例(1.1.22 实况):
## 插件商店新增「授权前置自检」,一键定位无法授权的原因
- 后台「插件商店 → 授权」新增「授权前置自检」,逐项检测回跳地址 / 主社区地址 / 出网与证书 / 会话与二次验证
- 检测出网与证书时会区分 TLS 证书失败与网络不可达,并原样显示报错,便于站长自查
- 自检为只读,不修改任何配置步骤 3 · 预演(必跑)
FH_RELEASE_KEY=... FH_RELEASE_KEY_PASS=... \
"$PHP" cli/release_build.php --type=core --version= \
--notes=.workbuddy-ai/tmp/notes-.md --dry-run- 打印:包内文件数、排除清单(按原因分组)、8 项门禁结果。
- 想额外看文件清单:加
--list。 - 任何一项门禁 FAIL 就不要继续,先修。
- 预演完顺手确认
latest.json.core.notes非空(步骤 2 的##首标题被正确摘出)。
步骤 4 · 正式构建
FH_RELEASE_KEY=... FH_RELEASE_KEY_PASS=... \
"$PHP" cli/release_build.php --type=core --version= \
--notes=.workbuddy-ai/tmp/notes-.md --sign --key-id=flinthub-2026⚠️ 必须加 --sign —— 站点端 Updater::verifyManifest() 默认拒签,
未签名的包会在 preflight 阶段被拒(reason=unsigned)。
产物与 sha256 会打印在最后(记下来,写进交接说明)。
步骤 5 · 构建后必做(两条)
- 清
data/runtime/update_check.json
它是「检查更新」结果的 6 小时缓存,里面存着旧包的 sha256。
包一换不清 → 站点端非 force 检查会读到旧哈希 → 取包校验失败。
> 「检查更新」按钮走 force=1 可绕过;locateManifest() 恒 ttl=0;但普通检查会踩。
- 跑「包 ↔ 站点逐字节一致」
```bash
"$PHP" .workbuddy-ai/tmp/fh_pkg_vs_site.php
```
必须输出 ✅ 包与站点代码逐字节一致(排除 version.php / 改动说明.md 两件生成物)。
⚠️ 不一致 = 升级即降级(把站点代码覆盖回旧版)。
步骤 6 · 验证
| 套件 | 命令 | 期望 |
|---|---|---|
| 只读差异探针 | "$PHP" cli/update_probe.php --no-cache | 本地版本 → 远端版本、差异五分类、预检 10 项、conflict 0 |
| 取包 + 预检 | "$PHP" cli/update_fetch_probe.php | 包 sha256 校验通过、预检通过、守卫零变化 |
| ★ 直读 zip | "$PHP" .workbuddy-ai/tmp/zip_vs_site.php <ver> | 逐条目 ↔ 站点逐字节一致(version.php 按预期差异放行) |
| 镜像一致性 | "$PHP" .workbuddy-ai/tmp/fh_manifest_mirror_check.php | 9 PASS(无参自动探测最新版本) |
| 验签 | "$PHP" .workbuddy-ai/tmp/fh_p4_sign_verify.php | 14 PASS |
| 验签 + 取包 | 见附录 A.4 | ok=true signed=true key_id=flinthub-2026 |
| 后台升级页冒烟 | bash .workbuddy-ai/tmp/fh_ui_smoke.sh | 24 PASS / 0 FAIL |
| 首页 / 主题页冒烟 | bash .workbuddy-ai/tmp/fh_theme_smoke.sh | 9 PASS / 0 FAIL |
| i18n 三语比对 | "$PHP" .workbuddy-ai/tools/fh_i18n_check.php | 53 PASS / 0 FAIL |
| 全角变量检查 | "$PHP" .workbuddy-ai/tools/php_var_cjk_check.php <改动文件...> | 无命中 |
步骤 7 · 部署上线
上传(宝塔文件管理器 / FTP / rsync 均可,保持相对路径):
release/core/flinthub-core-.zip
release/core/manifest-.json
release/core/latest.json不要上传 release/core/flinthub<ver>/(镜像目录,内含 install.php)。
部署后:站点端会自行按需拉包,无需重启。若改了 PHP 配置或新装了扩展才需要重启 PHP。
5. 版本号与发布类型规则
5.1 版本号
- 单一版本源 =
version.php的FLINTHUB_VERSION,形如V1.1.0(带V)。 - 站点根那份不随建包改动;打包器只往镜像里写目标版本。
Updater::normVersion()会剥掉前导V/v再比较。- ⚠️ 陷阱:
version_compare('1.1.0-B2','1.1.0-beta1') = -1—— 大写 B2 不被识别为 beta 别名。版本号里不要用大写预发布标记。
5.2 发布类型(当前实际支持)
| 类型 | 命令 | 说明 |
|---|---|---|
| 全量包 | --version=<ver>(不加 --base) | full=true。含站点根白名单内全部文件。任意版本可直升 |
| 增量包 | --version=<ver> --base=<base> | ⚠️ 当前是半成品,见 §10.1,不要用于生产 |
保留策略(pruneBeta() 自动执行,只清预发布包):
- 版本号含
-的(如1.1.0-beta2)→ 只保留最新 3 个(--keep-beta=N可调),同步删 zip +manifest-<ver>.json+ 镜像目录。 - 版本号不含
-的(正式版)→ 永不自动删除。
5.3 latest.json 结构(★ 2026-10-02 起扁平)
{
"schema": 1,
"generated_at": "2026-10-02T02:14:36+00:00",
"core": {
"version": "1.1.0",
"base_version": null,
"package": { "url": "/release/core/flinthub-core-1.1.0.zip", "size": 1801748, "sha256": "72f7e7f0…" },
"manifest": { "url": "/release/core/manifest-1.1.0.json", "sha256": "728176f4…" }
},
"plugins": { "index_url": "/release/plugins/index.json" }
}- ⚠️
core直接就是发行对象,不再有stable/beta嵌套。 - ⚠️ 只有一个发行版,不区分正式/测试通道;后台也没有通道选择器。
- 旧格式(带嵌套)会被站点端按「官网暂未发布可用版本」处理。
5.4 站点端版本校验(preflight 第 10 项)
- 全量包(
full=true)→SKIP,任意版本可直升。 - 增量包 → 要求
base_version ≤ 当前版本 < 目标版本(区间,不是严格相等)。
6. update/N 老式手工包(已停止维护)
update/99/ 是最后一次维护的老式增量包(V1.0.46 → V1.1.0),1.1.0 起不再随版本更新。
如果仍要同步它:
"$PHP" .workbuddy-ai/tmp/fh_u99_sync.php # 把仓库改动文件复制进去 + 写版本号
"$PHP" .workbuddy-ai/tmp/fh_u99_diff2.php # 子集差异复核(只应剩 version.php 刻意不同)
"$PHP" .workbuddy-ai/tmp/fh_u99_manifest_check.php # ★ 文档里的 SHA256 块逐条校验⚠️ update/99/version.php 刻意写 V1.0.99(不是 V1.1.0):覆盖后站点自报 V1.0.99
→ 在线升级页才会看到「远端 1.1.0 可用」→ 可实测完整升级链路。
⚠️ update/N 是手工目录,不受打包器白名单保护 —— 放进去什么就分发什么。同步后必须:
- 列出目录全部文件,逐个问「它该不该被分发」。
绝不能进任何分发目录的两类:
- 一次性/调试脚本(如
_fh_diag.php):往往自带硬编码 token 当门禁,token 随包公开 = 等于无门禁;
还会输出站点绝对路径 / error_log 路径 / 运行时状态 = 信息泄露。且极易过期(改结构后仍按旧格式解析 → 诊断结论是错的)。
- 站点私有文件(如
config.php):每站 DB 路径 /SITE_URL/ 密钥都不同,整份覆盖 = 毁站。
改动说明.md的文件清单必须与目录实际内容一致。
★ 清单没提、目录里却有 = 高危信号,一律先当遗留物处理。
7. 8 项发布门禁(打包器自动跑)
| # | 门禁 | 内容 |
|---|---|---|
| #1 | i18n 键集合 | 三语(zh / zh_tw / en)键集合零差异 + 键序一致 |
| #2 | PHP 语法 | 双版本(8.2 + 8.0)全量 php -l |
| #3 | 编码 | BOM 0 / CRLF 0 / 非 UTF-8 0(有白名单例外) |
| #4 | 调试残留 | 无 var_dump / print_r / dd( 等 |
| #5 | 卫生 | 无 config.php / .bak / .zip / .sqlite / .log |
| #6 | 包结构复核 | 顶层目录与根级文件符合预期 |
| #7 | 路径安全 | 包内容 ⊆ 升级器白名单 + 路径大小写一致 |
| #8 | 签名链路自检 | 私钥 ↔ 公钥配对、签名往返自验通过 |
门禁 #7 是防「装得上、升不了」的关键:包里若有升级器白名单之外的文件,
它会静默出现在 diff 的 skipped 里 —— 用户装了包,那个文件却永远不生效。
打包器必须复用 Updater 的常量(WHITELIST_DIRS / WHITELIST_FILES / ASSETS_EXCLUDES / BLACKLIST_*),
不许自抄清单,否则两边会漂移。
8. 白名单(决定「什么会进包」)
Updater 的常量是唯一真相源:
WHITELIST_DIRS = ['app/', 'assets/', 'lang/', 'cli/', 'docs/']
WHITELIST_FILES = ['index.php','cron_trigger.php','install.php','sw.js','version.php',
'.htaccess','web.config','nginx-server.conf','nginx-baota.conf','robots.txt',
'README.md','INSTALL.md','CHANGELOG.md','LICENSE',
'favicon.ico','favicon.svg','logo.svg','flinthub-badge.svg']
ASSETS_EXCLUDES = ['assets/uploads/', 'assets/themes/']
BLACKLIST_EXT = ['zip', 'bak', 'log', 'sqlite']
BLACKLIST_SUFFIX = ['-wal', '-shm']- 不会进包:
config.php(站点私有)、data/与protected/的实际内容(只带.htaccess/web.config自防护文件)、
assets/uploads/(用户上传)、assets/themes/(目录主题)、plugins/(插件是独立发布包)、
release/、update/、待办事宜/、审计/、根级散落文件。
- 会进包但升级器会 skip:
data/.htaccess、data/web.config、protected/.htaccess、protected/web.config
—— 这 4 个「防护骨架文件」供全新安装与手工补,升级器硬黑名单不写它们(门禁 #7 对它们豁免)。
9. 红线清单(务必遵守)
| # | 红线 | 原因 |
|---|---|---|
| 1 | config.php 永不进包 | 站点私有,覆盖 = 毁站。改它要同步改 install.php::configTemplate() |
| 2 | 私钥永不进站点根 / 仓库 / 发布包 | 泄露 = 签名体系失效 |
| 3 | 口令只走环境变量,不落盘、不进命令行参数 | 避免 shell 历史 / ps 列表泄露 |
| 4 | /release/ 必须对公网可读 | 它是下游所有安装的更新源。⚠️ 禁止一把 deny all |
| 5 | 不上传镜像目录 release/core/flinthub<ver>/ | 内含 install.php,有执行面 |
| 6 | 包必须与站点代码逐字节一致 | 否则升级 = 降级 |
| 7 | 构建后必清 data/runtime/update_check.json | 6h 缓存存着旧包 sha256 → 取包校验失败 |
| 8 | latest.json 保持扁平 | 站点端已不认 stable/beta 嵌套 |
| 9 | 改过 改动说明.md 的 SHA256 清单后必须逐条校验 | 手改极易出错(改错对象 / 重复行 / 错哈希) |
| 10 | 升级包不写数据库、不删数据 | 回滚 = 覆盖备份即可 |
| 11 | Windows 上别拿生产站当靶子跑 apply | rename() 无法覆盖「正在执行的入口脚本」→ 拒绝访问(5);Linux 无此问题 |
| 12 | 改插件静态资源必须 bump 版本号 | ?v= 是唯一的破缓存手段 |
10. 已知未实现 / 待办
10.1 ⚠️ 打包器 --base=<ver> 是半成品,不要用于生产
现状:--base 只影响元数据(manifest.base_version、manifest.full=false、改动说明文案),
完全不做文件过滤 —— 产出的包声称是「增量包」,实际仍是全量内容。
而且改动说明里会自动写一句「本版为增量包(基线 V<base> → V<ver>),只含与基线有差异的文件」
—— 这句话目前是假的。
要落地还需(用户已批准的方案):
--base真按基线过滤(只留新增 / 内容变化),并把「基线有、本次无」写进manifest.deleted。- 累积增量包命名加
-cum-前缀(区分全量包,prune才安全)。 - 实现累积增量包清理(同基准下只留最新一个,同步删
manifest-*.json)。 - 以上都落地后,才把「只含差异文件」这句话保留在文案里。
站点端已就绪:
preflight第 10 项、diff()的full守卫都已支持累积增量包。
10.2 其他
- ✅
release/core/的 beta1/beta2 开发期产物已于 2026-10-02 手工清理(zip + 镜像目录 + manifest 共 6 项)。
现发布源只剩正式版:flinthub-core-1.1.0.zip + flinthub1.1.0/ + manifest-1.1.0.json + latest.json。
备份留档在站点根之外 D:/phpstudy_pro/_flinthub_beta_backup_20261002/(14 MB)。
今后 beta 仍按 §5.2 规则「留最近 3 个」自动保留;清理是手工动作,打包器不会自动删正式版。
assets/css/themes/themes.zip是仓库里的游离文件;不会进包(门禁 #5 排除.zip),无害。- 根级散落着
config2.php、install2.php、version.php.bak、cb_dbg.log、update.zip、
install.zip、nginx-baota.zip、flinthub1.0.0.zip 等历史文件 —— 都不进包,但建议清理以免误传。
11. 故障排查
| 症状 | 先查什么 |
|---|---|
| 后台升级页「远端版本 / 清单签名」恒显示未知 | ① data/runtime/update_check.json 是否为旧缓存(清掉重试);② protected/error.log 有无 warning 污染 JSON 响应 |
/admin/update/check 前端报 SyntaxError: Unexpected token '<' | 同上 —— 老 config.php 的 SERVER_PORT warning 被 display_errors=On 打进了 JSON 体。已由 index.php / cron_trigger.php 顶部抑制修复 |
| 取包后 sha256 不一致,但包看起来是好的 | 忘了清 update_check.json(缓存里的旧哈希) |
| 升级后站点代码变旧 | 包与站点不一致 —— 跑 fh_pkg_vs_site.php,重新构建包 |
| 包内某文件在升级里永远不生效 | 该文件不在 Updater 白名单 → 门禁 #7 应拦下;跑 --dry-run 看是否 PASS |
apply() 报「落盘失败(write/rename)」 | 只在 Windows 本机测试时出现(入口脚本自身被覆盖);Linux 生产无此问题 |
| 想二次 apply 却报 409 | 回滚会释放升级锁 → 必须重新预检取锁;且成功升级后 last_backup 被清空,需重新备份 |
升级失败但 error.log 没内容 | 写操作失败是返回值,落点在审计表 data/meta/business.sqlite 的 audit_logs(action LIKE 'update%') |
12. 插件发布(Hub 官方路径)
适用:把
plugins/<目录名>/作为官方插件发布到 Hub 插件市场(商店里出现卡片 + 安装/升级按钮)。对照:§4 讲的是核心包(
--type=core);本节是插件包(--type=plugin)—— 流程同构,口径不同,别混。权威细则:
.workbuddy-ai/skills/flinthub-release-package/SKILL.md的 §0.5b(含逐条实证);本节是它的成文版。最后更新:2026-10-04(首个完整走完本节流程的实例:
floor_reply1.3.1)
12.1 两条插件发布路径(边界别混)
| 路径 | 入口 | 允许根级文件 | 产物落点 |
|---|---|---|---|
| 后台发布(Hub 官方,本节) | cli/release_build.php --type=plugin | ✅ 允许根级 改动说明.md | data/market/{packages,mirrors,manifests}/ |
| 前台投稿(用户投稿) | /plughub/submit | ❌ 禁止任何根级文件(每文件须 plugins/<name>/ 开头) | 由 Hub 侧接收 |
⚠️ 前台投稿包的硬约束(单根 plugins/<name>/、表单字段约束表)见 docs/插件开发规范.md §11.35。
12.2 四步流程
cd /d/phpstudy_pro/WWW
PHP="D:/phpstudy_pro/Extensions/php/php8.2.9nts/php.exe"
. .workbuddy-ai/tmp/fh_key_env.sh # 提供 FH_RELEASE_KEY / FH_RELEASE_KEY_ID / 口令
# ① 改版本号(plugin.json 的 version;见 §12.3)
# ② 翻转 activated → false(★插件专属,见 §12.4)
"$PHP" .workbuddy-ai/tmp/fh_flip_activated.php <目录名> flip
# ③ 出包(★必须 --sign)
"$PHP" cli/release_build.php --type=plugin --name=<目录名> --version= \
--sign --key-id="$FH_RELEASE_KEY_ID" --notes=<改动说明.md 路径>
# ④ 重建公开清单
"$PHP" cli/release_build.php --type=index
# ⑤ 还原 activated(★必须在 ③④ 之后)
"$PHP" .workbuddy-ai/tmp/fh_flip_activated.php <目录名> restore
# ⑥ 验收(见 §12.7)
"$PHP" .workbuddy-ai/tmp/fh_plugin_verify.php <目录名>
python .workbuddy-ai/tmp/verify_market_release.py⚠️ ③④ 必须在 flip 与 restore 之间(否则包内 activated 是 true)。
⚠️ 命令分步执行,别串成一条超长命令(本机沙箱会因命令过长拒绝解析,报 decisionRecord missing ...)。
12.3 版本号规则
| 项 | 规则 |
|---|---|
| 真相源 | plugins/<目录名>/plugin.json 的 version(不带 V;带 V 的是核心 version.php) |
| 默认口径 | 次版本 +1(a.b.c → a.(b+1).0)—— 与既有 50 个插件的 x.Y.0 谱系一致 |
| 例外 | 站长可指定 patch 位(如纯修复走 1.3.0 → 1.3.1)—— 由站长拍板,agent 不擅自决定 |
| 必须同步 | plugin.json + 说明.md 版本行 + 代码文件头注释(三处一起改) |
| ⚠️ 别用大写预发布标记 | version_compare('1.1.0-B2','1.1.0-beta1') = -1(同核心,见 §5.1) |
12.4 ★★ 包内 plugin.json.activated 必须是 false(插件专属红线)
为什么:市场安装页有「安装后自动启用」勾选框(app/Views/admin/_market_action.php:22,name="activate",默认 checked)
→ 启用与否由站长安装时决定,包里的值只能是中性默认。
⚠️ PluginUpdater::PRESERVE_KEYS = ['activated','sort_order'] 只在「升级」时回填本地值;
全新安装时 localPreserve() 因 localConfig() 为 null 返回 [] → 包里的值被直接采用。
包内带 true ⇒ 新站装完直接启用,且那个勾选框失效(isActivated() 已 true → 跳过 activate 分支)。
⚠️ 别被现状误导:仓库里已发布的包多数带 true —— 那只是构建机的本地启用态被原样打进去的潜在缺陷,
不是设计口径(依据:docs/插件开发规范.md:256「activated 由系统在激活/禁用时自动写回」)。
做法:用 fh_flip_activated.php <目录名> flip|restore —— 快照 两个文件
(plugin.json + plugins/plugins_cache.json),还原顺序 json 先、缓存后。
⚠️ 为什么必须连缓存一起快照:Plugin::cacheFresh()(Plugin.php:388-408)只要发现**任一 plugin.json
比 plugins_cache.json 新,就判缓存过期 → 全量重扫 + saveCache() 重写缓存文件**。
只还原 plugin.json 会留下一个被重写过的缓存;还原顺序反了则缓存 mtime 反而更旧 → 下次请求又触发重扫。
12.5 产物三件套与交付形态
| 产物 | 路径 | 是否上传 |
|---|---|---|
| 包 | data/market/packages/flinthub-plugin-<name>-<ver>.zip | ✅ 上传(增量新增) |
| 清单 | data/market/manifests/manifest-<name>-<ver>.json | ✅ 上传(已签名;增量新增) |
| 公开索引 | release/plugins/index.json | ❌ 不上传(见下方 ⚠️⚠️) |
| 镜像 | data/market/mirrors/flinthub-plugin-<name>-<ver>/ | ❌ 不上传(本地审计凭证) |
交付 = 这 2 个文件(包 + 清单),按站点根相对路径增量新增到 Hub 站点根
—— ⚠️ 只加这两个文件,不要整目录覆盖 data/market/manifests/。
⚠️⚠️ release/plugins/index.json 不上传(1.1.7「下架」功能起)
它是 data/market/manifests/ 的确定性函数(Hub 自己会重建)。上传它会:
① 复活已下架插件(归档在 manifests-off/ 的那些);
② 把只在 Hub 上存在的社区投稿从清单里抹掉(本地 index 不含它们)。
正确做法:包 + 清单传完后,到 Hub 后台 市场 Hub → 设置 Tab → 点「重建清单」
(POST /admin/market-hub/rebuild)。Hub 从自己的 manifests/ 重算清单,
按「同一插件只留最高版」→ 新版本自动生效,已下架 / 社区投稿状态原样保留。
⚠️ F-1 硬边界:清单公开(release/plugins/index.json 对公网可读);
包体受控(data/ 有三级防护,公网不可读,经 /api/market/download 鉴权流式输出)
→ 插件包体绝不进 release/。
⚠️ 旧版本包不要删(同 §5.2 核心策略)—— 客户端有 6h 清单缓存(Market::INDEX_TTL),
未同步的站仍按旧清单请求旧版本 → 旧包删了会 404(窗口 ≤6h)。打包器也不会自动清理正式版。
12.6 门禁 9 项(比核心包多一项)
| # | 门禁 | 插件包特有说明 |
|---|---|---|
| #1~#8 | 同 §7(i18n / 语法双版本 / 编码 / 调试残留 / 卫生 / 包结构 / 路径安全 / 签名链路自检) | #1 的 i18n 校验**含 plugins/*/lang/**;#7 校验「包内路径 ⊆ plugins/<name>/ + 根级 改动说明.md」 |
| #9 | 命名空间一致性 | 独立子进程实测 class_exists('\Plugin\<目录名>\Plugin');命名空间须被 Autoloader 三条候选之一命中(详见 docs/插件开发规范.md §5.1) |
12.7 发布后验收
"$PHP" .workbuddy-ai/tmp/fh_plugin_verify.php <目录名> # 单包 20 断言(只读)
python .workbuddy-ai/tmp/verify_market_release.py # 市场全量(期望「全部通过 ✓」)fh_plugin_verify.php 覆盖:清单可解析 → Updater::verifyManifest() 验签 → 包 sha256/size 对声明 →
manifest.files ↔ 站点根逐字节 → zip 条目 ↔ 站点根逐字节 → 镜像集合 == zip 集合 →
release/plugins/index.json 条目(version/sha256/size/manifest.sha256/signed/icon/url)→
plugin.json 版本已同步 → 本机 activated===true(还原态) → 包内 activated===false → 旧版本包仍在。
⚠️⚠️ 两条「逐字节一致」断言必须对 plugins/<name>/plugin.json 豁免 —— 包内 activated=false、站点根 true,
不豁免会必然报 2 条假 FAIL(2026-10-04 出 floor_reply 1.3.1 实测)。
正解 = 该文件改用「除 activated 外 JSON 等价」比较,其余文件照旧逐字节。
⚠️ 别断言「zip 文件集合 == manifest.files 集合」 —— 根级 改动说明.md 是打包器有意写进 zip 与镜像、
但不进 manifest.files 的文档(升级器按 manifest 逐条取文件 → 它永不被安装)。
正确断言 = 差集恰好 ['改动说明.md']。
12.8 插件发布红线
| # | 红线 | 原因 |
|---|---|---|
| 1 | 包内 activated 必须 false | 否则新站装完直接启用,站长勾选框失效(§12.4) |
| 2 | 本机 activated 必须还原成原样 | 构建机就是真实运行站,忘了还原 = 站点插件被静默停用 |
| 3 | 镜像目录不上传 | 本地审计凭证,无分发价值 |
| 4 | 插件包体绝不进 release/ | F-1 边界:release/ 公开,包体须经鉴权下载 |
| 5 | 旧版本包不删 | 客户端 6h 清单缓存期内仍可能请求旧版(§12.5) |
| 6 | 必须 --sign | 站点端 Updater::verifyManifest() 默认拒签 |
| 7 | 改静态资源必须 bump 版本号 | ?v= 是唯一破缓存手段(同核心,见 §9 红线 12) |
| 8 | release/plugins/index.json 不上传 | 上传会复活已下架插件 + 抹掉只在 Hub 的社区投稿(§12.5) |
| 9 | 不整目录覆盖 data/market/manifests/ | 同上;且会物理删掉 Hub 上独有的社区投稿 manifest |
12.9 已知坑(都踩过)
| 症状 | 真因 | 处置 |
|---|---|---|
验收报 2 条「逐字节不一致」,都指向 plugin.json | 探针没豁免 activated(包内 false / 站点根 true) | 用 §12.7 的「除 activated 外 JSON 等价」口径 |
| 验收报「文件集合不等」 | 断言写成「zip == manifest.files」 | 差集应恰好 ['改动说明.md'](§12.7) |
| 出包命令整条被沙箱拒绝 | 命令过长 / 引号复杂 | 拆成多条分步执行 |
| 商店里没有卡片 | 新清单没重建 / 包+清单没到位 | ① 传完包+清单后忘了点「重建清单」(§12.5)→ 先点它;② 同机 Hub 每次现读、不走 TTL,不是缓存问题 |
| 下架过的插件又出现在商店 | 上传了本地 index.json 或整目录覆盖了 manifests/ | 见 §12.5 ⚠️⚠️;把 Hub 上 manifests-off/ 的 manifest 移回 manifests/ 可重新下架 |
| 卡片只有「详情」、没有「安装」 | 核心 bug(视图数据),非插件问题 | 1.1.6 已修(见 skill §0.5b ⑥);旧站先升级核心 |
| 卡片在,按钮是「升级到 x.y.z」 | 已装且远端更高 | 正常升级,非故障 |
12.10 本节用到的脚本
| 脚本 | 作用 |
|---|---|
.workbuddy-ai/tmp/fh_key_env.sh | 发布密钥环境(私钥路径 / key_id / 口令) |
| `.workbuddy-ai/tmp/fh_flip_activated.php <目录名> flip\ | restore` |
.workbuddy-ai/tmp/fh_plugin_verify.php <目录名> [版本] | 单包发布验收(20 断言,只读) |
.workbuddy-ai/tmp/verify_market_release.py | 市场全量端到端验收(50 插件 / 图标表 / CRC / 路径) |
.workbuddy-ai/tmp/build_market_release.py | 汇总「可上传目录」到 .workbuddy-ai/output/market-release/ |
附录 A · 命令清单
A.1 环境变量(可 source 复用)
# .workbuddy-ai/tmp/fh_key_env.sh 已封装(本机专用)
. /d/phpstudy_pro/WWW/.workbuddy-ai/tmp/fh_key_env.sh
# 提供:FH_RELEASE_KEY / FH_RELEASE_KEY_ID / FH_RELEASE_KEY_PASS_FILEA.2 构建
cd /d/phpstudy_pro/WWW
PHP="D:/phpstudy_pro/Extensions/php/php8.2.9nts/php.exe"
PASS=$(tr -d '\r\n' < /d/keys/flinthub/.fh_passphrase)
RUN() { FH_RELEASE_KEY=D:/keys/flinthub/flinthub-2026.key FH_RELEASE_KEY_PASS="$PASS" "$PHP" cli/release_build.php "$@"; }
RUN --type=core --version=1.1.1 --dry-run # 预演
RUN --type=core --version=1.1.1 --dry-run --list # 预演 + 文件清单
RUN --type=core --version=1.1.1 --sign --key-id=flinthub-2026 # 正式
RUN --type=plugin --name=<目录名> --version=1.1.1 --sign --key-id=flinthub-2026 # 插件包A.3 构建后
"$PHP" -r '$f="data/runtime/update_check.json"; if(is_file($f)){unlink($f);echo "已清\n";}else{echo "无缓存\n";}'
"$PHP" .workbuddy-ai/tmp/fh_pkg_vs_site.phpA.4 验签 + 取包校验
"$PHP" -r '
require_once "app/Core/Autoloader.php"; use app\Helpers\Updater; ini_set("display_errors","0");
$mf = Updater::readJson("release/core/manifest-.json");
$s = Updater::verifyManifest($mf, false);
echo "验签: ok=",var_export($s["ok"]??null,true)," signed=",var_export($s["signed"]??null,true)," key_id=",($s["key_id"]??"-"),"\n";
echo "取包: ",json_encode(Updater::verifyPackage("release/core/flinthub-core-.zip",$mf),JSON_UNESCAPED_UNICODE),"\n";'A.5 回归套件一览
| 脚本 | 覆盖 | 基线结果 |
|---|---|---|
.workbuddy-ai/tmp/fh_ui_smoke.sh | 后台升级页 HTTP(页面 200 / 无原始 i18n 键 / 未登录 302 / 缺 CSRF 403 / check 正例 / 已删路由 404 / error.log 零新增) | 24/24 |
.workbuddy-ai/tmp/fh_theme_smoke.sh | 首页 + 后台主题页 + 主题 CSS 可达性 | 9/9 |
.workbuddy-ai/tmp/fh_pkg_vs_site.php | 发布包镜像 ↔ 站点逐文件 sha256 | 316/316 |
.workbuddy-ai/tmp/zip_vs_site.php <ver> | ★ 直读 zip 每个条目 ↔ 站点逐字节(剔除目录条目 + 改动说明.md;version.php 按「预期差异」断言包内为目标版本) | 317/317 |
.workbuddy-ai/tmp/fh_manifest_mirror_check.php | 镜像 ↔ manifest ↔ latest.json + 站点端验签(无参自动探测最新版本) | 9/9 |
.workbuddy-ai/tmp/fh_p4_sign_verify.php | 签名链路(含篡改 4 类 / 未签名默认拒 / 键序无关) | 14/14 |
cli/update_fetch_probe.php | 取包 sha256 + 预检 10 项 + 增量守卫 | 19/19 |
.workbuddy-ai/tmp/fh_u99_diff2.php | update/99 子集差异 | — |
.workbuddy-ai/tmp/fh_u99_sync.php | update/99 同步 | — |
.workbuddy-ai/tmp/fh_u99_manifest_check.php | 改动说明.md SHA256 块逐条校验 + 漏项反查 | 全 PASS / 漏项 0 |
.workbuddy-ai/tmp/fh_plugin_verify.php <目录名> [版本] | 插件包发布验收(20 断言,只读) | 20/20(floor_reply 1.3.1) |
| `.workbuddy-ai/tmp/fh_flip_activated.php <目录名> flip\ | restore` | 插件发布 activated 口径翻转 / 按字节还原 |
.workbuddy-ai/tmp/verify_market_release.py | 插件市场全量端到端验收(50 插件 / 图标表 / CRC / 路径) | 全部通过 ✓ |
.workbuddy-ai/tmp/build_market_release.py | 汇总「可上传目录」到 .workbuddy-ai/output/market-release/ | — |
.workbuddy-ai/tmp/mksess.php | 造管理员会话夹具(php mksess.php 1 admin / php mksess.php del <sid>) | — |
.workbuddy-ai/tools/fh_i18n_check.php | 三语键集合 + 键序 + 代码引用键 | 53/53 |
.workbuddy-ai/tools/php_var_cjk_check.php | 全角标点紧跟 $var 的静默空值风险 | 0 命中 |
.workbuddy-ai/tmp/fh_p4_sandbox_test.php | 升级内核沙箱(备份/应用/回滚) | 32/32 |
.workbuddy-ai/tmp/fh_p4_rehearsal*.sh | 站点完整副本实弹演练 | 41/41 |
附录 B · 给 agent 的执行契约
如果你是一个被指派「发布 FlintHub 新版本」的 agent,请遵守:
必做
- 读完本文件再动手。特别是 §9 红线、§10.1(
--base未实现)。 - 先预演(
--dry-run),8 项门禁全 PASS 才正式构建。 - 正式构建必须带
--sign --key-id=flinthub-2026。 - 构建后必做两条:清
update_check.json+ 跑fh_pkg_vs_site.php。 - 把产物 sha256(zip / manifest)原文抄进回复,方便人工核对。
- 报告要包含:版本号、包大小、文件数、两项 sha256、门禁结果、验证套件结果。
- 出插件包(
--type=plugin)走 §12,别套用核心包步骤 —— 多一步activated翻转/还原;
验收用 fh_plugin_verify.php;不需要清 update_check.json(那是核心更新检查的缓存)。
禁止
- 不要改
config.php、不要把它或任何站点私有文件放进任何分发目录。 - 不要动
D:/keys/flinthub/里的私钥;不要把口令写进任何文件或命令历史。 - 不要删除
release/core/里正式版(版本号不含-)的包 —— 全量包永久保留。 - 不要在生产站上真跑
apply/rollback(要演练就造副本,见fh_p4_rehearsal*.sh)。 - 不要清页面缓存(站长可能正开着页面自测)。
- 不要用
rm通配删文件;删文件一律用 PHPunlink()/rmdir(),且只按精确文件名删自己刚建的。 - 改过
改动说明.md的 SHA256 清单后,必须跑fh_u99_manifest_check.php,别信肉眼。 - 出插件包时不要忘记还原本机
activated—— 构建机就是真实运行站(§12.4 / §12.8)。
判停条件
- 门禁任一项 FAIL → 停下来修,不要继续。
fh_pkg_vs_site.php报不一致 → 停下来重新构建,不要上线。- 不确定某个文件该不该进包 → 停下来问,不要猜。
- 插件包验收报「
plugin.json逐字节不一致」→ 先怀疑断言没豁免activated,别急着改包(§12.7)。