diff --git a/src/main/java/org/openrewrite/github/ReplaceAlwaysWithSuccessOrFailure.java b/src/main/java/org/openrewrite/github/ReplaceAlwaysWithSuccessOrFailure.java new file mode 100644 index 0000000..fdbf490 --- /dev/null +++ b/src/main/java/org/openrewrite/github/ReplaceAlwaysWithSuccessOrFailure.java @@ -0,0 +1,90 @@ +/* + * 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.Getter; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Preconditions; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.yaml.JsonPathMatcher; +import org.openrewrite.yaml.YamlIsoVisitor; +import org.openrewrite.yaml.trait.BlockScalar; +import org.openrewrite.yaml.tree.Yaml; + +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class ReplaceAlwaysWithSuccessOrFailure extends Recipe { + // A leading string literal alternative consumes `'...'` so that only the unquoted group 1 calls are replaced + private static final Pattern ALWAYS_CALL = Pattern.compile("'(?:[^']|'')*'|((? getVisitor() { + return Preconditions.check(new IsGitHubActionsFile(), new YamlIsoVisitor() { + @Override + public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) { + Yaml.Mapping.Entry e = super.visitMappingEntry(entry, ctx); + if (!"if".equals(e.getKey().getValue()) || !(e.getValue() instanceof Yaml.Scalar)) { + return e; + } + Yaml.Scalar condition = (Yaml.Scalar) e.getValue(); + if (!condition.getValue().contains("always") || + !(STEP_CONDITION.matches(getCursor()) || JOB_CONDITION.matches(getCursor()))) { + return e; + } + Optional blockScalar = BLOCK_SCALAR.get(condition, getCursor()); + String value = blockScalar.isPresent() ? blockScalar.get().getBody() : condition.getValue(); + // `''` is YAML's escape for a quote, not a closed expression string + String updated = condition.getStyle() == Yaml.Scalar.Style.SINGLE_QUOTED ? + replaceAlways(value.replace("''", "'")).replace("'", "''") : + replaceAlways(value); + if (!value.equals(updated)) { + return e.withValue(blockScalar.isPresent() ? + blockScalar.get().withBody(updated) : condition.withValue(updated)); + } + return e; + } + }); + } + + private static String replaceAlways(String condition) { + String replacement = ONLY_ALWAYS.matcher(condition).matches() ? REPLACEMENT : PARENTHESIZED_REPLACEMENT; + Matcher matcher = ALWAYS_CALL.matcher(condition); + StringBuffer updated = new StringBuffer(); + while (matcher.find()) { + if (matcher.group(1) != null) { + matcher.appendReplacement(updated, replacement); + } + } + return matcher.appendTail(updated).toString(); + } +} diff --git a/src/main/resources/META-INF/rewrite/examples.yml b/src/main/resources/META-INF/rewrite/examples.yml index 1cda55a..107cc44 100644 --- a/src/main/resources/META-INF/rewrite/examples.yml +++ b/src/main/resources/META-INF/rewrite/examples.yml @@ -541,6 +541,34 @@ examples: language: yaml --- type: specs.openrewrite.org/v1beta/example +recipeName: org.openrewrite.github.ReplaceAlwaysWithSuccessOrFailure +examples: +- description: '`ReplaceAlwaysWithSuccessOrFailureTest#replacesJobAndStepConditions`' + sources: + - before: | + on: push + jobs: + build: + if: always() + runs-on: ubuntu-latest + steps: + - name: Upload results + if: ${{ always() }} + run: ./upload-results.sh + after: | + on: push + jobs: + build: + if: success() || failure() + runs-on: ubuntu-latest + steps: + - name: Upload results + if: ${{ success() || failure() }} + run: ./upload-results.sh + path: .github/workflows/ci.yml + language: yaml +--- +type: specs.openrewrite.org/v1beta/example recipeName: org.openrewrite.github.ReplaceDependabotReviewersWithCodeowners examples: - description: '`ReplaceDependabotReviewersWithCodeownersTest#migrateReviewersToNewCodeownersFile`' diff --git a/src/main/resources/META-INF/rewrite/recipes.csv b/src/main/resources/META-INF/rewrite/recipes.csv index 81e0f49..9f78616 100644 --- a/src/main/resources/META-INF/rewrite/recipes.csv +++ b/src/main/resources/META-INF/rewrite/recipes.csv @@ -22,6 +22,7 @@ maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.Prefe 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.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.,, maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.ReplaceDependabotReviewersWithCodeowners,Replace Dependabot `reviewers` with `CODEOWNERS`,"Replaces the [removed](https://github.blog/changelog/2025-04-29-dependabot-reviewers-configuration-option-being-replaced-by-code-owners/) `reviewers` option in `.github/dependabot.yml` with equivalent `CODEOWNERS` entries. Each reviewer is mapped onto the manifest files Dependabot updates for that `package-ecosystem` and `directory`, so ownership stays as narrow as the Dependabot configuration was. Update entries whose `package-ecosystem` has no known manifests are left untouched.",1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""codeownersPath"",""type"":""String"",""displayName"":""`CODEOWNERS` path"",""description"":""Where to write the migrated reviewers when the repository does not have a `CODEOWNERS` file yet. Defaults to `.github/CODEOWNERS`. When a `CODEOWNERS` file already exists in any of the locations GitHub recognizes, that file is appended to instead and this option is ignored."",""example"":""CODEOWNERS""}]", maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.ReplaceOssrhSecretsWithSonatype,Replace OSSRH secrets with Sonatype secrets,Replace deprecated OSSRH_S01 secrets with new Sonatype secrets in GitHub Actions workflows. This is an example use of the `ReplaceSecrets` and `ReplaceSecretKeys` recipes combined used to update the Maven publishing secrets in OpenRewrite's GitHub organization.,5,,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.ReplaceRunners,Replace runners for a job,Replaces the runners of a given job.,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"":""runners"",""type"":""List"",""displayName"":""Runners"",""description"":""The new list of runners to set"",""example"":""ubuntu-latest"",""required"":true}]", diff --git a/src/test/java/org/openrewrite/github/ReplaceAlwaysWithSuccessOrFailureTest.java b/src/test/java/org/openrewrite/github/ReplaceAlwaysWithSuccessOrFailureTest.java new file mode 100644 index 0000000..b101297 --- /dev/null +++ b/src/test/java/org/openrewrite/github/ReplaceAlwaysWithSuccessOrFailureTest.java @@ -0,0 +1,312 @@ +/* + * 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.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.yaml.Assertions.yaml; + +class ReplaceAlwaysWithSuccessOrFailureTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new ReplaceAlwaysWithSuccessOrFailure()); + } + + @DocumentExample + @Test + void replacesJobAndStepConditions() { + rewriteRun( + //language=yaml + yaml( + """ + on: push + jobs: + build: + if: always() + runs-on: ubuntu-latest + steps: + - name: Upload results + if: ${{ always() }} + run: ./upload-results.sh + """, + """ + on: push + jobs: + build: + if: success() || failure() + runs-on: ubuntu-latest + steps: + - name: Upload results + if: ${{ success() || failure() }} + run: ./upload-results.sh + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void replacesCompositeActionStepCondition() { + rewriteRun( + //language=yaml + yaml( + """ + name: Upload results + description: Upload test results + runs: + using: composite + steps: + - if: always() + shell: bash + run: ./upload-results.sh + """, + """ + name: Upload results + description: Upload test results + runs: + using: composite + steps: + - if: success() || failure() + shell: bash + run: ./upload-results.sh + """, + spec -> spec.path(".github/actions/upload/action.yml") + ) + ); + } + + @Test + void preservesPrecedenceInLargerExpressions() { + rewriteRun( + //language=yaml + yaml( + """ + on: push + jobs: + build: + runs-on: ubuntu-latest + steps: + - if: always() && github.ref == 'refs/heads/main' + run: ./publish.sh + - if: ${{ cancelled() || always() }} + run: ./cleanup.sh + """, + """ + on: push + jobs: + build: + runs-on: ubuntu-latest + steps: + - if: (success() || failure()) && github.ref == 'refs/heads/main' + run: ./publish.sh + - if: ${{ cancelled() || (success() || failure()) }} + run: ./cleanup.sh + """, + spec -> spec.path(".github/workflows/ci.yaml") + ) + ); + } + + @Test + void replacesCallsWithInternalWhitespace() { + rewriteRun( + //language=yaml + yaml( + """ + on: push + jobs: + build: + if: ${{ always ( ) }} + runs-on: ubuntu-latest + steps: + - run: ./build.sh + """, + """ + on: push + jobs: + build: + if: ${{ success() || failure() }} + runs-on: ubuntu-latest + steps: + - run: ./build.sh + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void preservesBlockScalarStyle() { + rewriteRun( + //language=yaml + yaml( + """ + on: push + jobs: + build: + runs-on: ubuntu-latest + steps: + - if: >- + always() && + github.ref == 'refs/heads/main' + run: ./publish.sh + """, + """ + on: push + jobs: + build: + runs-on: ubuntu-latest + steps: + - if: >- + (success() || failure()) && + github.ref == 'refs/heads/main' + run: ./publish.sh + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void doesNotChangeOtherConditionsOrValues() { + rewriteRun( + //language=yaml + yaml( + """ + on: push + jobs: + build: + if: notalways() + runs-on: ubuntu-latest + env: + DESCRIPTION: always() + steps: + - if: success() || failure() + run: echo always() + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void doesNotReplaceTextInsideStringLiterals() { + rewriteRun( + //language=yaml + yaml( + """ + on: push + jobs: + build: + runs-on: ubuntu-latest + steps: + - if: contains(github.event.head_commit.message, 'always()') + run: ./build.sh + - if: always() && contains(github.event.head_commit.message, 'always()') + run: ./publish.sh + """, + """ + on: push + jobs: + build: + runs-on: ubuntu-latest + steps: + - if: contains(github.event.head_commit.message, 'always()') + run: ./build.sh + - if: (success() || failure()) && contains(github.event.head_commit.message, 'always()') + run: ./publish.sh + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void doesNotReplaceTextInsideQuotedScalarStringLiterals() { + rewriteRun( + //language=yaml + yaml( + """ + on: push + jobs: + build: + runs-on: ubuntu-latest + steps: + - if: 'always() && contains(github.event.head_commit.message, ''always()'')' + run: ./publish.sh + - if: "always() && contains(github.event.head_commit.message, 'always()')" + run: ./upload.sh + """, + """ + on: push + jobs: + build: + runs-on: ubuntu-latest + steps: + - if: '(success() || failure()) && contains(github.event.head_commit.message, ''always()'')' + run: ./publish.sh + - if: "(success() || failure()) && contains(github.event.head_commit.message, 'always()')" + run: ./upload.sh + """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void doesNotChangeUnrelatedIfKeys() { + rewriteRun( + //language=yaml + yaml( + """ + name: Example + inputs: + if: + description: A regular input named if + default: always() + runs: + using: composite + steps: + - run: ./build.sh + shell: bash + """, + spec -> spec.path("action.yml") + ) + ); + } + + @Test + void doesNotChangeNonGitHubActionsYaml() { + rewriteRun( + //language=yaml + yaml( + """ + jobs: + build: + if: always() + steps: + - if: always() + run: ./build.sh + """, + spec -> spec.path("pipeline.yml") + ) + ); + } +}