From 2ea4aba2d28af2daea16373272a284c4f2dd7814 Mon Sep 17 00:00:00 2001 From: Y Ethan Guo Date: Fri, 3 Jul 2026 16:23:30 -0700 Subject: [PATCH 1/4] ci: gate pull requests on 80% patch (changed-line) coverage Add a Patch Coverage workflow that runs after Java CI uploads coverage to Codecov, reads the PR's changed-line (diff) coverage from the Codecov API, and posts a failing commit status when it is below 80%. This enforces test coverage on new and modified code without penalizing a PR for the repository's existing overall coverage. Docs/config/test-only PRs with no measurable changed source lines pass automatically. --- .github/workflows/patch_coverage.yml | 115 +++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 .github/workflows/patch_coverage.yml diff --git a/.github/workflows/patch_coverage.yml b/.github/workflows/patch_coverage.yml new file mode 100644 index 0000000000000..98270acc22f46 --- /dev/null +++ b/.github/workflows/patch_coverage.yml @@ -0,0 +1,115 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Enforces a minimum coverage on the lines changed by a pull request (patch / +# diff coverage). It runs after the "Java CI" workflow finishes uploading +# coverage to Codecov, reads the PR's diff coverage from the Codecov API, and +# posts a commit status that fails when the changed lines are covered below the +# threshold. This gates new/modified code, not the whole project, so it does not +# penalize a PR for the repository's existing coverage. +name: Patch Coverage + +on: + workflow_run: + workflows: ["Java CI"] + types: + - completed + +# Least privilege: only needs to post a commit status back onto the PR head. +permissions: + contents: read + statuses: write + +env: + # Minimum required coverage (percent) of the lines changed by the PR. + MIN_PATCH_COVERAGE: "80" + # Name shown for the commit status check on the PR. + STATUS_CONTEXT: "coverage/patch" + +jobs: + patch-coverage: + name: Enforce patch coverage >= 80% + # Only gate pull requests; pushes to master/release branches have no diff to score. + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Check changed-line coverage from Codecov + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + owner="${REPO%%/*}" + repo="${REPO##*/}" + + post_status() { + # $1 = state (pending|success|failure), $2 = description + gh api -X POST "repos/${REPO}/statuses/${HEAD_SHA}" \ + -f state="$1" \ + -f context="${STATUS_CONTEXT}" \ + -f description="$2" >/dev/null 2>&1 || \ + echo "WARN: could not post commit status (${1}: ${2})" + } + + # Resolve the PR number from the head commit of the triggering CI run. + pr="$(gh api "repos/${REPO}/commits/${HEAD_SHA}/pulls" \ + --jq '.[0].number // empty' 2>/dev/null || true)" + if [ -z "${pr}" ]; then + echo "No open PR found for ${HEAD_SHA}; nothing to gate." + exit 0 + fi + echo "Gating PR #${pr} (head ${HEAD_SHA})" + post_status pending "Waiting for Codecov to report patch coverage" + + api="https://api.codecov.io/api/v2/github/${owner}/repos/${repo}/compare?pullid=${pr}" + + # Codecov may still be merging uploads from the CI matrix; poll briefly. + cov="" + for attempt in $(seq 1 20); do + resp="$(curl -sf "${api}" 2>/dev/null || true)" + if [ -n "${resp}" ]; then + # Diff (changed-line) coverage lives at totals.base.diff[5] as a + # percentage string; fall back to head.diff[5] / patch.coverage. + cov="$(printf '%s' "${resp}" | jq -r ' + (.totals.base.diff[5] // .totals.head.diff[5] // .totals.patch.coverage // empty) + | tostring' 2>/dev/null || true)" + fi + if [ -n "${cov}" ] && [ "${cov}" != "null" ] && [ "${cov}" != "empty" ]; then + break + fi + echo "Codecov not ready yet (attempt ${attempt}/20); sleeping 30s..." + sleep 30 + done + + if [ -z "${cov}" ] || [ "${cov}" = "null" ]; then + # No diff-coverage figure: typically means the PR changed no + # measurable source lines (docs/config/tests-only) or Codecov had no + # report. Do not fail the PR in that case. + echo "No patch coverage reported for PR #${pr}; treating as pass." + post_status success "No changed lines to score" + exit 0 + fi + + echo "Patch (changed-line) coverage: ${cov}% (minimum ${MIN_PATCH_COVERAGE}%)" + if awk "BEGIN { exit !(${cov} >= ${MIN_PATCH_COVERAGE}) }"; then + post_status success "Patch coverage ${cov}% >= ${MIN_PATCH_COVERAGE}%" + echo "PASS: changed lines meet the ${MIN_PATCH_COVERAGE}% coverage bar." + else + post_status failure "Patch coverage ${cov}% < ${MIN_PATCH_COVERAGE}%" + echo "FAIL: changed lines are only ${cov}% covered (need ${MIN_PATCH_COVERAGE}%)." + echo "Add tests for the new/modified code, or justify the gap with a reviewer." + exit 1 + fi From 79fe7b357afa6380f21e32cced45e59f4e536cc3 Mon Sep 17 00:00:00 2001 From: Y Ethan Guo Date: Fri, 3 Jul 2026 17:10:45 -0700 Subject: [PATCH 2/4] Wait for Codecov aggregation to complete and post the report on the PR - Poll the Codecov commit endpoint until state=complete and the session count stops growing before reading diff coverage, so the gate does not score a partially-merged report from the multi-job CI matrix and fail PRs spuriously. Fail open if Codecov never settles within the budget. - Post (and update in place) a patch-coverage report comment on the open PR so the result is visible before merging, in addition to the coverage/patch commit status. --- .github/workflows/patch_coverage.yml | 86 +++++++++++++++++++++------- 1 file changed, 64 insertions(+), 22 deletions(-) diff --git a/.github/workflows/patch_coverage.yml b/.github/workflows/patch_coverage.yml index 98270acc22f46..6c76429fe91d9 100644 --- a/.github/workflows/patch_coverage.yml +++ b/.github/workflows/patch_coverage.yml @@ -27,10 +27,11 @@ on: types: - completed -# Least privilege: only needs to post a commit status back onto the PR head. +# Least privilege: post a commit status on the PR head and a report comment on the PR. permissions: contents: read statuses: write + pull-requests: write env: # Minimum required coverage (percent) of the lines changed by the PR. @@ -64,6 +65,25 @@ jobs: echo "WARN: could not post commit status (${1}: ${2})" } + # Post (or update in place) the patch-coverage report comment on the open + # PR, so the result is visible on the PR before merging. The marker lets + # this update the same comment on re-runs instead of stacking new ones. + MARKER="" + upsert_comment() { + # $1 = markdown body (without the marker) + local body existing + body="${MARKER}"$'\n'"$1" + existing="$(gh api "repos/${REPO}/issues/${pr}/comments" --paginate \ + --jq "map(select(.body | startswith(\"${MARKER}\"))) | .[0].id // empty" 2>/dev/null || true)" + if [ -n "${existing}" ]; then + gh api -X PATCH "repos/${REPO}/issues/comments/${existing}" -f body="${body}" >/dev/null 2>&1 \ + || echo "WARN: could not update PR comment" + else + gh api -X POST "repos/${REPO}/issues/${pr}/comments" -f body="${body}" >/dev/null 2>&1 \ + || echo "WARN: could not create PR comment" + fi + } + # Resolve the PR number from the head commit of the triggering CI run. pr="$(gh api "repos/${REPO}/commits/${HEAD_SHA}/pulls" \ --jq '.[0].number // empty' 2>/dev/null || true)" @@ -72,43 +92,65 @@ jobs: exit 0 fi echo "Gating PR #${pr} (head ${HEAD_SHA})" - post_status pending "Waiting for Codecov to report patch coverage" + post_status pending "Waiting for Codecov to finish aggregating coverage" - api="https://api.codecov.io/api/v2/github/${owner}/repos/${repo}/compare?pullid=${pr}" + commit_api="https://api.codecov.io/api/v2/github/${owner}/repos/${repo}/commits/${HEAD_SHA}" + compare_api="https://api.codecov.io/api/v2/github/${owner}/repos/${repo}/compare?pullid=${pr}" - # Codecov may still be merging uploads from the CI matrix; poll briefly. - cov="" - for attempt in $(seq 1 20); do - resp="$(curl -sf "${api}" 2>/dev/null || true)" - if [ -n "${resp}" ]; then - # Diff (changed-line) coverage lives at totals.base.diff[5] as a - # percentage string; fall back to head.diff[5] / patch.coverage. - cov="$(printf '%s' "${resp}" | jq -r ' - (.totals.base.diff[5] // .totals.head.diff[5] // .totals.patch.coverage // empty) - | tostring' 2>/dev/null || true)" - fi - if [ -n "${cov}" ] && [ "${cov}" != "null" ] && [ "${cov}" != "empty" ]; then - break + # This job runs via workflow_run, i.e. only AFTER every Java CI job has + # finished, so all matrix uploads have already been submitted to Codecov. + # The remaining wait is for Codecov to finish MERGING those sessions. + # Reading diff coverage before merging completes would undercount lines + # covered by not-yet-merged sessions and fail PRs spuriously, so wait for + # the commit to reach state="complete" (and its session count to stop + # growing) before trusting the number. Budget ~20 min. + state="" + prev_sessions="-1" + stable=0 + for attempt in $(seq 1 40); do + cresp="$(curl -sf "${commit_api}" 2>/dev/null || true)" + state="$(printf '%s' "${cresp}" | jq -r '.state // empty' 2>/dev/null || true)" + sessions="$(printf '%s' "${cresp}" | jq -r '.totals.sessions // 0' 2>/dev/null || echo 0)" + echo "attempt ${attempt}/40: state=${state:-none} sessions=${sessions}" + if [ "${state}" = "complete" ] && [ "${sessions}" = "${prev_sessions}" ]; then + stable=1; break # complete and session count unchanged since last poll fi - echo "Codecov not ready yet (attempt ${attempt}/20); sleeping 30s..." + prev_sessions="${sessions}" sleep 30 done - if [ -z "${cov}" ] || [ "${cov}" = "null" ]; then - # No diff-coverage figure: typically means the PR changed no - # measurable source lines (docs/config/tests-only) or Codecov had no - # report. Do not fail the PR in that case. - echo "No patch coverage reported for PR #${pr}; treating as pass." + if [ "${stable}" != "1" ]; then + # Codecov never settled within the budget (slow/outage). Fail open: do + # not block the PR on Codecov infrastructure; log loudly instead. + echo "WARN: Codecov did not reach a stable 'complete' state in time; skipping the gate." + post_status success "Skipped: Codecov did not finish aggregating in time" + exit 0 + fi + + # Codecov has merged all sessions; read the changed-line (diff) coverage. + resp="$(curl -sf "${compare_api}" 2>/dev/null || true)" + cov="$(printf '%s' "${resp}" | jq -r ' + (.totals.base.diff[5] // .totals.head.diff[5] // .totals.patch.coverage // empty) + | tostring' 2>/dev/null || true)" + + if [ -z "${cov}" ] || [ "${cov}" = "null" ] || [ "${cov}" = "empty" ]; then + # No diff-coverage figure even though aggregation is complete: the PR + # changed no measurable source lines (docs/config/tests-only). Pass. + echo "No patch coverage reported for PR #${pr} (no measurable changed lines); treating as pass." post_status success "No changed lines to score" + upsert_comment "$(printf '### Patch coverage: not applicable ✅\n\nThis PR changed no measurable source lines (docs / config / test-only), so there is nothing to gate.')" exit 0 fi + report_url="https://app.codecov.io/gh/${owner}/${repo}/pull/${pr}" echo "Patch (changed-line) coverage: ${cov}% (minimum ${MIN_PATCH_COVERAGE}%)" if awk "BEGIN { exit !(${cov} >= ${MIN_PATCH_COVERAGE}) }"; then post_status success "Patch coverage ${cov}% >= ${MIN_PATCH_COVERAGE}%" + upsert_comment "$(printf '### Patch coverage: `%s%%` ✅\n\nCoverage of the lines this PR changed is **%s%%**, at or above the required **%s%%**.\n\n[View the coverage report on Codecov](%s).' "${cov}" "${cov}" "${MIN_PATCH_COVERAGE}" "${report_url}")" echo "PASS: changed lines meet the ${MIN_PATCH_COVERAGE}% coverage bar." else post_status failure "Patch coverage ${cov}% < ${MIN_PATCH_COVERAGE}%" + upsert_comment "$(printf '### Patch coverage: `%s%%` ❌\n\nCoverage of the lines this PR changed is **%s%%**, below the required **%s%%**. Please add tests for the new or modified code, or discuss the gap with a reviewer.\n\n[View which changed lines are uncovered on Codecov](%s).' "${cov}" "${cov}" "${MIN_PATCH_COVERAGE}" "${report_url}")" echo "FAIL: changed lines are only ${cov}% covered (need ${MIN_PATCH_COVERAGE}%)." echo "Add tests for the new/modified code, or justify the gap with a reviewer." exit 1 From 18950287c66750f2b855591d2380770e1fbcef7c Mon Sep 17 00:00:00 2001 From: Y Ethan Guo Date: Fri, 3 Jul 2026 18:06:01 -0700 Subject: [PATCH 3/4] Run the patch-coverage gate on the pull request directly Switch the trigger from workflow_run to pull_request so the guard runs on the PR itself (including the PR that introduces it), instead of loading the definition from the default branch. The job's own pass/fail is the gate and the result is written to the run summary, so it works for fork PRs where the token is read-only; the PR comment is posted best-effort on top. Skips immediately when no main-source (.java/.scala) lines changed, and waits for Codecov to finish aggregating the CI matrix uploads before scoring. --- .github/workflows/patch_coverage.yml | 142 ++++++++++++--------------- 1 file changed, 61 insertions(+), 81 deletions(-) diff --git a/.github/workflows/patch_coverage.yml b/.github/workflows/patch_coverage.yml index 6c76429fe91d9..426586aa9d88e 100644 --- a/.github/workflows/patch_coverage.yml +++ b/.github/workflows/patch_coverage.yml @@ -13,117 +13,103 @@ # See the License for the specific language governing permissions and # limitations under the License. -# Enforces a minimum coverage on the lines changed by a pull request (patch / -# diff coverage). It runs after the "Java CI" workflow finishes uploading -# coverage to Codecov, reads the PR's diff coverage from the Codecov API, and -# posts a commit status that fails when the changed lines are covered below the -# threshold. This gates new/modified code, not the whole project, so it does not -# penalize a PR for the repository's existing coverage. +# Enforces a minimum coverage on the lines a pull request changes (patch / diff +# coverage). It runs directly on the pull request: it waits for the PR's coverage +# to be aggregated on Codecov (uploaded by the Java CI matrix), reads the +# changed-line coverage, and fails when it is below the threshold. The job's own +# pass/fail is the gate; the result is also written to the run summary and, when +# permitted, posted as a PR comment. This gates new/modified code, not overall +# project coverage, so a PR is not penalized for the repository's existing +# coverage. name: Patch Coverage on: - workflow_run: - workflows: ["Java CI"] + pull_request: types: - - completed + - opened + - synchronize + - reopened + branches: + - master + - 'release-*' + - branch-0.x + +concurrency: + group: patch-coverage-${{ github.event.pull_request.number }} + cancel-in-progress: true -# Least privilege: post a commit status on the PR head and a report comment on the PR. permissions: contents: read - statuses: write pull-requests: write env: # Minimum required coverage (percent) of the lines changed by the PR. MIN_PATCH_COVERAGE: "80" - # Name shown for the commit status check on the PR. - STATUS_CONTEXT: "coverage/patch" + # Max minutes to wait for the CI matrix to finish uploading and for Codecov to + # aggregate the report for this PR head, before giving up (fail open). + MAX_WAIT_MINUTES: "90" jobs: patch-coverage: name: Enforce patch coverage >= 80% - # Only gate pull requests; pushes to master/release branches have no diff to score. - if: github.event.workflow_run.event == 'pull_request' runs-on: ubuntu-latest steps: - - name: Check changed-line coverage from Codecov + - name: Enforce patch coverage env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} - HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + PR: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail owner="${REPO%%/*}" repo="${REPO##*/}" - post_status() { - # $1 = state (pending|success|failure), $2 = description - gh api -X POST "repos/${REPO}/statuses/${HEAD_SHA}" \ - -f state="$1" \ - -f context="${STATUS_CONTEXT}" \ - -f description="$2" >/dev/null 2>&1 || \ - echo "WARN: could not post commit status (${1}: ${2})" - } - - # Post (or update in place) the patch-coverage report comment on the open - # PR, so the result is visible on the PR before merging. The marker lets - # this update the same comment on re-runs instead of stacking new ones. - MARKER="" - upsert_comment() { - # $1 = markdown body (without the marker) - local body existing - body="${MARKER}"$'\n'"$1" - existing="$(gh api "repos/${REPO}/issues/${pr}/comments" --paginate \ - --jq "map(select(.body | startswith(\"${MARKER}\"))) | .[0].id // empty" 2>/dev/null || true)" - if [ -n "${existing}" ]; then - gh api -X PATCH "repos/${REPO}/issues/comments/${existing}" -f body="${body}" >/dev/null 2>&1 \ - || echo "WARN: could not update PR comment" - else - gh api -X POST "repos/${REPO}/issues/${pr}/comments" -f body="${body}" >/dev/null 2>&1 \ - || echo "WARN: could not create PR comment" - fi + report() { # $1 = markdown line(s) for the run summary + optional PR comment + printf '%s\n' "$1" >> "${GITHUB_STEP_SUMMARY}" + # Best effort: fork PRs get a read-only token and cannot comment; that + # is fine, the job conclusion below is the actual gate. + gh pr comment "${PR}" --repo "${REPO}" --edit-last --body "$1" 2>/dev/null \ + || gh pr comment "${PR}" --repo "${REPO}" --body "$1" 2>/dev/null \ + || echo "NOTE: could not post PR comment (read-only token on fork PRs); see run summary." } - # Resolve the PR number from the head commit of the triggering CI run. - pr="$(gh api "repos/${REPO}/commits/${HEAD_SHA}/pulls" \ - --jq '.[0].number // empty' 2>/dev/null || true)" - if [ -z "${pr}" ]; then - echo "No open PR found for ${HEAD_SHA}; nothing to gate." + # Skip PRs that change no main source: there are no measurable changed + # lines to gate, so the check passes immediately without waiting on CI. + changed_main="$(gh api "repos/${REPO}/pulls/${PR}/files" --paginate \ + --jq '.[].filename | select(test("/src/main/.*\\.(java|scala)$"))' 2>/dev/null || true)" + if [ -z "${changed_main}" ]; then + echo "No changed main-source (.java/.scala) files; patch-coverage gate not applicable." + report "$(printf '### Patch coverage: not applicable ✅\nThis PR changes no main source (`.java`/`.scala`) lines, so there is nothing to gate.')" exit 0 fi - echo "Gating PR #${pr} (head ${HEAD_SHA})" - post_status pending "Waiting for Codecov to finish aggregating coverage" commit_api="https://api.codecov.io/api/v2/github/${owner}/repos/${repo}/commits/${HEAD_SHA}" - compare_api="https://api.codecov.io/api/v2/github/${owner}/repos/${repo}/compare?pullid=${pr}" + compare_api="https://api.codecov.io/api/v2/github/${owner}/repos/${repo}/compare?pullid=${PR}" - # This job runs via workflow_run, i.e. only AFTER every Java CI job has - # finished, so all matrix uploads have already been submitted to Codecov. - # The remaining wait is for Codecov to finish MERGING those sessions. - # Reading diff coverage before merging completes would undercount lines - # covered by not-yet-merged sessions and fail PRs spuriously, so wait for - # the commit to reach state="complete" (and its session count to stop - # growing) before trusting the number. Budget ~20 min. - state="" + # Wait for the CI matrix to finish uploading and for Codecov to finish + # MERGING those uploads for this PR head. Reading diff coverage before + # aggregation completes would undercount lines covered by not-yet-merged + # sessions and fail the PR spuriously, so wait until the commit reaches + # state="complete" and its session count stops growing. + deadline=$(( SECONDS + MAX_WAIT_MINUTES * 60 )) prev_sessions="-1" - stable=0 - for attempt in $(seq 1 40); do + settled=0 + while [ "${SECONDS}" -lt "${deadline}" ]; do cresp="$(curl -sf "${commit_api}" 2>/dev/null || true)" state="$(printf '%s' "${cresp}" | jq -r '.state // empty' 2>/dev/null || true)" sessions="$(printf '%s' "${cresp}" | jq -r '.totals.sessions // 0' 2>/dev/null || echo 0)" - echo "attempt ${attempt}/40: state=${state:-none} sessions=${sessions}" - if [ "${state}" = "complete" ] && [ "${sessions}" = "${prev_sessions}" ]; then - stable=1; break # complete and session count unchanged since last poll + echo "waiting for Codecov: state=${state:-none} sessions=${sessions}" + if [ "${state}" = "complete" ] && [ "${sessions}" = "${prev_sessions}" ] && [ "${sessions}" != "0" ]; then + settled=1; break fi prev_sessions="${sessions}" - sleep 30 + sleep 60 done - if [ "${stable}" != "1" ]; then - # Codecov never settled within the budget (slow/outage). Fail open: do - # not block the PR on Codecov infrastructure; log loudly instead. - echo "WARN: Codecov did not reach a stable 'complete' state in time; skipping the gate." - post_status success "Skipped: Codecov did not finish aggregating in time" + if [ "${settled}" != "1" ]; then + echo "WARN: Codecov did not reach a stable 'complete' state within ${MAX_WAIT_MINUTES}m; skipping the gate (fail open)." + report "$(printf '### Patch coverage: skipped ⚠️\nCodecov did not finish aggregating coverage for this PR within %s minutes, so the gate was skipped. Re-run after CI completes to enforce it.' "${MAX_WAIT_MINUTES}")" exit 0 fi @@ -134,24 +120,18 @@ jobs: | tostring' 2>/dev/null || true)" if [ -z "${cov}" ] || [ "${cov}" = "null" ] || [ "${cov}" = "empty" ]; then - # No diff-coverage figure even though aggregation is complete: the PR - # changed no measurable source lines (docs/config/tests-only). Pass. - echo "No patch coverage reported for PR #${pr} (no measurable changed lines); treating as pass." - post_status success "No changed lines to score" - upsert_comment "$(printf '### Patch coverage: not applicable ✅\n\nThis PR changed no measurable source lines (docs / config / test-only), so there is nothing to gate.')" + echo "No patch coverage figure reported despite complete aggregation; treating as pass." + report "$(printf '### Patch coverage: not applicable ✅\nCodecov reported no changed-line coverage figure for this PR (no instrumented changed lines).')" exit 0 fi - report_url="https://app.codecov.io/gh/${owner}/${repo}/pull/${pr}" + report_url="https://app.codecov.io/gh/${owner}/${repo}/pull/${PR}" echo "Patch (changed-line) coverage: ${cov}% (minimum ${MIN_PATCH_COVERAGE}%)" if awk "BEGIN { exit !(${cov} >= ${MIN_PATCH_COVERAGE}) }"; then - post_status success "Patch coverage ${cov}% >= ${MIN_PATCH_COVERAGE}%" - upsert_comment "$(printf '### Patch coverage: `%s%%` ✅\n\nCoverage of the lines this PR changed is **%s%%**, at or above the required **%s%%**.\n\n[View the coverage report on Codecov](%s).' "${cov}" "${cov}" "${MIN_PATCH_COVERAGE}" "${report_url}")" + report "$(printf '### Patch coverage: `%s%%` ✅\nCoverage of the lines this PR changed is **%s%%**, at or above the required **%s%%**. [Codecov report](%s)' "${cov}" "${cov}" "${MIN_PATCH_COVERAGE}" "${report_url}")" echo "PASS: changed lines meet the ${MIN_PATCH_COVERAGE}% coverage bar." else - post_status failure "Patch coverage ${cov}% < ${MIN_PATCH_COVERAGE}%" - upsert_comment "$(printf '### Patch coverage: `%s%%` ❌\n\nCoverage of the lines this PR changed is **%s%%**, below the required **%s%%**. Please add tests for the new or modified code, or discuss the gap with a reviewer.\n\n[View which changed lines are uncovered on Codecov](%s).' "${cov}" "${cov}" "${MIN_PATCH_COVERAGE}" "${report_url}")" + report "$(printf '### Patch coverage: `%s%%` ❌\nCoverage of the lines this PR changed is **%s%%**, below the required **%s%%**. Please add tests for the new or modified code, or discuss the gap with a reviewer. [Codecov report](%s)' "${cov}" "${cov}" "${MIN_PATCH_COVERAGE}" "${report_url}")" echo "FAIL: changed lines are only ${cov}% covered (need ${MIN_PATCH_COVERAGE}%)." - echo "Add tests for the new/modified code, or justify the gap with a reviewer." exit 1 fi From cc123a939f8f99e71f17e336d757a017871657ca Mon Sep 17 00:00:00 2001 From: Y Ethan Guo Date: Fri, 3 Jul 2026 19:11:52 -0700 Subject: [PATCH 4/4] Wait for Java CI to finish before scoring patch coverage The gate runs concurrently with Java CI and Codecov reports state=complete after every individual upload, so scoring on state+session-stability alone could read a 1-session partial report and fail spuriously (observed: 0% patch coverage while the coverage-producing test jobs were still running). First block until the Java CI run for this head SHA is completed (all uploads submitted), then require Codecov's session count to hold steady across consecutive polls before reading diff coverage. Bump the budget to cover the full matrix plus Codecov merge. --- .github/workflows/patch_coverage.yml | 50 ++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/.github/workflows/patch_coverage.yml b/.github/workflows/patch_coverage.yml index 426586aa9d88e..3ba0f4b3c683e 100644 --- a/.github/workflows/patch_coverage.yml +++ b/.github/workflows/patch_coverage.yml @@ -40,14 +40,16 @@ concurrency: permissions: contents: read + actions: read pull-requests: write env: # Minimum required coverage (percent) of the lines changed by the PR. MIN_PATCH_COVERAGE: "80" - # Max minutes to wait for the CI matrix to finish uploading and for Codecov to - # aggregate the report for this PR head, before giving up (fail open). - MAX_WAIT_MINUTES: "90" + # Max minutes to wait for Java CI to finish AND for Codecov to aggregate the + # report for this PR head, before giving up (fail open). Covers the full CI + # matrix (~75m) plus Codecov merge time. + MAX_WAIT_MINUTES: "120" jobs: patch-coverage: @@ -87,29 +89,51 @@ jobs: commit_api="https://api.codecov.io/api/v2/github/${owner}/repos/${repo}/commits/${HEAD_SHA}" compare_api="https://api.codecov.io/api/v2/github/${owner}/repos/${repo}/compare?pullid=${PR}" - # Wait for the CI matrix to finish uploading and for Codecov to finish - # MERGING those uploads for this PR head. Reading diff coverage before - # aggregation completes would undercount lines covered by not-yet-merged - # sessions and fail the PR spuriously, so wait until the commit reaches - # state="complete" and its session count stops growing. deadline=$(( SECONDS + MAX_WAIT_MINUTES * 60 )) + + # STEP 1: wait until the Java CI run for this exact head SHA has COMPLETED. + # This gate runs concurrently with Java CI, and Codecov reports state="complete" + # after every single upload (it re-aggregates incrementally). Scoring before the + # whole matrix has uploaded would read a partial report and fail spuriously, so + # first block on Java CI finishing: only then are all coverage sessions submitted. + ci_done=0 + while [ "${SECONDS}" -lt "${deadline}" ]; do + ci_status="$(gh api "repos/${REPO}/actions/runs?head_sha=${HEAD_SHA}&per_page=100" \ + --jq '[.workflow_runs[] | select(.name=="Java CI")] | sort_by(.run_number) | last | .status' 2>/dev/null || true)" + echo "waiting for Java CI to finish: status=${ci_status:-unknown}" + if [ "${ci_status}" = "completed" ]; then ci_done=1; break; fi + sleep 60 + done + if [ "${ci_done}" != "1" ]; then + echo "WARN: Java CI did not complete within ${MAX_WAIT_MINUTES}m; skipping the gate (fail open)." + report "$(printf '### Patch coverage: skipped ⚠️\nJava CI did not finish within %s minutes, so patch coverage could not be scored. Re-run after CI completes to enforce it.' "${MAX_WAIT_MINUTES}")" + exit 0 + fi + + # STEP 2: Java CI is done, so all uploads are submitted; now wait for Codecov to + # finish MERGING them. Require state="complete" AND the session count to hold + # steady across 3 consecutive polls (90s apart) so we do not score mid-merge. prev_sessions="-1" + stable_count=0 settled=0 while [ "${SECONDS}" -lt "${deadline}" ]; do cresp="$(curl -sf "${commit_api}" 2>/dev/null || true)" state="$(printf '%s' "${cresp}" | jq -r '.state // empty' 2>/dev/null || true)" sessions="$(printf '%s' "${cresp}" | jq -r '.totals.sessions // 0' 2>/dev/null || echo 0)" - echo "waiting for Codecov: state=${state:-none} sessions=${sessions}" + echo "waiting for Codecov merge: state=${state:-none} sessions=${sessions} stable=${stable_count}" if [ "${state}" = "complete" ] && [ "${sessions}" = "${prev_sessions}" ] && [ "${sessions}" != "0" ]; then - settled=1; break + stable_count=$(( stable_count + 1 )) + if [ "${stable_count}" -ge 2 ]; then settled=1; break; fi + else + stable_count=0 fi prev_sessions="${sessions}" - sleep 60 + sleep 90 done if [ "${settled}" != "1" ]; then - echo "WARN: Codecov did not reach a stable 'complete' state within ${MAX_WAIT_MINUTES}m; skipping the gate (fail open)." - report "$(printf '### Patch coverage: skipped ⚠️\nCodecov did not finish aggregating coverage for this PR within %s minutes, so the gate was skipped. Re-run after CI completes to enforce it.' "${MAX_WAIT_MINUTES}")" + echo "WARN: Codecov did not reach a stable 'complete' state within the budget; skipping the gate (fail open)." + report "$(printf '### Patch coverage: skipped ⚠️\nCodecov did not finish aggregating coverage for this PR in time, so the gate was skipped. Re-run to enforce it.')" exit 0 fi