Skip to content
Merged
Show file tree
Hide file tree
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 Jun 12, 2026
4f9233b
BUILD-11310 Add extract_metrics_json with sentinel recovery
mikolaj-matuszny-ext-sonarsource Jun 12, 2026
9d65def
BUILD-11310 Add collect_job_metrics log aggregation
mikolaj-matuszny-ext-sonarsource Jun 12, 2026
ce08811
BUILD-11310 Add markdown rendering with folding
mikolaj-matuszny-ext-sonarsource Jun 12, 2026
f89209d
BUILD-11310 Add sticky PR comment upsert
mikolaj-matuszny-ext-sonarsource Jun 12, 2026
5d6744f
BUILD-11310 Wire report-ci-insights main and add README
mikolaj-matuszny-ext-sonarsource Jun 12, 2026
8613f8e
BUILD-11310 Harden report-ci-insights: validate JSON, escape pipes
mikolaj-matuszny-ext-sonarsource Jun 12, 2026
57d00df
BUILD-11310 Address SonarCloud smells in report-ci-insights spec
mikolaj-matuszny-ext-sonarsource Jun 15, 2026
a15015a
BUILD-11310 Add report-ci-insights to sonar.sources for coverage trac…
mikolaj-matuszny-ext-sonarsource Jun 15, 2026
a01c8a4
BUILD-11310 Cover orchestrator entry script and saved-only cache branch
mikolaj-matuszny-ext-sonarsource Jun 15, 2026
aa208ca
BUILD-11310 Reformat multi-line constructs for full coverage attribution
mikolaj-matuszny-ext-sonarsource Jun 15, 2026
1591c06
BUILD-11310 Sanitize newlines and angle brackets in PR comment cells
mikolaj-matuszny-ext-sonarsource Jun 15, 2026
2c9dec3
BUILD-11310 Trim verbose comments in report-ci-insights lib and spec
mikolaj-matuszny-ext-sonarsource Jun 15, 2026
1067097
BUILD-11310 Escape ampersand in HTML-entity replacement for bash 4.3+…
mikolaj-matuszny-ext-sonarsource Jun 15, 2026
e5ab1dd
BUILD-11310 Rename report-ci-insights action to report-ci-metrics
mikolaj-matuszny-ext-sonarsource Jun 15, 2026
8de7bbd
BUILD-11310 Move report-ci-metrics docs to root README
mikolaj-matuszny-ext-sonarsource Jun 15, 2026
6261c54
BUILD-11310 Add report-ci-metrics section to root README
mikolaj-matuszny-ext-sonarsource Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .shellspec
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# kcov (coverage) options
--kcov-options "--include-pattern=build-poetry,get-build-number,pr_cleanup,promote,build-gradle,config-maven,build-maven,config-npm,build-npm,build-yarn,shared,config-gradle,config-pip,update-release-channel"
--kcov-options "--include-pattern=build-poetry,get-build-number,pr_cleanup,promote,build-gradle,config-maven,build-maven,config-npm,build-npm,build-yarn,shared,config-gradle,config-pip,update-release-channel,report-ci-metrics"
# --kcov-options "--exclude-pattern=.github,.idea,.git"

# define minimum coverage (fail otherwise)
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ These badges show the status of workflows in dummy repositories that use (or sho
- [`code-signing`](#code-signing)
- [`check-sca`](#check-sca)
- [`update-release-channel`](#update-release-channel)
- [`report-ci-metrics`](#report-ci-metrics)

---

Expand Down Expand Up @@ -1539,6 +1540,46 @@ Terraform resource; add the environment for your repo there alongside the existi

---

## `report-ci-metrics`

Aggregate per-job CI resource metrics from the current workflow run and post a sticky pull-request comment summarising them.

### Usage

Add a dedicated reporting job that runs after all the jobs you want covered. It must run on every pull-request outcome
(`if: always()`) so the comment is posted even when an earlier job fails:

```yaml
jobs:
report-ci-metrics:
needs: [build, test, lint] # list every job whose metrics you want reported
if: always() && github.event_name == 'pull_request'
runs-on: sonar-xs
permissions:
actions: read # read sibling job logs via the Actions API
pull-requests: write # post / update the sticky comment
steps:
- uses: SonarSource/ci-github-actions/report-ci-metrics@v1
```

### How it works

1. Lists the run's jobs via the GitHub Actions API and downloads each completed sibling's log.
2. Recovers the metrics JSON that the runner-side CI-metrics hook emitted into the log (a sentinel-wrapped block), skipping the
report job itself and any job without a metrics block.
3. Renders a headline of run totals (CPU-seconds, peak memory, network, cache) plus foldable per-job and cache breakdown tables.
4. Posts a sticky comment matched by a hidden marker — re-runs update the same comment instead of duplicating it. If no sibling
produced metrics, nothing is posted.

### Scope and behaviour

- Metrics are produced only on **Linux ARC and WarpBuild runners** where the CI Metrics runner hook runs; jobs on other runners
simply contribute no data.
- **Fail-open**: any error is logged as a warning and the step exits `0`, so this action never fails the workflow.
- Runs only for `pull_request` events — there is no PR to comment on otherwise.

---

## Deployment Strategy

All build actions (`build-maven`, `build-gradle`, `build-npm`, `build-yarn`, `build-poetry`) share the same branch-based deployment and
Expand Down
14 changes: 14 additions & 0 deletions report-ci-metrics/action.yml
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
285 changes: 285 additions & 0 deletions report-ci-metrics/lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
#!/usr/bin/env bash
Comment thread
mikolaj-matuszny-ext-sonarsource marked this conversation as resolved.
# 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//</\&lt;}
s=${s//>/\&gt;}
printf '%s' "$s"
}
Comment thread
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"
}
10 changes: 10 additions & 0 deletions report-ci-metrics/report-ci-metrics.sh
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
2 changes: 1 addition & 1 deletion sonar-project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ sonar.projectName=ci-github-actions

sonar.sourceEncoding=UTF-8

sonar.sources=build-poetry,get-build-number,pr_cleanup,promote,build-gradle,build-maven,config-npm,build-npm,build-yarn,shared,config-pip,cache,code-signing,config-gradle,config-maven,check-sca,update-release-channel
sonar.sources=build-poetry,get-build-number,pr_cleanup,promote,build-gradle,build-maven,config-npm,build-npm,build-yarn,shared,config-pip,cache,code-signing,config-gradle,config-maven,check-sca,update-release-channel,report-ci-metrics
sonar.tests=spec

sonar.coverageReportPaths=coverage/coverage_data/sonar_coverage.xml
Loading
Loading