Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 56 additions & 4 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
#
Expand All @@ -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:
Expand All @@ -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"
Expand All @@ -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"
Expand Down
7 changes: 6 additions & 1 deletion index.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
5 changes: 5 additions & 0 deletions tests/known-red.tsv
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Members known to fail in the full sweep, one line each: <leg>\t<member>\t<issue>.
# A leg is <platform>-<toolchain> (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`.
161 changes: 161 additions & 0 deletions tests/red_members.py
Original file line number Diff line number Diff line change
@@ -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 (`<seconds>\t<member>\t<ok|FAIL>`, in the artifacts
`timings-<platform>-<toolchain>-<shard>`), 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 <timings dir> <known-red.tsv> [--members-result R]
red_members.py selftest

A leg is `<platform>-<toolchain>` (`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 <leg>\\t<member>\\t<issue>, 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-<platform>-<toolchain>-<shard>
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 "
"`<leg>\\t<member>\\t<issue>` 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 = "# <leg>\t<member>\t<issue>\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:]))
Loading