diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b909619e..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: @@ -463,6 +477,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 +1600,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 +1644,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 +1656,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 +1678,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/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" 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:]))