diff --git a/src/main/java/org/openrewrite/github/security/ArtifactSecurityRecipe.java b/src/main/java/org/openrewrite/github/security/ArtifactSecurityRecipe.java
index 1cf387e..ddb39e3 100644
--- a/src/main/java/org/openrewrite/github/security/ArtifactSecurityRecipe.java
+++ b/src/main/java/org/openrewrite/github/security/ArtifactSecurityRecipe.java
@@ -19,6 +19,7 @@
import lombok.Value;
import org.jspecify.annotations.Nullable;
import org.openrewrite.*;
+import org.openrewrite.github.util.YamlScalarAccessor;
import org.openrewrite.marker.SearchResult;
import org.openrewrite.yaml.JsonPathMatcher;
import org.openrewrite.yaml.YamlIsoVisitor;
@@ -78,7 +79,7 @@ public TreeVisitor, ExecutionContext> getVisitor() {
);
}
- private static class ArtifactSecurityVisitor extends YamlIsoVisitor {
+ private static class ArtifactSecurityVisitor extends YamlIsoVisitor implements YamlScalarAccessor {
private static final JsonPathMatcher STEP_USES_MATCHER = new JsonPathMatcher("$..steps[*].uses");
@@ -108,7 +109,7 @@ public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionC
private Yaml.Mapping.Entry checkUsesEntry(Yaml.Mapping.Entry entry) {
- String usesValue = YamlHelper.getScalarValue(entry.getValue());
+ String usesValue = getScalarValue(entry.getValue());
if (usesValue == null) {
return entry;
}
@@ -133,7 +134,7 @@ private Yaml.Mapping.Entry checkCheckoutAction(Yaml.Mapping.Entry entry) {
return entry;
}
- String persistCredentials = YamlHelper.findNestedScalarValue(stepMapping, "with", "persist-credentials");
+ String persistCredentials = findNestedScalarValue(stepMapping, "with", "persist-credentials");
if (persistCredentials == null) {
// No 'with' section or no persist-credentials means default behavior (persist-credentials: true)
@@ -159,7 +160,7 @@ private Yaml.Mapping.Entry checkUploadArtifactAction(Yaml.Mapping.Entry entry) {
return entry;
}
- String pathValue = YamlHelper.findNestedScalarValue(stepMapping, "with", "path");
+ String pathValue = findNestedScalarValue(stepMapping, "with", "path");
if (pathValue != null && hasDangerousArtifactPaths(pathValue)) {
return SearchResult.found(entry,
"Uploading potentially sensitive paths that may contain credentials or configuration files.");
diff --git a/src/main/java/org/openrewrite/github/security/CachePoisoningRecipe.java b/src/main/java/org/openrewrite/github/security/CachePoisoningRecipe.java
index 08fca92..451a7aa 100644
--- a/src/main/java/org/openrewrite/github/security/CachePoisoningRecipe.java
+++ b/src/main/java/org/openrewrite/github/security/CachePoisoningRecipe.java
@@ -20,6 +20,7 @@
import org.openrewrite.ExecutionContext;
import org.openrewrite.Recipe;
import org.openrewrite.TreeVisitor;
+import org.openrewrite.github.util.YamlScalarAccessor;
import org.openrewrite.marker.SearchResult;
import org.openrewrite.yaml.YamlIsoVisitor;
import org.openrewrite.yaml.tree.Yaml;
@@ -90,7 +91,7 @@ public TreeVisitor, ExecutionContext> getVisitor() {
return new CachePoisoningVisitor();
}
- private static class CachePoisoningVisitor extends YamlIsoVisitor {
+ private static class CachePoisoningVisitor extends YamlIsoVisitor implements YamlScalarAccessor {
private boolean isPublishingWorkflow = false;
private boolean hasPublisherAction = false;
@@ -131,14 +132,14 @@ private void analyzeWorkflow(Yaml.Document document) {
}
private boolean isPublishingTrigger(Yaml.Block onValue) {
- String scalarTrigger = YamlHelper.getScalarValue(onValue);
+ String scalarTrigger = getScalarValue(onValue);
if (scalarTrigger != null) {
return "release".equals(scalarTrigger);
}
if (onValue instanceof Yaml.Sequence) {
Yaml.Sequence sequence = (Yaml.Sequence) onValue;
for (Yaml.Sequence.Entry seqEntry : sequence.getEntries()) {
- String trigger = YamlHelper.getScalarValue(seqEntry.getBlock());
+ String trigger = getScalarValue(seqEntry.getBlock());
if ("release".equals(trigger)) {
return true;
}
@@ -184,7 +185,7 @@ private boolean hasReleaseBranches(Yaml.Block branchesValue) {
if (branchesValue instanceof Yaml.Sequence) {
Yaml.Sequence sequence = (Yaml.Sequence) branchesValue;
for (Yaml.Sequence.Entry entry : sequence.getEntries()) {
- String branch = YamlHelper.getScalarValue(entry.getBlock());
+ String branch = getScalarValue(entry.getBlock());
if (branch != null && RELEASE_BRANCH_PATTERN.matcher(branch).matches()) {
return true;
}
@@ -230,7 +231,7 @@ private boolean jobHasPublisherAction(Yaml.Mapping jobMapping) {
private boolean stepUsesPublisherAction(Yaml.Mapping stepMapping) {
for (Yaml.Mapping.Entry entry : stepMapping.getEntries()) {
if ("uses".equals(entry.getKey().getValue())) {
- String uses = YamlHelper.getScalarValue(entry.getValue());
+ String uses = getScalarValue(entry.getValue());
if (uses != null) {
String actionName = extractActionName(uses);
return PUBLISHER_ACTIONS.contains(actionName);
@@ -262,7 +263,7 @@ private boolean isCacheAwareActionStep(Yaml.Mapping.Entry entry) {
return false;
}
- String uses = YamlHelper.getScalarValue(entry.getValue());
+ String uses = getScalarValue(entry.getValue());
if (uses == null) {
return false;
}
@@ -272,7 +273,7 @@ private boolean isCacheAwareActionStep(Yaml.Mapping.Entry entry) {
}
private String getActionName(Yaml.Mapping.Entry entry) {
- String uses = YamlHelper.getScalarValue(entry.getValue());
+ String uses = getScalarValue(entry.getValue());
return uses != null ? extractActionName(uses) : "unknown";
}
diff --git a/src/main/java/org/openrewrite/github/security/ExcessivePermissionsRecipe.java b/src/main/java/org/openrewrite/github/security/ExcessivePermissionsRecipe.java
index 3dedd87..07b6658 100644
--- a/src/main/java/org/openrewrite/github/security/ExcessivePermissionsRecipe.java
+++ b/src/main/java/org/openrewrite/github/security/ExcessivePermissionsRecipe.java
@@ -18,6 +18,7 @@
import lombok.EqualsAndHashCode;
import lombok.Value;
import org.openrewrite.*;
+import org.openrewrite.github.util.YamlScalarAccessor;
import org.openrewrite.marker.SearchResult;
import org.openrewrite.yaml.YamlIsoVisitor;
import org.openrewrite.yaml.tree.Yaml;
@@ -58,7 +59,7 @@ public TreeVisitor, ExecutionContext> getVisitor() {
);
}
- private static class ExcessivePermissionsVisitor extends YamlIsoVisitor {
+ private static class ExcessivePermissionsVisitor extends YamlIsoVisitor implements YamlScalarAccessor {
@Override
public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) {
@@ -76,7 +77,7 @@ private boolean isPermissionsEntry(Yaml.Mapping.Entry entry) {
}
private Yaml.Mapping.Entry checkPermissions(Yaml.Mapping.Entry entry) {
- String scalarPermissionValue = YamlHelper.getScalarValue(entry.getValue());
+ String scalarPermissionValue = getScalarValue(entry.getValue());
if (scalarPermissionValue != null) {
return checkScalarPermissions(entry, scalarPermissionValue);
}
@@ -107,7 +108,7 @@ private Yaml.Mapping.Entry checkMappingPermissions(Yaml.Mapping.Entry entry, Yam
for (Yaml.Mapping.Entry permEntry : permissionsMapping.getEntries()) {
String permissionName = permEntry.getKey().getValue();
- String permissionValue = YamlHelper.getScalarValue(permEntry.getValue());
+ String permissionValue = getScalarValue(permEntry.getValue());
if (permissionName != null && permissionValue != null) {
if ("write".equals(permissionValue)) {
diff --git a/src/main/java/org/openrewrite/github/security/GitHubEnvRecipe.java b/src/main/java/org/openrewrite/github/security/GitHubEnvRecipe.java
index 9bd8eaa..d8c46e0 100644
--- a/src/main/java/org/openrewrite/github/security/GitHubEnvRecipe.java
+++ b/src/main/java/org/openrewrite/github/security/GitHubEnvRecipe.java
@@ -20,6 +20,7 @@
import org.openrewrite.ExecutionContext;
import org.openrewrite.Recipe;
import org.openrewrite.TreeVisitor;
+import org.openrewrite.github.util.YamlScalarAccessor;
import org.openrewrite.marker.SearchResult;
import org.openrewrite.yaml.YamlIsoVisitor;
import org.openrewrite.yaml.tree.Yaml;
@@ -75,7 +76,7 @@ public TreeVisitor, ExecutionContext> getVisitor() {
return new GitHubEnvVisitor();
}
- private static class GitHubEnvVisitor extends YamlIsoVisitor {
+ private static class GitHubEnvVisitor extends YamlIsoVisitor implements YamlScalarAccessor {
private boolean hasDangerousTriggers = false;
@@ -109,14 +110,14 @@ private void analyzeTriggers(Yaml.Document document) {
}
private boolean checkForDangerousTriggers(Yaml.Block onValue) {
- String scalarTrigger = YamlHelper.getScalarValue(onValue);
+ String scalarTrigger = getScalarValue(onValue);
if (scalarTrigger != null) {
return DANGEROUS_TRIGGERS.contains(scalarTrigger);
}
if (onValue instanceof Yaml.Sequence) {
Yaml.Sequence sequence = (Yaml.Sequence) onValue;
for (Yaml.Sequence.Entry seqEntry : sequence.getEntries()) {
- String trigger = YamlHelper.getScalarValue(seqEntry.getBlock());
+ String trigger = getScalarValue(seqEntry.getBlock());
if (trigger != null && DANGEROUS_TRIGGERS.contains(trigger)) {
return true;
}
@@ -163,7 +164,7 @@ private boolean isRunStepEntry(Yaml.Mapping.Entry entry) {
}
private String getRunContent(Yaml.Mapping.Entry entry) {
- return YamlHelper.getScalarValue(entry.getValue());
+ return getScalarValue(entry.getValue());
}
private boolean usesGitHubEnv(String runContent) {
diff --git a/src/main/java/org/openrewrite/github/security/SelfHostedRunnerRecipe.java b/src/main/java/org/openrewrite/github/security/SelfHostedRunnerRecipe.java
index 08de146..13116c4 100644
--- a/src/main/java/org/openrewrite/github/security/SelfHostedRunnerRecipe.java
+++ b/src/main/java/org/openrewrite/github/security/SelfHostedRunnerRecipe.java
@@ -18,6 +18,7 @@
import lombok.EqualsAndHashCode;
import lombok.Value;
import org.openrewrite.*;
+import org.openrewrite.github.util.YamlScalarAccessor;
import org.openrewrite.marker.SearchResult;
import org.openrewrite.yaml.YamlIsoVisitor;
import org.openrewrite.yaml.tree.Yaml;
@@ -47,7 +48,7 @@ public TreeVisitor, ExecutionContext> getVisitor() {
);
}
- private static class SelfHostedRunnerVisitor extends YamlIsoVisitor {
+ private static class SelfHostedRunnerVisitor extends YamlIsoVisitor implements YamlScalarAccessor {
@Override
public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) {
@@ -87,7 +88,7 @@ private Yaml.Mapping.Entry checkRunsOnValue(Yaml.Mapping.Entry entry, String run
}
private Yaml.Mapping.Entry checkRunsOnSequence(Yaml.Mapping.Entry entry, Yaml.Sequence sequence) {
- String firstValue = YamlHelper.getScalarValue(sequence.getEntries().get(0).getBlock());
+ String firstValue = getScalarValue(sequence.getEntries().get(0).getBlock());
if ("self-hosted".equals(firstValue)) {
return SearchResult.found(entry,
"Uses self-hosted runner which may have security implications in public repositories. " +
@@ -137,7 +138,7 @@ private boolean containsSelfHostedInMatrixValues(Yaml.Mapping matrixMapping) {
if (matrixEntry.getValue() instanceof Yaml.Sequence) {
Yaml.Sequence sequence = (Yaml.Sequence) matrixEntry.getValue();
for (Yaml.Sequence.Entry seqEntry : sequence.getEntries()) {
- String value = YamlHelper.getScalarValue(seqEntry.getBlock());
+ String value = getScalarValue(seqEntry.getBlock());
if ("self-hosted".equals(value)) {
return true;
}
diff --git a/src/main/java/org/openrewrite/github/security/TemplateInjectionRecipe.java b/src/main/java/org/openrewrite/github/security/TemplateInjectionRecipe.java
index 21ca547..33b1a3e 100644
--- a/src/main/java/org/openrewrite/github/security/TemplateInjectionRecipe.java
+++ b/src/main/java/org/openrewrite/github/security/TemplateInjectionRecipe.java
@@ -19,6 +19,7 @@
import lombok.Value;
import org.jspecify.annotations.Nullable;
import org.openrewrite.*;
+import org.openrewrite.github.util.YamlScalarAccessor;
import org.openrewrite.marker.SearchResult;
import org.openrewrite.yaml.JsonPathMatcher;
import org.openrewrite.yaml.YamlIsoVisitor;
@@ -93,7 +94,7 @@ public TreeVisitor, ExecutionContext> getVisitor() {
);
}
- private static class TemplateInjectionVisitor extends YamlIsoVisitor {
+ private static class TemplateInjectionVisitor extends YamlIsoVisitor implements YamlScalarAccessor {
private static final JsonPathMatcher STEP_RUN_MATCHER = new JsonPathMatcher("$..steps[*].run");
private static final JsonPathMatcher STEP_USES_MATCHER = new JsonPathMatcher("$..steps[*].uses");
@@ -122,7 +123,7 @@ public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionC
}
private Yaml.Mapping.Entry checkRunEntry(Yaml.Mapping.Entry entry) {
- String runCommand = YamlHelper.getScalarValue(entry.getValue());
+ String runCommand = getScalarValue(entry.getValue());
if (runCommand == null) {
return entry;
}
@@ -143,7 +144,7 @@ private Yaml.Mapping.Entry checkRunEntry(Yaml.Mapping.Entry entry) {
}
private Yaml.Mapping.Entry checkUsesEntry(Yaml.Mapping.Entry entry) {
- String usesValue = YamlHelper.getScalarValue(entry.getValue());
+ String usesValue = getScalarValue(entry.getValue());
if (usesValue == null) {
return entry;
}
@@ -161,7 +162,7 @@ private Yaml.Mapping.Entry checkUsesEntry(Yaml.Mapping.Entry entry) {
}
private Yaml.Mapping.Entry checkScriptEntry(Yaml.Mapping.Entry entry) {
- String scriptContent = YamlHelper.getScalarValue(entry.getValue());
+ String scriptContent = getScalarValue(entry.getValue());
if (scriptContent == null) {
return entry;
}
diff --git a/src/main/java/org/openrewrite/github/security/UnpinnedActionsRecipe.java b/src/main/java/org/openrewrite/github/security/UnpinnedActionsRecipe.java
index e549475..0cf6a8a 100644
--- a/src/main/java/org/openrewrite/github/security/UnpinnedActionsRecipe.java
+++ b/src/main/java/org/openrewrite/github/security/UnpinnedActionsRecipe.java
@@ -18,9 +18,8 @@
import lombok.EqualsAndHashCode;
import lombok.Value;
import org.openrewrite.*;
+import org.openrewrite.github.util.ActionStep;
import org.openrewrite.marker.SearchResult;
-import org.openrewrite.yaml.YamlIsoVisitor;
-import org.openrewrite.yaml.tree.Yaml;
import java.util.regex.Pattern;
@@ -32,8 +31,6 @@ public class UnpinnedActionsRecipe extends Recipe {
"^([^/@]+/[^/@]+)(@(main|master|HEAD|latest|v?\\d+(\\.\\d+)*(\\.\\d+)*))??$"
);
- private static final Pattern SHA_PATTERN = Pattern.compile("^[a-f0-9]{40}$");
-
@Override
public String getDisplayName() {
return "Pin GitHub Actions to specific commits";
@@ -51,68 +48,47 @@ public String getDescription() {
public TreeVisitor, ExecutionContext> getVisitor() {
return Preconditions.check(
new FindSourceFiles(".github/workflows/*.yml"),
- new UnpinnedActionsVisitor()
+ new ActionStep.Matcher().asVisitor((actionStep, ctx) -> {
+ if (isUnpinned(actionStep)) {
+ String actionRef = actionStep.getActionRef();
+ return SearchResult.found(actionStep.getTree(),
+ "Action '" + actionRef + "' is not pinned to a commit SHA. " +
+ "Consider pinning to a specific commit for security and reproducibility.");
+ }
+ return actionStep.getTree();
+ })
);
}
- private static class UnpinnedActionsVisitor extends YamlIsoVisitor {
-
- @Override
- public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) {
- Yaml.Mapping.Entry mappingEntry = super.visitMappingEntry(entry, ctx);
-
- if (isUsesEntry(mappingEntry)) {
- String usesValue = getUsesValue(mappingEntry);
- if (usesValue != null && isUnpinned(usesValue)) {
- return SearchResult.found(mappingEntry,
- "Action '" + usesValue + "' is not pinned to a commit SHA. " +
- "Consider pinning to a specific commit for security and reproducibility.");
- }
- }
-
- return mappingEntry;
+ private static boolean isUnpinned(ActionStep actionStep) {
+ String actionRef = actionStep.getActionRef();
+ if (actionRef == null) {
+ return false;
}
- private boolean isUsesEntry(Yaml.Mapping.Entry entry) {
- // Broader approach - match any "uses" entry and let the logic handle context validation
- return entry.getKey() instanceof Yaml.Scalar &&
- "uses".equals(((Yaml.Scalar) entry.getKey()).getValue());
+ // Skip local actions (start with ./)
+ if (actionRef.startsWith("./")) {
+ return false;
}
- private String getUsesValue(Yaml.Mapping.Entry entry) {
- if (entry.getValue() instanceof Yaml.Scalar) {
- return ((Yaml.Scalar) entry.getValue()).getValue();
- }
- return null;
+ // Skip Docker actions (start with docker://)
+ if (actionRef.startsWith("docker://")) {
+ return false;
}
- private boolean isUnpinned(String usesValue) {
- // Skip local actions (start with ./)
- if (usesValue.startsWith("./")) {
- return false;
- }
-
- // Skip Docker actions (start with docker://)
- if (usesValue.startsWith("docker://")) {
- return false;
- }
-
- // Check if it's a repository action
- String[] parts = usesValue.split("@", 2);
- if (parts.length < 2) {
- // No @ symbol means no version specified at all
- return true;
- }
-
- String version = parts[1];
-
- // If it's already a SHA, it's pinned
- if (SHA_PATTERN.matcher(version).matches()) {
- return false;
- }
+ // If it's already pinned to a SHA, it's not unpinned
+ if (actionStep.isVersionPinned()) {
+ return false;
+ }
- // If it matches unpinned patterns (main, master, HEAD, latest, or version tags), it's unpinned
- return UNPINNED_ACTION_PATTERN.matcher(usesValue).matches();
+ // Check if version exists
+ String version = actionStep.getActionVersion();
+ if (version == null) {
+ // No @ symbol means no version specified at all
+ return true;
}
+
+ // If it matches unpinned patterns (main, master, HEAD, latest, or version tags), it's unpinned
+ return UNPINNED_ACTION_PATTERN.matcher(actionRef).matches();
}
}
diff --git a/src/main/java/org/openrewrite/github/security/UnpinnedDockerImagesRecipe.java b/src/main/java/org/openrewrite/github/security/UnpinnedDockerImagesRecipe.java
index 6549f17..31bdb43 100644
--- a/src/main/java/org/openrewrite/github/security/UnpinnedDockerImagesRecipe.java
+++ b/src/main/java/org/openrewrite/github/security/UnpinnedDockerImagesRecipe.java
@@ -18,6 +18,7 @@
import lombok.EqualsAndHashCode;
import lombok.Value;
import org.openrewrite.*;
+import org.openrewrite.github.util.YamlScalarAccessor;
import org.openrewrite.marker.SearchResult;
import org.openrewrite.yaml.YamlIsoVisitor;
import org.openrewrite.yaml.tree.Yaml;
@@ -55,7 +56,7 @@ public TreeVisitor, ExecutionContext> getVisitor() {
);
}
- private static class UnpinnedDockerImagesVisitor extends YamlIsoVisitor {
+ private static class UnpinnedDockerImagesVisitor extends YamlIsoVisitor implements YamlScalarAccessor {
@Override
public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) {
@@ -78,7 +79,7 @@ private boolean isImageEntry(Yaml.Mapping.Entry entry) {
}
private String getImageValue(Yaml.Mapping.Entry entry) {
- return YamlHelper.getScalarValue(entry.getValue());
+ return getScalarValue(entry.getValue());
}
private boolean isUnpinnedDockerImage(String imageValue) {
diff --git a/src/main/java/org/openrewrite/github/security/YamlHelper.java b/src/main/java/org/openrewrite/github/security/YamlHelper.java
deleted file mode 100644
index 48a3acd..0000000
--- a/src/main/java/org/openrewrite/github/security/YamlHelper.java
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * 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.security;
-
-import org.jspecify.annotations.Nullable;
-import org.openrewrite.yaml.tree.Yaml;
-
-/**
- * Utility class containing common patterns for working with YAML LST in OpenRewrite recipes.
- * These methods help eliminate code duplication and provide idiomatic ways to work with YAML structures.
- */
-final class YamlHelper {
-
- private YamlHelper() {
- // Utility class - prevent instantiation
- }
-
- /**
- * Safely extracts the string value from a YAML block if it's a scalar.
- *
- * @param block The YAML block to extract value from
- * @return The string value if block is a Yaml.Scalar, null otherwise
- */
- public static @Nullable String getScalarValue(Yaml.Block block) {
- return block instanceof Yaml.Scalar ? ((Yaml.Scalar) block).getValue() : null;
- }
-
- /**
- * Finds a mapping entry with the given key in a YAML mapping.
- *
- * @param mapping The YAML mapping to search in
- * @param key The key to look for
- * @return The Yaml.Mapping if found, null otherwise
- */
- private static Yaml.@Nullable Mapping findMappingWithKey(Yaml.Mapping mapping, String key) {
- for (Yaml.Mapping.Entry entry : mapping.getEntries()) {
- if (key.equals(entry.getKey().getValue()) && entry.getValue() instanceof Yaml.Mapping) {
- return (Yaml.Mapping) entry.getValue();
- }
- }
- return null;
- }
-
- /**
- * Finds a scalar value for a given key in a YAML mapping.
- *
- * @param mapping The YAML mapping to search in
- * @param key The key to look for
- * @return The scalar value if found, null otherwise
- */
- private static @Nullable String findScalarValue(Yaml.Mapping mapping, String key) {
- for (Yaml.Mapping.Entry entry : mapping.getEntries()) {
- if (key.equals(entry.getKey().getValue())) {
- return getScalarValue(entry.getValue());
- }
- }
- return null;
- }
-
- /**
- * Finds a nested scalar value by traversing through a parent key to a child key.
- * For example, findNestedScalarValue(mapping, "with", "path") would find the value at with.path
- *
- * @param mapping The YAML mapping to search in
- * @param parentKey The parent key (e.g., "with")
- * @param childKey The child key (e.g., "path")
- * @return The nested scalar value if found, null otherwise
- */
- public static @Nullable String findNestedScalarValue(Yaml.Mapping mapping, String parentKey, String childKey) {
- Yaml.Mapping parentMapping = findMappingWithKey(mapping, parentKey);
- if (parentMapping == null) {
- return null;
- }
- return findScalarValue(parentMapping, childKey);
- }
-
-}
diff --git a/src/main/java/org/openrewrite/github/util/ActionStep.java b/src/main/java/org/openrewrite/github/util/ActionStep.java
new file mode 100644
index 0000000..f63bdeb
--- /dev/null
+++ b/src/main/java/org/openrewrite/github/util/ActionStep.java
@@ -0,0 +1,251 @@
+/*
+ * 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.util;
+
+import lombok.AllArgsConstructor;
+import org.jspecify.annotations.Nullable;
+import org.openrewrite.Cursor;
+import org.openrewrite.Tree;
+import org.openrewrite.TreeVisitor;
+import org.openrewrite.trait.Trait;
+import org.openrewrite.trait.VisitFunction2;
+import org.openrewrite.yaml.YamlIsoVisitor;
+import org.openrewrite.yaml.tree.Yaml;
+
+/**
+ * Represents a GitHub Actions workflow step that uses an external action (has a `uses` key).
+ *
+ * This trait provides read-only semantic methods for querying action references,
+ * versions, and metadata without manual cursor navigation or instanceof checks.
+ *
+ * Design Principle: This trait is designed for querying and matching only.
+ * For tree modifications, use the trait to identify targets, then build updated trees
+ * directly in your visitor and return them.
+ *
+ * Example workflow step:
+ *
+ * - uses: actions/checkout@v3
+ * - uses: actions/setup-java@a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
+ *
+ *
+ * Example usage:
+ *
+ * new ActionStep.Matcher()
+ * .withRequiredAction("actions/checkout")
+ * .asVisitor((actionStep, ctx) -> {
+ * if (actionStep.isVersionPinned()) {
+ * // Read-only operations
+ * String owner = actionStep.getActionOwner();
+ * String version = actionStep.getActionVersion();
+ * }
+ * return actionStep.getTree();
+ * })
+ *
+ */
+@AllArgsConstructor
+public class ActionStep implements Trait {
+ Cursor cursor;
+
+ @Override
+ public Cursor getCursor() {
+ return cursor;
+ }
+
+ /**
+ * Get the full action reference (e.g., "actions/checkout@v3").
+ *
+ * @return The full action reference, or null if not available
+ */
+ public @Nullable String getActionRef() {
+ Yaml.Mapping.Entry entry = getTree();
+ if (entry.getValue() instanceof Yaml.Scalar) {
+ return ((Yaml.Scalar) entry.getValue()).getValue();
+ }
+ return null;
+ }
+
+ /**
+ * Get the action owner (e.g., "actions" from "actions/checkout@v3").
+ *
+ * @return The action owner, or null if not available or for local/docker actions
+ */
+ public @Nullable String getActionOwner() {
+ String ref = getActionRef();
+ if (ref == null || !ref.contains("/")) {
+ return null;
+ }
+ // Skip local and docker actions
+ if (ref.startsWith("./") || ref.startsWith("docker://")) {
+ return null;
+ }
+ String nameWithOwner = ref.contains("@") ? ref.substring(0, ref.indexOf("@")) : ref;
+ return nameWithOwner.substring(0, nameWithOwner.indexOf("/"));
+ }
+
+ /**
+ * Get the action name without version (e.g., "actions/checkout" from "actions/checkout@v3").
+ *
+ * @return The action name, or null if not available
+ */
+ public @Nullable String getActionName() {
+ String ref = getActionRef();
+ if (ref == null) {
+ return null;
+ }
+ return ref.contains("@") ? ref.substring(0, ref.indexOf("@")) : ref;
+ }
+
+ /**
+ * Get the action version (e.g., "v3" from "actions/checkout@v3").
+ *
+ * @return The action version, or null if not specified
+ */
+ public @Nullable String getActionVersion() {
+ String ref = getActionRef();
+ if (ref == null || !ref.contains("@")) {
+ return null;
+ }
+ return ref.substring(ref.indexOf("@") + 1);
+ }
+
+ /**
+ * Check if the action is pinned to a full 40-character SHA.
+ *
+ * @return true if pinned to a SHA, false otherwise
+ */
+ public boolean isVersionPinned() {
+ String version = getActionVersion();
+ return version != null && version.matches("[a-f0-9]{40}");
+ }
+
+ /**
+ * Check if this action matches a given action pattern.
+ *
+ * Supports patterns like:
+ *
+ * - "actions/checkout" - exact match
+ * - "actions/checkout@*" - match any version
+ *
+ *
+ * @param actionPattern The pattern to match
+ * @return true if matches, false otherwise
+ */
+ public boolean matchesAction(String actionPattern) {
+ String name = getActionName();
+ if (name == null) {
+ return false;
+ }
+ if (actionPattern.endsWith("@*")) {
+ return name.equals(actionPattern.substring(0, actionPattern.length() - 2));
+ }
+ return name.equals(actionPattern);
+ }
+
+ /**
+ * Check if a cursor points to an action step (a mapping entry with key "uses").
+ *
+ * @param cursor The cursor to check
+ * @return true if it's an action step, false otherwise
+ */
+ public static boolean isActionStep(Cursor cursor) {
+ if (!(cursor.getValue() instanceof Yaml.Mapping.Entry)) {
+ return false;
+ }
+ Yaml.Mapping.Entry entry = cursor.getValue();
+ return entry.getKey() instanceof Yaml.Scalar &&
+ "uses".equals(((Yaml.Scalar) entry.getKey()).getValue());
+ }
+
+ /**
+ * Check if a cursor is within a steps array of a job.
+ *
+ * @param cursor The cursor to check
+ * @return true if within a steps array, false otherwise
+ */
+ public static boolean withinStepsArray(Cursor cursor) {
+ Cursor parent = cursor.getParent();
+ while (parent != null) {
+ if (parent.getValue() instanceof Yaml.Mapping.Entry) {
+ Yaml.Mapping.Entry entry = parent.getValue();
+ if (entry.getKey() instanceof Yaml.Scalar &&
+ "steps".equals(((Yaml.Scalar) entry.getKey()).getValue())) {
+ return true;
+ }
+ }
+ parent = parent.getParent();
+ }
+ return false;
+ }
+
+ /**
+ * Matcher for finding ActionStep traits in YAML documents.
+ */
+ public static class Matcher extends YamlTraitMatcher {
+ @Nullable
+ protected String requiredAction;
+
+ /**
+ * Filter to match only a specific action.
+ *
+ * @param action The action name to match (e.g., "actions/checkout")
+ * @return This matcher for chaining
+ */
+ public Matcher withRequiredAction(String action) {
+ this.requiredAction = action;
+ return this;
+ }
+
+ @Override
+ protected @Nullable ActionStep test(Cursor cursor) {
+ // Must be a mapping entry with key "uses"
+ if (!isActionStep(cursor)) {
+ return null;
+ }
+
+ // Must be within a steps array
+ if (!withinStepsArray(cursor)) {
+ return null;
+ }
+
+ // Create trait instance
+ ActionStep trait = new ActionStep(cursor);
+
+ // Apply filter if configured
+ if (requiredAction != null && !trait.matchesAction(requiredAction)) {
+ return null;
+ }
+
+ return trait;
+ }
+
+ /**
+ * Override asVisitor for better performance - only visit mapping entries.
+ */
+ @Override
+ public TreeVisitor extends Tree, P> asVisitor(VisitFunction2 visitor) {
+ return new YamlIsoVisitor() {
+ @Override
+ public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, P p) {
+ ActionStep actionStep = test(getCursor());
+ if (actionStep != null) {
+ return (Yaml.Mapping.Entry) visitor.visit(actionStep, p);
+ }
+ return super.visitMappingEntry(entry, p);
+ }
+ };
+ }
+ }
+}
diff --git a/src/main/java/org/openrewrite/github/util/YamlScalarAccessor.java b/src/main/java/org/openrewrite/github/util/YamlScalarAccessor.java
new file mode 100644
index 0000000..c4f0f28
--- /dev/null
+++ b/src/main/java/org/openrewrite/github/util/YamlScalarAccessor.java
@@ -0,0 +1,242 @@
+/*
+ * 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.util;
+
+import org.jspecify.annotations.Nullable;
+import org.openrewrite.yaml.tree.Yaml;
+
+/**
+ * A utility interface that provides safe access to scalar values from YAML LST elements.
+ * This interface encapsulates common patterns for extracting string values from
+ * YAML blocks, eliminating the need for repeated type checking and casting.
+ *
+ *
This interface is designed to be implemented by recipes or visitors that need to
+ * work with YAML scalar values, providing a cleaner API than static utility methods.
+ *
+ * Usage Example
+ *
+ * private static class MyVisitor extends YamlIsoVisitor<ExecutionContext>
+ * implements YamlScalarAccessor {
+ *
+ * {@literal @}Override
+ * public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) {
+ * // Extract scalar value from mapping entry
+ * String value = getScalarValue(entry.getValue());
+ * if (value != null) {
+ * // Work with the scalar value
+ * }
+ * return super.visitMappingEntry(entry, ctx);
+ * }
+ *
+ * {@literal @}Override
+ * public Yaml.Sequence visitSequence(Yaml.Sequence sequence, ExecutionContext ctx) {
+ * // Extract values from sequence entries
+ * for (Yaml.Sequence.Entry entry : sequence.getEntries()) {
+ * String value = getSequenceEntryValue(entry);
+ * if (value != null) {
+ * // Process each value
+ * }
+ * }
+ * return super.visitSequence(sequence, ctx);
+ * }
+ * }
+ *
+ *
+ * Migration from YamlHelper
+ * This interface replaces the static {@code YamlHelper} utility class with a more
+ * composable and testable approach. To migrate existing code:
+ *
+ * // Before (using static utility):
+ * String value = YamlHelper.getScalarValue(block);
+ *
+ * // After (using interface):
+ * class MyVisitor extends YamlIsoVisitor<ExecutionContext>
+ * implements YamlScalarAccessor {
+ * void myMethod(Yaml.Block block) {
+ * String value = getScalarValue(block);
+ * }
+ * }
+ *
+ *
+ * @see org.openrewrite.yaml.tree.Yaml.Scalar
+ * @see org.openrewrite.yaml.tree.Yaml.Block
+ */
+public interface YamlScalarAccessor {
+
+ /**
+ * Safely extracts the string value from a YAML block if it's a scalar.
+ *
+ * This method performs type checking and casting in a single operation,
+ * returning null if the block is not a Yaml.Scalar. This eliminates the need
+ * for explicit instanceof checks and casts throughout recipe code.
+ *
+ * Example:
+ *
+ * Yaml.Block block = entry.getValue();
+ * String value = getScalarValue(block);
+ * if (value != null) {
+ * // block was a Yaml.Scalar with a non-null value
+ * }
+ *
+ *
+ *
+ * @param block The YAML block to extract value from, may be null
+ * @return The string value if block is a Yaml.Scalar, null otherwise
+ */
+ default @Nullable String getScalarValue(Yaml.Block block) {
+ return block instanceof Yaml.Scalar ? ((Yaml.Scalar) block).getValue() : null;
+ }
+
+ /**
+ * Safely extracts the string value from a YAML scalar.
+ *
+ * This is a convenience method for when you already know the element is
+ * a scalar but want null-safe value extraction. Useful when working with
+ * APIs that return {@code Yaml.Scalar} directly.
+ *
+ * Example:
+ *
+ * Yaml.Scalar scalar = (Yaml.Scalar) entry.getKey();
+ * String keyValue = getScalarValue(scalar);
+ *
+ *
+ *
+ * @param scalar The YAML scalar to extract value from, may be null
+ * @return The string value if scalar is non-null, null otherwise
+ */
+ default @Nullable String getScalarValue(Yaml.Scalar scalar) {
+ return scalar != null ? scalar.getValue() : null;
+ }
+
+ /**
+ * Extracts scalar value from a mapping entry's value.
+ *
+ * Common pattern when working with key-value pairs in YAML mappings.
+ * Equivalent to calling {@code getScalarValue(entry.getValue())} but more
+ * semantic and concise.
+ *
+ * Example:
+ *
+ * for (Yaml.Mapping.Entry entry : mapping.getEntries()) {
+ * String value = getEntryScalarValue(entry);
+ * if (value != null) {
+ * // Process entry's value
+ * }
+ * }
+ *
+ *
+ *
+ * @param entry The mapping entry to extract value from, may be null
+ * @return The string value if entry's value is a Yaml.Scalar, null otherwise
+ */
+ default @Nullable String getEntryScalarValue(Yaml.Mapping.Entry entry) {
+ return entry != null ? getScalarValue(entry.getValue()) : null;
+ }
+
+ /**
+ * Extracts scalar value from a sequence entry's block.
+ *
+ * Common pattern when iterating over YAML sequences (arrays/lists).
+ * Equivalent to calling {@code getScalarValue(entry.getBlock())} but more
+ * semantic for sequence processing.
+ *
+ * Example:
+ *
+ * Yaml.Sequence sequence = (Yaml.Sequence) onValue;
+ * for (Yaml.Sequence.Entry seqEntry : sequence.getEntries()) {
+ * String trigger = getSequenceEntryValue(seqEntry);
+ * if ("pull_request_target".equals(trigger)) {
+ * // Found dangerous trigger
+ * }
+ * }
+ *
+ *
+ *
+ * @param entry The sequence entry to extract value from, may be null
+ * @return The string value if entry's block is a Yaml.Scalar, null otherwise
+ */
+ default @Nullable String getSequenceEntryValue(Yaml.Sequence.Entry entry) {
+ return entry != null ? getScalarValue(entry.getBlock()) : null;
+ }
+
+ /**
+ * Finds a scalar value for a given key in a YAML mapping.
+ *
+ * This method searches through all entries in the mapping to find one
+ * with the specified key, then extracts its scalar value if present. Returns
+ * null if the key is not found or if the value is not a scalar.
+ *
+ * Example:
+ *
+ * Yaml.Mapping jobMapping = (Yaml.Mapping) jobEntry.getValue();
+ * String runsOn = findScalarValueByKey(jobMapping, "runs-on");
+ * if ("self-hosted".equals(runsOn)) {
+ * // Detected self-hosted runner
+ * }
+ *
+ *
+ *
+ * @param mapping The YAML mapping to search in
+ * @param key The key to look for
+ * @return The scalar value if found, null otherwise
+ */
+ default @Nullable String findScalarValueByKey(Yaml.Mapping mapping, String key) {
+ for (Yaml.Mapping.Entry entry : mapping.getEntries()) {
+ if (key.equals(entry.getKey().getValue())) {
+ return getScalarValue(entry.getValue());
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Finds a nested scalar value by traversing through a parent key to a child key.
+ *
+ * This method handles the common pattern of accessing nested YAML values like
+ * "with.path" by first finding the parent mapping, then searching for the child key.
+ * Returns null if either the parent key is not found, the parent value is not a
+ * mapping, the child key is not found, or the child value is not a scalar.
+ *
+ * Example usage for GitHub Actions workflow:
+ *
+ * # YAML:
+ * - uses: actions/cache@v3
+ * with:
+ * path: ~/.cache
+ * key: cache-key
+ *
+ * # Code:
+ * Yaml.Mapping stepMapping = (Yaml.Mapping) stepEntry.getBlock();
+ * String cachePath = findNestedScalarValue(stepMapping, "with", "path");
+ * // Returns: "~/.cache"
+ *
+ *
+ *
+ * @param mapping The YAML mapping to search in
+ * @param parentKey The parent key (e.g., "with")
+ * @param childKey The child key (e.g., "path")
+ * @return The nested scalar value if found, null otherwise
+ */
+ default @Nullable String findNestedScalarValue(Yaml.Mapping mapping, String parentKey, String childKey) {
+ for (Yaml.Mapping.Entry entry : mapping.getEntries()) {
+ if (parentKey.equals(entry.getKey().getValue()) && entry.getValue() instanceof Yaml.Mapping) {
+ Yaml.Mapping parentMapping = (Yaml.Mapping) entry.getValue();
+ return findScalarValueByKey(parentMapping, childKey);
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/org/openrewrite/github/util/YamlTraitMatcher.java b/src/main/java/org/openrewrite/github/util/YamlTraitMatcher.java
new file mode 100644
index 0000000..7926b8a
--- /dev/null
+++ b/src/main/java/org/openrewrite/github/util/YamlTraitMatcher.java
@@ -0,0 +1,89 @@
+/*
+ * 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.util;
+
+import org.jspecify.annotations.Nullable;
+import org.openrewrite.Cursor;
+import org.openrewrite.SourceFile;
+import org.openrewrite.trait.SimpleTraitMatcher;
+import org.openrewrite.trait.Trait;
+import org.openrewrite.yaml.tree.Yaml;
+
+/**
+ * Base matcher for YAML traits in GitHub Actions workflows.
+ * Provides common utility methods for cursor navigation and value extraction.
+ *
+ * @param The trait type being matched
+ */
+public abstract class YamlTraitMatcher> extends SimpleTraitMatcher {
+
+ /**
+ * Check if the cursor is within a YAML mapping.
+ *
+ * @param cursor The cursor to check
+ * @return true if within a mapping, false otherwise
+ */
+ protected boolean withinMapping(Cursor cursor) {
+ return cursor.firstEnclosing(Yaml.Mapping.class) != null;
+ }
+
+ /**
+ * Validate that the cursor is in a valid YAML context.
+ *
+ * @param cursor The cursor to validate
+ * @return true if in a valid YAML document, false otherwise
+ */
+ protected boolean isValidYamlContext(Cursor cursor) {
+ SourceFile sourceFile = cursor.firstEnclosing(SourceFile.class);
+ return sourceFile instanceof Yaml.Documents;
+ }
+
+ /**
+ * Safely extract a scalar value from a YAML block.
+ *
+ * @param block The YAML block to extract from
+ * @return The scalar value, or null if not a scalar
+ */
+ protected @Nullable String getScalarValue(Yaml.Block block) {
+ return block instanceof Yaml.Scalar ? ((Yaml.Scalar) block).getValue() : null;
+ }
+
+ /**
+ * Get the scalar key from a mapping entry.
+ *
+ * @param entry The mapping entry
+ * @return The key as a string, or null if not a scalar key
+ */
+ protected @Nullable String getScalarKey(Yaml.Mapping.Entry entry) {
+ return entry.getKey() instanceof Yaml.Scalar ? ((Yaml.Scalar) entry.getKey()).getValue() : null;
+ }
+
+ /**
+ * Find a scalar value by key in a mapping.
+ *
+ * @param mapping The mapping to search
+ * @param key The key to find
+ * @return The scalar value, or null if not found or not a scalar
+ */
+ protected @Nullable String findScalarValueByKey(Yaml.Mapping mapping, String key) {
+ for (Yaml.Mapping.Entry entry : mapping.getEntries()) {
+ if (key.equals(getScalarKey(entry))) {
+ return getScalarValue(entry.getValue());
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/org/openrewrite/github/util/package-info.java b/src/main/java/org/openrewrite/github/util/package-info.java
new file mode 100644
index 0000000..b810fab
--- /dev/null
+++ b/src/main/java/org/openrewrite/github/util/package-info.java
@@ -0,0 +1,61 @@
+/*
+ * 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.
+ */
+
+/**
+ * Utility interfaces for GitHub Actions YAML processing.
+ *
+ *
This package contains reusable utility interfaces that provide common patterns for working with
+ * GitHub Actions workflow YAML files. These interfaces use default methods to provide mixins that
+ * can be implemented by recipe visitors.
+ *
+ * Available Utilities
+ *
+ * - {@link org.openrewrite.github.util.YamlScalarAccessor} - Safe extraction of
+ * scalar values from YAML LST elements
+ *
+ *
+ * Using Utility Interfaces
+ * These interfaces are designed to be implemented by recipe visitors to provide reusable
+ * functionality through default interface methods:
+ *
+ *
+ * private static class MyVisitor extends YamlIsoVisitor<ExecutionContext>
+ * implements YamlScalarAccessor {
+ *
+ * {@literal @}Override
+ * public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) {
+ * String value = getScalarValue(entry.getValue());
+ * if (value != null) {
+ * // Process the scalar value
+ * }
+ * return super.visitMappingEntry(entry, ctx);
+ * }
+ * }
+ *
+ *
+ * Design Philosophy
+ * Utility interfaces in this package follow these principles:
+ *
+ * - Composability - Multiple interfaces can be combined in a single visitor
+ * - Java 8 Compatibility - All code uses Java 8 syntax only
+ * - Null Safety - Methods handle null inputs gracefully using {@code @Nullable}
+ * - Documentation - Comprehensive JavaDoc with real-world examples
+ * - Testability - Utilities can be tested independently
+ *
+ *
+ * @see org.openrewrite.github.util.YamlScalarAccessor
+ */
+package org.openrewrite.github.util;
diff --git a/src/test/java/org/openrewrite/github/util/ActionStepTest.java b/src/test/java/org/openrewrite/github/util/ActionStepTest.java
new file mode 100644
index 0000000..1814c95
--- /dev/null
+++ b/src/test/java/org/openrewrite/github/util/ActionStepTest.java
@@ -0,0 +1,313 @@
+/*
+ * 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.util;
+
+import org.junit.jupiter.api.Test;
+import org.openrewrite.DocumentExample;
+import org.openrewrite.ExecutionContext;
+import org.openrewrite.test.RecipeSpec;
+import org.openrewrite.test.RewriteTest;
+import org.openrewrite.yaml.YamlIsoVisitor;
+import org.openrewrite.yaml.tree.Yaml;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.openrewrite.yaml.Assertions.yaml;
+
+class ActionStepTest implements RewriteTest {
+
+ @Override
+ public void defaults(RecipeSpec spec) {
+ spec.recipe(RewriteTest.toRecipe(() -> new YamlIsoVisitor<>() {
+ // No-op visitor - we're testing trait matching, not transformation
+ }));
+ }
+
+ @DocumentExample
+ @Test
+ void findsActionSteps() {
+ rewriteRun(
+ yaml(
+ """
+ name: Test Workflow
+ on: push
+ jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-java@v2
+ with:
+ distribution: 'temurin'
+ - run: echo "Not an action step"
+ """,
+ spec -> spec.afterRecipe(results -> {
+ ActionStep.Matcher matcher = new ActionStep.Matcher();
+ List steps = matcher.lower(results)
+ .collect(Collectors.toList());
+
+ assertThat(steps).hasSize(2);
+ assertThat(steps.get(0).getActionName()).isEqualTo("actions/checkout");
+ assertThat(steps.get(0).getActionVersion()).isEqualTo("v3");
+ assertThat(steps.get(1).getActionName()).isEqualTo("actions/setup-java");
+ })
+ )
+ );
+ }
+
+ @Test
+ void matcherWithFilterFindsSpecificAction() {
+ rewriteRun(
+ yaml(
+ """
+ jobs:
+ test:
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-java@v2
+ - uses: gradle/gradle-build-action@v2
+ """,
+ spec -> spec.afterRecipe(results -> {
+ ActionStep.Matcher matcher = new ActionStep.Matcher()
+ .withRequiredAction("actions/setup-java");
+
+ List steps = matcher.lower(results)
+ .collect(Collectors.toList());
+
+ assertThat(steps).hasSize(1);
+ assertThat(steps.get(0).getActionName()).isEqualTo("actions/setup-java");
+ })
+ )
+ );
+ }
+
+ @Test
+ void doesNotMatchRunSteps() {
+ rewriteRun(
+ yaml(
+ """
+ jobs:
+ test:
+ steps:
+ - run: echo "hello"
+ - name: Build
+ run: ./gradlew build
+ """,
+ spec -> spec.afterRecipe(results -> {
+ ActionStep.Matcher matcher = new ActionStep.Matcher();
+ List steps = matcher.lower(results)
+ .collect(Collectors.toList());
+
+ assertThat(steps).isEmpty();
+ })
+ )
+ );
+ }
+
+ @Test
+ void extractsActionNameAndVersion() {
+ rewriteRun(
+ yaml(
+ """
+ jobs:
+ build:
+ steps:
+ - uses: actions/checkout@v3
+ - uses: octocat/hello-world-action@main
+ - uses: actions/setup-java@a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
+ """,
+ spec -> spec.afterRecipe(results -> {
+ ActionStep.Matcher matcher = new ActionStep.Matcher();
+ List steps = matcher.lower(results)
+ .collect(Collectors.toList());
+
+ // Test semantic version
+ assertThat(steps.get(0).getActionName()).isEqualTo("actions/checkout");
+ assertThat(steps.get(0).getActionVersion()).isEqualTo("v3");
+ assertThat(steps.get(0).getActionOwner()).isEqualTo("actions");
+ assertThat(steps.get(0).isVersionPinned()).isFalse();
+
+ // Test branch reference
+ assertThat(steps.get(1).getActionName()).isEqualTo("octocat/hello-world-action");
+ assertThat(steps.get(1).getActionVersion()).isEqualTo("main");
+ assertThat(steps.get(1).isVersionPinned()).isFalse();
+
+ // Test SHA pinning
+ assertThat(steps.get(2).getActionName()).isEqualTo("actions/setup-java");
+ assertThat(steps.get(2).isVersionPinned()).isTrue();
+ })
+ )
+ );
+ }
+
+ @Test
+ void matchesActionPatterns() {
+ rewriteRun(
+ yaml(
+ """
+ jobs:
+ build:
+ steps:
+ - uses: actions/setup-java@v2
+ """,
+ spec -> spec.afterRecipe(results -> {
+ ActionStep.Matcher matcher = new ActionStep.Matcher();
+ ActionStep step = matcher.lower(results)
+ .findFirst()
+ .orElseThrow();
+
+ assertThat(step.matchesAction("actions/setup-java")).isTrue();
+ assertThat(step.matchesAction("actions/setup-java@*")).isTrue();
+ assertThat(step.matchesAction("actions/setup-node")).isFalse();
+ })
+ )
+ );
+ }
+
+ @Test
+ void handlesActionWithoutVersion() {
+ rewriteRun(
+ yaml(
+ """
+ jobs:
+ build:
+ steps:
+ - uses: actions/checkout
+ """,
+ spec -> spec.afterRecipe(results -> {
+ ActionStep.Matcher matcher = new ActionStep.Matcher();
+ List steps = matcher.lower(results)
+ .collect(Collectors.toList());
+
+ assertThat(steps).hasSize(1);
+ assertThat(steps.get(0).getActionName()).isEqualTo("actions/checkout");
+ assertThat(steps.get(0).getActionVersion()).isNull();
+ assertThat(steps.get(0).isVersionPinned()).isFalse();
+ })
+ )
+ );
+ }
+
+ @Test
+ void doesNotMatchUsesOutsideSteps() {
+ rewriteRun(
+ yaml(
+ """
+ jobs:
+ call-workflow:
+ uses: ./.github/workflows/reusable.yml
+ build:
+ steps:
+ - uses: actions/checkout@v3
+ """,
+ spec -> spec.afterRecipe(results -> {
+ ActionStep.Matcher matcher = new ActionStep.Matcher();
+ List steps = matcher.lower(results)
+ .collect(Collectors.toList());
+
+ // Should only find the step-level uses, not the job-level uses
+ assertThat(steps).hasSize(1);
+ assertThat(steps.get(0).getActionName()).isEqualTo("actions/checkout");
+ })
+ )
+ );
+ }
+
+ @Test
+ void handlesMultipleJobsAndSteps() {
+ rewriteRun(
+ yaml(
+ """
+ jobs:
+ build:
+ steps:
+ - uses: actions/checkout@v3
+ - run: echo "build"
+ test:
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-java@v2
+ deploy:
+ steps:
+ - uses: actions/checkout@v3
+ """,
+ spec -> spec.afterRecipe(results -> {
+ ActionStep.Matcher matcher = new ActionStep.Matcher();
+ List steps = matcher.lower(results)
+ .collect(Collectors.toList());
+
+ assertThat(steps).hasSize(4);
+ // Three checkouts and one setup-java
+ long checkouts = steps.stream()
+ .filter(s -> "actions/checkout".equals(s.getActionName()))
+ .count();
+ assertThat(checkouts).isEqualTo(3);
+ })
+ )
+ );
+ }
+
+ @Test
+ void handlesLocalActions() {
+ rewriteRun(
+ yaml(
+ """
+ jobs:
+ build:
+ steps:
+ - uses: ./local-action
+ - uses: actions/checkout@v3
+ """,
+ spec -> spec.afterRecipe(results -> {
+ ActionStep.Matcher matcher = new ActionStep.Matcher();
+ List steps = matcher.lower(results)
+ .collect(Collectors.toList());
+
+ // Both should match - local actions are still action steps
+ assertThat(steps).hasSize(2);
+ assertThat(steps.get(0).getActionRef()).isEqualTo("./local-action");
+ assertThat(steps.get(0).getActionName()).isEqualTo("./local-action");
+ assertThat(steps.get(0).getActionOwner()).isNull(); // No owner for local actions
+ })
+ )
+ );
+ }
+
+ @Test
+ void handlesDockerActions() {
+ rewriteRun(
+ yaml(
+ """
+ jobs:
+ build:
+ steps:
+ - uses: docker://alpine:3.8
+ - uses: actions/checkout@v3
+ """,
+ spec -> spec.afterRecipe(results -> {
+ ActionStep.Matcher matcher = new ActionStep.Matcher();
+ List steps = matcher.lower(results)
+ .collect(Collectors.toList());
+
+ assertThat(steps).hasSize(2);
+ assertThat(steps.get(0).getActionRef()).isEqualTo("docker://alpine:3.8");
+ })
+ )
+ );
+ }
+}