Skip to content

feat(ci): add a self-serve build-health tool, raise the bazel matrix cap - #656

Open
balajinvda wants to merge 5 commits into
mainfrom
fix/ci-cache-quota
Open

feat(ci): add a self-serve build-health tool, raise the bazel matrix cap#656
balajinvda wants to merge 5 commits into
mainfrom
fix/ci-cache-quota

Conversation

@balajinvda

@balajinvda balajinvda commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Why

Two questions came up that nobody could answer without hand-rolling gh api
calls: are the caches healthy, and why is the build slow.

The caches were not healthy. The repo sat at 9.42 GB of its 10 GB quota (94%,
and 97% by the time this was written), which means GitHub is evicting
least-recently-used entries continuously. That failure mode is invisible in a
green pipeline: jobs still pass, they just stop being fast. Two specific causes:

  • dependency-docs-bazel held 4.19 GB across three copies, 42% of the entire
    quota, because every branch saved its own full-size copy.
  • 1.90 GB sat on gh-readonly-queue/** refs. Those branches are deleted when
    the merge queue drains, so the entries can never be restored, but they still
    count against quota until evicted.

Answering the second question needed a tool, and the first version of that tool
was wrong in a way worth calling out: it sampled actions/runs?per_page=100 and
filtered by workflow name client-side, so it saw 14 of 1275 bazel runs. That led
me to state there was no history to trend against. There are three full weeks.

What changed

Cache:

  • Split both cache families into actions/cache/restore + guarded
    actions/cache/save. Dependency-docs saves only from main; bazel skips
    merge-queue refs. Restores are unchanged everywhere, so no job gets colder.

tools/ci-health (Go, with tools/ci/ci-health as the stable entrypoint):

  • Reads the per-workflow runs endpoint and pages through it.
  • --dashboard writes a self-contained HTML report and opens it. Inline SVG,
    no CDN and no JavaScript dependency, so it works offline and adds nothing to
    the dependency surface.
  • Separates queue wait from execution time. The Actions UI only shows the sum,
    and the distinction turns out to matter: stargate spends roughly 6.1 min
    waiting for a runner against roughly 3.4 min building.
  • Ranks the causes of slowness rather than dumping metrics.
  • Drops the oldest week from the trend when the history window was truncated;
    only the tail of that week is held, so its median plotted as a misleading
    near-zero point.
  • Flags when change detection skipped most matrix rows, so a fast median is not
    misread as a speedup.

Matrix:

  • max-parallel 8 to 12 on the bazel matrix.

Customer Release Notes

Not customer visible.

Plan Summary

Not applicable.

Usage

tools/ci/ci-health --dashboard     # visual report, opens in a browser
tools/ci/ci-health --why           # same findings, as text
tools/ci/ci-health                 # cache and quota only
tools/ci/ci-health --durations     # duration percentiles by week
tools/ci/ci-health --merge-times   # PR open to merge latency

Testing

go test ./tools/ci-health/...: 37 tests, no third-party dependencies. Each
case corresponds to a defect this tool actually shipped with, including the two
found in review: bare-array pagination and single-page cache listing. Coverage
includes percentile interpolation, the partial-week drop, skipped-matrix
classification, the queue/execution split, long-pole attribution excluding gate
jobs, tolerance of failed job fetches, and that the dashboard stays
self-contained.

Every subcommand was run against live data, including error paths. The dashboard
was rendered headless and inspected, not just parsed.

The full 25-row matrix passed on this branch with no HTTP 429s at the new cap.

Notes

On the max-parallel change, the measured result was weaker than predicted.
Comparing two 25-row pull_request runs, same branch and same runner class:

wall clock slowest row sum of row time
cap 8 10.2 min 6.3 min 77 min
cap 12 9.3 min 8.5 min 94 min

Wall clock improved 9%, but per-row time rose 22%. Both runs used GitHub-hosted
runners (separate VMs, so not CPU contention between rows); the shared remote
cache endpoint is the likeliest explanation. This is n=1 on each side, so it may
be noise. Worth a week of samples via tools/ci/ci-health --durations before
concluding either way, and easy to revert if not.

The cap also exists to keep simultaneous actions/checkout downloads under
GitHub's rate limit, which previously failed runs at "Set up job" with HTTP 429.
That is why this is 12 and not 16: combined with the bazel-docker matrix the
checkout burst goes from 12 to 16. If 429s reappear, walk this back first.

This fix stops the bleed but does not reclaim space. Deleting the two stranded
merge-queue entries frees 1.90 GB immediately.

GitHub Pages is not enabled on this repo, and enabling it would publish the
dashboard publicly. A scheduled workflow uploading the HTML as an artifact is
the better route if we want it without running the tool locally.

References

None

Related Merge Requests/Pull Requests

None

Dependencies

None. The dashboard is inline SVG specifically to avoid adding a charting
library, and the Go module has no requires.

Github commit:
fix(ci): cut cache-quota waste, add a self-serve build-health dashboard

Co-authored-by: Balaji Ganesan bganesan@nvidia.com

Summary by CodeRabbit

  • New Features
    • Added a CI health tool with reports for workflow performance, job durations, queue delays, cache usage, weekly trends, and pull-request merge latency.
    • Added an optional self-contained HTML dashboard with charts, ranked diagnostics, cache details, and critical-path insights.
    • Added configurable repository, workflow, sampling, history, and output options.
  • Improvements
    • Increased Bazel workflow concurrency from 8 to 12 jobs.
    • Improved handling of paginated data, incomplete records, failed requests, and empty results.

@balajinvda
balajinvda requested a review from a team as a code owner August 4, 2026 15:40
@balajinvda
balajinvda requested a review from apartha-nv August 4, 2026 15:40
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The pull request adds a Go CI health CLI with GitHub data collection, performance analysis, text reports, and a self-contained HTML dashboard. It adds tests, a Bash launcher, Go module metadata, build-output exclusion, and increases Bazel matrix concurrency from 8 to 12.

Changes

CI health and workflow operations

Layer / File(s) Summary
CI entrypoints and workflow concurrency
.github/workflows/bazel.yml, tools/ci/ci-health, tools/ci-health/go.mod, tools/ci-health/.gitignore
The Bazel matrix allows twelve concurrent jobs. The Bash entrypoint invokes the Go CLI and forwards arguments. The Go module and build-output ignore rule are added.
GitHub Actions data collection
tools/ci-health/github.go, tools/ci-health/github_test.go
The tool retrieves paginated workflow runs, jobs, caches, and pull-request data through gh. It filters malformed records, reports command errors, and collects jobs with bounded concurrency.
CI health analysis
tools/ci-health/analysis.go, tools/ci-health/analysis_test.go
The analyzer calculates job timing, critical-path, skipped-matrix, runner, weekly latency, cache, and ranked diagnostic statistics.
CLI reports and HTML dashboard
tools/ci-health/main.go, tools/ci-health/render.go
The CLI supports cache, duration, slowness, weekly, merge-latency, and combined reports. It renders an escaped, self-contained HTML dashboard and can open it in a browser.
Analysis, API, and rendering validation
tools/ci-health/analysis_test.go, tools/ci-health/github_test.go
Tests cover calculations, pagination, filtering, failed job requests, input validation, escaping, empty datasets, and dashboard output.

Estimated code review effort: 4 (Complex) | ~60 minutes

Suggested reviewers: apartha-nv, kristinapathak, mikeyrcamp

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant CIHealth
  participant GHCLI
  participant GitHub
  participant Dashboard
  Operator->>CIHealth: Select report mode and limits
  CIHealth->>GHCLI: Request runs, jobs, caches, or PRs
  GHCLI->>GitHub: Fetch paginated JSON data
  GitHub-->>GHCLI: Return API records
  GHCLI-->>CIHealth: Return decoded data
  CIHealth->>CIHealth: Analyze timing, cache, and diagnostic data
  CIHealth->>Dashboard: Render self-contained HTML
  Dashboard-->>Operator: Display report
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title follows Conventional Commits and accurately describes the new CI build-health tool and Bazel matrix change.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ci-cache-quota

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tools/ci/ci-health`:
- Around line 174-191: Add focused tests in tools/ci/test-ci-health covering
mocked gh responses, pagination behavior, percentile edge cases, and main()
command dispatch for workflow, merge-time, cache, and --all options. Keep the
tests targeted to the existing cache_report, workflow_report, merge_time_report,
and main symbols without changing tool behavior.
- Line 81: Update the GitHub API retrieval used by the cache, workflow-run, and
pull-request metrics to follow pagination and aggregate every page needed for
the report window. In particular, replace the single-page call around gh with a
pagination-aware fetch, then apply args.prs only as a client-side limit after
all relevant pull requests are collected, preserving existing metric
calculations.
- Around line 118-123: Update percentile to use a documented percentile
calculation method that interpolates between adjacent sorted values, ensuring
percentile([1, 2], 0.5) returns 1.5 and p90 for ten ascending values reflects
the 90th percentile rather than automatically selecting the maximum. Preserve
the existing empty-input return of 0.0.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: ee52de18-52b9-4966-9a97-e99e95c715b7

📥 Commits

Reviewing files that changed from the base of the PR and between 4d2a710 and b2ee20c.

📒 Files selected for processing (3)
  • .github/workflows/bazel.yml
  • .github/workflows/license-dependencies.yml
  • tools/ci/ci-health

Comment thread tools/ci/ci-health Outdated
Comment thread tools/ci/ci-health Outdated
Comment thread tools/ci/ci-health Outdated
@balajinvda balajinvda changed the title fix(ci): stop merge-queue refs burning the cache quota, add a health tool fix(ci): cut cache-quota waste, add a self-serve build-health dashboard Aug 4, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.github/workflows/bazel.yml (1)

741-754: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Save the caches after the test step, not between build and test.

The previous combined actions/cache@v4 step saved in the post-job phase, so the entry contained everything the build and the test step fetched. actions/cache/save@v4 saves at the point where the step runs. bazel test //... can fetch further external repositories into ~/.cache/bazel/_bazel_*/cache (test-only dependencies and toolchains reached only by the test configuration). Those fetches now land after the save, so the next run restores an incomplete repository cache and re-downloads them.

Move this step below bazel test //... (after line 876). The conditions themselves are correct.

♻️ Proposed move
-      - name: Save Bazel repository + disk caches
-        # Never from a merge-queue ref: gh-readonly-queue/** is deleted when the
-        # queue drains, so the entry is unrestorable but still occupies quota.
-        if: >-
-          steps.precheck.outputs.skip == 'false'
-          && steps.bazel_cache.outputs.cache-hit != 'true'
-          && !startsWith(github.ref, 'refs/heads/gh-readonly-queue/')
-        uses: actions/cache/save@v4
-        with:
-          path: |
-            ~/.cache/bazel/_bazel_${{ env.USER || 'root' }}/install
-            ~/.cache/bazel/_bazel_${{ env.USER || 'root' }}/cache
-          key: bazel-${{ matrix.subtree.workdir == '.' && 'rootmodule' || matrix.subtree.id }}-${{ hashFiles(format('{0}/MODULE.bazel.lock', matrix.subtree.workdir), format('{0}/.bazelversion', matrix.subtree.workdir)) }}
-
       - name: bazel test //...

Then add the same step immediately after the bazel test //... step:

      - name: Save Bazel repository + disk caches
        # Runs after build and test so the entry holds every fetched external
        # repository. Never from a merge-queue ref: gh-readonly-queue/** is
        # deleted when the queue drains, so the entry is unrestorable but still
        # occupies quota.
        if: >-
          always()
          && steps.precheck.outputs.skip == 'false'
          && steps.bazel_cache.outputs.cache-hit != 'true'
          && !startsWith(github.ref, 'refs/heads/gh-readonly-queue/')
        uses: actions/cache/save@v4
        with:
          path: |
            ~/.cache/bazel/_bazel_${{ env.USER || 'root' }}/install
            ~/.cache/bazel/_bazel_${{ env.USER || 'root' }}/cache
          key: bazel-${{ matrix.subtree.workdir == '.' && 'rootmodule' || matrix.subtree.id }}-${{ hashFiles(format('{0}/MODULE.bazel.lock', matrix.subtree.workdir), format('{0}/.bazelversion', matrix.subtree.workdir)) }}

Drop always() if you want a failed test to skip the save.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/bazel.yml around lines 741 - 754, Move the “Save Bazel
repository + disk caches” step to immediately after the `bazel test //...` step
so test-only downloads are included. Preserve its existing conditions, or add
`always()` if failed tests should still save the cache; do not leave the
original pre-test save step in place.
🧹 Nitpick comments (2)
tools/ci/test-ci-health (1)

59-70: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add cases for the API-shaped helpers.

The pure analysis functions are well covered. gh_paged and merge_time_report are not, and that is where the key="items" defect on tools/ci/ci-health line 568 sits. Add a case that stubs ci.gh_json with a bare list and a 100-item page, then asserts that gh_paged and merge_time_report aggregate correctly.

As per coding guidelines, "For changed tool behavior, add or update focused tests."

Also applies to: 219-235

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/ci/test-ci-health` around lines 59 - 70, Add focused tests for the
API-shaped helpers `gh_paged` and `merge_time_report` in the existing test
suite. Stub `ci.gh_json` to return a bare list containing a full 100-item page,
then assert both helpers aggregate the results correctly and preserve the
expected behavior without assuming an `items` wrapper.

Source: Coding guidelines

tools/ci/ci-health (1)

65-70: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a timeout to the gh call.

subprocess.run has no timeout. fetch_jobs issues these calls from eight threads, so one stalled request blocks the tool with no output. Add a timeout and convert the expiry into the existing RuntimeError path, which main already reports through sys.exit.

♻️ Proposed change
 def gh_json(path, repo):
     cmd = ["gh", "api", f"repos/{repo}/{path}"]
-    out = subprocess.run(cmd, capture_output=True, text=True)
+    try:
+        out = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
+    except subprocess.TimeoutExpired:
+        raise RuntimeError(f"gh api {path} timed out")
     if out.returncode != 0:
         raise RuntimeError(f"gh api {path} failed: {out.stderr.strip()}")
     return json.loads(out.stdout)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/ci/ci-health` around lines 65 - 70, Update gh_json to pass a finite
timeout to subprocess.run and catch subprocess.TimeoutExpired, converting it
into the existing RuntimeError path with a clear failure message. Preserve the
current nonzero-return handling and JSON parsing behavior for completed
requests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tools/ci/ci-health`:
- Around line 567-575: Update merge_time_report so every --prs value uses
gh_paged with key=None for the pulls endpoint, including limits at or below 100,
and rely on its client-side limit. Remove the conditional gh_json path while
preserving the existing pull request filtering and latency calculation.

In `@tools/ci/test-ci-health`:
- Around line 16-23: Add tools/ci/test-ci-health to the appropriate CI job in
.github/workflows/bazel.yml, following the existing invocation pattern used for
tools/ci/test-bazel-cache-upload-mode. Preserve the executable entrypoint and
ensure the test runs as part of the workflow.
- Around line 25-36: Add an explicit import for importlib.machinery alongside
the existing imports in the test module so the SourceFileLoader reference used
during ci module loading is available reliably; leave the existing _spec
initialization and execution flow unchanged.

---

Outside diff comments:
In @.github/workflows/bazel.yml:
- Around line 741-754: Move the “Save Bazel repository + disk caches” step to
immediately after the `bazel test //...` step so test-only downloads are
included. Preserve its existing conditions, or add `always()` if failed tests
should still save the cache; do not leave the original pre-test save step in
place.

---

Nitpick comments:
In `@tools/ci/ci-health`:
- Around line 65-70: Update gh_json to pass a finite timeout to subprocess.run
and catch subprocess.TimeoutExpired, converting it into the existing
RuntimeError path with a clear failure message. Preserve the current
nonzero-return handling and JSON parsing behavior for completed requests.

In `@tools/ci/test-ci-health`:
- Around line 59-70: Add focused tests for the API-shaped helpers `gh_paged` and
`merge_time_report` in the existing test suite. Stub `ci.gh_json` to return a
bare list containing a full 100-item page, then assert both helpers aggregate
the results correctly and preserve the expected behavior without assuming an
`items` wrapper.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 6f3bcbbc-02cf-45bc-840a-967d7306d398

📥 Commits

Reviewing files that changed from the base of the PR and between b2ee20c and dbbc674.

📒 Files selected for processing (3)
  • .github/workflows/bazel.yml
  • tools/ci/ci-health
  • tools/ci/test-ci-health

Comment thread tools/ci/ci-health Outdated
Comment thread tools/ci/test-ci-health Outdated
Comment thread tools/ci/test-ci-health Outdated
Comment thread tools/ci/ci-health Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tools/ci-health/github.go`:
- Around line 170-195: Update fetchJobs to paginate each run’s jobs endpoint
until all pages are retrieved, preserving the worker limit and aggregating every
page’s Jobs into that run’s result. Use the API’s pagination metadata or
next-page indicator and stop when no further page exists. Add a focused test
covering a run whose jobs span multiple pages and verifying all jobs are
returned.

In `@tools/ci-health/main.go`:
- Around line 156-162: Maintain a separate run slice matching the --runs
job-timing window, and pass that slice to diagnose instead of runsList. Update
printWhy and the dashboard diagnostic summary to use the same job-sample slice,
while retaining runsList exclusively for trend-chart data.
- Around line 143-154: Update the command flow around the usage fetch,
fetchCaches, and summariseCaches calls to compute a needCache condition and
perform those requests only for the default, --why, --all, and dashboard modes.
Ensure --durations and --merge-times bypass all cache access while preserving
the existing cache summary behavior for cache-dependent modes.
- Around line 89-93: Validate the parsed count options before dispatch so
--runs, --history, --weeks, and --prs reject negative values instead of reaching
slicing logic. Update the flag-handling flow around the IntVar bindings and
return a clear validation error or usage failure for any negative value, while
preserving valid zero and positive inputs.

In `@tools/ci-health/render.go`:
- Around line 183-196: The quotaBar function currently caps the calculated used
percentage before displaying it. Preserve the raw percentage for threshold
selection and the quotatxt output, while introducing a separately capped value
only for the CSS width attribute. Add a regression test covering utilization
above quota, such as 12 GB, verifying the text reports 120% while the bar width
remains 100%.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 79e3a8e1-7fba-4df1-a6ff-3ec263ba77c8

📥 Commits

Reviewing files that changed from the base of the PR and between dbbc674 and 7f05f19.

📒 Files selected for processing (9)
  • tools/ci-health/.gitignore
  • tools/ci-health/analysis.go
  • tools/ci-health/analysis_test.go
  • tools/ci-health/github.go
  • tools/ci-health/github_test.go
  • tools/ci-health/go.mod
  • tools/ci-health/main.go
  • tools/ci-health/render.go
  • tools/ci/ci-health

Comment thread tools/ci-health/github.go
Comment thread tools/ci-health/main.go
Comment thread tools/ci-health/main.go Outdated
Comment thread tools/ci-health/main.go
Comment thread tools/ci-health/render.go
@balajinvda
balajinvda added this pull request to the merge queue Aug 10, 2026
@balajinvda
balajinvda removed this pull request from the merge queue due to a manual request Aug 10, 2026
balaji-g and others added 5 commits August 10, 2026 16:21
…tool

The repository was at 9.42 GB of its 10 GB Actions cache quota across 16
entries, so every new entry was evicting one another job needed. Nothing
reports this: jobs still pass, they are just colder.

Two causes.

dependency-docs-bazel was 4.19 GB, 42% of the whole budget, in three copies.
It caches a Bazel build of five Java runtime inventories, and every branch
wrote its own full-size copy. It now restores everywhere and saves only from
main, so one authoritative entry serves every branch.

Merge-queue refs were duplicating the largest keys. gh-readonly-queue/**
branches are deleted when the queue drains, so an entry saved there can never
be restored, but it still counts against quota until evicted. 1.90 GB was sat
in exactly that state, from the pr-593 and pr-595 merges. The bazel cache is
now split into restore plus a save that skips those refs.

Adds tools/ci/ci-health so this is answerable without hand-written gh api
calls: cache totals against quota, largest families with their share,
duplicate keys across refs with the merge-queue waste called out, workflow
duration percentiles by day, and PR open-to-merge latency.

It also records the trap in reading those numbers: a low median duration
usually means change detection skipped rows, not that builds got faster, so
the report prints run counts and p90 alongside.

Co-authored-by: Balaji Ganesan <bganesan@nvidia.com>
The duration report pulled `actions/runs?per_page=100` and filtered by
workflow name in Python, so it sampled the last 100 runs across all
workflows and kept whichever happened to be bazel. In practice that was 14
runs out of 1275, and it under-reported worse as the repo got busier. It
now reads the per-workflow endpoint and pages through it.

That truncation also produced a false conclusion: with only two days of
runs visible there appeared to be no history to trend against. There are
three full weeks.

Adds `--dashboard`, which writes a self-contained HTML report and opens it.
No CDN and no JavaScript dependency; the charts are inline SVG, so the file
works offline and adds nothing to the dependency surface.

The report separates queue wait from execution time, because the Actions UI
shows only their sum. On current data that distinction matters: several
matrix rows spend more wall clock waiting for a runner than building.

Also drops the oldest week from the trend when the history window was
truncated. Only the tail of that week is held, so its median came from an
arbitrary slice and plotted as a misleading near-zero point.

Adds focused tests for the pure analysis functions, one per defect the tool
has actually shipped with.

Co-authored-by: Balaji Ganesan <bganesan@nvidia.com>
A 25-row merge-queue run needs three waves at max-parallel 8 and two at 12,
and full-matrix runs are what set the p90 (20.1 min against a 6.8 min
median).

The cap exists to keep simultaneous actions/checkout downloads under
GitHub's rate limit, which previously failed runs at "Set up job" with HTTP
429. That constraint is unchanged, so this is a measured step rather than a
jump to 16: combined with the bazel-docker matrix the checkout burst goes
from 12 to 16. Walk this back first if 429s reappear.

Co-authored-by: Balaji Ganesan <bganesan@nvidia.com>
Review feedback: tools/AGENTS.md prefers Go for non-trivial repo tooling and
says to avoid new Python, partly because some CI environments here do not
guarantee a Python interpreter. This tool is squarely in that category:
structured API parsing, concurrent fetches, and logic that benefits from unit
tests. It should not have been Python to begin with.

Behaviour is unchanged. The Go build produces the same ranked causes, the same
weekly trend, and a visually identical dashboard against live data.

Layout follows the existing convention: the tool lives in its own module at
tools/ci-health/, with tools/ci/ci-health kept as the stable entrypoint.

Two fixes carried over from review of the Python version:

- The pulls endpoint returns a bare array, so paging it with a wrapper key
  crashed once --prs exceeded one page. The Go paging helper takes an empty key
  for bare-array endpoints, and a test covers both response shapes.
- The cache listing read a single page, silently capping the report at 100
  entries. It now pages.

37 tests, no third-party dependencies.

Co-authored-by: Balaji Ganesan <bganesan@nvidia.com>
Four issues from review, all real, all verified rather than taken on trust.

fetchJobs requested per_page=100 but never asked for page 2, so any run with
more than 100 jobs reported partial timings. Wide matrix runs are exactly the
ones whose numbers matter, so this silently understated the busiest runs. It
now pages until total_count is reached.

Negative counts panicked instead of erroring: --runs, --history, --weeks and
--prs all reach slice bounds, and `--runs=-1` died with "slice bounds out of
range [:-1]". Reproduced before fixing. They are now rejected with a message
naming the flag.

--durations and --merge-times read no cache data but still fetched it, so they
failed whenever cache access failed, for reports that never used the result.
The cache calls are now made only for the modes that read them.

diagnose divided job medians drawn from the --runs sample by a wall-clock
median drawn from the full --history window. Mixing two windows can misstate
each cause's share and reorder them. Diagnosis now runs on the sampled window;
the full history still feeds the trend charts, where it belongs.

Tests cover pagination across two pages, the single-page stop, and rejection of
every negative count flag. 40 tests pass.

Verified live afterwards: --runs=-1 now prints a clean error, --durations
completes without touching the cache, and --why reports over the window it
actually sampled.

Co-authored-by: Balaji Ganesan <bganesan@nvidia.com>
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@balajinvda balajinvda changed the title fix(ci): cut cache-quota waste, add a self-serve build-health dashboard feat(ci): add a self-serve build-health tool, raise the bazel matrix cap Aug 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tools/ci-health/analysis.go`:
- Around line 212-214: Update the label-trimming logic for the truncated report
so it removes the first label whenever truncated is true and labels is
non-empty, including the single-bucket case. Add a regression test covering
truncated input with exactly one bucket and verify the incomplete bucket is
excluded.

In `@tools/ci-health/github_test.go`:
- Around line 34-36: Handle fmt.Sscanf errors in both page-parsing stubs:
tools/ci-health/github_test.go lines 34-36 and 224-226. Check the scan result
before decrementing idx or using page, and return a contextual wrapped error
with %w when parsing fails; preserve normal response-page selection for valid
page numbers.

In `@tools/ci-health/github.go`:
- Around line 29-38: Update the fetch function’s *exec.ExitError branch to wrap
the original err with %w while retaining the trimmed stderr in the formatted
message; leave the generic error branch and successful output behavior
unchanged.
- Around line 29-38: Instrument the fetch function around the outbound gh api
command by creating a client span, injecting W3C trace context into the command
via the -H request header, and recording command failures on the span before
returning the existing errors. Reuse the repository’s existing tracing package
and provider setup where available; otherwise add the required OpenTelemetry
dependency with Apache-2.0 license and attribution review. Add focused tests
covering span creation, trace-header propagation, and failure recording.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 80b02766-f1cc-4a21-bc8e-dd76c4181204

📥 Commits

Reviewing files that changed from the base of the PR and between 56e4b2c and b21a78c.

📒 Files selected for processing (10)
  • .github/workflows/bazel.yml
  • tools/ci-health/.gitignore
  • tools/ci-health/analysis.go
  • tools/ci-health/analysis_test.go
  • tools/ci-health/github.go
  • tools/ci-health/github_test.go
  • tools/ci-health/go.mod
  • tools/ci-health/main.go
  • tools/ci-health/render.go
  • tools/ci/ci-health
🚧 Files skipped from review as they are similar to previous changes (5)
  • tools/ci-health/go.mod
  • tools/ci/ci-health
  • tools/ci-health/.gitignore
  • tools/ci-health/render.go
  • tools/ci-health/analysis_test.go

Comment on lines +212 to +214
if truncated && len(labels) > 1 {
labels = labels[1:]
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Drop the only partial week.

When truncated is true and one bucket exists, this condition retains incomplete data. The report then shows a percentile for an arbitrary slice of that week. Drop the first label whenever truncated is true and labels is non-empty. Add a one-bucket regression test.

Proposed fix
-	if truncated && len(labels) > 1 {
+	if truncated && len(labels) > 0 {
 		labels = labels[1:]
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if truncated && len(labels) > 1 {
labels = labels[1:]
}
if truncated && len(labels) > 0 {
labels = labels[1:]
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/ci-health/analysis.go` around lines 212 - 214, Update the
label-trimming logic for the truncated report so it removes the first label
whenever truncated is true and labels is non-empty, including the single-bucket
case. Add a regression test covering truncated input with exactly one bucket and
verify the incomplete bucket is excluded.

Comment on lines +34 to +36
if i := strings.Index(path, "&page="); i >= 0 {
fmt.Sscanf(path[i+len("&page="):], "%d", &idx)
idx--

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 3 'fmt\.Sscanf\(' tools/ci-health/github_test.go

Repository: NVIDIA/nvcf

Length of output: 554


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- guidance ---'
find .. -name AGENTS.md -print
printf '%s\n' '--- github_test.go outline ---'
ast-grep outline tools/ci-health/github_test.go
printf '%s\n' '--- relevant source ---'
sed -n '1,90p' tools/ci-health/github_test.go
sed -n '200,250p' tools/ci-health/github_test.go
printf '%s\n' '--- call sites and test coverage ---'
rg -n -C 3 'httptest|page=|github_test|mock|ServeHTTP|RoundTripper|Sscanf' tools/ci-health

Repository: NVIDIA/nvcf

Length of output: 10737


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- applicable guidance ---'
sed -n '1,220p' AGENTS.md
sed -n '1,220p' tools/AGENTS.md

printf '%s\n' '--- standalone fmt.Sscanf behavior ---'
cat >/tmp/verify_scan.go <<'EOF'
package main

import (
	"fmt"
)

func main() {
	for _, input := range []string{"2", "", "x", "2x", "2&page=3"} {
		value := 0
		n, err := fmt.Sscanf(input, "%d", &value)
		fmt.Printf("%q: n=%d value=%d err=%T:%v\n", input, n, value, err, err)
	}
}
EOF
go run /tmp/verify_scan.go
rm -f /tmp/verify_scan.go

printf '%s\n' '--- production pagination inputs ---'
sed -n '50,85p' tools/ci-health/github.go
sed -n '165,205p' tools/ci-health/github.go

Repository: NVIDIA/nvcf

Length of output: 15051


Handle page-number parse failures in both test stubs.

Check each fmt.Sscanf result and return a wrapped error when parsing fails. In the first stub, a failed scan decrements idx to -1 and can panic when indexing pages. In the second stub, a failed scan leaves page as 1 and selects the wrong response page. Preserve the scan error with %w.

🧰 Tools
🪛 golangci-lint (2.12.2)

[error] 35-35: Error return value of fmt.Sscanf is not checked

(errcheck)

📍 Affects 1 file
  • tools/ci-health/github_test.go#L34-L36 (this comment)
  • tools/ci-health/github_test.go#L224-L226
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/ci-health/github_test.go` around lines 34 - 36, Handle fmt.Sscanf
errors in both page-parsing stubs: tools/ci-health/github_test.go lines 34-36
and 224-226. Check the scan result before decrementing idx or using page, and
return a contextual wrapped error with %w when parsing fails; preserve normal
response-page selection for valid page numbers.

Sources: Coding guidelines, Linters/SAST tools

Comment thread tools/ci-health/github.go
Comment on lines +29 to +38
var fetch = func(path, repo string) ([]byte, error) {
out, err := exec.Command("gh", "api", "repos/"+repo+"/"+path).Output()
if err != nil {
var ee *exec.ExitError
if ok := asExitError(err, &ee); ok {
return nil, fmt.Errorf("gh api %s: %s", path, strings.TrimSpace(string(ee.Stderr)))
}
return nil, fmt.Errorf("gh api %s: %w", path, err)
}
return out, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Preserve the gh command error.

Line 34 returns only formatted stderr. This discards the originating *exec.ExitError.

Wrap err with %w and retain stderr in the message.

Proposed fix
-			return nil, fmt.Errorf("gh api %s: %s", path, strings.TrimSpace(string(ee.Stderr)))
+			return nil, fmt.Errorf("gh api %s: %s: %w", path, strings.TrimSpace(string(ee.Stderr)), err)

As per coding guidelines, preserve the originating error with %w.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var fetch = func(path, repo string) ([]byte, error) {
out, err := exec.Command("gh", "api", "repos/"+repo+"/"+path).Output()
if err != nil {
var ee *exec.ExitError
if ok := asExitError(err, &ee); ok {
return nil, fmt.Errorf("gh api %s: %s", path, strings.TrimSpace(string(ee.Stderr)))
}
return nil, fmt.Errorf("gh api %s: %w", path, err)
}
return out, nil
var fetch = func(path, repo string) ([]byte, error) {
out, err := exec.Command("gh", "api", "repos/"+repo+"/"+path).Output()
if err != nil {
var ee *exec.ExitError
if ok := asExitError(err, &ee); ok {
return nil, fmt.Errorf("gh api %s: %s: %w", path, strings.TrimSpace(string(ee.Stderr)), err)
}
return nil, fmt.Errorf("gh api %s: %w", path, err)
}
return out, nil
🧰 Tools
🪛 ast-grep (0.45.0)

[error] 29-29: An argument passed to exec.Command/exec.CommandContext is built by concatenating a string literal with dynamic input. If that input is attacker-controlled (and especially when the command is a shell such as sh -c/bash -c), this enables OS command injection. Pass untrusted data as separate, fixed arguments instead of interpolating it into a command string, avoid invoking a shell, and validate/escape the input where a shell is unavoidable.
Context: exec.Command("gh", "api", "repos/"+repo+"/"+path)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(command-injection-exec-concat-arg-go)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/ci-health/github.go` around lines 29 - 38, Update the fetch function’s
*exec.ExitError branch to wrap the original err with %w while retaining the
trimmed stderr in the formatted message; leave the generic error branch and
successful output behavior unchanged.

Source: Coding guidelines


📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Go modules ---'
fd -HI -t f '^go\.mod$' .

printf '%s\n' '--- Existing OpenTelemetry usage ---'
rg -n -C 3 'go\.opentelemetry\.io|otel\.|traceparent|propagation\.' . -g '*.go' -g 'go.mod'

Repository: NVIDIA/nvcf

Length of output: 50368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Guidance files ---'
find .. -name AGENTS.md -print

printf '%s\n' '--- Tool files and module declarations ---'
git ls-files 'tools/ci-health/*' 'go.mod' 'go.sum' '*go.mod' '*go.sum' | sed -n '1,160p'

printf '%s\n' '--- Relevant tool source ---'
sed -n '1,90p' tools/ci-health/github.go
sed -n '1,80p' tools/ci-health/main.go 2>/dev/null || true

printf '%s\n' '--- Non-vendor tracing usage ---'
rg -n -g '*.go' -g 'go.mod' -g '!**/vendor/**' \
  'go\.opentelemetry\.io|otel\.|traceparent|propagation\.' . \
  | sed -n '1,240p'

printf '%s\n' '--- Tool tests and callers ---'
rg -n -g '!**/vendor/**' 'tools/ci-health|fetch\(|gh api|github\.go' . \
  | sed -n '1,240p'

Repository: NVIDIA/nvcf

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Applicable guidance ---'
for f in AGENTS.md tools/AGENTS.md tools/ci-health/AGENTS.md; do
  if [ -f "$f" ]; then
    echo "### $f"
    cat "$f"
  fi
done

printf '%s\n' '--- ci-health module ---'
cat tools/ci-health/go.mod
if [ -f tools/ci-health/go.sum ]; then
  sed -n '1,160p' tools/ci-health/go.sum
fi

printf '%s\n' '--- ci-health source outline and tests ---'
wc -l tools/ci-health/*.go
sed -n '1,260p' tools/ci-health/github_test.go

printf '%s\n' '--- Existing tracing helpers and module boundaries ---'
sed -n '1,180p' src/libraries/go/worker/tracing/tracing.go
sed -n '1,120p' src/libraries/go/lib/pkg/otel/otel.go
sed -n '1,180p' src/libraries/go/lib/go.mod

Repository: NVIDIA/nvcf

Length of output: 44413


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- License policy ---'
if [ -f .allowed-licenses.txt ]; then
  rg -n -i 'apache|otel|mit|bsd' .allowed-licenses.txt
else
  echo '.allowed-licenses.txt not found'
fi

printf '%s\n' '--- Existing module references to shared tracing packages ---'
rg -n -g 'go.mod' -g '!**/vendor/**' \
  'src/libraries/go/(worker|lib)|pkg/otel|pkg/nvkit/tracing' . \
  | sed -n '1,160p'

printf '%s\n' '--- GitHub CLI invocation capabilities in repository docs ---'
rg -n -i -g '!**/vendor/**' -g '!**/*.js' \
  'gh api|--header|-H[[:space:]].*trace|TRACEPARENT|TRACESTATE' . \
  | sed -n '1,200p'

Repository: NVIDIA/nvcf

Length of output: 25565


🌐 Web query:

GitHub CLI gh api custom HTTP request headers -H documentation

💡 Result:

To add custom HTTP request headers when using the GitHub CLI (gh), use the --header or -H flag with the gh api command [1][2]. The syntax follows the standard key:value format: gh api -H 'Header-Name: header-value' You can provide multiple headers by repeating the -H flag [1]. Key points regarding headers in the GitHub CLI: 1. Per-request Headers: The -H flag is specifically for the gh api command and only applies to the request being executed [3]. 2. Response Headers: If you need to view the HTTP response headers returned by the server, you can use the --include flag with your gh api command [1][2]. 3. Global/Per-host Headers: As of August 2026, the GitHub CLI does not natively support configuring custom global or per-host HTTP headers that apply automatically to all gh commands (such as gh pr or gh issue) [3]. Users must explicitly add headers to each gh api call [3]. 4. Preview Headers: While the --header flag can be used for any custom header, the gh api command also provides a dedicated --preview flag for simplifying GitHub API preview requests [1]. For example, --preview name is a shorthand for adding an Accept header with the appropriate media type [1].

Citations:


Instrument the outbound GitHub call.

Create a client span around fetch, propagate W3C trace context with gh api -H, and record command failures as span errors. Add focused instrumentation tests.

tools/ci-health has no tracing dependency or provider setup. Reuse the existing repository tracing package or add OpenTelemetry with the required Apache-2.0 license and attribution review.

🧰 Tools
🪛 ast-grep (0.45.0)

[error] 29-29: An argument passed to exec.Command/exec.CommandContext is built by concatenating a string literal with dynamic input. If that input is attacker-controlled (and especially when the command is a shell such as sh -c/bash -c), this enables OS command injection. Pass untrusted data as separate, fixed arguments instead of interpolating it into a command string, avoid invoking a shell, and validate/escape the input where a shell is unavoidable.
Context: exec.Command("gh", "api", "repos/"+repo+"/"+path)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(command-injection-exec-concat-arg-go)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/ci-health/github.go` around lines 29 - 38, Instrument the fetch
function around the outbound gh api command by creating a client span, injecting
W3C trace context into the command via the -H request header, and recording
command failures on the span before returning the existing errors. Reuse the
repository’s existing tracing package and provider setup where available;
otherwise add the required OpenTelemetry dependency with Apache-2.0 license and
attribution review. Add focused tests covering span creation, trace-header
propagation, and failure recording.

Source: Coding guidelines

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.

3 participants