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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* Copyright 2026 the original author or authors.
* <p>
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* https://docs.moderne.io/licensing/moderne-source-available-license
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openrewrite.github;

import lombok.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("'(?:[^']|'')*'|((?<![A-Za-z0-9_.])always\\s*\\(\\s*\\)(?![A-Za-z0-9_]))");
private static final Pattern ONLY_ALWAYS = Pattern.compile("\\s*(?:always\\s*\\(\\s*\\)|\\$\\{\\{\\s*always\\s*\\(\\s*\\)\\s*}})\\s*");
private static final String REPLACEMENT = "success() || failure()";
private static final String PARENTHESIZED_REPLACEMENT = "(" + REPLACEMENT + ")";
private static final BlockScalar.Matcher BLOCK_SCALAR = new BlockScalar.Matcher();
private static final JsonPathMatcher STEP_CONDITION = new JsonPathMatcher("$..steps[*].if");
private static final JsonPathMatcher JOB_CONDITION = new JsonPathMatcher("$.jobs.*.if");

@Getter
final String displayName = "Replace `always()` with `success() || failure()`";

@Getter
final String description = "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.";

@Override
public TreeVisitor<?, ExecutionContext> getVisitor() {
return Preconditions.check(new IsGitHubActionsFile(), new YamlIsoVisitor<ExecutionContext>() {
@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> 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();
}
}
28 changes: 28 additions & 0 deletions src/main/resources/META-INF/rewrite/examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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`'
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/META-INF/rewrite/recipes.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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}]",
Expand Down
Loading
Loading