diff --git a/.github/workflows/python-sonarcloud.yml b/.github/workflows/python-sonarcloud.yml index 53f3a8c8..d359fc32 100644 --- a/.github/workflows/python-sonarcloud.yml +++ b/.github/workflows/python-sonarcloud.yml @@ -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: '' + source-directory: description: 'Source code directory to analyze (default: src)' type: string @@ -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 @@ -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 }} diff --git a/docs/workflows/python-sonarcloud.md b/docs/workflows/python-sonarcloud.md index 870ad2b8..e50650a1 100644 --- a/docs/workflows/python-sonarcloud.md +++ b/docs/workflows/python-sonarcloud.md @@ -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 | @@ -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 @@ -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 @@ -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 diff --git a/workflow-templates/python-sonarcloud.yml b/workflow-templates/python-sonarcloud.yml index 638838d7..b2ebc5ca 100644 --- a/workflow-templates/python-sonarcloud.yml +++ b/workflow-templates/python-sonarcloud.yml @@ -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: