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>
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>
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>
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>
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>
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>
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>
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>
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>
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 不会参与。