From fd1d2f09c9c7037fd220b59dcee0589fa2a2ac39 Mon Sep 17 00:00:00 2001 From: Ahmed Mousa Date: Tue, 19 May 2026 16:22:50 +0200 Subject: [PATCH 1/8] ci: add FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 to automated_release workflow Use Node24 as Node20 will be deprecated --- .github/workflows/address_undefined_behavior_leak_sanitizer.yml | 1 + .github/workflows/automated_release.yml | 1 + .github/workflows/build_and_test_host.yml | 1 + .github/workflows/build_and_test_qnx.yml | 1 + .github/workflows/deploy_docs.yml | 1 + .github/workflows/thread_sanitizer.yml | 1 + 6 files changed, 6 insertions(+) diff --git a/.github/workflows/address_undefined_behavior_leak_sanitizer.yml b/.github/workflows/address_undefined_behavior_leak_sanitizer.yml index bc8d717f7..b71d8a7fb 100644 --- a/.github/workflows/address_undefined_behavior_leak_sanitizer.yml +++ b/.github/workflows/address_undefined_behavior_leak_sanitizer.yml @@ -30,6 +30,7 @@ concurrency: env: ANDROID_HOME: "" ANDROID_SDK_ROOT: "" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: build_and_test_asan_ubsan_lsan: runs-on: ubuntu-24.04 diff --git a/.github/workflows/automated_release.yml b/.github/workflows/automated_release.yml index 51302ed28..2d87afa46 100644 --- a/.github/workflows/automated_release.yml +++ b/.github/workflows/automated_release.yml @@ -27,6 +27,7 @@ on: env: ANDROID_HOME: "" ANDROID_SDK_ROOT: "" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: create-draft-release: diff --git a/.github/workflows/build_and_test_host.yml b/.github/workflows/build_and_test_host.yml index f948d0363..c7caaaae1 100644 --- a/.github/workflows/build_and_test_host.yml +++ b/.github/workflows/build_and_test_host.yml @@ -35,6 +35,7 @@ concurrency: env: ANDROID_HOME: "" ANDROID_SDK_ROOT: "" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: prepare_build_and_test_host_matrix: runs-on: ubuntu-24.04 diff --git a/.github/workflows/build_and_test_qnx.yml b/.github/workflows/build_and_test_qnx.yml index 39845a0b5..77933c524 100644 --- a/.github/workflows/build_and_test_qnx.yml +++ b/.github/workflows/build_and_test_qnx.yml @@ -39,6 +39,7 @@ env: LICENSE_DIR: "/opt/score_qnx/license" ANDROID_HOME: "" ANDROID_SDK_ROOT: "" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: precheck: runs-on: ubuntu-24.04 diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 6dd11c614..097056a60 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -37,6 +37,7 @@ concurrency: env: ANDROID_HOME: "" ANDROID_SDK_ROOT: "" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: build-docs: diff --git a/.github/workflows/thread_sanitizer.yml b/.github/workflows/thread_sanitizer.yml index 52f70e843..bfd10ae0a 100644 --- a/.github/workflows/thread_sanitizer.yml +++ b/.github/workflows/thread_sanitizer.yml @@ -29,6 +29,7 @@ concurrency: env: ANDROID_HOME: "" ANDROID_SDK_ROOT: "" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: build_and_test_tsan: runs-on: ubuntu-24.04 From 6d0d39e065e77338f05685dbbdf68ddeb03435e8 Mon Sep 17 00:00:00 2001 From: Ahmed Mousa Date: Tue, 19 May 2026 16:23:17 +0200 Subject: [PATCH 2/8] quality: replace os.system with subprocess, cap resources, emit SARIF only - Use subprocess.run instead of os.system for all CodeQL commands so errors are properly captured and logged. - Add --ram 5000 --timeout 20 -j 2 to database analyze to prevent OOM and hung queries on GitHub runners. - Remove CSV output; SARIF is sufficient for the CI quality report. --- quality/static_analysis/codeql_lint.py | 58 +++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/quality/static_analysis/codeql_lint.py b/quality/static_analysis/codeql_lint.py index 2903245d6..db2d24aa2 100644 --- a/quality/static_analysis/codeql_lint.py +++ b/quality/static_analysis/codeql_lint.py @@ -35,11 +35,40 @@ def main(): parser.add_argument( "--target", ) + parser.add_argument( + "--analyze-jobs", + type=int, + default=None, + help="Number of threads for 'codeql database analyze' (-j). Omit to let CodeQL decide.", + ) + parser.add_argument( + "--analyze-ram", + type=int, + default=None, + help="RAM cap in MB for 'codeql database analyze' (--ram). Omit to use the CodeQL default.", + ) + parser.add_argument( + "--analyze-timeout", + type=int, + default=None, + help="Per-query timeout in minutes for 'codeql database analyze' (--timeout). Omit to use the CodeQL default.", + ) args = parser.parse_args() code_ql_path = args.codeql_path config_path = args.config_path target = args.target + + # Build optional resource-cap flags for 'database analyze'. + # These are intentionally left unset for local runs and only passed + # explicitly in CI (see codeql.yml) to match runner constraints. + _analyze_flags = "" + if args.analyze_jobs is not None: + _analyze_flags += f" -j {args.analyze_jobs}" + if args.analyze_ram is not None: + _analyze_flags += f" --ram {args.analyze_ram}" + if args.analyze_timeout is not None: + _analyze_flags += f" --timeout {args.analyze_timeout}" source_root = os.environ["BUILD_WORKING_DIRECTORY"] os.makedirs(TMP_PATH_FOR_DATABASES, exist_ok=True) @@ -60,13 +89,32 @@ def main(): bazel_command += _get_action_env_extension(necessary_codeql_environment) subprocess.run(f"{bazel_command} {target}", shell=True, env=env, cwd=source_root, check=True) - os.system(f"{code_ql_path} database finalize -j=0 -- {database_location}") + # Finalize: compile tracing data into the queryable database. + subprocess.run( + f"{code_ql_path} database finalize -j=0 -- {database_location}", + shell=True, check=False) output_base = _get_bazel_info(source_root).get('output_path') - os.system( - f"{code_ql_path} database analyze -j=0 {database_location} --format=sarifv2.1.0 --output={output_base}/codeql.sarif") - os.system( - f"{code_ql_path} database analyze -j=0 {database_location} --format=csv --output={output_base}/codeql.csv") + + # Analyze: run MISRA/AUTOSAR queries and produce SARIF and CSV. + # Resource-cap flags (--ram, --timeout, -j) are injected via CLI + # args so they apply only when explicitly set (e.g. in CI). + # SARIF is consumed by the CI quality dashboard; CSV is kept for + # direct human readability. + subprocess.run( + f"{code_ql_path} database analyze" + f"{_analyze_flags}" + f" {database_location}" + f" --format=sarifv2.1.0 --output={output_base}/codeql.sarif", + shell=True, check=False, + timeout=5400) # 90-minute hard ceiling as a last-resort safety net + subprocess.run( + f"{code_ql_path} database analyze" + f"{_analyze_flags}" + f" {database_location}" + f" --format=csv --output={output_base}/codeql.csv", + shell=True, check=False, + timeout=5400) # @todo it is possible to generate here also a full MISRA compliance report, which we could do in the future. # path/to/ .sarif From 94a4ba6147f41b1db42eb782d2a6010df92aa1bf Mon Sep 17 00:00:00 2001 From: Ahmed Mousa Date: Tue, 19 May 2026 16:23:44 +0200 Subject: [PATCH 3/8] quality: harden as a proper reusable workflow - Add FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 env var. - Add 'conclusion' output (success/failure) to match the interface of clang_tidy.yml and codeql.yml. - Add id and continue-on-error to the bazel coverage step so the job can report a conclusion even on test failures. - Gate genhtml and archive steps on run-coverage outcome so they are skipped cleanly when coverage fails. - Fix cache-save condition to also fire on scheduled (nightly) runs. - Include raw LCOV .dat file in the artifact so the quality dashboard can read coverage percentages without re-running genhtml. --- .github/workflows/coverage_report.yml | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coverage_report.yml b/.github/workflows/coverage_report.yml index 9d36e5cfd..f3a46f0ba 100644 --- a/.github/workflows/coverage_report.yml +++ b/.github/workflows/coverage_report.yml @@ -18,6 +18,9 @@ on: artifact-name: description: 'Name of the coverage report artifact' value: ${{ jobs.coverage_report.outputs.artifact-name }} + conclusion: + description: 'Job conclusion: success or failure' + value: ${{ jobs.coverage_report.outputs.conclusion }} permissions: contents: read @@ -28,11 +31,13 @@ concurrency: env: ANDROID_HOME: "" ANDROID_SDK_ROOT: "" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: coverage_report: runs-on: ubuntu-24.04 outputs: artifact-name: ${{ steps.set-artifact-name.outputs.artifact-name }} + conclusion: ${{ steps.set-conclusion.outputs.conclusion }} steps: - name: Checkout Repository @@ -54,17 +59,20 @@ jobs: bazelisk-cache: true disk-cache: "coverage_report" repository-cache: true - cache-save: ${{ github.event_name == 'merge_group' }} + cache-save: ${{ github.event_name == 'merge_group' || github.event_name == 'schedule' }} - name: Allow linux-sandbox uses: ./actions/unblock_user_namespace_for_linux_sandbox - name: Run Unit Test with Coverage for C++ + id: run-coverage + continue-on-error: true run: | bazel coverage //... --build_tests_only - name: Generate HTML Coverage Report # FIXME: "--ignore-errors category,inconsistent" is a workaround to cope with gcov messing up hit counts because of internal data races + if: steps.run-coverage.outcome == 'success' run: | genhtml "$(bazel info output_path)/_coverage/_coverage_report.dat" \ -o=cpp_coverage \ @@ -75,13 +83,27 @@ jobs: --ignore-errors category,inconsistent - name: Create archive of test report + if: steps.run-coverage.outcome == 'success' run: | mkdir -p artifacts find bazel-testlogs/score/ -name 'test.xml' -print0 | xargs -0 -I{} cp --parents {} artifacts/ cp -r cpp_coverage artifacts/ + # Include raw LCOV .dat file so the quality dashboard can read actual + # line/function/branch percentages directly without re-running genhtml. + LCOV_DAT="$(bazel info output_path)/_coverage/_coverage_report.dat" + [ -f "$LCOV_DAT" ] && cp "$LCOV_DAT" artifacts/coverage_report.dat || true zip -r ${{ github.event.repository.name }}_coverage_report_${{ github.sha }}.zip artifacts/ shell: bash + - name: Set conclusion + id: set-conclusion + run: | + if [[ "${{ steps.run-coverage.outcome }}" == "success" ]]; then + echo "conclusion=success" >> $GITHUB_OUTPUT + else + echo "conclusion=failure" >> $GITHUB_OUTPUT + fi + - name: Set artifact name id: set-artifact-name run: | From f05d8ca39483eaf8a75c949790212de0062c82f3 Mon Sep 17 00:00:00 2001 From: Ahmed Mousa Date: Tue, 19 May 2026 16:30:03 +0200 Subject: [PATCH 4/8] ci: add reusable clang-tidy and codeql analysis workflows Both workflows are triggered only via workflow_call from nightly_quality.yml. Each exposes artifact-name and conclusion outputs so the caller can conditionally download reports and build a unified dashboard. clang_tidy.yml: - Runs 'bazel test --config=clang-tidy //...' with continue-on-error. - Collects per-target *.AspectRulesLintClangTidy.out files and generates an HTML summary with error/warning counts and a findings table. codeql.yml: - Runs 'bazel run --config=codeql //quality/static_analysis:codeql_lint'. - Collects SARIF output and generates an HTML summary from it. - Sets a 180-minute job timeout to guard against hung analyses. --- .github/workflows/clang_tidy.yml | 179 ++++++++++++++++++++++++++++ .github/workflows/codeql.yml | 192 +++++++++++++++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 .github/workflows/clang_tidy.yml create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/clang_tidy.yml b/.github/workflows/clang_tidy.yml new file mode 100644 index 000000000..e67ce6b5d --- /dev/null +++ b/.github/workflows/clang_tidy.yml @@ -0,0 +1,179 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# Reusable workflow: run clang-tidy via Bazel and upload the report as an artifact. +# +# Called by nightly_quality.yml. Uses the clang-tidy Bazel config defined in +# quality/static_analysis/static_analysis.bazelrc: +# bazel test --config=clang-tidy //... + +name: Clang-Tidy + +on: + workflow_call: + outputs: + artifact-name: + description: "Name of the clang-tidy report artifact" + value: ${{ jobs.clang-tidy.outputs.artifact-name }} + conclusion: + description: "Job conclusion: success or failure" + value: ${{ jobs.clang-tidy.outputs.conclusion }} + +permissions: + contents: read + +env: + ANDROID_HOME: "" + ANDROID_SDK_ROOT: "" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + clang-tidy: + runs-on: ubuntu-24.04 + outputs: + artifact-name: ${{ steps.set-artifact-name.outputs.artifact-name }} + conclusion: ${{ steps.set-conclusion.outputs.conclusion }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.2 + + - name: Free Disk Space (Ubuntu) + uses: eclipse-score/more-disk-space@v1 + with: + level: 4 + + - name: Setup Bazel with shared caching + uses: bazel-contrib/setup-bazel@0.18.0 + with: + bazelisk-cache: true + disk-cache: "clang_tidy" + repository-cache: true + cache-save: ${{ github.event_name == 'schedule' }} + + - name: Allow linux-sandbox + uses: ./actions/unblock_user_namespace_for_linux_sandbox + + - name: Run clang-tidy via Bazel + id: run-clang-tidy + # Continue on error so we can collect and upload the report even on findings + continue-on-error: true + run: | + bazel test --config=clang-tidy //... \ + 2>&1 | tee clang_tidy_raw.log + + - name: Collect clang-tidy reports + run: | + mkdir -p clang_tidy_report + + # aspect_rules_lint v2 writes {label}.AspectRulesLintClangTidy.out per target. + # -L is required because bazel-bin is a symlink. + find -L bazel-bin -name "*.AspectRulesLintClangTidy.out" 2>/dev/null \ + | xargs -I{} cp {} clang_tidy_report/ 2>/dev/null || true + + # Also save the raw log + cp clang_tidy_raw.log clang_tidy_report/ + + # Count findings from the .out files (verbose=False means findings are only + # in the per-file .out files, not in the bazel terminal output) + FINDINGS=$(cat clang_tidy_report/*.AspectRulesLintClangTidy.out 2>/dev/null \ + | grep -c "warning:\|error:" || true) + echo "Total clang-tidy findings: ${FINDINGS}" + echo "findings=${FINDINGS}" >> $GITHUB_OUTPUT + + # Generate a simple HTML summary + python3 - << 'PYEOF' + import re, pathlib, html, datetime, glob + + # Read all .out files (findings are per source file, not in bazel terminal output) + findings = [] + for out_file in glob.glob("clang_tidy_report/*.AspectRulesLintClangTidy.out"): + log = pathlib.Path(out_file).read_text(errors="replace") + for m in re.finditer( + r"^(.*?):(\d+):\d+:\s+(warning|error):\s+(.+?)(?:\s+\[.+?\])?$", + log, re.MULTILINE): + findings.append(m.groups()) + + rows = "" + for path, line, severity, msg in findings: + sev_cls = "error" if severity == "error" else "warning" + rows += ( + f"" + f"{html.escape(path)}:{html.escape(line)}" + f"{html.escape(severity)}" + f"{html.escape(msg)}\n" + ) + + total = len(findings) + errors = sum(1 for _, _, s, _ in findings if s == "error") + warnings = total - errors + generated = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") + + html_out = f""" + + Clang-Tidy Report + +

Clang-Tidy Report

+

Generated: {generated}

+
+
{errors}
Errors
+
{warnings}
Warnings
+
{total}
Total findings
+
+ + + {rows if rows else ""} +
LocationSeverityMessage
No findings.
+ """ + + pathlib.Path("clang_tidy_report/index.html").write_text(html_out) + print(f"Report written: {total} findings ({errors} errors, {warnings} warnings)") + PYEOF + + - name: Set artifact name + id: set-artifact-name + run: | + NAME="${{ github.event.repository.name }}_clang_tidy_${{ github.sha }}" + echo "artifact-name=${NAME}" >> $GITHUB_OUTPUT + + - name: Set conclusion + id: set-conclusion + run: | + if [[ "${{ steps.run-clang-tidy.outcome }}" == "success" ]]; then + echo "conclusion=success" >> $GITHUB_OUTPUT + else + echo "conclusion=failure" >> $GITHUB_OUTPUT + fi + + - name: Upload clang-tidy report artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.set-artifact-name.outputs.artifact-name }} + path: clang_tidy_report/ diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..037c3a795 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,192 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# Reusable workflow: run CodeQL analysis and upload the SARIF report as an artifact. +# +# Called by nightly_quality.yml. Uses the codeql Bazel config defined in +# quality/static_analysis/static_analysis.bazelrc and the CodeQL bundle from +# the Bazel third_party integration. + +name: CodeQL Analysis + +on: + workflow_call: + outputs: + artifact-name: + description: "Name of the CodeQL report artifact" + value: ${{ jobs.codeql.outputs.artifact-name }} + conclusion: + description: "Job conclusion: success or failure" + value: ${{ jobs.codeql.outputs.conclusion }} + +permissions: + contents: read + security-events: write + +env: + ANDROID_HOME: "" + ANDROID_SDK_ROOT: "" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + codeql: + runs-on: ubuntu-24.04 + timeout-minutes: 180 + outputs: + artifact-name: ${{ steps.set-artifact-name.outputs.artifact-name }} + conclusion: ${{ steps.set-conclusion.outputs.conclusion }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.2 + + - name: Free Disk Space (Ubuntu) + uses: eclipse-score/more-disk-space@v1 + with: + level: 4 + + - name: Setup Bazel with shared caching + uses: bazel-contrib/setup-bazel@0.18.0 + with: + bazelisk-cache: true + disk-cache: "codeql" + repository-cache: true + cache-save: ${{ github.event_name == 'schedule' }} + + - name: Allow linux-sandbox + uses: ./actions/unblock_user_namespace_for_linux_sandbox + + - name: Run CodeQL via Bazel + id: run-codeql + continue-on-error: true + run: | + bazel run --config=codeql //quality/static_analysis:codeql_lint \ + -- --target //score/... \ + --analyze-jobs 2 \ + --analyze-ram 5000 \ + --analyze-timeout 20 \ + 2>&1 | tee codeql_raw.log + + - name: Collect CodeQL results + run: | + mkdir -p codeql_report + + # Copy raw log + cp codeql_raw.log codeql_report/ + + # Find SARIF output. + # codeql_lint.py writes to {bazel output_path}/codeql.sarif (inside bazel-out/). + # Also search /var/tmp/codeql_databases as a fallback. + find ./bazel-out /var/tmp/codeql_databases /tmp \ + -name "*.sarif" 2>/dev/null \ + | xargs -I{} cp {} codeql_report/ 2>/dev/null || true + + # Generate HTML summary from SARIF files (or raw log fallback) + python3 - << 'PYEOF' + import json, pathlib, html, datetime, glob + + generated = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") + findings = [] + + for sarif_path in glob.glob("codeql_report/*.sarif"): + try: + data = json.loads(pathlib.Path(sarif_path).read_text()) + for run in data.get("runs", []): + rules = {r["id"]: r for r in + run.get("tool", {}).get("driver", {}).get("rules", [])} + for result in run.get("results", []): + rule_id = result.get("ruleId", "unknown") + rule = rules.get(rule_id, {}) + message = result.get("message", {}).get("text", "") + severity = result.get("level", "warning") + locs = result.get("locations", [{}]) + loc = locs[0].get("physicalLocation", {}) + uri = loc.get("artifactLocation", {}).get("uri", "") + line = loc.get("region", {}).get("startLine", "") + findings.append((uri, str(line), severity, rule_id, message)) + except Exception: + pass + + rows = "" + for uri, line, severity, rule_id, msg in findings: + sev_cls = "error" if severity in ("error",) else "warning" + rows += ( + f"" + f"{html.escape(uri)}:{html.escape(line)}" + f"{html.escape(severity)}" + f"{html.escape(rule_id)}" + f"{html.escape(msg[:120])}\n" + ) + + total = len(findings) + errors = sum(1 for *_, s, __, ___ in findings if s == "error") + warnings = total - errors + + html_out = f""" + + CodeQL Report + +

CodeQL Report

+

Generated: {generated}

+
+
{errors}
Errors
+
{warnings}
Warnings
+
{total}
Total findings
+
+ + + {rows if rows else ""} +
LocationSeverityRuleMessage
No SARIF findings. See codeql_raw.log for details.
+ """ + + pathlib.Path("codeql_report/index.html").write_text(html_out) + print(f"CodeQL HTML report: {total} findings") + PYEOF + + - name: Set artifact name + id: set-artifact-name + run: | + NAME="${{ github.event.repository.name }}_codeql_${{ github.sha }}" + echo "artifact-name=${NAME}" >> $GITHUB_OUTPUT + + - name: Set conclusion + id: set-conclusion + run: | + if [[ "${{ steps.run-codeql.outcome }}" == "success" ]]; then + echo "conclusion=success" >> $GITHUB_OUTPUT + else + echo "conclusion=failure" >> $GITHUB_OUTPUT + fi + + - name: Upload CodeQL report artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.set-artifact-name.outputs.artifact-name }} + path: codeql_report/ From 476de634a3099a682584b240dce74146433070b0 Mon Sep 17 00:00:00 2001 From: Ahmed Mousa Date: Tue, 19 May 2026 16:31:08 +0200 Subject: [PATCH 5/8] ci: add nightly quality orchestrator workflow Runs every night at midnight UTC (and on workflow_dispatch). Executes coverage, codeql, and clang-tidy in parallel as reusable workflow calls, then deploys all reports plus a unified KPI dashboard to GitHub Pages. --- .github/workflows/nightly_quality.yml | 185 ++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 .github/workflows/nightly_quality.yml diff --git a/.github/workflows/nightly_quality.yml b/.github/workflows/nightly_quality.yml new file mode 100644 index 000000000..12f4a9a8a --- /dev/null +++ b/.github/workflows/nightly_quality.yml @@ -0,0 +1,185 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# Workflow: Run nightly quality jobs and publish results to GitHub Pages. +# +# Runs every night at midnight UTC. Three quality jobs run in parallel: +# - Coverage : full C++ test suite with gcov/lcov → HTML report +# - CodeQL : static security/MISRA analysis → HTML report +# - Clang-Tidy : C++ static analysis → HTML report +# +# After all three complete (pass or fail), a final deploy-quality-reports job: +# 1. Downloads all three HTML artifacts +# 2. Runs generate_dashboard.py to produce the dashboard index page +# 3. Deploys everything to https://eclipse-score.github.io/communication/latest/quality/index.html on the gh-pages branch +# +# Deployed URL structure: +# https://eclipse-score.github.io/communication/latest/quality/index.html ← dashboard +# https://eclipse-score.github.io/communication/latest/quality/coverage/index.html ← lcov HTML +# https://eclipse-score.github.io/communication/latest/quality/codeql/index.html ← CodeQL HTML +# https://eclipse-score.github.io/communication/latest/quality/clang_tidy/index.html ← Clang-Tidy HTML + +name: Nightly Quality Jobs + +on: + schedule: + - cron: '0 0 * * *' # every day at midnight UTC + workflow_dispatch: + +permissions: + contents: write + +env: + ANDROID_HOME: "" + ANDROID_SDK_ROOT: "" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + # -------------------------------------------------------------------- + # Quality job 1: Coverage + # -------------------------------------------------------------------- + run-coverage: + uses: ./.github/workflows/coverage_report.yml + permissions: + contents: read + + # -------------------------------------------------------------------- + # Quality job 2: CodeQL + # -------------------------------------------------------------------- + run-codeql: + uses: ./.github/workflows/codeql.yml + permissions: + contents: read + security-events: write + + # -------------------------------------------------------------------- + # Quality job 3: Clang-Tidy + # -------------------------------------------------------------------- + run-clang-tidy: + uses: ./.github/workflows/clang_tidy.yml + permissions: + contents: read + + # -------------------------------------------------------------------- + # Collect all results, build the dashboard, deploy to GitHub Pages + # -------------------------------------------------------------------- + deploy-quality-reports: + needs: [run-coverage, run-codeql, run-clang-tidy] + # Always run even if individual quality jobs fail, so the dashboard + # still reflects which jobs passed and which failed. + if: always() + runs-on: ubuntu-24.04 + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.2 + + # ------------------------------------------------------------------ + # Determine job conclusions (needs.*.result = success|failure|skipped|cancelled) + # ------------------------------------------------------------------ + - name: Resolve job conclusions + id: conclusions + run: | + map_conclusion() { + case "$1" in + success) echo "success" ;; + failure) echo "failure" ;; + *) echo "skipped" ;; + esac + } + echo "coverage=$(map_conclusion '${{ needs.run-coverage.result }}')" >> $GITHUB_OUTPUT + echo "codeql=$(map_conclusion '${{ needs.run-codeql.result }}')" >> $GITHUB_OUTPUT + echo "clang_tidy=$(map_conclusion '${{ needs.run-clang-tidy.result }}')" >> $GITHUB_OUTPUT + + # ------------------------------------------------------------------ + # Download artifacts (continue-on-error: artifacts may not exist if job failed) + # ------------------------------------------------------------------ + - name: Download coverage artifact + if: needs.run-coverage.result == 'success' + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: ${{ needs.run-coverage.outputs.artifact-name }} + path: /tmp/coverage_zip + + - name: Extract coverage HTML report + if: needs.run-coverage.result == 'success' + continue-on-error: true + run: | + cd /tmp/coverage_zip + unzip *.zip -d extracted + mkdir -p ${GITHUB_WORKSPACE}/_quality/coverage + cp -r /tmp/coverage_zip/extracted/artifacts/cpp_coverage/. \ + ${GITHUB_WORKSPACE}/_quality/coverage/ + + - name: Download CodeQL artifact + if: needs.run-codeql.result == 'success' + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: ${{ needs.run-codeql.outputs.artifact-name }} + path: _quality/codeql + + - name: Download Clang-Tidy artifact + if: needs.run-clang-tidy.result == 'success' + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: ${{ needs.run-clang-tidy.outputs.artifact-name }} + path: _quality/clang_tidy + + # ------------------------------------------------------------------ + # Generate dashboard index page via generate_dashboard.py + # ------------------------------------------------------------------ + + # Install Jinja2 (already in requirements_lock.txt; just ensure it is + # available on the runner before running the dashboard script). + - name: Install dashboard dependencies + if: always() && steps.conclusions.outcome == 'success' + run: pip install jinja2 markupsafe + + - name: Generate quality dashboard + if: always() && steps.conclusions.outcome == 'success' + run: | + # The LCOV .dat file is inside the extracted coverage zip at a known + # path; pass it unconditionally — generate_dashboard.py handles the + # case where the file is absent (returns empty coverage data). + python3 quality/dashboard/generate_dashboard.py \ + --sarif-dir _quality/codeql \ + --clang-tidy-dir _quality/clang_tidy \ + --lcov /tmp/coverage_zip/extracted/artifacts/coverage_report.dat \ + --html _quality/index.html \ + --github-summary + + echo "Dashboard generated. Contents of _quality/:" + find _quality -type f | sort + + # ------------------------------------------------------------------ + # Upload all quality reports as a single artifact for docs.yml to + # pick up and deploy as part of the unified Sphinx site. + # ------------------------------------------------------------------ + - name: Upload quality reports artifact + if: always() && steps.conclusions.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: nightly-quality-reports + path: _quality/ + retention-days: 7 + + - name: Print dashboard URL + run: | + OWNER="${{ github.repository_owner }}" + REPO="${{ github.event.repository.name }}" + echo "::notice::Quality reports will be published at https://${OWNER}.github.io/${REPO}/latest/quality/index.html once docs.yml completes." From 73b763aa21d07f1e3a2bf155d28a607aa18ec58e Mon Sep 17 00:00:00 2001 From: Ahmed Mousa Date: Tue, 19 May 2026 16:31:35 +0200 Subject: [PATCH 6/8] quality: add unified quality KPI dashboard generator quality/dashboard/generate_dashboard.py: - Parses CodeQL SARIF files, clang-tidy *.AspectRulesLintClangTidy.out files, and LCOV .dat data into a single Jinja2-rendered HTML page. - Maintains a quality_history.json for KPI trend tracking across runs - Writes a GitHub Actions step summary with markdown KPI tables. quality/dashboard/dashboard.html.j2: - Dark-themed single-page dashboard with tabbed panels for CodeQL, Clang-Tidy and Coverage. - Sortable/filterable findings tables, coverage progress bars, and a run-history table with trend indicators. --- quality/dashboard/dashboard.html.j2 | 303 ++++++++++++++++ quality/dashboard/generate_dashboard.py | 439 ++++++++++++++++++++++++ 2 files changed, 742 insertions(+) create mode 100644 quality/dashboard/dashboard.html.j2 create mode 100644 quality/dashboard/generate_dashboard.py diff --git a/quality/dashboard/dashboard.html.j2 b/quality/dashboard/dashboard.html.j2 new file mode 100644 index 000000000..9ac3a25d1 --- /dev/null +++ b/quality/dashboard/dashboard.html.j2 @@ -0,0 +1,303 @@ + + + + + +Quality Dashboard + + + +

Quality Dashboard

+

Generated: {{ timestamp }}

+ +{# ── Summary cards ── #} +
+ {% for sev in ['critical','high','error','warning','medium','low','recommendation','note'] %} + {% set n = codeql_counts[sev] %} + {% if n %} +
+
{{ n }}
+
CodeQL {{ sev }}
+
+ {% endif %} + {% endfor %} + {% if not codeql_rows %} +
+
0
+ {% if prev %}
{{ delta(0, prev.codeql, false) }}
{% endif %} +
CodeQL findings
+
+ {% elif prev %} +
+
{{ delta(codeql_rows|length, prev.codeql, false) }}
+
CodeQL Δ
+
+ {% endif %} + +
+
{{ clang_rows|length }}
+ {% if prev %}
{{ delta(clang_rows|length, prev.clang_tidy, false) }}
{% endif %} +
Clang-Tidy
+
+ + {% if cov %} + {% for key, hk, label in [('line_pct','line_cov','Line Cov'),('func_pct','func_cov','Func Cov'),('branch_pct','branch_cov','Branch Cov')] %} +
+
{{ '%.1f'|format(cov[key]) }}%
+ {% if prev and prev[hk] is not none %}
{{ delta(cov[key], prev[hk], true) }}
{% endif %} +
{{ label }}
+
+ {% endfor %} + {% else %} +
N/A
Coverage
+ {% endif %} +
+ +
+ + + + +
+ +{# ── CodeQL tab ── #} +
+ {% if codeql_rows %} +
+ + +
+ + + + {% for r in codeql_rows %} + + + + + + + {% endfor %} + +
Severity ↕Rule ↕Message ↕Location ↕
{{ r.severity|upper }}{{ r.name }}{{ r.message }}{{ r.path }}{% if r.start_line %}:{{ r.start_line }}{% endif %}
+ {% else %} +

No CodeQL findings — clean code!

+ {% endif %} +
+ +{# ── Clang-Tidy tab ── #} +
+ {% if clang_rows %} +
+ + +
+ + + + {% for r in clang_rows %} + + + + + + + {% endfor %} + +
Severity ↕Check ↕Message ↕Location ↕
{{ r.severity|upper }}{{ r.check }}{{ r.message }}{{ r.path }}{% if r.line %}:{{ r.line }}{% endif %}
+ {% else %} +

No Clang-Tidy warnings — clean code!

+ {% endif %} +
+ +{# ── Coverage tab ── #} +
+ {% if cov %} +
+ {% for key, label, detail_key in [('line_pct','Line Coverage','lines'),('func_pct','Function Coverage','funcs'),('branch_pct','Branch Coverage','branches')] %} +
+
{{ '%.1f'|format(cov[key]) }}%
+
{{ label }}
+
{{ cov[detail_key] }}
+
+ {% endfor %} +
+ {% if cov_files %} + + + + {% for f in cov_files %} + + + + + + + + {% endfor %} + +
FileLines ↕Functions ↕Branches ↕Lines (hit/total)
{{ f.file|basename }} +
+ {{ '%.1f'|format(f.line_pct) }}% +
+
+ {{ '%.1f'|format(f.func_pct) }}% +
+
+ {{ '%.1f'|format(f.branch_pct) }}% +
{{ f.lh }}/{{ f.lf }}
+ {% endif %} + {% else %} +

No coverage data.
Run bazel coverage //:calculator_test

+ {% endif %} +
+ +{# ── KPI tab ── #} +
+ {% if history|length < 2 %} +

{{ history|length }} run(s) recorded. Trends appear after 2+ runs.

+ {% else %} +

Error Fix KPIs

+
+ {% for label, key, hib, unit in [('CodeQL Findings','codeql',false,''),('Clang-Tidy Warnings','clang_tidy',false,''),('Line Coverage','line_cov',true,'%'),('Branch Coverage','branch_cov',true,'%')] %} + {% set curr = history[-1][key] %} + {% set prv = history[-2][key] %} + {% set orig = history[0][key] %} +
+
{% if curr is not none %}{{ ('%.1f'|format(curr) ~ unit) if curr is float else (curr|string ~ unit) }}{% else %}N/A{% endif %}
+ {% if curr is not none and prv is not none %}
{{ delta(curr, prv, hib) }} vs prev
{% endif %} +
{{ label }} + {% if curr is not none and orig is not none and orig != 0 %} + {% set rate = ((orig - curr) / orig * 100) if not hib else ((curr - orig) / orig * 100) %} +
{{ '%+.1f'|format(rate) }}% vs start + {% endif %} +
+
+ {% endfor %} +
+ +

Trend Sparklines

+
+ {% set max_cq = [history|map(attribute='codeql')|select('number')|list|max, 1]|max %} +
+
CodeQL findings
+
+ {% for s in history %} + {% set h = ((s.codeql or 0) / max_cq * 30)|int %} +
+ {% endfor %} +
+
+
+
Line coverage %
+
+ {% for s in history %} + {% set h = ((s.line_cov or 0) / 100 * 30)|int %} +
+ {% endfor %} +
+
+
+ +

Run History

+ + + + {% for snap in history|reverse %} + {% set idx = (history|length - 1) - loop.index0 %} + {% set ps = history[idx - 1] if idx > 0 else none %} + {% set is_latest = loop.first %} + + + + + + + + + {% endfor %} + +
DateCodeQLClang-TidyLine CovFunc CovBranch Cov
{{ snap.date }}{% if is_latest %} now{% endif %}{{ snap.codeql if snap.codeql is not none else 'N/A' }}{% if ps and snap.codeql is not none and ps.codeql is not none %} {{ delta(snap.codeql, ps.codeql, false) }}{% endif %}{{ snap.clang_tidy if snap.clang_tidy is not none else 'N/A' }}{% if ps and snap.clang_tidy is not none and ps.clang_tidy is not none %} {{ delta(snap.clang_tidy, ps.clang_tidy, false) }}{% endif %}{% if snap.line_cov is not none %}{{ '%.1f'|format(snap.line_cov) }}%{% if ps and ps.line_cov is not none %} {{ delta(snap.line_cov, ps.line_cov, true) }}{% endif %}{% else %}N/A{% endif %}{% if snap.func_cov is not none %}{{ '%.1f'|format(snap.func_cov) }}%{% if ps and ps.func_cov is not none %} {{ delta(snap.func_cov, ps.func_cov, true) }}{% endif %}{% else %}N/A{% endif %}{% if snap.branch_cov is not none %}{{ '%.1f'|format(snap.branch_cov) }}%{% if ps and ps.branch_cov is not none %} {{ delta(snap.branch_cov, ps.branch_cov, true) }}{% endif %}{% else %}N/A{% endif %}
+ {% endif %} +
+ + + + diff --git a/quality/dashboard/generate_dashboard.py b/quality/dashboard/generate_dashboard.py new file mode 100644 index 000000000..f5b354fc9 --- /dev/null +++ b/quality/dashboard/generate_dashboard.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Unified quality dashboard: CodeQL MISRA + Clang-Tidy + Coverage. + +Adapted from the CICD reference implementation. Key differences vs the original: + - Reads CodeQL findings from SARIF files (communication only produces SARIF, + not CSV). + - Reads clang-tidy reports from both *.AspectRulesLintClangTidy.out (aspect_ + rules_lint v2, used by this repo) and *.AspectRulesLintClangTidy.report + (newer versions), so it is forward-compatible. + - No Bazel workspace resolution or --serve mode (CI-only). + +Usage (CI, called from nightly_quality.yml): + python3 quality/dashboard/generate_dashboard.py \\ + --sarif-dir _quality/codeql \\ + --clang-tidy-dir _quality/clang_tidy \\ + --lcov /tmp/coverage_zip/extracted/artifacts/coverage_report.dat \\ + --history _quality/data/quality_history.json \\ + --html _quality/index.html \\ + --github-summary +""" + +import argparse +import json +import os +import pathlib +import re +import sys +from collections import Counter +from datetime import datetime, timezone + +from jinja2 import Environment, FileSystemLoader +from markupsafe import Markup + +_TEMPLATE_DIR = pathlib.Path(__file__).parent + +# ── Helpers exposed to the Jinja2 template ──────────────────────────────────── +_SEV_COLOUR = { + "critical": "#c0392b", "high": "#e74c3c", "error": "#e74c3c", + "warning": "#e67e22", "medium": "#e67e22", "low": "#f1c40f", + "recommendation": "#3498db", "note": "#95a5a6", +} +_SEV_ORDER = { + "critical": 0, "high": 1, "error": 1, "warning": 2, "medium": 2, + "low": 3, "recommendation": 4, "note": 4, +} + + +def _sev_colour(s: str) -> str: + return _SEV_COLOUR.get(str(s).lower(), "#95a5a6") + + +def _cov_colour(pct) -> str: + pct = float(pct or 0) + return "#27ae60" if pct >= 90 else ("#e67e22" if pct >= 70 else "#e74c3c") + + +def _delta_badge(curr, prev, higher_is_better: bool) -> Markup: + try: + diff = float(curr) - float(prev) + except (TypeError, ValueError): + return Markup("") + if diff == 0: + return Markup('=') + improved = (diff < 0) if not higher_is_better else (diff > 0) + cls = "trend-dn" if improved else "trend-up" + sym = "↓" if diff < 0 else "↑" + fmt = f"{abs(diff):.1f}" if abs(diff) != int(abs(diff)) else str(int(abs(diff))) + return Markup(f'{sym}{fmt}') + + +# ── Data parsers ────────────────────────────────────────────────────────────── + +def load_codeql_sarif(sarif_dir: pathlib.Path) -> list[dict]: + """Load CodeQL findings from SARIF files. + + The communication repo's codeql_lint.py outputs SARIF only (CSV was + removed). This function parses SARIF v2.1.0 as produced by CodeQL and + returns rows in the same schema that dashboard.html.j2 expects: + name, description, severity, message, path, start_line. + """ + if not sarif_dir or not sarif_dir.exists(): + return [] + rows = [] + for sarif_path in sarif_dir.rglob("*.sarif"): + try: + data = json.loads(sarif_path.read_text(encoding="utf-8", errors="replace")) + for run in data.get("runs", []): + rules = { + r["id"]: r + for r in run.get("tool", {}).get("driver", {}).get("rules", []) + } + for result in run.get("results", []): + rule_id = result.get("ruleId", "unknown") + rule = rules.get(rule_id, {}) + message = result.get("message", {}).get("text", "") + # Prefer the result-level severity; fall back to the rule's + # default configuration level, then to "warning". + severity = ( + result.get("level") + or rule.get("defaultConfiguration", {}).get("level") + or "warning" + ) + locs = result.get("locations", [{}]) + loc = locs[0].get("physicalLocation", {}) if locs else {} + uri = loc.get("artifactLocation", {}).get("uri", "") + line = str(loc.get("region", {}).get("startLine", "")) + rows.append({ + "name": rule.get("name", rule_id), + "description": rule.get("shortDescription", {}).get("text", ""), + "severity": severity, + "message": message, + "path": uri, + "start_line": line, + }) + except Exception: + pass + return sorted(rows, key=lambda r: _SEV_ORDER.get(r["severity"].lower(), 99)) + + +_CT_FULL_RE = re.compile(r'^(.+?):(\d+):\d+: (warning|error): (.+?) \[([^\]]+)\]\s*$') +_CT_SIMPLE_RE = re.compile(r'^(warning|error): (.+?) \[([^\]]+)\]\s*$') + + +def load_clang_tidy(search_dir: pathlib.Path) -> list[dict]: + """Load clang-tidy findings. + + Handles both: + *.AspectRulesLintClangTidy.out – aspect_rules_lint v2 (current repo) + *.AspectRulesLintClangTidy.report – newer aspect_rules_lint versions + """ + if not search_dir or not search_dir.exists(): + return [] + findings, seen = [], set() + patterns = ( + "*.AspectRulesLintClangTidy.out", + "*.AspectRulesLintClangTidy.report", + ) + for pattern in patterns: + for report_file in search_dir.rglob(pattern): + for line in report_file.read_text(encoding="utf-8", errors="replace").splitlines(): + m = _CT_FULL_RE.match(line) + if m: + fpath, lineno, sev, msg, check = m.groups() + key = (fpath, lineno, check, msg[:80]) + if key not in seen: + seen.add(key) + findings.append({ + "path": fpath, "line": lineno, + "severity": sev, "message": msg, "check": check, + }) + continue + m = _CT_SIMPLE_RE.match(line) + if m: + sev, msg, check = m.groups() + key = ("", "", check, msg[:80]) + if key not in seen: + seen.add(key) + findings.append({ + "path": "", "line": "", + "severity": sev, "message": msg, "check": check, + }) + return sorted(findings, key=lambda r: (0 if r["severity"] == "error" else 1, r["path"])) + + +def load_lcov(path: pathlib.Path) -> tuple[dict, list[dict]]: + if not path or not path.is_file(): + return {}, [] + files, cur = [], None + for raw in path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = raw.strip() + if line.startswith("SF:"): + cur = {"file": line[3:], "lf": 0, "lh": 0, "fnf": 0, "fnh": 0, + "brf": 0, "brh": 0, "_lf": 0, "_lh": 0} + elif cur is None: + continue + elif line.startswith("DA:"): + parts = line[3:].split(",") + cur["_lf"] += 1 + if len(parts) >= 2: + try: + if int(parts[1]) > 0: + cur["_lh"] += 1 + except ValueError: + pass + elif line.startswith("LF:"): + cur["lf"] = int(line[3:] or 0) + elif line.startswith("LH:"): + cur["lh"] = int(line[3:] or 0) + elif line.startswith("FNF:"): + cur["fnf"] = int(line[4:] or 0) + elif line.startswith("FNH:"): + cur["fnh"] = int(line[4:] or 0) + elif line.startswith("BRF:"): + cur["brf"] = int(line[4:] or 0) + elif line.startswith("BRH:"): + cur["brh"] = int(line[4:] or 0) + elif line == "end_of_record": + if cur["lf"] == 0: + cur["lf"] = cur["_lf"] + if cur["lh"] == 0: + cur["lh"] = cur["_lh"] + files.append(cur) + cur = None + + def pct(h, f): + return round(100.0 * h / f, 1) if f else 0.0 + + for f in files: + f["line_pct"] = pct(f["lh"], f["lf"]) + f["func_pct"] = pct(f["fnh"], f["fnf"]) + f["branch_pct"] = pct(f["brh"], f["brf"]) + + lf = sum(f["lf"] for f in files) + lh = sum(f["lh"] for f in files) + fnf = sum(f["fnf"] for f in files) + fnh = sum(f["fnh"] for f in files) + brf = sum(f["brf"] for f in files) + brh = sum(f["brh"] for f in files) + summary = { + "line_pct": pct(lh, lf), "func_pct": pct(fnh, fnf), "branch_pct": pct(brh, brf), + "lines": f"{lh}/{lf}", "funcs": f"{fnh}/{fnf}", "branches": f"{brh}/{brf}", + } + return summary, sorted(files, key=lambda f: f["line_pct"]) + + +def load_history(path: pathlib.Path) -> list[dict]: + if not path or not path.exists(): + return [] + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, list) else [] + except (json.JSONDecodeError, OSError): + return [] + + +def save_history(path: pathlib.Path, history: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(history, indent=2), encoding="utf-8") + print(f"History saved: {path} ({len(history)} runs)") + + +# ── HTML rendering ──────────────────────────────────────────────────────────── + +def render_dashboard( + codeql_rows, clang_rows, cov_summary, cov_files, history, timestamp, +) -> str: + env = Environment(loader=FileSystemLoader(str(_TEMPLATE_DIR)), autoescape=True) + env.globals["sev_colour"] = _sev_colour + env.globals["cov_colour"] = _cov_colour + env.globals["delta"] = _delta_badge + env.filters["basename"] = lambda p: pathlib.Path(p).name or p + env.tests["number"] = lambda x: isinstance(x, (int, float)) and x is not None + tmpl = env.get_template("dashboard.html.j2") + return tmpl.render( + timestamp=timestamp, + codeql_rows=codeql_rows, + codeql_counts=Counter(r["severity"].lower() for r in codeql_rows), + clang_rows=clang_rows, + clang_counts=Counter(r["severity"] for r in clang_rows), + clang_errors=sum(1 for r in clang_rows if r["severity"] == "error"), + cov=cov_summary or None, + cov_files=cov_files, + history=history, + prev=history[-2] if len(history) >= 2 else None, + ) + + +# ── GitHub Actions step summary ─────────────────────────────────────────────── + +def write_github_summary( + codeql_rows, clang_rows, cov_summary, history, summary_path, +) -> None: + lines = ["## Quality Dashboard\n", "### CodeQL (MISRA C++)\n"] + if not codeql_rows: + lines.append("**No findings.** :white_check_mark:\n") + else: + counts = Counter(r["severity"].lower() for r in codeql_rows) + lines += [ + f"**Total: {len(codeql_rows)} findings**\n", + "| Severity | Count |", + "|----------|------:|", + ] + for sev in ["critical", "high", "error", "warning", "medium", "low", + "recommendation", "note"]: + if sev in counts: + lines.append(f"| {sev.capitalize()} | {counts[sev]} |") + + lines += ["", "### Clang-Tidy\n"] + if not clang_rows: + lines.append("**No warnings.** :white_check_mark:\n") + else: + counts = Counter(r["severity"] for r in clang_rows) + lines += [ + f"**Total: {len(clang_rows)} warnings**\n", + "| Severity | Count |", + "|----------|------:|", + ] + for sev in ["error", "warning"]: + if sev in counts: + lines.append(f"| {sev.capitalize()} | {counts[sev]} |") + + lines += ["", "### Coverage\n"] + if cov_summary: + lines += [ + "| Metric | Value |", + "|--------|-------|", + f"| Lines | {cov_summary['line_pct']:.1f}% ({cov_summary['lines']}) |", + f"| Functions | {cov_summary['func_pct']:.1f}% ({cov_summary['funcs']}) |", + f"| Branches | {cov_summary['branch_pct']:.1f}% ({cov_summary['branches']}) |", + ] + else: + lines.append("Coverage data not available.\n") + + if len(history) >= 2: + prev, curr = history[-2], history[-1] + lines += [ + "", "### KPI Trend vs Previous Run\n", + "| Metric | Prev | Now | Δ |", + "|--------|------|-----|---|", + ] + for label, key, hib in [ + ("CodeQL findings", "codeql", False), + ("Clang-Tidy warnings", "clang_tidy", False), + ("Line coverage %", "line_cov", True), + ("Branch coverage %", "branch_cov", True), + ]: + pv, cv = prev.get(key), curr.get(key) + if pv is not None and cv is not None: + diff = cv - pv + sym = "↓" if diff < 0 else ("↑" if diff > 0 else "=") + good = (diff < 0) if not hib else (diff > 0) + icon = "✅" if good else ("⚠️" if diff != 0 else "") + fmt = (f"{abs(diff):.1f}" if isinstance(diff, float) + else str(abs(int(diff)))) + lines.append(f"| {label} | {pv} | {cv} | {sym}{fmt} {icon} |") + lines.append( + f"\n_Tracking since {history[0].get('date', 'start')} ({len(history)} runs)_" + ) + + with open(summary_path, "a", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + + +# ── Entry point ─────────────────────────────────────────────────────────────── + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate unified quality dashboard") + parser.add_argument( + "--sarif-dir", default="", + help="Directory containing CodeQL SARIF files (*.sarif)", + ) + parser.add_argument( + "--clang-tidy-dir", default="", + help="Directory containing clang-tidy report files " + "(*.AspectRulesLintClangTidy.out or .report)", + ) + parser.add_argument( + "--lcov", default="", + help="Path to LCOV .dat coverage data file", + ) + parser.add_argument( + "--html", default="dashboard.html", + help="Output HTML dashboard path", + ) + parser.add_argument( + "--github-summary", action="store_true", + help="Append markdown summary to $GITHUB_STEP_SUMMARY", + ) + parser.add_argument( + "--history", default="", + help="Path to KPI history JSON file (read before, updated after rendering)", + ) + args = parser.parse_args() + + sarif_dir = pathlib.Path(args.sarif_dir) if args.sarif_dir else pathlib.Path("") + clang_tidy_dir = (pathlib.Path(args.clang_tidy_dir) + if args.clang_tidy_dir else pathlib.Path("")) + lcov_path = pathlib.Path(args.lcov) if args.lcov else pathlib.Path("") + html_path = pathlib.Path(args.html) + hist_path = pathlib.Path(args.history) if args.history else None + + html_path.parent.mkdir(parents=True, exist_ok=True) + + codeql_rows = load_codeql_sarif(sarif_dir) + clang_rows = load_clang_tidy(clang_tidy_dir) + cov_summary, cov_files = load_lcov(lcov_path) + timestamp = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + history = load_history(hist_path) if hist_path else [] + history.append({ + "date": timestamp, + "codeql": len(codeql_rows), + "clang_tidy": len(clang_rows), + "line_cov": cov_summary.get("line_pct") if cov_summary else None, + "func_cov": cov_summary.get("func_pct") if cov_summary else None, + "branch_cov": cov_summary.get("branch_pct") if cov_summary else None, + }) + if hist_path: + save_history(hist_path, history) + + html_path.write_text( + render_dashboard(codeql_rows, clang_rows, cov_summary, cov_files, + history, timestamp), + encoding="utf-8", + ) + + print(f"Dashboard written: {html_path}") + print(f" CodeQL: {len(codeql_rows)} findings") + print(f" Clang-Tidy: {len(clang_rows)} warnings") + if cov_summary: + print(f" Coverage: {cov_summary['line_pct']:.1f}% lines " + f"{cov_summary['func_pct']:.1f}% funcs " + f"{cov_summary['branch_pct']:.1f}% branches") + else: + print(" Coverage: N/A") + + if args.github_summary: + write_github_summary( + codeql_rows, clang_rows, cov_summary, history, + os.environ.get("GITHUB_STEP_SUMMARY", "/dev/null"), + ) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 4ce467ec1db24c7568f3fc4579b57c1696a97014 Mon Sep 17 00:00:00 2001 From: Ahmed Mousa Date: Tue, 19 May 2026 16:32:40 +0200 Subject: [PATCH 7/8] ci: add Sphinx docs deploy workflow with version switcher --- .github/workflows/docs.yml | 221 ++++++++++++++++++++++ docs/sphinx/conf.py | 25 +-- docs/sphinx/utils/update_versions_json.py | 122 ++++++++++++ 3 files changed, 357 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/sphinx/utils/update_versions_json.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..c4f9be363 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,221 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# Workflow: Build and deploy Sphinx documentation to GitHub Pages. +# +# Triggers: +# - Push to main → deploys to …/latest/ +# - Release published → deploys to …// and …/stable/ +# - workflow_dispatch → manual re-run (deploys as "latest") +# - workflow_run (nightly) → triggered by Nightly Quality Jobs; +# downloads fresh quality reports and bundles +# them into the Sphinx site under latest/quality/ +# +# URL structure on GitHub Pages: +# https://eclipse-score.github.io/communication/ ← root redirect to latest/ +# https://eclipse-score.github.io/communication/latest/ ← built from main +# https://eclipse-score.github.io/communication/latest/quality/ ← quality dashboard + reports +# https://eclipse-score.github.io/communication/stable/ ← copy of newest release +# https://eclipse-score.github.io/communication/v1.2.3/ ← per-release snapshot + +name: Build and Deploy Documentation + +on: + push: + branches: [main] + release: + types: [published] + workflow_dispatch: + workflow_run: + workflows: ["Nightly Quality Jobs"] + types: [completed] + +permissions: + contents: write + actions: read # needed to download artifacts from the nightly workflow_run + +env: + ANDROID_HOME: "" + ANDROID_SDK_ROOT: "" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + build-and-deploy-docs: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.2 + + # ------------------------------------------------------------------ + # Determine deployment target (latest vs release tag) + # ------------------------------------------------------------------ + - name: Set deployment variables + id: vars + run: | + OWNER="${{ github.repository_owner }}" + REPO="${{ github.event.repository.name }}" + BASE_URL="https://${OWNER}.github.io/${REPO}" + + if [[ "${{ github.event_name }}" == "release" ]]; then + VERSION="${{ github.ref_name }}" + DEST_DIR="${{ github.ref_name }}" + IS_RELEASE="true" + else + VERSION="latest" + DEST_DIR="latest" + IS_RELEASE="false" + fi + + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "dest_dir=${DEST_DIR}" >> $GITHUB_OUTPUT + echo "base_url=${BASE_URL}" >> $GITHUB_OUTPUT + echo "is_release=${IS_RELEASE}" >> $GITHUB_OUTPUT + + # ------------------------------------------------------------------ + # Build environment setup + # ------------------------------------------------------------------ + - name: Free Disk Space (Ubuntu) + uses: eclipse-score/more-disk-space@v1 + with: + level: 4 + + - name: Setup Bazel with shared caching + uses: bazel-contrib/setup-bazel@0.18.0 + with: + bazelisk-cache: true + disk-cache: "docs" + repository-cache: true + cache-save: ${{ github.event_name != 'pull_request' }} + + - name: Allow linux-sandbox + uses: ./actions/unblock_user_namespace_for_linux_sandbox + + # ------------------------------------------------------------------ + # Generate Sphinx artifacts via Bazel (mirrors .readthedocs.yaml) + # ------------------------------------------------------------------ + - name: Generate documentation artifacts via Bazel + run: | + bazel build //docs/sphinx:generate_api_rst + cp -f score/mw/com/README.md docs/sphinx/README.md + mkdir -p docs/sphinx/generated + cp -f bazel-bin/docs/sphinx/generated/*.rst docs/sphinx/generated/ + mkdir -p score/mw/com/design/doxygen_build/xml + cp -rf bazel-bin/score/mw/com/design/doxygen_build/xml/* \ + score/mw/com/design/doxygen_build/xml/ + + - name: Install Python dependencies + run: pip install -r requirements_lock.txt + + # ------------------------------------------------------------------ + # Build Sphinx HTML + # ------------------------------------------------------------------ + - name: Build Sphinx documentation + env: + DOCS_VERSION: ${{ steps.vars.outputs.version }} + DOCS_BASE_URL: ${{ steps.vars.outputs.base_url }} + run: sphinx-build docs/sphinx _sphinx_output + + # ------------------------------------------------------------------ + # Assemble the deploy directory + # _deploy/ + # / ← sphinx HTML output + # stable/ ← copy of sphinx output (releases only) + # versions.json ← version list for the navbar switcher + # index.html ← root redirect to latest/ + # .nojekyll ← disable GitHub Pages Jekyll processing + # ------------------------------------------------------------------ + - name: Assemble deploy directory + env: + DEST_DIR: ${{ steps.vars.outputs.dest_dir }} + IS_RELEASE: ${{ steps.vars.outputs.is_release }} + BASE_URL: ${{ steps.vars.outputs.base_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p _deploy + + # Sphinx output into the version subfolder + cp -r _sphinx_output _deploy/${DEST_DIR} + + # For releases: also publish as stable/ + if [[ "${IS_RELEASE}" == "true" ]]; then + cp -r _sphinx_output _deploy/stable + fi + + # Root index.html: redirect to latest/ + cat > _deploy/index.html << 'REDIRECT' + + + + + + Redirecting to latest documentation... + + +

Redirecting to latest documentation...

+ + + REDIRECT + + # Prevent Jekyll from processing the site + touch _deploy/.nojekyll + + # Generate versions.json for the pydata-sphinx-theme version switcher + python3 docs/sphinx/utils/update_versions_json.py \ + --owner "${{ github.repository_owner }}" \ + --repo "${{ github.event.repository.name }}" \ + --base-url "${BASE_URL}" \ + --output _deploy/versions.json + + # ------------------------------------------------------------------ + # Bundle quality reports into the deploy so docs and quality reports + # are published together in one gh-pages push. + # - Triggered by workflow_run (nightly): download the fresh artifact. + # - All other triggers: restore the existing reports from gh-pages so + # they are preserved and not wiped by this deploy. + # ------------------------------------------------------------------ + QUALITY_DEST="_deploy/${DEST_DIR}/quality" + mkdir -p "${QUALITY_DEST}" + + if [[ "${{ github.event_name }}" == "workflow_run" ]]; then + echo "Triggered by nightly quality run — quality artifact will be downloaded in the next step." + else + echo "Restoring existing quality reports from gh-pages..." + git fetch origin gh-pages 2>/dev/null || true + git show origin/gh-pages:latest/quality 2>/dev/null \ + && git checkout origin/gh-pages -- latest/quality 2>/dev/null \ + && cp -r latest/quality/. "${QUALITY_DEST}/" \ + || echo "No existing quality reports found — skipping restore." + fi + + # ------------------------------------------------------------------ + # Download quality reports artifact (only when triggered by nightly) + # ------------------------------------------------------------------ + - name: Download quality reports from nightly run + if: github.event_name == 'workflow_run' + uses: actions/download-artifact@v4 + with: + name: nightly-quality-reports + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: _deploy/${{ steps.vars.outputs.dest_dir }}/quality + + # ------------------------------------------------------------------ + # Deploy to gh-pages branch (keep_files preserves other versions) + # ------------------------------------------------------------------ + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./_deploy + keep_files: true diff --git a/docs/sphinx/conf.py b/docs/sphinx/conf.py index 0808c1bbc..e952a165f 100644 --- a/docs/sphinx/conf.py +++ b/docs/sphinx/conf.py @@ -20,7 +20,6 @@ import os import sys import warnings -from pathlib import Path # -- Project information -- project = 'Eclipse S-CORE' @@ -28,11 +27,10 @@ author = 'Eclipse Foundation' release = '1.0.0' -# GitHub Pages base URL (update org/repo as needed) -GITHUB_PAGES_URL = os.environ.get( - 'DOCS_BASE_URL', - 'https://eclipse-score.github.io/communication' -) +# Version and base URL injected by the docs.yml GitHub Actions workflow. +# Falls back to sensible defaults for local builds. +docs_version = os.environ.get('DOCS_VERSION', 'latest') +docs_base_url = os.environ.get('DOCS_BASE_URL', '').rstrip('/') # -- General configuration --- extensions = [ @@ -75,6 +73,10 @@ # -- Options for HTML output -- html_theme = 'pydata_sphinx_theme' +# Canonical base URL for this version (helps search engines and the switcher) +if docs_base_url: + html_baseurl = f"{docs_base_url}/{docs_version}/" + # Professional theme configuration inspired by modern open-source projects html_theme_options = { # Navigation settings @@ -89,6 +91,12 @@ 'navbar_center': ['navbar-nav'], 'navbar_end': ['version-switcher', 'navbar-icon-links', 'theme-switcher'], + # Version switcher — reads versions.json from the GitHub Pages root + 'switcher': { + 'json_url': f"{docs_base_url}/versions.json" if docs_base_url else '/versions.json', + 'version_match': docs_version, + }, + # Search configuration 'search_bar_text': 'Search documentation...', @@ -113,11 +121,6 @@ } ], - # Version switcher configuration - 'switcher': { - 'json_url': f'{GITHUB_PAGES_URL}/switcher.json', - 'version_match': os.environ.get('DOCS_VERSION', 'latest'), - }, } # Add custom styling diff --git a/docs/sphinx/utils/update_versions_json.py b/docs/sphinx/utils/update_versions_json.py new file mode 100644 index 000000000..2548f043b --- /dev/null +++ b/docs/sphinx/utils/update_versions_json.py @@ -0,0 +1,122 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Generate versions.json for the pydata-sphinx-theme version switcher. + +Called from the docs.yml GitHub Actions workflow. Reads published releases +via the ``gh`` CLI and writes a JSON file consumed by the version-switcher +component in the Sphinx navbar. + +Output format (pydata-sphinx-theme v0.15+): + + [ + {"name": "latest (main)", "version": "latest", "url": "…/latest/", "preferred": true}, + {"name": "stable (v2.1.0)", "version": "stable", "url": "…/stable/"}, + {"name": "v2.1.0", "version": "v2.1.0", "url": "…/v2.1.0/"}, + … + ] + +Usage: + python3 update_versions_json.py \\ + --owner eclipse-score \\ + --repo communication \\ + --base-url https://eclipse-score.github.io/communication \\ + --output versions.json +""" + +import argparse +import json +import subprocess +import sys + + +def _fetch_releases(owner: str, repo: str) -> list[dict]: + """Return published (non-draft, non-prerelease) releases via the gh CLI.""" + result = subprocess.run( + [ + "gh", "release", "list", + "--repo", f"{owner}/{repo}", + "--json", "tagName,isDraft,isPrerelease", + "--limit", "50", + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0 or not result.stdout.strip(): + # No releases yet or gh CLI not authenticated — return empty list + return [] + return [ + r for r in json.loads(result.stdout) + if not r.get("isDraft") and not r.get("isPrerelease") + ] + + +def build_versions(base_url: str, releases: list[dict]) -> list[dict]: + """Build the versions list from the release data.""" + base = base_url.rstrip("/") + + versions = [ + { + "name": "latest (main)", + "version": "latest", + "url": f"{base}/latest/", + "preferred": True, + } + ] + + if releases: + newest_tag = releases[0]["tagName"] + versions.append( + { + "name": f"stable ({newest_tag})", + "version": "stable", + "url": f"{base}/stable/", + } + ) + for release in releases: + tag = release["tagName"] + versions.append( + { + "name": tag, + "version": tag, + "url": f"{base}/{tag}/", + } + ) + + return versions + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate versions.json for the pydata-sphinx-theme version switcher." + ) + parser.add_argument("--owner", required=True, help="GitHub organisation or user name") + parser.add_argument("--repo", required=True, help="Repository name") + parser.add_argument("--base-url", required=True, help="Root GitHub Pages URL (no trailing slash)") + parser.add_argument("--output", default="versions.json", help="Output file path") + args = parser.parse_args() + + releases = _fetch_releases(args.owner, args.repo) + versions = build_versions(args.base_url, releases) + + with open(args.output, "w", encoding="utf-8") as fh: + json.dump(versions, fh, indent=2) + fh.write("\n") + + version_names = [v["version"] for v in versions] + print(f"Written {args.output} with {len(versions)} version(s): {version_names}") + + +if __name__ == "__main__": + main() From 3b8e61b3a221d8d3c086229dd7a83a52b2a18912 Mon Sep 17 00:00:00 2001 From: Ahmed Mousa Date: Tue, 19 May 2026 16:33:23 +0200 Subject: [PATCH 8/8] docs: add quality reports page to Sphinx documentation --- docs/sphinx/BUILD | 1 + docs/sphinx/index.rst | 6 ++++++ docs/sphinx/quality_reports.rst | 26 ++++++++++++++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 docs/sphinx/quality_reports.rst diff --git a/docs/sphinx/BUILD b/docs/sphinx/BUILD index b0267a098..c3264f4af 100644 --- a/docs/sphinx/BUILD +++ b/docs/sphinx/BUILD @@ -56,6 +56,7 @@ sphinx_module( "index.rst", "introduction.rst", "message_passing.rst", + "quality_reports.rst", "safety_reports.rst", ":doxygen_xml", ":generate_api_rst", diff --git a/docs/sphinx/index.rst b/docs/sphinx/index.rst index 1d6e7ba66..8efdafaae 100644 --- a/docs/sphinx/index.rst +++ b/docs/sphinx/index.rst @@ -27,6 +27,12 @@ including the LoLa (Low Latency) implementation and Message Passing library. safety_reports +.. toctree:: + :maxdepth: 1 + :caption: Quality Reports: + + quality_reports + .. Note: safety_reports.rst contains links to pre-built HTML reports from external targets. About This Documentation diff --git a/docs/sphinx/quality_reports.rst b/docs/sphinx/quality_reports.rst new file mode 100644 index 000000000..8c772f706 --- /dev/null +++ b/docs/sphinx/quality_reports.rst @@ -0,0 +1,26 @@ +Quality Reports +=============== + +Nightly quality job results are built and published to GitHub Pages after each +nightly run of the `Nightly Quality Jobs`_ workflow. + +.. list-table:: + :widths: 20 45 35 + :header-rows: 1 + + * - Job + - Description + - Report + * - Coverage + - Line and branch coverage from C++ unit tests (gcov/lcov) + - `Coverage report `_ + * - CodeQL + - Static security and quality analysis + - `CodeQL report `_ + * - Clang-Tidy + - C++ static analysis + - `Clang-Tidy report `_ + +`Open Quality Dashboard `_ + +.. _Nightly Quality Jobs: https://github.com/eclipse-score/communication/actions/workflows/nightly_quality.yml