Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* Copyright 2025 the original author or authors.
* <p>
* 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
* <p>
* https://docs.moderne.io/licensing/moderne-source-available-license
* <p>
* 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<String> tags = new LinkedHashSet<>(Arrays.asList("github", "python", "deprecation"));

@Override
public Validated<Object> 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<ExecutionContext>() {
@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> 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);
}
}
36 changes: 36 additions & 0 deletions src/main/resources/META-INF/rewrite/examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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`'
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/META-INF/rewrite/recipes.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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.,,
Expand Down
Loading
Loading