From 8adffa24fdbe9d035901a09f2ab6149108163a39 Mon Sep 17 00:00:00 2001 From: poketopa Date: Sun, 23 Aug 2026 22:10:52 +0900 Subject: [PATCH 1/4] Add setup-python version upgrade recipe --- .../SetupPythonUpgradePythonVersion.java | 227 ++++++++ .../resources/META-INF/rewrite/examples.yml | 36 ++ .../resources/META-INF/rewrite/recipes.csv | 1 + .../SetupPythonUpgradePythonVersionTest.java | 520 ++++++++++++++++++ 4 files changed, 784 insertions(+) create mode 100644 src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java create mode 100644 src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java diff --git a/src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java b/src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java new file mode 100644 index 0000000..21acc6a --- /dev/null +++ b/src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java @@ -0,0 +1,227 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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. + */ +package org.openrewrite.github; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.jspecify.annotations.Nullable; +import org.openrewrite.*; +import org.openrewrite.semver.Semver; +import org.openrewrite.yaml.JsonPathMatcher; +import org.openrewrite.yaml.YamlVisitor; +import org.openrewrite.yaml.tree.Yaml; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@EqualsAndHashCode(callSuper = false) +@Value +public class SetupPythonUpgradePythonVersion extends Recipe { + + @Option(displayName = "Python version", + description = "The target Python version.", + example = "3.14", + required = true) + String version; + + String displayName = "Upgrade `actions/setup-python` `python-version`"; + + String description = "Update the Python version used by `actions/setup-python` if it is below the expected version number."; + + Set tags = new LinkedHashSet<>(Arrays.asList("github", "python", "deprecation")); + + @Override + public Validated validate() { + return super.validate() + .and(Validated.required("version", version)) + .and(Validated.test("version", "must be a major.minor Python version", version, + version -> version != null && parseTargetVersion(version) != null)); + } + + @Override + public TreeVisitor getVisitor() { + PythonVersion parsedVersion = parseTargetVersion(version); + if (parsedVersion == null) { + return TreeVisitor.noop(); + } + return Preconditions.check(new IsGitHubActionsWorkflow(), new UpgradePythonVersionVisitor(version, parsedVersion, toSemverVersion(version))); + } + + @AllArgsConstructor + private static class UpgradePythonVersionVisitor extends YamlVisitor { + private static final JsonPathMatcher pythonVersion = new JsonPathMatcher("..steps[?(@.uses =~ 'actions/setup-python@v*.*')].with.python-version"); + private static final Pattern pythonVersionPattern = Pattern.compile("([0-9]+)\\.([0-9]+)(?:\\.[0-9]+)?(?:[-+][0-9A-Za-z.-]+)?"); + private static final Pattern targetPythonVersionPattern = Pattern.compile("([0-9]+)\\.([0-9]+)"); + private static final Pattern lowerBoundPattern = Pattern.compile("(?:^|[\\s,])(?:>=|>)\\s*([0-9]+)\\.([0-9]+)(?:\\.[0-9]+)*(?:[-+][^\\s,]+)?"); + private static final Pattern upperBoundPattern = Pattern.compile("(?:^|[\\s,])<=?\\s*([0-9]+)\\.([0-9]+)(?:\\.[0-9]+)*(?:[-+][^\\s,]+)?"); + private static final Pattern hyphenRangePattern = Pattern.compile("(?:^|\\s)[0-9]+\\.[0-9]+(?:\\.[0-9]+)*\\s+-\\s+([0-9]+)\\.([0-9]+)(?:\\.[0-9]+)*(?:\\s|$)"); + private static final Pattern xRangePattern = Pattern.compile("([0-9]+)\\.([0-9]+)\\.(?:x|X|\\*)"); + private static final Pattern majorXRangePattern = Pattern.compile("([0-9]+)\\.(?:x|X|\\*)"); + private static final Pattern tildeRangePattern = Pattern.compile("~\\s*([0-9]+)\\.([0-9]+)(?:\\.[0-9]+)?"); + + private final String version; + private final PythonVersion parsedVersion; + private final String semverVersion; + + @Override + public Yaml visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) { + if (!pythonVersion.matches(getCursor()) || hasPythonVersionFile()) { + return super.visitMappingEntry(entry, ctx); + } + + if (!(entry.getValue() instanceof Yaml.Scalar)) { + return super.visitMappingEntry(entry, ctx); + } + + Yaml.Scalar currentValue = (Yaml.Scalar) entry.getValue(); + if (!isSafelyBelowTarget(currentValue.getValue(), parsedVersion, semverVersion)) { + return super.visitMappingEntry(entry, ctx); + } + + return super.visitMappingEntry( + entry.withValue(currentValue.withValue(version)), + ctx + ); + } + + private boolean hasPythonVersionFile() { + Object parent = getCursor().getParentOrThrow().getValue(); + if (!(parent instanceof Yaml.Mapping)) { + return false; + } + for (Yaml.Mapping.Entry entry : ((Yaml.Mapping) parent).getEntries()) { + if ("python-version-file".equals(entry.getKey().getValue())) { + return true; + } + } + return false; + } + + private static boolean isSafelyBelowTarget(String currentVersion, PythonVersion targetVersion, String semverVersion) { + PythonVersion exactVersion = parseVersion(currentVersion); + if (exactVersion != null) { + return exactVersion.compareTo(targetVersion) < 0; + } + + if (currentVersion.contains("||") || + Semver.validate(currentVersion, null, Semver.Ecosystem.NODE).isInvalid() || + Semver.satisfies(semverVersion, currentVersion, Semver.Ecosystem.NODE)) { + return false; + } + + Matcher lowerBound = lowerBoundPattern.matcher(currentVersion); + while (lowerBound.find()) { + PythonVersion lowerBoundVersion = new PythonVersion( + Integer.parseInt(lowerBound.group(1)), + Integer.parseInt(lowerBound.group(2)) + ); + if (lowerBoundVersion.compareTo(targetVersion) >= 0) { + return false; + } + } + + Matcher xRange = xRangePattern.matcher(currentVersion); + if (xRange.matches()) { + PythonVersion xRangeVersion = new PythonVersion( + Integer.parseInt(xRange.group(1)), + Integer.parseInt(xRange.group(2)) + ); + return xRangeVersion.compareTo(targetVersion) < 0; + } + + Matcher majorXRange = majorXRangePattern.matcher(currentVersion); + if (majorXRange.matches()) { + return Integer.parseInt(majorXRange.group(1)) < targetVersion.major; + } + + Matcher tildeRange = tildeRangePattern.matcher(currentVersion); + if (tildeRange.matches()) { + PythonVersion tildeRangeVersion = new PythonVersion( + Integer.parseInt(tildeRange.group(1)), + Integer.parseInt(tildeRange.group(2)) + ); + return tildeRangeVersion.compareTo(targetVersion) < 0; + } + + Matcher upperBound = upperBoundPattern.matcher(currentVersion); + while (upperBound.find()) { + PythonVersion upperBoundVersion = new PythonVersion( + Integer.parseInt(upperBound.group(1)), + Integer.parseInt(upperBound.group(2)) + ); + if (upperBoundVersion.compareTo(targetVersion) <= 0) { + return true; + } + } + + Matcher hyphenRange = hyphenRangePattern.matcher(currentVersion); + while (hyphenRange.find()) { + PythonVersion upperBoundVersion = new PythonVersion( + Integer.parseInt(hyphenRange.group(1)), + Integer.parseInt(hyphenRange.group(2)) + ); + if (upperBoundVersion.compareTo(targetVersion) < 0) { + return true; + } + } + return false; + } + } + + private static String toSemverVersion(String version) { + return version + ".0"; + } + + private static @Nullable PythonVersion parseTargetVersion(String version) { + Matcher matcher = UpgradePythonVersionVisitor.targetPythonVersionPattern.matcher(version); + if (!matcher.matches()) { + return null; + } + try { + return new PythonVersion(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2))); + } catch (NumberFormatException ex) { + return null; + } + } + + private static @Nullable PythonVersion parseVersion(String version) { + Matcher matcher = UpgradePythonVersionVisitor.pythonVersionPattern.matcher(version); + if (!matcher.matches()) { + return null; + } + try { + return new PythonVersion(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2))); + } catch (NumberFormatException ex) { + return null; + } + } + + @AllArgsConstructor + private static class PythonVersion implements Comparable { + private final int major; + private final int minor; + + @Override + public int compareTo(PythonVersion other) { + int majorComparison = Integer.compare(major, other.major); + return majorComparison == 0 ? Integer.compare(minor, other.minor) : majorComparison; + } + } +} diff --git a/src/main/resources/META-INF/rewrite/examples.yml b/src/main/resources/META-INF/rewrite/examples.yml index 439febe..fc34d0c 100644 --- a/src/main/resources/META-INF/rewrite/examples.yml +++ b/src/main/resources/META-INF/rewrite/examples.yml @@ -808,6 +808,42 @@ examples: language: yaml --- type: specs.openrewrite.org/v1beta/example +recipeName: org.openrewrite.github.SetupPythonUpgradePythonVersion +examples: +- description: '`SetupPythonUpgradePythonVersionTest#upgradePythonVersion`' + parameters: + - '3.14' + sources: + - before: | + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - run: python -m pytest + after: | + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - run: python -m pytest + path: .github/workflows/ci.yml + language: yaml +--- +type: specs.openrewrite.org/v1beta/example recipeName: org.openrewrite.github.UpgradeSlackNotificationVersion2 examples: - description: '`UpgradeSlackNotificationVersion2Test#updatesVersion2`' diff --git a/src/main/resources/META-INF/rewrite/recipes.csv b/src/main/resources/META-INF/rewrite/recipes.csv index ebff36f..b411fba 100644 --- a/src/main/resources/META-INF/rewrite/recipes.csv +++ b/src/main/resources/META-INF/rewrite/recipes.csv @@ -51,6 +51,7 @@ maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.Setup - `full`: Install all extras and dev dependencies (`uv sync --all-extras --dev`) See the [UV GitHub integration guide](https://docs.astral.sh/uv/guides/integration/github/) for more details.",1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""uvVersion"",""type"":""String"",""displayName"":""UV version"",""description"":""The version of the `astral-sh/setup-uv` action to use. Defaults to `v6`."",""example"":""v6""},{""name"":""syncStrategy"",""type"":""String"",""displayName"":""Sync strategy"",""description"":""Strategy for the `uv sync` command replacement."",""example"":""locked"",""valid"":[""basic"",""locked"",""full""]},{""name"":""transformPipCommands"",""type"":""Boolean"",""displayName"":""Transform pip commands"",""description"":""Whether to transform `pip install` commands to `uv` equivalents:\n- `pip install -r requirements.txt` → `uv sync`\n- `pip install .` → `uv sync`\n- `python -m pytest` → `uv run pytest`\n\nWhen disabled, only the action itself is replaced. Defaults to `true`."",""example"":""true""},{""name"":""enableCache"",""type"":""Boolean"",""displayName"":""Enable cache"",""description"":""Whether to automatically convert `cache: 'pip'` to `enable-cache: 'true'` for UV's built-in caching. When disabled, cache settings are left unchanged. Defaults to `true`."",""example"":""true""}]", +maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.SetupPythonUpgradePythonVersion,Upgrade `actions/setup-python` `python-version`,Update the Python version used by `actions/setup-python` if it is below the expected version number.,1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""version"",""type"":""String"",""displayName"":""Python version"",""description"":""The target Python version."",""example"":""3.14"",""required"":true}]", maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.UpgradeOfficialGitHubActions,Upgrade official GitHub Actions to their latest versions,"Upgrades actions from the official `actions` and `github` organizations to the newest known version, working entirely offline. Each reference is upgraded while preserving its existing precision: a major version (`v4`) moves to the newest major, a full version (`v4.1.2`) to the newest full version, and a commit SHA to the latest known commit. Actions that are not official, not known, or already up to date are left untouched.",1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,, maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.UpgradeSlackNotificationVersion2,Upgrade `slackapi/slack-github-action`,Update the Slack GitHub Action to use version 2.0.,1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,, maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.gradle.RenameGradleBuildActionToSetupGradle,Rename `gradle/gradle-build-action` to `gradle/actions/setup-gradle`,Rename the deprecated `gradle/gradle-build-action` to `gradle/actions/setup-gradle@v6`.,2,Gradle,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,, diff --git a/src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java b/src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java new file mode 100644 index 0000000..6a5b9ce --- /dev/null +++ b/src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java @@ -0,0 +1,520 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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. + */ +package org.openrewrite.github; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.yaml.Assertions.yaml; + +class SetupPythonUpgradePythonVersionTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new SetupPythonUpgradePythonVersion("3.14")); + } + + @DocumentExample + @Test + void upgradePythonVersion() { + rewriteRun( + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - run: python -m pytest + """, + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - run: python -m pytest + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void customTargetVersion() { + rewriteRun( + spec -> spec.recipe(new SetupPythonUpgradePythonVersion("3.12")), + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.10.1' + """, + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void doubleQuotedVersionPreservesStyle() { + rewriteRun( + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + """, + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void skipPythonVersionFile() { + rewriteRun( + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version-file: .python-version + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void skipWhenPythonVersionFileAndPythonVersionAreBothPresent() { + rewriteRun( + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + python-version-file: .python-version + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void preserveAlreadyCurrentAndNewerVersions() { + rewriteRun( + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - uses: actions/setup-python@v5 + with: + python-version: '3.15' + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void upgradePlainNumericYamlScalar() { + rewriteRun( + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: 3.10 + """, + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: 3.14 + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void upgradeMultipleJobsAndWorkflows() { + rewriteRun( + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.9' + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + """, + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v4 + with: + python-version: '3.14' + """, + spec -> spec.path(".github/workflows/ci.yml") + ), + yaml( + """ + name: Release + on: + workflow_dispatch: + jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.8' + """, + """ + name: Release + on: + workflow_dispatch: + jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + """, + spec -> spec.path(".github/workflows/release.yaml") + ) + ); + } + + @Test + void upgradeSafelyOlderUpperBoundRange() { + rewriteRun( + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '>=3.10 <3.14' + """, + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void upgradeSafelyOlderLessThanOrEqualRangeAndXRange() { + rewriteRun( + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '<=3.13' + - uses: actions/setup-python@v5 + with: + python-version: '3.13.x' + """, + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void upgradeSafelyOlderTildeAndMajorXRanges() { + rewriteRun( + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '~3.13.0' + - uses: actions/setup-python@v5 + with: + python-version: '~3.12' + - uses: actions/setup-python@v5 + with: + python-version: '2.x' + - uses: actions/setup-python@v5 + with: + python-version: '2.*' + """, + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void preserveCompatibleRangeAndDynamicValues() { + rewriteRun( + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '>=3.10 <3.15' + - uses: actions/setup-python@v5 + with: + python-version: '>=4 || <3.14' + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/setup-python@v5 + with: + python-version: pypy3.10 + - uses: actions/setup-python@v5 + with: + python-version: graalpy-24.0 + - uses: actions/setup-python@v5 + with: + python-version: | + 3.10 + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void ignoreOtherActionsAndNonWorkflowFiles() { + rewriteRun( + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: example/setup-python@v5 + with: + python-version: '3.10' + """, + spec -> spec.path(".github/workflows/ci.yml") + ), + yaml( + """ + jobs: + test: + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + """, + spec -> spec.path("action.yml") + ) + ); + } + + @Test + void ignoreWorkflowWithoutSetupPython() { + rewriteRun( + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: python -m pytest + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } +} From bba6df717e325fcf6d03da44928919abe5dee2a8 Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Sun, 23 Aug 2026 16:10:01 +0200 Subject: [PATCH 2/4] Compare `python-version` through `Semver` instead of hand-rolled patterns The recipe carried seven regexes and a `PythonVersion implements Comparable` class to decide whether a `python-version` was below the target. `org.openrewrite.semver.Semver` already answers both halves of that question, so delegate to it: - Concrete versions go through `Semver.compare(.., MAVEN)`. Maven precedence orders a two-part `major.minor`, which `Ecosystem.NODE` rejects as non-strict SemVer. - Ranges are decided by the comparator alone. Probing the target plus an implausibly high version separates a range bounded below the target (`<=3.13`, `~3.12`, `2.x`) from one that is open-ended or already compatible (`>=3.9`, `^3.9`, `3.x`, `>=4 || <3.14`), without any arithmetic on captured groups. One pattern remains, because that is the branch point the library cannot give us: `Semver.isVersion` returns true for `3.x` and `3.7 - 3.9`, as `RELEASE_PATTERN` swallows the trailing text into its qualifier group. The block scalar skip is now keyed on the scalar style rather than on a version pattern failing to match. A FOLDED/LITERAL value carries the block envelope, which `Yaml.Scalar#withValue` would clobber, so it is a write-safety guard rather than a version question, and it also covers the folded spelling the regex mismatch happened to miss. Also inline the visitor as an anonymous class, since it holds no per-visit state, and put a `python-version` key comparison in front of `JsonPathMatcher.matches`. That matcher re-visits the enclosing document on every call, so testing it against every mapping entry in a workflow made the cost quadratic in file size. Test indentation now follows `.editorconfig`, which sets a continuation indent of 2 for `src/test/java`. --- .../SetupPythonUpgradePythonVersion.java | 202 +---- .../SetupPythonUpgradePythonVersionTest.java | 767 ++++++++---------- 2 files changed, 385 insertions(+), 584 deletions(-) diff --git a/src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java b/src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java index 21acc6a..688d721 100644 --- a/src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java +++ b/src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java @@ -15,10 +15,8 @@ */ package org.openrewrite.github; -import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Value; -import org.jspecify.annotations.Nullable; import org.openrewrite.*; import org.openrewrite.semver.Semver; import org.openrewrite.yaml.JsonPathMatcher; @@ -28,13 +26,23 @@ import java.util.Arrays; import java.util.LinkedHashSet; import java.util.Set; -import java.util.regex.Matcher; import java.util.regex.Pattern; +import static org.openrewrite.semver.Semver.Ecosystem.MAVEN; +import static org.openrewrite.semver.Semver.Ecosystem.NODE; + @EqualsAndHashCode(callSuper = false) @Value public class SetupPythonUpgradePythonVersion extends Recipe { + private static final JsonPathMatcher PYTHON_VERSION = new JsonPathMatcher("..steps[?(@.uses =~ 'actions/setup-python@v*.*')].with.python-version"); + private static final Pattern MAJOR_MINOR = Pattern.compile("[0-9]+\\.[0-9]+"); + + // Concrete versions only; `Semver.isVersion` also accepts ranges such as `3.x` and `3.7 - 3.9` + private static final Pattern CPYTHON_VERSION = Pattern.compile("[0-9]+\\.[0-9]+(\\.[0-9]+)?([-+].*)?"); + + private static final String ABOVE_ANY_PYTHON_VERSION = "999.999.999"; + @Option(displayName = "Python version", description = "The target Python version.", example = "3.14", @@ -50,178 +58,52 @@ public class SetupPythonUpgradePythonVersion extends Recipe { @Override public Validated validate() { return super.validate() - .and(Validated.required("version", version)) .and(Validated.test("version", "must be a major.minor Python version", version, - version -> version != null && parseTargetVersion(version) != null)); + v -> v != null && MAJOR_MINOR.matcher(v).matches())); } @Override public TreeVisitor getVisitor() { - PythonVersion parsedVersion = parseTargetVersion(version); - if (parsedVersion == null) { - return TreeVisitor.noop(); - } - return Preconditions.check(new IsGitHubActionsWorkflow(), new UpgradePythonVersionVisitor(version, parsedVersion, toSemverVersion(version))); - } - - @AllArgsConstructor - private static class UpgradePythonVersionVisitor extends YamlVisitor { - private static final JsonPathMatcher pythonVersion = new JsonPathMatcher("..steps[?(@.uses =~ 'actions/setup-python@v*.*')].with.python-version"); - private static final Pattern pythonVersionPattern = Pattern.compile("([0-9]+)\\.([0-9]+)(?:\\.[0-9]+)?(?:[-+][0-9A-Za-z.-]+)?"); - private static final Pattern targetPythonVersionPattern = Pattern.compile("([0-9]+)\\.([0-9]+)"); - private static final Pattern lowerBoundPattern = Pattern.compile("(?:^|[\\s,])(?:>=|>)\\s*([0-9]+)\\.([0-9]+)(?:\\.[0-9]+)*(?:[-+][^\\s,]+)?"); - private static final Pattern upperBoundPattern = Pattern.compile("(?:^|[\\s,])<=?\\s*([0-9]+)\\.([0-9]+)(?:\\.[0-9]+)*(?:[-+][^\\s,]+)?"); - private static final Pattern hyphenRangePattern = Pattern.compile("(?:^|\\s)[0-9]+\\.[0-9]+(?:\\.[0-9]+)*\\s+-\\s+([0-9]+)\\.([0-9]+)(?:\\.[0-9]+)*(?:\\s|$)"); - private static final Pattern xRangePattern = Pattern.compile("([0-9]+)\\.([0-9]+)\\.(?:x|X|\\*)"); - private static final Pattern majorXRangePattern = Pattern.compile("([0-9]+)\\.(?:x|X|\\*)"); - private static final Pattern tildeRangePattern = Pattern.compile("~\\s*([0-9]+)\\.([0-9]+)(?:\\.[0-9]+)?"); - - private final String version; - private final PythonVersion parsedVersion; - private final String semverVersion; - - @Override - public Yaml visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) { - if (!pythonVersion.matches(getCursor()) || hasPythonVersionFile()) { - return super.visitMappingEntry(entry, ctx); - } - - if (!(entry.getValue() instanceof Yaml.Scalar)) { - return super.visitMappingEntry(entry, ctx); - } - - Yaml.Scalar currentValue = (Yaml.Scalar) entry.getValue(); - if (!isSafelyBelowTarget(currentValue.getValue(), parsedVersion, semverVersion)) { - return super.visitMappingEntry(entry, ctx); - } - - return super.visitMappingEntry( - entry.withValue(currentValue.withValue(version)), - ctx - ); - } - - private boolean hasPythonVersionFile() { - Object parent = getCursor().getParentOrThrow().getValue(); - if (!(parent instanceof Yaml.Mapping)) { - return false; - } - for (Yaml.Mapping.Entry entry : ((Yaml.Mapping) parent).getEntries()) { - if ("python-version-file".equals(entry.getKey().getValue())) { - return true; + return Preconditions.check(new IsGitHubActionsWorkflow(), new YamlVisitor() { + @Override + public Yaml visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) { + if (!"python-version".equals(entry.getKey().getValue()) || + !(entry.getValue() instanceof Yaml.Scalar) || + !PYTHON_VERSION.matches(getCursor()) || + hasPythonVersionFile()) { + return super.visitMappingEntry(entry, ctx); } - } - return false; - } - - private static boolean isSafelyBelowTarget(String currentVersion, PythonVersion targetVersion, String semverVersion) { - PythonVersion exactVersion = parseVersion(currentVersion); - if (exactVersion != null) { - return exactVersion.compareTo(targetVersion) < 0; - } - - if (currentVersion.contains("||") || - Semver.validate(currentVersion, null, Semver.Ecosystem.NODE).isInvalid() || - Semver.satisfies(semverVersion, currentVersion, Semver.Ecosystem.NODE)) { - return false; - } - Matcher lowerBound = lowerBoundPattern.matcher(currentVersion); - while (lowerBound.find()) { - PythonVersion lowerBoundVersion = new PythonVersion( - Integer.parseInt(lowerBound.group(1)), - Integer.parseInt(lowerBound.group(2)) - ); - if (lowerBoundVersion.compareTo(targetVersion) >= 0) { - return false; + Yaml.Scalar currentValue = (Yaml.Scalar) entry.getValue(); + // The value of a block scalar carries the block envelope, which `withValue` would clobber + if (currentValue.getStyle() == Yaml.Scalar.Style.LITERAL || + currentValue.getStyle() == Yaml.Scalar.Style.FOLDED) { + return super.visitMappingEntry(entry, ctx); } - } - - Matcher xRange = xRangePattern.matcher(currentVersion); - if (xRange.matches()) { - PythonVersion xRangeVersion = new PythonVersion( - Integer.parseInt(xRange.group(1)), - Integer.parseInt(xRange.group(2)) - ); - return xRangeVersion.compareTo(targetVersion) < 0; - } - Matcher majorXRange = majorXRangePattern.matcher(currentVersion); - if (majorXRange.matches()) { - return Integer.parseInt(majorXRange.group(1)) < targetVersion.major; - } - - Matcher tildeRange = tildeRangePattern.matcher(currentVersion); - if (tildeRange.matches()) { - PythonVersion tildeRangeVersion = new PythonVersion( - Integer.parseInt(tildeRange.group(1)), - Integer.parseInt(tildeRange.group(2)) - ); - return tildeRangeVersion.compareTo(targetVersion) < 0; - } - - Matcher upperBound = upperBoundPattern.matcher(currentVersion); - while (upperBound.find()) { - PythonVersion upperBoundVersion = new PythonVersion( - Integer.parseInt(upperBound.group(1)), - Integer.parseInt(upperBound.group(2)) - ); - if (upperBoundVersion.compareTo(targetVersion) <= 0) { - return true; + if (!isBelowTarget(currentValue.getValue())) { + return super.visitMappingEntry(entry, ctx); } - } - Matcher hyphenRange = hyphenRangePattern.matcher(currentVersion); - while (hyphenRange.find()) { - PythonVersion upperBoundVersion = new PythonVersion( - Integer.parseInt(hyphenRange.group(1)), - Integer.parseInt(hyphenRange.group(2)) - ); - if (upperBoundVersion.compareTo(targetVersion) < 0) { - return true; - } + return super.visitMappingEntry(entry.withValue(currentValue.withValue(version)), ctx); } - return false; - } - } - private static String toSemverVersion(String version) { - return version + ".0"; - } - - private static @Nullable PythonVersion parseTargetVersion(String version) { - Matcher matcher = UpgradePythonVersionVisitor.targetPythonVersionPattern.matcher(version); - if (!matcher.matches()) { - return null; - } - try { - return new PythonVersion(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2))); - } catch (NumberFormatException ex) { - return null; - } - } - - private static @Nullable PythonVersion parseVersion(String version) { - Matcher matcher = UpgradePythonVersionVisitor.pythonVersionPattern.matcher(version); - if (!matcher.matches()) { - return null; - } - try { - return new PythonVersion(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2))); - } catch (NumberFormatException ex) { - return null; - } + private boolean hasPythonVersionFile() { + Yaml.Mapping with = getCursor().getParentOrThrow().getValue(); + return with.getEntries().stream() + .anyMatch(e -> "python-version-file".equals(e.getKey().getValue())); + } + }); } - @AllArgsConstructor - private static class PythonVersion implements Comparable { - private final int major; - private final int minor; - - @Override - public int compareTo(PythonVersion other) { - int majorComparison = Integer.compare(major, other.major); - return majorComparison == 0 ? Integer.compare(minor, other.minor) : majorComparison; + private boolean isBelowTarget(String currentVersion) { + // Maven precedence orders `major.minor`, which is not strict SemVer; ranges follow the npm grammar `setup-python` documents + if (CPYTHON_VERSION.matcher(currentVersion).matches()) { + return Semver.compare(currentVersion, version, MAVEN) < 0; } + // No comparator exposes a range's upper bound, so a range is only raised when it admits neither the target nor an implausibly high version + return Semver.validate(currentVersion, null, NODE).isValid() && + !Semver.satisfies(version + ".0", currentVersion, NODE) && + !Semver.satisfies(ABOVE_ANY_PYTHON_VERSION, currentVersion, NODE); } } diff --git a/src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java b/src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java index 6a5b9ce..676b20e 100644 --- a/src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java +++ b/src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java @@ -33,488 +33,407 @@ public void defaults(RecipeSpec spec) { @Test void upgradePythonVersion() { rewriteRun( - yaml( - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - run: python -m pytest - """, - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.14' - - run: python -m pytest - """, - spec -> spec.path(".github/workflows/ci.yml") - ) + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - run: python -m pytest + """, + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - run: python -m pytest + """, + spec -> spec.path(".github/workflows/ci.yml") + ) ); } @Test void customTargetVersion() { rewriteRun( - spec -> spec.recipe(new SetupPythonUpgradePythonVersion("3.12")), - yaml( - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: '3.10.1' - """, - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - """, - spec -> spec.path(".github/workflows/ci.yml") - ) + spec -> spec.recipe(new SetupPythonUpgradePythonVersion("3.12")), + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.10.1' + """, + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + """, + spec -> spec.path(".github/workflows/ci.yml") + ) ); } @Test - void doubleQuotedVersionPreservesStyle() { + void preserveScalarStyle() { rewriteRun( - yaml( - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: "3.10" - """, - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: "3.14" - """, - spec -> spec.path(".github/workflows/ci.yml") - ) + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - uses: actions/setup-python@v5 + with: + python-version: 3.10 + """, + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + - uses: actions/setup-python@v5 + with: + python-version: 3.14 + """, + spec -> spec.path(".github/workflows/ci.yml") + ) ); } @Test void skipPythonVersionFile() { rewriteRun( - yaml( - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version-file: .python-version - """, - spec -> spec.path(".github/workflows/ci.yml") - ) + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version-file: .python-version + """, + spec -> spec.path(".github/workflows/ci.yml") + ) ); } @Test void skipWhenPythonVersionFileAndPythonVersionAreBothPresent() { rewriteRun( - yaml( - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - python-version-file: .python-version - """, - spec -> spec.path(".github/workflows/ci.yml") - ) + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + python-version-file: .python-version + """, + spec -> spec.path(".github/workflows/ci.yml") + ) ); } @Test void preserveAlreadyCurrentAndNewerVersions() { rewriteRun( - yaml( - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: '3.14' - - uses: actions/setup-python@v5 - with: - python-version: '3.15' - """, - spec -> spec.path(".github/workflows/ci.yml") - ) - ); - } - - @Test - void upgradePlainNumericYamlScalar() { - rewriteRun( - yaml( - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: 3.10 - """, - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: 3.14 - """, - spec -> spec.path(".github/workflows/ci.yml") - ) + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - uses: actions/setup-python@v5 + with: + python-version: '3.15' + """, + spec -> spec.path(".github/workflows/ci.yml") + ) ); } @Test void upgradeMultipleJobsAndWorkflows() { rewriteRun( - yaml( - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: '3.9' - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v4 - with: - python-version: '3.11' - """, - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: '3.14' - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v4 - with: - python-version: '3.14' - """, - spec -> spec.path(".github/workflows/ci.yml") - ), - yaml( - """ - name: Release - on: - workflow_dispatch: - jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: '3.8' - """, - """ - name: Release - on: - workflow_dispatch: - jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: '3.14' - """, - spec -> spec.path(".github/workflows/release.yaml") - ) - ); - } - - @Test - void upgradeSafelyOlderUpperBoundRange() { - rewriteRun( - yaml( - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: '>=3.10 <3.14' - """, - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: '3.14' - """, - spec -> spec.path(".github/workflows/ci.yml") - ) - ); - } - - @Test - void upgradeSafelyOlderLessThanOrEqualRangeAndXRange() { - rewriteRun( - yaml( - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: '<=3.13' - - uses: actions/setup-python@v5 - with: - python-version: '3.13.x' - """, - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: '3.14' - - uses: actions/setup-python@v5 - with: - python-version: '3.14' - """, - spec -> spec.path(".github/workflows/ci.yml") - ) + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.9' + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + """, + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v4 + with: + python-version: '3.14' + """, + spec -> spec.path(".github/workflows/ci.yml") + ), + yaml( + """ + name: Release + on: + workflow_dispatch: + jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.8' + """, + """ + name: Release + on: + workflow_dispatch: + jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + """, + spec -> spec.path(".github/workflows/release.yaml") + ) ); } @Test - void upgradeSafelyOlderTildeAndMajorXRanges() { + void upgradeSafelyOlderRanges() { rewriteRun( - yaml( - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: '~3.13.0' - - uses: actions/setup-python@v5 - with: - python-version: '~3.12' - - uses: actions/setup-python@v5 - with: - python-version: '2.x' - - uses: actions/setup-python@v5 - with: - python-version: '2.*' - """, - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: '3.14' - - uses: actions/setup-python@v5 - with: - python-version: '3.14' - - uses: actions/setup-python@v5 - with: - python-version: '3.14' - - uses: actions/setup-python@v5 - with: - python-version: '3.14' - """, - spec -> spec.path(".github/workflows/ci.yml") - ) + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '>=3.10 <3.14' + - uses: actions/setup-python@v5 + with: + python-version: '<=3.13' + - uses: actions/setup-python@v5 + with: + python-version: '3.13.x' + - uses: actions/setup-python@v5 + with: + python-version: '~3.13.0' + - uses: actions/setup-python@v5 + with: + python-version: '~3.12' + - uses: actions/setup-python@v5 + with: + python-version: '2.x' + - uses: actions/setup-python@v5 + with: + python-version: '2.*' + """, + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + """, + spec -> spec.path(".github/workflows/ci.yml") + ) ); } @Test void preserveCompatibleRangeAndDynamicValues() { rewriteRun( - yaml( - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v5 - with: - python-version: '>=3.10 <3.15' - - uses: actions/setup-python@v5 - with: - python-version: '>=4 || <3.14' - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - uses: actions/setup-python@v5 - with: - python-version: pypy3.10 - - uses: actions/setup-python@v5 - with: - python-version: graalpy-24.0 - - uses: actions/setup-python@v5 - with: - python-version: | - 3.10 - """, - spec -> spec.path(".github/workflows/ci.yml") - ) + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '>=3.10 <3.15' + - uses: actions/setup-python@v5 + with: + python-version: '>=4 || <3.14' + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/setup-python@v5 + with: + python-version: pypy3.10 + - uses: actions/setup-python@v5 + with: + python-version: graalpy-24.0 + - uses: actions/setup-python@v5 + with: + python-version: | + 3.10 + """, + spec -> spec.path(".github/workflows/ci.yml") + ) ); } @Test void ignoreOtherActionsAndNonWorkflowFiles() { rewriteRun( - yaml( - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: example/setup-python@v5 - with: - python-version: '3.10' - """, - spec -> spec.path(".github/workflows/ci.yml") - ), - yaml( - """ - jobs: - test: - steps: - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - """, - spec -> spec.path("action.yml") - ) + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: example/setup-python@v5 + with: + python-version: '3.10' + """, + spec -> spec.path(".github/workflows/ci.yml") + ), + yaml( + """ + jobs: + test: + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + """, + spec -> spec.path("action.yml") + ) ); } @Test void ignoreWorkflowWithoutSetupPython() { rewriteRun( - yaml( - """ - name: CI - on: - pull_request: - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: python -m pytest - """, - spec -> spec.path(".github/workflows/ci.yml") - ) + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: python -m pytest + """, + spec -> spec.path(".github/workflows/ci.yml") + ) ); } } From a9562af44fcdd528272a98883c3d82a06095e6a3 Mon Sep 17 00:00:00 2001 From: poketopa Date: Sun, 23 Aug 2026 23:42:42 +0900 Subject: [PATCH 3/4] Avoid downgrading newer Python ranges --- .../github/SetupPythonUpgradePythonVersion.java | 12 ++++++++++++ .../SetupPythonUpgradePythonVersionTest.java | 14 +++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java b/src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java index 688d721..6fa7c76 100644 --- a/src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java +++ b/src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java @@ -26,6 +26,7 @@ import java.util.Arrays; import java.util.LinkedHashSet; import java.util.Set; +import java.util.regex.Matcher; import java.util.regex.Pattern; import static org.openrewrite.semver.Semver.Ecosystem.MAVEN; @@ -40,6 +41,7 @@ public class SetupPythonUpgradePythonVersion extends Recipe { // Concrete versions only; `Semver.isVersion` also accepts ranges such as `3.x` and `3.7 - 3.9` private static final Pattern CPYTHON_VERSION = Pattern.compile("[0-9]+\\.[0-9]+(\\.[0-9]+)?([-+].*)?"); + private static final Pattern NON_UPPER_BOUND_RANGE_VERSION = Pattern.compile("(?:^|[\\s,])(?:>=|>|[~^])?\\s*[v=]?([0-9]+(?:\\.[0-9]+){0,2})"); private static final String ABOVE_ANY_PYTHON_VERSION = "999.999.999"; @@ -101,6 +103,16 @@ private boolean isBelowTarget(String currentVersion) { if (CPYTHON_VERSION.matcher(currentVersion).matches()) { return Semver.compare(currentVersion, version, MAVEN) < 0; } + // A union or any lower/base version at or above the target could select a newer Python and must not be downgraded + if (currentVersion.contains("||")) { + return false; + } + Matcher rangeVersion = NON_UPPER_BOUND_RANGE_VERSION.matcher(currentVersion); + while (rangeVersion.find()) { + if (Semver.compare(rangeVersion.group(1), version, MAVEN) >= 0) { + return false; + } + } // No comparator exposes a range's upper bound, so a range is only raised when it admits neither the target nor an implausibly high version return Semver.validate(currentVersion, null, NODE).isValid() && !Semver.satisfies(version + ".0", currentVersion, NODE) && diff --git a/src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java b/src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java index 676b20e..0834faa 100644 --- a/src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java +++ b/src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java @@ -349,7 +349,7 @@ void upgradeSafelyOlderRanges() { } @Test - void preserveCompatibleRangeAndDynamicValues() { + void preserveNonOlderRangesAndDynamicValues() { rewriteRun( yaml( """ @@ -366,6 +366,18 @@ void preserveCompatibleRangeAndDynamicValues() { - uses: actions/setup-python@v5 with: python-version: '>=4 || <3.14' + - uses: actions/setup-python@v5 + with: + python-version: '>3.14 <3.15' + - uses: actions/setup-python@v5 + with: + python-version: '>=3.15 <4' + - uses: actions/setup-python@v5 + with: + python-version: '3.15.x' + - uses: actions/setup-python@v5 + with: + python-version: '~3.15' - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} From e97f03562fe75b7dcdc9eb410b98740b5f1e6f62 Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Sun, 23 Aug 2026 16:57:39 +0200 Subject: [PATCH 4/4] Raise a `python-version` written as a lone block scalar `python-version: |` followed by a single version was left untouched, because the recipe skipped every FOLDED/LITERAL scalar. That skip was needed for write safety rather than for any version reason: the raw `Yaml.Scalar#value` of a block scalar carries the envelope, so `withValue("3.14")` collapsed the whole thing to `python-version: |3.14`. `BlockScalar` exists for exactly this, and reads and writes the body without touching the envelope. Use it, so a lone version in a block is raised like any other. A block holding several versions is still left alone. That is a deliberate test matrix, and raising each entry below the target would just repeat the target. --- .../SetupPythonUpgradePythonVersion.java | 23 +++++---- .../SetupPythonUpgradePythonVersionTest.java | 49 +++++++++++++++++++ 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java b/src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java index 6fa7c76..d6d9b3b 100644 --- a/src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java +++ b/src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java @@ -21,10 +21,12 @@ import org.openrewrite.semver.Semver; import org.openrewrite.yaml.JsonPathMatcher; import org.openrewrite.yaml.YamlVisitor; +import org.openrewrite.yaml.trait.BlockScalar; import org.openrewrite.yaml.tree.Yaml; import java.util.Arrays; import java.util.LinkedHashSet; +import java.util.Optional; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -77,17 +79,20 @@ public Yaml visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) { } Yaml.Scalar currentValue = (Yaml.Scalar) entry.getValue(); - // The value of a block scalar carries the block envelope, which `withValue` would clobber - if (currentValue.getStyle() == Yaml.Scalar.Style.LITERAL || - currentValue.getStyle() == Yaml.Scalar.Style.FOLDED) { - return super.visitMappingEntry(entry, ctx); - } - - if (!isBelowTarget(currentValue.getValue())) { - return super.visitMappingEntry(entry, ctx); + // A block scalar's raw value carries the block envelope, so its body is read and written through the trait + Optional blockScalar = new BlockScalar.Matcher().get(currentValue, getCursor()); + if (blockScalar.isPresent()) { + BlockScalar block = blockScalar.get(); + String body = block.getBody(); + // Several versions in one block is a deliberate matrix, rather than a single version to raise + if (!body.contains("\n") && isBelowTarget(body)) { + return super.visitMappingEntry(entry.withValue(block.withBody(version)), ctx); + } + } else if (isBelowTarget(currentValue.getValue())) { + return super.visitMappingEntry(entry.withValue(currentValue.withValue(version)), ctx); } - return super.visitMappingEntry(entry.withValue(currentValue.withValue(version)), ctx); + return super.visitMappingEntry(entry, ctx); } private boolean hasPythonVersionFile() { diff --git a/src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java b/src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java index 0834faa..049a6ca 100644 --- a/src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java +++ b/src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java @@ -387,10 +387,59 @@ void preserveNonOlderRangesAndDynamicValues() { - uses: actions/setup-python@v5 with: python-version: graalpy-24.0 + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void upgradeLoneBlockScalarVersionButNotAMatrix() { + rewriteRun( + yaml( + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: | + 3.10 + - uses: actions/setup-python@v5 + with: + python-version: > + 3.11 + - uses: actions/setup-python@v5 + with: + python-version: | + 3.10 + 3.11 + """, + """ + name: CI + on: + pull_request: + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: | + 3.14 + - uses: actions/setup-python@v5 + with: + python-version: > + 3.14 - uses: actions/setup-python@v5 with: python-version: | 3.10 + 3.11 """, spec -> spec.path(".github/workflows/ci.yml") )