From a7a741b9037587620746dfbdd34f88a7bdbf704f Mon Sep 17 00:00:00 2001 From: Byron Williams Date: Wed, 5 Aug 2026 18:56:05 -0700 Subject: [PATCH 1/6] feat(sonarcloud): decouple sonar.python.version from the build version ``-Dsonar.python.version`` was wired to the ``python-version`` input, which also feeds ``actions/setup-python``. Because setup-python accepts exactly one version, a caller could never declare a support RANGE to the analyzer, only the single version CI happens to build with. That is not cosmetic. Sonar gates version-specific rules on ALL declared versions: ``PythonVersionUtils.areSourcePythonVersionsGreaterOrEqualThan`` is an ``allMatch`` over the parsed set. Declaring one modern version therefore opens every version gate, and the analyzer raises rules for syntax the project cannot legally use. Observed on cyo-adventure: 5 PEP 695 findings (``python:S6794`` x4, ``python:S6796`` x1) against a codebase whose ``requires-python`` is ``>=3.11`` and whose compatibility matrix still runs a 3.11 leg, where ``type X = ...`` is a SyntaxError. Both checks call that helper with ``Version.V_312``. The findings were on their way to being accepted by hand as false positives; they are neither false nor positives, just the answer to a question nobody meant to ask. Adds an optional ``sonar-python-version`` input for the source support range (``'3.11,3.12,3.13,3.14'``), defaulting to empty and falling back to ``python-version``, so every existing caller is byte-identical. Callers whose ``requires-python`` is wider than their CI version should set it. - YAML parses; the new input registers with ``default: ''``, ``required: false`` - actionlint: no new findings at the changed lines (the SC2086/SC2129 reports are pre-existing, in ``run:`` blocks this commit does not touch) - The value lands in the scan action's ``with: args:``, not a ``run:`` block, so it introduces no shell interpolation Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/python-sonarcloud.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-sonarcloud.yml b/.github/workflows/python-sonarcloud.yml index 53f3a8c8..65922a13 100644 --- a/.github/workflows/python-sonarcloud.yml +++ b/.github/workflows/python-sonarcloud.yml @@ -68,11 +68,26 @@ 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,3.14"). + Sets -Dsonar.python.version. Defaults to python-version when empty. + 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 @@ -392,7 +407,7 @@ jobs: args: > -Dsonar.organization=${{ inputs.sonar-organization }} -Dsonar.projectKey=${{ inputs.sonar-project-key }} - -Dsonar.python.version=${{ inputs.python-version }} + -Dsonar.python.version=${{ inputs.sonar-python-version != '' && inputs.sonar-python-version || inputs.python-version }} -Dsonar.sources=${{ inputs.source-directory }} -Dsonar.python.coverage.reportPaths=${{ inputs.coverage-paths }} -Dsonar.coverage.exclusions=${{ inputs.coverage-exclusions }} From 3da29ec44dca81752b55a3ed2a866c2a3345fe84 Mon Sep 17 00:00:00 2001 From: Byron Williams Date: Wed, 5 Aug 2026 19:39:41 -0700 Subject: [PATCH 2/6] fix(sonarcloud): validate and normalize sonar-python-version The scan step passes sonar.python.version inside a folded block scalar (args: >), where YAML joins lines with spaces and the scanner then splits the result on whitespace. A value containing a space became two scanner arguments and the version list was silently truncated. SonarSource's own documentation writes the property as "3.11, 3.12", so a caller copying the vendor format hit that misparse with no validation and no warning. Resolve the value in a bash step instead: strip whitespace so both spellings work, then reject anything that is not a comma-separated list of MAJOR.MINOR versions. The regex doubles as the GITHUB_OUTPUT shape guard (PR #234), since a newline in a caller value could otherwise append extra key=value lines and forge unrelated step outputs. Both inputs reach the run: body through env:, never through template expansion. Also narrow the description example from 3.14 to 3.13 so it matches this repo's python-versions:supported marker and cannot trip the policy gate in scripts/check-python-versions.sh. Verified: 11 guard cases (vendor spacing, messy whitespace, empty fallback, both-empty, patch version, range operator, command substitution, trailing comma, newline injection) behave as designed; actionlint rc=0; yamllint adds no new warnings; qlty clean. --- .github/workflows/python-sonarcloud.yml | 56 ++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-sonarcloud.yml b/.github/workflows/python-sonarcloud.yml index 65922a13..906cc726 100644 --- a/.github/workflows/python-sonarcloud.yml +++ b/.github/workflows/python-sonarcloud.yml @@ -75,8 +75,11 @@ on: sonar-python-version: description: >- - Python versions the SOURCE supports, comma-separated (e.g. "3.11,3.12,3.13,3.14"). + 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 commas is stripped, so SonarSource's own documented + "3.11, 3.12" spacing is accepted; any other shape 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). @@ -397,6 +400,55 @@ 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 ^[0-9]+\.[0-9]+ match + # succeeds, which admits 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: | + if [ -n "$SONAR_PYTHON_VERSION" ]; then + RAW="$SONAR_PYTHON_VERSION" + FROM_INPUT="sonar-python-version" + else + RAW="$PYTHON_VERSION" + FROM_INPUT="python-version (sonar-python-version was empty)" + fi + + VERSION="${RAW//[[:space:]]/}" + + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+(,[0-9]+\.[0-9]+)*$ ]]; then + # Reduce the echoed value to a safe charset first: it is caller-controlled, and a + # raw newline or colon would let it forge a ::workflow-command:: in the log. + SAFE_RAW="${RAW//[^0-9A-Za-z.,_ -]/?}" + echo "::error::$FROM_INPUT must be a comma-separated list of MAJOR.MINOR Python versions, for example '3.11,3.12,3.13'. Received: '$SAFE_RAW'" + exit 1 + fi + + echo "Resolved sonar.python.version=$VERSION (from $FROM_INPUT)" + 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 @@ -407,7 +459,7 @@ jobs: args: > -Dsonar.organization=${{ inputs.sonar-organization }} -Dsonar.projectKey=${{ inputs.sonar-project-key }} - -Dsonar.python.version=${{ inputs.sonar-python-version != '' && inputs.sonar-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 }} From a44f770ac85c2761ff334188c609bda829d85eb1 Mon Sep 17 00:00:00 2001 From: Byron Williams Date: Wed, 5 Aug 2026 19:39:51 -0700 Subject: [PATCH 3/6] docs(sonarcloud): document sonar-python-version input The inputs table still described python-version as "Python version for analysis" and did not list sonar-python-version at all, so callers had no way to learn the input exists or why they would set it. CodeRabbit flagged the same gap on PR #291. Add the missing row, retitle python-version to say it is the single actions/setup-python version, and add a "Declaring supported source versions" section covering the allMatch gating rule that makes the input necessary, the accepted format, and the inverse hazard: declaring versions below the real requires-python floor silently suppresses rules that should fire. Also correct the sonar-project.properties example, which showed a single version and did not mention that the workflow always passes -Dsonar.python.version on the command line, so a sonar.python.version key in that file is ignored. --- docs/workflows/python-sonarcloud.md | 46 +++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/docs/workflows/python-sonarcloud.md b/docs/workflows/python-sonarcloud.md index 870ad2b8..005e543a 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,42 @@ 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. Surrounding whitespace is tolerated and +stripped, so both `3.11,3.12` and SonarSource's own documented `3.11, 3.12` spacing work. +Anything else (a patch version, a range operator, a non-numeric token) fails the job with +an explicit message rather than reaching the scanner as a malformed argument. + ## Usage Examples ### Basic Configuration @@ -193,7 +230,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 +245,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 From a9fe594812163bd7c7239c7ed2c7603f08ac3ea6 Mon Sep 17 00:00:00 2001 From: Byron Williams Date: Wed, 5 Aug 2026 19:40:01 -0700 Subject: [PATCH 4/6] docs(workflow-templates): flag the hardcoded sonar.python.version The standalone starter workflow invokes the scanner directly and hardcodes -Dsonar.python.version=3.12, so it carries the same defect the reusable workflow just fixed: adopters whose requires-python is wider than 3.12 get version-gated rules raised against syntax they cannot use. The template has no workflow_call inputs to widen, so document the adjustment at the value itself, including the folded-scalar constraint that each value stay space-free. --- workflow-templates/python-sonarcloud.yml | 7 +++++++ 1 file changed, 7 insertions(+) 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: From c150019b118245e21a5b56e113b2980428053c08 Mon Sep 17 00:00:00 2001 From: Byron Williams Date: Wed, 5 Aug 2026 20:36:53 -0700 Subject: [PATCH 5/6] fix(sonarcloud): reject whitespace inside a version, not just around commas Copilot's re-review flagged that the docs promised whitespace was stripped "around commas" while the resolver stripped whitespace everywhere. The claim was wrong in the caller's favour: stripping first meant "3 .11" was silently normalized to "3.11" instead of being reported as the typo it is, so a mistyped value could quietly analyze against a version the caller never declared. Validate before stripping, with whitespace permitted only where it carries no meaning: around the commas and at the ends. SonarSource's documented "3.11, 3.12" spacing still passes; "3 .11", "3. 11", "3.1 1", and a bare space used as a separator now fail with the same explicit message as any other malformed shape. The GITHUB_OUTPUT shape guard is unaffected. The accepted set is still only digits, dots, commas, and whitespace, and stripping the whitespace leaves no newline, no '=', and no shell metacharacter. Verified: 18 cases, extracted from the workflow body itself so the test cannot drift from the shipped code. 8 pass cases (including tab and newline as inter-comma whitespace) and 10 reject cases (the 4 new whitespace-inside- version cases, patch version, range operator, command substitution, trailing comma, both-empty, newline injection). actionlint findings are byte-identical to base at 46; yamllint line-length warnings unchanged at 5; qlty clean; check-python-versions.sh rc=0. --- .github/workflows/python-sonarcloud.yml | 22 ++++++++++++++-------- docs/workflows/python-sonarcloud.md | 10 ++++++---- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/.github/workflows/python-sonarcloud.yml b/.github/workflows/python-sonarcloud.yml index 906cc726..4cf301af 100644 --- a/.github/workflows/python-sonarcloud.yml +++ b/.github/workflows/python-sonarcloud.yml @@ -77,9 +77,10 @@ on: 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 commas is stripped, so SonarSource's own documented - "3.11, 3.12" spacing is accepted; any other shape fails the job with an - explicit message instead of reaching the scanner malformed. + 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). @@ -418,8 +419,9 @@ jobs: # #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 ^[0-9]+\.[0-9]+ match - # succeeds, which admits no newline, no '=', and no shell metacharacter. + # #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' @@ -435,9 +437,11 @@ jobs: FROM_INPUT="python-version (sonar-python-version was empty)" fi - VERSION="${RAW//[[:space:]]/}" - - if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+(,[0-9]+\.[0-9]+)*$ ]]; then + # 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 [[ ! "$RAW" =~ ^[[:space:]]*[0-9]+\.[0-9]+([[:space:]]*,[[:space:]]*[0-9]+\.[0-9]+)*[[:space:]]*$ ]]; then # Reduce the echoed value to a safe charset first: it is caller-controlled, and a # raw newline or colon would let it forge a ::workflow-command:: in the log. SAFE_RAW="${RAW//[^0-9A-Za-z.,_ -]/?}" @@ -445,6 +449,8 @@ jobs: exit 1 fi + VERSION="${RAW//[[:space:]]/}" + echo "Resolved sonar.python.version=$VERSION (from $FROM_INPUT)" echo "version=$VERSION" >> "$GITHUB_OUTPUT" shell: bash diff --git a/docs/workflows/python-sonarcloud.md b/docs/workflows/python-sonarcloud.md index 005e543a..15f83a55 100644 --- a/docs/workflows/python-sonarcloud.md +++ b/docs/workflows/python-sonarcloud.md @@ -114,10 +114,12 @@ suppresses rules you actually want. A project whose `requires-python` is `>=3.12 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. Surrounding whitespace is tolerated and -stripped, so both `3.11,3.12` and SonarSource's own documented `3.11, 3.12` spacing work. -Anything else (a patch version, a range operator, a non-numeric token) fails the job with -an explicit message rather than reaching the scanner as a malformed argument. +**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. ## Usage Examples From f101aa3e56adfeab03971e597dd6892bde71ddbd Mon Sep 17 00:00:00 2001 From: Byron Williams Date: Wed, 5 Aug 2026 20:54:37 -0700 Subject: [PATCH 6/6] fix(sonarcloud): keep the python-version fallback lenient Copilot caught a backward-compatibility break I introduced in c150019. The strict MAJOR.MINOR list validation was applied to both inputs, but the two have different contracts. sonar-python-version is a sonar.python.version list and nothing else. python-version feeds actions/setup-python, whose accepted syntax is far wider: '3.12.1', '3.13.0-rc.1', '>=3.11', and 'pypy3.10' are all legal there and all analyzed fine before this step existed. Validating them against a Sonar list shape hard-failed the job for callers who never opted into sonar.python.version at all. Split the paths. The explicit input keeps strict validation. The fallback extracts the leading MAJOR.MINOR, since that is all sonar.python.version accepts, and only fails when no minor version can be derived ('3.x', 'pypy', empty), where the message points at sonar-python-version rather than guessing a version the caller never declared. A narrowed value is logged as narrowed, so the normalization is never silent, and the fallback now also emits a notice that a single build version may be too narrow. The GITHUB_OUTPUT shape guard survives the split: 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. Verified: 26 cases, extracted from the workflow body so the test cannot drift from shipped code. 16 explicit-input cases (6 accept including vendor spacing, tab and newline; 10 reject including the injection and whitespace-inside-version cases) and 10 fallback cases (6 accept including the 3.12.1 regression Copilot found, 4 reject). Rejects write nothing to GITHUB_OUTPUT. pre-commit clean, qlty clean, check-python-versions.sh rc=0, actionlint 46 findings matching base with none in the step's line range. --- .github/workflows/python-sonarcloud.yml | 63 +++++++++++++++++-------- docs/workflows/python-sonarcloud.md | 10 ++++ 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/.github/workflows/python-sonarcloud.yml b/.github/workflows/python-sonarcloud.yml index 4cf301af..d359fc32 100644 --- a/.github/workflows/python-sonarcloud.yml +++ b/.github/workflows/python-sonarcloud.yml @@ -429,29 +429,54 @@ jobs: 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 - RAW="$SONAR_PYTHON_VERSION" - FROM_INPUT="sonar-python-version" + # 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 - RAW="$PYTHON_VERSION" - FROM_INPUT="python-version (sonar-python-version was empty)" - fi - - # 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 [[ ! "$RAW" =~ ^[[:space:]]*[0-9]+\.[0-9]+([[:space:]]*,[[:space:]]*[0-9]+\.[0-9]+)*[[:space:]]*$ ]]; then - # Reduce the echoed value to a safe charset first: it is caller-controlled, and a - # raw newline or colon would let it forge a ::workflow-command:: in the log. - SAFE_RAW="${RAW//[^0-9A-Za-z.,_ -]/?}" - echo "::error::$FROM_INPUT must be a comma-separated list of MAJOR.MINOR Python versions, for example '3.11,3.12,3.13'. Received: '$SAFE_RAW'" - exit 1 + # #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 - VERSION="${RAW//[[:space:]]/}" - - echo "Resolved sonar.python.version=$VERSION (from $FROM_INPUT)" + # #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 diff --git a/docs/workflows/python-sonarcloud.md b/docs/workflows/python-sonarcloud.md index 15f83a55..e50650a1 100644 --- a/docs/workflows/python-sonarcloud.md +++ b/docs/workflows/python-sonarcloud.md @@ -121,6 +121,16 @@ reaching the scanner as a malformed argument: a patch version (`3.12.1`), a rang (`>=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