diff --git a/shadow-docs/changes/20260918-fix-cross-platform-ci/brief.md b/shadow-docs/changes/20260918-fix-cross-platform-ci/brief.md new file mode 100644 index 0000000..046de02 --- /dev/null +++ b/shadow-docs/changes/20260918-fix-cross-platform-ci/brief.md @@ -0,0 +1,85 @@ +--- +{ + "schema": "shadow-dev/v1", + "name": "20260918-fix-cross-platform-ci", + "type": "fix", + "scope": null, + "status": "branched", + "baseBranch": "main", + "branch": "fix/20260918-fix-cross-platform-ci", + "files": [], + "github": { + "repository": "stack-wuh/shadow-dev-cli", + "issue": 25, + "issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/25", + "pullRequest": null, + "pullRequestUrl": null + }, + "review": { + "conclusion": "pending", + "verifiedCommit": null, + "verifiedAt": null + }, + "workflow": { + "operation": null, + "checkpoint": "issue:25", + "planHash": "4c33e17d13906ff50b9ef77b99c83e4f21491bc1d7ba48c1088a641850138e22", + "updatedAt": null, + "lastError": null, + "issuePlan": { + "title": "[fix] 跨平台 CI 绿灯:钉死测试的 locale 与 tar 可执行依赖", + "titleRaw": null, + "supplement": "", + "body": "## 动机\nmain 的 CI 矩阵在 macos/windows 仍有两簇恒红(ubuntu 已由 #23 转绿):①`cli.test.mjs` 用例 20(TTY)断言 stderr 含中文「命令一览」,但语言链 `locale 探测 > zh` 在 en-US runner 上渲染英文——PR #10 引入的 locale 依赖,本地中文机掩盖;已用 `SHADOW_DEV_LANG=en` 本地复现同款断言失败。②`install.test.mjs` 全部用例在 windows runner 死于 `makeTarball`:node 直接 spawn 的 `tar` 绑到 System32 bsdtar,读不了 toUnix 产出的 MSYS `/tmp` 路径(38b1364 起即存在)。两簇都是**测试侧**缺陷,运行时行为正确。红 CI 继续让真实回归裸奔。\n\n## 引用规范\n- shadow-docs/knowledge/cli-output-contract.md\n - 当前结论: 语言链 `--lang` > `SHADOW_DEV_LANG` > locale 探测 > 默认 zh,只影响 stderr 文案\n - 适用 scope: 测试必须显式固定语言(--lang / SHADOW_DEV_LANG),不得依赖 runner locale\n- shadow-docs/knowledge/install-distribution.md\n - 当前结论: shim/产物断言是平台分支的;跨平台命令要在设计上可移植\n - 适用 scope: test/install.test.mjs\n- norms/tdd-verification.md\n - 当前结论: 先复现红(本地 SHADOW_DEV_LANG=en 已复现 ①;②的权威红源是 windows CI 矩阵)再修\n\n## 决策\n- **选型:** ①用例 20 的 `runTty(['--help'])` 显式传 `SHADOW_DEV_LANG=zh`(断言中文通道);②`makeTarball` 的 tar 改在 bash 内执行(`bash -c \"tar ...\"`),与安装器本体同源(install-cli.sh 里 tar 天然由 bash 解析,Windows 下命中 git 的 GNU tar),测试与实现走同一条解析路径。\n- **对比方案:** ①改成\"按 locale 断言中英文任一\"被否——非确定性断言是反模式;②toUnix 产 Windows 原生路径给 bsdtar 被否——`-C` 参数与包内 POSIX 路径又需要 MSYS 形态,两头不讨好;根治是统一交给 bash 解析。\n- **理由:** 测试向契约要确定性:语言显式声明、工具链与生产路径一致;均不动运行时代码。\n\n## 任务\n### Phase 1\n\n- [ ] task-1 — `test/cli.test.mjs` — 用例 20 的 plain(无 --json 的 TTY help)runTty 显式 `SHADOW_DEV_LANG=zh`;本地 `SHADOW_DEV_LANG=en` 强制下先跑红(已复现)后转绿\n- [ ] task-2 — `test/install.test.mjs` — `makeTarball` 的 `spawnSync('tar',...)` 改为 `spawnSync('bash', ['-c', ...])`;本地 8 用例保持绿\n\n### Phase 2\n\n- [ ] task-3 — PR 上 CI 9 格矩阵(ubuntu/macos/windows × 20/22/24)全绿作为权威验证;失败则在同一调查上下文内修到绿\n\n完整 brief:shadow-docs/changes/20260918-fix-cross-platform-ci/brief.md\n\n\n", + "labels": [ + "fix" + ] + } + } +} +--- + +# 跨平台 CI 绿灯:钉死测试的 locale 与 tar 可执行依赖 + +## 动机 + +main 的 CI 矩阵在 macos/windows 仍有两簇恒红(ubuntu 已由 #23 转绿):①`cli.test.mjs` 用例 20(TTY)断言 stderr 含中文「命令一览」,但语言链 `locale 探测 > zh` 在 en-US runner 上渲染英文——PR #10 引入的 locale 依赖,本地中文机掩盖;已用 `SHADOW_DEV_LANG=en` 本地复现同款断言失败。②`install.test.mjs` 全部用例在 windows runner 死于 `makeTarball`:node 直接 spawn 的 `tar` 绑到 System32 bsdtar,读不了 toUnix 产出的 MSYS `/tmp` 路径(38b1364 起即存在)。两簇都是**测试侧**缺陷,运行时行为正确。红 CI 继续让真实回归裸奔。 + +## 引用规范 + +- shadow-docs/knowledge/cli-output-contract.md + - 当前结论: 语言链 `--lang` > `SHADOW_DEV_LANG` > locale 探测 > 默认 zh,只影响 stderr 文案 + - 适用 scope: 测试必须显式固定语言(--lang / SHADOW_DEV_LANG),不得依赖 runner locale +- shadow-docs/knowledge/install-distribution.md + - 当前结论: shim/产物断言是平台分支的;跨平台命令要在设计上可移植 + - 适用 scope: test/install.test.mjs +- norms/tdd-verification.md + - 当前结论: 先复现红(本地 SHADOW_DEV_LANG=en 已复现 ①;②的权威红源是 windows CI 矩阵)再修 + +## 决策 + +- **选型:** ①用例 20 的 `runTty(['--help'])` 显式传 `SHADOW_DEV_LANG=zh`(断言中文通道);②`makeTarball` 的 tar 改在 bash 内执行(`bash -c "tar ..."`),与安装器本体同源(install-cli.sh 里 tar 天然由 bash 解析,Windows 下命中 git 的 GNU tar),测试与实现走同一条解析路径。 +- **对比方案:** ①改成"按 locale 断言中英文任一"被否——非确定性断言是反模式;②toUnix 产 Windows 原生路径给 bsdtar 被否——`-C` 参数与包内 POSIX 路径又需要 MSYS 形态,两头不讨好;根治是统一交给 bash 解析。 +- **理由:** 测试向契约要确定性:语言显式声明、工具链与生产路径一致;均不动运行时代码。 + +## 任务 + +### Phase 1 + +- [x] task-1 — `test/cli.test.mjs` — 用例 20 的 plain(无 --json 的 TTY help)runTty 显式 `SHADOW_DEV_LANG=zh`;本地 `SHADOW_DEV_LANG=en` 强制下先跑红(已复现)后转绿 +- [x] task-2 — `test/install.test.mjs` — `makeTarball` 的 `spawnSync('tar',...)` 改为 `spawnSync('bash', ['-c', ...])`;本地 8 用例保持绿 + +### Phase 2 + +- [ ] task-3 — PR 上 CI 9 格矩阵(ubuntu/macos/windows × 20/22/24)全绿作为权威验证;失败则在同一调查上下文内修到绿 + +## 结果 + +- 实际耗时: — +- 验证: — + +## 知识评估 + +- **预期影响:** 更新 +- **候选卡片:** shadow-docs/knowledge/cli-output-contract.md +- **理由:** 语言链约束需追加"测试必须显式固定语言,禁止依赖 runner locale"的执行约束;安装卡片验证方式补"tar 经 bash 解析"的可移植性约束 diff --git a/shadow-docs/knowledge/cli-output-contract.md b/shadow-docs/knowledge/cli-output-contract.md index 280cd91..1a73d58 100644 --- a/shadow-docs/knowledge/cli-output-contract.md +++ b/shadow-docs/knowledge/cli-output-contract.md @@ -26,7 +26,7 @@ verified: 2026-09-18 - 缺必填参数报错时,stderr 必须从 `COMMANDS` 逐行列出缺失参数的目录描述(`flag * desc`,经 `human.argLine` 与 help 详情共用同一渲染),示例行占位符与目录 example 一致(变更名为 ``);`HINTS` 只保留 code 级短句兜底,不得重复目录中的参数说明。 - `shadow-dev change list` 是变更名发现入口:stdout 契约 `data.changes:[{name,type,status,branch,archived}]`(按 name 排序)。默认只列 `shadow-docs/changes/` 活动目录(条目 `archived:false`);`--all` 合并归档条目(`archived:true`),`--archived` 只列归档,两参同传按超集 `--all`;解析失败目录静默跳过(与 indexer 同规则,双目录扫描复用 `brief(r, name, archived)` 第三参)。新增无值布尔 flag 必须在 `lib/args.mjs` 白名单登记,否则会被当作取值 flag 吞掉后随参数。 - 抑制 stdout 的分支必须仍然设置退出码;plan 的人用收场行必须透出 `planHash`(PTY 环境下的 agent 兜底)。`--json` 是跨环境逃生门,不得复用为其他语义。 -- 错误 code 与 `data.nextStep` 模板永不本地化;语言链固定为 `--lang` > `SHADOW_DEV_LANG` > locale 探测 > 默认 zh,且只影响 stderr 文案。 +- 错误 code 与 `data.nextStep` 模板永不本地化;语言链固定为 `--lang` > `SHADOW_DEV_LANG` > locale 探测 > 默认 zh,且只影响 stderr 文案。**测试对 stderr 文案的断言必须显式钉 `--lang` 或 `SHADOW_DEV_LANG`**——CI runner 的 locale(macos/windows 为 en)会经探测改变渲染语言,隐式依赖机器环境的断言在非中文 runner 上恒假。 - `nextStep` 为 additive 字段,写入发生在 planHash 持久化与计算之后,不得参与 hash 输入。 - plan 域可导出 `present(x)` 声明 stdout data 的**最小投影**(`cli.mjs planDomain` 应用,缺省恒等):重字段(全文正文、整段 brief/repo 回显)不进机器契约,语义输入仍在完整 planData 内参与 planHash。首个用户是 `issue plan`(摘要三件套 bodyBytes/bodySha256/sections,见 [issue 正文双通道结构契约](issue-body-contract.md));新域裁剪须有 token 依据并测试钉住。 - help 的 `data.help` 恒为字符串(概览默认唯一字段,最小面 <1KB);结构化目录 `data.commands` 经 `--full` opt-in;`help <命令>` 组详情恒定返回该组 `commands`。 diff --git a/shadow-docs/knowledge/install-distribution.md b/shadow-docs/knowledge/install-distribution.md index ccdec82..dd7f4a2 100644 --- a/shadow-docs/knowledge/install-distribution.md +++ b/shadow-docs/knowledge/install-distribution.md @@ -9,6 +9,7 @@ source: - changes/20260917-fix-installer-url-taint/brief.md - changes/20260917-feature-install-link-mode/brief.md - changes/20260918-fix-installer-ci-cmd-assert/brief.md + - changes/20260918-fix-cross-platform-ci/brief.md verified: 2026-09-18 --- @@ -26,6 +27,7 @@ verified: 2026-09-18 - `LINK` 与 `CURRENT` 语义互斥不复用:`install` 永不写/删 `LINK`,`unlink` 只删 `LINK`;status 的 `linked` 为 additive 字段。JSON 输出中的 Windows 路径必须转义反斜杠(`sed 's/\\/\\\\/g'`)。 - Windows 上 `LINK` 落盘存 `cygpath -w` 的 Windows 形态(`.cmd` shim 用 `set /p` 直读,POSIX 形态 node 打不开);shim `.cmd` 用 goto 两段分支而非括号块(括号块内 `%errorlevel%` 提前展开会吞掉真实退出码)。 - 信任边界分轨表述:release 轨 HTTPS + GitHub 仓库(无独立校验和),link 轨目标是用户显式给出的本机目录——引入 link 不扩大下载面,也不得把 link 目标喂给任何网络请求。 +- 测试需要打包 tar 时**必须经 `bash -c 'tar ...'` 执行**(与安装器本体同一解析路径):node 直接 `spawnSync('tar')` 在 Windows runner 绑到 System32 bsdtar,读不了 `toUnix()` 产出的 MSYS `/tmp` 路径,导致全部安装器用例在 windows CI 恒红。 ## 适用边界 diff --git a/test/cli.test.mjs b/test/cli.test.mjs index b20d780..0067fdf 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -307,7 +307,8 @@ test('jsonEnabled routes the JSON surface by environment and explicit flags', as }) test('TTY suppresses stdout JSON; --json and env restore it; planHash surfaces on stderr', () => { - const plain = runTty(['--help']) + // 中文通道断言必须显式钉语言:CI runner locale(mac/windows 为 en)经语言链探测会渲染英文,禁止隐式依赖机器环境 + const plain = runTty(['--help'], process.cwd(), { SHADOW_DEV_LANG: 'zh' }) assert.equal(plain.status, 0, plain.stderr) assert.equal(plain.stdout.trim(), '', 'interactive help must not print JSON') assert.match(plain.stderr, /shadow-dev 命令一览/) diff --git a/test/install.test.mjs b/test/install.test.mjs index cde61d7..967c7a0 100644 --- a/test/install.test.mjs +++ b/test/install.test.mjs @@ -29,7 +29,9 @@ function makeTarball(version) { writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'shadow-dev-cli', version })) writeFileSync(join(app, 'README.md'), '# fake artifact\n') const tgz = join(dir, `shadow-dev-cli-v${version}.tar.gz`) - const r = spawnSync('tar', ['-czf', toUnix(tgz), '-C', toUnix(dir), 'shadow-dev-cli'], { encoding: 'utf8' }) + // tar 必须在 bash 内执行:Windows runner 上 node 直 spawn 绑到 System32 bsdtar,读不了 MSYS 路径; + // bash -c 让测试与 install-cli.sh 本体走同一解析路径(Windows 命中 git 的 GNU tar) + const r = spawnSync('bash', ['-c', 'tar -czf "$0" -C "$1" shadow-dev-cli', toUnix(tgz), toUnix(dir)], { encoding: 'utf8' }) assert.equal(r.status, 0, r.stderr) return tgz } @@ -100,7 +102,7 @@ test('artifact without cli.mjs fails selfcheck and leaves pointer untouched', sk writeFileSync(join(dir, 'shadow-dev-cli', 'package.json'), '{"name":"shadow-dev-cli","version":"0.0.1"}') writeFileSync(join(dir, 'shadow-dev-cli', 'README.md'), '# nope\n') const tgz = join(dir, 'bad.tar.gz') - spawnSync('tar', ['-czf', toUnix(tgz), '-C', toUnix(dir), 'shadow-dev-cli']) + spawnSync('bash', ['-c', 'tar -czf "$0" -C "$1" shadow-dev-cli', toUnix(tgz), toUnix(dir)]) const r = run(['install', '--from', toUnix(tgz), '--prefix', toUnix(prefix), '--bin', toUnix(bin)]) assert.equal(r.status, 3) assert.ok(!existsSync(join(prefix, 'CURRENT')))