-
Notifications
You must be signed in to change notification settings - Fork 1
BUILD-11310 Add report-ci-metrics action #298
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mikolaj-matuszny-ext-sonarsource
merged 17 commits into
master
from
BUILD-11310-report-ci-insights
Jun 15, 2026
+803
−2
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
d3b5cdf
BUILD-11310 Scaffold report-ci-insights action
mikolaj-matuszny-ext-sonarsource 4f9233b
BUILD-11310 Add extract_metrics_json with sentinel recovery
mikolaj-matuszny-ext-sonarsource 9d65def
BUILD-11310 Add collect_job_metrics log aggregation
mikolaj-matuszny-ext-sonarsource ce08811
BUILD-11310 Add markdown rendering with folding
mikolaj-matuszny-ext-sonarsource f89209d
BUILD-11310 Add sticky PR comment upsert
mikolaj-matuszny-ext-sonarsource 5d6744f
BUILD-11310 Wire report-ci-insights main and add README
mikolaj-matuszny-ext-sonarsource 8613f8e
BUILD-11310 Harden report-ci-insights: validate JSON, escape pipes
mikolaj-matuszny-ext-sonarsource 57d00df
BUILD-11310 Address SonarCloud smells in report-ci-insights spec
mikolaj-matuszny-ext-sonarsource a15015a
BUILD-11310 Add report-ci-insights to sonar.sources for coverage trac…
mikolaj-matuszny-ext-sonarsource a01c8a4
BUILD-11310 Cover orchestrator entry script and saved-only cache branch
mikolaj-matuszny-ext-sonarsource aa208ca
BUILD-11310 Reformat multi-line constructs for full coverage attribution
mikolaj-matuszny-ext-sonarsource 1591c06
BUILD-11310 Sanitize newlines and angle brackets in PR comment cells
mikolaj-matuszny-ext-sonarsource 2c9dec3
BUILD-11310 Trim verbose comments in report-ci-insights lib and spec
mikolaj-matuszny-ext-sonarsource 1067097
BUILD-11310 Escape ampersand in HTML-entity replacement for bash 4.3+…
mikolaj-matuszny-ext-sonarsource e5ab1dd
BUILD-11310 Rename report-ci-insights action to report-ci-metrics
mikolaj-matuszny-ext-sonarsource 8de7bbd
BUILD-11310 Move report-ci-metrics docs to root README
mikolaj-matuszny-ext-sonarsource 6261c54
BUILD-11310 Add report-ci-metrics section to root README
mikolaj-matuszny-ext-sonarsource File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| name: Report CI Metrics | ||
| description: Aggregate per-job CI metrics from the run and post a sticky PR comment | ||
| runs: | ||
| using: composite | ||
| steps: | ||
| - name: Report CI metrics | ||
| shell: bash | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| RUN_ID: ${{ github.run_id }} | ||
| REPO: ${{ github.repository }} | ||
| PR_NUMBER: ${{ github.event.pull_request.number }} | ||
| SELF_JOB: ${{ github.job }} | ||
| run: ${GITHUB_ACTION_PATH}/report-ci-metrics.sh |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,285 @@ | ||
| #!/usr/bin/env bash | ||
| # report-ci-metrics library functions. Tested directly via shellspec. | ||
|
|
||
| # Recover the last sentinel-wrapped metrics JSON from a job log's text. Empty if absent. | ||
| extract_metrics_json() { | ||
| local text=$1 | ||
| printf '%s' "$text" \ | ||
| | grep -o '===CI_METRICS_JSON_BEGIN===.*===CI_METRICS_JSON_END===' \ | ||
| | tail -1 \ | ||
| | sed -e 's/.*===CI_METRICS_JSON_BEGIN===//' -e 's/===CI_METRICS_JSON_END===.*//' | ||
| } | ||
|
|
||
| # List the run's jobs and emit "<name>\t<json>" for each completed sibling whose log | ||
| # carries a metrics block. Skips: non-completed jobs, the report job itself (exact or | ||
| # matrix "id (val)" prefix match on SELF_JOB), log-download failures, and jobs with no | ||
| # metrics block. Record format relies on job names having no tab/newline. | ||
| collect_job_metrics() { | ||
| local jobs id name status log metrics | ||
| jobs=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs" --paginate -q '.jobs[] | "\(.id)\t\(.name)\t\(.status)"') || return 0 | ||
| while IFS=$'\t' read -r id name status; do | ||
| [[ -z "$id" ]] && continue | ||
| [[ "$status" == "completed" ]] || continue | ||
| [[ "$name" == "$SELF_JOB" || "$name" == "$SELF_JOB "* ]] && continue | ||
| log=$(gh api "repos/$REPO/actions/jobs/$id/logs" 2>/dev/null) || continue | ||
| metrics=$(extract_metrics_json "$log") | ||
| [[ -n "$metrics" ]] || continue | ||
| # Drop malformed/truncated JSON so it never reaches the renderers. | ||
| jq -e . >/dev/null 2>&1 <<< "$metrics" || continue | ||
| printf '%s\t%s\n' "$name" "$metrics" | ||
| done <<< "$jobs" | ||
| } | ||
|
|
||
| # Sanitize an author-influenced value for safe inclusion in a markdown table cell: | ||
| # escape pipes (column injection), collapse CR/LF (row/line injection), and neutralize | ||
| # angle brackets (HTML/details injection) since the comment is posted with write scope. | ||
| _rci_md_cell() { | ||
| local s=$1 | ||
| s=${s//|/\\|} | ||
| s=${s//$'\r'/ } | ||
| s=${s//$'\n'/ } | ||
| # Escape the & in the replacement: bash 4.3+ treats a bare & as the matched text. | ||
| s=${s//</\<} | ||
| s=${s//>/\>} | ||
| printf '%s' "$s" | ||
| } | ||
|
mikolaj-matuszny-ext-sonarsource marked this conversation as resolved.
|
||
|
|
||
| # Bytes → human-readable, matching the producer hook's fmt_bytes (2dp, IEC units). | ||
| # A blank/non-numeric arg renders as "n/a" so callers can pass jq nulls directly. | ||
| _rci_fmt_bytes() { | ||
| local b=${1:-} | ||
| [[ "$b" =~ ^[0-9]+$ ]] || { printf 'n/a'; return; } | ||
| if (( b >= 1073741824 )); then awk -v b="$b" 'BEGIN{printf "%.2f GiB", b/1073741824}' | ||
| elif (( b >= 1048576 )); then awk -v b="$b" 'BEGIN{printf "%.2f MiB", b/1048576}' | ||
| elif (( b >= 1024 )); then awk -v b="$b" 'BEGIN{printf "%.2f KiB", b/1024}' | ||
| else printf '%s B' "$b" | ||
| fi | ||
| } | ||
|
|
||
| # CPU-avg display cell for one job's JSON, mirroring the hook's step-summary logic: | ||
| # cores = usage_seconds / duration_seconds (2dp); | ||
| # denominator = limit_cores when avg_utilization is known, else online_count; | ||
| # "<cores> / <avail> cores (<pct>%)", falling back to bare cores, then "n/a". | ||
| _rci_cpu_cell() { | ||
| local json=$1 | ||
| jq -r '.cgroup.cpu as $c | .duration_seconds as $d | if ($c.usage_seconds != null and $d != null and $d > 0) then (($c.usage_seconds / $d) * 100 | round / 100) as $cores | if ($c.limit_cores != null and $c.avg_utilization != null) then "\($cores) / \(($c.limit_cores*100|round)/100) cores (\(($c.avg_utilization*100)|round)%)" elif ($c.online_count != null and $c.online_count > 0) then "\($cores) / \($c.online_count) cores (\((($cores/$c.online_count)*100)|round)%)" else "\($cores) cores" end else "n/a" end' <<< "$json" | ||
| } | ||
|
|
||
| # Sum a numeric jq path across all record JSONs; nulls count as 0. Echoes an integer-ish sum. | ||
| _rci_sum() { | ||
| local records=$1 path=$2 total=0 name json | ||
| while IFS=$'\t' read -r name json; do | ||
| [[ -z "$json" ]] && continue | ||
| local v | ||
| v=$(jq -r "($path) // 0" <<< "$json" 2>/dev/null) | ||
| total=$(awk -v a="$total" -v b="$v" 'BEGIN{printf "%.0f", a+b}') | ||
| done <<< "$records" | ||
| printf '%s' "$total" | ||
| } | ||
|
|
||
| # render_headline <records> — a totals line, then a flags line if any job OOM'd/throttled. | ||
| render_headline() { | ||
| local records=$1 | ||
| local njobs cpu_total rx tx restored saved | ||
| local worst_name="" worst_peak=-1 | ||
| local oom_jobs="" oom_count=0 thr_jobs="" thr_count=0 | ||
| local name json | ||
|
|
||
| njobs=0 | ||
| while IFS=$'\t' read -r name json; do | ||
| [[ -z "$json" ]] && continue | ||
| njobs=$((njobs + 1)) | ||
| local peak okill thr | ||
| peak=$(jq -r '.cgroup.memory.peak_bytes // -1' <<< "$json" 2>/dev/null) | ||
| if [[ "$peak" =~ ^[0-9]+$ ]] && (( peak > worst_peak )); then | ||
| worst_peak=$peak; worst_name=$name | ||
| fi | ||
| okill=$(jq -r '.cgroup.memory.oom_kill // 0' <<< "$json" 2>/dev/null) | ||
| if [[ "$okill" =~ ^[0-9]+$ ]] && (( okill > 0 )); then | ||
| oom_count=$((oom_count + 1)) | ||
| oom_jobs="${oom_jobs:+$oom_jobs, }$name" | ||
| fi | ||
| thr=$(jq -r '.cgroup.cpu.throttled_seconds // 0' <<< "$json" 2>/dev/null) | ||
| if awk -v t="$thr" 'BEGIN{exit !(t+0>0)}'; then | ||
| thr_count=$((thr_count + 1)) | ||
| thr_jobs="${thr_jobs:+$thr_jobs, }$name" | ||
| fi | ||
| done <<< "$records" | ||
|
|
||
| cpu_total=$(_rci_sum "$records" '.cgroup.cpu.usage_seconds') | ||
| cpu_total=$(awk -v u="$cpu_total" 'BEGIN{printf "%.1f", u}') | ||
| rx=$(_rci_sum "$records" '.net.rx_bytes') | ||
| tx=$(_rci_sum "$records" '.net.tx_bytes') | ||
| restored=$(_rci_sum "$records" '([.cache[]?.size_bytes_restored // 0] | add) // 0') | ||
| saved=$(_rci_sum "$records" '([.cache[]? | select(.saved == true) | .size_bytes_at_end // 0] | add) // 0') | ||
|
|
||
| local job_word="jobs"; [[ "$njobs" -eq 1 ]] && job_word="job" | ||
| local line="**${njobs} ${job_word}** · CPU ${cpu_total} CPU-s" | ||
| if [[ -n "$worst_name" ]]; then | ||
| line="${line} · peak mem $(_rci_fmt_bytes "$worst_peak") (${worst_name})" | ||
| fi | ||
| line="${line} · net $(_rci_fmt_bytes "$rx") ↓ / $(_rci_fmt_bytes "$tx") ↑" | ||
| local cache_seg="" | ||
| [[ "$restored" -gt 0 ]] && cache_seg="cache $(_rci_fmt_bytes "$restored") restored" | ||
| if [[ "$saved" -gt 0 ]]; then | ||
| if [[ -n "$cache_seg" ]]; then cache_seg="${cache_seg} / $(_rci_fmt_bytes "$saved") saved" | ||
| else cache_seg="cache $(_rci_fmt_bytes "$saved") saved"; fi | ||
| fi | ||
| [[ -n "$cache_seg" ]] && line="${line} · ${cache_seg}" | ||
| printf '%s\n' "$line" | ||
|
|
||
| # Flags line only when something actually went wrong. | ||
| if (( oom_count > 0 || thr_count > 0 )); then | ||
| local flags="" | ||
| if (( oom_count > 0 )); then | ||
| local w="jobs"; [[ "$oom_count" -eq 1 ]] && w="job" | ||
| flags="${oom_count} ${w} OOM-killed (${oom_jobs})" | ||
| fi | ||
| if (( thr_count > 0 )); then | ||
| local w="jobs"; [[ "$thr_count" -eq 1 ]] && w="job" | ||
| local seg="${thr_count} ${w} CPU-throttled (${thr_jobs})" | ||
| flags="${flags:+$flags · }${seg}" | ||
| fi | ||
| printf '> ⚠️ %s\n' "$flags" | ||
| fi | ||
| } | ||
|
|
||
| # render_table <records> — foldable per-job table. Columns with no data in any job are | ||
| # dropped: pre-scan to pick the surviving columns, then render header + one row per job. | ||
| render_table() { | ||
| local records=$1 | ||
| local njobs=0 name json | ||
| local has_cpu=0 has_mem=0 has_disk=0 has_net=0 has_flags=0 | ||
|
|
||
| while IFS=$'\t' read -r name json; do | ||
| [[ -z "$json" ]] && continue | ||
| njobs=$((njobs + 1)) | ||
| jq -e '.cgroup.cpu.usage_seconds != null and .duration_seconds != null' <<< "$json" >/dev/null 2>&1 && has_cpu=1 | ||
| jq -e '.cgroup.memory.peak_bytes != null' <<< "$json" >/dev/null 2>&1 && has_mem=1 | ||
| jq -e '.disk.used_bytes != null and .disk.total_bytes != null' <<< "$json" >/dev/null 2>&1 && has_disk=1 | ||
| jq -e '.net.rx_bytes != null or .net.tx_bytes != null' <<< "$json" >/dev/null 2>&1 && has_net=1 | ||
| jq -e '(.cgroup.memory.oom_kill // 0) > 0 or (.cgroup.cpu.throttled_seconds // 0) > 0' <<< "$json" >/dev/null 2>&1 && has_flags=1 | ||
| done <<< "$records" | ||
|
|
||
| # Build the ordered column list from the surviving sections. "Job" is always present. | ||
| local cols=("Job") | ||
| (( has_cpu )) && cols+=("CPU avg") | ||
| (( has_mem )) && cols+=("Mem peak") | ||
| (( has_disk )) && cols+=("Disk") | ||
| (( has_net )) && cols+=("Net ↓/↑") | ||
| (( has_flags )) && cols+=("Flags") | ||
|
|
||
| local job_word="jobs"; [[ "$njobs" -eq 1 ]] && job_word="job" | ||
| printf '<details><summary>Per-job breakdown (%d %s)</summary>\n\n' "$njobs" "$job_word" | ||
|
|
||
| # Header + separator. | ||
| local header="|" sep="|" c | ||
| for c in "${cols[@]}"; do header="${header} ${c} |"; sep="${sep}---|"; done | ||
| printf '%s\n%s\n' "$header" "$sep" | ||
|
|
||
| # One row per job, emitting only the surviving columns. | ||
| while IFS=$'\t' read -r name json; do | ||
| [[ -z "$json" ]] && continue | ||
| name=$(_rci_md_cell "$name") | ||
| local row="| ${name} |" | ||
| if (( has_cpu )); then row="${row} $(_rci_cpu_cell "$json") |"; fi | ||
| if (( has_mem )); then | ||
| local pk; pk=$(jq -r '.cgroup.memory.peak_bytes // empty' <<< "$json") | ||
| row="${row} $(_rci_fmt_bytes "$pk") |" | ||
| fi | ||
| if (( has_disk )); then | ||
| local du dt cell | ||
| du=$(jq -r '.disk.used_bytes // empty' <<< "$json") | ||
| dt=$(jq -r '.disk.total_bytes // empty' <<< "$json") | ||
| if [[ "$du" =~ ^[0-9]+$ && "$dt" =~ ^[0-9]+$ ]]; then | ||
| cell="$(_rci_fmt_bytes "$du") / $(_rci_fmt_bytes "$dt")" | ||
| else cell="n/a"; fi | ||
| row="${row} ${cell} |" | ||
| fi | ||
| if (( has_net )); then | ||
| local rxb txb | ||
| rxb=$(jq -r '.net.rx_bytes // empty' <<< "$json") | ||
| txb=$(jq -r '.net.tx_bytes // empty' <<< "$json") | ||
| row="${row} $(_rci_fmt_bytes "$rxb") ↓ / $(_rci_fmt_bytes "$txb") ↑ |" | ||
| fi | ||
| if (( has_flags )); then | ||
| local f="" ok th | ||
| ok=$(jq -r '.cgroup.memory.oom_kill // 0' <<< "$json") | ||
| th=$(jq -r '.cgroup.cpu.throttled_seconds // 0' <<< "$json") | ||
| [[ "$ok" =~ ^[0-9]+$ ]] && (( ok > 0 )) && f="🔴 OOM" | ||
| if awk -v t="$th" 'BEGIN{exit !(t+0>0)}'; then f="${f:+$f }🟡 throttled"; fi | ||
| row="${row} ${f} |" | ||
| fi | ||
| printf '%s\n' "$row" | ||
| done <<< "$records" | ||
|
|
||
| printf '\n</details>\n' | ||
| } | ||
|
|
||
| # render_cache_fold <records> — foldable cache table; empty string unless a job has cache. | ||
| render_cache_fold() { | ||
| local records=$1 | ||
| local count=0 name json rows="" | ||
|
|
||
| while IFS=$'\t' read -r name json; do | ||
| [[ -z "$json" ]] && continue | ||
| local n | ||
| n=$(jq -r '(.cache // []) | length' <<< "$json" 2>/dev/null) | ||
| [[ "$n" =~ ^[0-9]+$ ]] || n=0 | ||
| (( n == 0 )) && continue | ||
| count=$((count + n)) | ||
| local i | ||
| for (( i = 0; i < n; i++ )); do | ||
| local key hit backend restored_b saved_flag end_b | ||
| key=$(jq -r ".cache[$i].key // \"\"" <<< "$json") | ||
| hit=$(jq -r ".cache[$i].cache_hit // false | if . then \"yes\" else \"no\" end" <<< "$json") | ||
| backend=$(jq -r ".cache[$i].backend // \"\"" <<< "$json") | ||
| restored_b=$(jq -r ".cache[$i].size_bytes_restored // empty" <<< "$json") | ||
| saved_flag=$(jq -r ".cache[$i].saved // false | if . then \"yes\" else \"no\" end" <<< "$json") | ||
| end_b=$(jq -r ".cache[$i].size_bytes_at_end // empty" <<< "$json") | ||
| key=$(_rci_md_cell "$key") | ||
| backend=$(_rci_md_cell "$backend") | ||
| rows="${rows}| ${key} | ${hit} | ${backend} | $(_rci_fmt_bytes "$restored_b") | ${saved_flag} | $(_rci_fmt_bytes "$end_b") |"$'\n' | ||
| done | ||
| done <<< "$records" | ||
|
|
||
| (( count == 0 )) && return 0 | ||
|
|
||
| local word="entries"; [[ "$count" -eq 1 ]] && word="entry" | ||
| printf '<details><summary>Cache (%d %s)</summary>\n\n' "$count" "$word" | ||
| printf '| Key | Hit | Backend | Size Restored | Saved | Size Saved |\n' | ||
| printf '|---|---|---|---|---|---|\n' | ||
| printf '%s' "$rows" | ||
| printf '\n</details>\n' | ||
| } | ||
|
|
||
| # Post or update the sticky comment, matched by the marker in <body>'s first line: | ||
| # PATCH the first comment containing the marker, else POST a new one (idempotent on re-run). | ||
| upsert_comment() { | ||
| local body=$1 marker='<!-- ci-metrics-report -->' id | ||
| id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate -q ".[] | select(.body | contains(\"$marker\")) | .id" | head -1) || true | ||
| if [[ -n "$id" ]]; then | ||
| gh api "repos/$REPO/issues/comments/$id" -X PATCH -f body="$body" | ||
| else | ||
| gh api "repos/$REPO/issues/$PR_NUMBER/comments" -X POST -f body="$body" | ||
| fi | ||
| } | ||
|
|
||
| # Entry point: collect sibling metrics, render, upsert the sticky comment. Returns early | ||
| # (no comment) when there's no PR context or no metrics. Marker must lead the body. | ||
| main() { | ||
| [[ -n "${PR_NUMBER:-}" ]] || { echo "::notice::report-ci-metrics: no PR context, skipping"; return 0; } | ||
| local records | ||
| records=$(collect_job_metrics) | ||
| [[ -n "$records" ]] || { echo "::notice::report-ci-metrics: no CI metrics found, skipping comment"; return 0; } | ||
| local marker='<!-- ci-metrics-report -->' | ||
| local body | ||
| body="$marker | ||
| ## 📊 CI Metrics | ||
|
|
||
| $(render_headline "$records") | ||
|
|
||
| $(render_table "$records") | ||
| $(render_cache_fold "$records")" | ||
| upsert_comment "$body" | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| #!/usr/bin/env bash | ||
| # pipefail is deliberately omitted: extraction pipelines (e.g. grep|tail|sed) can | ||
| # legitimately have no matches, and pipefail would trip the ERR trap below. | ||
| set -u | ||
| trap 'echo "::warning::report-ci-metrics failed (fail-open)"; exit 0' ERR | ||
| here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) | ||
| # shellcheck source=SCRIPTDIR/lib.sh | ||
| . "$here/lib.sh" | ||
| main "$@" | ||
| exit 0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.