Skip to content

Commit ec1ddab

Browse files
committed
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.
1 parent 1041a95 commit ec1ddab

3 files changed

Lines changed: 207 additions & 3 deletions

File tree

‎.github/workflows/validate.yml‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,8 @@ jobs:
463463
# cases are about the rule and not about any member.
464464
- name: The compatibility classifier agrees with its own rules
465465
run: python3 tests/openkal/compat.py selftest
466+
- name: The red-member rule agrees with its own cases
467+
run: python3 tests/red_members.py selftest
466468
# ── Single-source-of-truth grammar check ─────────────────────────
467469
# `mcpp xpkg parse` uses EXACTLY the resolver's parser, so what
468470
# passes here is what builds for users of the pinned MCPP_VERSION.
@@ -1584,6 +1586,32 @@ jobs:
15841586
path: member-timings.tsv
15851587
retention-days: 90
15861588

1589+
# ── Every red member names its issue ────────────────────────────────────
1590+
# The full sweeps of 2026-09-26 failed on a member and on the CN-mirror job,
1591+
# and no issue recorded either. A failing member is either listed in
1592+
# tests/known-red.tsv with the open issue that owns it, or it fails this job;
1593+
# the run summary lists every failing member with its issue (task N2 of the
1594+
# 2026-09-28 ecosystem design). `always()`: it reads the rows the members'
1595+
# shards uploaded, whatever the members' job concluded.
1596+
red-members:
1597+
name: red members name their issues
1598+
needs: [select, workspace]
1599+
if: always() && needs.select.outputs.members != ''
1600+
runs-on: ubuntu-latest
1601+
steps:
1602+
- uses: actions/checkout@v4
1603+
- uses: actions/download-artifact@v4
1604+
with:
1605+
pattern: timings-*
1606+
path: timings
1607+
continue-on-error: true
1608+
- name: Every failing member names its issue
1609+
run: |
1610+
set -o pipefail
1611+
mkdir -p timings
1612+
python3 tests/red_members.py report timings tests/known-red.tsv \
1613+
--members-result "${{ needs.workspace.result }}" | tee -a "$GITHUB_STEP_SUMMARY"
1614+
15871615
# ─────────────────────────────────────────────────────────────────────────
15881616
# A full sweep that fails must be SEEN.
15891617
#
@@ -1602,10 +1630,10 @@ jobs:
16021630
# already visible on the PR.
16031631
sweep-alert:
16041632
name: full sweep failed — open an issue
1605-
needs: [workspace]
1633+
needs: [workspace, mirror-cn-reachable, red-members]
16061634
if: >-
16071635
always()
1608-
&& needs.workspace.result == 'failure'
1636+
&& (needs.workspace.result == 'failure' || needs.mirror-cn-reachable.result == 'failure')
16091637
&& (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
16101638
runs-on: ubuntu-latest
16111639
permissions:
@@ -1614,7 +1642,14 @@ jobs:
16141642
- name: Open or update the sweep-failure issue
16151643
env:
16161644
GH_TOKEN: ${{ github.token }}
1645+
# This job checks nothing out, so `gh` cannot infer the repository
1646+
# from a work tree: on 2026-09-26 every `gh issue` call here failed
1647+
# with "not a git repository", and the failing sweep opened no issue.
1648+
GH_REPO: ${{ github.repository }}
16171649
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
1650+
FAILED_JOBS: >-
1651+
${{ needs.workspace.result == 'failure' && 'workspace' || '' }}
1652+
${{ needs.mirror-cn-reachable.result == 'failure' && 'mirror-cn-reachable' || '' }}
16181653
run: |
16191654
set -euo pipefail
16201655
TITLE="full workspace sweep is failing"
@@ -1629,7 +1664,10 @@ jobs:
16291664
"This is the only signal that catches a published package broken by an" \
16301665
"upstream change — selective CI cannot select a member no diff names." \
16311666
"" \
1632-
"Check the \`workspace\` job's failed shards for which members regressed.")
1667+
"Failed jobs: $FAILED_JOBS." \
1668+
"" \
1669+
"The \`red members name their issues\` job lists each failing member with" \
1670+
"the issue that owns it (tests/known-red.tsv).")
16331671
if [ -n "$NUM" ]; then
16341672
gh issue reopen "$NUM" 2>/dev/null || true
16351673
gh issue comment "$NUM" --body "$BODY"

‎tests/known-red.tsv‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Members known to fail in the full sweep, one line each: <leg>\t<member>\t<issue>.
2+
# A leg is <platform>-<toolchain> (linux-default, linux-llvm, macos-default,
3+
# windows-default), or * for every leg. tests/red_members.py fails the sweep's
4+
# summary when a member fails and has no line here; a line is removed when its
5+
# issue is closed. See .github/workflows/validate.yml, job `red-members`.

‎tests/red_members.py‎

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
#!/usr/bin/env python3
2+
"""A failing member names its issue (task N2 of the 2026-09-28 ecosystem design,
3+
mcpp-community/mcpp `.agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md`).
4+
5+
WHY. The two full sweeps of 2026-09-26 on `main` failed -- one on the
6+
`mirror-cn-reachable` job, one on the member `pangocairo` (linux) -- and nothing
7+
recorded either: the job that opens the sweep issue failed as well, and a red
8+
weekly sweep that no issue names is read by nobody (#482).
9+
10+
`tests/known-red.tsv` lists the members known to fail, per leg, each with the
11+
open issue that owns it. After the members have run, this script reads every
12+
shard's timing rows (`<seconds>\t<member>\t<ok|FAIL>`, in the artifacts
13+
`timings-<platform>-<toolchain>-<shard>`), and:
14+
15+
- lists every failing member with its issue, in the run summary;
16+
- fails when a failing member has no issue on its leg;
17+
- fails when the members' job failed and no member row says FAIL (a shard that
18+
failed before testing: nothing can be attributed);
19+
- lists a known-red member that passed, so its line can be removed.
20+
21+
red_members.py report <timings dir> <known-red.tsv> [--members-result R]
22+
red_members.py selftest
23+
24+
A leg is `<platform>-<toolchain>` (`linux-default`, `linux-llvm`,
25+
`macos-default`, `windows-default`); `*` in known-red.tsv matches every leg.
26+
"""
27+
from __future__ import annotations
28+
29+
import pathlib
30+
import sys
31+
import tempfile
32+
33+
34+
def read_known(path: pathlib.Path) -> dict[tuple[str, str], str]:
35+
known: dict[tuple[str, str], str] = {}
36+
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
37+
if not line.strip() or line.lstrip().startswith("#"):
38+
continue
39+
parts = line.split("\t")
40+
if len(parts) != 3 or not all(p.strip() for p in parts):
41+
raise SystemExit(f"{path}:{number}: expected <leg>\\t<member>\\t<issue>, got {line!r}")
42+
leg, member, issue = (p.strip() for p in parts)
43+
known[(leg, member)] = issue
44+
return known
45+
46+
47+
def read_rows(timings: pathlib.Path) -> list[tuple[str, str, str]]:
48+
"""(leg, member, status) for every row of every shard."""
49+
rows = []
50+
for tsv in sorted(timings.glob("timings-*/timings.tsv")):
51+
# timings-<platform>-<toolchain>-<shard>
52+
name = tsv.parent.name.split("-")
53+
if len(name) < 4:
54+
continue
55+
leg = f"{name[1]}-{name[2]}"
56+
for line in tsv.read_text(encoding="utf-8").splitlines():
57+
parts = line.split("\t")
58+
if len(parts) == 3:
59+
rows.append((leg, parts[1], parts[2].strip()))
60+
return rows
61+
62+
63+
def report(timings: pathlib.Path, known_path: pathlib.Path, members_result: str,
64+
out=sys.stdout) -> int:
65+
known = read_known(known_path)
66+
rows = read_rows(timings)
67+
failing = sorted({(leg, m) for leg, m, s in rows if s == "FAIL"})
68+
passing = {(leg, m) for leg, m, s in rows if s == "ok"}
69+
70+
def issue_of(leg: str, member: str) -> str | None:
71+
return known.get((leg, member)) or known.get(("*", member))
72+
73+
unattributed = [(leg, m) for leg, m in failing if not issue_of(leg, m)]
74+
print("### Red members", file=out)
75+
print("", file=out)
76+
if failing:
77+
print("| leg | member | issue |", file=out)
78+
print("|---|---|---|", file=out)
79+
for leg, m in failing:
80+
print(f"| {leg} | `{m}` | {issue_of(leg, m) or '**none**'} |", file=out)
81+
else:
82+
print("No member failed.", file=out)
83+
healed = sorted((leg, m) for (leg, m) in known
84+
if leg != "*" and (leg, m) in passing)
85+
if healed:
86+
print("", file=out)
87+
print("Listed in tests/known-red.tsv and passing now (remove the line once "
88+
"its issue is closed):", file=out)
89+
for leg, m in healed:
90+
print(f"- {leg} `{m}` ({known[(leg, m)]})", file=out)
91+
status = 0
92+
if unattributed:
93+
print("", file=out)
94+
print("A failing member without an issue fails this job: open one and add "
95+
"`<leg>\\t<member>\\t<issue>` to tests/known-red.tsv, or fix the member.",
96+
file=out)
97+
status = 1
98+
if members_result == "failure" and not failing:
99+
print("", file=out)
100+
print("The members' job failed, and no member row says FAIL: a shard failed "
101+
"before testing, and nothing can be attributed to a member.", file=out)
102+
status = 1
103+
return status
104+
105+
106+
def selftest() -> int:
107+
def run(rows_by_artifact: dict[str, str], known: str, result: str) -> tuple[int, str]:
108+
with tempfile.TemporaryDirectory() as d:
109+
root = pathlib.Path(d)
110+
for artifact, text in rows_by_artifact.items():
111+
(root / artifact).mkdir()
112+
(root / artifact / "timings.tsv").write_text(text, encoding="utf-8")
113+
known_path = root / "known-red.tsv"
114+
known_path.write_text(known, encoding="utf-8")
115+
import io
116+
buf = io.StringIO()
117+
code = report(root, known_path, result, out=buf)
118+
return code, buf.getvalue()
119+
120+
header = "# <leg>\t<member>\t<issue>\n"
121+
cases = [
122+
# (name, rows, known, members result, expected status, text that must appear)
123+
("all green", {"timings-linux-default-0": "10\ta\tok\n"}, header, "success", 0, "No member failed"),
124+
("a red member without an issue", {"timings-linux-default-0": "10\ta\tFAIL\n"}, header,
125+
"failure", 1, "**none**"),
126+
("a red member with its issue", {"timings-linux-default-0": "10\ta\tFAIL\n"},
127+
header + "linux-default\ta\t#1\n", "failure", 0, "| linux-default | `a` | #1 |"),
128+
("an issue on another leg does not cover", {"timings-linux-llvm-1": "10\ta\tFAIL\n"},
129+
header + "linux-default\ta\t#1\n", "failure", 1, "**none**"),
130+
("a wildcard leg covers", {"timings-windows-default-0": "10\ta\tFAIL\n"},
131+
header + "*\ta\t#2\n", "failure", 0, "#2"),
132+
("a shard that failed before testing", {"timings-linux-default-0": "10\ta\tok\n"}, header,
133+
"failure", 1, "failed before testing"),
134+
("a known-red member that passes is named", {"timings-macos-default-0": "10\ta\tok\n"},
135+
header + "macos-default\ta\t#3\n", "success", 0, "passing now"),
136+
]
137+
failed = 0
138+
for name, rows, known, result, want_status, want_text in cases:
139+
status, text = run(rows, known, result)
140+
ok = status == want_status and want_text in text
141+
print(f"{'ok' if ok else 'FAIL'}: {name}")
142+
if not ok:
143+
failed += 1
144+
print(text)
145+
return 1 if failed else 0
146+
147+
148+
def main(argv: list[str]) -> int:
149+
if argv[:1] == ["selftest"]:
150+
return selftest()
151+
if argv[:1] == ["report"] and len(argv) >= 3:
152+
result = ""
153+
if "--members-result" in argv:
154+
result = argv[argv.index("--members-result") + 1]
155+
return report(pathlib.Path(argv[1]), pathlib.Path(argv[2]), result)
156+
print(__doc__)
157+
return 2
158+
159+
160+
if __name__ == "__main__":
161+
sys.exit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)