车东 Che, Dong

Fri 16 October, 2026

03:49 refactor(cli): log-analyze.sh 参数化,项目里不再有站点专属配置 » Recent Commits to phpman:master
refactor(cli): log-analyze.sh 参数化,项目里不再有站点专属配置

这个脚本是 phpman 里唯一把 chedong.com 硬编码进去的地方,而且是它最复杂的一处。
现在项目只保留通用部分,站点相关的三项全部从外部进来:

- PHPMAN_WEBLOG_DIR(必填)—— 原来硬编码 `/home/chedong/logs/chedong.com/https`
- 位置参数 YYYY-MM-DD [YYYY-MM-DD] —— 不变
- PHPMAN_SPIDER_EXTRA(可选)—— 本站爬虫长尾,默认 $HOME/.phpman/spider-extra.txt

爬虫清单原本用两种形式各写了一遍(一个 grep 模式串 + 一个 300+ 字符的正则),
改一处必须记得改另一处。现在合并成一份 SPIDER_RE,两处 grep 共用;并且都用 -i,
所以 GPT/gpt、Claude/claude 这类大小写变体不用再各列一遍。项目自带的通用清单只剩
`bot|spider|crawler|slurp|ia_archiver|anthropic|meta-externalagent|oai-` —— 前三个
通用词覆盖绝大多数,后面几个是名字里不含它们的。

顺带删掉 `/blog/archives/`、`/blog/docs/` 两条 sed:那是 chedong.com 的 URL 模式,
与 phpman 无关。

修掉三个 bug(前两个是用户点名的,第三个是跑生产日志时暴露的):

1. 结尾两行打的是 `$LOG` —— 一个从未定义过的变量(脚本定义的是 `LOG_CURRENT`),
   所以「Log file / Last entry」从来没打出来过。现在按日志目录 + 文件数 + 最后一条
   记录来报,实测在生产上正常输出。
2. 归档收集循环用 `date -d "$D +1 day"` —— GNU 专有,macOS/BSD 上静默失败,循环
   一次都不跑,脚本看起来「没有日志」。改成 next_day() 同时试 GNU `-d` 与 BSD `-v`。
3. 提取 UA 时对所有引号段做匹配,于是请求行 `"GET /robots.txt HTTP/1.1"` 因为含
   "robot" 被当成一个爬虫报出来(生产上显示为 328 次)。现在排掉请求行。

另有一处实测踩到的坑:按日期**拼文件名**,不 glob 目录。DreamHost 上那个日志目录
不可列举(`ls` 报 Permission denied),glob 展开不出任何东西,而按名字直读可以。
glob 作为兜底保留,只在按名字一个都没找到时试一次。

还有一处:`wc -l < file` 在 BSD 上补前导空格,`$TOTAL` 拼进 awk 会变成语法错误
(原脚本同样存在)。统一 `tr -d ' '`;百分比也改成先算好再拼 —— 塞进 echo 的 $()
里需要嵌套三层引号,POSIX sh 下 awk 收到的是被截断的程序。

`make test` 补上 `cli/*.sh` 的 `sh -n`(原来只查 `deploy/*.sh`)。

实测:本地 fixture 走 BSD 分支正常;生产 2026-10-15 单日 62,103 行、爬虫 55.8%,
范围 10-15→10-16 合并 71,102 行。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
03:08 fix(release): RELEASE_ID 恢复净化 —— 它会被远端 shell 重新解析 (#250) » Recent Commits to phpman:master
fix(release): RELEASE_ID 恢复净化 —— 它会被远端 shell 重新解析 (#250)

#250 的独立安全审查没有发现达到报告门槛的漏洞(该改动集里没有不可信输入路径:
流进 shell / 路径 / 公网页面的值要么是 CLI/env,要么先过了
`^[0-9]+\.[0-9]+\.[0-9]+$` 校验),但指出一处**纵深防御回归**,是我引入的:

旧 RELEASE_ID 有净化,注释里写着理由「sanitized because the tag ... must stay a safe
path component」。新实现直接 `$$(cat .release-version)`,未净化。而 RELEASE_ID 在
_release-code 里是**不加引号地插在双引号 ssh 命令串内**的,会被远端 shell 重新解析 ——
一旦 .release-version 里出现 shell 元字符,就是生产主机上以部署用户身份的命令注入,
atomic-release.sh 那个单引号参数也会被顶破。

今天不可利用:.release-version 的唯一写入者是 next-version.sh,两条输出路径都校验成
vN.N.N。但那让这个安全属性**依赖两个文件之外的一个脚本** —— 哪天有人放宽校验(比如接受
VERSION=4.13-rc1,仓库里历史上就有 v3.6.1-readme 这种带后缀的 tag),它就立刻变成活的。
在这里净化,属性就回到本地。

实测:`v4.13-rc1'; id; echo '` → `v4.13-rc1___id__echo__`。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
02:52 docs: 发布流程文档同步到新的版本号方案,并归档实施计划 (#250) » Recent Commits to phpman:master
docs: 发布流程文档同步到新的版本号方案,并归档实施计划 (#250)

- README:发布流程改为「本地提交(不 push)→ make staging → make release」,
  新增一节说明 make release 的四步顺序(守卫 → 版本 → 部署 → 打 tag/push);
  第 5 步不再手工 git tag(tag 已由 release 创建)
- docs/05-PLAN.md 的 Update Flow 重写:补上守卫与版本来源,说明 _release-tag
  为什么必须在所有步骤之后(release-reindex 的 index 重建跑在 _release-code 之后)
- CHANGELOG:新增 Fixed 段
- docs/superpowers/plans/:实施计划归档,附一节「实施中的偏差」记录三处计划没预见到
  的问题(precheck 输出 tag 快照、守卫加有界重试、RELEASE_STAMP 在 parse time 解析)

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
02:31 feat(release): 版本号以 origin 的 tag 为准,打 tag 并入 make release (#250) » Recent Commits to phpman:master
feat(release): 版本号以 origin 的 tag 为准,打 tag 并入 make release (#250)

删除 make tag / tag-minor —— 它们从本地 tag 列表推下一个版本号,在 tag 陈旧的机器上
会算出**倒退**的版本号并 push 到 origin,永久留下一个错 tag。这是 #250 的写入版:
make release 只是把错的版本显示出去,make tag 是把它写下来。

make release / release-reindex 现在:
- 先跑 deploy/release-precheck.sh(三条守卫),任何 rsync 之前
- 版本号由 origin 的 tag 快照算出(默认补丁 +1,VERSION=4.13 显式覆盖)
- 部署全部成功后由 _release-tag 打 annotated tag 并 push master + tag
- 加 test-unit 前置:守卫的回归此前可以在完全没被测过的情况下发出去

实现中发现并修掉一个自己引入的洞:_release-code 原本让 next-version.sh 自己
ls-remote 一次。到 github.com:22 的连接在这台机器上偶发超时(实测 10 次挂 1-2 次),
那次失败会得到空输入,被当成「远端还没有 tag」算出 0.0.1 —— 一个静默的版本倒退,
正是这套机制要消灭的东西。改成由 precheck 把它已经取到、并据以判断过的**同一份**
tag 快照交给下游(precheck 成功时把快照打到 stdout),一次网络请求,且守卫看到的
状态与算版本号用的状态必然一致。

同时给守卫的网络调用加了有界重试(默认 3 次 / 2s 线性退避):守卫的职责是判断状态,
一次网络抖动只能说明「暂时问不到」,不重试会有 10-20% 的假阳性让 make release 没法用。

RELEASE_STAMP 在 parse time 解析一次:recipe 每行是独立 shell,行内 date 会让同一
次部署的各行拿到不同时间戳,把一次部署劈成两个 release 目录。

实测:守卫 3 在脏树上拦住(报出具体文件);make -n test/logcheck/verify 均无网络调用。
609 passed / 0 failed。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
02:05 feat(staging): 版本串改为 staging-<sha>[-dirty],不再冒充发布版本 (#250) » Recent Commits to phpman:master
feat(staging): 版本串改为 staging-<sha>[-dirty],不再冒充发布版本 (#250)

tag 现在只在 release 那一刻打,所以 staging 部署的通常是**尚未打 tag** 的提交。
继续用 `git describe --abbrev=0` 会让它报上一个 release 的版本号 —— 跑的却是更新的
代码,方向正好反了(issue 里那个 staging 停在 v4.12.2、生产反而是 v4.11.2-59 的倒挂
就是这么来的)。sha 是诚实的;工作树脏时补 -dirty,因为那个 sha 描述不了实际传上去
的字节。

- 新增 DEPLOY_PATHS:会被实际上传的路径(src/ phpMan.php cli/ deploy/
  phpman.config.php.example phpman.css phpman.js),既是「脏」的判定范围,也是
  release-precheck.sh 的入参,只在一处定义
- STAGING_VERSION 用 git status --porcelain -uall -- $(DEPLOY_PATHS),与守卫 3
  同一套判定:路径外的草稿笔记不标记,src/ 里没 git add 的新文件会标记
- 两条 $(shell) 都是纯本地命令(无网络),放 parse-time 可以接受

实测:干净树 → staging-13f0ee8;改动 src/config.php → staging-13f0ee8-dirty;
根目录放 scratch-note.txt → 仍是 staging-13f0ee8。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
02:02 feat(deploy): release-precheck.sh — 发布前的三条守卫 (#250) » Recent Commits to phpman:master
feat(deploy): release-precheck.sh — 发布前的三条守卫 (#250)

把版本号的权威来源改成远端之后,版本号从**远端**算、代码却从**本地**工作树传,
两者一旦不同源,新算出的版本号就会被贴到别的代码上。三条守卫各堵一种:

1. origin/master 是 HEAD 的祖先 —— 本地落后时,会算出新版本号、部署旧代码、
   再把旧 HEAD 打成新版本号推走。先 git fetch 再判断,否则本地跟踪引用本身可能
   就是陈旧的,检查形同虚设;fetch 失败(断网/认证)必须中止,不能当成「没落后」
2. HEAD 未被 origin 上的 tag 指着 —— 同一个提交不发布两次。只查远端不查本地,
   这样「部署成功但 push 失败」能自愈(_release-tag 在 push 失败时会删掉本地 tag)
3. 部署路径上工作树干净 —— 部署传的是工作树里的 src/ 与 phpMan.php,脏树会部署出
   tag 指向的提交之外的字节

实现上踩到两个坑,都在注释里留了记录:

- 剥 "refs/tags/" 前缀不能用 `sed 's#.*refs/tags/##'`:`.*` 贪婪,会把行首的 sha
  一起吃光,awk 就再也匹配不上 HEAD(annotated 与 lightweight tag 都漏检)
- 脏检查必须加 `-uall`:默认 git 把整个未跟踪目录折叠成一行 "?? src/",报错只说得出
  目录名、说不出是哪个文件,而这条报错的全部价值就是让人知道该处理什么

18 条断言,每条都造真实临时仓库(含 detached HEAD 与 origin 不可达的负例)。
全套 607 passed / 0 failed。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
01:58 feat(deploy): next-version.sh — 版本号以远端 tag 为权威来源 (#250) » Recent Commits to phpman:master
feat(deploy): next-version.sh — 版本号以远端 tag 为权威来源 (#250)

`git describe` 读的是运行 make 那台机器碰巧有的 tag,同一个提交在两台机器上会被
描述成不同的东西 —— 生产就是这样把已经越过 v4.12.2 的代码标成了
v4.11.2-59-gf06411a。新脚本从 stdin 收 tag 列表(调用方喂
`git ls-remote --tags origin`,测试喂 fixture),自身不碰网络。

- 只认三段纯数字:仓库里真实存在的两段 tag(v2.0…v4.5)、带后缀的
  (v3.6.1-readme)、非版本的(phpMan_2/time)与 annotated tag 的 `^{}` 行都滤掉
- 显式 VERSION 归一化成三段:`VERSION=4.13` → 4.13.0。不归一化的话下一次默认发布
  扫不到 v4.13,会从 v4.12.2 算出 v4.12.3 —— 正是要消灭的静默倒退,而且守卫拦不住
- 显式 VERSION 必须严格大于远端最新,等于也拒(否则同一版本号会被发两次)
- 远端一个三段 tag 都没有时给 0.0.1 并在 stderr 说明(首次发布就是这个状态)

顺带新增 `make test-unit`:`make test` 只跑语法检查,从不调用 test/run_all.php,
所以这些守卫的回归此前可以在完全没被测过的情况下发出去。它是 release 的前置
(下一条提交接上)。

36 条新断言;全套 589 passed / 0 failed。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
01:39 docs: 发布版本号完整性设计(#250) » Recent Commits to phpman:master
docs: 发布版本号完整性设计(#250)

生产页脚显示 v4.11.2-59-gf06411a,比发过的 v4.12.2 还低,而代码其实是更新的提交。
根因不是某次手滑:GIT_TAG / GIT_VERSION 由 `git describe` 在**运行 make 的那台机器**上
现算,读的是那台机器的 tag 集合。同一个提交在 tag 齐全的机器上是 v4.12.2-1-gf06411a,
在 tag 停在 v4.11.2 的机器上是 v4.11.2-59-gf06411a —— 两个串都长得完全合法,所以发布
一路绿灯。`make tag` 有同一个毛病而且方向更糟:它从本地 tag 推下一个版本号,会算出
倒退的版本号并 push 到 origin,永久留下一个错 tag。

设计要点(详见文档):
- 版本号的权威来源改为 **origin 的 tag**,`make tag` / `tag-minor` 删除并入 `make release`
- 默认补丁 +1;`VERSION=4.13` / `VERSION=5.0` 显式覆盖(归一化成三段 tag)
- 顺序:先部署,全部步骤成功后再打 tag + push master
- 四条守卫一律拦住报错:origin/master 须是 HEAD 的祖先、HEAD 未被 tag 过、
  新版本号 > 远端最新、部署路径上工作树干净
- staging 显示 staging-<sha>(脏树时 -dirty),不再假装自己是发布版本

文档同时记下了三个实现约束:GIT_TAG/RELEASE_ID 是 parse-time :=(不能在那里加
网络调用)、recipe 每行独立 shell(版本号要算一次多处用)、PHPMAN_VERSION 全仓库
只当不透明字符串用(所以 staging-<sha> 安全)。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>

Tue 13 October, 2026

09:48 feat(logs): 机器格式渲染埋点,区分「客户端放弃」与「进程被杀」 (#244) » Recent Commits to phpman:master
feat(logs): 机器格式渲染埋点,区分「客户端放弃」与「进程被杀」 (#244)

/markdown、/json、/mcp 的渲染耗时达到 PHPMAN_MF_LOG_MS(默认 500ms)时,
向 phpman_error.log 写一行:

  mf mode=man name=zshall sec=1 fmt=markdown elapsed=4164ms peak=94.2MB bytes=1666546

为什么需要它:机器格式上的 5xx 现在什么都不留,于是「客户端等不及断开」和
「mod_fcgid 杀掉了慢进程」在日志里完全同形——而两者的修法相反(前者要缩短
渲染或改成分块/预渲染,后者要调 FastCGI 超时或降内存)。这一行让二者**靠有无
区分**:5xx 且能找到对应行 = 渲染跑完了、投递失败;5xx 且没有对应行 = 进程在
到达终点前就没了。

elapsed 从 REQUEST_TIME_FLOAT 起算,因此是 PHP 自己的墙钟时间,不含 mod_fcgid
排队等待;拿它和 access log 里客户端观测到的耗时对比,正是暴露那段排队的办法。
peak 取 memory_get_peak_usage(true)——web SAPI 的 memory_limit 是 128M,而
info py 这类页面实测峰值 60MB,被杀与不杀就在这条线附近。

阈值就是全部的流量控制,且落在两个分布天然分开的位置:2026-10-13 在 staging
实测,缓存命中 30-90ms,同一页冷渲染 400-4200ms(zshall 4.2s、pydoc/numpy
2.5s、perldiag 1.7s)。所以 500 让日志量与「被首次抓取的页数」成正比,而不是
与每天 28k 次机器格式请求成正比。PHPMAN_MF_LOG_MS=0 关闭。

埋点放在格式分支之前、body 输出之前,一次调用覆盖三种格式,同时保证「5xx 且
无此行」确实意味着进程死于渲染中途,而不是渲染完成后在投递阶段失败。

页面名直接从 URL 来,所以 phpManLogSafeField() 先折叠控制字符再截断:否则一个
名字里带换行的请求就能往这份日志里追加一行伪造的 `PHP Fatal error`——而被伪造
的正是读取这份遥测的那个文件。

刻意**不**动 RENDERER_VERSION:渲染输出一个字节都没变,bump 只会白白作废整个
页面缓存。

新增 test/unit/test_mf_render_log.php(24 条断言):阈值边界(400ms 静默 /
600ms 记录 / 恰好 500ms 记录 / 0 与负数关闭)、各字段齐全、elapsed 从请求起点
起算、换行/CR/TAB/ESC 注入无效、超长名字截断、以及包装函数写到被重定向的
error_log 的行为。

Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
02:47 fix(deploy): 切完 current 重启 PHP worker,原子发布不再被 opcache 吞掉 » Recent Commits to phpman:master
fix(deploy): 切完 current 重启 PHP worker,原子发布不再被 opcache 吞掉

opcache.revalidate_path=0 下,编译产物按「请求路径」而不是解析后的
真实路径缓存,所以 retarget current 对已运行的 worker 完全不可见,
而且不会过期:编译来源仍存在、mtime 未变,validate_timestamps 也就
一直认为旧字节码是新的。

2026-10-13 生产就是这样:current 已经指向 releases/v4.12.1-…,站点
仍连续 20 多分钟返回 v4.11.2-50-g2870e58,池子里既有切换前也有切换
后启动的 worker;一次 opcache_reset() 立刻切过去。atomic-release.sh
第 4 步现在做等价的事(worker 退出、mod_fcgid 按需拉起),make
rollback 因为复用同一脚本也一并覆盖;PHPMAN_PHP_RESTART=0 可跳过。

同时修正脚本里那段错误解释:它把原因归到 realpath_cache_ttl 并建议
等 300 秒——revalidate_path=0 时 opcache 根本不会再解析路径,TTL
不会参与。
02:18 docs: 其余 9 个文件里的 v5.0 提法改为 v4.12.0 » Recent Commits to phpman:master
02:14 docs(plan): 05-PLAN 里的 v5.0.0/v5.0 改为 4.12.0 » Recent Commits to phpman:master
02:09 docs(CHANGELOG): 定版 4.12.0 » Recent Commits to phpman:master
02:00 fix(make): 远端 $HOME 的校验不再打死所有目标 » Recent Commits to phpman:master
01:53 feat(logcheck): 轮转归档不再让报告看起来「一直很干净」 » Recent Commits to phpman:master
01:33 feat(logs): make log-rotate —— 轮转 phpMan 错误日志 » Recent Commits to phpman:master

Mon 12 October, 2026

03:12 docs(deploy): atomic-release.sh 记录切换后的 realpath 缓存窗口 (#243) » Recent Commits to phpman:master

Sat 10 October, 2026

21:43 docs(search): 更正 FTS5 JOIN 注释与 CHANGELOG 里的放大机制 » Recent Commits to phpman:master
20:49 docs(plan): 记下第二轮分支清理(origin 上 4 个,其中 2 条先移植再删) » Recent Commits to phpman:master
20:45 fix(search): 去掉 FTS5 三处无用 LEFT JOIN,重复行不再吃满 LIMIT 预算 » Recent Commits to phpman:master