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..d6d9b3b
--- /dev/null
+++ b/src/main/java/org/openrewrite/github/SetupPythonUpgradePythonVersion.java
@@ -0,0 +1,126 @@
+/*
+ * 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.EqualsAndHashCode;
+import lombok.Value;
+import org.openrewrite.*;
+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;
+
+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 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";
+
+ @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.test("version", "must be a major.minor Python version", version,
+ v -> v != null && MAJOR_MINOR.matcher(v).matches()));
+ }
+
+ @Override
+ public TreeVisitor, ExecutionContext> getVisitor() {
+ 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);
+ }
+
+ Yaml.Scalar currentValue = (Yaml.Scalar) entry.getValue();
+ // 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, ctx);
+ }
+
+ private boolean hasPythonVersionFile() {
+ Yaml.Mapping with = getCursor().getParentOrThrow().getValue();
+ return with.getEntries().stream()
+ .anyMatch(e -> "python-version-file".equals(e.getKey().getValue()));
+ }
+ });
+ }
+
+ 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;
+ }
+ // 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) &&
+ !Semver.satisfies(ABOVE_ANY_PYTHON_VERSION, currentVersion, NODE);
+ }
+}
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..049a6ca
--- /dev/null
+++ b/src/test/java/org/openrewrite/github/SetupPythonUpgradePythonVersionTest.java
@@ -0,0 +1,500 @@
+/*
+ * 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 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"
+ - 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")
+ )
+ );
+ }
+
+ @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 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 upgradeSafelyOlderRanges() {
+ 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'
+ - 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 preserveNonOlderRangesAndDynamicValues() {
+ 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: '>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 }}
+ - uses: actions/setup-python@v5
+ with:
+ python-version: pypy3.10
+ - 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")
+ )
+ );
+ }
+
+ @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")
+ )
+ );
+ }
+}