Skip to content

fix(ci): PR diff coverage uses base branch SHAs and aggregate 80% gate (port to 5.7-main)#2616

Merged
sherwinski merged 1 commit into5.7-mainfrom
ci/5.7/pr-diff-base-and-aggregate
Apr 16, 2026
Merged

fix(ci): PR diff coverage uses base branch SHAs and aggregate 80% gate (port to 5.7-main)#2616
sherwinski merged 1 commit into5.7-mainfrom
ci/5.7/pr-diff-base-and-aggregate

Conversation

@sherwinski
Copy link
Copy Markdown
Contributor

Description

One Line Summary

Port of #2608 to 5.7-main so PR diff coverage measures exactly the PR patch against the chosen base branch (not main) and enforces an aggregate 80% gate on touched executable lines.

Details

Motivation

PRs targeting 5.7-main (e.g. #2614) are currently flagged by the [Diff Coverage] Check coverage CI step for files that were not changed in the PR. Root cause: on 5.7-main the coverage script diffs against origin/main by default, so every file that differs between main and 5.7-main is treated as "changed" and run through the 80% gate.

#2608 fixed this on main by:

  1. Passing the PR's base.sha / head.sha into the script so the diff matches exactly what GitHub shows as the PR patch, regardless of the chosen base branch.
  2. Switching the gate from per-file to an aggregate 80% across touched executable lines (per-file numbers remain in the report for context).
  3. Failing with a clear message when src/main Kotlin/Java files change in the diff but none map to the merged JaCoCo report.
  4. Renaming the workflow step so the label reads as "coverage on changed lines."

This PR cherry-picks that commit (5f7187bae) onto 5.7-main.

Scope

  • .github/workflows/ci.yml — step rename + pass PR base/head SHAs via DIFF_BASE_SHA / DIFF_HEAD_SHA.
  • OneSignalSDK/coverage/checkCoverage.sh — SHA-based diff range, aggregate gate, unmapped-source failure mode, updated report copy.
  • No SDK runtime code changes.

Notes on the port

Testing

Unit testing

N/A — CI-only change.

Manual testing

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

#2608)

Co-authored-by: AR Abdul Azeez <abdul@onesignal.com>
Comment on lines 368 to 395
print("\n ✓ All checked files have no executable lines (or fully covered)")
sys.exit(0)
else:
if eligible_paths:
msg = (
"Changed Kotlin/Java sources under `src/main` could not be matched to the merged JaCoCo report "
"(or the diff has no executable touched lines). Check module wiring and that unit tests run for those sources."
)
if generate_markdown:
markdown_output.append(f"\n### ❌ No usable coverage for changed sources\n\n{msg}\n")
with open(markdown_report, 'w') as f:
f.write('\n'.join(markdown_output))
print('\n'.join(markdown_output))
else:
print(f"\n ✗ {msg}")
sys.exit(1)
if generate_markdown:
markdown_output.append(f"\n### ⚠️ No Coverage Data\n")
markdown_output.append("No coverage data found for changed files\n")
markdown_output.append(f"\n### No main-source changes to gate\n")
markdown_output.append("No `src/main` Kotlin/Java changes in the diff.\n")
with open(markdown_report, 'w') as f:
f.write('\n'.join(markdown_output))
else:
print("\n ⚠ No coverage data found for changed files")
print(" This may mean files aren't being compiled or tested")
print("\n ✓ No main-source Kotlin/Java changes in the diff")
sys.exit(0)
PYEOF

CHECK_RESULT=$?
if [ $CHECK_RESULT -eq 1 ]; then
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The new eligible_paths module-wiring check (exit 1 when src/main sources can't be matched to JaCoCo) is bypassed whenever at least one changed src/main file is found in JaCoCo with zero executable lines. In a mixed PR where FileA is in JaCoCo with 0 executable lines and FileB is absent from JaCoCo entirely, the elif files_checked branch fires and exits 0 with 'All checked files have no executable lines', silently ignoring FileB's missing JaCoCo entry. The eligible_paths failure path introduced by this PR is therefore unreachable in this scenario, defeating the module-wiring detection it was designed to provide.

Extended reasoning...

What the bug is

The PR introduces a new failure mode: when src/main Kotlin/Java files are changed but none appear in the JaCoCo report, the script exits 1 with a clear error about module wiring. This is implemented via two lists: eligible_paths (all src/main changed files, populated before JaCoCo lookup) and files_checked (only files that ARE found in JaCoCo). The branching after the loop is: (1) if total_lines > 0 → aggregate gate; (2) elif files_checked: → exit 0 'All checked files have no executable lines'; (3) else: → check eligible_paths, possibly exit 1.

The specific code path that triggers it

In the scenario where FileA is found in JaCoCo with file_total == 0 (0 executable lines, e.g. a pure interface or annotation class), it is added to files_checked but does NOT increment total_lines. If FileB is a src/main Kotlin file NOT found in JaCoCo at all, it is added to eligible_paths but not files_checked. After the loop: total_lines = 0, files_checked = ['FileA'] (non-empty), eligible_paths = ['FileA', 'FileB']. The elif files_checked: branch fires and exits 0.

Why existing code doesn't prevent it

The elif files_checked branch does not inspect eligible_paths at all — it assumes that if any file was found in JaCoCo, the exit-0 message is correct. But eligible_paths may contain additional files that were never found. The branch message 'All checked files have no executable lines' is also misleading: FileB was never checked, it simply doesn't exist in the report.

What the impact would be

A module wiring issue — a src/main Kotlin source compiled into a module not included in the JaCoCo merge — will go undetected in CI. The build exits 0, the PR appears to pass coverage, and the structural problem (missing module from test run) is only visible as a per-file '⚠️ Not in coverage report' warning that reviewers may overlook. The entire purpose of the new eligible_paths failure mode is defeated whenever at least one other changed src/main file happens to be in JaCoCo with 0 executable lines.

Addressing the refutation

The refutation argues that 'none map to the merged JaCoCo report' is intentional — the feature is for the all-or-nothing case, and partial mismatches still show a per-file warning. However: (1) the per-file warning does not cause a non-zero exit code, so CI does not fail; (2) the PR description does not state this all-or-nothing limitation — it says 'Failing with a clear message when src/main Kotlin/Java files change in the diff but none map to the merged JaCoCo report', which could reasonably apply to the case where only some are missing; (3) the design goal (catching module wiring failures) is not served by a warning that exits 0. The refutation's logic would justify removing the eligible_paths check entirely, since a missing file always produces a per-file warning regardless.

Step-by-step proof

  1. PR changes two src/main files: com/onesignal/Foo.kt (a Kotlin interface with no body statements) and com/onesignal/Bar.kt (new class in a mis-wired module).
  2. Loop iteration for Foo.kt: found in JaCoCo with file_total = 0files_checked.append('Foo.kt'), total_lines stays 0.
  3. Loop iteration for Bar.kt: not found in JaCoCo → prints '⚠️ Bar.kt: Not in coverage report' → nothing added to files_checked.
  4. After loop: total_lines = 0, files_checked = ['Foo.kt'], eligible_paths = ['Foo.kt', 'Bar.kt'].
  5. Branching: total_lines > 0 → False; elif files_checked: → True → exits 0 with 'All checked files have no executable lines'.
  6. The else: branch containing the eligible_paths check is never reached. Bar.kt's module wiring problem goes undetected.

How to fix it

In the elif files_checked: branch, also check whether eligible_paths contains files not in files_checked. If so, report those as missing and exit 1. For example: unmatched = [p for p in eligible_paths if os.path.basename(p) not in files_checked], and if unmatched is non-empty, emit the same module-wiring error message and exit 1.

Comment on lines +140 to +165
if diff_range:
result = subprocess.run(
['git', 'diff', '--cached', '--unified=0', '--', file_path],
['git', 'diff', '--unified=0', diff_range, '--', file_path],
capture_output=True,
text=True,
cwd=project_root
)

# If no staged changes, check unstaged changes
if result.returncode != 0 or not result.stdout.strip():
result = subprocess.run(
['git', 'diff', '--unified=0', '--', file_path],
capture_output=True,
text=True,
cwd=project_root
)

# If still nothing, try alternative base branch format
if result.returncode != 0 or not result.stdout.strip():
if result.returncode != 0 or not result.stdout.strip():
return None
else:
result = subprocess.run(
['git', 'diff', '--unified=0', base_branch, 'HEAD', '--', file_path],
['git', 'diff', '--unified=0', base_branch + '...HEAD', '--', file_path],
capture_output=True,
text=True,
cwd=project_root
)

if result.returncode != 0 or not result.stdout.strip():
return None
if result.returncode != 0 or not result.stdout.strip():
result = subprocess.run(
['git', 'diff', '--cached', '--unified=0', '--', file_path],
capture_output=True,
text=True,
cwd=project_root
)
if result.returncode != 0 or not result.stdout.strip():
result = subprocess.run(
['git', 'diff', '--unified=0', '--', file_path],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The else branch in get_changed_lines() (lines 149–178) that handles staged/unstaged fallbacks is dead code and can never execute, causing a regression in the local developer workflow. Because the shell always exports DIFF_RANGE as a non-empty string (either the SHA-pair or ${BASE_BRANCH}...HEAD), Python's if diff_range: is always true — so a developer with staged-but-not-committed Kotlin/Java files will have those files in CHANGED_FILES but get_changed_lines() will call git diff --unified=0 origin/main...HEAD -- <staged_file>, return nothing, and fall back to checking all executable lines instead of only the changed ones, producing false failures or false passes.

Extended reasoning...

The bug: unreachable else branch

In get_changed_lines(), the new code uses if diff_range: … else: … where the else branch provides the staged/unstaged fallback chain that existed pre-PR. The problem is that diff_range is always non-empty: the shell always assigns DIFF_RANGE before exporting it — either ${DIFF_BASE_SHA}...${DIFF_HEAD_SHA} (lines 77–78, when both SHAs are set) or ${BASE_BRANCH}...HEAD (lines 80–81, the local/workflow_dispatch path, where BASE_BRANCH defaults to origin/main). There is no code path that leaves DIFF_RANGE empty before export DIFF_RANGE.

Why the guard doesn't help

Python reads it as diff_range = os.environ.get('DIFF_RANGE', '').strip() (around line 135). Since the environment variable is always a non-empty string like origin/main...HEAD, diff_range is always truthy. The else block — which tries git diff --cached (staged) and then git diff (unstaged) as progressive fallbacks — can never execute.

Concrete impact for local use (documented in the script header)

Step through the local case where a developer has staged-but-not-committed Kotlin/Java changes:

  1. Shell runs the non-SHA branch: DIFF_RANGE="origin/main...HEAD"; STAGED_FILES is populated via git diff --cached --name-only; CHANGED_FILES therefore includes the staged files.
  2. Python receives DIFF_RANGE="origin/main...HEAD" — truthy — so it always takes the if diff_range: path and calls git diff --unified=0 origin/main...HEAD -- <staged_file>.
  3. For a file that is only staged (not yet committed), that diff returns nothing. Python returns None for changed_lines.
  4. The caller falls back to checking all executable lines in the file instead of only the changed ones — producing a false failure (low overall coverage even though the small changed portion is covered) or a false pass (high overall coverage even though the new lines are not).

Pre-PR, the code tried git diff --cached next, which would correctly return the staged diff and pinpoint exactly the changed lines.

Fix

Either (a) leave DIFF_RANGE unset/empty in the non-SHA shell path and continue to rely on the Python fallback chain, or (b) detect the local case in Python as diff_range == base_branch + '...HEAD' and add the staged/unstaged fallback after the primary git diff call returns empty, or (c) do the staged/unstaged fallbacks inside the if diff_range: block when the primary diff returns nothing.

@sherwinski sherwinski merged commit ee18562 into 5.7-main Apr 16, 2026
2 of 3 checks passed
@sherwinski sherwinski deleted the ci/5.7/pr-diff-base-and-aggregate branch April 16, 2026 22:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants