From 6d6f740def70b1a9ff828700399fb348e4db1132 Mon Sep 17 00:00:00 2001 From: Joshua Hale Date: Mon, 18 May 2026 15:21:03 +0100 Subject: [PATCH 1/4] Improve upload diagnostics in workflow logs Surface the HTTP status code and response body in the action output so that users can distinguish between a true 201 (report stored) and a 200 (report silently rejected, e.g. commit not latest on branch). Parameters are logged in a collapsed group to avoid noise. Add a fail-on-error input (default: false) so that upload failures do not break existing workflows. Users who want strict behaviour can opt in. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- action.yml | 60 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/action.yml b/action.yml index de49b1a..46d63c4 100644 --- a/action.yml +++ b/action.yml @@ -11,6 +11,10 @@ inputs: label: description: 'Label for the coverage report (e.g. "code-coverage/jacoco")' required: true + fail-on-error: + description: 'Whether to fail the workflow step if the upload fails' + required: false + default: 'false' token: description: 'GitHub token with code-quality:write permission' required: false @@ -26,6 +30,7 @@ runs: INPUT_FILE: ${{ inputs.file }} INPUT_LANGUAGE: ${{ inputs.language }} INPUT_LABEL: ${{ inputs.label }} + FAIL_ON_ERROR: ${{ inputs.fail-on-error }} run: | set -euo pipefail @@ -71,6 +76,15 @@ runs: --jq '.[0].number // empty' 2>/dev/null || true) fi + echo "::group::Upload parameters" + echo " commit_oid: $COMMIT_OID" + echo " ref: ${REF:-}" + echo " pr_number: ${PR_NUMBER:-}" + echo " language: $INPUT_LANGUAGE" + echo " label: $INPUT_LABEL" + echo " file: $INPUT_FILE ($(wc -c < "$INPUT_FILE" | tr -d ' ') bytes)" + echo "::endgroup::" + # Gzip and base64-encode the report. We write to files and use jq # --rawfile to avoid hitting the OS argument length limit on large # coverage reports. @@ -99,15 +113,43 @@ runs: > __body.json fi - UPLOAD_OUTPUT=$(gh api --method PUT "/repos/${{ github.repository }}/code-coverage/report" \ - --input __body.json 2>&1) || { - if echo "$UPLOAD_OUTPUT" | grep -qi "not authorized"; then - echo "::error::Coverage upload returned 403 Forbidden. Ensure the calling job has 'code-quality: write' permission. See https://github.com/actions/upload-code-coverage#permissions" + HTTP_RESPONSE=$(mktemp) + set +e + gh api --method PUT "/repos/${{ github.repository }}/code-coverage/report" \ + --input __body.json \ + --include > "$HTTP_RESPONSE" 2>&1 + GH_EXIT=$? + set -e + HTTP_CODE=$(grep -i "^HTTP/" "$HTTP_RESPONSE" | tail -1 | awk '{print $2}') + + RESPONSE_BODY=$(sed '1,/^\r*$/d' "$HTTP_RESPONSE") + rm -f __coverage_b64.txt __body.json "$HTTP_RESPONSE" + + if [ -z "$HTTP_CODE" ]; then + echo "::error::Coverage upload failed: could not reach the API" + [ "$FAIL_ON_ERROR" = "true" ] && exit 1 || exit 0 + fi + + if [ "$HTTP_CODE" -ge 400 ]; then + if echo "$RESPONSE_BODY" | grep -qi "not authorized"; then + echo "::error::Coverage upload returned HTTP $HTTP_CODE. Ensure the calling job has 'code-quality: write' permission. See https://github.com/actions/upload-code-coverage#permissions" else - echo "::error::Coverage upload failed: $UPLOAD_OUTPUT" + echo "::error::Coverage upload failed (HTTP $HTTP_CODE): $RESPONSE_BODY" fi - rm -f __coverage_b64.txt __body.json - exit 1 - } + [ "$FAIL_ON_ERROR" = "true" ] && exit 1 || exit 0 + fi - rm -f __coverage_b64.txt __body.json + if [ "$HTTP_CODE" = "201" ]; then + echo "Coverage report uploaded successfully." + elif [ "$HTTP_CODE" = "200" ]; then + # HTTP 200 means the API accepted the request but did not store the + # report (e.g. the commit is not the latest on the branch). + RESPONSE_MSG=$(echo "$RESPONSE_BODY" | jq -r '.message // empty' 2>/dev/null || echo "$RESPONSE_BODY") + if [ -n "$RESPONSE_MSG" ]; then + echo "::warning::Coverage upload returned HTTP 200 (report not stored): $RESPONSE_MSG" + else + echo "::warning::Coverage upload returned HTTP 200 but expected 201. The report may not have been stored." + fi + else + echo "::notice::Coverage upload returned unexpected HTTP $HTTP_CODE: $RESPONSE_BODY" + fi From 3b001fa454027f5519e00ee352efa30ce97136f1 Mon Sep 17 00:00:00 2001 From: Joshua Hale Date: Mon, 18 May 2026 17:29:17 +0100 Subject: [PATCH 2/4] Fix response body parsing for CRLF line endings Strip carriage returns before splitting headers from body so that the blank-line separator is matched correctly on responses with CRLF line endings from gh api --include. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 46d63c4..3ef2af2 100644 --- a/action.yml +++ b/action.yml @@ -122,7 +122,7 @@ runs: set -e HTTP_CODE=$(grep -i "^HTTP/" "$HTTP_RESPONSE" | tail -1 | awk '{print $2}') - RESPONSE_BODY=$(sed '1,/^\r*$/d' "$HTTP_RESPONSE") + RESPONSE_BODY=$(sed 's/\r$//' "$HTTP_RESPONSE" | sed '1,/^$/d') rm -f __coverage_b64.txt __body.json "$HTTP_RESPONSE" if [ -z "$HTTP_CODE" ]; then From e2ef9906e1d5c0b4695f29f2374a109df15125f8 Mon Sep 17 00:00:00 2001 From: Joshua Hale Date: Thu, 21 May 2026 11:34:25 +0100 Subject: [PATCH 3/4] Default fail-on-error to true and update docs The previous default of false silently swallowed upload errors, which is a change from the original behaviour where the action exited 1 on API errors. Default to true so workflows fail visibly on upload errors, and mention the opt-out in error messages and the README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 30 ++++++++++++++++++++++++------ action.yml | 15 ++++++++------- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index cb45955..277a78b 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,13 @@ The action handles everything else automatically: gzip/base64 encoding, resolvin ## Inputs -| Input | Required | Description | -|-------|----------|-------------| -| `file` | Yes | Path to the Cobertura XML coverage report | -| `language` | Yes | Linguist language name (e.g. `Java`, `Go`, `Python`) | -| `label` | Yes | Label for the report (e.g. `code-coverage/jacoco`) | -| `token` | No | GitHub token (defaults to `github.token`) | +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `file` | Yes | | Path to the Cobertura XML coverage report | +| `language` | Yes | | Linguist language name (e.g. `Java`, `Go`, `Python`) | +| `label` | Yes | | Label for the report (e.g. `code-coverage/jacoco`) | +| `fail-on-error` | No | `true` | Whether to fail the workflow step if the upload fails | +| `token` | No | `github.token` | GitHub token with `code-quality:write` permission | ## Permissions @@ -35,6 +36,23 @@ permissions: For push-only workflows where the action looks up PR numbers via `gh pr list`, also add `pull-requests: read`. +## Error handling + +By default, the action fails the workflow step (exits with code 1) when the upload is unsuccessful. This ensures you notice when coverage data is not being stored. + +If coverage upload is best-effort in your workflow and you don't want a transient API failure to block CI, set `fail-on-error: false`: + +```yaml +- uses: actions/upload-code-coverage@v1 + with: + file: cobertura.xml + language: Java + label: code-coverage/jacoco + fail-on-error: false +``` + +When `fail-on-error` is `false`, upload errors are still surfaced as `::error::` annotations in the workflow log, but the step exits 0. + ## Event handling The action auto-detects the event type and resolves the correct values: diff --git a/action.yml b/action.yml index 3ef2af2..fc5a295 100644 --- a/action.yml +++ b/action.yml @@ -12,9 +12,9 @@ inputs: description: 'Label for the coverage report (e.g. "code-coverage/jacoco")' required: true fail-on-error: - description: 'Whether to fail the workflow step if the upload fails' + description: 'Whether to fail the workflow step if the upload fails (set to false to treat upload errors as warnings)' required: false - default: 'false' + default: 'true' token: description: 'GitHub token with code-quality:write permission' required: false @@ -126,17 +126,18 @@ runs: rm -f __coverage_b64.txt __body.json "$HTTP_RESPONSE" if [ -z "$HTTP_CODE" ]; then - echo "::error::Coverage upload failed: could not reach the API" - [ "$FAIL_ON_ERROR" = "true" ] && exit 1 || exit 0 + echo "::error::Coverage upload failed: could not reach the API. To treat upload errors as warnings, add 'fail-on-error: false' to the action inputs." + [ "$FAIL_ON_ERROR" != "false" ] && exit 1 || exit 0 fi if [ "$HTTP_CODE" -ge 400 ]; then + HINT="To treat upload errors as warnings, add 'fail-on-error: false' to the action inputs." if echo "$RESPONSE_BODY" | grep -qi "not authorized"; then - echo "::error::Coverage upload returned HTTP $HTTP_CODE. Ensure the calling job has 'code-quality: write' permission. See https://github.com/actions/upload-code-coverage#permissions" + echo "::error::Coverage upload returned HTTP $HTTP_CODE. Ensure the calling job has 'code-quality: write' permission. See https://github.com/actions/upload-code-coverage#permissions. $HINT" else - echo "::error::Coverage upload failed (HTTP $HTTP_CODE): $RESPONSE_BODY" + echo "::error::Coverage upload failed (HTTP $HTTP_CODE): $RESPONSE_BODY. $HINT" fi - [ "$FAIL_ON_ERROR" = "true" ] && exit 1 || exit 0 + [ "$FAIL_ON_ERROR" != "false" ] && exit 1 || exit 0 fi if [ "$HTTP_CODE" = "201" ]; then From 61dae9715e79fc694baffe3747258e48252ec83d Mon Sep 17 00:00:00 2001 From: Joshua Hale Date: Fri, 22 May 2026 11:47:38 +0100 Subject: [PATCH 4/4] Strip documentation_url from error output (pre-GA) The API response includes a documentation_url field that currently 404s since docs are not yet published. Extract just the message field for display. Added TODO comments to restore it for GA. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test_upload_coverage.py | 21 +++++++++++++++++++++ upload_coverage.py | 21 ++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/test_upload_coverage.py b/test_upload_coverage.py index dfe2280..d2e5f84 100644 --- a/test_upload_coverage.py +++ b/test_upload_coverage.py @@ -372,5 +372,26 @@ def test_403_permissions_error_includes_fail_on_error_hint(self): self.assertIn("fail-on-error: false", output) + def test_error_response_strips_documentation_url(self): + """Only the message field is shown, not the full JSON with documentation_url. + + TODO(GA): When docs are published at docs.github.com/rest/code-quality/code-coverage, + include the documentation_url in the output and update this test accordingly. + """ + body = json.dumps({ + "message": "Code quality is not enabled for this repository.", + "documentation_url": "https://docs.github.com/rest/code-quality/code-coverage", + "status": "403", + }) + opener = mock.Mock(return_value=FakeResponse(status=403, body=body.encode())) + + exit_code, output, _ = self.run_main(opener=opener) + + self.assertEqual(1, exit_code) + self.assertIn("Code quality is not enabled", output) + self.assertNotIn("documentation_url", output) + self.assertNotIn("docs.github.com", output) + + if __name__ == "__main__": unittest.main() diff --git a/upload_coverage.py b/upload_coverage.py index 1517307..322db06 100755 --- a/upload_coverage.py +++ b/upload_coverage.py @@ -44,6 +44,24 @@ def log_upload_parameters( print("::endgroup::") +def _extract_message(body: str) -> str: + """Extract the human-readable message from an API JSON response. + + Falls back to the raw body if parsing fails or no message field exists. + We intentionally strip documentation_url and other fields because the + docs URL currently 404s (pre-GA). + TODO(GA): Once docs are live, consider including documentation_url in output. + """ + try: + data = json.loads(body) + message = data.get("message", "") + if message: + return message + except (json.JSONDecodeError, AttributeError, TypeError): + pass + return body + + def encode_coverage_report(file_path: str) -> str: data = Path(file_path).read_bytes() return base64.b64encode(gzip.compress(data)).decode("ascii") @@ -133,7 +151,8 @@ def handle_response(status: int, body: str, fail_on_error: bool) -> int: if status == 403 and "not authorized" in body.lower(): emit_annotation("error", f"{PERMISSIONS_ERROR.format(status=status)}. {FAIL_ON_ERROR_HINT}") else: - emit_annotation("error", f"Coverage upload failed (HTTP {status}): {body}. {FAIL_ON_ERROR_HINT}") + display_body = _extract_message(body) + emit_annotation("error", f"Coverage upload failed (HTTP {status}): {display_body}. {FAIL_ON_ERROR_HINT}") return 1 if fail_on_error else 0 # Unexpected status code