Conditional Execution Strategies in GitHub Actions for Path-Specific Workflows

Implementing conditional logic in GitHub Actions to trigger jobs or steps only when specific files change is a critical optimization technique for modern continuous integration and continuous deployment (CI/CD) pipelines. While the platform provides native support for filtering entire workflows based on file paths, executing specific jobs or individual steps conditionally requires more sophisticated approaches. This limitation is particularly relevant in complex repository structures, such as monorepos containing multiple microservices, where unnecessary execution of unrelated deployment pipelines wastes computational resources and increases cycle times. By leveraging native path filters for workflows, third-party marketplace actions like dorny/paths-filter, custom scripting with git diff and PowerShell, or reusable workflows, engineers can achieve granular control over pipeline execution, significantly reducing costs and improving efficiency.

Native Workflow-Level Path Filtering

GitHub Actions natively supports triggering entire workflows based on file changes through the paths configuration within event triggers. This is the simplest and most efficient method for scenarios where the entire workflow should only run when specific directories or files are modified. The native filter operates at the workflow level, meaning if the specified paths are not changed, the workflow is never triggered, and no runners are consumed.

To implement this, the paths key is added under the event trigger, such as push or pull_request. For example, to ensure a workflow only runs when files within the src directory are modified, the configuration utilizes a glob pattern. This approach effectively skips the workflow execution for changes to documentation files like README.md or other non-source code files.

yaml on: push: paths: - 'src/**' pull_request: paths: - 'src/**'

This configuration ensures that the workflow executes only when files inside the src/ folder change. If a commit only modifies README.md or other files outside the src/ directory, the workflow is bypassed entirely. For more granular control at the workflow level, the paths-ignore key can be used in conjunction with paths to explicitly exclude specific files. This is useful when a broad path is specified, but certain files within that path should not trigger the workflow.

yaml on: push: paths: - 'src/**' paths-ignore: - 'README.md'

In this scenario, the workflow runs when files in src/ change, but explicitly skips execution if the only change is to README.md. While effective for simple workflows, native path filters cannot be used to conditionally run individual jobs or steps within a workflow that is triggered by other events or broader path changes.

Conditional Job Execution with Third-Party Actions

GitHub Actions does not natively support triggering individual jobs based on file changes after a workflow has been triggered. To address this limitation, developers often rely on community-created marketplace actions. The most prominent solution for this use case is the dorny/paths-filter action. This action allows for the definition of multiple path filters and outputs boolean values indicating whether specific paths have changed. This enables subsequent jobs to conditionally execute based on these outputs.

The implementation typically involves a dedicated job that runs the dorny/paths-filter action to check for changes. This job must define outputs that map to the filtered paths. Other jobs in the workflow then depend on this check job using the needs keyword and utilize an if condition to evaluate the output values.

```yaml
on:
push:
branches:
- main

jobs:
changes:
runs-on: ubuntu-latest
outputs:
src: ${{ steps.changes.outputs.src }}
infra: ${{ steps.changes.outputs.infra }}
steps:
- uses: actions/checkout@v3
- uses: dorny/paths-filter@v2
id: changes
with:
filters: |
src:
- 'src/'
infra:
- 'infra/
'

src:
needs: changes
if: ${{ needs.changes.outputs.src == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: ...

infra:
needs: changes
if: ${{ needs.changes.outputs.infra == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: ...
```

In this example, the workflow triggers on pushes to the main branch. The changes job executes first, checking for modifications in both src/ and infra/ directories. The dorny/paths-filter action sets outputs src and infra to true or false based on the presence of changes. The src job then only runs if needs.changes.outputs.src is true, and similarly for the infra job. This pattern is particularly useful for infrastructure-as-code workflows, such as running Terraform only when files in the deploy/tf/** directory change, thereby shaving minutes off pipeline execution time and reducing cloud infrastructure costs.

```yaml
- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
tf:
- 'deploy/tf/**'

  • run: terraform apply ...
    if: steps.changes.outputs.tf == 'true'
    ```

This approach allows for conditional execution at both the job and step level within a workflow. By checking steps.changes.outputs.tf == 'true', a specific step can be skipped if no infrastructure changes are detected, further optimizing the pipeline.

Custom Scripting for Conditional Steps and Jobs

For environments that prefer not to rely on third-party actions or require more complex logic, custom scripting using git diff and PowerShell Core can be employed to implement conditional execution. This method involves writing a script that calculates the difference between the current commit and the previous one, filters the results based on specific criteria, and sets a workflow output that can be referenced by subsequent steps or jobs.

To implement this, the workflow must checkout the repository with sufficient depth to allow for diffing. The fetch-depth: 2 option ensures that the previous commit is available. A PowerShell script then uses git diff --name-only HEAD^ HEAD to retrieve the list of modified files. This list is filtered using regex patterns to identify changes in specific directories or file types. The result is then written to the workflow output using the ::set-output command.

```yaml
name: demo
on:
push:
branches:
- 'main'

jobs:
conditionalstep:
runs-on: 'ubuntu-20.04'
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 2
- shell: pwsh
id: check
filechanged
run: |
$diff = git diff --name-only HEAD^ HEAD
$SourceDiff = $diff | Where-Object { $
-match '^docs/' -or $_ -match '.md$' }
$HasDiff = $SourceDiff.Length -gt 0
Write-Host "::set-output name=docschanged::$HasDiff"
- shell: pwsh
if: steps.check
filechanged.outputs.docschanged == 'True'
run: echo publish docs
```

In this example, the script checks if any files in the docs/ directory or any Markdown files (.md) have changed. If changes are detected, the docs_changed output is set to True. The subsequent step then only runs if this condition is met. This approach can also be extended to conditionally run jobs by defining outputs at the job level and referencing them in dependent jobs using the needs keyword.

Reusable Workflows for Monorepo Optimizations

In monorepo architectures, where multiple microservices reside in a single repository, efficient pipeline management is crucial. Running full deployment pipelines for every commit, even when only a subset of services is affected, is wasteful. Reusable workflows offer a standardized way to check for path changes and conditionally execute downstream workflows or jobs. A custom reusable workflow can be created to check if files in a specified directory have changed and output a boolean value indicating whether the calling workflow should proceed.

This reusable workflow is triggered using workflow_call and accepts an input for the path to check. It leverages the GITHUB_TOKEN to access commit data and determines if changes exist within the specified path. The output should_run is then used by calling workflows to decide whether to execute their specific deployment jobs.

```yaml
name: Check Path Changes
on:
workflowcall:
inputs:
path
tocheck:
required: true
type: string
description: "Path prefix to check for changes"
outputs:
should
run:
description: "Whether the calling workflow should run based on path changes"
value: ${{ jobs.checkchanges.outputs.shouldrun }}

jobs:
checkchanges:
name: "Check for changes in ${{ inputs.path
tocheck }}"
runs-on: ubuntu-latest
outputs:
should
run: ${{ steps.check.outputs.shouldrun }}
steps:
- name: Check for changes
id: check
env:
GITHUB
TOKEN: ${{ secrets.GITHUBTOKEN }}
run: |
if [[ "${{ github.event
name }}" == "workflowdispatch" ]]; then
echo "should
run=true" >> $GITHUBOUTPUT
else
# Logic to check git diff for path
tocheck
# Set should
run=true or false based on result
fi
```

When integrating this reusable workflow into a deployment pipeline, a job is added that calls the check-path-changes.yml workflow. This job requires specific permissions to access commit data and pull requests. The subsequent deployment job then depends on this check job and uses an if condition to evaluate the should_run output.

```yaml
jobs:
checkchanges:
name: "Check for changes in directory A"
uses: ./.github/workflows/check-path-changes.yml
permissions:
actions: write
contents: read
pull-requests: read
with:
path
to_check: "directory A"

deploy:
name: "Deploy"
runs-on: ubuntu-latest
needs: [checkchanges]
if: ${{ needs.check
changes.outputs.should_run == 'true' }}
steps:
- run: |
echo "Deploying..."
```

This pattern ensures that service A only deploys if files in directory A have changed, preventing unnecessary deployments of unrelated services. The needs property ensures the deployment job waits for the check job to complete, and the if property enforces the conditional logic. This approach is particularly effective in large-scale monorepos where minimizing redundant CI/CD runs is essential for cost and time efficiency.

Conclusion

Optimizing GitHub Actions pipelines by conditionally executing jobs and steps based on file changes is a multifaceted challenge that requires selecting the appropriate strategy for the specific repository structure and workflow complexity. Native path filtering provides a lightweight, efficient solution for entire workflows but lacks granularity for internal job logic. For more granular control, third-party actions like dorny/paths-filter offer a robust, community-supported mechanism to evaluate changes and trigger specific jobs. Custom scripting with git diff and PowerShell provides maximum flexibility for complex logic without external dependencies. Finally, reusable workflows present a scalable solution for monorepo environments, enabling standardized path-checking patterns across multiple services. By implementing these strategies, development teams can significantly reduce CI/CD costs, shorten feedback loops, and ensure that resources are only consumed when necessary, leading to more efficient and responsive software delivery processes.

Sources

  1. GitHub Community Discussion on Path Filters
  2. CBUI Dev: How To Only Run GitHub Actions Steps If Files Change
  3. How.wtf: Run Workflow Step or Job Based on File Changes
  4. Meziantou: Executing GitHub Actions Jobs or Steps Only When Specific Files Change
  5. Norday Tech: Only Run GitHub Actions When Certain Files Have Changed

Related Posts