Skip to content

ci: say which boards failed and why, not just that the nightly is red - #2297

Open
openipc-ai wants to merge 2 commits into
masterfrom
ci-nightly-build-summary
Open

ci: say which boards failed and why, not just that the nightly is red#2297
openipc-ai wants to merge 2 commits into
masterfrom
ci-nightly-build-summary

Conversation

@openipc-ai

@openipc-ai openipc-ai commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Closes #2271.

The question, and the answer

build.yml builds ~99 matrix cells with fail-fast: false. One failing cell does not cancel the
others, but the run still reports conclusion: failure. #2271 asked whether that is the signal we
want, and said explicitly that "current behaviour is correct" is a legitimate result — the point
was to make it a decision rather than an accident of the default.

The polarity does not change. The legibility does. The reasoning is now a comment in ci-gate
rather than an unstated default:

  • The nightly's product is a complete set of images. A board that failed leaves nightly and
    latest pointing at yesterday's image for it, so whoever flashes that board silently gets stale
    firmware. Green would be a lie about what got published.
  • The transient classes are already absorbed upstream of the gate. Anything that reaches it
    survived seven attempts across ~38 minutes of backoff (Nightly CI is flaky on master — package download 404s break ~30% of scheduled runs #2036); that is no longer a blip.
  • ci-gate serves pull requests too. Relaxing the matrix rule to quieten the nightly would relax
    branch protection with it.

Measured over the 30 scheduled runs from 2026-07-23 to 2026-08-21: 1 failure. Red is rare, so
the alert works. The problem was never the colour.

The actual problem

On 2026-08-12 (run 31649720825) a
majestic-webui fixup did a bare rm on two files upstream had deleted, and 94 of 99 boards
failed in the same place. Establishing that took opening 94 job logs, because the run's entire
output was the word "failure". "The nightly is red" does not distinguish one broken board from
ninety-four.

What this does

1. Names the cause where the evidence is. Buildroot says which package and which phase in
exactly one line and nowhere else:

make[1]: *** [package/pkg-generic.mk:374: .../majestic-webui-dist/.stamp_target_installed] Error 1

The retry loop already holds that log, so it now classifies out of it and folds the result into
the ::error::/::warning:: it already emitted. The phase is the class: .stamp_downloaded
is a mirror or a 404 (the #2036 family), anything after it is real code. No new steps, no new
artifacts.

-echo "::error::build failed after ${attempt} attempts"
+echo "::error::${{matrix.platform}}: build failed after ${attempt} attempts — ${cause}"

2. A Build summary job that aggregates it once, into the run summary:

  • N of M failed, grouped by cause, so 94-boards-one-bug reads as one row.
  • Flakes the retry loop absorbed — a board that went green on its third attempt is invisible
    today, and the same cause exhausting its budget is what turns the nightly red. This is the
    leading indicator, and nothing surfaced it.
  • Headroom warnings rolled up. The dry run against last night's green nightly already found
    gk7205v300_lite at 0KB free of 5120KB.
  • Last 14 scheduled runs per board, so "hi3516ev300_neo has been red three nights running"
    is visible without digging through run history.

Nothing is stored: three read-only endpoints, ~130 requests, and the footer prints the count it
actually used (the token budget is 1000/hr and publish already spends ~400 on paced uploads).

3. It cannot fail the run. That would be the exact mistake #2271 decided not to make, so the
job is deliberately not in ci-gate's needs and its step swallows a non-zero exit into a
warning. What keeps it honest instead is build-summary.py --self-test in lint.yml, a merge
gate that asserts the grammars still match what build.yml emits — a reworded ::error:: would
otherwise reduce the summariser to reporting nothing, and reporting nothing looks exactly like a
quiet night.

Sample output

Dry-run against run 32534401852
(a green nightly), abridged:

## nightly-20260821-...

**All 99 boards built.**

### Headroom warnings

| Board                  | Image           | Free  | Cap     |
|------------------------|-----------------|------:|--------:|
| `gk7205v300_lite`      | rootfs.squashfs |   0KB | 5120KB  |
| `hi3516ev300_lite`     | uImage          |   1KB | 2048KB  |
| `hi3516ev300_ultimate` | uImage          |   1KB | 2048KB  |
| `gk7205v200_lite`      | rootfs.squashfs |  12KB | 5120KB  |
...

### Last 14 scheduled runs

Every board passed in every run in the window.

<sub>108 API request(s).</sub>

Test plan

None of this is reachable from PR CI — the summary job only runs where there is a matrix, which is
the same gap that motivated lint-workflow-shell.py — so it was proven against real data before
this PR existed.

  • python3 .github/scripts/build-summary.py --self-test — 4 grammars, 7 annotations in
    build.yml, 99 fixture boards.
  • The drift gate actually catches drift. Reworded an annotation in build.yml:
    grammar 'exhausted' no longer matches build.yml. Added an unclassified one:
    build.yml's matrix job emits 8 annotations, 7 are classified. Both restored.
  • classify_failure against the real 2026-08-12 log, with the function extracted from the
    workflow YAML rather than retyped → majestic-webui-dist/target_installed. Synthetic
    download failure → opus-1.4/downloaded; repack failure → Makefile:210: repack; nothing
    recognisable → unclassified.
  • End to end: that cause, formatted into the exact annotation build.yml now emits, parses
    back to cause majestic-webui-dist/target_installed and attempts: 7.
  • Dry-run against real runs. 31649720825 → 92 of 96 boards failed in 8s / 97 requests
    (it predates the new annotations, so it correctly degrades to step: Build firmware rather
    than crashing). 32534401852 → All 99 boards built, headroom table above, 15s.
  • python3 .github/scripts/ci-matrix.py --self-test — ok (99 boards, 132 packages, 52 cases).
  • python3 .github/scripts/lint-workflow-shell.py [--self-test] — 48 run blocks parse clean.
  • bash .github/scripts/test_load_hisilicon.sh, bash .github/scripts/test_sysupgrade.sh — pass.
  • First nightly after merge: read the rendered summary and check the footer's request count
    against the budget. --no-flakes drops the bulk of it if it ever crowds publish.

Notes for review

  • build-summary.py is added to ci-matrix.py's NO_BUILD_SCRIPTS. It cannot change a byte of an
    image, and unclassified it would widen every PR touching it to the full 99-board matrix.
    Touching ci-matrix.py to say so widens this PR to the full matrix — ci-matrix.py is
    deliberately unclassified, since a smoke set would build the boards that were already in
    ALL_BOARDS and never a newly added one. That is one full matrix now to stop every future PR
    touching the summariser from paying the same. The build.yml edit on its own would have taken
    the 13-board smoke set.
  • The annotations endpoint is addressed as /check-runs/{job_id}/annotations, which relies on a
    job id being usable as a check run id. That is true today and is verified against both runs
    above, but it is not documented as a guarantee — a 404 there degrades one board to "no detail",
    never to a crash, and the counts stay exact.
  • The lint.yml self-test is a separate job rather than a step in workflow run blocks parse, so
    no existing check context is renamed and branch protection is untouched.
  • Stdlib only, so no install step anywhere.

build.yml builds ~99 boards with fail-fast: false, and one failing cell turns
the run red. #2271 asked whether that is the signal we want. It is, and the
reasoning is now written into ci-gate rather than left as the default it
started as: the nightly's product is a complete set of images, and a board
that failed leaves `nightly` and `latest` holding yesterday's image for it, so
green would be a lie about what got published. The transient classes are
already absorbed by the seven-attempt backoff (#2036), and the same gate serves
pull requests, so relaxing it to quieten the nightly would relax branch
protection with it.

What was actually wrong is that the colour was the only output. On 2026-08-12
(run 31649720825) a majestic-webui fixup did a bare rm on two files upstream
had deleted and 94 of 99 boards failed in the same place — establishing that
took opening 94 job logs, because the run said, in full, "failure".

So: name the cause where the evidence is, and aggregate it once.

- The retry loop now classifies each failure out of the build log it already
  has. Buildroot names the package and the phase in exactly one line, and the
  phase is the class (.stamp_downloaded is a mirror or a 404, anything later is
  real code). That goes into the ::error:: and ::warning:: it already emitted.
- A new Build summary job renders the run: N of M failed grouped by cause,
  the flakes the backoff absorbed (invisible until now, and the thing that
  predicts the nightly going red again), headroom warnings, and a per-board
  strip over the last 14 scheduled runs so "red three nights running" is
  visible without digging through run history. Nothing is stored; it is three
  read-only API endpoints and ~130 requests, and the footer prints the count
  it used.
- Reporting only. It cannot fail the run — that would be the exact mistake
  #2271 decided not to make — so it is not in ci-gate's needs and its step
  swallows a non-zero exit into a warning. What keeps it honest instead is
  build-summary.py --self-test in lint.yml, a merge gate that asserts the
  grammars still match the annotations build.yml emits: a reworded ::error::
  would otherwise reduce the summariser to reporting nothing, which looks
  exactly like a quiet night.

Verified before merge, since none of this is reachable from PR CI: the
classifier extracted from build.yml gives majestic-webui-dist/target_installed
on the real 2026-08-12 log, that exact annotation parses back to the same cause
and 7 attempts, and the summariser was dry-run against runs 31649720825 and
32534401852 (which surfaced gk7205v300_lite at 0KB free of 5120KB).

build-summary.py goes in ci-matrix.py's NO_BUILD_SCRIPTS: it cannot change a
byte of an image, and unclassified it would widen every PR touching it to the
full 99-board matrix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

CI: add per-board failure causes and a nightly build summary

✨ Enhancement ⚙️ Configuration changes 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Classify matrix build failures by board and root cause directly from Buildroot logs.
• Add a non-gating “Build summary” job that aggregates failures, flakes, headroom, and streaks.
• Add a lint self-test to prevent drift between workflow annotations and the summariser.
Diagram

graph TD
  A["build.yml workflow"] --> B["Matrix build jobs"] --> C["Workflow annotations"]
  D["Build summary job"] --> E["build-summary.py"] --> F[("GitHub REST API")]
  F --> B --> C
  G["lint.yml self-test job"] --> E --> A
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Artifact-based aggregation (upload per-board JSON, then download and summarise)
  • ➕ Avoids per-board check-run annotation API calls (lower REST budget, fewer rate-limit risks).
  • ➕ Uses stable Actions primitives (artifacts) instead of the undocumented job-id==check-run-id behavior.
  • ➖ Adds artifact upload/download steps to every matrix cell (time + storage + failure surface).
  • ➖ Still needs structured data emission per board; increases workflow complexity.
2. GitHub Actions outputs-only aggregation (needs/outputs from matrix)
  • ➕ No external API calls; all data stays within the workflow execution context.
  • ➖ Matrix jobs can’t practically emit rich per-board structured outputs at this scale (size limits; awkward fan-in).
  • ➖ Harder to include historical streaks without still calling the API.

Recommendation: Keep the PR’s approach: emitting structured, human-meaningful annotations where the evidence is (the retry loop’s build log), then aggregating via a non-gating summary job. The design explicitly preserves the existing failure polarity while making runs legible, and the lint self-test meaningfully mitigates the main risk (annotation format drift). If API budget becomes a problem, the existing --no-flakes knob is a good first lever before moving to artifact aggregation.

Files changed (4) +824 / -5

Enhancement (2) +797 / -3
build-summary.pyAdd GitHub Actions matrix summariser with drift-protected grammars +683/-0

Add GitHub Actions matrix summariser with drift-protected grammars

• Introduces a stdlib-only script that fetches run jobs, check-run annotations, and recent scheduled-run history to render a single markdown summary. It groups failures by classified cause, reports absorbed flakes (retries that eventually passed), rolls up headroom warnings, and shows per-board streak strips for the last N scheduled runs. Includes a comprehensive --self-test that validates parsing/rendering and asserts build.yml annotation anchors/coverage to prevent silent drift.

.github/scripts/build-summary.py

build.ymlEmit per-board failure causes and add a non-gating Build summary job +114/-3

Emit per-board failure causes and add a non-gating Build summary job

• Enhances the retry loop to classify Buildroot failures from the existing captured build log and include the board + cause in ::error::/::warning:: annotations (including retries that later succeed). Adds a new ‘Build summary’ job that runs regardless of matrix success (but only when the matrix ran), uses read-only permissions, and cannot fail the workflow. Expands ci-gate commentary to explicitly justify keeping ‘one failing cell makes the run fail’, and points failures to the Build summary for breakdown.

.github/workflows/build.yml

Tests (1) +25 / -0
lint.ymlAdd build-summary self-test job to prevent annotation grammar drift +25/-0

Add build-summary self-test job to prevent annotation grammar drift

• Ensures changes to workflows/scripts trigger lint, and adds a dedicated job that runs build-summary.py --self-test. This makes annotation wording/coverage mismatches a merge-time failure while keeping the nightly summary job itself non-gating.

.github/workflows/lint.yml

Other (1) +2 / -2
ci-matrix.pyExclude build-summary.py from triggering full build matrix +2/-2

Exclude build-summary.py from triggering full build matrix

• Adds build-summary.py to NO_BUILD_SCRIPTS so edits to the summariser do not widen CI into the full 99-board matrix. Keeps CI behavior consistent with other CI-only plumbing scripts.

.github/scripts/ci-matrix.py

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 22, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Racy API call counter ✓ Resolved 🐞 Bug ◔ Observability
Description
Api.calls is incremented from multiple ThreadPoolExecutor threads without synchronization, so the
displayed API request count can be wrong (lost updates). This undermines the script’s own
rate-budget reporting and makes regressions in API usage harder to spot.
Code

.github/scripts/build-summary.py[R158-161]

+                "X-GitHub-Api-Version": "2022-11-28",
+            })
+            self.calls += 1
+            try:
Evidence
The new script performs concurrent API requests using ThreadPoolExecutor, but increments
self.calls inside Api.get() without any synchronization; in CPython this can lose increments
under contention, producing an incorrect request count in the footer/log output.

.github/scripts/build-summary.py[141-178]
.github/scripts/build-summary.py[291-295]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Api.calls` is updated concurrently from multiple threads without a lock, so the final count can be under-reported.
### Issue Context
`collect_run()` and `collect_history()` fetch data using `ThreadPoolExecutor`, calling `Api.get()` from worker threads.
### Fix Focus Areas
- .github/scripts/build-summary.py[141-178]
- .github/scripts/build-summary.py[291-295]
### Suggested fix
Add a `threading.Lock` (e.g., `self._calls_lock`) in `Api.__init__` and wrap `self.calls += 1` with the lock. Alternatively, remove shared mutation and return per-call counts from workers and sum them in the main thread.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Unencoded query parameters ✓ Resolved 🐞 Bug ☼ Reliability
Description
Api.get() concatenates query parameters without URL encoding, so any parameter value containing
reserved characters (e.g., spaces, '+', '&') can produce malformed requests or wrong server
interpretation. This is brittle for future params and makes the helper unsafe as a general API
client.
Code

.github/scripts/build-summary.py[R148-151]

+        url = f"{API}{path}"
+        if params:
+            url += "?" + "&".join(f"{k}={v}" for k, v in params.items())
+        # Six attempts covers a secondary-rate-limit pause; the same shape the
Evidence
The new Api.get() builds ?k=v&... directly from params.items() without applying URL encoding,
which is incorrect for general URL construction and can change request semantics when reserved
characters are present.

.github/scripts/build-summary.py[147-151]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Query strings are built with manual string concatenation and no URL escaping.
### Issue Context
Although current callers pass simple values, this helper will break if any new parameter contains reserved characters.
### Fix Focus Areas
- .github/scripts/build-summary.py[147-151]
### Suggested fix
Import `urllib.parse` and replace manual construction with:

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

3. Repeated set creation in loop ✓ Resolved 🐞 Bug ➹ Performance
Description
render() rebuilds set(boards) inside a comprehension for every element of failed, causing
unnecessary repeated work. This is small today but is an avoidable quadratic pattern in a function
that already iterates multiple times over the matrix.
Code

.github/scripts/build-summary.py[R399-402]

+            attempts = {r["attempts"] for r in failed if r["board"] in set(boards)}
+            suffix = (f" (after {max(attempts)} attempts)"
+                      if max(attempts) > 1 else "")
+            out.append(f"| `{cause}`{suffix} | {len(boards)} "
Evidence
The code calls set(boards) inside the set-comprehension filter, so the set is rebuilt on every
iteration over failed for that group.

.github/scripts/build-summary.py[394-403]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`set(boards)` is constructed repeatedly inside the comprehension used to compute `attempts`.
### Issue Context
This runs per failure-cause group and loops over `failed` entries, rebuilding the same set many times.
### Fix Focus Areas
- .github/scripts/build-summary.py[394-404]
### Suggested fix
Move `board_set = set(boards)` outside the comprehension:

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can commit Qodo's fix in one click with committable suggestions (GitHub & GitLab)

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread .github/scripts/build-summary.py
Comment thread .github/scripts/build-summary.py
…elf a nightly

Review on #2297.

- Api.calls was incremented from up to eight worker threads without a lock.
  `+=` is load-add-store, not one step, so increments can be lost. An
  under-report is the one wrong answer this counter must not give: its whole
  job is to make the API budget measured rather than assumed.
- Query strings were concatenated by hand. Every value passed today is a bare
  word or an int, but urlencode is one line and the helper should not be a
  trap for the next parameter.
- The failures-by-cause table rebuilt set(boards) once per element of the
  group. Grouping the result objects instead of the board names drops the set
  entirely and reads better.
- preflight computes build_id as nightly-<date>-<sha> for every event, so a
  pull request's summary was headed with the name of a nightly release that
  does not exist. Only the events that publish one use it now; everything else
  falls back to the run id, and report_title() is covered by --self-test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@openipc-ai

Copy link
Copy Markdown
Collaborator Author

Thanks — all three findings were real. Fixed in 0fce083, plus one wart of my own that the review made me look at.

1. Racy API counter. Correct, and it matters more than a lost integer usually would: the counter exists so the API budget is measured rather than assumed, and an under-report is precisely the wrong answer — it would say the job is cheaper than it is, right up until it crowds publish's ~400 uploads. self.calls += 1 is load-add-store across up to eight worker threads. Now under a threading.Lock.

2. Unencoded query parameters. Every value passed today is a bare word or an int, so nothing is broken now — but the helper reads as a general client and shouldn't be a trap for the next parameter. Replaced with urllib.parse.urlencode.

3. set(boards) rebuilt per element. Taking the suggestion one step further: the loop was grouping board names and then going back to failed to recover the attempt counts. Grouping the result objects instead drops the set entirely and is shorter:

groups[result["cause"] or "unclassified"].append(result)
...
attempts = max(result["attempts"] for result in group)
boards = [result["board"] for result in group]

4. Not from the review, but adjacent and worth fixing in the same push: preflight computes build_id as nightly-<date>-<sha> for every event, so a pull request's summary was headed with the name of a nightly release that does not exist. Only schedule and workflow_dispatch use it now; everything else falls back to Build <run_id>. Pulled out as report_title() so --self-test covers it.

Re-verified: --self-test passes (4 grammars, 7 annotations, 99 fixture boards, and the new title cases). Dry runs still correct against real runs — 32534401852 as a PR now reads ## Build 32534401852 at exactly 101 requests (2 job pages + 99 annotations, which is the count being exact rather than approximately right), and 31649720825 as a schedule still reads ## nightly-20260812-1fa881 / 92 of 96 boards failed.

@openipc-ai

Copy link
Copy Markdown
Collaborator Author

CI is fully green, and the new job ran for real rather than just being parsed.

Firmware (…) 99/99 pass
CI Gate pass
Build summary pass
build summariser agrees with build.yml pass
workflow run blocks parse, shipped shell scripts parse, sysupgrade rootfs verification, load_hisilicon os_mem_size derivation pass
qodo-gate pass
Publish releases skipped (no artifacts on a PR, as designed)

From the Build summary job's own log (run 32591745450):

build-summary: 0 of 99 boards failed, 101 API request(s)

101 is the number to look at. It is exactly 2 job-list pages + 99 annotation fetches, and it matches the local dry run against a different run to the request — which is the point of putting the counter behind a lock. No traceback, no ::warning::build summary failed fallback, and the job did not touch the gate.

The one test-plan box still open is the post-merge one: read the first nightly's rendered summary and check the request count with the 14-run streak window attached (~130 rather than 101) against the budget publish is already drawing on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decide whether one failing matrix cell should fail the whole nightly run

1 participant