$ 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:
- lint-build and unit-tests run in parallel (no dependencies)
- deploy-nonprod waits for both to finish (needs both)
- e2e-tests and load-tests run in parallel after deploy-nonprod
- 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
- Use environment-specific variables: Don’t hardcode values. Use vars.MY_VAR and set different values for dev/staging/prod.
- Always provide defaults: ${{ vars.MY_VAR || ‘default’ }} saves you from errors when variables aren’t set.
- Use status functions carefully: success() checks ALL previous steps. If you want to run cleanup even after failures, use if: always() or if: !cancelled().
- The needs: keyword is powerful: It creates dependencies between jobs. Use it to build proper CI/CD pipelines.
- Test with workflow_dispatch: I add boolean inputs to test different scenarios without changing code:
- 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