diff --git a/src/main/java/org/openrewrite/github/AutoCancelInProgressWorkflow.java b/src/main/java/org/openrewrite/github/AutoCancelInProgressWorkflow.java index 1a098bd..3d82961 100644 --- a/src/main/java/org/openrewrite/github/AutoCancelInProgressWorkflow.java +++ b/src/main/java/org/openrewrite/github/AutoCancelInProgressWorkflow.java @@ -15,73 +15,38 @@ */ package org.openrewrite.github; -import lombok.EqualsAndHashCode; -import lombok.Value; -import org.jspecify.annotations.Nullable; -import org.openrewrite.*; -import org.openrewrite.internal.ListUtils; -import org.openrewrite.internal.StringUtils; -import org.openrewrite.yaml.JsonPathMatcher; -import org.openrewrite.yaml.YamlIsoVisitor; -import org.openrewrite.yaml.YamlParser; -import org.openrewrite.yaml.tree.Yaml; +import lombok.Getter; +import org.openrewrite.Recipe; +import org.openrewrite.yaml.MergeYaml; -@EqualsAndHashCode(callSuper = false) -@SuppressWarnings("ConcatenationWithEmptyString") -@Value -public class AutoCancelInProgressWorkflow extends Recipe { - @Option(displayName = "Optional access token", - description = "Optionally provide the key name of a repository or organization secret that contains a GitHub personal access token with permission to cancel workflows.", - required = false, - example = "WORKFLOWS_ACCESS_TOKEN") - @Nullable - String accessToken; - - String displayName = "Cancel in-progress workflow when it is triggered again"; +import java.util.List; - String description = "When a workflow is already running and would be triggered again, cancel the existing workflow. " + - "See [`styfle/cancel-workflow-action`](https://github.com/styfle/cancel-workflow-action) for details."; +import static java.util.Collections.singletonList; - @Override - public TreeVisitor getVisitor() { - JsonPathMatcher firstStep = new JsonPathMatcher("$.jobs..steps[:1].uses"); - JsonPathMatcher jobSteps = new JsonPathMatcher("$.jobs..steps.*"); +public class AutoCancelInProgressWorkflow extends Recipe { - String userProvidedAccessTokenTemplate = "" + - "- uses: styfle/cancel-workflow-action@0.9.1\n" + - " with:\n" + - " access_token: ${{ secrets." + accessToken + " }}"; + private static final String CONCURRENCY = "concurrency:\n" + + " group: ${{ github.workflow }}-${{ github.ref }}\n" + + " cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}"; - String defaultAccessTokenTemplate = "" + - "- uses: styfle/cancel-workflow-action@0.9.1\n" + - " with:\n" + - " access_token: ${{ github.token }}"; + @Getter + final String displayName = "Cancel in-progress workflow when it is triggered again"; - return Preconditions.check(new IsGitHubActionsWorkflow(), new YamlIsoVisitor() { - @Override - public Yaml.Mapping.Entry visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) { - if (firstStep.matches(getCursor()) && (!(entry.getValue() instanceof Yaml.Scalar) || - !((Yaml.Scalar) entry.getValue()).getValue().contains("cancel-workflow-action"))) { - getCursor().dropParentUntil(Yaml.Sequence.class::isInstance).putMessage("ADD_STEP", true); - } - return super.visitMappingEntry(entry, ctx); - } + @Getter + final String description = "When a workflow is already running and would be triggered again, cancel the existing workflow, " + + "through the native [`concurrency`](https://docs.github.com/en/actions/using-jobs/using-concurrency) property. " + + "Runs on the default branch are not cancelled."; - @Override - public Yaml.Sequence visitSequence(Yaml.Sequence sequence, ExecutionContext ctx) { - Yaml.Sequence s = super.visitSequence(sequence, ctx); - if (jobSteps.matches(getCursor()) && Boolean.TRUE.equals(getCursor().getMessage("ADD_STEP"))) { - Yaml.Documents documents = new YamlParser() - .parse(ctx, StringUtils.isNullOrEmpty(accessToken) ? defaultAccessTokenTemplate : userProvidedAccessTokenTemplate) - .map(Yaml.Documents.class::cast) - .findFirst() - .get(); - Yaml.Sequence.Entry cancelWorkflowAction = ((Yaml.Sequence) documents.getDocuments().get(0).getBlock()).getEntries().get(0); - cancelWorkflowAction = autoFormat(cancelWorkflowAction.withPrefix("\n"), ctx, getCursor()); - return s.withEntries(ListUtils.concat(cancelWorkflowAction, s.getEntries())); - } - return s; - } - }); + @Override + public List getRecipeList() { + return singletonList(new MergeYaml( + "$", + CONCURRENCY, + true, + null, + ".github/workflows/*.{yml,yaml}", + MergeYaml.InsertMode.Before, + "jobs", + null)); } } diff --git a/src/main/resources/META-INF/rewrite/examples.yml b/src/main/resources/META-INF/rewrite/examples.yml index 1a0047f..439febe 100644 --- a/src/main/resources/META-INF/rewrite/examples.yml +++ b/src/main/resources/META-INF/rewrite/examples.yml @@ -80,25 +80,31 @@ examples: type: specs.openrewrite.org/v1beta/example recipeName: org.openrewrite.github.AutoCancelInProgressWorkflow examples: -- description: '`AutoCancelInProgressWorkflowTest#useDefaultAccessToken`' - parameters: - - 'null' +- description: '`AutoCancelInProgressWorkflowTest#addConcurrency`' sources: - before: | + on: + push: + branches: + - main jobs: build: runs-on: linux steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 after: | + on: + push: + branches: + - main + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: build: runs-on: linux steps: - - uses: styfle/cancel-workflow-action@0.9.1 - with: - access_token: ${{ github.token }} - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 path: .github/workflows/ci.yml language: yaml --- diff --git a/src/main/resources/META-INF/rewrite/recipes.csv b/src/main/resources/META-INF/rewrite/recipes.csv index 4dfd62f..ebff36f 100644 --- a/src/main/resources/META-INF/rewrite/recipes.csv +++ b/src/main/resources/META-INF/rewrite/recipes.csv @@ -2,7 +2,7 @@ ecosystem,packageName,name,displayName,description,recipeCount,category1,categor maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.AddCronTrigger,Add cron workflow trigger,The `schedule` [event](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#scheduled-events) allows you to trigger a workflow at a scheduled time.,1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""cron"",""type"":""String"",""displayName"":""Cron expression"",""description"":""Using the [POSIX cron syntax](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/crontab.html#tag_20_25_07) or the non standard options @hourly @daily @weekly @weekdays @weekends @monthly @yearly."",""example"":""@daily"",""required"":true},{""name"":""workflowFileMatcher"",""type"":""String"",""displayName"":""Workflow files to match"",""description"":""Matches one or more workflows to update. Defaults to `*.{yml,yaml}`"",""example"":""build.yml""}]", maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.AddDependabotCooldown,Add cooldown periods to Dependabot configuration,"Adds a `cooldown` section to each update configuration in Dependabot files. Supports `default-days`, `semver-major-days`, `semver-minor-days`, `semver-patch-days`, `include`, and `exclude` options. This implements a security best practice where dependencies are not immediately adopted upon release, allowing time for security vendors to identify potential supply chain compromises. Cooldown applies only to version updates, not security updates. [Read more about dependency cooldowns](https://blog.yossarian.net/2025/11/21/We-should-all-be-using-dependency-cooldowns). [The available configuration options for dependabot are listed on GitHub](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates).",1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""cooldownDays"",""type"":""Integer"",""displayName"":""Default cooldown days"",""description"":""The number of days to wait before considering a published dependency suitable for use (1-90). This delay allows security vendors time to identify potential compromises. Applied to all version types unless specific semver options are set."",""example"":""7""},{""name"":""semverMajorDays"",""type"":""Integer"",""displayName"":""Semver major cooldown days"",""description"":""The number of days to wait for major version updates (1-90). Only applies to package managers that support semantic versioning."",""example"":""14""},{""name"":""semverMinorDays"",""type"":""Integer"",""displayName"":""Semver minor cooldown days"",""description"":""The number of days to wait for minor version updates (1-90). Only applies to package managers that support semantic versioning."",""example"":""7""},{""name"":""semverPatchDays"",""type"":""Integer"",""displayName"":""Semver patch cooldown days"",""description"":""The number of days to wait for patch version updates (1-90). Only applies to package managers that support semantic versioning."",""example"":""3""},{""name"":""include"",""type"":""List"",""displayName"":""Include dependencies"",""description"":""List of up to 150 dependencies to apply cooldown to. Supports wildcard patterns with `*`. If not specified, cooldown applies to all dependencies."",""example"":""lodash, react*""},{""name"":""exclude"",""type"":""List"",""displayName"":""Exclude dependencies"",""description"":""List of up to 150 dependencies to exempt from cooldown. Supports wildcard patterns with `*`. Exclude list takes precedence over include list."",""example"":""critical-security-package""},{""name"":""excludeEcosystems"",""type"":""List"",""displayName"":""Exclude ecosystems"",""description"":""List of ecosystems to be excluded"",""example"":""github-actions""}]", maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.AddManualTrigger,Add manual workflow trigger,"You can manually trigger workflow runs. To trigger specific workflows in a repository, use the `workflow_dispatch` event.",2,,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.AutoCancelInProgressWorkflow,Cancel in-progress workflow when it is triggered again,"When a workflow is already running and would be triggered again, cancel the existing workflow. See [`styfle/cancel-workflow-action`](https://github.com/styfle/cancel-workflow-action) for details.",1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""accessToken"",""type"":""String"",""displayName"":""Optional access token"",""description"":""Optionally provide the key name of a repository or organization secret that contains a GitHub personal access token with permission to cancel workflows."",""example"":""WORKFLOWS_ACCESS_TOKEN""}]", +maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.AutoCancelInProgressWorkflow,Cancel in-progress workflow when it is triggered again,"When a workflow is already running and would be triggered again, cancel the existing workflow, through the native [`concurrency`](https://docs.github.com/en/actions/using-jobs/using-concurrency) property. Runs on the default branch are not cancelled.",2,,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.ChangeAction,Change GitHub Action,Change a GitHub Action in any workflow.,1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""oldAction"",""type"":""String"",""displayName"":""Action"",""description"":""Name of the action to match."",""example"":""gradle/wrapper-validation-action"",""required"":true},{""name"":""oldSha"",""type"":""String"",""displayName"":""Old commit SHA"",""description"":""Restricts the change by the existing `uses:` ref. When omitted, the action is changed regardless of how it is pinned (the default; commit SHA pins are rewritten). When set to an empty string, only references that are **not** pinned to a 40-character commit SHA are changed, leaving deliberate SHA pins on the original action untouched. When set to a specific commit SHA, only references pinned to exactly that SHA are changed."",""example"":""8f4b7f84864484a7bf31766abe9204da3cbe65b3""},{""name"":""newAction"",""type"":""String"",""displayName"":""Action"",""description"":""Name of the action to use instead."",""example"":""gradle/actions/wrapper-validation"",""required"":true},{""name"":""newVersion"",""type"":""String"",""displayName"":""Version"",""description"":""New version to use."",""example"":""v3"",""required"":true}]", maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.ChangeActionVersion,Change GitHub Action version,Change the version of a GitHub Action in any workflow.,1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""action"",""type"":""String"",""displayName"":""Action"",""description"":""Name of the action to update."",""example"":""actions/setup-java"",""required"":true},{""name"":""version"",""type"":""String"",""displayName"":""Version"",""description"":""Version to use."",""example"":""v4"",""required"":true},{""name"":""oldSha"",""type"":""String"",""displayName"":""Old commit SHA"",""description"":""Restricts the change by the existing `uses:` ref. When omitted, the version is changed regardless of how the action is pinned (the default; commit SHA pins are rewritten). When set to an empty string, only references that are **not** pinned to a 40-character commit SHA are changed, preserving deliberate SHA pins. When set to a specific commit SHA, only references pinned to exactly that SHA are changed."",""example"":""8f4b7f84864484a7bf31766abe9204da3cbe65b3""}]", maven,org.openrewrite.recipe:rewrite-github-actions,org.openrewrite.github.ChangeDependabotScheduleInterval,Change dependabot schedule interval,Change the schedule interval for a given package-ecosystem in a `dependabot.yml` configuration file. [The available configuration options for dependabot are listed on GitHub](https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates).,1,,GitHub Actions,,Recipes to perform [GitHub Actions](https://docs.github.com/en/actions) hygiene and migration tasks.,"[{""name"":""packageEcosystem"",""type"":""String"",""displayName"":""Package ecosystem"",""description"":""The package-ecosystem to make updates on."",""example"":""maven"",""required"":true},{""name"":""interval"",""type"":""String"",""displayName"":""Schedule interval"",""description"":""The schedule interval value the package-ecosystem should use."",""example"":""weekly"",""valid"":[""daily"",""weekly"",""monthly""],""required"":true}]", diff --git a/src/test/java/org/openrewrite/github/AutoCancelInProgressWorkflowTest.java b/src/test/java/org/openrewrite/github/AutoCancelInProgressWorkflowTest.java index 8447a17..0635d7c 100644 --- a/src/test/java/org/openrewrite/github/AutoCancelInProgressWorkflowTest.java +++ b/src/test/java/org/openrewrite/github/AutoCancelInProgressWorkflowTest.java @@ -17,35 +17,48 @@ 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 AutoCancelInProgressWorkflowTest implements RewriteTest { + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new AutoCancelInProgressWorkflow()); + } + @DocumentExample @Test - void useDefaultAccessToken() { + void addConcurrency() { rewriteRun( - spec -> spec.recipe(new AutoCancelInProgressWorkflow(null)), //language=yaml yaml( """ + on: + push: + branches: + - main jobs: build: runs-on: linux steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 """, """ + on: + push: + branches: + - main + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: build: runs-on: linux steps: - - uses: styfle/cancel-workflow-action@0.9.1 - with: - access_token: ${{ github.token }} - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 """, spec -> spec.path(".github/workflows/ci.yml") ) @@ -53,29 +66,37 @@ void useDefaultAccessToken() { } @Test - void useUserProvidedAccessToken() { + void retainExistingConcurrency() { rewriteRun( - spec -> spec.recipe(new AutoCancelInProgressWorkflow("WORKFLOWS_ACCESS_TOKEN")), //language=yaml yaml( """ + on: push + concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true jobs: build: runs-on: linux steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 """, + spec -> spec.path(".github/workflows/ci.yml") + ) + ); + } + + @Test + void notAWorkflowFile() { + rewriteRun( + //language=yaml + yaml( """ jobs: build: runs-on: linux - steps: - - uses: styfle/cancel-workflow-action@0.9.1 - with: - access_token: ${{ secrets.WORKFLOWS_ACCESS_TOKEN }} - - uses: actions/checkout@v2 """, - spec -> spec.path(".github/workflows/ci.yml") + spec -> spec.path("src/main/resources/application.yml") ) ); }