From 29dc6adff42865d0c232ea5b4a3dec463f9944b8 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 14 Nov 2026 22:21:18 +0900 Subject: [PATCH 1/4] 2026.11.14.1: the SDK a link names, and a release that publishes without GitCode A build program reads MCPP_TARGET_SDK, the Apple SDK the link names with -isysroot (empty when it names none), from the same function as the link line. A package that supplies a libSystem stub for a link without an SDK asks this instead of MCPP_TARGET_REQUESTED, which on a Mac with --target said the SDK was absent while the link carried it. The release mirrors GitHub, then opens the xim-pkgindex pull request from the sealed mcpp-release.json (index_pr.py, with the updater's marker), and mirrors GitCode last and only when dispatched with `gitcode`. bump_index.sh is removed. Design: .agents/docs/2026-11-14-release-publication-and-macos-sdk-design.md --- ...elease-publication-and-macos-sdk-design.md | 132 +++++++++++++ .agents/docs/README.md | 7 +- .github/tools/bump_index.sh | 75 -------- .github/tools/index_pr.py | 173 ++++++++++++++++++ .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 99 +++++----- CHANGELOG.md | 26 +++ docs/30-build-mcpp.md | 1 + docs/92-release.md | 43 +++-- docs/zh/30-build-mcpp.md | 1 + docs/zh/92-release.md | 32 +++- mcpp.toml | 2 +- modules/versioning/src/version.cppm | 2 +- src/build/build_program.cppm | 31 ++-- src/build/flags.cppm | 32 +++- src/build/hostprogram.cppm | 6 + src/build/prepare/toolchain_env.cpp | 1 + tests/scripts/test_index_pr.py | 129 +++++++++++++ tests/unit/test_link_line_golden.cpp | 22 +++ 19 files changed, 642 insertions(+), 175 deletions(-) create mode 100644 .agents/docs/2026-11-14-release-publication-and-macos-sdk-design.md delete mode 100755 .github/tools/bump_index.sh create mode 100644 .github/tools/index_pr.py create mode 100644 tests/scripts/test_index_pr.py diff --git a/.agents/docs/2026-11-14-release-publication-and-macos-sdk-design.md b/.agents/docs/2026-11-14-release-publication-and-macos-sdk-design.md new file mode 100644 index 000000000..f29241c0a --- /dev/null +++ b/.agents/docs/2026-11-14-release-publication-and-macos-sdk-design.md @@ -0,0 +1,132 @@ +--- +subject: release +status: active +--- + +# 发布的发布面与 macOS 链接的 SDK 契约(2026-11-14 补充) + +**日期:** 2026-11-14 +**状态:** 第 1 节(B1–B4)与第 2 节的 M1 在 mcpp 2026.11.14.1 实施;M2、M3 在 openkal-macos 的补丁版实施,待负责人审阅。 + +本文补充 `2026-11-14-ci-and-release-time-cost-design.md`,处理 v2026.11.13.1 发布中测到的两件事:一是 `publish-ecosystem` +的耗时与它对 xim-pkgindex 自动更新器的依赖;二是 canary `cxx aarch64-macos test` 揭示的 mcpp 与 openkal-macos 之间关于 +macOS SDK 的契约矛盾。两件事互不依赖,各自一个 PR(mcpp 一个,openkal-macos 一个)。 + +## 1. 发布面:GitCode 同步默认关闭,索引 PR 直接创建 + +### 1.1 测量(v2026.11.13.1,运行 43594022005) + +| 环节 | 用时 | +|---|---| +| 四个构建并行,至 `create-release` | 40 分钟 | +| 封存(`release-manifest`) | 约 1 分钟 | +| GitHub 镜像(`xlings-res/mcpp`,8 个资源) | 64 s,每个资源 1 s | +| GitCode 镜像(同 8 个资源) | 713 s;四个归档各约 604 s(10–17 MB),`.sha256` 各 4–7 s | +| 索引 PR(`bump_index.sh`) | 向 xim-pkgindex 发 `repository_dispatch`,等待其更新器开出 PR,上限 1200 s | + +GitCode 一侧的瓶颈是 GitHub 美国 runner 到 `file.gitcode.com` 的上行(`mirror_res.sh` 的测量:0.012 MB/s;中国大陆主机为 +1.84 MB/s)。同一批资源由负责人在本地以 gtc 上传、取回逐字节比对,用时以分钟计。 + +`bump_index.sh` 不写配方,它请求 xim-pkgindex 的更新器(`xpkgindex-update.yml`)提出 PR;该更新器要求 GLOBAL 与 CN 两处 +资源都已存在才提出。因此索引 PR 被串在 GitCode 镜像之后,GitCode 慢或失败时索引 PR 也不出现。 + +### 1.2 方案 + +**B1. GitCode 镜像是一项可选择的步骤,默认不运行。** + +- `release.yml` 的 `workflow_dispatch` 增加布尔输入 `gitcode`(缺省 `false`)。`publish-ecosystem` 的镜像分为两步: + GitHub 一步以 `MIRROR_HOSTS=gh` 调用 `mirror_res.sh`,总是运行;GitCode 一步以 `MIRROR_HOSTS=gtc` 调用,只在 + `WITH_GITCODE`(`github.event_name == 'workflow_dispatch' && inputs.gitcode`)为真时运行。tag 推送没有输入, + 只镜像 GitHub。`mirror_res.sh` 已支持 `MIRROR_HOSTS=both|gh|gtc`,脚本本身不改。 +- 步骤的 `timeout-minutes`:GitHub 一步 10 分钟,GitCode 一步 45 分钟(脚本自身的 GitCode 期限为 2400 s)。 +- 本地补齐 GitCode 的做法写入 `docs/92-release.md` §2:`MIRROR_HOSTS=gtc bash .github/tools/mirror_res.sh mcpp ` + (需要 `GITCODE_TOKEN` 与 PATH 上的 gtc),随后逐个资源从 GitCode 取回并与 GitHub 的字节比对。检查清单中"两处镜像都提供 + 四个平台"一项保留,执行者由 CI 变为负责人。 + +**B2. 索引 PR 由发布直接创建,不经 xim-pkgindex 的更新器。** + +- 新脚本 `.github/tools/index_pr.py --manifest --gitcode pending|absent`(替换 `bump_index.sh`, + 后者删除): + 1. 读本次运行封存的 `mcpp-release.json`(`release-manifest` 作业上传的 artifact `mcpp-release-manifest`),取版本与四个 + 平台归档的 sha256;不重新下载、不重新计算,索引写入的摘要即封存的摘要。tag 重建时版本亦取自清单而非 `mcpp.toml`。 + 2. 在 xim-pkgindex `main` 的 `pkgs/m/mcpp.lua` 中,向 `linux`、`macosx`、`windows` 三张表各追加该版本的条目(GLOBAL 为 + `github.com/xlings-res/mcpp/releases/download//…`,CN 为 `gitcode.com/xlings-res/mcpp/releases/download//…`), + 并把三处 `latest` 指向它。条目的形状与更新器写出的相同(见 xim-pkgindex#1149)。 + 3. 以 `XIM_PKGINDEX_TOKEN`(Contents 与 Pull requests 权限;`gh auth setup-git` 供推送认证)推送分支 + `auto/bump-xim-mcpp`,开 PR。PR 正文以更新器的标记 + `` 开头:更新器按该标记识别已有提案,不再重复提出。 + 同一分支上若有较早版本的打开 PR,改写其标题与正文,不另开。 + 4. PR 正文写明 GitCode 一侧的状态:`pending`(本次运行随后镜像 GitCode,作业绿后合入)或 `absent`(未由本次运行镜像, + 以 `MIRROR_HOSTS=gtc` 补齐后合入)。 +- 已存在同一标记的 PR(打开或已合入)时脚本不做任何事;配方已含该版本时同样不做任何事。重复运行无副作用。 +- 改写的是配方的版本表,不改配方逻辑。由此配方多了一个写入者(mcpp 的发布),这是有意的取舍:发布不再等待另一仓库的 + 调度,代价是条目形状须与更新器一致,由 xim-pkgindex 自身的 CI(配方解析、L3/L4 安装检查)把关。更新器继续服务其余包。 + +**B3. 次序与时长。** `publish-ecosystem` 变为:GitHub 镜像(约 1 分钟)→ 索引 PR(秒级)→ 可选的 GitCode 镜像(放在最后, +失败不影响前两步的结果,但仍使作业为红)。发布从分派到索引 PR 预计约 42 分钟。负责人在本地补齐 GitCode 之后合入索引 PR, +随后 `ci-fresh-install` 与真实安装验证照旧。 + +**B4. 验证。** `tests/scripts/test_index_pr.py` 离线运行:以固定的 `mcpp.lua` 片段与封存清单为输入,断言追加的三处条目、 +`latest` 的移动、标记行与幂等;并读 `release.yml`,断言 `gitcode` 输入缺省为假、三步的次序、只有 GitCode 一步调用 gtc +且受 `WITH_GITCODE` 约束、索引一步读封存清单。另以 v2026.11.13.1 的真实清单对 #1155 之前的 `mcpp.lua` 执行 +`--edit`,结果与手工合入的 xim-pkgindex#1155 逐字节相同。 + +## 2. macOS 链接的 SDK:mcpp 与 openkal-macos 的契约 + +### 2.1 现象 + +canary `cxx aarch64-macos test`(macOS runner,`mcpp test --target aarch64-macos`)自加入起 9 次运行全部链接失败: +`ld64.lld: error: undefined symbol: strlen`(以及 `memcpy`、`pthread_create` 等)。测试程序经 mtest → mcpplibs.os 依赖 +openkal-macos;同一行的 build、run 两格不依赖 openkal-macos,通过。 + +### 2.2 原因:两处陈述自 2026-08-25 起矛盾 + +| 时间 | 变化 | +|---|---| +| 2026-06-24(mcpp#162) | macOS 的每个链接带 `-isysroot `,不依赖主机开发工具的状态 | +| 2026-08-24(mcpp#486) | `build_program.cppm` 对构建程序陈述:Mac 上 `--target aarch64-macos` 的目标侧由图供给,mcpp 不把系统 SDK 放上链接。openkal-macos 据此在 `MCPP_TARGET_REQUESTED` 非空时把 `port/`(只列三个名字的 `libSystem.tbd`)加入链接搜索路径 | +| 2026-08-25(d35c0436) | 图供给 C 库时 `-isysroot` 同样保留(`flags.cppm` "AND KEPT")。上一行的陈述自此不成立 | + +`-L port` 先于 SDK 被搜索,三个名字的存根遮蔽了 SDK 的完整 `libSystem`。缺陷不由 2026.11.13.1 引入,自 2026.8.25.2 起 +潜伏:mcpp 自己的 macOS 作业是原生构建(不写 `--target`,openkal-macos 不加存根),canary 是第一个在 Mac 上以 `--target` +构建依赖 openkal-macos 的程序的检查。 + +影响面:在 Mac 上以 `--target aarch64-macos`、`--target x86_64-macos`,或以含 macOS 成员的目标集合,构建任何依赖 +openkal-macos 的项目(mcpplibs.os、mtest 的使用者)。 + +### 2.3 方案 + +openkal-macos 要回答的问题是"链接中有没有系统自己的 `libSystem`",而不是"是否指定了目标"。这个事实由 mcpp 决定, +因此由 mcpp 陈述。 + +**M1(mcpp):构建程序得到链接所用的 SDK。** +- 新变量 `MCPP_TARGET_SDK`:链接以 `-isysroot` 指名的 SDK 目录;链接不指名 SDK 时为空。与其余变量一样总是给出,空即不适用。 +- 取值与 `flags.cppm` 的链接参数出自同一个函数:把 `flags.cppm` 中选择 `macos_sdk` 的三支(Apple 交叉行的 SDK、以 + `AppleIsysroot` 陈述的基座、`apple::sdk_path()`)提为 `mcpp::toolchain::apple::link_sdk(...)`,链接参数与构建程序的环境 + 各读一次,二者不会再分歧。 +- `hostprogram.cppm` 增加访问器 `target_sdk()`;`build_program.cppm` 中关于"`--target` 时不放 SDK"的陈述改为现行行为。 +- 变量进入构建程序的重跑键(`contract_hash`),SDK 改变时构建程序重新运行。 +- 文档:`docs/30-build-mcpp.md` 的变量表(中英)。测试:单元测试断言 macOS 链接参数中的 `-isysroot` 与 `MCPP_TARGET_SDK` + 相同、非 Apple 目标为空。 + +**M2(openkal-macos):存根只在链接中没有系统的存根时提供。** +- `build.mcpp` 的条件改为:指定了目标(`MCPP_TARGET_REQUESTED` 非空),且 `MCPP_TARGET_SDK` 为空。典型情形是从 Linux 交叉 + 到 macOS。原生构建、Mac 上的 `--target` 由 SDK 回答,不再加存根。 +- 兼容:不认识 `MCPP_TARGET_SDK` 的旧 mcpp 不给出它(读为空),条件退化为现行条件,行为不变。新 mcpp 配旧 openkal-macos + 的组合仍是现行行为。 +- 文件头的说明随之改写:判断依据是链接中是否已有系统的存根,由 mcpp 陈述。 +- 以补丁版发布(0.23.x 的下一个)。mcpplibs.os 的依赖范围为 `>=0.22.0, <0.24.0`,使用者解析时自动取到,不需要其余包改动。 + +**M3(验证)。** +- openkal-macos 的 CI:macOS 上以 `--target aarch64-macos` 构建并运行其一致性测试(此前只有原生构建);Linux 上交叉到 + `aarch64-macos` 的链接仍取存根并成功。 +- mcpp 的 canary:`cxx aarch64-macos test` 在新的 openkal-macos 发布后通过,canary 报告该格与矩阵不符,矩阵改回 `ok`。 + +### 2.4 不采纳 + +- **mcpp 把 SDK 的库目录排在包的 `link_search` 之前。** 这改变 `-L` 的一般语义(包陈述的搜索路径先于系统),为一个包的 + 问题改所有包的规则。 +- **openkal-macos 以主机系统判断。** 其文件头记录了以主机判断的失败(2026-08-23):判断应依据链接的事实,而该事实只有 + mcpp 知道。 +- **openkal-macos 自行探测 SDK(`xcrun --show-sdk-path`)。** 它重复 mcpp 的判断,且与 mcpp 实际所用的 SDK(`SDKROOT`、 + 基座陈述)可能不同。 diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 1fd4bdf2c..c1290c3a4 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded --- ``` -361 records. +362 records. ## By subject @@ -144,6 +144,10 @@ Records that declare one. Everything else is listed by date below. - [The category the plugin taxonomy does not name, and what a platform actually decomposes into](2026-09-11-distribution-plugins-and-platform-decomposition.md) — active +### release + +- [发布的发布面与 macOS 链接的 SDK 契约(2026-11-14 补充)](2026-11-14-release-publication-and-macos-sdk-design.md) — active + ### review - [Two days of mcpp and xlings: a review of what merged, what is known, and what is open](2026-09-28-ecosystem-review-of-two-days-of-mcpp-and-xlings.md) — active @@ -197,6 +201,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-11 +- [发布的发布面与 macOS 链接的 SDK 契约(2026-11-14 补充)](2026-11-14-release-publication-and-macos-sdk-design.md) — active - [CI 与发布的时间成本:按测量调度的测试、分片、并行发布](2026-11-14-ci-and-release-time-cost-design.md) — active - [The three-plane platform architecture: mcpp × openkal × xim](2026-11-12-three-plane-platform-architecture-design.md) — superseded by [2026-11-12-ecosystem-architecture-optimization-design.md](2026-11-12-ecosystem-architecture-optimization-design.md) - [生态架构优化方案:四个平面的收敛、已合入项的纠正与新系统的准入(mcpp × openkal × xlings)](2026-11-12-ecosystem-architecture-optimization-design.md) — active diff --git a/.github/tools/bump_index.sh b/.github/tools/bump_index.sh deleted file mode 100755 index 75ea2253d..000000000 --- a/.github/tools/bump_index.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash -# Ask openxlings/xim-pkgindex to propose , and wait for the PR. -# -# The index edits its own recipes. Since openxlings/xim-pkgindex#1027 one -# updater does it for every package (`xpkgindex-update.yml`, pinned -# openxlings/xpkgindex-update): it discovers the release from the recipe's -# `extensions.autoupdate.source` (xlings-res/ releases), requires the -# GLOBAL and CN resources, computes each sha256 and opens a PR whose body -# starts with `` -# (its branch, `auto/bump-xim-`, does not name the version, so the -# marker is what identifies the proposal). The script that used to do this here, -# `.github/scripts/version-check.py`, was removed from the index by that -# change; 2026.10.24.1 is the release that found out (publish-ecosystem -# failed on the missing file). Asking the index's own updater keeps one writer -# of the recipe. -# -# This opens a PR only -- a maintainer reviews and merges it. It runs after -# the binaries are mirrored, because the updater requires both hosts. -# -# The request is a repository_dispatch of type `xpkgindex-update` naming -# `xim:`; the index's xpkgindex-update-request.yml passes it on to the -# updater as a workflow_dispatch with the index's own token. Starting the -# updater from here would be a workflow_dispatch, which needs Actions: write; -# PKGINDEX_TOKEN holds Contents and Pull requests (measured 2026-10-28: -# repository_dispatch 204, workflow_dispatch 403). -# -# Usage: bump_index.sh # e.g. mcpp 2026.10.24.1 -# Auth: -# PKGINDEX_TOKEN (required) -- a token with Contents: write on -# openxlings/xim-pkgindex -set -euo pipefail - -PROJ="${1:?usage: bump_index.sh }" -VER="${2:?usage: bump_index.sh }" -INDEX_REPO="${INDEX_REPO:-openxlings/xim-pkgindex}" -export MARKER="" -WAIT_S="${BUMP_WAIT_S:-1200}" -export GH_TOKEN="${PKGINDEX_TOKEN:?PKGINDEX_TOKEN required (dispatch on $INDEX_REPO)}" - -info() { echo "[bump] $*"; } - -# The updater's PR for this version, open or merged. -pr_url() { - gh pr list -R "$INDEX_REPO" --state all --limit 50 --json url,body,state \ - --jq '[.[] | select(.state != "CLOSED" and (.body | startswith(env.MARKER)))][0].url // empty' \ - 2>/dev/null || true -} - -if url="$(pr_url)" && [[ -n "$url" ]]; then - info "PR already proposed: $url" - exit 0 -fi - -info "requesting the update proposal of xim:$PROJ" -if ! gh api -X POST "repos/$INDEX_REPO/dispatches" \ - -f event_type=xpkgindex-update -f "client_payload[package]=xim:$PROJ" > /dev/null; then - echo "[bump] PKGINDEX_TOKEN cannot send a repository_dispatch to $INDEX_REPO (it needs Contents: write);" \ - "a maintainer runs: gh workflow run xpkgindex-update.yml -R $INDEX_REPO --ref main -f package=xim:$PROJ" >&2 - exit 1 -fi - -deadline=$(( $(date +%s) + WAIT_S )) -while (( $(date +%s) < deadline )); do - sleep 30 - if url="$(pr_url)" && [[ -n "$url" ]]; then - info "PR opened: $url" - exit 0 - fi -done - -# BLOCKING: without the PR the index never learns the release exists. The -# updater's run says why (a resource missing on one host, a policy refusal). -echo "[bump] no PR for xim:$PROJ $VER after ${WAIT_S}s; see" \ - "https://github.com/$INDEX_REPO/actions/workflows/xpkgindex-update.yml" >&2 -exit 1 diff --git a/.github/tools/index_pr.py b/.github/tools/index_pr.py new file mode 100644 index 000000000..56b462a65 --- /dev/null +++ b/.github/tools/index_pr.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""THE INDEX ENTRY OF A RELEASE, PROPOSED DIRECTLY (design 2026-11-14 +release-publication §1.2 B2). + + index_pr.py --manifest mcpp-release.json [--gitcode pending|absent] [--dry-run] + index_pr.py --manifest mcpp-release.json --edit # edit a file in place, no PR + +Reads the sealed release manifest (`mcpp-release.json`: version, and each +platform archive with its sha256), appends the version to the recipe's +`linux`, `macosx` and `windows` tables of openxlings/xim-pkgindex +`pkgs/

/.lua` -- the GLOBAL url on github.com/xlings-res, the CN url +on gitcode.com/xlings-res -- moves each table's `latest` to it, and opens a pull +request from the branch `auto/bump-xim-`. + +The digests are the sealed manifest's: nothing is downloaded or computed +again. The pull request body starts with the index updater's marker +(`xpkgindex-update`), by which that updater knows the proposal exists and makes +none of its own. A pull request with the marker, open or merged, ends the +script with nothing done, so a run started again changes nothing. It used to +ask the updater to propose (`bump_index.sh`), which requires both mirrors and +so waited on the GitCode upload; v2026.11.13.1's updater run failed. + +Auth: PKGINDEX_TOKEN (Contents and Pull requests on openxlings/xim-pkgindex). +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +INDEX_REPO = os.environ.get("INDEX_REPO", "openxlings/xim-pkgindex") +RES_OWNER = "xlings-res" +# The manifest's platform and architecture words, as the recipe's tables name them. +TABLE = {"linux": "linux", "macos": "macosx", "windows": "windows"} +ARCH = {"arm64": "aarch64"} + + +def entries(manifest: dict) -> dict[str, list[tuple[str, str, str]]]: + """Each recipe table's (arch, asset, sha256), in the manifest's order.""" + out: dict[str, list[tuple[str, str, str]]] = {} + for a in manifest["assets"]: + table = TABLE[a["platform"]] + out.setdefault(table, []).append((ARCH.get(a["arch"], a["arch"]), a["name"], a["sha256"])) + return out + + +def version_block(project: str, version: str, rows: list[tuple[str, str, str]]) -> str: + lines = [f' ["{version}"] = {{'] + for arch, asset, sha in rows: + lines += [ + f" {arch} = {{", + " url = {", + f' GLOBAL = "https://github.com/{RES_OWNER}/{project}/releases/download/{version}/{asset}",', + f' CN = "https://gitcode.com/{RES_OWNER}/{project}/releases/download/{version}/{asset}",', + " },", + f' sha256 = "{sha}",', + " },", + ] + lines.append(" },") + return "\n".join(lines) + "\n" + + +def edit(text: str, project: str, version: str, tables: dict[str, list[tuple[str, str, str]]]) -> str: + """The recipe with the version appended to each table and `latest` moved to it. + A table that already holds the version is left as it is.""" + for table, rows in tables.items(): + start = re.search(rf"^ {table} = \{{\n", text, re.M) + if not start: + raise SystemExit(f"index_pr: the recipe has no `{table}` table") + end = text.index("\n },\n", start.end()) + 1 + body = text[start.end():end] + if f'["{version}"] = {{' in body: + continue + body = re.sub(r'\["latest"\] = \{ ref = "[^"]*" \}', f'["latest"] = {{ ref = "{version}" }}', body, count=1) + body += version_block(project, version, rows) + text = text[:start.end()] + body + text[end:] + return text + + +def marker(project: str, version: str) -> str: + return f'' + + +def sh(*args: str, cwd: Path | None = None, capture: bool = False) -> str: + r = subprocess.run(args, cwd=cwd, check=True, text=True, + stdout=subprocess.PIPE if capture else None) + return r.stdout or "" + + +def existing_pr(project: str, version: str) -> str: + out = sh("gh", "pr", "list", "-R", INDEX_REPO, "--state", "all", "--limit", "50", + "--json", "url,body,state", capture=True) + for pr in json.loads(out): + if pr["state"] != "CLOSED" and pr["body"].startswith(marker(project, version)): + return pr["url"] + return "" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--manifest", required=True, type=Path) + ap.add_argument("--project", default="mcpp") + # `pending`: the release run mirrors GitCode after this pull request. + ap.add_argument("--gitcode", choices=["pending", "absent"], default="absent") + ap.add_argument("--edit", type=Path, help="edit this recipe in place and open no pull request") + ap.add_argument("--dry-run", action="store_true") + a = ap.parse_args() + + manifest = json.loads(a.manifest.read_text()) + version, tables = manifest["version"], entries(manifest) + if a.edit: + a.edit.write_text(edit(a.edit.read_text(), a.project, version, tables)) + return 0 + + if url := existing_pr(a.project, version): + print(f"[index] proposed already: {url}") + return 0 + recipe = f"pkgs/{a.project[0]}/{a.project}.lua" + branch = f"auto/bump-xim-{a.project}" + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) / "index" + sh("gh", "repo", "clone", INDEX_REPO, str(repo), "--", "--depth", "1", "--quiet") + path = repo / recipe + path.write_text(edit(path.read_text(), a.project, version, tables)) + if not sh("git", "status", "--porcelain", cwd=repo, capture=True).strip(): + print(f"[index] {recipe} holds {version} already") + return 0 + title = f"{a.project} {version}; latest" + cn = ("The release run mirrors GitCode after this pull request: merge once its " + "`publish-ecosystem` job is green." if a.gitcode == "pending" else + "The release run did not mirror GitCode (the `gitcode` input): the CN urls are " + "served once the archives are uploaded there (`MIRROR_HOSTS=gtc .github/tools/mirror_res.sh`). " + "Merge after that.") + body = "\n".join([ + marker(a.project, version), + f"Append `xim:{a.project}@{version}` to the linux, macosx and windows tables and move " + "`latest` to it. Existing versions are unchanged.", + "", + f"The digests are those of the sealed release manifest (`mcpp-release.json` of " + f"{manifest.get('tag', version)}, commit {manifest.get('commit', '?')}).", + "", + cn, + ]) + if a.dry_run: + print(sh("git", "diff", cwd=repo, capture=True)) + print(body) + return 0 + sh("git", "switch", "-c", branch, cwd=repo) + sh("git", "-c", "user.name=mcpp-release", "-c", "user.email=mcpp-release@users.noreply.github.com", + "commit", "-q", "-am", title, cwd=repo) + sh("git", "push", "-q", "-f", "origin", branch, cwd=repo) + # An open pull request of an earlier version from the same branch now + # carries this commit: it is retitled, not duplicated. + stale = json.loads(sh("gh", "pr", "list", "-R", INDEX_REPO, "--head", branch, "--state", "open", + "--json", "url", capture=True)) + if stale: + url = stale[0]["url"] + sh("gh", "pr", "edit", url, "--title", title, "--body", body) + print(f"[index] updated {url}") + else: + url = sh("gh", "pr", "create", "-R", INDEX_REPO, "--base", "main", "--head", branch, + "--title", title, "--body", body, capture=True).strip() + print(f"[index] opened {url}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85d610a66..f44e1b5eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -199,6 +199,9 @@ jobs: - name: The release canary runner runs each command under the named bash run: python3 tests/scripts/test_release_canaries.py + - name: The release proposes its index entry from the sealed manifest, GitCode last + run: python3 tests/scripts/test_index_pr.py + - name: The protocol table of SPEC-007 names the engine's protocol run: python3 tests/scripts/test_protocol_table.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 341fede34..4c7c7c1c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,10 @@ on: description: 'build the ecosystem canaries (xlings, mcppls) with the candidate before the release is created' type: boolean default: false + gitcode: + description: 'also mirror the archives to GitCode (about 10 minutes per archive from a GitHub runner); off, the owner mirrors GitCode locally (docs/92-release.md)' + type: boolean + default: false # THE BUILDS RUN AT ONCE, AND ONE JOB CREATES THE RELEASE (design 2026-11-14). # The four platform builds start together and keep their archives as @@ -971,81 +975,74 @@ jobs: overwrite: true # A rerun has already proved the public bytes identical. retention-days: 30 - # Publish this release into the xlings ecosystem, after ALL platform builds - # have uploaded their assets and the immutable manifest gate has passed: - # ① mirror binaries → xlings-res/mcpp (GitHub + GitCode) so XLINGS_RES - # downloads resolve on every platform (incl. the CN/GitCode path); - # ② open a PR against openxlings/xim-pkgindex bumping mcpp to this version - # (a maintainer merges it — index git source is not on the critical path). - # These publication steps are required for ecosystem completeness: their - # failure leaves the GitHub Release object available but keeps this workflow - # red, so post-release verification cannot report a complete release. + # PUBLISH THE RELEASE INTO THE XLINGS ECOSYSTEM (design 2026-11-14 + # release-publication, B1-B3), after every platform archive is in the release + # and the manifest is sealed: + # 1. mirror the archives to xlings-res/mcpp on GitHub (about a minute); + # 2. open the index pull request on openxlings/xim-pkgindex from the sealed + # manifest (`index_pr.py`; seconds, no other repository's workflow); + # 3. mirror GitCode, only when the run is dispatched with `gitcode`. A + # GitHub runner uploads there at about 0.012 MB/s: v2026.11.13.1 spent + # 713 s on it, 604 s per archive. Without it the owner mirrors GitCode + # locally and merges the index pull request after that (docs/92-release.md). + # A failed step keeps the workflow red; the steps before it stand. # Shared vendored scripts live in .github/tools/ (kept in sync with xlings). publish-ecosystem: needs: release-manifest if: ${{ !cancelled() && needs.release-manifest.result == 'success' }} runs-on: ubuntu-latest - # A4 hardening: a single stuck upload once held this job >1h (6h default - # ceiling). The mirror script has per-file timeouts and (post-0.0.89) - # batch-upload + ranged-GET verification — normal runs are minutes; 30 - # is the generous backstop (20 was hit by the old per-asset verify loop). - timeout-minutes: 30 + timeout-minutes: 60 env: XLINGS_RES_TOKEN: ${{ secrets.XLINGS_RES_TOKEN }} GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }} XIM_PKGINDEX_TOKEN: ${{ secrets.XIM_PKGINDEX_TOKEN }} + # A tag push has no inputs: `inputs.gitcode` is empty and GitCode is not mirrored. + WITH_GITCODE: ${{ github.event_name == 'workflow_dispatch' && inputs.gitcode }} steps: - name: Checkout code uses: actions/checkout@v4 + - name: The sealed manifest + uses: actions/download-artifact@v4 + with: + name: mcpp-release-manifest + path: sealed - name: Determine version id: version - run: echo "version=$(awk -F '\"' '/^version[[:space:]]*=/{print $2; exit}' mcpp.toml)" >> "$GITHUB_OUTPUT" + run: echo "version=$(python3 -c 'import json; print(json.load(open("sealed/mcpp-release.json"))["version"])')" >> "$GITHUB_OUTPUT" - - name: Mirror binaries to xlings-res/mcpp (gh + gtc) + - name: Mirror the archives to xlings-res/mcpp on GitHub if: ${{ env.XLINGS_RES_TOKEN != '' }} - # THIS MUST BE LONGER THAN THE SCRIPT'S OWN LEG DEADLINE, AND FOR - # A LONG TIME IT WAS SHORTER. - # - # `mirror_res.sh` gives GitCode `MIRROR_LEG_DEADLINE_GTC=2400s` because - # that host shapes inbound traffic; this step killed it at 600s, so the - # script's budget could never be spent and the shorter of two - # disagreeing limits always won. Every release since has ended the same - # way — `The action … has timed out after 10 minutes`, GitHub's eight - # assets uploaded in seconds and GitCode's three large ones cut off, - # with only the small `.sha256` files through. v2026.8.25.1 and - # v2026.8.25.2 both, measured. - # - # The paragraph this replaces described a design that no longer - # exists: a per-asset `MIRROR_UPLOAD_TIMEOUT` of 180s. The script's own - # comment records why it went ("the old per-asset cap failed four - # releases in a row") — but this value, calibrated to it, stayed. - # - # 45 > 40 leaves the script room to reach its own deadline and report - # what it abandoned by name, which is the visibility the value exists - # for; the job's `timeout-minutes: 30` above is the outer backstop. - timeout-minutes: 45 + timeout-minutes: 10 env: GH_TOKEN: ${{ secrets.XLINGS_RES_TOKEN }} + MIRROR_HOSTS: gh run: | - chmod +x .github/tools/gtc .github/tools/mirror_res.sh - export PATH="$PWD/.github/tools:$PATH" - # A4: BLOCKING. Both mirror hosts serve users (GLOBAL + CN install - # paths); an incomplete mirror must fail here, visibly, instead of - # surfacing as a 404 in the first user's install (or fresh-install CI). + chmod +x .github/tools/mirror_res.sh bash .github/tools/mirror_res.sh mcpp "${{ steps.version.outputs.version }}" - - name: Open index bump PR (xim-pkgindex) + - name: Open the index pull request (xim-pkgindex) if: ${{ env.XIM_PKGINDEX_TOKEN != '' }} + timeout-minutes: 5 + env: + GH_TOKEN: ${{ secrets.XIM_PKGINDEX_TOKEN }} + run: | + # The push of the index branch authenticates through gh. + gh auth setup-git + python3 .github/tools/index_pr.py --manifest sealed/mcpp-release.json \ + --gitcode "${{ env.WITH_GITCODE == 'true' && 'pending' || 'absent' }}" + + - name: Mirror the archives to GitCode + if: ${{ env.XLINGS_RES_TOKEN != '' && env.WITH_GITCODE == 'true' }} + # Longer than the script's own GitCode deadline (MIRROR_LEG_DEADLINE_GTC, + # 2400 s), so the script reaches it and names what it abandoned. + timeout-minutes: 45 env: - PKGINDEX_TOKEN: ${{ secrets.XIM_PKGINDEX_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.XLINGS_RES_TOKEN }} + MIRROR_HOSTS: gtc run: | - chmod +x .github/tools/bump_index.sh - # A4: BLOCKING — a missing bump PR means the index never learns the - # release exists and every post-release install of the new version 404s. - # The index's own updater writes the PR (bump_index.sh says why); - # it needs the GitCode mirror above to be complete. - bash .github/tools/bump_index.sh mcpp "${{ steps.version.outputs.version }}" + chmod +x .github/tools/gtc .github/tools/mirror_res.sh + export PATH="$PWD/.github/tools:$PATH" + bash .github/tools/mirror_res.sh mcpp "${{ steps.version.outputs.version }}" # Post-release verification (ci-fresh-install) is triggered via its # `workflow_run: [release]` hook — a platform-generated event that is diff --git a/CHANGELOG.md b/CHANGELOG.md index 096480048..f5d1b392a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ > Each `## []` section is that release's notes. Entries are written in English > from 2026.9.28.3 on; earlier entries remain as written. +## [2026.11.14.1] - 2026-11-14 + +### Added + +- A build program reads `MCPP_TARGET_SDK`: the Apple SDK directory the link + names with `-isysroot`, empty when the link names none (`target_sdk()` in + `mcpp.hostprogram`). The link line and the variable are read from one + function, so they name the same SDK. A package that supplies its own + `libSystem` stub for a link without an SDK (a cross link to macOS from another + host) now has the fact it needs; it used to infer it from + `MCPP_TARGET_REQUESTED`, which on a Mac with `--target aarch64-macos` said the + SDK was absent while the link carried it. + +### Changed + +- The release publishes in the order GitHub mirror, index pull request, then + GitCode, and mirrors GitCode only when started with `gitcode` + (`gh workflow run release.yml -f gitcode=true`). A GitHub runner uploaded each + archive of v2026.11.13.1 to GitCode in about 604 s; the owner mirrors GitCode + locally with `MIRROR_HOSTS=gtc .github/tools/mirror_res.sh` (docs/92-release.md). +- The release opens the xim-pkgindex pull request itself + (`.github/tools/index_pr.py`) from the sealed `mcpp-release.json`, with the + index updater's marker, instead of asking the updater to propose it; the + updater waited for both mirrors, and its run for v2026.11.13.1 failed. + `bump_index.sh` is removed. + ## [2026.11.13.1] - 2026-11-13 ### Added diff --git a/docs/30-build-mcpp.md b/docs/30-build-mcpp.md index 43a32170c..c0e971531 100644 --- a/docs/30-build-mcpp.md +++ b/docs/30-build-mcpp.md @@ -1349,6 +1349,7 @@ The running program receives the build context as `MCPP_*` variables | `MCPP_KERNEL_ABI_INTERFACES` *(2026.11.10.1+)* | `mcpp::kernel_abi_interfaces()` | for a package that states `requires = ["mcpp:kernel-abi=..."]`: the `provides-interfaces` of the resolved kernel-abi provider, joined by `;` (`openkal.fs;openkal.process;...`). Always set; empty when the provider states no list or the package states no such requirement, which a program reads as "unknown" and answers by binding every interface | | `MCPP_HOST` | `mcpp::host()` | the host triple | | `MCPP_PROFILE` | `mcpp::profile()` | effective profile name (`dev`/`release`/…) | +| `MCPP_TARGET_SDK` *(2026.11.14.1+)* | `mcpp::target_sdk()` | the SDK the link names with `-isysroot` (a Mach-O link on a macOS host); empty when it names none. A package that supplies a stub of a system library supplies it only where this is empty: elsewhere the SDK's complete library is on the link | | `MCPP_TOOLCHAIN_SYSROOT` *(2026.9.5.2+)* | `mcpp::toolchain_sysroot()` | the `--sysroot` mcpp passes to its own compiler; empty when it passes none. For a rule package that runs a **second** compiler — see "Driving a second compiler" above | | `MCPP_TOOLCHAIN_BINUTILS_DIR` *(2026.9.5.2+)* | `mcpp::toolchain_binutils_dir()` | the directory mcpp names with `-B`; empty when it names none (a musl or MinGW payload brings its own assembler and linker) | | `MCPP_CXX_STDLIB` *(2026.9.6.3+)* | `mcpp::cxx_stdlib()` | the C++ standard library the resolved toolchain uses — `libstdc++`, `libc++`, `msvc-stl`; empty when no toolchain resolved. A different question from `MCPP_TARGET_LIBC`, which is the C library | diff --git a/docs/92-release.md b/docs/92-release.md index 5c50dff77..911424042 100644 --- a/docs/92-release.md +++ b/docs/92-release.md @@ -52,11 +52,23 @@ build ×4 at once (linux x86_64 / linux aarch64 / macOS ARM64 / Windows x64) [canaries beside them, when started by hand with `canaries`] → create-release: GitHub Release v with every archive + .sha256 sidecars → recompute every payload hash and publish immutable mcpp-release.json - → mirror to xlings-res/mcpp on BOTH GitHub and GitCode - → open the version-bump PR against openxlings/xim-pkgindex + → mirror to xlings-res/mcpp on GitHub + → open the index PR against openxlings/xim-pkgindex from the sealed manifest + → [mirror to xlings-res/mcpp on GitCode, when started by hand with `gitcode`] → workflow_run hook fires ci-fresh-install ``` +GitCode is mirrored by the release only when it is started with `gitcode` +(`gh workflow run release.yml -f gitcode=true`; 2026.11.14.1+). A GitHub runner +uploads to GitCode at about 0.012 MB/s: v2026.11.13.1 spent 713 s there, 604 s +per archive, against 64 s for all eight assets on GitHub. Otherwise the owner +mirrors GitCode from a host with a usable route, with the same script, and +merges the index PR after that (§3): + +```bash +GITCODE_TOKEN=... MIRROR_HOSTS=gtc bash .github/tools/mirror_res.sh mcpp # gtc on PATH +``` + The four builds start together and keep their archives as artifacts; `create-release` creates the release with all of them once every build has passed, so no release exists with a platform missing (2026.11.13.1+). The @@ -104,16 +116,22 @@ from a moving workspace or from a subset of release assets. Two steps are **not** automated: -- **merging the xim-pkgindex bump PR** — a maintainer does it. Until it lands, +- **merging the xim-pkgindex index PR** — a maintainer does it. Until it lands, the released version is downloadable but not installable via `xlings install`. - **bumping `.xlings.json`** — see §4. -The bump PR is machine-authored: branch `bump/mcpp-`, commits by -`xlings-ci `, and a diff that is invariably +22/-3. The three -deletions are the load-bearing part — the generator *replaces* each platform +The index PR is written by the release itself (`.github/tools/index_pr.py`, +2026.11.14.1+; it used to ask the index's updater, which waited for both +mirrors): branch `auto/bump-xim-mcpp`, commits by `mcpp-release`, the digests +those of the sealed `mcpp-release.json`, and a diff that is invariably +22/-3. +Its body starts with the updater's marker +(``), by +which the updater makes no proposal of its own, and states whether the run +mirrored GitCode. A run started again finds the marker and opens nothing. The +three deletions are the load-bearing part — the script *replaces* each platform table's `["latest"]` line rather than adding one. A hand-written index PR is not -an equivalent shortcut, and filing one alongside the bot's carries two distinct -costs, both incurred on 2026-09-05 by `xim-pkgindex#764`: +an equivalent shortcut, and filing one alongside the release's carries two +distinct costs, both incurred on 2026-09-05 by `xim-pkgindex#764`: - The hand-written diff appended `["latest"] = { ref = "" }` above the existing line instead of replacing it, leaving two `["latest"]` keys in every @@ -122,11 +140,11 @@ costs, both incurred on 2026-09-05 by `xim-pkgindex#764`: correct, so every pinned consumer stayed green — mcpp's own CI pins exactly and never saw it. Only `xlings install mcpp` without a version served the stale binary. -- Landing ahead of the bot's PR left that PR conflicting. The resolution is to - take the bot branch's side of `pkgs/m/mcpp.lua`; it differs from the +- Landing ahead of the generated PR left that PR conflicting. The resolution is to + take the generated branch's side of `pkgs/m/mcpp.lua`; it differs from the hand-written `main` only by the stale `["latest"]` lines. -A merged bump PR is therefore confirmed by what `latest` resolves to, which the +A merged index PR is therefore confirmed by what `latest` resolves to, which the presence of the version string elsewhere in the file does not answer: ```bash @@ -305,8 +323,9 @@ would again let the index guard and the installed version drift apart. [ ] gh workflow run release.yml --ref main [ ] release.yml green (4 builds + immutable manifest + publish-ecosystem) [ ] downloaded mcpp-release.json regenerates byte-identically from public assets +[ ] GitCode mirrored (release started with `gitcode`, or MIRROR_HOSTS=gtc locally) [ ] mirrors serve all four platforms on BOTH hosts, sha256 recomputed -[ ] merge the xim-pkgindex bump PR +[ ] merge the xim-pkgindex index PR [ ] index `latest` resolves to the new version in all three platform tables [ ] xim-index-latest.json's `index_version` equals xim-pkgindex `main` [ ] clean-room XLINGS_HOME: xlings install mcpp@ succeeds diff --git a/docs/zh/30-build-mcpp.md b/docs/zh/30-build-mcpp.md index 2f557631e..f0d8746f4 100644 --- a/docs/zh/30-build-mcpp.md +++ b/docs/zh/30-build-mcpp.md @@ -1151,6 +1151,7 @@ mcpp 会把它自己构建时用的**同一份** std 模块暂存过来,缓存 | `MCPP_KERNEL_ABI_INTERFACES` *(2026.11.10.1+)* | `mcpp::kernel_abi_interfaces()` | 声明 `requires = ["mcpp:kernel-abi=..."]` 的包:所解析的 kernel-abi 提供者的 `provides-interfaces`,以 `;` 连接(`openkal.fs;openkal.process;...`)。总是设置;提供者未声明清单或包没有该需求时为空,程序把空读作「未知」,按全部接口绑定 | | `MCPP_HOST` | `mcpp::host()` | 宿主三元组 | | `MCPP_PROFILE` | `mcpp::profile()` | 生效 profile 名(`dev`/`release`/…) | +| `MCPP_TARGET_SDK` *(2026.11.14.1+)* | `mcpp::target_sdk()` | 链接以 `-isysroot` 指名的 SDK(macOS 主机上的 Mach-O 链接);不指名时为空串。提供某个系统库存根的包只在它为空时提供:不为空时链接中已有 SDK 的完整库 | | `MCPP_TOOLCHAIN_SYSROOT` *(2026.9.5.2+)* | `mcpp::toolchain_sysroot()` | mcpp 传给自己那个编译器的 `--sysroot`;不传时为空串。供运行**第二个**编译器的规则包使用 —— 见上文「驱动第二个编译器」 | | `MCPP_TOOLCHAIN_BINUTILS_DIR` *(2026.9.5.2+)* | `mcpp::toolchain_binutils_dir()` | mcpp 用 `-B` 指的目录;不指时为空串(musl 与 MinGW 载荷自带汇编器与链接器) | | `MCPP_CXX_STDLIB` *(2026.9.6.3+)* | `mcpp::cxx_stdlib()` | 解析出的工具链使用的 C++ 标准库 —— `libstdc++`、`libc++`、`msvc-stl`;没有工具链解析时为空串。与 `MCPP_TARGET_LIBC` 不是同一个问题,后者是 C 库 | diff --git a/docs/zh/92-release.md b/docs/zh/92-release.md index dedea25f8..8457a1ec7 100644 --- a/docs/zh/92-release.md +++ b/docs/zh/92-release.md @@ -49,11 +49,21 @@ POSIX `sh`/dash 解析不了——`sh check_version_pins.sh` 会在第 95 行附 [手动发布且带 `canaries` 时,canaries 与之并行] → create-release:GitHub Release v,含全部归档与 .sha256 边车文件 → 重新计算每个载荷的哈希并发布不可变 mcpp-release.json - → 镜像到 xlings-res/mcpp 的 GitHub 与 GitCode 双端 - → 向 openxlings/xim-pkgindex 开版本 bump PR + → 镜像到 xlings-res/mcpp 的 GitHub 端 + → 以封存的 manifest 向 openxlings/xim-pkgindex 开索引 PR + → [手动发布且带 `gitcode` 时,镜像到 xlings-res/mcpp 的 GitCode 端] → workflow_run 钩子触发 ci-fresh-install ``` +发布只在带 `gitcode` 启动时镜像 GitCode(`gh workflow run release.yml -f gitcode=true`;2026.11.14.1+)。 +GitHub runner 向 GitCode 的上传约 0.012 MB/s:v2026.11.13.1 在其上用了 713 s,每个归档 604 s,而 GitHub +端八个资源共 64 s。不带 `gitcode` 时由负责人在线路可用的主机上以同一脚本镜像 GitCode,之后再合入索引 +PR(§3): + +```bash +GITCODE_TOKEN=... MIRROR_HOSTS=gtc bash .github/tools/mirror_res.sh mcpp # PATH 上需有 gtc +``` + 四个构建同时开始,各自把归档留作 artifact;`create-release` 在每个构建都通过后一次创建带全部归档的 release,因此不存在缺少某个平台的 release(2026.11.13.1+)。生态 canaries(xlings、mcppls; `.github/release-canaries.toml`)只在手动发布且带 `canaries` 时用候选版本构建 @@ -96,14 +106,17 @@ reconciler 与 `tools/pypi/` 中的 `mcpp-bin` PyPI wheel 构建脚本)必须 两步**没有**自动化: -- **合并 xim-pkgindex 的 bump PR**——由维护者完成。在它落地之前,发布出来 +- **合并 xim-pkgindex 的索引 PR**——由维护者完成。在它落地之前,发布出来 的版本可以下载,但无法通过 `xlings install` 安装。 - **bump `.xlings.json`**——见 §4。 -bump PR 是机器生成的:分支 `bump/mcpp-`,提交者 `xlings-ci -`,diff 恒为 +22/-3。承重的是那三行删除——生成器对每个 +索引 PR 由发布自身写出(`.github/tools/index_pr.py`,2026.11.14.1+;此前请求索引的更新器提出, +而更新器要等两端镜像都完成):分支 `auto/bump-xim-mcpp`,提交者 `mcpp-release`,摘要取自封存的 +`mcpp-release.json`,diff 恒为 +22/-3。正文以更新器的标记 +(``)开头,更新器据此不再自行提出, +并写明本次运行是否镜像了 GitCode。重复运行见到该标记即不再开 PR。承重的是那三行删除——脚本对每个 平台表是**替换** `["latest"]` 那一行,而不是新增一行。手写一个索引 PR -并不是等价的捷径;在 bot 的 PR 之外另开一个,代价有两份,2026-09-05 的 +并不是等价的捷径;在发布生成的 PR 之外另开一个,代价有两份,2026-09-05 的 `xim-pkgindex#764` 两份都付了: - 手写的 diff 把 `["latest"] = { ref = "<新版>" }` **追加**在原有那行上面 @@ -112,10 +125,10 @@ bump PR 是机器生成的:分支 `bump/mcpp-`,提交者 `xlings-ci 正确,所以每个按精确版本 pin 的消费者都是绿的——mcpp 自己的 CI 正是 精确 pin,完全看不见这件事。只有不带版本的 `xlings install mcpp` 会 拿到过期的二进制。 -- 它先于 bot 的 PR 落地,使那个 PR 变成冲突。解法是取 bot 分支那一侧的 +- 它先于生成的 PR 落地,使那个 PR 变成冲突。解法是取生成分支那一侧的 `pkgs/m/mcpp.lua`;它与手写后的 `main` 只差那几行陈旧的 `["latest"]`。 -因此,bump PR 合入的判据是 `latest` **解析成什么**,而不是文件里别处出现 +因此,索引 PR 合入的判据是 `latest` **解析成什么**,而不是文件里别处出现 了版本字符串: ```bash @@ -281,8 +294,9 @@ curl -fsSL https://github.com/xlings-res/xim-index/releases/download/latest/xim- [ ] gh workflow run release.yml --ref main [ ] release.yml green (4 builds + immutable manifest + publish-ecosystem) [ ] downloaded mcpp-release.json regenerates byte-identically from public assets +[ ] GitCode mirrored (release started with `gitcode`, or MIRROR_HOSTS=gtc locally) [ ] mirrors serve all four platforms on BOTH hosts, sha256 recomputed -[ ] merge the xim-pkgindex bump PR +[ ] merge the xim-pkgindex index PR [ ] index `latest` resolves to the new version in all three platform tables [ ] xim-index-latest.json's `index_version` equals xim-pkgindex `main` [ ] clean-room XLINGS_HOME: xlings install mcpp@ succeeds diff --git a/mcpp.toml b/mcpp.toml index 218ded807..0589631af 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.11.13.1" +version = "2026.11.14.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index 2d95bdbf3..b5ec58849 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.11.13.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.11.14.1"; } // namespace mcpp diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index a521d5f60..4e84a2d53 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -106,6 +106,10 @@ struct BuildProgramEnv { // hostprogram::toolchain_dir / sysroot_dir for why declaring was wrong. std::string toolchainDir; std::string targetSysroot; + // The SDK the link names with `-isysroot` (`link_sdk`), empty when it + // names none: whether the system's own stubs of its libraries are on the + // link, which a package that supplies a stub of one must ask. + std::string targetSdk; // THE TWO ANSWERS A SECOND COMPILER NEEDS AND CANNOT DERIVE. // // `toolchainSysroot` is the `--sysroot` mcpp passes to its own compiler and @@ -671,23 +675,17 @@ contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv // one that a platform package has to ask: **was this build POINTED at a // target**, or is it an ordinary native build? // - // The two are not the same even when the triples are equal. `mcpp build - // --target aarch64-macos` on an arm64 Mac names the same machine the host - // is, and yet it is the graph that supplies the target side — so this tool - // puts no system SDK on the link, and the package that knows the system is - // the only thing that can name one. A native build on the same machine gets - // the SDK and needs nothing from the package. + // The two are not the same even when the triples are equal: `mcpp build + // --target aarch64-macos` on an arm64 Mac names the machine the host is. // - // Measured 2026-08-23, `openkal-macos` trying to decide this from what - // was available. From the host: right for the cross, wrong for - // `--target aarch64-macos` ON a Mac (`library not found for -lSystem`). - // From `MCPP_TARGET`: right for the cross, wrong for the native build, - // because it is never empty (`undefined symbol: wcslen`, `strtoul`, … — - // the package's three-name stub had shadowed the vendor's complete one). - // - // An older mcpp sets neither, and that is the correct answer for it: - // it has no graph-supplied target side, so the system is always on the - // link and a package should supply nothing. + // WHETHER THE SYSTEM'S OWN LIBRARIES ARE ON THE LINK IS A SEPARATE QUESTION, + // answered by `MCPP_TARGET_SDK` below. This paragraph once answered it from + // this variable ("a requested target puts no system SDK on the link"); the + // link has named the SDK on every Mach-O link of a macOS host since + // 2026.8.25.2, and `openkal-macos`, which supplied its three-name + // `libSystem` stub on a requested target, shadowed the SDK's complete one + // (`undefined symbol: strlen`, canary `cxx aarch64-macos test`, + // 2026-11-14). e.emplace_back("MCPP_TARGET_REQUESTED", env.targetTriple); e.emplace_back("MCPP_TARGET_PROVIDER", env.targetProvider); e.emplace_back("MCPP_KERNEL_ABI_INTERFACES", env.kernelAbiInterfaces); @@ -712,6 +710,7 @@ contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv e.emplace_back("MCPP_CXX_STDLIB", shared_value("MCPP_CXX_STDLIB")); e.emplace_back("MCPP_CXX_ABI_ROOT", env.cxxAbiRoot); e.emplace_back("MCPP_TARGET_SYSROOT", env.targetSysroot); + e.emplace_back("MCPP_TARGET_SDK", env.targetSdk); // #734 E2. Every key always, empty when it does not apply, for the reason // the lines above give. for (auto key : kBuildInformationKeys) { diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 40eaa3a73..328537325 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -194,6 +194,15 @@ CompileFlags compute_build_contract_flags(const BuildPlan& plan); // by the build information a build program receives (#734 E2). dist::Format target_object_format(const mcpp::toolchain::Toolchain& tc); +// THE SDK THE LINK NAMES with `-isysroot`, or empty when it names none. One +// answer, read by the macOS link line and by the build programs +// (`MCPP_TARGET_SDK`): a package that supplies a stub of a system library asks +// whether the system's own is on the link, which only this decides. A Mach-O +// link on a macOS host names one -- the located Apple SDK of a cross row, a +// base stated as an Apple SDK, or the machine's SDK -- whichever party supplies +// the C library; any other link names none. +std::filesystem::path link_sdk(const mcpp::toolchain::Toolchain& tc); + // #734 E2: the C++ runtime contract of the package's programs as the manifest // states it or its default gives it, spelled as `cxx_runtime` is written; and // on the MSVC ABI the CRT linkage that contract compiles with ("static" or @@ -678,6 +687,16 @@ dist::Format target_object_format(const mcpp::toolchain::Toolchain& tc) { : dist::Format::Elf); } +std::filesystem::path link_sdk(const mcpp::toolchain::Toolchain& tc) { + if (link_shape(current_link_host(), target_object_format(tc), false, false) != LinkShape::AppleSdk) + return {}; + if (!tc.base.apple.root.empty()) return tc.base.apple.root; + if (tc.base.stated() && tc.base.address == mcpp::toolchain::base::Address::AppleIsysroot) + return tc.base.root; + if (auto sdk = mcpp::toolchain::apple::sdk_path()) return *sdk; + return {}; +} + std::string program_cxx_runtime(const mcpp::manifest::Manifest& m, const mcpp::toolchain::Toolchain& tc) { const auto& bc = m.buildConfig; @@ -2263,16 +2282,11 @@ CompileFlags compute_flags_impl(const BuildPlan& plan, bool packageLinkFlags) { // incompatible with arm64 (iOS Simulator18.0.0) // // which is why the cross path also carries `--no-default-config`. + // + // The choice is `link_sdk`'s, which the build programs read as well. std::string macos_sdk; - if (appleCross) { - macos_sdk = " -isysroot " - + escape_path(plan.toolchain.base.apple.root); - } else if (plan.toolchain.base.stated() - && plan.toolchain.base.address == mcpp::toolchain::base::Address::AppleIsysroot) { - macos_sdk = " -isysroot " + escape_path(plan.toolchain.base.root); - } else if (auto sdk = mcpp::toolchain::apple::sdk_path()) { - macos_sdk = " -isysroot " + escape_path(*sdk); - } + if (const auto sdk = link_sdk(plan.toolchain); !sdk.empty()) + macos_sdk = " -isysroot " + escape_path(sdk); // AND KEPT, BECAUSE THE GRAPH BRANCH BELOW REPLACES THIS LINE. // // `-isysroot` and the deployment floor describe the TARGET OS. Every diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index 80f51708c..1b07e74b7 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -646,6 +646,12 @@ inline const char* cxx_abi_root() { return env_or("MCPP_CXX_ABI_ // through the runtime binding, and nothing has to look for it. inline const char* sysroot_dir() { return env_or("MCPP_TARGET_SYSROOT"); } +// The SDK the link names with `-isysroot` (a Mach-O link on a macOS host), or +// "" when it names none. A package that supplies a stub of a system library +// supplies it only where this is empty: where it is not, the SDK's complete +// library is on the link, and the stub, searched first, would shadow it. +inline const char* target_sdk() { return env_or("MCPP_TARGET_SDK"); } + // ── The build information of the resolved toolchain (#734 E2, protocol 14) ── // // Facts, never interpretations: which tools this row runs, which tools are the diff --git a/src/build/prepare/toolchain_env.cpp b/src/build/prepare/toolchain_env.cpp index e17a95e49..d25040476 100644 --- a/src/build/prepare/toolchain_env.cpp +++ b/src/build/prepare/toolchain_env.cpp @@ -490,6 +490,7 @@ void fill_target_build_env(mcpp::build::BuildProgramEnv& e, e.toolchainDir = (tc && !tc->binaryPath.empty()) ? tc->binaryPath.parent_path().parent_path().string() : std::string{}; e.targetSysroot = tc ? tc->base.multilibRoot.string() : std::string{}; + e.targetSdk = tc ? mcpp::build::link_sdk(*tc).string() : std::string{}; e.compilerId = !tc ? std::string{} : tc->compiler == mcpp::toolchain::CompilerId::GCC ? "gcc" : tc->compiler == mcpp::toolchain::CompilerId::Clang ? "clang" diff --git a/tests/scripts/test_index_pr.py b/tests/scripts/test_index_pr.py new file mode 100644 index 000000000..39d2dd6fe --- /dev/null +++ b/tests/scripts/test_index_pr.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""The index entry a release proposes (.github/tools/index_pr.py), and the order +in which the release publishes (release.yml `publish-ecosystem`); offline, no +pull request.""" + +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / ".github/tools/index_pr.py" +spec = importlib.util.spec_from_file_location("index_pr", SCRIPT) +index_pr = importlib.util.module_from_spec(spec) +spec.loader.exec_module(index_pr) + +RECIPE = '''package = { + xpm = { + linux = { + ["latest"] = { ref = "2026.11.12.1" }, + ["2026.11.12.1"] = { + x86_64 = { + url = { GLOBAL = "g", CN = "c" }, + sha256 = "old", + }, + }, + }, + macosx = { + ["latest"] = { ref = "2026.11.12.1" }, + }, + windows = { + ["latest"] = { ref = "2026.11.12.1" }, + }, + }, +} +''' + +MANIFEST = { + "version": "2026.11.13.1", + "tag": "v2026.11.13.1", + "assets": [ + {"platform": "linux", "arch": "aarch64", "name": "mcpp-2026.11.13.1-linux-aarch64.tar.gz", "sha256": "a" * 64}, + {"platform": "linux", "arch": "x86_64", "name": "mcpp-2026.11.13.1-linux-x86_64.tar.gz", "sha256": "b" * 64}, + {"platform": "macos", "arch": "arm64", "name": "mcpp-2026.11.13.1-macosx-arm64.tar.gz", "sha256": "c" * 64}, + {"platform": "windows", "arch": "x86_64", "name": "mcpp-2026.11.13.1-windows-x86_64.zip", "sha256": "d" * 64}, + ], +} + + +class IndexEntry(unittest.TestCase): + def edited(self) -> str: + return index_pr.edit(RECIPE, "mcpp", MANIFEST["version"], index_pr.entries(MANIFEST)) + + def test_each_table_gains_the_version_and_its_latest(self): + text = self.edited() + self.assertEqual(text.count('["2026.11.13.1"] = {'), 3) + self.assertEqual(text.count('["latest"] = { ref = "2026.11.13.1" }'), 3) + self.assertNotIn('["latest"] = { ref = "2026.11.12.1" }', text) + self.assertIn('sha256 = "old"', text) # an existing version is unchanged + + def test_the_urls_name_both_mirrors_and_the_digest_is_the_manifests(self): + text = self.edited() + self.assertIn('GLOBAL = "https://github.com/xlings-res/mcpp/releases/download/2026.11.13.1/' + 'mcpp-2026.11.13.1-macosx-arm64.tar.gz"', text) + self.assertIn('CN = "https://gitcode.com/xlings-res/mcpp/releases/download/2026.11.13.1/' + 'mcpp-2026.11.13.1-windows-x86_64.zip"', text) + self.assertIn('sha256 = "' + "c" * 64 + '"', text) + + def test_the_manifests_words_are_the_recipes(self): + tables = index_pr.entries(MANIFEST) + self.assertEqual(sorted(tables), ["linux", "macosx", "windows"]) + self.assertEqual(tables["macosx"][0][0], "aarch64") + macos = self.edited().split(" macosx = {")[1].split(" windows = {")[0] + self.assertIn(" aarch64 = {", macos) + + def test_a_second_edit_changes_nothing(self): + once = self.edited() + self.assertEqual(index_pr.edit(once, "mcpp", MANIFEST["version"], index_pr.entries(MANIFEST)), once) + + def test_the_marker_is_the_updaters(self): + self.assertEqual(index_pr.marker("mcpp", "2026.11.13.1"), + '') + + +class Publication(unittest.TestCase): + """release.yml: GitHub mirror, then the index pull request, then GitCode only when asked.""" + + def job(self) -> str: + text = (ROOT / ".github/workflows/release.yml").read_text() + return text.split("\n publish-ecosystem:\n")[1] + + def steps(self) -> list[str]: + return self.job().split("\n - name: ")[1:] + + def test_gitcode_is_an_input_off_by_default(self): + text = (ROOT / ".github/workflows/release.yml").read_text() + block = text.split("\n gitcode:\n")[1].split("\n tag:")[0].split("\njobs:")[0] + self.assertIn("type: boolean", block) + self.assertIn("default: false", block) + self.assertIn("WITH_GITCODE: ${{ github.event_name == 'workflow_dispatch' && inputs.gitcode }}", self.job()) + + def test_the_order_is_github_index_gitcode(self): + names = [s.splitlines()[0] for s in self.steps()] + github = names.index("Mirror the archives to xlings-res/mcpp on GitHub") + index = names.index("Open the index pull request (xim-pkgindex)") + gitcode = names.index("Mirror the archives to GitCode") + self.assertLess(github, index) + self.assertLess(index, gitcode) + + def test_only_the_gitcode_step_reaches_gitcode(self): + for step in self.steps(): + name = step.splitlines()[0] + if name == "Mirror the archives to GitCode": + self.assertIn("env.WITH_GITCODE == 'true'", step) + self.assertIn("MIRROR_HOSTS: gtc", step) + else: + self.assertNotIn(".github/tools/gtc", step, name) + if "mirror_res.sh" in step: + self.assertIn("MIRROR_HOSTS: gh", step, name) + + def test_the_index_reads_the_sealed_manifest(self): + step = next(s for s in self.steps() if s.startswith("Open the index pull request")) + self.assertIn("index_pr.py --manifest sealed/mcpp-release.json", step) + self.assertNotIn("bump_index.sh", self.job()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_link_line_golden.cpp b/tests/unit/test_link_line_golden.cpp index 98edfa93d..ae116275b 100644 --- a/tests/unit/test_link_line_golden.cpp +++ b/tests/unit/test_link_line_golden.cpp @@ -197,3 +197,25 @@ UTest the_graph_supplies_the_c_library{"LinkLineGolden/TheGraphSuppliesTheCLibra const auto f = compute_flags(plan); expect_words("the link's own group", c_library_words(f.sysroot, t.root), Words{}); }}; + +// THE SDK OF A MACH-O LINK IS ONE ANSWER (design 2026-11-14 release-publication +// §2.3): the link line names with `-isysroot` the SDK the build programs read +// as `MCPP_TARGET_SDK` (`link_sdk`). A package that supplies a stub of a system +// library asks it whether the system's own is on the link; two answers had +// disagreed since 2026.8.25.2. An ELF link names none on every host. +UTest the_link_and_the_build_programs_name_one_sdk{"LinkLineGolden/TheLinkAndTheBuildProgramsNameOneSdk", [] { + Tree t; + t.file("llvm/bin/clang++.cfg"); + t.dir("llvm/include/c++/v1"); + auto elf = plan_for(tc::CompilerId::Clang, t.file("llvm/bin/clang++"), "x86_64-linux-gnu"); + expect(link_sdk(elf.toolchain).empty()) << link_sdk(elf.toolchain); + auto macho = plan_for(tc::CompilerId::Clang, t.root / "llvm/bin/clang++", "aarch64-apple-darwin"); + const auto sdk = link_sdk(macho.toolchain); + if constexpr (!mcpplibs::os::is_macos) { + expect(sdk.empty()) << "a link on this host names no SDK: " << sdk; + } else { + if (sdk.empty()) skip("no Apple SDK on this machine"); + const auto f = compute_flags(macho); + expect(f.ld.contains(" -isysroot " + sdk.string())) << f.ld; + } +}}; From f35e85eee29802ff0ea4ddf704c5ae513a5f4168 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 14 Nov 2026 23:02:38 +0900 Subject: [PATCH 2/4] A runner's bare name on Windows is the program with the system's suffix, never the launcher beside it --- CHANGELOG.md | 8 ++++++++ src/build/runner_lookup.cppm | 13 ++++++++++--- tests/unit/test_runner_lookup.cpp | 32 ++++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d1b392a..48120757b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,14 @@ updater waited for both mirrors, and its run for v2026.11.13.1 failed. `bump_index.sh` is removed. +### Fixed + +- On Windows a runner's bare first word is the program with the system's + suffix (`.exe`, `.cmd`, `.bat`) and never a file without one. A package for + every system that carries a POSIX launcher beside its program (dirun's + `bin/dirun` and `bin/dirun.exe`) had the launcher taken, and the run refused + with `executable format error`. + ## [2026.11.13.1] - 2026-11-13 ### Added diff --git a/src/build/runner_lookup.cppm b/src/build/runner_lookup.cppm index 82ee0722c..800784eaa 100644 --- a/src/build/runner_lookup.cppm +++ b/src/build/runner_lookup.cppm @@ -86,14 +86,21 @@ inline Lookup locate(std::string_view argv0, return out; } // A bare name on Windows is a program with the system's suffix, as the - // shell finds it: `okrun` is `okrun.exe`. + // shell finds it: `okrun` is `okrun.exe`. The file without a suffix is not + // one: the system starts no file without one (`CreateProcess` appends + // `.exe`), and every file is "executable" there. A package for every + // system carries both -- dirun's `bin/dirun`, a POSIX launcher, beside + // `bin/dirun.exe` -- and the launcher was taken and refused + // (`executable format error`, 2026-11-14). auto in_dir = [&](const std::filesystem::path& d) -> std::optional { - if (auto c = d / a0; detail::executable_file(c)) return c; if constexpr (mcpplibs::os::is_windows) { - if (!a0.has_extension()) + if (!a0.has_extension()) { for (auto ext : { ".exe", ".cmd", ".bat" }) if (auto c = d / (a0.string() + ext); detail::executable_file(c)) return c; + return std::nullopt; + } } + if (auto c = d / a0; detail::executable_file(c)) return c; return std::nullopt; }; for (auto const& d : depBinDirs) { diff --git a/tests/unit/test_runner_lookup.cpp b/tests/unit/test_runner_lookup.cpp index badcbc3e5..ab518d1f9 100644 --- a/tests/unit/test_runner_lookup.cpp +++ b/tests/unit/test_runner_lookup.cpp @@ -153,6 +153,36 @@ UTest spawn_failed_message_is_verbatim{"RunnerLookup/SpawnFailedMessageIsVerbati #else -UTest runner_lookup_windows_covered_by_integration{"RunnerLookup/WindowsCoveredByIntegration", [] { /* passes */; }}; +namespace { +std::filesystem::path file_in(const std::filesystem::path& dir, std::string_view name) { + std::filesystem::create_directories(dir); + auto p = dir / name; + { std::ofstream o(p, std::ios::binary); o << "x"; } + return p; +} +std::filesystem::path fresh_root(std::string_view name) { + auto root = std::filesystem::temp_directory_path() / name; + std::filesystem::remove_all(root); + return root; +} +} // namespace + +// A package for every system carries a POSIX launcher beside the program: +// the bare name is the program with the system's suffix, never the launcher. +UTest a_bare_name_is_the_suffixed_program{"RunnerLookup/ABareNameIsTheSuffixedProgram", [] { + auto root = fresh_root("mcpp-runner-lookup-win-1"); + file_in(root / "bin", "dirun"); + auto exe = file_in(root / "bin", "dirun.exe"); + auto l = locate("dirun", payload_search_dirs(root), ""); + expect(fatal(l.program.has_value())); + expect(eq(*l.program, exe)); +}}; + +UTest a_file_without_a_suffix_is_no_program{"RunnerLookup/AFileWithoutASuffixIsNoProgram", [] { + auto root = fresh_root("mcpp-runner-lookup-win-2"); + file_in(root / "bin", "tool"); + auto l = locate("tool", payload_search_dirs(root), ""); + expect(!l.program.has_value()); +}}; #endif From c8395807093a465b4672f0de44583f5ab62ccb45 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 15 Nov 2026 00:55:35 +0900 Subject: [PATCH 3/4] A planned row is served by the chosen platform whose statement lists it with a toolchain --- ...elease-publication-and-macos-sdk-design.md | 30 ++++++++++++++ CHANGELOG.md | 7 ++++ docs/20-toolchains.md | 11 ++++- docs/zh/20-toolchains.md | 7 +++- modules/manifest/src/platform_providers.cppm | 11 +++++ src/build/prepare/platform.cpp | 41 ++++++++++++++++++- src/build/prepare/state.cppm | 11 ++++- src/build/prepare/toolchain.cpp | 17 +++++++- tests/unit/test_platform.cpp | 28 +++++++++++++ 9 files changed, 158 insertions(+), 5 deletions(-) diff --git a/.agents/docs/2026-11-14-release-publication-and-macos-sdk-design.md b/.agents/docs/2026-11-14-release-publication-and-macos-sdk-design.md index f29241c0a..8f02c0047 100644 --- a/.agents/docs/2026-11-14-release-publication-and-macos-sdk-design.md +++ b/.agents/docs/2026-11-14-release-publication-and-macos-sdk-design.md @@ -130,3 +130,33 @@ openkal-macos 要回答的问题是"链接中有没有系统自己的 `libSystem mcpp 知道。 - **openkal-macos 自行探测 SDK(`xcrun --show-sdk-path`)。** 它重复 mcpp 的判断,且与 mcpp 实际所用的 SDK(`SDKROOT`、 基座陈述)可能不同。 + +## 3. 平台服务 `planned` 行(负责人 2026-11-15 确认) + +### 3.1 现象 + +openkal-platform 0.28.0 在 `[platform] targets` 中列出 `i686-openkal`,并以 `toolchain = "llvm@23.1.3"` 陈述其工具链。 +mcpp 的内建数据仍把该行登记为 `planned`,未指定工具链的构建一律拒绝。以 `--target openkal`(平台各 target 组成的集合) +构建的每个使用者因此在 `i686-openkal` 一支上被拒绝,除非逐个项目写 `[target.i686-openkal] toolchain`。 + +### 3.2 规则 + +`planned` 表示引擎自己的提供者尚未服务该行,不表示该行不可构建。已有的例外是"为构建指定的工具链" +(`[target.] toolchain`、`--toolchain`、使用者传给子构建的工具链)。本节增加第二个同等明确的来源: +为该请求选择的平台(`--platform`、`[target.] platform`、配置),其陈述以工具链服务该行——该行列在 +`[platform] targets` 且有 `toolchain`,或列在指定了工具链的 `[[platform.provider]]` 中。 + +- 判定读平台的陈述(`mcpp.manifest.platform_providers::serves_with_toolchain`),与 `mcpp why platform`、 + 语句检查共用同一份提供者列表,不按平台名或行名硬编码。 +- 时机:目标层级检查时平台尚未读入,故记录待定的行(`PrepareState::plannedRow`);平台为目录或第二遍已在 + P1 读入时在 `step1_select_platform` 判定;由本遍获取的包在 `step4b_apply_platform` 判定,服务则以平台已读入 + 的状态重新准备一遍(既有的 platform restart),不服务则拒绝。 +- 拒绝信息指名该平台及其来源,并给出两条出路(指定工具链,或选择服务该行的平台)。未选择平台时信息不变。 +- 兼容:只放宽,不改变已能构建的请求;旧 mcpp 对同一平台仍按原规则拒绝,平台以 `mcpp >= 2026.11.14.1` 声明下限。 + +### 3.3 验证 + +- 单元:`PlatformManifest/APlannedRowIsServedByAStatementWithAToolchain`(有工具链则服务,未列出或无工具链则不服务)。 +- 本机:`--target i686-openkal --platform path:` 与 `--target openkal` 构建成功; + `--target riscv64-linux-musl --platform path:` 与 `--platform openkal-platform@0.27.1 --target i686-openkal` + (索引包,本遍获取)均被拒绝并指名平台。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 48120757b..74d6fa54c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,13 @@ updater waited for both mirrors, and its run for v2026.11.13.1 failed. `bump_index.sh` is removed. +- A `planned` row is served by the platform chosen for the request when the + platform's statement lists it with a toolchain (`[platform] targets` beside + its `toolchain`, or a `[[platform.provider]]` that names one), as by a + toolchain named for the build. A platform that lists the row nowhere is named + in the refusal. `--target openkal` with openkal-platform 0.28.0, whose + targets include `i686-openkal`, was refused on that leg. + ### Fixed - On Windows a runner's bare first word is the program with the system's diff --git a/docs/20-toolchains.md b/docs/20-toolchains.md index 56e8f80b4..55c12d209 100644 --- a/docs/20-toolchains.md +++ b/docs/20-toolchains.md @@ -283,7 +283,16 @@ vocabulary, in four statuses: | `installed` | a payload here already produces it | nothing | | `available` | a payload exists for this host | `mcpp toolchain install` | | `via dependency graph` | the compiler is here; the target's system is not, and packages can supply it | depend on an implementation of the target's kernel interface and C library | -| `planned` | registered in the vocabulary, not yet shipped | — | +| `planned` | registered in the vocabulary, not yet shipped | name a toolchain for the build, or choose a platform that serves the row | + +A `planned` row is built when the build names its toolchain +(`[target.] toolchain`, `--toolchain`), or when the platform chosen for the +request (`--platform`, `[target.] platform`, the configuration) lists the +row with a toolchain: among `[platform] targets` beside its `toolchain`, or in a +`[[platform.provider]]` that names one (2026.11.14.1+). The platform's +statement decides, and a platform that does not list the row is named in the +refusal. A set of targets (`--target openkal`) is served leg by leg the same +way. **A target absent from this block cannot be built here at all** — and that is a narrower statement than it used to be. Until mcpp 2026.8.25.2 the block diff --git a/docs/zh/20-toolchains.md b/docs/zh/20-toolchains.md index 7af8bdd9a..bc869d201 100644 --- a/docs/zh/20-toolchains.md +++ b/docs/zh/20-toolchains.md @@ -253,7 +253,12 @@ Available toolchains (run `mcpp toolchain install `): | `installed` | 本机已有的载荷就能产出它 | 无 | | `available` | 本宿主上存在可安装的载荷 | `mcpp toolchain install` | | `via dependency graph` | 编译器在本机,而该 target 的系统不在,由包供给 | 依赖一个实现该 target 内核接口与 C 库的包 | -| `planned` | 已登记在词汇表中,尚未发布 | — | +| `planned` | 已登记在词汇表中,尚未发布 | 为构建指定工具链,或选择服务该行的平台 | + +`planned` 行在两种情形下构建:构建指定了工具链(`[target.] toolchain`、`--toolchain`),或为该请求选择的 +平台(`--platform`、`[target.] platform`、配置)以工具链列出该行——列在 `[platform] targets` 中且有 +`toolchain`,或列在指定了工具链的 `[[platform.provider]]` 中(2026.11.14.1+)。由平台的陈述决定;不列出该行的 +平台会在拒绝信息中被指名。目标集合(`--target openkal`)的各个分支按同一规则逐一判断。 **不出现在这个块里的 target,在本机根本构建不了**——而这句话现在比以前更窄。 在 mcpp 2026.8.25.2 之前,这个块只列出载荷能服务的那些,于是「系统来自依赖图」 diff --git a/modules/manifest/src/platform_providers.cppm b/modules/manifest/src/platform_providers.cppm index e267ff0cc..fbffb7c7f 100644 --- a/modules/manifest/src/platform_providers.cppm +++ b/modules/manifest/src/platform_providers.cppm @@ -41,6 +41,11 @@ struct ServedIdentity { // rows it defines, by identity, then priority (highest first), then provider. std::vector served_identities(const Statement& s, std::string_view name); +// Whether a provider of the statement serves `identity` and names the toolchain +// it is built with: the statement then serves a row the engine registers as +// `planned`, as a toolchain named for the build does (SPEC-016 §5). +bool serves_with_toolchain(const Statement& s, std::string_view name, std::string_view identity); + // The statement checked against the rows: a row defined twice, or a definition // of a built-in row, is an error; a provider that serves no row, and two // providers that serve one identity at one priority, are warnings. @@ -122,6 +127,12 @@ std::vector served_identities(const Statement& s, std::string_vi return out; } +bool serves_with_toolchain(const Statement& s, std::string_view name, std::string_view identity) { + return std::ranges::any_of(stated_providers(s, name), [&](auto const& p) { + return !p.toolchain.empty() && std::ranges::contains(rows_served(s, p), identity); + }); +} + StatementFindings check_statement(const Statement& s, std::string_view name) { StatementFindings f; std::set own; diff --git a/src/build/prepare/platform.cpp b/src/build/prepare/platform.cpp index bb20d45de..483d28cab 100644 --- a/src/build/prepare/platform.cpp +++ b/src/build/prepare/platform.cpp @@ -308,7 +308,7 @@ std::string platform_short_name(std::string_view ref) { } std::string named_provider(const PrepareState& state, const mcpp::toolchain::triple::Triple& t, - std::span config, std::string* platform) { + std::span config, std::string* platform, std::string* chosenOut) { const auto flag = !state.overrides.platform_choice.empty() ? state.overrides.platform_choice : mcpp::invocation::get("MCPP_PLATFORM").value_or(""); @@ -321,6 +321,7 @@ std::string named_provider(const PrepareState& state, const mcpp::toolchain::tri if (!sel->builtin.empty()) return sel->builtin; if (sel->none) return "none"; const auto chosen = known_short_name(sel->ref); + if (chosenOut) *chosenOut = chosen; for (auto const* p : mcpp::toolchain::target::providers()) { if (p->platform.empty()) continue; auto ref = mp::parse_ref_text(p->platform); @@ -334,6 +335,22 @@ cfgpred::Ctx platform_cfg_context(const PrepareState& state) { return cfgpred::context_for(target_of(state).str(), state.platformPackageShortName); } +// A `planned` row the chosen platform was to serve (`state.plannedRow`), checked +// against the platform's statement once it is read: served when a provider of the +// statement lists the row with a toolchain; refused, naming the platform, when not. +static std::expected planned_row_served(PrepareState& state, const mp::Statement& s, + std::string_view platformName, std::string_view shown) { + if (state.plannedRow.empty()) return {}; + const auto row = state.plannedRow; + if (mp::serves_with_toolchain(s, platformName, row)) return {}; + refusal::record(refusal::Code::TierPlanned); + return std::unexpected(std::format( + "target '{}' is registered but not yet supported (planned), and the platform {} does not serve it:\n" + " its statement lists no provider of the row with a toolchain.\n" + " A toolchain named for the build opts in early: [target.{}] toolchain, or --toolchain.", + row, shown, row)); +} + std::expected step1_select_platform(PrepareState& state) { // A host sub-build (build programs, tools) is the host graph: its platform // is the one its caller states (the build programs' graph under a host @@ -373,6 +390,13 @@ std::expected step1_select_platform(PrepareState& state) { if (!sel) return std::unexpected(sel.error()); state.platform = mcpp::platform::Resolved{}; state.platform.selection = std::move(*sel); + if (state.platform.selection.none && !state.plannedRow.empty()) { + refusal::record(refusal::Code::TierPlanned); + return std::unexpected(std::format( + "target '{}' is registered but not yet supported (planned) — no toolchain is published for it yet.\n" + " A toolchain named for the build opts in early: [target.{}] toolchain, or --toolchain.", + state.plannedRow, state.plannedRow)); + } if (!state.platform.selection.none) { auto const& ref = state.platform.selection.ref; // A directory is read now, and its manifest's own error is the answer @@ -400,6 +424,9 @@ std::expected step1_select_platform(PrepareState& state) { if (auto m = mcpp::manifest::load(early / "mcpp.toml"); m && m->platformDeclared) { state.platformData = mcpp::platform::provider_view(m->platform, t.str()); state.platformEarly = true; + auto n = mcpp::pm::compat::resolve_package_name(m->package.name, m->package.namespace_); + if (auto r = planned_row_served(state, m->platform, n.shortName, mp::display(state.platform.selection.ref)); !r) + return r; } } return {}; @@ -497,6 +524,18 @@ std::expected step4b_apply_platform(PrepareState& state) { P.package = qualified(found->manifest); P.version = found->manifest.package.version; P.root = found->root; + // A planned row this platform was to serve: refused now when it does not; + // when it does and the first pass fetched it, prepared again with the + // platform read in P1, where its toolchain is the build's. + if (!state.plannedRow.empty() && !state.platformEarly) { + auto n = mcpp::pm::compat::resolve_package_name(found->manifest.package.name, + found->manifest.package.namespace_); + if (auto r = planned_row_served(state, found->manifest.platform, n.shortName, + std::format("{}@{}", P.package, P.version)); !r) + return std::unexpected(r.error()); + g_platformRestart = P.root; + return std::unexpected(std::string("mcpp:platform-restart")); + } define_platform(found->manifest.platform, state.platformPackageShortName, ref.spec.path.empty() ? std::format("{}@{}", P.package, P.version) : "path:" + ref.spec.path); diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index 909d99888..607610c84 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -938,6 +938,11 @@ struct PrepareState { // `platformData` was read in P1 (a directory, or a second pass), so the // target base reads the platform's `sysroot` (base.cpp, tier four). bool platformEarly = false; + // A `planned` row requested with a platform chosen for it and no toolchain + // named: the platform serves it when its statement lists the row with a + // toolchain, which is known once the platform is read (P1 for a directory + // or a second pass, P4 for a package fetched by this pass). + std::string plannedRow; // What build programs stated with the platform's directives (Tier::Program): // a dependency's, the root's, and the platform's plugin. mcpp::manifest::platform::Statement programPlatform; @@ -1333,9 +1338,13 @@ void define_early_platform(PrepareState& state); // a built-in provider names); `none` for `--platform none`. A platform package no // built-in provider names leaves it empty and is reported in `platform` (its // short name). +// `platform`: the short name of a chosen platform the engine's providers do not +// name. `chosen`: the short name of any platform chosen for the request (the +// flag, the environment or a table; not the built-in column), whichever +// providers serve it. std::string named_provider(const PrepareState& state, const mcpp::toolchain::triple::Triple& t, std::span config, - std::string* platform = nullptr); + std::string* platform = nullptr, std::string* chosen = nullptr); // The short name of the package a reference names (`path:

` reads its manifest). std::string platform_short_name(std::string_view ref); diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index 8e2abe869..4d6328e96 100644 --- a/src/build/prepare/toolchain.cpp +++ b/src/build/prepare/toolchain.cpp @@ -538,6 +538,9 @@ struct TargetOverrideCtx { // `[toolchain] default`: a pair the configuration states. bool targetFromGlobalDefault = false; const mcpp::toolchain::triple::TargetInfo* known = nullptr; + // The platform the project, the command line or the machine chose for the + // request (its short name), or empty. + std::string chosenPlatform; }; // The body of the `apply_target_section` closure the single-function version @@ -622,7 +625,7 @@ step1_resolve_target_triple_request(PrepareState& state, TargetOverrideCtx& ctx) std::vector config; if (auto cfg = state.get_cfg(true); cfg) config = (*cfg)->platformOverrides; std::string platform; - const std::string provider = named_provider(state, *ctx.parsed, config, &platform); + const std::string provider = named_provider(state, *ctx.parsed, config, &platform, &ctx.chosenPlatform); auto sctx = target_context(state.m->platformOverrides, config, family, provider == "none" ? std::string{} : provider, state.msvc_usable_either_origin); @@ -772,6 +775,18 @@ step1_validate_target_tier(PrepareState& state, TargetOverrideCtx& ctx) { // and a platform's conformance run that names its toolchain on the command // line was refused. const bool toolchainNamed = ctx.hasToolchainOverride || state.tcFromCommandLine || state.tcFromConsumer; + // AND SO IS ONE A PLATFORM CHOSEN FOR THE REQUEST NAMES FOR THE ROW + // (2026.11.14.1). A platform that lists the row among its targets with its + // toolchain serves it; the row's `planned` says only that the engine's own + // providers do not yet. The platform's statement is read with the platform + // (`step1_select_platform`, or `step4b_apply_platform` for a package this + // pass fetches), and the build is refused there when it does not list the + // row. openkal-platform 0.28.0 lists `i686-openkal`, and `--target openkal` + // (its targets as one set) was refused on that leg. + if (ctx.known && ctx.known->tier == "planned" && !toolchainNamed && !ctx.chosenPlatform.empty()) { + state.plannedRow = ctx.parsed->str(); + return {}; + } if (ctx.known && ctx.known->tier == "planned" && !toolchainNamed) { refusal::record(refusal::Code::TierPlanned); // The subject is what the user wrote. When completion filled a diff --git a/tests/unit/test_platform.cpp b/tests/unit/test_platform.cpp index 3d555b01d..359534863 100644 --- a/tests/unit/test_platform.cpp +++ b/tests/unit/test_platform.cpp @@ -658,6 +658,34 @@ serves = ["riscv64-nosuch"] expect(eq(served[1].provider, "toy")); }}; +// A planned row is served by a platform whose statement lists it with a +// toolchain (2026.11.14.1): `[platform] targets` with its `toolchain`, or a +// provider that names one. A row it lists nowhere, or lists with no toolchain, +// is not served. +UTest a_planned_row_is_served_by_a_statement_with_a_toolchain{"PlatformManifest/APlannedRowIsServedByAStatementWithAToolchain", [] { + auto with = parse(R"( +[package] +name = "toy-platform" +version = "0.1.0" + +[platform] +targets = ["x86_64-openkal", "i686-openkal"] +toolchain = "llvm@23.1.3" +)"); + expect(mp::serves_with_toolchain(with.platform, "toy-platform", "i686-openkal")); + expect(!mp::serves_with_toolchain(with.platform, "toy-platform", "riscv64-linux-musl")); + + auto without = parse(R"( +[package] +name = "toy-platform" +version = "0.1.0" + +[platform] +targets = ["i686-openkal"] +)"); + expect(!mp::serves_with_toolchain(without.platform, "toy-platform", "i686-openkal")); +}}; + // A provider states the link and the runner of the identities it serves, over // either base: one platform states rows whose conventions differ (2026.11.13.1). UTest a_provider_states_its_link_and_runner{"PlatformMerge/AProviderStatesItsLinkAndRunner", [] { From d0354b8d85848d97948d4270e64abe2809f890f1 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 15 Nov 2026 01:26:47 +0900 Subject: [PATCH 4/4] Test discovery leaves out a directory below the package root that holds its own mcpp.toml --- ...elease-publication-and-macos-sdk-design.md | 11 ++++++++++ CHANGELOG.md | 4 ++++ docs/08-testing.md | 5 ++++- docs/zh/08-testing.md | 4 +++- src/build/test_targets.cppm | 22 ++++++++++++++++++- tests/unit/test_test_targets.cpp | 21 ++++++++++++++++++ 6 files changed, 64 insertions(+), 3 deletions(-) diff --git a/.agents/docs/2026-11-14-release-publication-and-macos-sdk-design.md b/.agents/docs/2026-11-14-release-publication-and-macos-sdk-design.md index 8f02c0047..4876b51ef 100644 --- a/.agents/docs/2026-11-14-release-publication-and-macos-sdk-design.md +++ b/.agents/docs/2026-11-14-release-publication-and-macos-sdk-design.md @@ -160,3 +160,14 @@ mcpp 的内建数据仍把该行登记为 `planned`,未指定工具链的构 - 本机:`--target i686-openkal --platform path:` 与 `--target openkal` 构建成功; `--target riscv64-linux-musl --platform path:` 与 `--platform openkal-platform@0.27.1 --target i686-openkal` (索引包,本遍获取)均被拒绝并指名平台。 + +## 4. 测试发现不进入另一个包 + +mtest 的标准布局把需要构建之外输入的一类测试放成独立成员包(`tests/cts/mcpp.toml`)。默认的 +`tests/**/*.cpp` 此前会把这样一个包的源文件(`tests/cts/src/main.cpp`)当作根包的测试编译。 + +- 规则:包根之下含有 `mcpp.toml` 的目录是另一个包;任何 `[test] discover` glob 都不发现其中的文件。 + 规则以清单文件为依据,不依赖目录名,夹具工程(`tests/e2e//app/mcpp.toml`)同样适用。 +- 实现:`discover_test_targets` 对每个候选文件沿父目录向上检查到包根,结果按目录缓存。 +- 验证:单元 `TestTargets/APackageBelowTestsIsNotItsTests`;本机一个含 `tests/cts/mcpp.toml` 的工程, + `mcpp test --list` 只列出根包的测试。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 74d6fa54c..399efa3cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,10 @@ toolchain named for the build. A platform that lists the row nowhere is named in the refusal. `--target openkal` with openkal-platform 0.28.0, whose targets include `i686-openkal`, was refused on that leg. +- Test discovery leaves out every directory below the package root that holds + an `mcpp.toml`: such a directory is a package of its own. The default + `tests/**/*.cpp` compiled the sources of a member suite (`tests/cts/src/main.cpp`, + the layout mtest states for a suite with outside inputs) as tests of the root. ### Fixed diff --git a/docs/08-testing.md b/docs/08-testing.md index d4f9741e7..360c493d6 100644 --- a/docs/08-testing.md +++ b/docs/08-testing.md @@ -53,7 +53,10 @@ files it matches from the set, whichever glob found them. A test's name is its path relative to the fixed directory of the first glob that matched it, without the extension, so the default names `tests/unit/test_span.cpp` `unit/test_span`. `discover = []` discovers no test. Two files whose names coincide are refused, -naming both. +naming both. A directory below the package root that holds an `mcpp.toml` is a +package of its own, and no glob discovers its files (2026.11.14.1+): a suite that +needs inputs from outside the build (`tests/cts/mcpp.toml`) and a fixture +project below a test's data directory are built and tested as packages. `[test] programs = "directory"` (2026.11.8.1+) makes the files of one directory below a glob's fixed directory one program, named by the directory diff --git a/docs/zh/08-testing.md b/docs/zh/08-testing.md index 58aa0bb4a..432a9533a 100644 --- a/docs/zh/08-testing.md +++ b/docs/zh/08-testing.md @@ -48,7 +48,9 @@ discover = ["checks/**/*.cpp", "!checks/fixtures/**"] 被哪个 glob 找到的。一个测试的名字,是它相对于第一个匹配它的 glob 所在固定目录的 路径,去掉扩展名 —— 因此默认配置下 `tests/unit/test_span.cpp` 的名字是 `unit/test_span`。`discover = []` 不发现任何测试。两个名字相同的文件会被拒绝,并 -点出这两者。 +点出这两者。包根之下含有 `mcpp.toml` 的目录是另一个包,任何 glob 都不会发现其中的 +文件(2026.11.14.1+):需要构建之外输入的套件(`tests/cts/mcpp.toml`)以及测试数据 +目录下的夹具工程,按包构建和测试。 `[test] programs = "directory"`(2026.11.8.1+)使 glob 固定目录之下同一目录的文件成为 一个程序,以目录命名(`tests/unit/*.cpp` 为程序 `unit`):每个文件是它的一个翻译单元, diff --git a/src/build/test_targets.cppm b/src/build/test_targets.cppm index 8362832b9..c19b0dada 100644 --- a/src/build/test_targets.cppm +++ b/src/build/test_targets.cppm @@ -64,6 +64,26 @@ discover_test_targets(const std::filesystem::path& manifestRoot, // name every earlier release gave it. std::vector> testFiles; { + // A DIRECTORY WITH A MANIFEST OF ITS OWN IS ANOTHER PACKAGE. Its files + // are that package's sources, built by it and tested by it, whichever + // glob reaches them: a suite that needs inputs from outside the build + // is a package below `tests/` (`tests/cts/mcpp.toml`), and a fixture + // project is one below a test's data directory. + std::map packageDir; + auto in_other_package = [&](const std::filesystem::path& file) { + const auto root = packageRoot.lexically_normal(); + for (auto dir = file.parent_path().lexically_normal(); + dir != root && dir.has_relative_path() && dir != dir.parent_path(); + dir = dir.parent_path()) { + auto [it, fresh] = packageDir.try_emplace(dir, false); + if (fresh) { + std::error_code ec; + it->second = std::filesystem::is_regular_file(dir / "mcpp.toml", ec); + } + if (it->second) return true; + } + return false; + }; std::set excluded; for (auto const& g : result.discover) { if (g.starts_with('!')) @@ -76,7 +96,7 @@ discover_test_targets(const std::filesystem::path& manifestRoot, const auto prefix = mcpp::modgraph::glob_literal_prefix(g); const auto base = prefix.empty() ? packageRoot : packageRoot / prefix; for (auto& p : mcpp::modgraph::expand_glob(packageRoot, g)) { - if (excluded.contains(p) || !seenFiles.insert(p).second) continue; + if (excluded.contains(p) || in_other_package(p) || !seenFiles.insert(p).second) continue; testFiles.emplace_back(p, base); } } diff --git a/tests/unit/test_test_targets.cpp b/tests/unit/test_test_targets.cpp index 28d0a2b27..e7a5d2cf6 100644 --- a/tests/unit/test_test_targets.cpp +++ b/tests/unit/test_test_targets.cpp @@ -210,6 +210,27 @@ version = "0.1.0" expect(eq(result->targets[0].name, "unit/a")); }}; +UTest a_package_below_tests_is_not_its_tests{"TestTargets/APackageBelowTestsIsNotItsTests", [] { + Tmp tmp; + write_manifest(tmp.path, R"([package] +name = "demo" +version = "0.1.0" + +[test] +discover = ["tests/**/*.cpp"] +)"); + write_file(tmp.path / "tests/unit/a.cpp", "int main() {}\n"); + write_manifest(tmp.path / "tests/cts", "[package]\nname = \"cts\"\nversion = \"0.1.0\"\n"); + write_file(tmp.path / "tests/cts/src/main.cpp", "int main() {}\n"); + write_manifest(tmp.path / "tests/e2e/data/app", "[package]\nname = \"app\"\nversion = \"0.1.0\"\n"); + write_file(tmp.path / "tests/e2e/data/app/src/main.cpp", "int main() {}\n"); + + auto result = mcpp::build::discover_test_targets(tmp.path, {}); + if (auto&& failed = expect(fatal(result.has_value())); !failed) failed << result.error(); + expect(fatal(eq(result->targets.size(), 1u))); + expect(eq(result->targets[0].name, "unit/a")); +}}; + UTest broken_manifest_still_returns_inventory{"TestTargets/BrokenManifestStillReturnsInventory", [] { Tmp tmp; write_file(tmp.path / "mcpp.toml", "this is not valid TOML\n");