From 45d514f00cad355cc223c145f542150dcafe98f4 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 27 Jul 2026 13:57:37 +0800 Subject: [PATCH 1/2] feat: date-based version scheme + xlings pin single source of truth (2026.7.27.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things that had to ship together: mcpp's version format moves to the date form YYYY.M.D.N, and that format broke a comparison that had to be fixed first. Version scheme -------------- `YYYY.M.D.N`, month/day unpadded, matching the xlings ecosystem (xlings migrated from 0.4.70 to 2026.7.27.x the same day). The fourth segment: `.0` is reserved for formal / stable releases; routine iterations start at `.1`. Ordering across the change is monotonic — 0.0.109 < 2026.7.27.1. Fix: four-segment versions were silently truncated ------------------------------------------------- `version_req::Version` was strictly three segments, so parse_version("2026.7.27.1") produced {2026, 7, 27} and dropped the fourth without an error — every release of a given day compared EQUAL. That lands on pm/index_contract.cppm: an index declaring min_mcpp = "2026.7.27.5" against a running 2026.7.27.1 compares equal, `have >= need` holds, and the E0006 floor lets a too-old mcpp through — the single thing that check exists to prevent. Version now carries a fourth component plus `components` (how many segments the source wrote). Two constraints: - ordering compares only the four numbers; if `components` took part, "1.2" would stop equalling "1.2.0". - str() must render the fourth segment, because pm/resolver.cppm returns it as the RESOLVED dependency version, which flows into the lock file and the xlings wire address. A version ending in ".0" collapsed to three segments would address an index key that does not exist. Existing three-segment resolution is byte-identical: the fourth component is 0 on both sides, so ^ / ~ / = are unchanged (regression cases added). xlings pins: one source of truth, machine-checked ------------------------------------------------- All pins move to 2026.7.27.2, and `pinned::kXlingsVersion` becomes the single source of truth that .github/tools/check_version_pins.sh enforces across all 16 pin sites in 7 files — plus mcpp's own version across its 4 locations. This replaces a comment that said to keep three named files in lock-step by hand. That list was ALREADY incomplete: it omitted both composite actions, which is how CI's sandbox sat on 0.4.30 for weeks unnoticed. Building the check surfaced three more pin sites nobody had recorded (release.yml's hardcoded aarch64 xlings tarball literals, which are not interpolated from XLINGS_VERSION) and three stale bootstraps (ci-fresh-install.yml on v0.4.38 twice and v0.4.51). install_pinned_mcpp.sh's version regex matched three segments only, which does not fail on a date version — it truncates both sides identically, so the pin check would silently accept any release of that day. Verified: v2026.7.27.2 exists on both openxlings/xlings and d2learn/xlings with all four platform assets, same naming as 0.4.69, direct GET 200. The aarch64 bundling step is guarded by `if curl`, so a missing asset would degrade silently rather than fail. --- ...7-27-date-version-and-xlings-pin-design.md | 242 ++++++++++++++++++ ...-07-27-date-version-and-xlings-pin-plan.md | 135 ++++++++++ .agents/skills/mcpp-release/SKILL.md | 58 +++-- .github/actions/bootstrap-mcpp/action.yml | 27 +- .github/actions/setup-macos-llvm/action.yml | 2 +- .github/tools/check_version_pins.sh | 124 +++++++++ .github/tools/install_pinned_mcpp.sh | 10 +- .github/workflows/bootstrap-macos.yml | 7 +- .github/workflows/ci-fresh-install.yml | 19 +- .github/workflows/ci-linux-e2e.yml | 2 +- .github/workflows/ci-linux.yml | 9 + .github/workflows/cross-build-test.yml | 11 +- .github/workflows/release.yml | 23 +- .xlings.json | 2 +- CHANGELOG.md | 24 ++ mcpp.toml | 2 +- src/toolchain/fingerprint.cppm | 2 +- src/version_req.cppm | 54 +++- src/xlings.cppm | 14 +- tests/unit/test_index_contract.cpp | 24 ++ tests/unit/test_version_req.cpp | 83 ++++++ 21 files changed, 804 insertions(+), 70 deletions(-) create mode 100644 .agents/docs/2026-07-27-date-version-and-xlings-pin-design.md create mode 100644 .agents/docs/2026-07-27-date-version-and-xlings-pin-plan.md create mode 100755 .github/tools/check_version_pins.sh 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..5cd36e41 --- /dev/null +++ b/.github/tools/check_version_pins.sh @@ -0,0 +1,124 @@ +#!/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: mcpp.toml=$v_toml fingerprint=$v_src .xlings.json=$v_xl MCPP_PIN=$v_pin" + +for pair in "mcpp.toml:$v_toml" \ + "src/toolchain/fingerprint.cppm:$v_src" \ + ".xlings.json:$v_xl" \ + ".github/workflows/ci-fresh-install.yml (MCPP_PIN):$v_pin"; do + where="${pair%:*}"; val="${pair##*:}" + [ -n "$val" ] || { bad "$where — could not read the mcpp version"; continue; } + [ "$val" = "$v_toml" ] || bad "$where has '$val' but mcpp.toml has '$v_toml'" +done + +if [ "$fail" = 0 ]; then + echo "OK: xlings pins all at $XLINGS_EXPECTED; mcpp version $v_toml consistent in 4 places" >&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..c6297a05 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -40,7 +40,7 @@ concurrency: # fails with `version not found` instead of silently testing an older # binary. Bump together with the .xlings.json workspace pin at release. env: - MCPP_PIN: '0.0.109' + MCPP_PIN: '2026.7.27.1' jobs: # ────────────────────────────────────────────────────────────────── @@ -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/.xlings.json b/.xlings.json index 188763d5..e66e75bf 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": "0.0.109" + "mcpp": "2026.7.27.1" } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c7b312e..9486d098 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,30 @@ > 本文件追踪 `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`,不存在回退。 + +### 修复 + +- **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/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/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"); +} From d4dd905d6f4a018db571e3f9b8196b2ebbb984c1 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 27 Jul 2026 14:36:44 +0800 Subject: [PATCH 2/2] fix: stop poisoning the run target's LD_LIBRARY_PATH with the private glibc (#291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also corrects the pin invariant this branch's own guard got wrong, which is what turned every CI job red. #291 — private glibc payload on LD_LIBRARY_PATH ----------------------------------------------- LD_LIBRARY_PATH is inherited by the entire process subtree. When the run target is something that shells out — a course provider calling 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 through GLIBC_PRIVATE (verified: the payload libc.so.6 carries a GLIBC_PRIVATE dependency on ld-linux-x86-64.so.2), 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. This is NOT "compiled on a different build machine, ABI-incompatible" as originally reported — it is plain version mismatch, and it hits every user whose host glibc differs. It survived this long precisely because it does not reproduce when the two happen to match. The payload dir is the one entry on LD_LIBRARY_PATH that is not also in the executable's RUNPATH — flags.cppm excludes it deliberately to keep static and musl links clean. Its only purpose is letting a dlopen()'d library, whose own DT_NEEDED closure never consults the executable's RUNPATH, resolve the same libc. So emit it only when the build actually has such a library (depRuntimeLibraryDirs non-empty). The real host-GL passthrough case reaches mcpp through compat.glx-runtime's `[runtime] library_dirs`, which populates exactly that vector, so it is unaffected; a zero-dependency binary no longer gets it. process.cppm's strip_private_glibc already protects mcpp's own children. It cannot reach one hop further out — mcpp sets the variable for the target on purpose, and what the target spawns is beyond its control. Not emitting it when unnecessary is within its control. e2e 166 pins both directions: a zero-dependency project must NOT receive it, a project with `[runtime] library_dirs` MUST. It asserts on the emitted environment rather than on whether a shell crashes — a crash-based test passes for the wrong reason wherever host and payload glibc match, which is how this went unnoticed. e2e 65 dropped its glibc-payload assertion: its fixture has no dependencies, so it never modelled the dlopen case its own header describes. Pin guard: the bootstrap pin is SUPPOSED to lag ----------------------------------------------- The guard added earlier in this branch asserted that all four version locations are equal. That is wrong, and CI proved it — every job failed with `package 'mcpp@2026.7.27.1' not found`. .xlings.json and MCPP_PIN name a mcpp that is already published, so they must agree with each other but must NOT track the version being built; they are bumped in a separate commit after the release lands in xim-pkgindex (as ci-fresh-install.yml's own comment says). Both are back to 0.0.109. The guard now checks three invariants instead: mcpp.toml == MCPP_VERSION; the two bootstrap sites agree; and the bootstrap pin is never NEWER than the version being built — a four-key numeric sort, so the date scheme orders correctly. That last check reproduces the exact CI failure locally in under a second. --- .github/tools/check_version_pins.sh | 43 ++++++-- .github/workflows/ci-fresh-install.yml | 2 +- .xlings.json | 2 +- CHANGELOG.md | 12 +++ src/build/plan.cppm | 23 ++++- tests/e2e/166_run_env_no_private_glibc.sh | 99 +++++++++++++++++++ .../e2e/65_toolchain_runtime_dirs_for_run.sh | 12 ++- 7 files changed, 179 insertions(+), 14 deletions(-) create mode 100755 tests/e2e/166_run_env_no_private_glibc.sh diff --git a/.github/tools/check_version_pins.sh b/.github/tools/check_version_pins.sh index 5cd36e41..3fdb8a06 100755 --- a/.github/tools/check_version_pins.sh +++ b/.github/tools/check_version_pins.sh @@ -107,18 +107,41 @@ v_xl=$(grep -oE '"mcpp"[[:space:]]*:[[:space:]]*"[^"]+"' .xlings.json \ v_pin=$(grep -oE "MCPP_PIN:[[:space:]]*'[^']+'" .github/workflows/ci-fresh-install.yml \ | grep -oE "'[^']+'" | tr -d "'" | head -1) -note "mcpp version: mcpp.toml=$v_toml fingerprint=$v_src .xlings.json=$v_xl MCPP_PIN=$v_pin" - -for pair in "mcpp.toml:$v_toml" \ - "src/toolchain/fingerprint.cppm:$v_src" \ - ".xlings.json:$v_xl" \ - ".github/workflows/ci-fresh-install.yml (MCPP_PIN):$v_pin"; do - where="${pair%:*}"; val="${pair##*:}" - [ -n "$val" ] || { bad "$where — could not read the mcpp version"; continue; } - [ "$val" = "$v_toml" ] || bad "$where has '$val' but mcpp.toml has '$v_toml'" +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; mcpp version $v_toml consistent in 4 places" >&2 + echo "OK: xlings pins all at $XLINGS_EXPECTED; building mcpp $v_toml, bootstrapping from $v_xl" >&2 fi exit "$fail" diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index c6297a05..78683b5a 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -40,7 +40,7 @@ concurrency: # fails with `version not found` instead of silently testing an older # binary. Bump together with the .xlings.json workspace pin at release. env: - MCPP_PIN: '2026.7.27.1' + MCPP_PIN: '0.0.109' jobs: # ────────────────────────────────────────────────────────────────── diff --git a/.xlings.json b/.xlings.json index e66e75bf..188763d5 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": "2026.7.27.1" + "mcpp": "0.0.109" } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9486d098..37febf58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,18 @@ ### 修复 +- **[#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 去读新索引,得到的是描述符读取返回空这类难以归因的次生故障 —— 而挡住这种情况正是该检查存在的唯一理由。 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/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