Skip to content
Open
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
19 changes: 16 additions & 3 deletions src/main/java/org/openrewrite/github/ChangeAction.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@

import lombok.EqualsAndHashCode;
import lombok.Value;
import org.jspecify.annotations.Nullable;
import org.openrewrite.*;
import org.openrewrite.yaml.ChangeValue;

@EqualsAndHashCode(callSuper = false)
@Value
Expand All @@ -38,6 +38,18 @@ public class ChangeAction extends Recipe {
example = "v3")
String newVersion;

@Option(displayName = "Old commit SHA",
description = "Restricts the change by the existing `uses:` ref. When omitted, the " +
"action is changed regardless of how it is pinned (the default; commit SHA pins " +
"are rewritten). When set to an empty string, only references that are **not** " +
"pinned to a 40-character commit SHA are changed, leaving deliberate SHA pins on " +
"the original action untouched. When set to a specific commit SHA, only " +
"references pinned to exactly that SHA are changed.",
required = false,
example = "8f4b7f84864484a7bf31766abe9204da3cbe65b3")
@Nullable
String oldSha;

String displayName = "Change GitHub Action";

String description = "Change a GitHub Action in any workflow.";
Expand All @@ -46,8 +58,9 @@ public class ChangeAction extends Recipe {
public TreeVisitor<?, ExecutionContext> getVisitor() {
return Preconditions.check(
new IsGitHubActionsWorkflow(),
new ChangeValue(
new ChangeUsesVisitor(
"$.jobs..[?(@.uses =~ '" + oldAction + "(?:@.+)?')].uses",
newAction + '@' + newVersion, null).getVisitor());
oldSha,
current -> newAction + '@' + newVersion));
}
}
46 changes: 17 additions & 29 deletions src/main/java/org/openrewrite/github/ChangeActionVersion.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,8 @@

import lombok.EqualsAndHashCode;
import lombok.Value;
import org.jspecify.annotations.Nullable;
import org.openrewrite.*;
import org.openrewrite.yaml.ChangeValue;
import org.openrewrite.yaml.YamlIsoVisitor;
import org.openrewrite.yaml.search.FindKey;
import org.openrewrite.yaml.tree.Yaml;

@EqualsAndHashCode(callSuper = false)
@Value
Expand All @@ -36,6 +33,18 @@ public class ChangeActionVersion extends Recipe {
example = "v4")
String version;

@Option(displayName = "Old commit SHA",
description = "Restricts the change by the existing `uses:` ref. When omitted, the " +
"version is changed regardless of how the action is pinned (the default; commit " +
"SHA pins are rewritten). When set to an empty string, only references that are " +
"**not** pinned to a 40-character commit SHA are changed, preserving deliberate " +
"SHA pins. When set to a specific commit SHA, only references pinned to exactly " +
"that SHA are changed.",
required = false,
example = "8f4b7f84864484a7bf31766abe9204da3cbe65b3")
@Nullable
String oldSha;

String displayName = "Change GitHub Action version";

String description = "Change the version of a GitHub Action in any workflow.";
Expand All @@ -44,30 +53,9 @@ public class ChangeActionVersion extends Recipe {
public TreeVisitor<?, ExecutionContext> getVisitor() {
return Preconditions.check(
new IsGitHubActionsWorkflow(),
new YamlIsoVisitor<ExecutionContext>() {
@Override
public Yaml.Documents visitDocuments(Yaml.Documents documents, ExecutionContext ctx) {
Yaml.Documents docs = super.visitDocuments(documents, ctx);
// Find all 'uses' entries that match the specified action
for (Yaml uses : FindKey.find(docs, "$.jobs..[?(@.uses =~ '" + action + "(?:@.+)?')].uses")) {
if (!(uses instanceof Yaml.Mapping.Entry)) {
continue;
}
if (!(((Yaml.Mapping.Entry) uses).getValue() instanceof Yaml.Scalar)) {
continue;
}

// Extract the old action name (without version), and replace with the new version
String oldAction = ((Yaml.Scalar) ((Yaml.Mapping.Entry) uses).getValue()).getValue().split("@")[0];
docs = (Yaml.Documents) new ChangeValue(
"$.jobs..[?(@.uses =~ '" + oldAction + "(?:@.+)?')].uses",
oldAction + '@' + version, null)
.getVisitor()
.visitNonNull(docs, ctx);
}
return docs;
}
}
);
new ChangeUsesVisitor(
"$.jobs..[?(@.uses =~ '" + action + "(?:@.+)?')].uses",
oldSha,
current -> current.split("@", 2)[0] + '@' + version));
}
}
68 changes: 68 additions & 0 deletions src/main/java/org/openrewrite/github/ChangeUsesVisitor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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 org.jspecify.annotations.Nullable;
import org.openrewrite.ExecutionContext;
import org.openrewrite.yaml.JsonPathMatcher;
import org.openrewrite.yaml.YamlIsoVisitor;
import org.openrewrite.yaml.tree.Yaml;

import java.util.function.UnaryOperator;

/**
* Rewrites the value of the {@code uses:} entries selected by a JsonPath, applying a per-entry
* value transform. Entries are only rewritten when {@link UsesRefs#matchesOldSha} accepts the
* current value, so SHA pins can be preserved via the {@code oldSha} sentinel. Shared by
* {@link ChangeAction} and {@link ChangeActionVersion}; the two recipes differ only in the
* {@code rename} function they supply.
*/
class ChangeUsesVisitor extends YamlIsoVisitor<ExecutionContext> {

private final JsonPathMatcher matcher;

@Nullable
private final String oldSha;

private final UnaryOperator<String> rename;

ChangeUsesVisitor(String usesPath, @Nullable String oldSha, UnaryOperator<String> rename) {
this.matcher = new JsonPathMatcher(usesPath);
this.oldSha = oldSha;
this.rename = rename;
}

@Override
public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) {
Yaml.Mapping.Entry e = super.visitMappingEntry(entry, ctx);
if (!(matcher.matches(getCursor()) && e.getValue() instanceof Yaml.Scalar)) {
return e;
}

Yaml.Scalar scalar = (Yaml.Scalar) e.getValue();
String current = scalar.getValue();
if (!UsesRefs.matchesOldSha(oldSha, current)) {
return e;
}

String newValue = rename.apply(current);
if (newValue.equals(current)) {
return e;
}

return e.withValue(scalar.withValue(newValue));
}
}
62 changes: 62 additions & 0 deletions src/main/java/org/openrewrite/github/UsesRefs.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* 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 org.jspecify.annotations.Nullable;

import java.util.regex.Pattern;

/**
* Helpers for the ref portion of a GitHub Actions {@code uses:} reference
* (e.g. the {@code v4} or commit SHA in {@code owner/repo@v4}).
*/
final class UsesRefs {

private static final Pattern SHA_PATTERN = Pattern.compile("^[a-f0-9]{40}$");

private UsesRefs() {
}

/**
* @return the ref after the first {@code @} in a {@code uses:} value, or {@code null} when the
* value has no {@code @}.
*/
static @Nullable String refOf(String usesValue) {
int at = usesValue.indexOf('@');
return at < 0 ? null : usesValue.substring(at + 1);
}

/**
* Decide whether a {@code uses:} value should be changed, given an {@code oldSha} sentinel that
* mirrors the Docker {@code ChangeFrom} {@code oldDigest} option:
* <ul>
* <li>{@code null} — change regardless of how the action is pinned (the default).</li>
* <li>empty string — change only refs that are <em>not</em> a 40-character commit SHA,
* preserving deliberate SHA pins.</li>
* <li>a concrete value — change only the entry whose ref equals that value.</li>
* </ul>
*/
static boolean matchesOldSha(@Nullable String oldSha, String usesValue) {
String ref = refOf(usesValue);
if (oldSha == null) {
return true;
}
if (oldSha.isEmpty()) {
return ref == null || !SHA_PATTERN.matcher(ref).matches();
}
return ref != null && ref.equals(oldSha);
}
}
Loading
Loading