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 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") + ) + ); + } +}