{"id":63,"date":"2025-12-17T12:00:00","date_gmt":"2025-12-17T12:00:00","guid":{"rendered":"https:\/\/rezachegini.com\/?p=63"},"modified":"2026-10-30T06:45:35","modified_gmt":"2026-10-30T06:45:35","slug":"variables-expressions-control-flow-in-github-actions","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=63","title":{"rendered":"Variables, Expressions &#038; Control Flow in GitHub Actions"},"content":{"rendered":"<p class=\"wp-block-paragraph\">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.<\/p>\n<h3 class=\"wp-block-heading\">Understanding Variables in GitHub Actions<\/h3>\n<p class=\"wp-block-paragraph\">Variables can be defined at three levels, and they override each other in a specific order. Let me show you from my actual workflow:<\/p>\n<h3 class=\"wp-block-heading\">The Three Levels<\/h3>\n<pre class=\"wp-block-code\"><code>name: 08 - Using Variables\n\non: workflow_dispatch\n\nenv:\n  WORKFLOW_VAR: 'I am a workflow env var'\n  OVERWRITTEN: 'I will be overwritten'\n  UNDEFINED_VAR_WITH_DEFAULT: ${{ vars.UNDEFINED_VAR || 'default value' }}\n\njobs:\n  echo:\n    runs-on: ubuntu-latest\n    env:\n      JOB_VAR: 'I am a job env var'\n      OVERWRITTEN: 'I have been overwritten at the job level'\n    steps:\n      - name: Print Env Variables\n        env:\n          STEP_VAR: 'I am a step env var'\n          step_var2: 'I am another step env var'\n        run: |\n          echo \"Step env var: ${{ env.STEP_VAR }}\"\n          echo \"Step env var 2: $step_var2\"\n          echo \"Job env var: ${{ env.JOB_VAR }}\"\n          echo \"Workflow env var: ${{ env.WORKFLOW_VAR }}\"\n          echo \"Overwritten: ${{ env.OVERWRITTEN }}\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">What happens here:<\/p>\n<ul class=\"wp-block-list\"><li>OVERWRITTEN starts as &#8220;I will be overwritten&#8221; at workflow level<\/li><li>Job level changes it to &#8220;I have been overwritten at the job level&#8221;<\/li><li>Step level would override both if we defined it there<\/li><\/ul>\n<p class=\"wp-block-paragraph\">The priority is: Step &gt; Job &gt; Workflow<\/p>\n<h3 class=\"wp-block-heading\">Two Ways to Access Variables<\/h3>\n<p class=\"wp-block-paragraph\">I discovered you can access variables in two ways:<\/p>\n<pre class=\"wp-block-code\"><code>run: |\n  echo \"Using env context: ${{ env.STEP_VAR }}\"\n  echo \"Using shell variable: $step_var2\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Both work, but ${{ env.VAR }} is safer because it works everywhere in YAML, not just in run: commands.<\/p>\n<h3 class=\"wp-block-heading\">Handling Undefined Variables<\/h3>\n<p class=\"wp-block-paragraph\">This is something I use a lot &#8211; providing default values:<\/p>\n<pre class=\"wp-block-code\"><code>env:\n  UNDEFINED_VAR_WITH_DEFAULT: ${{ vars.UNDEFINED_VAR || 'default value' }}<\/code><\/pre>\n<p class=\"wp-block-paragraph\">If UNDEFINED_VAR doesn&#8217;t exist, it uses &#8216;default value&#8217; instead of failing.<\/p>\n<h3 class=\"wp-block-heading\">Repository and Organization Variables<\/h3>\n<p class=\"wp-block-paragraph\">GitHub lets you set variables at different scopes:<\/p>\n<pre class=\"wp-block-code\"><code>jobs:\n  echo2:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Print Variables\n        run: |\n          echo \"Org var: ${{ vars.ORG_VAR }}\"\n          echo \"Org overwritten var: ${{ vars.OVERWRITTEN_VAR }}\"\n          echo \"Repo var: ${{ vars.REPOSITORY_VAR }}\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">You create these in GitHub Settings:<\/p>\n<ul class=\"wp-block-list\"><li>Organization variables: Shared across all repos in your org<\/li><li>Repository variables: Specific to one repo<\/li><li>Environment variables: Specific to deployment environments (staging, prod, etc.)<\/li><\/ul>\n<h3 class=\"wp-block-heading\">Environment-Specific Variables<\/h3>\n<p class=\"wp-block-paragraph\">This is really useful for different deployment targets:<\/p>\n<pre class=\"wp-block-code\"><code>jobs:\n  echo-prod:\n    runs-on: ubuntu-latest\n    environment: prod  # This is the key!\n    steps:\n      - name: Print Prod Variables\n        run: |\n          echo \"Org var: ${{ vars.ORG_VAR }}\"\n          echo \"Environment var: ${{ vars.TARGET_VAR }}\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">When you set environment: prod, GitHub loads variables you defined for that environment. Same variable name can have different values in staging vs prod.<\/p>\n<h3 class=\"wp-block-heading\">Expressions: Making Decisions<\/h3>\n<p class=\"wp-block-paragraph\">Expressions use the ${{ }} syntax and let you add logic to your workflows.<\/p>\n<h3 class=\"wp-block-heading\">Basic Conditionals<\/h3>\n<p class=\"wp-block-paragraph\">Here&#8217;s my workflow that uses expressions:<\/p>\n<pre class=\"wp-block-code\"><code>name: 07 - Using Expressions\nrun-name: 07 - Using Expressions | DEBUG - ${{ inputs.debug &amp;&amp; 'ON' || 'OFF' }}\n\non:\n  workflow_dispatch:\n    inputs:\n      debug:\n        type: boolean\n        default: false\n\njobs:\n  echo:\n    runs-on: ubuntu-latest\n    steps:\n      - name: '[debug] Print start-up data'\n        if: inputs.debug\n        run: |\n          echo \"Triggered by: ${{ github.event_name }}\"\n          echo \"Branch: ${{ github.ref }}\"\n          echo \"Commit SHA: ${{ github.sha }}\"\n          echo \"Runner OS: ${{ runner.os }}\"\n      - name: '[debug] Print when triggered from main'\n        if: inputs.debug &amp;&amp; github.ref == 'refs\/heads\/main'\n        run: echo \"I was triggered from main\"\n      - name: Greeting\n        run: echo \"Hello, world\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">What&#8217;s happening:<\/p>\n<ul class=\"wp-block-list\"><li>if: inputs.debug &#8211; step only runs if debug is true<\/li><li>if: inputs.debug &amp;&amp; github.ref == &#8216;refs\/heads\/main&#8217; &#8211; runs only if BOTH conditions are true<\/li><li>The run-name shows ON or OFF depending on debug value using ternary operator<\/li><\/ul>\n<h3 class=\"wp-block-heading\">The Context System<\/h3>\n<p class=\"wp-block-paragraph\">GitHub provides different contexts you can use in expressions:<\/p>\n<pre class=\"wp-block-code\"><code>steps:\n  - name: Display information\n    run: |\n      echo \"Event Name: ${{ github.event_name }}\"\n      echo \"Ref: ${{ github.ref }}\"\n      echo \"SHA: ${{ github.sha }}\"\n      echo \"Actor: ${{ github.actor }}\"\n      echo \"Workflow: ${{ github.workflow }}\"\n      echo \"Run ID: ${{ github.run_id }}\"\n      echo \"Run number: ${{ github.run_number }}\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">I use these for debugging and creating unique identifiers for my builds.<\/p>\n<h3 class=\"wp-block-heading\">Useful Functions for Workflow Control<\/h3>\n<p class=\"wp-block-paragraph\">GitHub Actions has built-in functions that I use all the time:<\/p>\n<h3 class=\"wp-block-heading\">Status Check Functions<\/h3>\n<pre class=\"wp-block-code\"><code>name: 09 - Using Functions\n\non:\n  pull_request:\n  workflow_dispatch:\n\njobs:\n  echo1:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Print PR title\n        run: echo \"${{ github.event.pull_request.title }}\"\n      - name: Bug step\n        if: ${{ !cancelled() &amp;&amp; contains(github.event.pull_request.title, 'fix') }}\n        run: echo \"I am a bug fix\"\n      - name: Sleep for 20 seconds\n        run: sleep 20\n      - name: Failing step\n        run: exit 1\n      - name: I will be skipped\n        if: ${{ success() }}\n        run: echo \"I will print if previous steps succeed.\"\n      - name: I will execute\n        if: ${{ failure() }}\n        run: echo \"I will print if any previous step fails.\"\n      - name: I will execute\n        if: ${{ !cancelled() }}\n        run: echo \"I will always print, except when the workflow is cancelled.\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Key functions:<\/p>\n<ul class=\"wp-block-list\"><li>success() &#8211; true if all previous steps succeeded<\/li><li>failure() &#8211; true if any previous step failed<\/li><li>cancelled() &#8211; true if workflow was cancelled<\/li><li>contains(string, substring) &#8211; checks if string contains substring<\/li><\/ul>\n<h3 class=\"wp-block-heading\">The toJSON() Function<\/h3>\n<p class=\"wp-block-paragraph\">This one helped me a lot when debugging:<\/p>\n<pre class=\"wp-block-code\"><code>- name: Print PR labels\n  run: |\n    cat &lt;&lt; EOF\n    ${{ toJSON(github.event.pull_request.labels) }}\n    EOF<\/code><\/pre>\n<p class=\"wp-block-paragraph\">It converts objects to readable JSON so you can see what data is available.<\/p>\n<h3 class=\"wp-block-heading\">Controlling Execution Flow<\/h3>\n<p class=\"wp-block-paragraph\">This is where everything comes together. Here&#8217;s my real workflow:<\/p>\n<pre class=\"wp-block-code\"><code>name: 10 - Controlling the Execution Flow\n\non:\n  workflow_dispatch:\n    inputs:\n      pass-unit-tests:\n        type: boolean\n        description: Whether unit tests will pass or not\n        default: true\n\njobs:\n  lint-build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Lint and build\n        run: echo \"Linting and building project\"\n  unit-tests:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Running unit tests\n        run: echo \"Running tests...\"\n      - name: Failing tests\n        if: ${{ !inputs.pass-unit-tests }}\n        run: exit 1\n  deploy-nonprod:\n    runs-on: ubuntu-latest\n    needs:\n      - lint-build\n      - unit-tests\n    steps:\n      - name: Deploying to nonprod\n        run: echo \"Deploying to nonprod...\"\n  e2e-tests:\n    runs-on: ubuntu-latest\n    needs:\n      - deploy-nonprod\n    steps:\n      - name: Running E2E tests\n        run: echo \"Running E2E tests\"\n  load-tests:\n    runs-on: ubuntu-latest\n    needs:\n      - deploy-nonprod\n    steps:\n      - name: Running load tests\n        run: echo \"Running load tests\"\n  deploy-prod:\n    runs-on: ubuntu-latest\n    needs:\n      - e2e-tests\n      - load-tests\n    steps:\n      - name: Deploying to prod\n        run: echo \"Deploying to prod...\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">The execution order:<\/p>\n<ol class=\"wp-block-list\"><li>lint-build and unit-tests run in parallel (no dependencies)<\/li><li>deploy-nonprod waits for both to finish (needs both)<\/li><li>e2e-tests and load-tests run in parallel after deploy-nonprod<\/li><li>deploy-prod waits for both tests to complete<\/li><\/ol>\n<p class=\"wp-block-paragraph\"><strong>Important:<\/strong> If unit-tests fails, deploy-nonprod never runs because of the needs: dependency.<\/p>\n<h3 class=\"wp-block-heading\">What I Learned<\/h3>\n<ol class=\"wp-block-list\"><li><strong>Use environment-specific variables:<\/strong> Don&#8217;t hardcode values. Use vars.MY_VAR and set different values for dev\/staging\/prod.<\/li><li><strong>Always provide defaults:<\/strong> ${{ vars.MY_VAR || &#8216;default&#8217; }} saves you from errors when variables aren&#8217;t set.<\/li><li><strong>Use status functions carefully:<\/strong> success() checks ALL previous steps. If you want to run cleanup even after failures, use if: always() or if: !cancelled().<\/li><li><strong>The needs: keyword is powerful:<\/strong> It creates dependencies between jobs. Use it to build proper CI\/CD pipelines.<\/li><li><strong>Test with workflow_dispatch:<\/strong> I add boolean inputs to test different scenarios without changing code:<\/li><li><strong>Debug with expressions:<\/strong> Show values in run-name to see what&#8217;s happening:<\/li><\/ol>\n<h3 class=\"wp-block-heading\">Real-World Example<\/h3>\n<p class=\"wp-block-paragraph\">Here&#8217;s how I combine everything in my workflows:<\/p>\n<pre class=\"wp-block-code\"><code>name: Deploy Application\nrun-name: Deploy to ${{ inputs.environment }} | Dry Run - ${{ inputs.dry-run &amp;&amp; 'YES' || 'NO' }}\n\non:\n  workflow_dispatch:\n    inputs:\n      environment:\n        type: choice\n        options:\n          - dev\n          - staging\n          - prod\n        required: true\n      dry-run:\n        type: boolean\n        default: false\n\nenv:\n  APP_VERSION: ${{ vars.APP_VERSION || 'latest' }}\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    environment: ${{ inputs.environment }}\n    if: ${{ !inputs.dry-run }}\n    steps:\n      - name: Deploy to ${{ inputs.environment }}\n        run: echo \"Deploying version ${{ env.APP_VERSION }} to ${{ inputs.environment }}\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This workflow:<\/p>\n<ul class=\"wp-block-list\"><li>Lets me choose the environment<\/li><li>Shows me what will happen in the run name<\/li><li>Has a dry-run option to test without deploying<\/li><li>Uses environment-specific variables<\/li><li>Only deploys if dry-run is false<\/li><\/ul>","protected":false},"excerpt":{"rendered":"<p>After my first article about GitHub Actions basics, I want to share how I handle variables and control my workflows. This is&hellip;<\/p>\n","protected":false},"author":2,"featured_media":66,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"2","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/variables-expressions-control-flow-github-actions-reza-chegini-bal2c\/","footnotes":""},"categories":[27,10],"tags":[30,8,29,6],"series":[28],"class_list":["post-63","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ci-cd","category-devops","tag-ci-cd","tag-devops","tag-github-actions","tag-yaml","series-github-actions"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/10\/variables-expressions-control-flow-github-actions-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/63","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=63"}],"version-history":[{"count":1,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/63\/revisions"}],"predecessor-version":[{"id":64,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/63\/revisions\/64"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/66"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=63"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=63"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=63"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=63"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}