From ec1ddab67be4aeded7a8503ede0e3767592455a5 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 28 Sep 2026 07:22:58 +0800 Subject: [PATCH 1/2] ci: a failing member names its issue, and the sweep alert names its repository The full sweeps of 2026-09-26 failed on mirror-cn-reachable and on pangocairo (linux), and nothing recorded either (#482): the alert job checks nothing out, so gh could not infer the repository and failed; and it watched only the workspace job. - tests/known-red.tsv lists the members known to fail, per leg, with the issue that owns each; tests/red_members.py, run by the new job 'red members name their issues', lists every failing member with its issue and fails when one has none, or when the members' job failed and no member row can be attributed. Its self-test runs in lint. - The alert sets GH_REPO and also runs when mirror-cn-reachable fails. --- .github/workflows/validate.yml | 44 ++++++++- tests/known-red.tsv | 5 + tests/red_members.py | 161 +++++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 tests/known-red.tsv create mode 100644 tests/red_members.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b909619e..2cd78a94 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -463,6 +463,8 @@ jobs: # cases are about the rule and not about any member. - name: The compatibility classifier agrees with its own rules run: python3 tests/openkal/compat.py selftest + - name: The red-member rule agrees with its own cases + run: python3 tests/red_members.py selftest # ── Single-source-of-truth grammar check ───────────────────────── # `mcpp xpkg parse` uses EXACTLY the resolver's parser, so what # passes here is what builds for users of the pinned MCPP_VERSION. @@ -1584,6 +1586,32 @@ jobs: path: member-timings.tsv retention-days: 90 + # ── Every red member names its issue ──────────────────────────────────── + # The full sweeps of 2026-09-26 failed on a member and on the CN-mirror job, + # and no issue recorded either. A failing member is either listed in + # tests/known-red.tsv with the open issue that owns it, or it fails this job; + # the run summary lists every failing member with its issue (task N2 of the + # 2026-09-28 ecosystem design). `always()`: it reads the rows the members' + # shards uploaded, whatever the members' job concluded. + red-members: + name: red members name their issues + needs: [select, workspace] + if: always() && needs.select.outputs.members != '' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: timings-* + path: timings + continue-on-error: true + - name: Every failing member names its issue + run: | + set -o pipefail + mkdir -p timings + python3 tests/red_members.py report timings tests/known-red.tsv \ + --members-result "${{ needs.workspace.result }}" | tee -a "$GITHUB_STEP_SUMMARY" + # ───────────────────────────────────────────────────────────────────────── # A full sweep that fails must be SEEN. # @@ -1602,10 +1630,10 @@ jobs: # already visible on the PR. sweep-alert: name: full sweep failed — open an issue - needs: [workspace] + needs: [workspace, mirror-cn-reachable, red-members] if: >- always() - && needs.workspace.result == 'failure' + && (needs.workspace.result == 'failure' || needs.mirror-cn-reachable.result == 'failure') && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest permissions: @@ -1614,7 +1642,14 @@ jobs: - name: Open or update the sweep-failure issue env: GH_TOKEN: ${{ github.token }} + # This job checks nothing out, so `gh` cannot infer the repository + # from a work tree: on 2026-09-26 every `gh issue` call here failed + # with "not a git repository", and the failing sweep opened no issue. + GH_REPO: ${{ github.repository }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + FAILED_JOBS: >- + ${{ needs.workspace.result == 'failure' && 'workspace' || '' }} + ${{ needs.mirror-cn-reachable.result == 'failure' && 'mirror-cn-reachable' || '' }} run: | set -euo pipefail TITLE="full workspace sweep is failing" @@ -1629,7 +1664,10 @@ jobs: "This is the only signal that catches a published package broken by an" \ "upstream change — selective CI cannot select a member no diff names." \ "" \ - "Check the \`workspace\` job's failed shards for which members regressed.") + "Failed jobs: $FAILED_JOBS." \ + "" \ + "The \`red members name their issues\` job lists each failing member with" \ + "the issue that owns it (tests/known-red.tsv).") if [ -n "$NUM" ]; then gh issue reopen "$NUM" 2>/dev/null || true gh issue comment "$NUM" --body "$BODY" diff --git a/tests/known-red.tsv b/tests/known-red.tsv new file mode 100644 index 00000000..4a81a59e --- /dev/null +++ b/tests/known-red.tsv @@ -0,0 +1,5 @@ +# Members known to fail in the full sweep, one line each: \t\t. +# A leg is - (linux-default, linux-llvm, macos-default, +# windows-default), or * for every leg. tests/red_members.py fails the sweep's +# summary when a member fails and has no line here; a line is removed when its +# issue is closed. See .github/workflows/validate.yml, job `red-members`. diff --git a/tests/red_members.py b/tests/red_members.py new file mode 100644 index 00000000..8a729eca --- /dev/null +++ b/tests/red_members.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""A failing member names its issue (task N2 of the 2026-09-28 ecosystem design, +mcpp-community/mcpp `.agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md`). + +WHY. The two full sweeps of 2026-09-26 on `main` failed -- one on the +`mirror-cn-reachable` job, one on the member `pangocairo` (linux) -- and nothing +recorded either: the job that opens the sweep issue failed as well, and a red +weekly sweep that no issue names is read by nobody (#482). + +`tests/known-red.tsv` lists the members known to fail, per leg, each with the +open issue that owns it. After the members have run, this script reads every +shard's timing rows (`\t\t`, in the artifacts +`timings---`), and: + +- lists every failing member with its issue, in the run summary; +- fails when a failing member has no issue on its leg; +- fails when the members' job failed and no member row says FAIL (a shard that + failed before testing: nothing can be attributed); +- lists a known-red member that passed, so its line can be removed. + + red_members.py report [--members-result R] + red_members.py selftest + +A leg is `-` (`linux-default`, `linux-llvm`, +`macos-default`, `windows-default`); `*` in known-red.tsv matches every leg. +""" +from __future__ import annotations + +import pathlib +import sys +import tempfile + + +def read_known(path: pathlib.Path) -> dict[tuple[str, str], str]: + known: dict[tuple[str, str], str] = {} + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if not line.strip() or line.lstrip().startswith("#"): + continue + parts = line.split("\t") + if len(parts) != 3 or not all(p.strip() for p in parts): + raise SystemExit(f"{path}:{number}: expected \\t\\t, got {line!r}") + leg, member, issue = (p.strip() for p in parts) + known[(leg, member)] = issue + return known + + +def read_rows(timings: pathlib.Path) -> list[tuple[str, str, str]]: + """(leg, member, status) for every row of every shard.""" + rows = [] + for tsv in sorted(timings.glob("timings-*/timings.tsv")): + # timings--- + name = tsv.parent.name.split("-") + if len(name) < 4: + continue + leg = f"{name[1]}-{name[2]}" + for line in tsv.read_text(encoding="utf-8").splitlines(): + parts = line.split("\t") + if len(parts) == 3: + rows.append((leg, parts[1], parts[2].strip())) + return rows + + +def report(timings: pathlib.Path, known_path: pathlib.Path, members_result: str, + out=sys.stdout) -> int: + known = read_known(known_path) + rows = read_rows(timings) + failing = sorted({(leg, m) for leg, m, s in rows if s == "FAIL"}) + passing = {(leg, m) for leg, m, s in rows if s == "ok"} + + def issue_of(leg: str, member: str) -> str | None: + return known.get((leg, member)) or known.get(("*", member)) + + unattributed = [(leg, m) for leg, m in failing if not issue_of(leg, m)] + print("### Red members", file=out) + print("", file=out) + if failing: + print("| leg | member | issue |", file=out) + print("|---|---|---|", file=out) + for leg, m in failing: + print(f"| {leg} | `{m}` | {issue_of(leg, m) or '**none**'} |", file=out) + else: + print("No member failed.", file=out) + healed = sorted((leg, m) for (leg, m) in known + if leg != "*" and (leg, m) in passing) + if healed: + print("", file=out) + print("Listed in tests/known-red.tsv and passing now (remove the line once " + "its issue is closed):", file=out) + for leg, m in healed: + print(f"- {leg} `{m}` ({known[(leg, m)]})", file=out) + status = 0 + if unattributed: + print("", file=out) + print("A failing member without an issue fails this job: open one and add " + "`\\t\\t` to tests/known-red.tsv, or fix the member.", + file=out) + status = 1 + if members_result == "failure" and not failing: + print("", file=out) + print("The members' job failed, and no member row says FAIL: a shard failed " + "before testing, and nothing can be attributed to a member.", file=out) + status = 1 + return status + + +def selftest() -> int: + def run(rows_by_artifact: dict[str, str], known: str, result: str) -> tuple[int, str]: + with tempfile.TemporaryDirectory() as d: + root = pathlib.Path(d) + for artifact, text in rows_by_artifact.items(): + (root / artifact).mkdir() + (root / artifact / "timings.tsv").write_text(text, encoding="utf-8") + known_path = root / "known-red.tsv" + known_path.write_text(known, encoding="utf-8") + import io + buf = io.StringIO() + code = report(root, known_path, result, out=buf) + return code, buf.getvalue() + + header = "# \t\t\n" + cases = [ + # (name, rows, known, members result, expected status, text that must appear) + ("all green", {"timings-linux-default-0": "10\ta\tok\n"}, header, "success", 0, "No member failed"), + ("a red member without an issue", {"timings-linux-default-0": "10\ta\tFAIL\n"}, header, + "failure", 1, "**none**"), + ("a red member with its issue", {"timings-linux-default-0": "10\ta\tFAIL\n"}, + header + "linux-default\ta\t#1\n", "failure", 0, "| linux-default | `a` | #1 |"), + ("an issue on another leg does not cover", {"timings-linux-llvm-1": "10\ta\tFAIL\n"}, + header + "linux-default\ta\t#1\n", "failure", 1, "**none**"), + ("a wildcard leg covers", {"timings-windows-default-0": "10\ta\tFAIL\n"}, + header + "*\ta\t#2\n", "failure", 0, "#2"), + ("a shard that failed before testing", {"timings-linux-default-0": "10\ta\tok\n"}, header, + "failure", 1, "failed before testing"), + ("a known-red member that passes is named", {"timings-macos-default-0": "10\ta\tok\n"}, + header + "macos-default\ta\t#3\n", "success", 0, "passing now"), + ] + failed = 0 + for name, rows, known, result, want_status, want_text in cases: + status, text = run(rows, known, result) + ok = status == want_status and want_text in text + print(f"{'ok' if ok else 'FAIL'}: {name}") + if not ok: + failed += 1 + print(text) + return 1 if failed else 0 + + +def main(argv: list[str]) -> int: + if argv[:1] == ["selftest"]: + return selftest() + if argv[:1] == ["report"] and len(argv) >= 3: + result = "" + if "--members-result" in argv: + result = argv[argv.index("--members-result") + 1] + return report(pathlib.Path(argv[1]), pathlib.Path(argv[2]), result) + print(__doc__) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) From e58e20bff3dce8e8f76488720320edf403271d65 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 28 Sep 2026 11:57:46 +0800 Subject: [PATCH 2/2] ci: validate every member with mcpp 2026.9.28.2; latest_mcpp -> 2026.9.28.2 2026.9.28.2 (mcpp-community/mcpp#730) changes what a member's build can see on Windows (the runtime placement resolver, the action PATH, the depfile) and the order of matching conditional tables; the one descriptor with two target_cfg predicates, compat.libarchive, names two that never match one target. No descriptor grammar changed, so min_mcpp does not move. --- .github/workflows/validate.yml | 16 +++++++++++++++- index.toml | 7 ++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 2cd78a94..eca1b9f2 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -357,7 +357,21 @@ env: # every member and platform. # # `index.toml` min_mcpp does not move: no descriptor grammar changed. - MCPP_VERSION: "2026.9.28.1" + # ── 2026-09-28: raised 2026.9.28.1 -> 2026.9.28.2 ───────────────────────── + # + # 2026.9.28.2 (mcpp-community/mcpp#730) changes four things a member's build + # can see: on Windows the files beside a program are decided by one runtime + # placement resolver, which places the toolset's C++ runtime over an older + # copy a dependency ships and states that copy once as a packaging fault; on + # Windows every action runs with the toolset's runtime directory first on + # PATH, so each action re-runs once after the raise; a GNU-dialect compile + # on Windows writes a depfile, so those members rebuild once; and matching + # conditional tables apply in order of selector specificity. The one + # descriptor with two `target_cfg` predicates, compat.libarchive, names two + # that never match one target (linux, macos), so no descriptor's flags move. + # + # `index.toml` min_mcpp does not move: no descriptor grammar changed. + MCPP_VERSION: "2026.9.28.2" jobs: lint: diff --git a/index.toml b/index.toml index 51fc70b0..c04d0082 100644 --- a/index.toml +++ b/index.toml @@ -115,7 +115,12 @@ # descriptor grammar. An engine from this release on that a later floor # leaves below min_mcpp reports the floor as a closing tip of the command that # refreshed the index, not as an error at the start of every command. +# ── 2026-09-28: latest_mcpp -> 2026.9.28.2, min_mcpp unchanged ───────────── +# +# Moved in the pull request that moves validate.yml's pin to 2026.9.28.2, +# which runs every member on every platform with it. The release changes no +# descriptor grammar. [index] spec = "1" min_mcpp = "2026.9.18.3" -latest_mcpp = "2026.9.28.1" +latest_mcpp = "2026.9.28.2"