-
Notifications
You must be signed in to change notification settings - Fork 0
feat: opt-in coverage comment in the CI pipeline #140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: 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 | ||
| 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,127 @@ jobs: | |
| check-name: Test Results (${{ inputs.image-name }}) | ||
| artifact-name: test-reports-${{ inputs.image-name }} | ||
|
|
||
| - 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' | ||
| shell: bash | ||
| env: | ||
| COVERAGE_XML: ${{ inputs.coverage-xml }} | ||
| COVERAGE_TITLE: Coverage | ||
| COVERAGE_MARKER: '<!-- jvm-coverage-summary -->' | ||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify unpinned action refs in workflow files
rg -nP '^\s*uses:\s*[^@]+@[A-Za-z0-9._-]+$' .github/workflowsRepository: yonatankarp/github-actions Length of output: 2086 🏁 Script executed: sed -n '165,180p' .github/workflows/ci.ymlRepository: yonatankarp/github-actions Length of output: 674 🏁 Script executed: rg 'actions/github-script' .github/workflows/ci.yml -nRepository: yonatankarp/github-actions Length of output: 113 🌐 Web query:
💡 Result: Pinning GitHub Actions to a full-length commit SHA is a security best practice that protects against supply chain attacks, such as malicious code being injected into an existing tag or branch [1][2][3]. Because tags and branches are mutable references that can be moved or force-pushed, a compromised action repository could silently inject malicious changes into your workflows [4][3]. A full commit SHA is immutable, ensuring that your workflow runs exactly the code you intend [5][2][6]. For actions/github-script v9, which was released in April 2026 [7][8], the recommended security approach is to pin to its specific full commit SHA [5][2]. The current SHA for version 9.0.0 is 3a2844b7e9c422d3c10d287c895573f7108da1b3 [7][8]. When pinning to a SHA, it is a standard practice to include the version tag as a comment for readability and to facilitate automated updates by tools like Dependabot or Renovate [2][9][3]: - uses: actions/github-script@3a2844b # v9.0.0 Key considerations for this practice include: 1. Enforcement: Many organizations now enforce SHA pinning via policy to prevent the use of mutable tags [10][5][2]. If your organization enables this, workflows using tags will fail [5][2]. 2. Maintenance: Using Dependabot or Renovate with appropriate presets (e.g., helpers:pinGitHubActionDigests) allows you to automate the process of updating these pinned SHAs, balancing security with ease of maintenance [2][4][11]. 3. Dependency Trees: Security policies that enforce SHA pinning may check the entire dependency tree of your actions, including internal sub-actions referenced by the action you are using [5]. 4. V9 Specifics: Note that actions/github-script v9 introduced breaking changes, including an upgrade to Citations:
🌐 Web query:
💡 Result: The actions/github-script v9.0.0 release is associated with the commit SHA 3a2844b7e9c422d3c10d287c895573f7108da1b3 [1][2]. This release, published on April 9, 2026, upgraded the action to use Citations:
Pin Using 🧰 Tools🪛 zizmor (1.25.2)[error] 171-171: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy) (unpinned-uses) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| env: | ||
| MARKER: '<!-- jvm-coverage-summary -->' | ||
| 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, | ||
| }); | ||
| } | ||
|
|
||
| - if: inputs.dockerfile-path != '' && steps.changes.outputs.source_code == 'true' | ||
| uses: yonatankarp/github-actions/.github/actions/build-docker-image@v2 | ||
| with: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle malformed coverage XML without failing the whole workflow.
A single malformed/misdetected XML causes
ET.parse(path)to raise and fail the job. Since this feature is enabled by default and intended to be no-op-safe, this should degrade gracefully (warn + skip bad file), not hard-fail CI.Suggested patch
rows = [] totals = {} + parsed_any = False for path in files: - root = ET.parse(path).getroot() + try: + root = ET.parse(path).getroot() + except ET.ParseError: + print(f"::warning file={path}::Invalid coverage XML; skipping.") + continue 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)} |") + parsed_any = True 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) + + if not parsed_any: + print("::notice::No valid coverage XML parsed; skipping the coverage comment.") + raise SystemExit(0)🤖 Prompt for AI Agents