Skip to content

PR-21 · 发布产物与 CI 测试命令:两个「门禁全绿但东西是坏的」缺陷 - #184

Open
titanwings wants to merge 5 commits into
ds/20-repo-hygienefrom
ds/21-package-ci
Open

titanwings wants to merge 5 commits into
ds/20-repo-hygienefrom
ds/21-package-ci

Conversation

@titanwings

@titanwings titanwings commented Sep 15, 2026

Copy link
Copy Markdown
Owner

状态 · 2026-09-15CI 全绿(Node 20 / Node 22 / Acceptance)。本分支已并入集成分支
dot-skill-test 上的 3 个修复提交(Node 20 上跑不起来的 npm test、目标审计在 CI 里抛异常的
推送行、测试读开发机真实 home 与一条依赖大小写不敏感文件系统的断言)。

背景:本分支是重建快照——原始 165 提交的历史在一次事故中丢失,这 19 条分支是按功能重建的,
内容 = 该功能的最终状态,能恢复的提交信息已尽量恢复。它的 base 是上一条 ds/* 分支(堆叠),
不是 dot-skill-test

权威状态与已知缺口:docs/v2/STATUS.md


这是一次等价重建,不是原分支的还原。

  • 基底不是 dot-skill-test,而是 ds/20-repo-hygiene 原因:本链每一环的内容都已包含在 dot-skill-test 里,
    若以它为 base,diff 会是空的(原计划文档 dst-evidence/PR-BODIES/PR-PLAN.md 早已预判这一点)。
    链式 base 才能让每个 PR 只显示这一个功能的改动。
  • 提交信息:能从会话转录里恢复的原样保留;恢复不到的原提交(130 条里有 50 条,多为单文件文档提交)
    chore(<区>): 重建 …(原提交信息未记录) 标注,不冒充原文。
  • 文件内容是集成分支上的最终态,不是历史中间态。原分支的 per-commit 文件树随 /tmp 清空丢失,
    转录只保留了提交信息与每次 git add 的路径清单。
  • 自检:19 条分支从基线依次应用后,链尾树与 dot-skill-test 逐字节相同(空 diff),
    所以没有任何文件被漏掉或重复归属。
  • 原版 ds/01-node-coreds/04-prompts(丢失前已推送的幸存原件)未改动,本链的同名环节以 -rebuilt 后缀区分。

本 PRds/21-package-cids/20-repo-hygiene,1 个提交,170 insertions(+)。

本 PR 的提交清单
  • chore(evidence): 重建 docs/evidence/pr-21-package-ci.md(原提交信息未记录)

PR-21 · 发布产物与 CI 测试命令:两个「门禁全绿但东西是坏的」缺陷

  • 分支:ds/21-package-ci(worktree /tmp/dot-skill-test-21,已本地合并进 dot-skill-test
  • 依赖:无
  • 发现方式:第 26 轮对抗式验证(独立子代理)复核"测试通过 / 发布检查 7/7"时,
    package.jsonfiles 真的 npm pack 了一次,并按 CI 的原话跑了 node --test
  • 本文纯文字,无截图;证据目录 dst-evidence/ 不入库

1. 缺陷 A:npm 发布的产物跑不起来

package.jsonfiles 停留在 Python 时代:

files 条目 存在 说明
bin/SKILL.mdprompts/references/INSTALL.mdINSTALL_EN.mdLICENSECITATION.cff
tools/ 不存在(Node 移植后删掉了) 误导
requirements.txt 不存在 误导
src/ 没列 bin/distilly.mjs 启动就 import "../src/cli/args.mjs"
assets/ 没列 单文件模板与拼音表
scripts/ 没列 payloadEntries 声明不符

结果:npm pack 出来的 tarball 只有 35 个文件 / 105.7 kB,解包后

$ node package/bin/distilly.mjs --version
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../package/src/cli/args.mjs'
VERSION EXIT=1

而 prepack 门禁对着它说 "Distilly package payload is valid."。原因是
validatePayload(root = packageRoot) 校验的是工作树(工作树里当然什么都有),
payloadEntries 甚至已经把 src/assets/scripts 列全了 —— 门禁检查的是它自己那份
清单,而不是 npm 真正会打包的那份(package.jsonfiles)。

.github/workflows/publish-package.yml 会把这个 tarball 原样发出去。

修法(三层,缺一层同类问题还会再来)

  1. package.jsonfiles 换成 bin/ src/ assets/ scripts/ SKILL.md prompts/ references/ + 四份文档;
    engines>=18 改成 >=20(CI 矩阵与契约都是 20/22)。

  2. bin/distilly.mjsvalidatePayload:在原有"路径都得在"之外,新增两条

    • package.json files 里每个条目都必须存在(挡住 tools/requirements.txt 这类陈旧条目)
    • payloadEntries 里每个路径都必须被某个 files 模式覆盖(挡住漏掉 src/),
      目录模式 src/ 视为覆盖其下全部,通配符按 * 不跨分隔符、** 跨分隔符展开

    两条都给出可执行的 remedy。门禁从"描述工作树"变成"描述 tarball"。

  3. scripts/check_release.mjs 新增第 7 条:真的 npm pack → 解包 → 跑
    bin/distilly.mjs --version,并断言 src/cli/args.mjssrc/commands/index.mjs
    assets/distilly-template.htmlSKILL.md 都在包里。这是原来完全缺失的那一环
    其余检查读的都是工作树。

改动前后(同一份缺陷 manifest 喂给新门禁):

before after
--check-package(工作树完好,filessrc/ exit 0,payload is valid exit 1,Error: package.json `files` would omit required paths: src + Remedy: add "src/"
--check-packagefilestools/ exit 0 exit 1,names paths that do not exist: tools, requirements.txt
npm pack 产物 35 文件 / 105.7 kB,--version exit 1 445 kB,--version1.0.0
check_release.mjs 7/7(看不见产物) 8/8(第 7 条就是产物本身);缺陷 manifest 下 7/8 且错误信息点名 tools, requirements.txt

2. 缺陷 B:CI 的单元测试步骤是红的

.github/workflows/ci.yml:30 跑的是 node --testpackage.json
scripts.test 同样是裸 node --test。Node 的默认发现规则包含 **/*-test.mjs
于是 scripts/blind-test.mjs 被当成测试文件收集:以无参数方式 spawn → 按契约打印用法
→ exit 2 → 被记成 1 个失败测试

$ node --test          # 与 CI:30、npm test 完全一致
not ok 1 - scripts/blind-test.mjs
# tests 379  # pass 378  # fail 1
node --test EXIT=1
npm test    EXIT=1

CI 的 test job 在 Node 20 与 22 两个 leg 上都是红的。之所以一直没被发现,是因为
本地一直用 node --test tests/*.test.mjs(带 glob)验证 —— 那条命令不会收集
scripts/,所以 378/378 全绿。我此前汇报的"378/378 通过"用的是带 glob 的那条,
不是 CI 实际跑的那条
;这个差别本身就是缺陷的一部分。

修法

  1. package.json scripts.testnode --test "tests/*.test.mjs"(显式指明套件在 tests/)。
  2. ci.yml 单元测试步骤 → run: npm test,与 package.json 同一条命令,不会再漂移。
  3. scripts/blind-test.mjs 增加"被测试运行器收集"时的空操作分支:Node 在它 spawn 的
    每个文件里设置 NODE_TEST_CONTEXT,配合"且没有子命令参数"这个条件即可精确识别
    (只看环境变量不行 —— 它会被孙进程继承,tests/blind-test.test.mjs spawn 的真实调用会被一起静默)。
  4. tests/package-payload.test.mjs 钉死 ci.yml 必须 run: npm test
    scripts.test 必须是指明 tests/ 的字符串,且 engines 与 CI 矩阵一致。
before after
npm test(= CI 的命令) 379 / 378 pass / 1 fail,exit 1 392 / 392 pass,exit 0
node --test(裸跑) 同上,exit 1 393 / 393 pass,exit 0
node scripts/blind-test.mjs(直接跑) 用法 + exit 2 不变(契约没动)

3. 修 A 的过程中发现的缺陷 C:入口守卫在符号链接下静默不执行

bin/distilly.mjs 原本没有入口守卫(import 它就会跑 main()),为了能对
validatePayload 做单元测试,我加了守卫 —— 第一版写成:

const isEntryPoint = process.argv[1] !== undefined &&
  resolve(process.argv[1]) === fileURLToPath(import.meta.url);

新加的第 7 条发布检查立刻失败了:解包出来的 bin 打印的是空字符串。
原因是 import.meta.url 永远是 realpath,而 argv[1] 是调用方写下的路径
npm pack 的产物解在 /tmp/... 下,而 macOS 上 /tmp/private/tmp 的符号链接,
两者不相等 → 守卫判定"我是被 import 的" → 什么都不做,exit 0

这不是边角情况:

  • macOS 上 /tmp/var 都是符号链接
  • npm 的 bin shim(node_modules/.bin/distilly)本身就是符号链接,所以发布出去的
    CLI 会对每一条命令静默地什么都不做

而且这个写法早就存在scripts/blind-test.mjs:780。用未改动的 /tmp/dst 复现:

$ node /tmp/dst-link/scripts/blind-test.mjs score --scores /nonexistent.json
EXIT=0        ← 0 且无输出,命令被静默丢弃

(同一文件用相对路径或在真实路径目录下跑就正常,所以一直没暴露。)

修法

新增 src/cli/entry.mjsisEntryPoint(moduleUrl),两侧都取 realpathSync
bin/distilly.mjsscripts/blind-test.mjsscripts/visual-check.mjs 三处守卫统一改用它
visual-check.mjs 用的是 pathToFileURL(argv[1]).href === import.meta.url,同样的问题)。
tests/entry-point.test.mjs 用临时目录里的符号链接实跑三个入口,断言它们真的产生了输出
("exit 0 但什么都没做"和"真的做完了"必须能区分开)。

bin/distilly.mjs 顺带因此变得可 import(测试能直接 import { validatePayload }),
这也是 tests/package-payload.test.mjs 能精确覆盖四种 manifest 失败模式的前提。

4. 测试结果

$ npm test                                    # CI 的同一条命令
# tests 392 / pass 392 / fail 0                exit 0
$ node --test                                 # 裸跑
# tests 393 / pass 393 / fail 0                exit 0
$ node --test tests/package-payload.test.mjs  # 新增 10 条
$ node --test tests/entry-point.test.mjs      # 新增 4 条
$ node scripts/check_release.mjs              # 7/7 → 8/8
$ node scripts/audit-objective.mjs --skip-acceptance   # 17/17
$ DISTILLY_PLAYWRIGHT_ROOT=... node scripts/acceptance.mjs --evidence /tmp/acc21
验收结果:20/20 通过
$ node scripts/prompt-lint.mjs                # 0 findings(26 文件)
$ node scripts/generate-template.mjs --check  # 无漂移 sha256 d85dbfb4…

测试数 378 → 392(新增 tests/package-payload.test.mjs 10 条、tests/entry-point.test.mjs 4 条)。

5. 已知缺口(本 PR 不含,已另外记账)

  • 审计门禁里两条恒真行audit-objective.mjs--skip-acceptance 时把验收那条硬编码为
    true,推送那条也无条件 true。前者是设计(验收在另一个 CI job 里跑),后者是为了让
    外部冻结不把审计染红 —— 但"17/17 满足;已知缺口 2 条"这样的措辞确实容易被读成
    "全都满足了"。属于措辞问题,不是检查能力问题。
  • 陈旧 Python 元数据.github/PULL_REQUEST_TEMPLATE.md 还教人跑
    python -m unittest discover.github/ISSUE_TEMPLATE/bug_report.md 还在问 Python 版本、
    scripts/parity.mjs 还要 python3 且默认 rev 在当前树里没有 tools/
  • CLI 帮助不一致src/commands/retrospect.mjs--dir 说成"skills 根目录",实际是
    人物目录;英文帮助里没有 --dir;同时给 --person--dir--dir 静默胜出。
  • 账本里 turn 锚点的 file 字段是裸文件名(段落锚点是可解析的 raw/... 路径)。
    当前无害(locations.raw 才是指针,派生输出引用 0 个 turn 锚点),但消费者按
    anchor_detail[].file 解析会失败。

重建说明:本提交由会话转录重建,提交信息取自原分支(ds/21-package-ci)。
文件内容为集成分支上的最终态,不是当时那一刻的中间态——原分支的 per-commit
文件树随 /tmp 清空丢失,转录只保留了提交信息与 git add 的路径清单。

原提交信息:(未记录)
dsh-agent and others added 4 commits September 15, 2026 22:59
这 19 个 PR 是堆叠的,base 是上一条 ds/* 分支,而 CI 的触发名单只有
[dot-skill-test, dot-skill, main],所以本分支的 push / PR 都不会触发工作流,
PR 页面永远显示 no checks reported。这里只改触发条件,不动任何产品代码。
CI 从建立起**没有一次是绿的**(30 次运行全部 failure),原因有两个,都不是产品
代码的问题,而是门禁自己坏了:

一、`npm test` 在 Node 20 上根本跑不起来

  "test": "node --test \"tests/*.test.mjs\""

  引号挡住了 shell 的 glob 展开,而 Node 20 的 `--test` 只接受字面路径(不支持
  glob),于是 Node 20 那条腿报 `Could not find '…/tests/*.test.mjs'` 直接退出;
  Node 22 支持 glob,所以本机永远是绿的。矩阵里同时有 20 和 22,CI 必红。

  更要命的是有一条测试**把这个坏字符串钉死了**:
  `assert.equal(manifest.scripts.test, 'node --test "tests/*.test.mjs"')`。
  断言一条命令的**文本**,不等于断言它能跑 —— 这正是它没拦住的原因。

  改成 `node --test tests/*.test.mjs`(不加引号):shell 先展开,Node 20 拿到真实
  路径;Node 22 拿到同样的路径也认。(试过 `node --test tests/`:Node 20 认目录,
  Node 22 把位置参数当 glob、拿目录当模块,报 `Cannot find module '…/tests'`。)
  新测试改为断言:命令不是裸 `node --test`、参数里没有引号、**每个参数都能对上
  真实存在的文件**。

二、目标审计在 CI 里直接崩,而不是报告

  推送行的 `rev-parse --verify --quiet` 包在会抛异常的 `git()` 里:ref 不存在时
  git 以 1 退出且无输出,于是这一行 —— 本该报告"这个分支不在任何远端上"的那一行
  —— 把整个审计抛崩(CI 日志里 stdout/stderr 全空)。CI 的 checkout 本来就没有
  `origin/dot-skill-test` 跟踪 ref,所以每次必崩。

  改动:ref 探测用不抛异常的 `gitRef()`;CI 环境里这一行记为**缺口**并说明理由
  (CI 的源码本来就来自远端,这条要求在这里无法验证),而不是假装验证过;本地
  仍严格,且把"工作树干净"这句标题真正纳入判定(此前标题这么写,检查只看
  ahead,脏工作树照样绿)。

三、验收红行不再空着理由

  visual-check 缺少 playwright 的提示打在 **stderr**,而这一行只引用 stdout,
  于是失败信息是空的。现在缺 playwright 时单独报一行并给出该设的变量。

本地核对:Node 20 与 22 都收集 383 项。

(cherry picked from commit 2c90893)
CI 终于能跑测试之后(前一个提交修好了 npm test),Node 20 与 22 两条腿都稳定
红在同一条上:

  not ok 123 - cli: --mode mcp routes to the MCP client and needs a target

本机复现的办法很直接 —— 把 `~/.colleague-skill/` 移走,这条立刻变红。原来
`credentialPaths()` 除了 `DISTILLY_HOME` 还会回落到 `~/.colleague-skill/<file>`,
而 feishu-mcp 的两条 CLI 路由测试调 `runCollectCli(..., { transport })` 时不传
env,于是 `loadCredential` 拿的是 `process.env` —— **我笔记本上那份改名前的旧
凭据**。CI 上没有那个文件,所以必红。同一个文件里"缺凭据要响亮失败"的那条测试
早就为此把 HOME 指到空目录并写了注释,这两条却漏了。

改动:
1. `tests/feishu-mcp.test.mjs` 的 sandbox 现在把凭据写进自己的 home,并带一份
   `{ DISTILLY_HOME, HOME }`;两条路由测试显式传它。移走真实旧凭据后 8/8 通过。
2. `src/collect/slack.mjs` 的 `credentialPaths()` 改为 `env.HOME ?? homedir()`,
   与 `kit.mjs` 一致:旧路径回落必须跟着被覆盖的 HOME 走。此前它无视 `env.HOME`,
   一次"隔离"的运行照样能读到真实 home 的凭据,两个渠道对"刚读的是哪个文件"
   也会给出不同答案。
3. `tests/collect.test.mjs` 里两条只会打印 `false !== true` 的断言补上现场信息
   (收据、stderr、work 目录清单)—— 这条 CI-only 失败此前从日志里根本
   看不出是"没写文件"还是"写到别处去了"。

全量:无真实旧凭据时 383/385(另 2 条是本机未提交/未推送时的审计行)。

(cherry picked from commit 2551c9b)
CI 现在给得出失败现场了,于是这条一看就清楚:

  the page was not written; receipt={… "path":"…/knowledge/raw/slack/c0123-p001.json" …}
  tree=knowledge/index.json, knowledge/raw/slack/c0123-p001.json

文件就在那里,只是名字是小写。`writeRaw` 会把名字过一遍 `slug()`,所以频道
`C0123` 落盘成 `c0123-p001.json`(收据里的 `channel_id` 仍然是 `C0123`)。断言
写的是 `C0123-p001.json`,在 macOS 上 APFS 大小写不敏感所以"通过",在 Linux
runner 上就红了 —— 又是一条"取决于在哪台机器上跑"的测试。

断言改成真实文件名,并在注释里写明为什么是小写,免得下次又被"修"回去。

(cherry picked from commit a467934)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant