GitHub Actions has a hidden superpower: its if expressions let you control exactly when a step runs, going way beyond simple branch or tag checks.

Let’s see it in action. Imagine you have a workflow that deploys to production, but you only want to run the deployment step if a specific tag is pushed and a certain environment variable is set.

name: Conditional Deployment

on:
  push:
    tags:
      - 'v*.*.*' # Matches tags like v1.0.0, v2.1.5

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install dependencies
        run: npm install

      - name: Build application
        run: npm run build

      - name: Deploy to Production
        # This is the magic: only runs if tag starts with 'v' AND secrets.PROD_DEPLOY_ENABLED is 'true'
        if: startsWith(github.ref, 'refs/tags/v') && secrets.PROD_DEPLOY_ENABLED == 'true'
        run: |
          echo "Deploying production version..."
          # Your actual deployment commands here
          # For example: aws s3 sync dist/ s3://my-prod-bucket/

In this example, the Deploy to Production step has an if condition. It will only execute if two things are true:

  1. github.ref starts with 'refs/tags/v'. This checks if the push event was for a tag that begins with v, like v1.0.0.
  2. secrets.PROD_DEPLOY_ENABLED is exactly equal to the string 'true'. This allows you to toggle production deployments via a repository secret.

This granular control is incredibly powerful. You can gate steps based on the branch name, tag name, commit message, the status of previous jobs, context information like the event payload, and even custom logic using GitHub Actions’ expression syntax.

The core of this power lies in the github context object, which provides access to a wealth of information about the workflow run. You can inspect github.event_name to see what triggered the workflow (e.g., push, pull_request, schedule), github.ref for branches and tags, github.sha for the commit hash, and github.actor for the user or bot that triggered the workflow.

Beyond the github context, you can also leverage other contexts like secrets (for sensitive information), env (for environment variables), and inputs (for workflow dispatch inputs). The expression syntax supports standard logical operators (&& for AND, || for OR, ! for NOT), comparison operators (==, !=, <, >, <=, >=), and a rich set of functions.

Some of the most useful functions include:

  • startsWith(string, searchString): Checks if a string begins with another string. Perfect for branch or tag prefixes.
  • endsWith(string, searchString): Checks if a string ends with another string. Useful for file extensions or specific suffixes.
  • contains(string, searchString): Checks if a string contains another string. Good for filtering commit messages.
  • format(string, ...args): Formats a string with arguments, similar to printf.
  • toJSON(object): Converts an object to a JSON string.
  • fromJSON(jsonString): Parses a JSON string into an object.

You can also compare numerical values, boolean values, and even check for the existence of properties. For instance, to run a step only if the BUILD_TYPE environment variable is set to release:

      - name: Run Release Build
        if: env.BUILD_TYPE == 'release'
        run: echo "Performing release build..."

Or, to run a step only if a specific file was changed in a pull_request event:

      - name: Run Linter on Changed Files
        if: github.event_name == 'pull_request' && contains(github.event.pull_request.changed_files, 'src/my_module.js')
        run: echo "Linter will run on src/my_module.js"

The if condition is evaluated before a step starts. If the condition evaluates to false, the step is skipped entirely, and subsequent steps are not affected unless they also have conditions that depend on the skipped step’s success. This is different from setting continue-on-error: true, which allows a step to fail without stopping the workflow.

A common pattern is to use if to control the execution of entire jobs, not just individual steps. This can be done at the job level:

jobs:
  test:
    runs-on: ubuntu-latest
    if: github.ref != 'refs/heads/main' # Only run tests if not on the main branch
    steps:
      - ...

This if expression is evaluated when the job is scheduled. If the condition is false, the entire test job is skipped.

When constructing complex if expressions, remember to enclose string literals in single quotes (') and use the correct operators. Debugging these expressions can sometimes be tricky. A useful technique is to add a temporary step that prints out the values you’re trying to evaluate. For example, to debug the github.ref value:

      - name: Debug Ref

        run: echo "github.ref is: ${{ github.ref }}"

This will print the exact value of github.ref during the workflow run, helping you pinpoint discrepancies in your conditions.

The true power of conditional logic in GitHub Actions becomes apparent when you start combining different contexts and functions. For example, you might want to deploy to a staging environment on every push to a develop branch, but only deploy to production on specific tags and if a manual approval step has been completed via an environment protection rule.

This system allows you to build highly sophisticated and automated CI/CD pipelines that respond dynamically to the context of each workflow run, ensuring that the right actions are taken at the right time and for the right reasons.

The next hurdle you’ll likely encounter is how to manage secrets and sensitive data effectively within these conditional workflows.

Want structured learning?

Take the full Github-actions course →