my blog

$ cat github-actions/part-02.md

Variables, Expressions & Control Flow in GitHub Actions

After my first article about GitHub Actions basics, I want to share how I handle variables and control my workflows. This is where GitHub Actions becomes really powerful.

Understanding Variables in GitHub Actions

Variables can be defined at three levels, and they override each other in a specific order. Let me show you from my actual workflow:

The Three Levels

name: 08 - Using Variables

on: workflow_dispatch

env:
  WORKFLOW_VAR: 'I am a workflow env var'
  OVERWRITTEN: 'I will be overwritten'
  UNDEFINED_VAR_WITH_DEFAULT: ${{ vars.UNDEFINED_VAR || 'default value' }}

jobs:
  echo:
    runs-on: ubuntu-latest
    env:
      JOB_VAR: 'I am a job env var'
      OVERWRITTEN: 'I have been overwritten at the job level'
    steps:
      - name: Print Env Variables
        env:
          STEP_VAR: 'I am a step env var'
          step_var2: 'I am another step env var'
        run: |
          echo "Step env var: ${{ env.STEP_VAR }}"
          echo "Step env var 2: $step_var2"
          echo "Job env var: ${{ env.JOB_VAR }}"
          echo "Workflow env var: ${{ env.WORKFLOW_VAR }}"
          echo "Overwritten: ${{ env.OVERWRITTEN }}"

What happens here:

  • OVERWRITTEN starts as “I will be overwritten” at workflow level
  • Job level changes it to “I have been overwritten at the job level”
  • Step level would override both if we defined it there

The priority is: Step > Job > Workflow

Two Ways to Access Variables

I discovered you can access variables in two ways:

run: |
  echo "Using env context: ${{ env.STEP_VAR }}"
  echo "Using shell variable: $step_var2"

Both work, but ${{ env.VAR }} is safer because it works everywhere in YAML, not just in run: commands.

Handling Undefined Variables

This is something I use a lot – providing default values:

env:
  UNDEFINED_VAR_WITH_DEFAULT: ${{ vars.UNDEFINED_VAR || 'default value' }}

If UNDEFINED_VAR doesn’t exist, it uses ‘default value’ instead of failing.

Repository and Organization Variables

GitHub lets you set variables at different scopes:

jobs:
  echo2:
    runs-on: ubuntu-latest
    steps:
      - name: Print Variables
        run: |
          echo "Org var: ${{ vars.ORG_VAR }}"
          echo "Org overwritten var: ${{ vars.OVERWRITTEN_VAR }}"
          echo "Repo var: ${{ vars.REPOSITORY_VAR }}"

You create these in GitHub Settings:

  • Organization variables: Shared across all repos in your org
  • Repository variables: Specific to one repo
  • Environment variables: Specific to deployment environments (staging, prod, etc.)

Environment-Specific Variables

This is really useful for different deployment targets:

jobs:
  echo-prod:
    runs-on: ubuntu-latest
    environment: prod  # This is the key!
    steps:
      - name: Print Prod Variables
        run: |
          echo "Org var: ${{ vars.ORG_VAR }}"
          echo "Environment var: ${{ vars.TARGET_VAR }}"

When you set environment: prod, GitHub loads variables you defined for that environment. Same variable name can have different values in staging vs prod.

Expressions: Making Decisions

Expressions use the ${{ }} syntax and let you add logic to your workflows.

Basic Conditionals

Here’s my workflow that uses expressions:

name: 07 - Using Expressions
run-name: 07 - Using Expressions | DEBUG - ${{ inputs.debug && 'ON' || 'OFF' }}

on:
  workflow_dispatch:
    inputs:
      debug:
        type: boolean
        default: false

jobs:
  echo:
    runs-on: ubuntu-latest
    steps:
      - name: '[debug] Print start-up data'
        if: inputs.debug
        run: |
          echo "Triggered by: ${{ github.event_name }}"
          echo "Branch: ${{ github.ref }}"
          echo "Commit SHA: ${{ github.sha }}"
          echo "Runner OS: ${{ runner.os }}"
      - name: '[debug] Print when triggered from main'
        if: inputs.debug && github.ref == 'refs/heads/main'
        run: echo "I was triggered from main"
      - name: Greeting
        run: echo "Hello, world"

What’s happening:

  • if: inputs.debug – step only runs if debug is true
  • if: inputs.debug && github.ref == ‘refs/heads/main’ – runs only if BOTH conditions are true
  • The run-name shows ON or OFF depending on debug value using ternary operator

The Context System

GitHub provides different contexts you can use in expressions:

steps:
  - name: Display information
    run: |
      echo "Event Name: ${{ github.event_name }}"
      echo "Ref: ${{ github.ref }}"
      echo "SHA: ${{ github.sha }}"
      echo "Actor: ${{ github.actor }}"
      echo "Workflow: ${{ github.workflow }}"
      echo "Run ID: ${{ github.run_id }}"
      echo "Run number: ${{ github.run_number }}"

I use these for debugging and creating unique identifiers for my builds.

Useful Functions for Workflow Control

GitHub Actions has built-in functions that I use all the time:

Status Check Functions

name: 09 - Using Functions

on:
  pull_request:
  workflow_dispatch:

jobs:
  echo1:
    runs-on: ubuntu-latest
    steps:
      - name: Print PR title
        run: echo "${{ github.event.pull_request.title }}"
      - name: Bug step
        if: ${{ !cancelled() && contains(github.event.pull_request.title, 'fix') }}
        run: echo "I am a bug fix"
      - name: Sleep for 20 seconds
        run: sleep 20
      - name: Failing step
        run: exit 1
      - name: I will be skipped
        if: ${{ success() }}
        run: echo "I will print if previous steps succeed."
      - name: I will execute
        if: ${{ failure() }}
        run: echo "I will print if any previous step fails."
      - name: I will execute
        if: ${{ !cancelled() }}
        run: echo "I will always print, except when the workflow is cancelled."

Key functions:

  • success() – true if all previous steps succeeded
  • failure() – true if any previous step failed
  • cancelled() – true if workflow was cancelled
  • contains(string, substring) – checks if string contains substring

The toJSON() Function

This one helped me a lot when debugging:

- name: Print PR labels
  run: |
    cat << EOF
    ${{ toJSON(github.event.pull_request.labels) }}
    EOF

It converts objects to readable JSON so you can see what data is available.

Controlling Execution Flow

This is where everything comes together. Here’s my real workflow:

name: 10 - Controlling the Execution Flow

on:
  workflow_dispatch:
    inputs:
      pass-unit-tests:
        type: boolean
        description: Whether unit tests will pass or not
        default: true

jobs:
  lint-build:
    runs-on: ubuntu-latest
    steps:
      - name: Lint and build
        run: echo "Linting and building project"
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - name: Running unit tests
        run: echo "Running tests..."
      - name: Failing tests
        if: ${{ !inputs.pass-unit-tests }}
        run: exit 1
  deploy-nonprod:
    runs-on: ubuntu-latest
    needs:
      - lint-build
      - unit-tests
    steps:
      - name: Deploying to nonprod
        run: echo "Deploying to nonprod..."
  e2e-tests:
    runs-on: ubuntu-latest
    needs:
      - deploy-nonprod
    steps:
      - name: Running E2E tests
        run: echo "Running E2E tests"
  load-tests:
    runs-on: ubuntu-latest
    needs:
      - deploy-nonprod
    steps:
      - name: Running load tests
        run: echo "Running load tests"
  deploy-prod:
    runs-on: ubuntu-latest
    needs:
      - e2e-tests
      - load-tests
    steps:
      - name: Deploying to prod
        run: echo "Deploying to prod..."

The execution order:

  1. lint-build and unit-tests run in parallel (no dependencies)
  2. deploy-nonprod waits for both to finish (needs both)
  3. e2e-tests and load-tests run in parallel after deploy-nonprod
  4. deploy-prod waits for both tests to complete

Important: If unit-tests fails, deploy-nonprod never runs because of the needs: dependency.

What I Learned

  1. Use environment-specific variables: Don’t hardcode values. Use vars.MY_VAR and set different values for dev/staging/prod.
  2. Always provide defaults: ${{ vars.MY_VAR || ‘default’ }} saves you from errors when variables aren’t set.
  3. Use status functions carefully: success() checks ALL previous steps. If you want to run cleanup even after failures, use if: always() or if: !cancelled().
  4. The needs: keyword is powerful: It creates dependencies between jobs. Use it to build proper CI/CD pipelines.
  5. Test with workflow_dispatch: I add boolean inputs to test different scenarios without changing code:
  6. Debug with expressions: Show values in run-name to see what’s happening:

Real-World Example

Here’s how I combine everything in my workflows:

name: Deploy Application
run-name: Deploy to ${{ inputs.environment }} | Dry Run - ${{ inputs.dry-run && 'YES' || 'NO' }}

on:
  workflow_dispatch:
    inputs:
      environment:
        type: choice
        options:
          - dev
          - staging
          - prod
        required: true
      dry-run:
        type: boolean
        default: false

env:
  APP_VERSION: ${{ vars.APP_VERSION || 'latest' }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    if: ${{ !inputs.dry-run }}
    steps:
      - name: Deploy to ${{ inputs.environment }}
        run: echo "Deploying version ${{ env.APP_VERSION }} to ${{ inputs.environment }}"

This workflow:

  • Lets me choose the environment
  • Shows me what will happen in the run name
  • Has a dry-run option to test without deploying
  • Uses environment-specific variables
  • Only deploys if dry-run is false

$ ls -t root-access/ | head -3

Hello world! uncategorized 1 min Building a Job Board API, Part 1: The Foundation job-board-api · 1/1 16 min Part 13 – Kubernetes Autoscaling in Action: A FastAPI Microservice That Scales Itself home-lab · 13/13 6 min

Leave a comment

Your email address will not be published. Required fields are marked *