From 16b1cac29d3638d989571f2b5ff530de29ff84d8 Mon Sep 17 00:00:00 2001 From: Yonatan Karp-Rudin Date: Sat, 20 Jun 2026 15:08:28 +0200 Subject: [PATCH 1/3] feat: add opt-in coverage comment to the CI pipeline The reusable build workflow published JUnit results and uploaded coverage HTML as an artifact, but never surfaced coverage on the pull request. Add a `jvm-coverage` composite action that summarises JaCoCo-format XML reports (emitted by both JaCoCo and Kotlinx Kover) into one Markdown table and posts/updates a single idempotent PR comment via github-script. Wire it into ci.yml behind a new `coverage` input (default false, so existing consumers are unaffected) and a `coverage-xml` glob input. The comment is gated to same-repo pull requests, since fork PRs receive a read-only token. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/jvm-coverage/action.yml | 129 ++++++++++++++++++++++++ .github/workflows/ci.yml | 20 ++++ 2 files changed, 149 insertions(+) create mode 100644 .github/actions/jvm-coverage/action.yml diff --git a/.github/actions/jvm-coverage/action.yml b/.github/actions/jvm-coverage/action.yml new file mode 100644 index 0000000..39503c6 --- /dev/null +++ b/.github/actions/jvm-coverage/action.yml @@ -0,0 +1,129 @@ +name: 'JVM Coverage Comment' +description: >- + Summarises JaCoCo-format coverage XML reports (emitted by both JaCoCo and + Kotlinx Kover) into one Markdown table and posts/updates a single coverage + comment on the pull request. The build that runs before this action is + responsible for producing the XML reports. + +inputs: + coverage-xml: + description: >- + Newline- or comma-separated glob(s) for JaCoCo-format coverage XML + reports, relative to the workspace. + required: false + default: '**/build/reports/jacoco/test/jacocoTestReport.xml' + title: + description: 'Heading shown on the coverage comment.' + required: false + default: 'Coverage' + marker: + description: 'Hidden HTML marker used to find and update the existing comment.' + required: false + default: '' + +runs: + using: composite + steps: + - name: Generate coverage summary + shell: bash + env: + COVERAGE_XML: ${{ inputs.coverage-xml }} + COVERAGE_TITLE: ${{ inputs.title }} + COVERAGE_MARKER: ${{ inputs.marker }} + run: | + python3 - <<'PY' + import glob + import os + import xml.etree.ElementTree as ET + + patterns = [ + p.strip() + for p in os.environ["COVERAGE_XML"].replace(",", "\n").splitlines() + if p.strip() + ] + files = sorted({f for pat in patterns for f in glob.glob(pat, recursive=True)}) + title = os.environ["COVERAGE_TITLE"] + marker = os.environ["COVERAGE_MARKER"] + + def percentage(covered, missed): + total = covered + missed + return "n/a" if total == 0 else f"{covered / total * 100:.2f}%" + + def counters(root): + return { + c.attrib["type"]: (int(c.attrib["covered"]), int(c.attrib["missed"])) + for c in root.findall("counter") + } + + rows = [] + totals = {} + for path in files: + root = ET.parse(path).getroot() + name = root.attrib.get("name") or path + module = counters(root) + line = module.get("LINE", (0, 0)) + branch = module.get("BRANCH", (0, 0)) + rows.append(f"| {name} | {percentage(*line)} | {percentage(*branch)} |") + for counter_type, (covered, missed) in module.items(): + acc_covered, acc_missed = totals.get(counter_type, (0, 0)) + totals[counter_type] = (acc_covered + covered, acc_missed + missed) + + lines = [marker, f"## {title}", ""] + if not files: + lines += ["No coverage reports were found for the configured globs.", ""] + else: + total_line = totals.get("LINE", (0, 0)) + total_branch = totals.get("BRANCH", (0, 0)) + lines += [ + f"**Total** — Lines {percentage(*total_line)} · " + f"Branches {percentage(*total_branch)}", + "", + "| Module | Lines | Branches |", + "| --- | ---: | ---: |", + *rows, + "", + ] + + with open("jvm-coverage-summary.md", "w", encoding="utf-8") as handle: + handle.write("\n".join(lines)) + PY + + - name: Comment coverage on the pull request + if: ${{ github.event_name == 'pull_request' }} + uses: actions/github-script@v9 + env: + MARKER: ${{ inputs.marker }} + with: + script: | + const fs = require('fs'); + const marker = process.env.MARKER; + const body = fs.readFileSync('jvm-coverage-summary.md', 'utf8'); + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + const existing = comments.find((comment) => + ['github-actions', 'github-actions[bot]'].includes(comment.user?.login) && + comment.body?.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b94ad50..c39c3c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,16 @@ on: required: false default: true description: 'Publish JUnit results as a check + upload test/coverage artifacts.' + coverage: + type: boolean + required: false + default: false + description: 'Post a coverage comment on the pull request from JaCoCo-format XML reports (works with JaCoCo and Kover).' + coverage-xml: + type: string + required: false + default: '**/build/reports/jacoco/test/jacocoTestReport.xml' + description: 'Newline- or comma-separated glob(s) for JaCoCo-format coverage XML reports.' submodules: type: string required: false @@ -75,6 +85,16 @@ jobs: check-name: Test Results (${{ inputs.image-name }}) artifact-name: test-reports-${{ inputs.image-name }} + - name: Coverage comment + if: >- + inputs.coverage + && github.event_name == 'pull_request' + && github.event.pull_request.head.repo.full_name == github.repository + && steps.changes.outputs.source_code == 'true' + uses: yonatankarp/github-actions/.github/actions/jvm-coverage@v2 + with: + coverage-xml: ${{ inputs.coverage-xml }} + - if: inputs.dockerfile-path != '' && steps.changes.outputs.source_code == 'true' uses: yonatankarp/github-actions/.github/actions/build-docker-image@v2 with: From de433e5da700a892397510e73782cdaeb9844299 Mon Sep 17 00:00:00 2001 From: Yonatan Karp-Rudin Date: Sat, 20 Jun 2026 15:19:05 +0200 Subject: [PATCH 2/3] feat: enable the coverage comment by default Default `coverage` to true and make the action a no-op when no JaCoCo-format XML matches the glob: it logs a notice and skips the comment instead of posting an empty one. This makes coverage zero-config for repos that emit JaCoCo/Kover XML and invisible for those that don't, so it is safe on by default. Repos can still set `coverage: false` to opt out entirely. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/jvm-coverage/action.yml | 35 +++++++++++++++++-------- .github/workflows/ci.yml | 4 +-- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/.github/actions/jvm-coverage/action.yml b/.github/actions/jvm-coverage/action.yml index 39503c6..8efc676 100644 --- a/.github/actions/jvm-coverage/action.yml +++ b/.github/actions/jvm-coverage/action.yml @@ -2,7 +2,8 @@ name: 'JVM Coverage Comment' description: >- Summarises JaCoCo-format coverage XML reports (emitted by both JaCoCo and Kotlinx Kover) into one Markdown table and posts/updates a single coverage - comment on the pull request. The build that runs before this action is + comment on the pull request. No-op when no matching reports are found, so it + is safe to run unconditionally. The build that runs before this action is responsible for producing the XML reports. inputs: @@ -25,6 +26,7 @@ runs: using: composite steps: - name: Generate coverage summary + id: summary shell: bash env: COVERAGE_XML: ${{ inputs.coverage-xml }} @@ -42,6 +44,11 @@ runs: if p.strip() ] files = sorted({f for pat in patterns for f in glob.glob(pat, recursive=True)}) + + if not files: + print("::notice::No coverage XML matched; skipping the coverage comment.") + raise SystemExit(0) + title = os.environ["COVERAGE_TITLE"] marker = os.environ["COVERAGE_MARKER"] @@ -68,13 +75,13 @@ runs: acc_covered, acc_missed = totals.get(counter_type, (0, 0)) totals[counter_type] = (acc_covered + covered, acc_missed + missed) - lines = [marker, f"## {title}", ""] - if not files: - lines += ["No coverage reports were found for the configured globs.", ""] - else: - total_line = totals.get("LINE", (0, 0)) - total_branch = totals.get("BRANCH", (0, 0)) - lines += [ + total_line = totals.get("LINE", (0, 0)) + total_branch = totals.get("BRANCH", (0, 0)) + summary = "\n".join( + [ + marker, + f"## {title}", + "", f"**Total** — Lines {percentage(*total_line)} · " f"Branches {percentage(*total_branch)}", "", @@ -83,13 +90,19 @@ runs: *rows, "", ] - + ) with open("jvm-coverage-summary.md", "w", encoding="utf-8") as handle: - handle.write("\n".join(lines)) + handle.write(summary) PY + if [[ -f jvm-coverage-summary.md ]]; then + echo "has_reports=true" >> "$GITHUB_OUTPUT" + else + echo "has_reports=false" >> "$GITHUB_OUTPUT" + fi + - name: Comment coverage on the pull request - if: ${{ github.event_name == 'pull_request' }} + if: ${{ github.event_name == 'pull_request' && steps.summary.outputs.has_reports == 'true' }} uses: actions/github-script@v9 env: MARKER: ${{ inputs.marker }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c39c3c5..0264ade 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,8 @@ on: coverage: type: boolean required: false - default: false - description: 'Post a coverage comment on the pull request from JaCoCo-format XML reports (works with JaCoCo and Kover).' + default: true + description: 'Post a coverage comment on the pull request from JaCoCo-format XML reports (works with JaCoCo and Kover). No-op when no reports match, so it is safe on by default; set false to disable entirely.' coverage-xml: type: string required: false From 8c3176774a06b6f925af5c2fc3d9658f56426d47 Mon Sep 17 00:00:00 2001 From: Yonatan Karp-Rudin Date: Sat, 20 Jun 2026 15:24:29 +0200 Subject: [PATCH 3/3] fix: inline coverage steps instead of a self-referenced action A reusable workflow can only reference actions by a published ref, and a new composite action does not exist on the v2 tag until release, so referencing jvm-coverage@v2 from ci.yml broke the workflow (and its own self-test) with "Can't find action.yml". Inline the coverage summary (python) and comment (github-script) steps directly into the build job; inline run steps and the published github-script action resolve for every consumer with no new tag. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/jvm-coverage/action.yml | 142 ------------------------ .github/workflows/ci.yml | 117 ++++++++++++++++++- 2 files changed, 114 insertions(+), 145 deletions(-) delete mode 100644 .github/actions/jvm-coverage/action.yml diff --git a/.github/actions/jvm-coverage/action.yml b/.github/actions/jvm-coverage/action.yml deleted file mode 100644 index 8efc676..0000000 --- a/.github/actions/jvm-coverage/action.yml +++ /dev/null @@ -1,142 +0,0 @@ -name: 'JVM Coverage Comment' -description: >- - Summarises JaCoCo-format coverage XML reports (emitted by both JaCoCo and - Kotlinx Kover) into one Markdown table and posts/updates a single coverage - comment on the pull request. No-op when no matching reports are found, so it - is safe to run unconditionally. The build that runs before this action is - responsible for producing the XML reports. - -inputs: - coverage-xml: - description: >- - Newline- or comma-separated glob(s) for JaCoCo-format coverage XML - reports, relative to the workspace. - required: false - default: '**/build/reports/jacoco/test/jacocoTestReport.xml' - title: - description: 'Heading shown on the coverage comment.' - required: false - default: 'Coverage' - marker: - description: 'Hidden HTML marker used to find and update the existing comment.' - required: false - default: '' - -runs: - using: composite - steps: - - name: Generate coverage summary - id: summary - shell: bash - env: - COVERAGE_XML: ${{ inputs.coverage-xml }} - COVERAGE_TITLE: ${{ inputs.title }} - COVERAGE_MARKER: ${{ inputs.marker }} - run: | - python3 - <<'PY' - import glob - import os - import xml.etree.ElementTree as ET - - patterns = [ - p.strip() - for p in os.environ["COVERAGE_XML"].replace(",", "\n").splitlines() - if p.strip() - ] - files = sorted({f for pat in patterns for f in glob.glob(pat, recursive=True)}) - - if not files: - print("::notice::No coverage XML matched; skipping the coverage comment.") - raise SystemExit(0) - - title = os.environ["COVERAGE_TITLE"] - marker = os.environ["COVERAGE_MARKER"] - - def percentage(covered, missed): - total = covered + missed - return "n/a" if total == 0 else f"{covered / total * 100:.2f}%" - - def counters(root): - return { - c.attrib["type"]: (int(c.attrib["covered"]), int(c.attrib["missed"])) - for c in root.findall("counter") - } - - rows = [] - totals = {} - for path in files: - root = ET.parse(path).getroot() - name = root.attrib.get("name") or path - module = counters(root) - line = module.get("LINE", (0, 0)) - branch = module.get("BRANCH", (0, 0)) - rows.append(f"| {name} | {percentage(*line)} | {percentage(*branch)} |") - for counter_type, (covered, missed) in module.items(): - acc_covered, acc_missed = totals.get(counter_type, (0, 0)) - totals[counter_type] = (acc_covered + covered, acc_missed + missed) - - total_line = totals.get("LINE", (0, 0)) - total_branch = totals.get("BRANCH", (0, 0)) - summary = "\n".join( - [ - marker, - f"## {title}", - "", - f"**Total** — Lines {percentage(*total_line)} · " - f"Branches {percentage(*total_branch)}", - "", - "| Module | Lines | Branches |", - "| --- | ---: | ---: |", - *rows, - "", - ] - ) - with open("jvm-coverage-summary.md", "w", encoding="utf-8") as handle: - handle.write(summary) - PY - - if [[ -f jvm-coverage-summary.md ]]; then - echo "has_reports=true" >> "$GITHUB_OUTPUT" - else - echo "has_reports=false" >> "$GITHUB_OUTPUT" - fi - - - name: Comment coverage on the pull request - if: ${{ github.event_name == 'pull_request' && steps.summary.outputs.has_reports == 'true' }} - uses: actions/github-script@v9 - env: - MARKER: ${{ inputs.marker }} - with: - script: | - const fs = require('fs'); - const marker = process.env.MARKER; - const body = fs.readFileSync('jvm-coverage-summary.md', 'utf8'); - const { owner, repo } = context.repo; - const issue_number = context.issue.number; - - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number, - per_page: 100, - }); - const existing = comments.find((comment) => - ['github-actions', 'github-actions[bot]'].includes(comment.user?.login) && - comment.body?.includes(marker) - ); - - if (existing) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body, - }); - } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0264ade..5d96815 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,15 +85,126 @@ jobs: check-name: Test Results (${{ inputs.image-name }}) artifact-name: test-reports-${{ inputs.image-name }} - - name: Coverage comment + - name: Generate coverage summary + id: coverage if: >- inputs.coverage && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && steps.changes.outputs.source_code == 'true' - uses: yonatankarp/github-actions/.github/actions/jvm-coverage@v2 + shell: bash + env: + COVERAGE_XML: ${{ inputs.coverage-xml }} + COVERAGE_TITLE: Coverage + COVERAGE_MARKER: '' + run: | + python3 - <<'PY' + import glob + import os + import xml.etree.ElementTree as ET + + patterns = [ + p.strip() + for p in os.environ["COVERAGE_XML"].replace(",", "\n").splitlines() + if p.strip() + ] + files = sorted({f for pat in patterns for f in glob.glob(pat, recursive=True)}) + + if not files: + print("::notice::No coverage XML matched; skipping the coverage comment.") + raise SystemExit(0) + + title = os.environ["COVERAGE_TITLE"] + marker = os.environ["COVERAGE_MARKER"] + + def percentage(covered, missed): + total = covered + missed + return "n/a" if total == 0 else f"{covered / total * 100:.2f}%" + + def counters(root): + return { + c.attrib["type"]: (int(c.attrib["covered"]), int(c.attrib["missed"])) + for c in root.findall("counter") + } + + rows = [] + totals = {} + for path in files: + root = ET.parse(path).getroot() + name = root.attrib.get("name") or path + module = counters(root) + line = module.get("LINE", (0, 0)) + branch = module.get("BRANCH", (0, 0)) + rows.append(f"| {name} | {percentage(*line)} | {percentage(*branch)} |") + for counter_type, (covered, missed) in module.items(): + acc_covered, acc_missed = totals.get(counter_type, (0, 0)) + totals[counter_type] = (acc_covered + covered, acc_missed + missed) + + total_line = totals.get("LINE", (0, 0)) + total_branch = totals.get("BRANCH", (0, 0)) + summary = "\n".join( + [ + marker, + f"## {title}", + "", + f"**Total** — Lines {percentage(*total_line)} · " + f"Branches {percentage(*total_branch)}", + "", + "| Module | Lines | Branches |", + "| --- | ---: | ---: |", + *rows, + "", + ] + ) + with open("jvm-coverage-summary.md", "w", encoding="utf-8") as handle: + handle.write(summary) + PY + + if [[ -f jvm-coverage-summary.md ]]; then + echo "has_reports=true" >> "$GITHUB_OUTPUT" + else + echo "has_reports=false" >> "$GITHUB_OUTPUT" + fi + + - name: Comment coverage on the pull request + if: ${{ steps.coverage.outputs.has_reports == 'true' }} + uses: actions/github-script@v9 + env: + MARKER: '' with: - coverage-xml: ${{ inputs.coverage-xml }} + script: | + const fs = require('fs'); + const marker = process.env.MARKER; + const body = fs.readFileSync('jvm-coverage-summary.md', 'utf8'); + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + const existing = comments.find((comment) => + ['github-actions', 'github-actions[bot]'].includes(comment.user?.login) && + comment.body?.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } - if: inputs.dockerfile-path != '' && steps.changes.outputs.source_code == 'true' uses: yonatankarp/github-actions/.github/actions/build-docker-image@v2