From 96b055f6e52c4ad846d2f76c38f68e89a646b1ef Mon Sep 17 00:00:00 2001 From: Greg Oledzki Date: Thu, 8 Oct 2026 11:06:29 +0200 Subject: [PATCH 1/4] Add `RemoveRunner` recipe --- .../org/openrewrite/github/RemoveRunner.java | 138 ++++++++ .../resources/META-INF/rewrite/examples.yml | 23 ++ .../resources/META-INF/rewrite/recipes.csv | 1 + .../openrewrite/github/RemoveRunnerTest.java | 308 ++++++++++++++++++ 4 files changed, 470 insertions(+) create mode 100644 src/main/java/org/openrewrite/github/RemoveRunner.java create mode 100644 src/test/java/org/openrewrite/github/RemoveRunnerTest.java diff --git a/src/main/java/org/openrewrite/github/RemoveRunner.java b/src/main/java/org/openrewrite/github/RemoveRunner.java new file mode 100644 index 0000000..2a36d73 --- /dev/null +++ b/src/main/java/org/openrewrite/github/RemoveRunner.java @@ -0,0 +1,138 @@ +/* + * Copyright 2026 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.internal.ListUtils; +import org.openrewrite.yaml.JsonPathMatcher; +import org.openrewrite.yaml.YamlIsoVisitor; +import org.openrewrite.yaml.tree.Yaml; + +import java.util.ArrayList; +import java.util.List; + +@EqualsAndHashCode(callSuper = false) +@Value +public class RemoveRunner extends Recipe { + private static final JsonPathMatcher RUNS_ON = new JsonPathMatcher("$.jobs.*.runs-on"); + + @Option(displayName = "Job name", + description = "The name of the job to update, use `*` to affect all the workflow jobs.", + example = "build") + String jobName; + + @Option(displayName = "Runner", + description = "The runner label to remove. Labels are compared case-insensitively, as GitHub does.", + example = "self-hosted") + String runner; + + String displayName = "Remove a runner from a job"; + + String description = "Removes a runner label from the `runs-on` of a job, leaving the remaining runner labels untouched. " + + "Both sequences (`runs-on: [self-hosted, linux]`) and the `labels` of a runner group are supported. " + + "The runner is never removed when it is the only one left, as a job requires at least one runner."; + + @Override + public TreeVisitor getVisitor() { + return Preconditions.check(new IsGitHubActionsWorkflow(), new YamlIsoVisitor() { + @Override + public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) { + Yaml.Mapping.Entry e = super.visitMappingEntry(entry, ctx); + if (!RUNS_ON.matches(getCursor()) || !isTargetJob()) { + return e; + } + if (e.getValue() instanceof Yaml.Sequence) { + return e.withValue(removeFrom((Yaml.Sequence) e.getValue())); + } + if (e.getValue() instanceof Yaml.Mapping) { + return e.withValue(removeFromLabels((Yaml.Mapping) e.getValue())); + } + return e; + } + + private boolean isTargetJob() { + if ("*".equals(jobName)) { + return true; + } + Object job = getCursor().getParentTreeCursor().getParentTreeCursor().getValue(); + return job instanceof Yaml.Mapping.Entry && jobName.equals(((Yaml.Mapping.Entry) job).getKey().getValue()); + } + }); + } + + private Yaml.Sequence removeFrom(Yaml.Sequence sequence) { + List entries = sequence.getEntries(); + List remaining = new ArrayList<>(entries.size()); + for (Yaml.Sequence.Entry entry : entries) { + if (!isRunner(entry.getBlock())) { + remaining.add(entry); + } + } + if (remaining.size() == entries.size() || remaining.isEmpty()) { + return sequence; + } + + Yaml.Sequence.Entry originalFirst = entries.get(0); + if (remaining.get(0) != originalFirst) { + remaining.set(0, remaining.get(0) + .withPrefix(originalFirst.getPrefix()) + .withBlock(remaining.get(0).getBlock().withPrefix(originalFirst.getBlock().getPrefix()))); + } + int last = remaining.size() - 1; + remaining.set(last, remaining.get(last).withTrailingCommaPrefix(entries.get(entries.size() - 1).getTrailingCommaPrefix())); + return sequence.withEntries(remaining); + } + + private Yaml.Mapping removeFromLabels(Yaml.Mapping runsOn) { + List entries = runsOn.getEntries(); + for (int i = 0; i < entries.size(); i++) { + Yaml.Mapping.Entry entry = entries.get(i); + if (!"labels".equals(entry.getKey().getValue())) { + continue; + } + if (entry.getValue() instanceof Yaml.Sequence) { + int index = i; + return runsOn.withEntries(ListUtils.map(entries, (idx, e) -> + idx == index ? e.withValue(removeFrom((Yaml.Sequence) e.getValue())) : e)); + } + if (isRunner(entry.getValue()) && hasGroup(runsOn)) { + List updated = new ArrayList<>(entries); + updated.remove(i); + if (i == 0) { + updated.set(0, updated.get(0).withPrefix(entry.getPrefix())); + } + return runsOn.withEntries(updated); + } + return runsOn; + } + return runsOn; + } + + private static boolean hasGroup(Yaml.Mapping runsOn) { + for (Yaml.Mapping.Entry entry : runsOn.getEntries()) { + if ("group".equals(entry.getKey().getValue())) { + return true; + } + } + return false; + } + + private boolean isRunner(Yaml.Block block) { + return block instanceof Yaml.Scalar && runner.equalsIgnoreCase(((Yaml.Scalar) block).getValue()); + } +} diff --git a/src/main/resources/META-INF/rewrite/examples.yml b/src/main/resources/META-INF/rewrite/examples.yml index 50edc58..0a3e4b6 100644 --- a/src/main/resources/META-INF/rewrite/examples.yml +++ b/src/main/resources/META-INF/rewrite/examples.yml @@ -504,6 +504,29 @@ examples: language: yaml --- type: specs.openrewrite.org/v1beta/example +recipeName: org.openrewrite.github.RemoveRunner +examples: +- description: '`RemoveRunnerTest#removeRunnerFromFlowSequence`' + parameters: + - build + - MyRunner + sources: + - before: | + jobs: + build: + runs-on: [ubuntu-latest, MyRunner, gpu] + other: + runs-on: [ubuntu-latest, MyRunner] + after: | + jobs: + build: + runs-on: [ubuntu-latest, gpu] + other: + runs-on: [ubuntu-latest, MyRunner] + path: .github/workflows/ci.yml + language: yaml +--- +type: specs.openrewrite.org/v1beta/example recipeName: org.openrewrite.github.RemoveUnusedWorkflowDispatchInputs examples: - description: '`RemoveUnusedWorkflowDispatchInputsTest#removeUnusedInputs`' diff --git a/src/main/resources/META-INF/rewrite/recipes.csv b/src/main/resources/META-INF/rewrite/recipes.csv index a2dccb3..bb2fd18 100644 --- a/src/main/resources/META-INF/rewrite/recipes.csv +++ b/src/main/resources/META-INF/rewrite/recipes.csv @@ -21,6 +21,7 @@ maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.Migra maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.PreferBlockStyleJobDependencies,Prefer block style for job dependencies,"Convert flow-style `needs` sequences (e.g. `needs: [dep1, dep2]`) to block-style in GitHub Actions workflow jobs when a job depends on more than one other job. Block style improves readability and produces cleaner diffs in source control.",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.PreferTemurinDistributions,Use `actions/setup-java` `temurin` distribution as they are cached in hosted runners,[Host runners](https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources/) include Temurin by default as part of the [hosted tool cache](https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#hosted-tool-cache). Using Temurin speeds up builds as there is no need to download and configure the Java SDK with every build.,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.RemoveAllCronTriggers,Remove all cron triggers,Removes all cron triggers from a workflow.,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.RemoveRunner,Remove a runner from a job,"Removes a runner label from the `runs-on` of a job, leaving the remaining runner labels untouched. Both sequences (`runs-on: [self-hosted, linux]`) and the `labels` of a runner group are supported. The runner is never removed when it is the only one left, as a job requires at least one runner.",1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""jobName"",""type"":""String"",""displayName"":""Job name"",""description"":""The name of the job to update, use `*` to affect all the workflow jobs."",""example"":""build"",""required"":true},{""name"":""runner"",""type"":""String"",""displayName"":""Runner"",""description"":""The runner label to remove. Labels are compared case-insensitively, as GitHub does."",""example"":""self-hosted"",""required"":true}]", maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.RemoveUnusedWorkflowDispatchInputs,Remove unused workflow dispatch inputs,Remove workflow_dispatch inputs that are not referenced anywhere in the workflow file.,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.RemoveWorkflowInputArgument,Remove workflow input argument,Remove a specific input argument from calls to a reusable workflow.,1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""workflowReference"",""type"":""String"",""displayName"":""Workflow reference"",""description"":""The workflow reference to match (e.g., `org/repo/.github/workflows/myWorkflow.yml`)."",""example"":""org/repo/.github/workflows/myWorkflow.yml"",""required"":true},{""name"":""version"",""type"":""String"",""displayName"":""Version"",""description"":""The version of the workflow to match (e.g., `v1.2.3`)."",""example"":""v1.2.3"",""required"":true},{""name"":""inputArgumentName"",""type"":""String"",""displayName"":""Input argument name"",""description"":""The name of the input argument to remove."",""example"":""myInputToRemove"",""required"":true}]", maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.ReplaceAlwaysWithSuccessOrFailure,Replace `always()` with `success() || failure()`,Replace `always()` in GitHub Actions job and step conditions with `success() || failure()` so that canceled workflows do not continue running or hang until they time out. Note that teardown steps deliberately using `always()` to still run on cancellation will no longer run.,1,,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/RemoveRunnerTest.java b/src/test/java/org/openrewrite/github/RemoveRunnerTest.java new file mode 100644 index 0000000..1efdd1c --- /dev/null +++ b/src/test/java/org/openrewrite/github/RemoveRunnerTest.java @@ -0,0 +1,308 @@ +/* + * Copyright 2026 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.RewriteTest; + +import static org.openrewrite.yaml.Assertions.yaml; + +class RemoveRunnerTest implements RewriteTest { + + @DocumentExample + @Test + void removeRunnerFromFlowSequence() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("build", "MyRunner")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: [ubuntu-latest, MyRunner, gpu] + other: + runs-on: [ubuntu-latest, MyRunner] + """, + """ + jobs: + build: + runs-on: [ubuntu-latest, gpu] + other: + runs-on: [ubuntu-latest, MyRunner] + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void removeFirstRunnerFromFlowSequence() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("build", "self-hosted")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: [ self-hosted, linux, x64 ] + """, + """ + jobs: + build: + runs-on: [ linux, x64 ] + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void removeLastRunnerFromFlowSequence() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("build", "x64")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: [self-hosted, linux, x64] + """, + """ + jobs: + build: + runs-on: [self-hosted, linux] + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void removeRunnerFromBlockSequence() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("build", "self-hosted")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: + - self-hosted + - linux + - x64 + steps: + - run: echo hi + """, + """ + jobs: + build: + runs-on: + - linux + - x64 + steps: + - run: echo hi + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void removeRunnerFromEveryJob() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("*", "gpu")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: [self-hosted, gpu] + test: + runs-on: + - self-hosted + - gpu + lint: + runs-on: ubuntu-latest + """, + """ + jobs: + build: + runs-on: [self-hosted] + test: + runs-on: + - self-hosted + lint: + runs-on: ubuntu-latest + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void matchRunnerCaseInsensitively() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("build", "GPU")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: [self-hosted, gpu] + """, + """ + jobs: + build: + runs-on: [self-hosted] + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void keepOnlyRunnerOfScalar() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("build", "ubuntu-latest")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: ubuntu-latest + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void keepOnlyRunnerOfSequence() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("build", "self-hosted")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: [self-hosted, self-hosted] + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void doNotChangeOtherJobsOrUnknownRunners() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("build", "gpu")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: [self-hosted, linux] + other: + runs-on: [self-hosted, gpu] + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void removeRunnerFromGroupLabels() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("build", "gpu")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: + group: ubuntu-runners + labels: [ubuntu-20.04-16core, gpu] + """, + """ + jobs: + build: + runs-on: + group: ubuntu-runners + labels: [ubuntu-20.04-16core] + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void removeScalarLabelWhenGroupRemains() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("build", "gpu")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: + labels: gpu + group: ubuntu-runners + """, + """ + jobs: + build: + runs-on: + group: ubuntu-runners + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void keepScalarLabelWithoutGroup() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("build", "gpu")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: + labels: gpu + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void ignoreNonWorkflowFiles() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("build", "gpu")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: [self-hosted, gpu] + """, + spec -> spec.path("config/ci.yml") + ) + ); + } +} From 734cdff914ba844a1dd65624abb55eacf6eb61aa Mon Sep 17 00:00:00 2001 From: Greg Oledzki Date: Thu, 8 Oct 2026 11:06:52 +0200 Subject: [PATCH 2/4] Drop emptied runner group labels --- .../org/openrewrite/github/RemoveRunner.java | 23 ++++++++--- .../openrewrite/github/RemoveRunnerTest.java | 41 +++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/openrewrite/github/RemoveRunner.java b/src/main/java/org/openrewrite/github/RemoveRunner.java index 2a36d73..1854151 100644 --- a/src/main/java/org/openrewrite/github/RemoveRunner.java +++ b/src/main/java/org/openrewrite/github/RemoveRunner.java @@ -105,12 +105,10 @@ private Yaml.Mapping removeFromLabels(Yaml.Mapping runsOn) { if (!"labels".equals(entry.getKey().getValue())) { continue; } - if (entry.getValue() instanceof Yaml.Sequence) { - int index = i; - return runsOn.withEntries(ListUtils.map(entries, (idx, e) -> - idx == index ? e.withValue(removeFrom((Yaml.Sequence) e.getValue())) : e)); - } - if (isRunner(entry.getValue()) && hasGroup(runsOn)) { + if (consistsOfRunner(entry.getValue())) { + if (!hasGroup(runsOn)) { + return runsOn; + } List updated = new ArrayList<>(entries); updated.remove(i); if (i == 0) { @@ -118,6 +116,11 @@ private Yaml.Mapping removeFromLabels(Yaml.Mapping runsOn) { } return runsOn.withEntries(updated); } + if (entry.getValue() instanceof Yaml.Sequence) { + int index = i; + return runsOn.withEntries(ListUtils.map(entries, (idx, e) -> + idx == index ? e.withValue(removeFrom((Yaml.Sequence) e.getValue())) : e)); + } return runsOn; } return runsOn; @@ -132,6 +135,14 @@ private static boolean hasGroup(Yaml.Mapping runsOn) { return false; } + private boolean consistsOfRunner(Yaml.Block labels) { + if (labels instanceof Yaml.Sequence) { + List entries = ((Yaml.Sequence) labels).getEntries(); + return !entries.isEmpty() && entries.stream().allMatch(e -> isRunner(e.getBlock())); + } + return isRunner(labels); + } + private boolean isRunner(Yaml.Block block) { return block instanceof Yaml.Scalar && runner.equalsIgnoreCase(((Yaml.Scalar) block).getValue()); } diff --git a/src/test/java/org/openrewrite/github/RemoveRunnerTest.java b/src/test/java/org/openrewrite/github/RemoveRunnerTest.java index 1efdd1c..41ee370 100644 --- a/src/test/java/org/openrewrite/github/RemoveRunnerTest.java +++ b/src/test/java/org/openrewrite/github/RemoveRunnerTest.java @@ -273,6 +273,47 @@ void removeScalarLabelWhenGroupRemains() { ); } + @Test + void removeEmptiedLabelsWhenGroupRemains() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("build", "gpu")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: + group: ubuntu-runners + labels: [gpu] + """, + """ + jobs: + build: + runs-on: + group: ubuntu-runners + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void keepSequenceLabelsWithoutGroup() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("build", "gpu")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: + labels: [gpu] + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + @Test void keepScalarLabelWithoutGroup() { rewriteRun( From f2455fde074eb8d7f4c9eb11db38c07139c6fbb7 Mon Sep 17 00:00:00 2001 From: Greg Oledzki Date: Thu, 8 Oct 2026 11:06:59 +0200 Subject: [PATCH 3/4] Keep comments of retained runner entries --- .../org/openrewrite/github/RemoveRunner.java | 16 +++++- .../openrewrite/github/RemoveRunnerTest.java | 52 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/openrewrite/github/RemoveRunner.java b/src/main/java/org/openrewrite/github/RemoveRunner.java index 1854151..d5178d2 100644 --- a/src/main/java/org/openrewrite/github/RemoveRunner.java +++ b/src/main/java/org/openrewrite/github/RemoveRunner.java @@ -25,11 +25,13 @@ import java.util.ArrayList; import java.util.List; +import java.util.regex.Pattern; @EqualsAndHashCode(callSuper = false) @Value public class RemoveRunner extends Recipe { private static final JsonPathMatcher RUNS_ON = new JsonPathMatcher("$.jobs.*.runs-on"); + private static final Pattern TRAILING_COMMENT = Pattern.compile("^[ \\t]*#[^\\n]*"); @Option(displayName = "Job name", description = "The name of the job to update, use `*` to affect all the workflow jobs.", @@ -78,9 +80,13 @@ private boolean isTargetJob() { private Yaml.Sequence removeFrom(Yaml.Sequence sequence) { List entries = sequence.getEntries(); List remaining = new ArrayList<>(entries.size()); + boolean previousRemoved = false; for (Yaml.Sequence.Entry entry : entries) { - if (!isRunner(entry.getBlock())) { - remaining.add(entry); + if (isRunner(entry.getBlock())) { + previousRemoved = true; + } else { + remaining.add(previousRemoved ? entry.withPrefix(withoutTrailingComment(entry.getPrefix())) : entry); + previousRemoved = false; } } if (remaining.size() == entries.size() || remaining.isEmpty()) { @@ -113,6 +119,8 @@ private Yaml.Mapping removeFromLabels(Yaml.Mapping runsOn) { updated.remove(i); if (i == 0) { updated.set(0, updated.get(0).withPrefix(entry.getPrefix())); + } else if (i < updated.size()) { + updated.set(i, updated.get(i).withPrefix(withoutTrailingComment(updated.get(i).getPrefix()))); } return runsOn.withEntries(updated); } @@ -126,6 +134,10 @@ private Yaml.Mapping removeFromLabels(Yaml.Mapping runsOn) { return runsOn; } + private static String withoutTrailingComment(String prefix) { + return TRAILING_COMMENT.matcher(prefix).replaceFirst(""); + } + private static boolean hasGroup(Yaml.Mapping runsOn) { for (Yaml.Mapping.Entry entry : runsOn.getEntries()) { if ("group".equals(entry.getKey().getValue())) { diff --git a/src/test/java/org/openrewrite/github/RemoveRunnerTest.java b/src/test/java/org/openrewrite/github/RemoveRunnerTest.java index 41ee370..203c588 100644 --- a/src/test/java/org/openrewrite/github/RemoveRunnerTest.java +++ b/src/test/java/org/openrewrite/github/RemoveRunnerTest.java @@ -121,6 +121,32 @@ void removeRunnerFromBlockSequence() { ); } + @Test + void dropTrailingCommentOfRemovedRunner() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("build", "gpu")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: + - self-hosted + - gpu # for CUDA tests + - x64 # architecture + """, + """ + jobs: + build: + runs-on: + - self-hosted + - x64 # architecture + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + @Test void removeRunnerFromEveryJob() { rewriteRun( @@ -273,6 +299,32 @@ void removeScalarLabelWhenGroupRemains() { ); } + @Test + void dropTrailingCommentOfRemovedLabels() { + rewriteRun( + spec -> spec.recipe(new RemoveRunner("build", "gpu")), + //language=yaml + yaml( + """ + jobs: + build: + runs-on: + group: ubuntu-runners + labels: gpu # for CUDA tests + custom: value + """, + """ + jobs: + build: + runs-on: + group: ubuntu-runners + custom: value + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + @Test void removeEmptiedLabelsWhenGroupRemains() { rewriteRun( From 3714ab9052628b70cbd5745b8ee5308eed2d3f41 Mon Sep 17 00:00:00 2001 From: Greg Oledzki Date: Thu, 8 Oct 2026 11:07:06 +0200 Subject: [PATCH 4/4] Check `runs-on` key before JsonPath --- src/main/java/org/openrewrite/github/RemoveRunner.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/openrewrite/github/RemoveRunner.java b/src/main/java/org/openrewrite/github/RemoveRunner.java index d5178d2..dd3d438 100644 --- a/src/main/java/org/openrewrite/github/RemoveRunner.java +++ b/src/main/java/org/openrewrite/github/RemoveRunner.java @@ -55,7 +55,7 @@ public TreeVisitor getVisitor() { @Override public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) { Yaml.Mapping.Entry e = super.visitMappingEntry(entry, ctx); - if (!RUNS_ON.matches(getCursor()) || !isTargetJob()) { + if (!"runs-on".equals(e.getKey().getValue()) || !RUNS_ON.matches(getCursor()) || !isTargetJob()) { return e; } if (e.getValue() instanceof Yaml.Sequence) {