This module dives deep into GitHub Actions workflow syntax. First we will discuss the concepts and then have a hands-on lab to use what we learn. By the end, you’ll be able to confidently build complex workflows using variables, expressions, contexts, and advanced YAML structures.
Workflows use the expression syntax ${{ }} to evaluate values dynamically. Expressions are crucial for creating flexible and reusable workflows. They allow you to access context-specific information and make decisions based on the current state of the workflow.
A few examples of expressions are as follows:
if: ${{ github.ref == 'refs/heads/main' }}run: echo "Hello ${{ github.actor }}"Note that expressions can use boolean, null, number, or string data types.
There are several built-in functions that can be used within expressions:
startsWith(),endsWith(),contains()- Search within strings
- Example:
startsWith(github.ref, 'refs/tags/')
toJSON(),fromJSON()- Convert between JSON strings and objects
- Example:
fromJSON('{"key":"value"}').key
success(),failure(),cancelled(),always()- Check the status of previous steps or jobs
- Example:
if: ${{ failure() }}
These are typical operators similar to other programming languages:
- Comparison:
==,!=,>,<,>=,<= - Logical:
&&,||,! - Arithmetic:
+,-,*,/,%
It may be helpful to review the Expressions syntax reference for a complete list of functions and operators.
Contexts in GitHub Actions are special global objects that provide access to runtime information about the workflow, repository, environment, and more. They allow you to write dynamic, flexible workflows by referencing data that changes depending on the event, job, or runner.
Contexts let you use information like branch name, actor, environment variables, secrets, and job outputs to make decisions or customize behavior and facilitate reusability without hardcoding values.
- github: Information about the repository, event, and actor.
- Example:
${{ github.ref }}(current branch or tag),${{ github.actor }}(user who triggered the workflow)
- Example:
- env: Environment variables defined in the workflow.
- Example:
${{ env.NODE_ENV }}
- Example:
- secrets: Encrypted secrets set in the repository or organization.
- Example:
${{ secrets.API_KEY }}
- Example:
- job: Status and outputs of the current job.
- Example:
${{ job.status }}
- Example:
- runner: Details about the runner executing the job.
- Example:
${{ runner.os }}(operating system),${{ runner.arch }}(architecture)
- Example:
- steps: Outputs and status of previous steps in the current job.
- Example:
${{ steps.step_id.outputs.output_name }}
- Example:
- needs: Outputs and status of previous jobs that the current job depends on.
- Example:
${{ needs.job_id.outputs.output_name }}
- Example:
For a full list of contexts and their properties, see the Contexts reference in the documentation.
Variables in GitHub Actions allow you to store and reuse values throughout your workflows. They make workflows more flexible, maintainable, and secure by enabling dynamic configuration and data sharing between steps and jobs.
You can define environment variables at different levels: workflow, job, and step.
Examples of each are below:
Workflow level:
env:
NODE_ENV: production
API_URL: https://api.example.comJob level:
jobs:
build:
env:
BUILD_NUMBER: 42
runs-on: ubuntu-latest
steps:
- run: echo "Build number is $BUILD_NUMBER"Step level:
steps:
- name: Set custom variable
env:
CUSTOM_VAR: hello
run: echo "Custom var is $CUSTOM_VAR"These can be accessed via shell or expressions:
- Shell access: Use
$VARIABLE_NAMEin shell scripts. - Expression access: Use
${{ env.VARIABLE_NAME }}in workflow expressions.
Additionally, you can pass data between steps or jobs as follows:
Step output:
steps:
- id: get_sha
run: echo "sha=${GITHUB_SHA}" >> $GITHUB_OUTPUTJob output:
jobs:
build:
outputs:
sha: ${{ steps.get_sha.outputs.sha }}
steps:
- id: get_sha
run: echo "sha=${GITHUB_SHA}" >> $GITHUB_OUTPUT
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploying commit ${{ needs.build.outputs.sha }}"A key thing to understand is that a job is executed on a fresh runner, so environment variables and step outputs do not persist between jobs unless explicitly passed using job outputs.
Artifacts are an alternative way to pass data between jobs, but they are more suited for larger files or build outputs rather than simple variables. For example:
Artifacts are another way to share data between jobs. Unlike outputs, artifacts persist after the workflow run and can be downloaded.
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: build-log
path: build.logThe documentation for variables is available here
Matrix builds allow you to run a job multiple times with different combinations of parameters, such as operating systems, language versions, or custom values. This is essential for testing across environments or parallelizing tasks.
Common use cases:
- Test across multiple OSes (Windows, Linux, macOS)
- Build and test against several language or tool versions
- Run jobs with different input data sets
Example:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm testYou can also use exclude and include to fine-tune which combinations are run:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [20, 22]
include:
- os: windows-latest
node: 20
exclude:
- os: ubuntu-latest
node: 22Concurrency controls prevent duplicate or overlapping workflow runs, saving resources and avoiding conflicts (e.g., multiple deployments).
Example:
concurrency:
group: ${{ github.ref }}
cancel-in-progress: truegroup: A unique identifier for the concurrency group (often a branch or environment)cancel-in-progress: If true, cancels any in-progress runs in the same group before starting a new one
You can use if expressions to run jobs or steps only when certain conditions are met. This enables dynamic workflows that respond to events, branch names, or previous job results.
Job-level condition:
jobs:
deploy:
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest
steps:
- run: echo "Deploying to production"Step-level condition:
steps:
- name: Run only on PRs
if: ${{ github.event_name == 'pull_request' }}
run: echo "This is a pull request"In this lab, you’ll build a workflow that generates and uploads a test log, and conditionally deploys if all tests pass. You’ll use expressions, contexts, variables, outputs, matrix builds, and artifacts.
Create a file named .github/workflows/expressions-lab.yml in your repository.
Define a workflow that installs dependencies, runs tests, and saves the results to a log file.
name: Advanced Workflow Lab
on: [push]
env:
GLOBAL_ENV: "global"
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
#os: [ubuntu-latest, windows-latest]
os: [ubuntu-latest]
node: [20, 22]
env:
JOB_ENV: "job"
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node }}
- name: Install dependencies
run: npm ci
- name: Run tests and save log
run: |
npm test | tee test-${{ matrix.os }}-${{ matrix.node }}.log
- name: Print context values
run: |
echo "Branch: ${{ github.ref }}"
echo "Actor: ${{ github.actor }}"
echo "OS: ${{ runner.os }}"
echo "Node version: ${{ matrix.node }}"
echo "Global env: $GLOBAL_ENV"
echo "Job env: $JOB_ENV"
- name: Dump entire github context
run: echo '${{ toJSON(github) }}'
- name: Save test result output
id: save
run: |
if grep -q "FAIL" test.log; then
echo "result=failure" >> $GITHUB_OUTPUT
else
echo "result=success" >> $GITHUB_OUTPUT
fi
- name: Upload test log
uses: actions/upload-artifact@v4
with:
name: test-log-${{ matrix.os }}-${{ matrix.node }}
path: test-${{ matrix.os }}-${{ matrix.node }}.logThe workflow runs npm test and saves the output to test.log. The Save test result output step checks for failures and sets the output accordingly. The test log is uploaded as an artifact.
Add a deploy job that only runs if all tests succeed and the branch is main. Note we're not actually deploying anywhere, just simulating it.
deploy:
needs: test
if: ${{ needs.test.outputs.result == 'success' && github.ref == 'refs/heads/main' }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
# os: [ubuntu-latest, windows-latest]
os: [ubuntu-latest]
node: [20, 22]
steps:
- name: Download test log
uses: actions/download-artifact@v4
with:
name: test-log-${{ matrix.os }}-${{ matrix.node }}
- run: echo "Deploying to production"The deploy job downloads the test log artifact from the previous job for inspection. Deployment only happens if tests pass and the branch is main.
Use concurrency to prevent duplicate runs on the same branch.
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true- Commit your workflow file and push it to your repository.
- Check the Actions tab to see the workflow run for each matrix combination.
- Inspect the uploaded test logs and verify that deployment only occurs when tests pass on the main branch.
- (Optional) Create multiple pushes to see concurrency in action.
This lab guided you through building a CI/CD workflow to learn advanced workflow syntax including:
- Expressions (
${{ }}) with operators and functions - Contexts (github, env, secrets, runner, steps, needs)
- Variables, outputs, and artifacts to share data across steps and jobs
- Advanced YAML features (matrix builds, concurrency, conditionals)
By mastering these concepts, you can create powerful, flexible workflows that adapt to your project's needs. Experiment further by adding more complexity or integrating with other services!