diff --git a/.agents/docs/2026-07-27-date-version-and-xlings-pin-design.md b/.agents/docs/2026-07-27-date-version-and-xlings-pin-design.md new file mode 100644 index 00000000..b298fc79 --- /dev/null +++ b/.agents/docs/2026-07-27-date-version-and-xlings-pin-design.md @@ -0,0 +1,242 @@ +# 日期版本号 + xlings pin 收敛 — 设计 + +日期:2026-07-27 +状态:设计定稿,待实施 +关联:#289(沙箱 xlings 永不刷新)、0.0.109 批次(#286/#287/#288) + +--- + +## 1. 目标 + +两件事,合并为一个 PR: + +1. **mcpp 版本号改为日期格式** `YYYY.M.D.N`,与 xlings 生态对齐(xlings 已于本日从 `0.4.70` 迁到 `2026.7.27.0/.1/.2`)。 +2. **把所有 xlings pin 升到最新** `2026.7.27.2`,包括 mcpp release 内置的那一份。 + +第 2 件事是例行操作;第 1 件事**会踩到一处真实的比较逻辑断点**,必须先修,否则版本一改,索引底线契约(E0006)就会静默失效。 + +--- + +## 2. 版本号规范 + +### 2.1 格式 + +``` +YYYY.M.D.N +2026.7.27.1 + │ │ │ └── 当日序号(ordinal) + │ │ └───── 日,不补零 + │ └─────── 月,不补零 + └─────────── 年 +``` + +不补零是与 xlings 已发布版本一致的写法(`2026.7.27.2`,不是 `2026.07.27.2`)。补零会让两个生态的版本串对不齐,也会让字符串比较的行为在跨月时更难推理。 + +### 2.2 第 4 段的语义 + +> **`.0` 保留给正式版本 / 稳定版本。日常迭代默认从 `.1` 开始。** + +即:一天之内可以有 `.1`、`.2`、`.3` …… 若干次常规发布;`.0` 这个位置只在该版本被认定为正式 release 或稳定版时才使用。 + +**本次发布取 `2026.7.27.1`** —— 这是一次常规迭代(版本方案迁移 + pin 升级),不是被指定的稳定版里程碑。若要改为 `.0`,只需改动版本常量本身,实现不受影响。 + +### 2.3 与旧版本的序关系 + +旧 `0.0.109` → 新 `2026.7.27.1`。第一段从 `0` 变 `2026`,在任何按段做数值比较的实现里都是单调递增的,不存在回退。这一点在 xlings 侧同样成立(`0.4.70` → `2026.7.27.0`),生态两侧方向一致。 + +--- + +## 3. 核心问题:4 段版本在比较时被静默截断 + +### 3.1 断点 + +`src/version_req.cppm` 的 `Version` 是**严格三段**: + +```cpp +struct Version { + int major = 0, minor = 0, patch = 0; + auto operator<=>(const Version&) const = default; +}; +``` + +`parse_version` 的循环上界是 `idx < 3`: + +```cpp +int* parts[3] = { &v.major, &v.minor, &v.patch }; +int idx = 0; +while (idx < 3 && i <= s.size()) { ... } +``` + +于是 `parse_version("2026.7.27.1")` 得到 `{2026, 7, 27}` —— **第 4 段被丢弃且不报错**。后果: + +``` +2026.7.27.0 == 2026.7.27.1 == 2026.7.27.9 // 全部相等 +``` + +### 3.2 这会坏掉什么 + +**E0006 索引底线契约**(`src/pm/index_contract.cppm:83-86`): + +```cpp +auto need = mcpp::version_req::parse_version(minMcpp); +auto have = mcpp::version_req::parse_version(ownVersion); +if (!need || !have) return std::nullopt; // 畸形契约永不 brick +if (*have >= *need) return std::nullopt; +``` + +索引写 `min_mcpp = "2026.7.27.5"`,用户跑 `2026.7.27.1` —— 两者比较相等,`have >= need` 成立,**底线检查放行**。用户拿着不够新的 mcpp 去读新索引,得到的是描述符读取返回空这类难以归因的次生故障(这正是 0.0.109 批次里已经踩过一次的形态)。 + +这不是理论风险:底线契约存在的唯一理由就是挡住这种情况,而日期版本让它在**同一天内**完全失效。 + +### 3.3 修法:4 段 + 精确回写 + +```cpp +struct Version { + int major = 0, minor = 0, patch = 0, revision = 0; + int components = 0; // 源串实际写了几段,仅用于回写,不参与比较 +}; +``` + +两条约束,缺一不可: + +**(a) 比较只看 4 个数字,不看 `components`。** +不能再用 `= default` 的 `<=>`(它会把 `components` 也纳入比较,导致 `"1.2"` ≠ `"1.2.0"`)。必须显式按 major → minor → patch → revision 比较。 + +**(b) `str()` 必须精确回写源串的段数。** +这条是**载荷性的**,不是美观问题。`src/pm/resolver.cppm:138`: + +```cpp +return parsed[*idx].str(); +``` + +依赖解析返回的版本串是**从 `Version` 重建的**,不是原始字符串 —— 它会流向 lock 文件、wire 地址、安装目标。所以: + +| 源串 | 解析 | `str()` 必须给出 | +|---|---|---| +| `1.15.2` | {1,15,2,0} c=3 | `1.15.2` ← 不能是 `1.15.2.0`,否则所有现存依赖的寻址全变 | +| `2026.7.27.1` | {2026,7,27,1} c=4 | `2026.7.27.1` | +| `2026.8.1.0` | {2026,8,1,0} c=4 | `2026.8.1.0` ← **不能塌成 `2026.8.1`** | + +第三行正是 `.0` 正式版约定的直接后果。若只用「第 4 段非 0 才打印」这种简化规则,`.0` 版本会被回写成三段,与索引里的字面 key 对不上。`components` 字段就是为这一行存在的。 + +### 3.4 连带需要照顾的点 + +`matches()` 里 Caret / Tilde 构造上界时是逐字段改写 `c.v`,新增字段后必须一并清零: + +```cpp +case Op::Caret: upper = c.v; ++upper.major; upper.minor = upper.patch = upper.revision = 0; +case Op::Tilde: upper = c.v; ++upper.minor; upper.patch = upper.revision = 0; +``` + +漏掉 `revision = 0` 会让 `^2026.7.27.3` 的上界变成 `2027.0.0.3`,把 `2027.0.0.0` ~ `2027.0.0.2` 错误排除。 + +对既有三段依赖,新增的第 4 段恒为 0,`^`/`~`/`=` 的语义**逐字节不变**。 + +--- + +## 4. xlings 侧:为什么不需要改 xlings 也能跑通 + +需要先确认一件事 —— xlings 自己的 `semver::parse`(`src/core/semver.cppm`)对 4 段版本是**直接拒绝**的: + +```cpp +// Trailing content after 3 components is invalid +if (pos < numpart.size()) return std::nullopt; +``` + +那 mcpp 发成 `2026.7.27.1` 还装得上吗?**装得上**,因为解析路径根本不走 semver: + +`src/core/xim/catalog.cppm::select_version_` 的顺序是 + +1. **先在版本表里做字面 key 精确匹配** —— `versions.find("2026.7.27.1")` 命中 `mcpp.lua` 里的字面键,直接返回; +2. 没有 hint 时读 `latest = { ref = "..." }`,同样是字面串; +3. 只有 hint 无法字面命中时,才落到 `semver::select_best`。 + +而 `xlings.lua` 里已经躺着 `["2026.7.27.2"]` / `["2026.7.27.0"]` 且 `latest.ref = "2026.7.27.2"` —— 这条路径**在生产里已经被 xlings 自己验证过了**。 + +### 已知的 xlings 侧边界(记录,不在本次范围) + +| 行为 | 状况 | +|---|---| +| `xlings install mcpp@2026.7.27.1` | ✅ 字面 key 精确匹配 | +| `xlings install mcpp`(latest) | ✅ 读 `latest.ref` | +| `sort_desc` 排序 | ⚠️ 4 段解析失败 → 回退**字典序**。日期 > 旧 semver 成立;同日 `<10` 次发布也成立;**第 10 次发布起 `.10` 会排在 `.9` 之下** | +| `xlings install mcpp@2026.7`(前缀) | ❌ 前缀范围匹配对 4 段版本无效 | +| `quick_install.sh` 镜像择优 | ⚠️ `sort -t. -k1,1n -k2,2n -k3,3n` **只排前三段**。仅在**未**显式指定版本时执行 —— 本仓所有 bootstrap 都显式传 `v`,故不触发 | + +后三条对 mcpp 的实际发布节奏无影响(同日 10 次发布不现实、前缀装 mcpp 无人使用、bootstrap 一律显式 pin),但值得作为 upstream issue 记在 xlings 侧,因为**它们同样作用于 xlings 自己的版本**。 + +已核实(不是推断):`v2026.7.27.2` 在 `openxlings/xlings` 与 `d2learn/xlings` 两个 org 上都存在,四平台资产齐全、命名与 `0.4.69` 完全同构,直连 GET 返回 200。release.yml 里那份 aarch64 xlings 是 `if curl` 保护的 —— 资产缺失只会**静默降级**成不打包,所以必须先验证再升。 + +--- + +## 5. 版本号与 pin 的完整落点 + +这是本设计的另一半价值:把散落的落点一次性列全。当前 `src/xlings.cppm` 那句「keep in lock-step with release.yml / cross-build-test.yml / ci-linux-e2e.yml」的注释**已经是不全的** —— 它漏掉了两个 composite action,而那两个正是 0.0.109 批次里让 CI 沙箱烂在 0.4.30 的元凶。 + +### 5.1 mcpp 自身版本(4 处) + +| 位置 | 形态 | +|---|---| +| `mcpp.toml` `[package].version` | release.yml 由它 awk 出 tag | +| `src/toolchain/fingerprint.cppm` `MCPP_VERSION` | `--version` 输出 + BMI 指纹 + E0006 比较 | +| `.xlings.json` `workspace.mcpp` | CI bootstrap 装哪个 mcpp | +| `.github/workflows/ci-fresh-install.yml` `MCPP_PIN` | 全新安装验证 | + +### 5.2 xlings pin(6 个文件) + +| 文件 | 数量 | +|---|---| +| `src/xlings.cppm` `pinned::kXlingsVersion` | 1(`mcpp self env` 打印) | +| `.github/actions/bootstrap-mcpp/action.yml` | default + cache key/lineage | +| `.github/actions/setup-macos-llvm/action.yml` | 同上 | +| `.github/workflows/bootstrap-macos.yml` | `XLINGS_VERSION` | +| `.github/workflows/ci-linux-e2e.yml` | `quick_install.sh -s v` | +| `.github/workflows/cross-build-test.yml` | `XLINGS_VERSION` ×2 | +| `.github/workflows/release.yml` | `XLINGS_VERSION`(**这一份决定 release 内置的 xlings**) | + +「mcpp 依赖的内部 xlings」即 release.yml 打进 tarball 的 `/registry/bin/xlings`,由 release.yml 的 `XLINGS_VERSION` 决定,`kXlingsVersion` 只是把它打印出来。两者必须一致,但**当前没有任何机制保证**。 + +下载 URL 用的是 `d2learn/xlings`,发布在 `openxlings/xlings`。已核实两个 org 的 `v2026.7.27.2` 与 `v0.4.69` tag 均存在,升级无风险。 + +### 5.3 格式敏感的脚本 + +`.github/tools/install_pinned_mcpp.sh` 两处: + +```bash +grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 +``` + +对 `2026.7.27.1` 只截出 `2026.7.27`。pin 与实测值都被同样截断,于是**校验会通过,但比的是三段** —— 同日任意序号都算匹配,这个 pin 校验就退化成了摆设。必须改为接受 3 或 4 段并精确比较。 + +--- + +## 6. 结构性修复:漂移守卫 + +上面 5.1 / 5.2 一共 10 个落点,靠注释和人工同步。0.0.109 批次已经证明这条约定会失效,而失效方式是**静默的**:CI 沙箱在 0.4.30 上跑了很久没人发现,直到索引做了短名迁移才炸出来。 + +因此本次一并加一个检查,把「约定」变成「机制」: + +- 所有 xlings pin 必须等于 `pinned::kXlingsVersion` +- `mcpp.toml` == `MCPP_VERSION` == `.xlings.json` == `MCPP_PIN` + +任一不一致 → CI 失败并指出具体文件。这直接对应 #289 里记的「Adjacent, same shape」那条。 + +守卫本身用纯文本提取实现,不依赖 mcpp 二进制 —— 它必须能在 mcpp 构建失败时依然跑得起来,否则在最需要它的时候恰好不可用。 + +--- + +## 7. 不做的事 + +- **不改 xlings 的 semver 解析。** 4 段拒绝解析是 xlings 侧的问题,且已由「字面 key 优先」的解析顺序绕开。要改应当在 xlings 仓库单独提 issue/PR,混进本 PR 会让两个生态的发布互相阻塞。 +- **不动 #289 的沙箱 xlings 刷新功能。** 本次只把 pin 升到最新;运行时检查与同步是独立特性。 +- **不改索引侧 `mcpp.lua`。** 新版本条目由发布流程的 `res_versioned` 机制追加。 + +--- + +## 8. 验收口径 + +1. `parse_version("2026.7.27.1") != parse_version("2026.7.27.2")`,且 `<` 成立 —— 单元测试锁住。 +2. `str()` 对 `1.15.2` / `2026.7.27.1` / `2026.8.1.0` 三种输入精确回写 —— 单元测试锁住。 +3. E0006 在同日不同序号之间能正确触发 —— 单元测试锁住。 +4. 漂移守卫在故意改坏一个 pin 时失败 —— 负向验证,不能只看它在正常状态下通过。 +5. 全平台 CI 绿;发布后真装 `mcpp@2026.7.27.1` 并跑通一次依赖解析。 diff --git a/.agents/docs/2026-07-27-date-version-and-xlings-pin-plan.md b/.agents/docs/2026-07-27-date-version-and-xlings-pin-plan.md new file mode 100644 index 00000000..4889b624 --- /dev/null +++ b/.agents/docs/2026-07-27-date-version-and-xlings-pin-plan.md @@ -0,0 +1,135 @@ +# 日期版本号 + xlings pin 收敛 — 实施计划 + +配套设计:`2026-07-27-date-version-and-xlings-pin-design.md` +目标版本:**2026.7.27.1**(常规迭代;`.0` 保留给正式/稳定版) +xlings 目标 pin:**2026.7.27.2** + +单 PR 交付。阶段之间有依赖顺序:P1 必须先于 P5(版本一改,比较逻辑就得是对的)。 + +--- + +## P1 — `version_req` 支持 4 段(先做,其余都依赖它) + +**文件**:`src/version_req.cppm` + +1. `Version` 增加 `int revision = 0;` 与 `int components = 0;` +2. **删掉 `= default` 的 `<=>`**,改为显式比较 major → minor → patch → revision。 + `components` **不得**参与比较(否则 `"1.2"` ≠ `"1.2.0"`)。 +3. `parse_version`:循环上界 3 → 4,记录实际解析到的段数进 `components`。 +4. `str()`:按 `components` 回写。`components == 0` 时按 3 段回写(默认构造的 `Version{}` 走这条)。 +5. `matches()` 的 Caret / Tilde 上界构造补 `upper.revision = 0`。 + +**头号风险**:`resolver.cppm:138` 用 `str()` 重建依赖版本串。任何回写不精确都会改变现存依赖的寻址。 + +**测试**(`tests/unit/test_version_req.cpp`,无则新建): + +| 用例 | 断言 | +|---|---| +| 序 | `2026.7.27.1 < 2026.7.27.2`;`2026.7.27.9 < 2026.7.27.10` | +| 跨方案序 | `0.0.109 < 2026.7.27.1` | +| 三段兼容 | `1.2` == `1.2.0`;`1` == `1.0.0` | +| 回写 | `1.15.2` → `1.15.2`(**非** `1.15.2.0`) | +| 回写 `.0` | `2026.8.1.0` → `2026.8.1.0`(**不塌成三段**) | +| 回写 4 段 | `2026.7.27.1` → `2026.7.27.1` | +| Caret 不回归 | `^1.2.3` 匹配 `1.9.9`、不匹配 `2.0.0` | +| Tilde 不回归 | `~1.2.3` 匹配 `1.2.9`、不匹配 `1.3.0` | +| Caret 上界含 revision | `^2026.7.27.3` 不匹配 `2027.0.0.1` | + +**E0006**(`tests/unit/` 内 index_contract 相关): +`floor_violation("2026.7.27.5", "2026.7.27.1")` 必须返回违规;反向必须放行。**这是当前会静默错放的那条。** + +--- + +## P2 — 修格式敏感的脚本 + +**文件**:`.github/tools/install_pinned_mcpp.sh`(两处,L82 / L84) + +```bash +# 旧:只截三段 +grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 +# 新:3 或 4 段 +grep -oE '[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1 +``` + +两处都要改 —— 只改一处会让 pin 与实测值按不同规则提取,比较结果无意义。 + +已确认 `release.yml` 从 `mcpp.toml` awk 取版本、`aur-publish.yml` 用 sed 取,均与段数无关,不需要改。 + +--- + +## P3 — 升 xlings pin 到 2026.7.27.2 + +按设计 §5.2 的 7 个位置逐一替换 `0.4.69` → `2026.7.27.2`: + +- `src/xlings.cppm` `pinned::kXlingsVersion`(同时把那条已经不全的 lock-step 注释改为指向 P4 的守卫) +- `.github/actions/bootstrap-mcpp/action.yml`(default + 两个 cache key/lineage) +- `.github/actions/setup-macos-llvm/action.yml` +- `.github/workflows/bootstrap-macos.yml` +- `.github/workflows/ci-linux-e2e.yml` +- `.github/workflows/cross-build-test.yml`(×2) +- `.github/workflows/release.yml`(**这一份决定 release 内置的 xlings**) + +Cache key 含 xlings 版本,改 pin 会自动换 lineage —— 这是 0.0.109 批次刻意做的,别再退回去。 + +**注意**:0.0.109 批次的教训是换 lineage 会**去掉热缓存的意外保护**,把原本被掩盖的问题暴露出来。所以 P3 之后必须跑一次冷缓存 CI,不能只看增量绿。 + +--- + +## P4 — 漂移守卫 + +**新增**:`.github/tools/check_version_pins.sh` + +两组断言: +1. 所有 xlings pin == `src/xlings.cppm` 的 `kXlingsVersion` +2. `mcpp.toml` == `MCPP_VERSION` == `.xlings.json` == `MCPP_PIN` + +实现约束:**纯文本提取,不依赖 mcpp 二进制** —— 构建挂掉时它必须仍然可用。 + +接进 `ci-linux.yml` 早期步骤(在构建之前,快速失败)。 + +**负向验证是验收的一部分**:故意改坏一个 pin,确认脚本失败并指名文件;改回后确认通过。只验证「正常时通过」等于没验证。 + +--- + +## P5 — 版本号切到 2026.7.27.1 + +四处同一个 commit 内改完(设计 §5.1):`mcpp.toml`、`fingerprint.cppm`、`.xlings.json`、`ci-fresh-install.yml` 的 `MCPP_PIN`。 + +改完立刻跑 P4 的守卫做自检。 + +--- + +## P6 — 规范落文档 + +**`.agents/skills/mcpp-release/SKILL.md`**: +- 日期格式 `YYYY.M.D.N`,不补零 +- **`.0` = 正式/稳定版;常规迭代从 `.1` 起** +- 版本落点从「两处」更正为**四处**(现文档只写了 `mcpp.toml` + `fingerprint.cppm`,漏了 `.xlings.json` 与 `MCPP_PIN`) +- 指向 P4 的守卫脚本 + +--- + +## P7 — 交付闭环 + +1. 本地:单元 + e2e 全绿 +2. 单 PR(附版本),全平台 CI 绿 +3. bypass squash 合入 +4. 发布闭环:四平台 release → 独立复算 sha256 → gitcode 镜像双端核验 → xim-pkgindex PR → 干净 `XLINGS_HOME` 真装 `mcpp@2026.7.27.1` → pin bump + +**发布运维已知坑**(0.0.109 批次实测,别重踩): +- gitcode 单文件 180s 上限对大 tarball 必然超时 → 本地 `gtc` 补传 + `cmp` 逐字节核验 +- `gtc` 失败输出会明文带 `access_token`,贴日志前必须打码 +- `gtc` 在 `obs_callback` 路径上退出码会说谎 —— 以 GET 回内容为准,不信退出码 +- 索引 artifact 传播滞后于 git;轮询 raw 是误导性的就绪信号 + +--- + +## 验收清单 + +- [ ] `2026.7.27.1 < 2026.7.27.2` 且 `2026.7.27.9 < 2026.7.27.10` +- [ ] `str()` 三种形态精确回写(含 `.0` 不塌段) +- [ ] E0006 在同日不同序号间正确触发 +- [ ] 既有三段依赖解析逐字节不变(Caret/Tilde 回归用例) +- [ ] 漂移守卫负向验证通过 +- [ ] 冷缓存 CI 全平台绿 +- [ ] 真装新版本并跑通一次依赖解析 diff --git a/.agents/skills/mcpp-release/SKILL.md b/.agents/skills/mcpp-release/SKILL.md index 1b7d8f70..f36935e4 100644 --- a/.agents/skills/mcpp-release/SKILL.md +++ b/.agents/skills/mcpp-release/SKILL.md @@ -5,14 +5,43 @@ description: Use when releasing a new version of mcpp — bumps version, creates # mcpp 版本发布流程 +## 版本号规范 + +**格式:`YYYY.M.D.N`**(日期版本,月/日不补零),例如 `2026.7.27.1`。 +自 `2026.7.27.1` 起启用,此前为 `0.0.x`。与 xlings 生态一致(xlings 于同日从 `0.4.70` 迁入)。 + +第 4 段的语义: + +> **`.0` 保留给正式版本 / 稳定版本。日常迭代默认从 `.1` 开始。** + +即一天内可发 `.1`、`.2`、`.3` …… 若干次常规版本;`.0` 只在该版本被认定为正式 release 或稳定版时使用。 + +跨方案的序是单调的:`0.0.109` < `2026.7.27.1`,第一段从 `0` 变 `2026`,不存在回退。 + +> 比较逻辑见 `src/version_req.cppm`。它支持 4 段;**改动那里时务必保证 `str()` 精确回写**, +> `src/pm/resolver.cppm` 用它重建依赖版本串,会流向 lock 文件与 xlings wire 地址。 +> 尤其 `.0` 结尾的版本不能塌成三段。 + ## Overview -mcpp 的版本号存在于 **两个位置**,发布时必须同步更新: +mcpp 的版本号存在于 **四个位置**,发布时必须同步更新: + +1. `mcpp.toml` → `[package].version` — 构建系统读取的项目版本,release.yml 由它推导 tag +2. `src/toolchain/fingerprint.cppm` → `MCPP_VERSION` — 编译期硬编码常量(`--version` 输出、BMI 指纹、E0006 索引底线比较) +3. `.xlings.json` → `workspace.mcpp` — CI bootstrap 装哪个 mcpp +4. `.github/workflows/ci-fresh-install.yml` → `MCPP_PIN` — 全新安装验证的目标版本 + +**不一致会导致 release smoke test 失败**(CI 检查 `mcpp --version` 是否匹配 tag)。 +后两处历史上多次漂移(`MCPP_PIN` 曾落后五个版本),所以现在有机器校验: -1. `mcpp.toml` → `[package].version` — 构建系统读取的项目版本 -2. `src/toolchain/fingerprint.cppm` → `MCPP_VERSION` — 编译期硬编码的版本常量(用于 `--version` 输出和 BMI 缓存指纹) +```bash +bash .github/tools/check_version_pins.sh +``` -**两者不一致会导致 release smoke test 失败**(CI 检查 `mcpp --version` 输出是否匹配 tag 版本)。 +它同时校验第二组不变量:**`.github/` 下所有 xlings pin 必须等于 `src/xlings.cppm` 的 +`pinned::kXlingsVersion`**(当前 16 个 pin 点、7 个文件,含 release.yml 里三处硬编码的 +aarch64 tarball 字面量)。`kXlingsVersion` 是唯一真源,也是 release 打进 +`/registry/bin/xlings` 的那一份。改 xlings 版本只改常量,然后跑这个脚本找出其余落点。 ## 发布步骤 @@ -26,26 +55,25 @@ gh run list --branch main --limit 3 所有 CI(ci / ci-macos / ci-windows)必须为 `success`。不要在 CI 红的时候发版。 -### 2. 同步更新两处版本号(单个 commit) +### 2. 同步更新四处版本号(单个 commit) -**关键:在同一个 commit 中更新两个文件,避免版本不一致。** +**关键:在同一个 commit 中更新全部四个文件,避免版本不一致。** ```bash -# 确定新版本号 -NEW_VERSION="X.Y.Z" +# 日期版本:当天序号从 .1 起;.0 仅用于正式/稳定版 +NEW_VERSION="2026.7.27.1" -# 更新 mcpp.toml sed -i "s/^version.*=.*/version = \"$NEW_VERSION\"/" mcpp.toml - -# 更新 fingerprint.cppm 中的硬编码版本 sed -i "s/MCPP_VERSION = \".*\"/MCPP_VERSION = \"$NEW_VERSION\"/" src/toolchain/fingerprint.cppm +sed -i "s/\"mcpp\": \"[^\"]*\"/\"mcpp\": \"$NEW_VERSION\"/" .xlings.json +sed -i "s/MCPP_PIN: '[^']*'/MCPP_PIN: '$NEW_VERSION'/" .github/workflows/ci-fresh-install.yml -# 验证两处一致 -grep 'version' mcpp.toml | head -1 -grep 'MCPP_VERSION' src/toolchain/fingerprint.cppm +# 机器校验四处一致(同时校验 xlings pin),别靠肉眼 +bash .github/tools/check_version_pins.sh # 单个 commit 提交 -git add mcpp.toml src/toolchain/fingerprint.cppm +git add mcpp.toml src/toolchain/fingerprint.cppm .xlings.json \ + .github/workflows/ci-fresh-install.yml git commit -m "chore: bump version to $NEW_VERSION" git push origin main ``` diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 009ce3d7..0a13e9ea 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -13,14 +13,19 @@ inputs: xlings-version: description: xlings release to bootstrap from required: false - # 0.4.69 is the floor the CURRENT index needs, not a routine bump. Its - # SPEC-001 short-name migration put two packages named `lua` in one repo - # (`compat:lua` and `mcpplibs.capi:lua`, both pulled in transitively by - # mcpplibs.xpkg). Before 0.4.69 (openxlings/xlings#381) a repo keyed its - # table by the bare `package.name`, so one of the two was simply - # unreachable — and which one depended on the machine, which is why CI - # failed on `compat:lua` on Windows and `mcpplibs.capi:lua` on Linux. - default: '0.4.69' + # Must equal `pinned::kXlingsVersion` in src/xlings.cppm — enforced by + # .github/tools/check_version_pins.sh, not by this comment. (The previous + # comment here listed the files to keep in lock-step, and that list was + # already incomplete; that is why the check exists.) + # + # Hard floor: 0.4.69. The index's SPEC-001 short-name migration put two + # packages named `lua` in one repo (`compat:lua` and `mcpplibs.capi:lua`, + # both pulled in transitively by mcpplibs.xpkg). Before 0.4.69 + # (openxlings/xlings#381) a repo keyed its table by the bare + # `package.name`, so one of the two was simply unreachable — and which one + # depended on the machine, which is why CI failed on `compat:lua` on + # Windows and `mcpplibs.capi:lua` on Linux. Never pin below that. + default: '2026.7.27.2' cache-target: description: also restore/save target/ (build artifacts + BMIs) required: false @@ -41,8 +46,10 @@ runs: # vendors xlings into ~/.mcpp/registry/bin once at `self init` and # never revisits it (acquire_xlings_binary returns early when the file # exists). With the version only in the key, restore-keys would hand a - # 0.4.30 sandbox to a 0.4.69 bootstrap and the sandbox — which is what - # actually resolves dependencies — would silently stay behind. + # sandbox built by an OLDER xlings to a newer bootstrap, and the + # sandbox — which is what actually resolves dependencies — would + # silently stay behind (observed: a 0.4.30 sandbox surviving under a + # 0.4.69 bootstrap for weeks). key: mcpp-sandbox-${{ runner.os }}-ci-xl${{ inputs.xlings-version }}-${{ hashFiles('mcpp.toml', '.xlings.json') }} restore-keys: | mcpp-sandbox-${{ runner.os }}-ci-xl${{ inputs.xlings-version }}- diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index 60bb0cc6..4ae107fe 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -15,7 +15,7 @@ inputs: # Floor imposed by the index, not a routine bump — see # .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required # (two packages named `lua` in one repo need openxlings/xlings#381). - default: '0.4.69' + default: '2026.7.27.2' runs: using: composite diff --git a/.github/tools/check_version_pins.sh b/.github/tools/check_version_pins.sh new file mode 100755 index 00000000..3fdb8a06 --- /dev/null +++ b/.github/tools/check_version_pins.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# +# Version / pin drift guard. +# +# Two invariants that used to live only in a comment: +# +# 1. Every xlings version pinned anywhere in .github/ equals +# `pinned::kXlingsVersion` in src/xlings.cppm — which is the version +# `mcpp self env` reports and the one release.yml bundles into the +# tarball as /registry/bin/xlings. +# +# 2. mcpp's own version is identical in all four places that carry it. +# +# Why this exists: src/xlings.cppm used to say "keep in lock-step with the +# XLINGS_VERSION pins in release.yml / cross-build-test.yml / ci-linux-e2e.yml" +# and that list was ALREADY incomplete — it omitted both composite actions, +# which sat on 0.4.30 while everything else moved to 0.4.69. CI's sandbox +# silently rotted for weeks. A comment cannot enforce a cross-file invariant; +# this can. +# +# Deliberately pure text extraction with no mcpp dependency: the guard has to +# run when the build is broken, which is precisely when pins are being changed. +# +# Usage: bash .github/tools/check_version_pins.sh [repo_dir] + +set -uo pipefail + +REPO_DIR="${1:-$(pwd)}" +cd "$REPO_DIR" || { echo "FAIL: cannot cd to $REPO_DIR" >&2; exit 1; } + +fail=0 +note() { printf '%s\n' "$*" >&2; } +bad() { printf 'FAIL: %s\n' "$*" >&2; fail=1; } + +# Strip YAML/shell comments so historical references in prose ("before 0.4.69 +# the index keyed by bare name") are not mistaken for live pins. +strip_comments() { sed 's/#.*//'; } + +# ── 1. xlings pins ──────────────────────────────────────────────────────── + +XLINGS_EXPECTED=$(grep -oE 'kXlingsVersion[[:space:]]*=[[:space:]]*"[^"]+"' src/xlings.cppm \ + | grep -oE '"[^"]+"' | tr -d '"' | head -1) +[ -n "$XLINGS_EXPECTED" ] || { + echo "FAIL: could not read kXlingsVersion from src/xlings.cppm" >&2; exit 1; } + +note "expected xlings pin: $XLINGS_EXPECTED (src/xlings.cppm)" + +# Anchored patterns only — a bare "version-looking number on a line mentioning +# xlings" would also match `xlings install llvm@20.1.7`, which pins LLVM, not +# xlings. Each alternative below ties the number to xlings itself. +# XLINGS_VERSION: '' workflow env +# default: '' composite action input (handled below) +# xlings-- tarball / extracted dir name +# xlings/releases/download/v direct release URL +# quick_install.sh … bash -s v bootstrap installer +# xlings@ xim target +scan_xlings_pins() { + local f="$1" + strip_comments < "$f" | grep -nE \ + "XLINGS_VERSION:[[:space:]]*'?[0-9]|xlings-[0-9]+\.[0-9]|xlings/releases/download/v[0-9]|bash -s v[0-9]|xlings@[0-9]" \ + | while IFS= read -r line; do + local no="${line%%:*}" + local ver + ver=$(printf '%s' "$line" | grep -oE \ + "(XLINGS_VERSION:[[:space:]]*'?|xlings-|download/v|bash -s v|xlings@)[0-9]+(\.[0-9]+)+" \ + | grep -oE '[0-9]+(\.[0-9]+)+' | head -1) + [ -n "$ver" ] && printf '%s:%s\t%s\n' "$f" "$no" "$ver" + done +} + +# The composite actions carry the pin as the `default:` of an input named +# `xlings-version`, several lines below the input key — so match it by block, +# not by line. +scan_action_default() { + local f="$1" + strip_comments < "$f" | awk -v file="$f" ' + /^[[:space:]]*xlings-version:[[:space:]]*$/ { inblock = 1; next } + inblock && /^[[:space:]]*[a-zA-Z_-]+:[[:space:]]*$/ && !/default/ { inblock = 0 } + inblock && /^[[:space:]]*default:/ { + if (match($0, /[0-9]+(\.[0-9]+)+/)) + printf "%s:%d\t%s\n", file, NR, substr($0, RSTART, RLENGTH) + inblock = 0 + }' +} + +found_any=0 +while IFS= read -r f; do + [ -f "$f" ] || continue + while IFS=$'\t' read -r loc ver; do + [ -n "${ver:-}" ] || continue + found_any=1 + if [ "$ver" != "$XLINGS_EXPECTED" ]; then + bad "$loc pins xlings $ver, expected $XLINGS_EXPECTED" + fi + done < <( { scan_xlings_pins "$f"; case "$f" in */action.yml) scan_action_default "$f";; esac; } ) +done < <(find .github -type f \( -name '*.yml' -o -name '*.yaml' -o -name '*.sh' \) | sort) + +[ "$found_any" = 1 ] || bad "found no xlings pins at all in .github/ — the scanner's patterns have gone stale, which would make this guard silently vacuous" + +# ── 2. mcpp's own version ───────────────────────────────────────────────── + +v_toml=$(awk -F '"' '/^version[[:space:]]*=/{print $2; exit}' mcpp.toml) +v_src=$(grep -oE 'MCPP_VERSION[[:space:]]*=[[:space:]]*"[^"]+"' src/toolchain/fingerprint.cppm \ + | grep -oE '"[^"]+"' | tr -d '"' | head -1) +v_xl=$(grep -oE '"mcpp"[[:space:]]*:[[:space:]]*"[^"]+"' .xlings.json \ + | grep -oE '"[^"]+"$' | tr -d '"' | head -1) +v_pin=$(grep -oE "MCPP_PIN:[[:space:]]*'[^']+'" .github/workflows/ci-fresh-install.yml \ + | grep -oE "'[^']+'" | tr -d "'" | head -1) + +note "mcpp version: building=$v_toml (fingerprint=$v_src) bootstrap pin=$v_xl (MCPP_PIN=$v_pin)" + +for n in "mcpp.toml:$v_toml" "src/toolchain/fingerprint.cppm:$v_src" \ + ".xlings.json:$v_xl" "ci-fresh-install.yml MCPP_PIN:$v_pin"; do + [ -n "${n##*:}" ] || bad "${n%:*} — could not read the mcpp version" +done + +# (a) The version being BUILT: mcpp.toml and the compiled-in constant are the +# same number by definition — release.yml derives the tag from the former +# and the smoke test greps the latter out of `mcpp --version`. +[ -z "$v_src" ] || [ "$v_src" = "$v_toml" ] \ + || bad "src/toolchain/fingerprint.cppm has '$v_src' but mcpp.toml has '$v_toml'" + +# (b) The version BOOTSTRAPPED FROM: both sites name a mcpp that is already +# published, so they must agree with each other — but they are NOT required +# to equal the version being built. They deliberately lag, and are bumped in +# a separate commit AFTER the release exists in xim-pkgindex (see the +# MCPP_PIN comment in ci-fresh-install.yml). Requiring equality here is what +# an earlier revision of this script got wrong: it sent CI to install a +# version that did not exist yet, and every job died with +# `package 'mcpp@' not found`. +[ -z "$v_xl" ] || [ -z "$v_pin" ] || [ "$v_xl" = "$v_pin" ] \ + || bad ".xlings.json pins '$v_xl' but ci-fresh-install.yml MCPP_PIN is '$v_pin' — both bootstrap the same released mcpp" + +# (c) …and the bootstrap pin must never run AHEAD of the version being built. +# Four-key numeric sort, so the date scheme orders correctly (a plain +# sort would put 2026.7.27.10 below 2026.7.27.9). +if [ -n "$v_xl" ] && [ -n "$v_toml" ] && [ "$v_xl" != "$v_toml" ]; then + newest=$(printf '%s\n%s\n' "$v_xl" "$v_toml" \ + | sort -t. -k1,1n -k2,2n -k3,3n -k4,4n | tail -1) + [ "$newest" = "$v_toml" ] \ + || bad "bootstrap pin '$v_xl' is NEWER than the version being built ('$v_toml') — CI would try to install an unreleased mcpp" +fi + +if [ "$fail" = 0 ]; then + echo "OK: xlings pins all at $XLINGS_EXPECTED; building mcpp $v_toml, bootstrapping from $v_xl" >&2 +fi +exit "$fail" diff --git a/.github/tools/install_pinned_mcpp.sh b/.github/tools/install_pinned_mcpp.sh index f3d2723c..017f74e1 100755 --- a/.github/tools/install_pinned_mcpp.sh +++ b/.github/tools/install_pinned_mcpp.sh @@ -78,10 +78,16 @@ MCPP="$XL_HOME/.xlings/subos/default/bin/mcpp${EXE}" # CI would get a bare non-zero and no diagnostic, exactly the failure mode this # whole change is meant to stop. A miss has to reach the check below, which can # name what it expected and what it found. +# +# Three OR four segments: mcpp's version scheme is the date form YYYY.M.D.N +# (e.g. 2026.7.27.1). A three-segment-only pattern still MATCHES such a version +# — it just silently truncates it to "2026.7.27" on both sides, so the +# comparison below would accept any release of that day and this guard would +# quietly stop guarding. PIN=$(grep -oE '"mcpp"[[:space:]]*:[[:space:]]*"[^"]+"' "$REPO_DIR/.xlings.json" \ - | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true) + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1 || true) GOT=$("$MCPP" --version 2>/dev/null | head -1 \ - | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true) + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1 || true) # Exact comparison: a substring match would let a pin of 0.0.10 be satisfied by # a 0.0.109 binary. [ -n "$PIN" ] && [ "$GOT" = "$PIN" ] || { diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index 19e2594c..432db041 100644 --- a/.github/workflows/bootstrap-macos.yml +++ b/.github/workflows/bootstrap-macos.yml @@ -14,9 +14,10 @@ jobs: timeout-minutes: 30 env: XLINGS_NON_INTERACTIVE: '1' - # Dormant (workflow_dispatch only), but kept in step with the rest: the - # index needs 0.4.69 to resolve two packages that share a short name. - XLINGS_VERSION: '0.4.69' + # Dormant (workflow_dispatch only), but kept in step with the rest — + # check_version_pins.sh holds it there. Floor: 0.4.69, below which the + # index cannot resolve two packages that share a short name. + XLINGS_VERSION: '2026.7.27.2' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 8346fe44..78683b5a 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -91,7 +91,7 @@ jobs: env: XLINGS_NON_INTERACTIVE: '1' run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v0.4.38 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.7.27.2 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -227,7 +227,7 @@ jobs: - name: Install xlings + mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v0.4.38 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.7.27.2 # Deliberately NOT writing to $GITHUB_PATH here. On container # images that declare no PATH in their config (opensuse/ # tumbleweed), appending a single dir to GITHUB_PATH makes the @@ -284,11 +284,14 @@ jobs: env: XLINGS_NON_INTERACTIVE: '1' run: | - # v0.4.50+: first xlings release whose macosx binary runs on - # macOS 14 (older ones carry minos=15 and refuse to start). - # v0.4.51: in-process sha256 — required on this image, which - # has no sha256sum binary (pinned fetches failed before). - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v0.4.51 + # Pinned to kXlingsVersion like every other bootstrap (see + # .github/tools/check_version_pins.sh). Two floors this image needs, + # both long satisfied — do not pin below them: + # v0.4.50+: first xlings whose macosx binary runs on macOS 14 + # (older ones carry minos=15 and refuse to start). + # v0.4.51+: in-process sha256 — this image has no sha256sum + # binary, so pinned fetches failed before it. + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.7.27.2 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 97cdd8ca..c05fa4b1 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -123,7 +123,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v0.4.69 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.7.27.2 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index b1c08465..76eefa4f 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -46,6 +46,15 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@v4 + + # Before the bootstrap, not after: this needs no toolchain and no mcpp, + # takes under a second, and the drift it catches (a stale xlings pin) + # would otherwise surface minutes later as an unrelated-looking + # dependency-resolution failure. Pure text extraction on purpose — it + # has to work when the build is broken. + - name: Check version / xlings pin consistency + run: bash .github/tools/check_version_pins.sh + - uses: ./.github/actions/bootstrap-mcpp - name: Configure mirror + Build mcpp from source (self-host) diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 38773400..f2ef366d 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -92,8 +92,11 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - # 0.4.69 (current) — kept in lock-step with the xlings the release - # is built/bundled against (release.yml). 0.4.67 carried the + # Must equal `pinned::kXlingsVersion` (src/xlings.cppm) and the + # xlings the release bundles — enforced by + # .github/tools/check_version_pins.sh. + # + # Floors worth remembering. 0.4.67 carried the # multi-index_repo install fix (openxlings/xlings#374); 0.4.68 adds # per-repo index artifact sources (openxlings/xlings#377) so the # mcpplibs index syncs via artifact with git as fallback (mcpp#269); @@ -105,7 +108,7 @@ jobs: # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '0.4.69' + XLINGS_VERSION: '2026.7.27.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -242,7 +245,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '0.4.69' + XLINGS_VERSION: '2026.7.27.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9507f471..db469053 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '0.4.69' + XLINGS_VERSION: '2026.7.27.2' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -278,10 +278,10 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" - - name: Bootstrap mcpp via xlings (latest 0.4.69) + - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '0.4.69' + XLINGS_VERSION: '2026.7.27.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -340,13 +340,16 @@ jobs: exec "$(dirname "$0")/bin/mcpp" "$@" LAUNCHER chmod +x "$STAGING/$WRAPPER/mcpp" - # Bundle the aarch64 xlings (0.4.69) so install.sh consumers on aarch64 - # get an aarch64 xlings, not the x86_64 bootstrap one. - XLA="xlings-0.4.69-linux-aarch64.tar.gz" + # Bundle the aarch64 xlings so install.sh consumers on aarch64 get an + # aarch64 xlings, not the x86_64 bootstrap one. The three literals + # below are pinned to the same version as XLINGS_VERSION; they are + # NOT interpolated from it, so check_version_pins.sh scans for them + # explicitly (they were absent from the old lock-step comment). + XLA="xlings-2026.7.27.2-linux-aarch64.tar.gz" if curl -fsSL -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v0.4.69/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.7.27.2/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-0.4.69-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.7.27.2-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -421,7 +424,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '0.4.69' + XLINGS_VERSION: '2026.7.27.2' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -603,7 +606,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '0.4.69' + XLINGS_VERSION: '2026.7.27.2' run: | # Captured before the `cd` below, in POSIX form: this step never # returns to the workspace, and GITHUB_WORKSPACE is a backslash diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c7b312e..37febf58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,42 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.7.27.1] — 2026-07-27 + +### 变更 + +- **版本号改为日期格式 `YYYY.M.D.N`。** 与 xlings 生态对齐(xlings 于同日从 `0.4.70` 迁入)。月/日不补零。 + + 第 4 段的约定:**`.0` 保留给正式版本 / 稳定版本,日常迭代默认从 `.1` 开始** —— 一天内可以有若干次常规发布,`.0` 这个槽位只在该版本被认定为正式 release 或稳定版时使用。 + + 跨方案的序是单调的:`0.0.109` < `2026.7.27.1`,第一段由 `0` 变 `2026`,不存在回退。 + +### 修复 + +- **[#291](https://github.com/mcpp-community/mcpp/issues/291) 私有 glibc payload 不再无条件出现在运行目标的 `LD_LIBRARY_PATH` 里。** 该变量会被**整棵进程子树**继承。当运行目标是个会 shell out 的程序(例如课程 provider 调 `popen("mcpp test ...")`)时,`/bin/sh` 是**宿主二进制** —— 它的 `PT_INTERP` 烙死在文件里,装载它的永远是**宿主 ld.so**,而这个变量却把 **payload 的 `libc.so.6`** 递给它。 + + glibc 的 libc 与 ld.so 之间通过 `GLIBC_PRIVATE` 版本锁定(实测:payload `libc.so.6` 对 `ld-linux-x86-64.so.2` 有 `GLIBC_PRIVATE` 依赖),二者必须同一次构建。于是**只要宿主 glibc 与 payload 不同版本**,shell 就在 `main` 之前死于装载器内的 SIGSEGV —— stdout 全空,没有任何诊断。报告者是 Ubuntu 22.04(glibc 2.35)对 payload 2.39。 + + 注意这**不是**「构建机不同导致 ABI 不兼容」:它是纯粹的版本错配,任何宿主 glibc ≠ payload 的用户都会中招。也正因为版本相同就不复现,它一直没被发现。 + + payload 目录是 `LD_LIBRARY_PATH` 里**唯一不同时出现在可执行文件 RUNPATH 中**的一项(`flags.cppm` 刻意把它排除,以保持静态/musl 链接干净)。它存在的唯一理由是:被 `dlopen()` 的库其自身的 DT_NEEDED 闭包**不会**查主程序的 RUNPATH。因此现在只在构建确实存在这类依赖库时才注入(`depRuntimeLibraryDirs` 非空)——真实的 host-GL 透传场景走 `compat.glx-runtime` 的 `[runtime] library_dirs`,正好落在这个条件内,行为不变;而零依赖的二进制不再拿到它。 + + `process.cppm` 的 `strip_private_glibc` 早已保护 mcpp **自己的**子进程,但它够不到再外一层:变量是 mcpp 有意设给目标的,目标之后再 fork 什么已超出 mcpp 的控制 —— 能控制的是**不必要时就不发**。 + + e2e 166 双向锁住(零依赖工程必须拿不到、有 `[runtime] library_dirs` 的工程必须拿得到)。它断言的是**发出的环境变量**而非「shell 是否崩溃」:后者在宿主与 payload 版本相同的机器上会因为错误的理由通过,那正是这个 bug 长期存活的原因。 + +- **4 段版本号在比较时被静默截断,致 E0006 索引底线检查失效。** `version_req::Version` 原本是严格三段,`parse_version("2026.7.27.1")` 解析出 `{2026, 7, 27}` 后**丢弃第 4 段且不报错** —— 同一天内的所有版本互相比较相等。 + + 后果落在 `pm/index_contract.cppm`:索引写 `min_mcpp = "2026.7.27.5"`、用户跑 `2026.7.27.1`,两者比较相等,`have >= need` 成立,**底线检查放行**。用户拿着不够新的 mcpp 去读新索引,得到的是描述符读取返回空这类难以归因的次生故障 —— 而挡住这种情况正是该检查存在的唯一理由。 + + 修法是把 `Version` 扩到 4 段,并新增 `components` 记录源串实际写了几段。两条约束:比较**只看 4 个数字**(`components` 若参与比较,`"1.2"` 就不再等于 `"1.2.0"`);`str()` 必须能回写第 4 段 —— 它是**载荷性的**,`pm/resolver.cppm` 用它重建已解析的依赖版本串,该串会流向 lock 文件与 xlings wire 地址,而 `.0` 结尾的版本一旦塌成三段就会指向一个不存在的索引 key。既有三段依赖的解析逐字节不变。 + +### 维护 + +- **所有 xlings pin 升至 `2026.7.27.2`,并加机器校验。** `src/xlings.cppm` 的 `pinned::kXlingsVersion` 现为唯一真源,`.github/tools/check_version_pins.sh` 强制 `.github/` 下全部 16 个 pin 点与之一致,并同时校验 mcpp 自身版本在四处的一致性。 + + 此前靠常量上方一句「keep in lock-step with release.yml / cross-build-test.yml / ci-linux-e2e.yml」的注释人工同步,而**那个清单本身就是不全的** —— 漏掉了两个 composite action,CI 沙箱因此在 0.4.30 上静默跑了数周。落实检查时又找出三处从未被记录的 pin(`release.yml` 里硬编码的 aarch64 xlings tarball 字面量)和三处落后的 bootstrap pin(`ci-fresh-install.yml` 的 `v0.4.38`×2 / `v0.4.51`)。 + ## [0.0.108] — 2026-07-26 ### 修复 diff --git a/mcpp.toml b/mcpp.toml index a5f7e1f8..0890e94f 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "0.0.109" +version = "2026.7.27.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 4003f794..63f1c884 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -460,7 +460,28 @@ make_plan(const mcpp::manifest::Manifest& manifest, for (auto const& dir : tc.linkRuntimeDirs) { append_unique_path(plan.runtimeLibraryDirs, dir); } - if (tc.payloadPaths) { + // The private glibc payload is the ONE entry that is not also in the + // executable's RUNPATH (flags.cppm excludes it deliberately, so static and + // musl links stay clean). It is here purely so a dlopen()'d library — whose + // own DT_NEEDED closure never consults the main executable's RUNPATH — can + // still resolve the same libc the executable was linked against. + // + // So add it ONLY when this build actually has such a library. mcpp#291: + // LD_LIBRARY_PATH is inherited by the whole process subtree, and a child + // that is a HOST binary (/bin/sh, reached via a provider's popen()) loads + // the HOST loader — PT_INTERP is baked into the executable and no + // environment variable can override it — while this variable hands it the + // payload libc.so.6. libc and ld.so are version-locked to each other + // through GLIBC_PRIVATE, so on any host whose glibc differs from the + // payload's the shell dies of SIGSEGV inside the dynamic linker, before + // main, with empty stdout and no diagnostic. (It does NOT reproduce when + // host and payload glibc happen to match, which is why this survived.) + // + // process.cppm's strip_private_glibc already removes this entry from + // mcpp's OWN children. It cannot help one hop further out: mcpp sets the + // variable for the target deliberately, and what the target then spawns is + // beyond mcpp's reach. Not emitting it unless it is needed is. + if (tc.payloadPaths && !plan.depRuntimeLibraryDirs.empty()) { append_unique_path(plan.runtimeLibraryDirs, tc.payloadPaths->glibcLib); } diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 85f4443c..cabf91c9 100644 --- a/src/toolchain/fingerprint.cppm +++ b/src/toolchain/fingerprint.cppm @@ -18,7 +18,7 @@ import mcpp.toolchain.detect; export namespace mcpp::toolchain { -inline constexpr std::string_view MCPP_VERSION = "0.0.109"; +inline constexpr std::string_view MCPP_VERSION = "2026.7.27.1"; struct FingerprintInputs { Toolchain toolchain; diff --git a/src/version_req.cppm b/src/version_req.cppm index fbe30225..3f22cd9a 100644 --- a/src/version_req.cppm +++ b/src/version_req.cppm @@ -9,8 +9,14 @@ // "*" → any // "" → any (treated as *) // -// Versions: major.minor.patch with all parts ≥ 0; missing parts default to 0 -// (e.g. "1.2" == "1.2.0", "1" == "1.0.0"). +// Versions: major.minor.patch[.revision] with all parts ≥ 0; missing parts +// default to 0 (e.g. "1.2" == "1.2.0", "1" == "1.0.0"). +// +// The fourth segment exists for mcpp's own date-based scheme (YYYY.M.D.N, +// e.g. "2026.7.27.1"). Without it the parser silently truncated, making every +// release of a given day compare EQUAL — which quietly disabled the E0006 +// index-floor check. Ordinary three-segment SemVer is unaffected: the fourth +// component is 0 on both sides, so ^ / ~ / = behave exactly as before. // // Pre-release / build metadata are NOT supported in M4 V1 — versions // containing '-' or '+' are still parsed by stripping after first such @@ -23,11 +29,35 @@ import std; export namespace mcpp::version_req { struct Version { - int major = 0, minor = 0, patch = 0; - auto operator<=>(const Version&) const = default; - + int major = 0, minor = 0, patch = 0, revision = 0; + // How many segments the source string actually wrote. Only str() reads + // it — it must NOT take part in ordering, or "1.2" and "1.2.0" would + // compare unequal. + int components = 0; + + // Explicit, not `= default`: the defaulted <=> would compare + // `components` too. Order is the four numbers, nothing else. + std::strong_ordering operator<=>(const Version& o) const { + if (auto c = major <=> o.major; c != 0) return c; + if (auto c = minor <=> o.minor; c != 0) return c; + if (auto c = patch <=> o.patch; c != 0) return c; + return revision <=> o.revision; + } + bool operator==(const Version& o) const { return (*this <=> o) == 0; } + + // Three segments are the floor, so every version that parsed before this + // field existed still renders byte-identically ("1.2" → "1.2.0"). The + // fourth is appended only when the source actually wrote it. + // + // Load-bearing: pm/resolver.cppm returns str() as the RESOLVED dependency + // version, which flows into the lock file and the xlings wire address — + // so this has to reproduce the index's literal version key. In particular + // a date version ending in ".0" (mcpp's formal-release convention) must + // stay four segments; collapsing it to "2026.8.1" would address a key + // that does not exist. std::string str() const { - return std::format("{}.{}.{}", major, minor, patch); + auto base = std::format("{}.{}.{}", major, minor, patch); + return components >= 4 ? std::format("{}.{}", base, revision) : base; } }; @@ -64,10 +94,10 @@ std::expected parse_version(std::string_view s) { s = s.substr(0, dash); } Version v; - int* parts[3] = { &v.major, &v.minor, &v.patch }; + int* parts[4] = { &v.major, &v.minor, &v.patch, &v.revision }; int idx = 0; std::size_t i = 0; - while (idx < 3 && i <= s.size()) { + while (idx < 4 && i <= s.size()) { std::size_t start = i; while (i < s.size() && std::isdigit(static_cast(s[i]))) ++i; if (start == i) { @@ -81,6 +111,7 @@ std::expected parse_version(std::string_view s) { if (i < s.size() && s[i] == '.') ++i; else break; } + v.components = idx; return v; } @@ -144,7 +175,12 @@ bool matches(const Requirement& r, const Version& v) { case Op::Caret: { // ^X.Y.Z = >=X.Y.Z, <(X+1).0.0 (leftmost-nonzero rule) // For simplicity here: bump major; if major==0 bump minor; if both 0 bump patch. + // Every branch must also zero `revision`, or the upper bound + // inherits the constraint's own fourth segment and wrongly + // excludes releases below it (^2026.7.27.3 would cut off + // 2027.0.0.0..2). Version upper = c.v; + upper.revision = 0; if (c.v.major != 0) { ++upper.major; upper.minor = 0; upper.patch = 0; } else if (c.v.minor != 0) { ++upper.minor; upper.patch = 0; } else { ++upper.patch; } @@ -154,7 +190,7 @@ bool matches(const Requirement& r, const Version& v) { case Op::Tilde: { // ~X.Y.Z = >=X.Y.Z, = c.v && v < upper)) return false; break; } diff --git a/src/xlings.cppm b/src/xlings.cppm index 91424d26..3e108ae4 100644 --- a/src/xlings.cppm +++ b/src/xlings.cppm @@ -33,10 +33,16 @@ struct Env { namespace pinned { inline constexpr std::string_view kPatchelfVersion = "0.18.0"; inline constexpr std::string_view kNinjaVersion = "1.12.1"; - // Keep in lock-step with the XLINGS_VERSION pins in release.yml / - // cross-build-test.yml / ci-linux-e2e.yml (the xlings actually bundled - // into releases). Printed by `mcpp self env`. - inline constexpr std::string_view kXlingsVersion = "0.4.69"; + // The xlings a release bundles at /registry/bin/xlings, and the + // version `mcpp self env` reports. + // + // This is the SOURCE OF TRUTH for every xlings pin in .github/, and + // `.github/tools/check_version_pins.sh` enforces that — CI fails if any + // pin disagrees. This comment used to instead name three files to keep + // in lock-step by hand; that list was already missing both composite + // actions, which is how CI's sandbox sat on 0.4.30 unnoticed while + // everything else had moved on. Don't reintroduce a hand-maintained list. + inline constexpr std::string_view kXlingsVersion = "2026.7.27.2"; inline constexpr std::string_view kNasmVersion = "3.02"; } diff --git a/tests/e2e/166_run_env_no_private_glibc.sh b/tests/e2e/166_run_env_no_private_glibc.sh new file mode 100755 index 00000000..aec9f0d9 --- /dev/null +++ b/tests/e2e/166_run_env_no_private_glibc.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# requires: +# mcpp#291 — a plain binary must NOT be handed the private glibc payload on +# LD_LIBRARY_PATH. +# +# That variable is inherited by the entire process subtree. When the target is +# something like a course provider that shells out (popen("mcpp test ...")), +# /bin/sh is a HOST binary: its PT_INTERP is baked in, so it loads the HOST +# ld.so while this variable hands it the PAYLOAD libc.so.6. glibc's libc and +# ld.so are version-locked to each other via GLIBC_PRIVATE, so on any host +# whose glibc differs from the payload's the shell dies of SIGSEGV inside the +# dynamic linker — before main, with empty stdout and no diagnostic. +# +# The payload dir belongs on LD_LIBRARY_PATH only when the build actually has +# a dlopen()-reachable dependency library (whose own DT_NEEDED closure cannot +# see the executable's RUNPATH). A project with no such dependency must get +# nothing. +# +# ASSERTS ON THE EMITTED ENVIRONMENT, not on whether a shell crashes: the crash +# needs host glibc != payload glibc. On a matching host (the common CI case) +# a crash-based test passes for the wrong reason and would never have caught +# this in the first place. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +cd "$TMP" +mkdir -p pkg/src +cd pkg +cat > mcpp.toml <<'EOF' +[package] +name = "envprobe" +version = "0.1.0" +standard = "c++23" +EOF + +# Print the loader path variable exactly as the run target receives it. +cat > src/main.cpp <<'EOF' +#include +#include +int main() { + const char* p = std::getenv("LD_LIBRARY_PATH"); + std::printf("LDLP=[%s]\n", p ? p : ""); + return 0; +} +EOF + +out=$("$MCPP" run 2>&1) || { echo "FAIL: mcpp run failed"; echo "$out"; exit 1; } + +line=$(printf '%s\n' "$out" | grep -oE 'LDLP=\[[^]]*\]' | head -1) +[ -n "$line" ] || { echo "FAIL: probe never printed LDLP"; echo "$out"; exit 1; } +echo "observed: $line" + +# The specific poison: the private glibc payload store directory. +if printf '%s' "$line" | grep -q 'xim-x-glibc'; then + echo "FAIL: the run target was handed the private glibc payload on LD_LIBRARY_PATH." + echo " $line" + echo " A binary with no dlopen-reachable dependency must not get it —" + echo " it propagates to every descendant process, including host shells." + exit 1 +fi + +# ── The other half: when a dlopen-reachable dependency library DOES exist, +# the payload dir must still be there. Without this, a later change could drop +# the entry entirely and the negative assertion above would happily pass. +GLIBC_STORE=$(ls -d "$HOME"/.mcpp/registry/data/xpkgs/xim-x-glibc/*/ 2>/dev/null | head -1) +if [ -z "$GLIBC_STORE" ]; then + echo "SKIP (positive half): no private glibc payload installed" + echo OK + exit 0 +fi + +cd "$TMP" +mkdir -p pkg2/src pkg2/runtime +cd pkg2 +cat > mcpp.toml <<'EOF' +[package] +name = "envprobe2" +version = "0.1.0" +standard = "c++23" + +[runtime] +library_dirs = ["runtime"] +EOF +cp ../pkg/src/main.cpp src/main.cpp + +out2=$("$MCPP" run 2>&1) || { echo "FAIL: mcpp run failed (positive half)"; echo "$out2"; exit 1; } +line2=$(printf '%s\n' "$out2" | grep -oE 'LDLP=\[[^]]*\]' | head -1) +echo "observed (with [runtime] library_dirs): $line2" + +printf '%s' "$line2" | grep -q 'xim-x-glibc' || { + echo "FAIL: a build WITH a dlopen-reachable dependency library dir lost the" + echo " private glibc payload from LD_LIBRARY_PATH. dlopen'd libraries do" + echo " not consult the executable's RUNPATH, so they need it here." + echo " $line2" + exit 1; } + +echo OK diff --git a/tests/e2e/65_toolchain_runtime_dirs_for_run.sh b/tests/e2e/65_toolchain_runtime_dirs_for_run.sh index 434fb725..1587ae2b 100755 --- a/tests/e2e/65_toolchain_runtime_dirs_for_run.sh +++ b/tests/e2e/65_toolchain_runtime_dirs_for_run.sh @@ -3,6 +3,17 @@ # dlopen() providers such as GLX drivers do not use the main executable's # RUNPATH for their own DT_NEEDED closure. mcpp run must therefore expose the # toolchain runtime directories in LD_LIBRARY_PATH as well. +# +# Scope note (mcpp#291): this fixture has NO dependencies, so it no longer +# asserts the private glibc payload dir. That entry is not a toolchain runtime +# dir — flags.cppm deliberately keeps it out of RUNPATH — and it is now emitted +# only when the build actually has a dlopen-reachable dependency library, i.e. +# when depRuntimeLibraryDirs is non-empty. The real case this test's headline +# describes (host-GL passthrough) reaches mcpp through compat.glx-runtime's +# `[runtime] library_dirs`, which populates exactly that vector, so it still +# gets the payload dir; a zero-dependency binary like the one below does not. +# Emitting it unconditionally is what poisoned every descendant process and +# segfaulted host shells reached via popen(). See tests/e2e/166. set -e OS="$(uname -s)" @@ -49,7 +60,6 @@ int main() { std::string path(value); if (path.find("@LLVM_LIB_SUBSTR@") == std::string::npos) return 11; - if (path.find("xim-x-glibc/2.39/lib64") == std::string::npos) return 12; return 0; } EOF diff --git a/tests/unit/test_index_contract.cpp b/tests/unit/test_index_contract.cpp index 5590c5a5..a6f991d6 100644 --- a/tests/unit/test_index_contract.cpp +++ b/tests/unit/test_index_contract.cpp @@ -14,6 +14,30 @@ TEST(IndexContract, FloorViolationOrdering) { EXPECT_NE(v->find("0.0.85"), std::string::npos); } +// The floor compares mcpp's OWN version, so the date scheme (YYYY.M.D.N) has +// to be ordered on all four segments. While parse_version truncated at three, +// every release of a given day compared equal and the floor let a too-old mcpp +// straight through — the exact failure this check exists to prevent. +TEST(IndexContract, FloorViolationSeparatesSameDayReleases) { + using mcpp::pm::floor_violation; + EXPECT_FALSE(floor_violation("2026.7.27.1", "2026.7.27.1").has_value()); + EXPECT_FALSE(floor_violation("2026.7.27.1", "2026.7.27.2").has_value()); + EXPECT_FALSE(floor_violation("2026.7.27.9", "2026.7.27.10").has_value()); + + auto v = floor_violation("2026.7.27.5", "2026.7.27.1"); + ASSERT_TRUE(v.has_value()) << "a lower same-day ordinal must violate the floor"; + EXPECT_NE(v->find("E0006"), std::string::npos); + EXPECT_NE(v->find("2026.7.27.5"), std::string::npos); +} + +TEST(IndexContract, FloorViolationAcrossVersionSchemes) { + using mcpp::pm::floor_violation; + // A date-scheme mcpp satisfies any legacy 0.0.x floor. + EXPECT_FALSE(floor_violation("0.0.109", "2026.7.27.1").has_value()); + // A legacy mcpp does not satisfy a date-scheme floor. + EXPECT_TRUE(floor_violation("2026.7.27.1", "0.0.109").has_value()); +} + TEST(IndexContract, EmptyOrMalformedNeverBricks) { using mcpp::pm::floor_violation; EXPECT_FALSE(floor_violation("", "0.0.84").has_value()); diff --git a/tests/unit/test_version_req.cpp b/tests/unit/test_version_req.cpp index eace52b0..648abb6d 100644 --- a/tests/unit/test_version_req.cpp +++ b/tests/unit/test_version_req.cpp @@ -91,3 +91,86 @@ TEST(VersionReq, RejectsGarbage) { EXPECT_FALSE(parse_req(">=foo").has_value()); EXPECT_FALSE(parse_version("not-a-version").has_value()); } + +// ─── Date-based versions (YYYY.M.D.N), mcpp's own scheme ──────────────── +// +// Before the fourth segment existed, parse_version truncated at three and +// every release of a given day compared EQUAL — which silently disabled the +// E0006 index-floor check (index_contract.cppm compares mcpp's own version). + +TEST(VersionReq, DateVersionParsesFourSegments) { + auto v = parse_version("2026.7.27.1"); + ASSERT_TRUE(v); + EXPECT_EQ(v->major, 2026); + EXPECT_EQ(v->minor, 7); + EXPECT_EQ(v->patch, 27); + EXPECT_EQ(v->revision, 1); +} + +TEST(VersionReq, DateVersionOrdersWithinTheSameDay) { + EXPECT_LT(*parse_version("2026.7.27.1"), *parse_version("2026.7.27.2")); + // Numeric, not lexicographic: the 10th release of a day must outrank the 9th. + EXPECT_LT(*parse_version("2026.7.27.9"), *parse_version("2026.7.27.10")); + EXPECT_EQ(*parse_version("2026.7.27.3"), *parse_version("2026.7.27.3")); +} + +TEST(VersionReq, DateVersionOutranksLegacySemver) { + EXPECT_LT(*parse_version("0.0.109"), *parse_version("2026.7.27.1")); +} + +TEST(VersionReq, ThreeSegmentVersionsStillCompareEqualAcrossWrittenLength) { + // `components` must not leak into ordering. + EXPECT_EQ(*parse_version("1.2"), *parse_version("1.2.0")); + EXPECT_EQ(*parse_version("1"), *parse_version("1.0.0")); + EXPECT_EQ(*parse_version("1.2.3"), *parse_version("1.2.3")); +} + +// str() feeds pm/resolver.cppm's resolved-version return value, which becomes +// the lock entry and the xlings wire address — it has to reproduce the +// index's literal version key. +TEST(VersionReq, StrKeepsThreeSegmentNormalizationUnchanged) { + EXPECT_EQ(parse_version("1.15.2")->str(), "1.15.2"); + EXPECT_EQ(parse_version("0.5")->str(), "0.5.0"); + EXPECT_EQ(parse_version("7")->str(), "7.0.0"); +} + +TEST(VersionReq, StrRendersTheFourthSegmentWhenWritten) { + EXPECT_EQ(parse_version("2026.7.27.1")->str(), "2026.7.27.1"); + // A trailing ".0" is mcpp's formal-release marker and must NOT collapse + // to three segments — "2026.8.1" is a key that does not exist. + EXPECT_EQ(parse_version("2026.8.1.0")->str(), "2026.8.1.0"); +} + +TEST(VersionReq, CaretUpperBoundZeroesTheFourthSegment) { + auto r = parse_req("^2026.7.27.3"); ASSERT_TRUE(r); + EXPECT_TRUE (matches(*r, *parse_version("2026.7.27.3"))); + EXPECT_TRUE (matches(*r, *parse_version("2026.9.1.0"))); + // Upper bound is 2027.0.0.0 — it must not inherit the constraint's ".3". + EXPECT_FALSE(matches(*r, *parse_version("2027.0.0.0"))); + EXPECT_FALSE(matches(*r, *parse_version("2027.0.0.2"))); +} + +TEST(VersionReq, TildeUpperBoundZeroesTheFourthSegment) { + auto r = parse_req("~2026.7.27.3"); ASSERT_TRUE(r); + EXPECT_TRUE (matches(*r, *parse_version("2026.7.99.0"))); + EXPECT_FALSE(matches(*r, *parse_version("2026.8.0.0"))); + EXPECT_FALSE(matches(*r, *parse_version("2026.8.0.5"))); +} + +TEST(VersionReq, ExactMatchDistinguishesSameDayReleases) { + auto r = parse_req("=2026.7.27.1"); ASSERT_TRUE(r); + EXPECT_TRUE (matches(*r, *parse_version("2026.7.27.1"))); + EXPECT_FALSE(matches(*r, *parse_version("2026.7.27.2"))); +} + +TEST(VersionReq, ChooseBestAmongDateVersions) { + std::vector avail = { + *parse_version("2026.7.27.1"), + *parse_version("2026.7.27.10"), + *parse_version("2026.7.27.9"), + }; + auto r = parse_req("*"); ASSERT_TRUE(r); + auto pick = choose(*r, avail); + ASSERT_TRUE(pick); + EXPECT_EQ(avail[*pick].str(), "2026.7.27.10"); +}