Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 100 additions & 2 deletions .github/workflows/python-sonarcloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,30 @@ on:
required: true

python-version:
description: 'Python version for analysis'
description: 'Python version used to build and run the analysis (single version; feeds actions/setup-python)'
type: string
required: false
default: '3.12'

sonar-python-version:
description: >-
Python versions the SOURCE supports, comma-separated (e.g. "3.11,3.12,3.13").
Sets -Dsonar.python.version. Defaults to python-version when empty.
Whitespace around the commas and at the ends is stripped, so SonarSource's own
documented "3.11, 3.12" spacing is accepted. Anything else, including whitespace
inside a version ("3 .11"), fails the job with an explicit message instead of
reaching the scanner malformed.
Set this whenever requires-python is wider than the single version used to
build, because Sonar gates version-specific rules on ALL declared versions
(PythonVersionUtils.areSourcePythonVersionsGreaterOrEqualThan is allMatch).
Declaring only the build version makes the analyzer raise rules for syntax
the project cannot actually use, e.g. PEP 695 S6794/S6796 against a codebase
that still supports 3.11. This is separate from python-version because
actions/setup-python accepts exactly one version and would fail on a list.
type: string
required: false
default: ''

Comment thread
coderabbitai[bot] marked this conversation as resolved.
source-directory:
description: 'Source code directory to analyze (default: src)'
type: string
Expand Down Expand Up @@ -382,6 +401,85 @@ jobs:
fi
fi

# Resolve sonar.python.version in bash rather than inline in the args: block below.
# The args: block is a folded scalar, so YAML turns every newline into a space and the
# scanner splits the result on whitespace. A value containing a space therefore becomes
# two scanner arguments and the version list is silently truncated. SonarSource's own
# documentation writes the property with spaces after commas ("3.11, 3.12"), so a caller
# copying the vendor format would hit that misparse with no validation and no warning.
# Stripping whitespace here makes both spellings work and keeps args: single-token.
#
# #CRITICAL: SONAR_PYTHON_VERSION and PYTHON_VERSION are caller-controlled, so both are
# passed through env: rather than interpolated into the run: body via ${{ ... }};
# template expansion runs before bash sees the line, so shell metacharacters in a
# caller's value would become script text (CodeQL py/code-injection).
# #VERIFY: this step references only $SONAR_PYTHON_VERSION and $PYTHON_VERSION, never
# ${{ inputs.* }} inside run:.
#
# #CRITICAL: the regex is also the GITHUB_OUTPUT shape guard. A caller value containing
# a newline could otherwise append extra key=value lines to the output file and forge
# unrelated step outputs (see PR #234).
# #VERIFY: the echo to $GITHUB_OUTPUT is reachable only after the match below succeeds,
# which admits only digits, dots, commas and whitespace; stripping the whitespace then
# leaves no newline, no '=', and no shell metacharacter.
- name: Resolve sonar.python.version
id: sonar_python
if: steps.detect.outputs.state == 'uv-locked' || steps.detect.outputs.state == 'uv-no-lock'
env:
SONAR_PYTHON_VERSION: ${{ inputs.sonar-python-version }}
PYTHON_VERSION: ${{ inputs.python-version }}
run: |
# Reduce a caller-controlled value to a safe charset before echoing it: a raw newline
# or colon would otherwise let it forge a ::workflow-command:: in the log.
safe() { printf '%s' "${1//[^0-9A-Za-z.,_ +>=<-]/?}"; }

if [ -n "$SONAR_PYTHON_VERSION" ]; then
# The explicit input is a sonar.python.version list and nothing else, so hold it to
# exactly that shape. Validate BEFORE stripping, so whitespace is tolerated only
# where it is meaningless: around the commas and at the ends. That accepts
# SonarSource's documented "3.11, 3.12" spacing while still rejecting whitespace
# inside a version ("3 .11"), which stripping first would have silently normalized
# into a different version.
if [[ ! "$SONAR_PYTHON_VERSION" =~ ^[[:space:]]*[0-9]+\.[0-9]+([[:space:]]*,[[:space:]]*[0-9]+\.[0-9]+)*[[:space:]]*$ ]]; then
echo "::error::sonar-python-version must be a comma-separated list of MAJOR.MINOR Python versions, for example '3.11,3.12,3.13'. Received: '$(safe "$SONAR_PYTHON_VERSION")'"
exit 1
fi
VERSION="${SONAR_PYTHON_VERSION//[[:space:]]/}"
echo "Resolved sonar.python.version=$VERSION (from sonar-python-version)"
else
# #CRITICAL: the fallback path must NOT apply the strict list shape. python-version
# feeds actions/setup-python, whose accepted syntax is much wider than MAJOR.MINOR:
# '3.12.1', '3.13.0-rc.1', '>=3.11', '3.x', and 'pypy3.10' are all legal there and
# all worked before this step existed. Rejecting them would break existing callers
# that never opted into sonar.python.version at all.
# #VERIFY: a caller passing python-version '3.12.1' still analyzes, as 3.12.
#
# sonar.python.version only accepts MAJOR.MINOR, so extract the leading one rather
# than passing the raw value through. '3.x' and other version-less forms have no
# derivable MINOR, so they fail with a pointer to the explicit input instead of
# guessing a version the caller never stated.
if [[ "$PYTHON_VERSION" =~ ([0-9]+)\.([0-9]+) ]]; then
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
else
echo "::error::Could not derive a MAJOR.MINOR Python version from python-version '$(safe "$PYTHON_VERSION")'. Set sonar-python-version explicitly to the versions your source supports, for example '3.11,3.12,3.13'."
exit 1
fi
if [ "$VERSION" = "$PYTHON_VERSION" ]; then
echo "Resolved sonar.python.version=$VERSION (from python-version; sonar-python-version was empty)"
else
echo "Resolved sonar.python.version=$VERSION (narrowed from python-version '$(safe "$PYTHON_VERSION")'; sonar-python-version was empty)"
fi
echo "::notice::sonar.python.version defaulted to the single build version $VERSION. If this project's requires-python is wider, set sonar-python-version, because Sonar gates version-specific rules on ALL declared versions."
fi

# #CRITICAL: this is the GITHUB_OUTPUT shape guard. A caller value containing a newline
# could otherwise append extra key=value lines and forge unrelated step outputs (#234).
# #VERIFY: both branches assign VERSION from digits-and-dots only, either a
# whitespace-stripped match of the strict list regex or two numeric BASH_REMATCH
# groups, so no newline, '=', or shell metacharacter can reach the output file.
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
shell: bash

- name: SonarCloud Scan
if: steps.detect.outputs.state == 'uv-locked' || steps.detect.outputs.state == 'uv-no-lock'
uses: SonarSource/sonarqube-scan-action@713881670b6b3676cda39549040e2d88c70d582e # v8.2 # nosemgrep: detected-sonarqube-docs-api-key # FP -- see #37
Expand All @@ -392,7 +490,7 @@ jobs:
args: >
-Dsonar.organization=${{ inputs.sonar-organization }}
-Dsonar.projectKey=${{ inputs.sonar-project-key }}
-Dsonar.python.version=${{ inputs.python-version }}
-Dsonar.python.version=${{ steps.sonar_python.outputs.version }}
-Dsonar.sources=${{ inputs.source-directory }}
-Dsonar.python.coverage.reportPaths=${{ inputs.coverage-paths }}
-Dsonar.coverage.exclusions=${{ inputs.coverage-exclusions }}
Expand Down
58 changes: 55 additions & 3 deletions docs/workflows/python-sonarcloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ jobs:

| Input | Type | Default | Description |
|-------|------|---------|-------------|
| `python-version` | string | '3.12' | Python version for analysis |
| `python-version` | string | '3.12' | Single Python version used to build the project and run the analysis; feeds `actions/setup-python` |
| `sonar-python-version` | string | '' | Python versions the source supports, comma-separated (`3.11,3.12,3.13`). Sets `sonar.python.version`. Falls back to `python-version` when empty. See [Declaring supported source versions](#declaring-supported-source-versions) |
| `source-directory` | string | 'src' | Source code directory |
| `coverage-paths` | string | 'coverage.xml' | Coverage report paths (comma-separated) |
| `coverage-exclusions` | string | See below | Paths to exclude from coverage |
Expand All @@ -82,6 +83,54 @@ jobs:
|--------|-------------|
| `SONAR_TOKEN` | SonarCloud authentication token (optional if `skip-if-no-token: true`) |

### Declaring supported source versions

`python-version` and `sonar-python-version` answer two different questions. Keep them
separate:

| Input | Question it answers | Cardinality |
|-------|---------------------|-------------|
| `python-version` | Which interpreter builds the project and runs the scan? | Exactly one (`actions/setup-python` rejects a list) |
| `sonar-python-version` | Which versions must the source code remain valid on? | One or more, comma-separated |

Set `sonar-python-version` whenever your `requires-python` range is wider than the single
version used to build:

```yaml
with:
python-version: '3.12' # builds and scans on 3.12
sonar-python-version: '3.11,3.12,3.13' # source must stay valid on all three
```

**Why it matters.** SonarPython gates version-specific rules on *every* declared version,
not the highest one. Declaring only the build version tells the analyzer the project is
3.12-only, so it raises 3.12+ syntax rules (PEP 695 `type` aliases, `python:S6794` and
`python:S6796`) against code that still has to run on 3.11. Adding 3.11 to the list
correctly silences them.

**The inverse also holds, and it loses findings silently.** Because the gate requires all
declared versions to meet a rule's threshold, declaring versions *below* your real floor
suppresses rules you actually want. A project whose `requires-python` is `>=3.12` that
declares `3.9,3.12` gets no 3.10+, 3.11+, or 3.12+ rules at all, with no warning. Declare
the range that matches `requires-python` and nothing wider.

**Format.** Comma-separated `MAJOR.MINOR` values. Whitespace around the commas and at the
ends is tolerated and stripped, so both `3.11,3.12` and SonarSource's own documented
`3.11, 3.12` spacing work. Anything else fails the job with an explicit message rather than
reaching the scanner as a malformed argument: a patch version (`3.12.1`), a range operator
(`>=3.11`), a trailing comma, or whitespace inside a version (`3 .11`), which is rejected
rather than silently normalized into a different version.

**When the input is empty.** The fallback derives `sonar.python.version` from
`python-version` instead, and it is deliberately lenient there, because `python-version`
feeds `actions/setup-python` and its accepted syntax is much wider than a Sonar version
list. The leading `MAJOR.MINOR` is extracted, so `3.12.1` resolves to `3.12`, `>=3.11` to
`3.11`, and `pypy3.10` to `3.10`. All of these are legal `python-version` values and none of
them should fail an analysis. Only a value with no derivable minor version (`3.x`, `pypy`,
or empty) fails, and the message points at `sonar-python-version` rather than guessing a
version you never declared. The strict list validation above applies to
`sonar-python-version` alone, since that input means a Sonar version list and nothing else.

## Usage Examples

### Basic Configuration
Expand Down Expand Up @@ -193,7 +242,8 @@ sonar.projectVersion=1.0.0
# Source configuration
sonar.sources=src
sonar.tests=tests
sonar.python.version=3.12
# Every version the source must stay valid on, not just the build version.
sonar.python.version=3.11,3.12,3.13

# Coverage configuration
sonar.python.coverage.reportPaths=coverage.xml
Expand All @@ -207,7 +257,9 @@ sonar.test.exclusions=**/integration_tests/**
sonar.sourceEncoding=UTF-8
```

**Note:** Workflow inputs override properties file settings.
**Note:** Workflow inputs override properties file settings. The workflow always passes
`-Dsonar.python.version` on the command line, so a `sonar.python.version` key in this file
is ignored; set the `sonar-python-version` input instead.

## Quality Gate Configuration

Expand Down
7 changes: 7 additions & 0 deletions workflow-templates/python-sonarcloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ jobs:
ls -lh coverage.xml
fi

# Widen -Dsonar.python.version below to every version your requires-python range
# supports, comma-separated with no spaces, for example 3.11,3.12,3.13. SonarPython
# gates version-specific rules on ALL declared versions, so declaring only the build
# version raises rules for syntax the project cannot use (PEP 695 S6794/S6796 against
# 3.11-compatible code). Declaring versions BELOW your real floor silently suppresses
# valid rules. Note that args: is a folded block scalar: keep each value space-free,
# because YAML joins these lines with spaces and the scanner splits on whitespace.
- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@713881670b6b3676cda39549040e2d88c70d582e # v8
env:
Expand Down
Loading