Master the GitHub Actions contexts (github, env, secrets, matrix, needs, steps) and the expressions, operators and functions that make workflows dynamic.
The issue opens pre-filled with this page title and URL.
You are writing a GitHub Actions workflow and you want to know which branch the
code runs on, who triggered the pipeline, or how to read the value of a secret.
How? That is where contexts and expressions come in.
This guide shows you how to reach all that information dynamically, without ever
hardcoding values that change from one run to the next.
What you will learn
Read the run information of a workflow through the github context
Pass data between steps and between jobs with $GITHUB_OUTPUT, outputs
and needs
Handle secrets and environment variables without exposing them in the logs
Write expressions: operators, status functions, toJSON(), hashFiles()
Reuse ready-made patterns for conditional deployment and event filtering
This guide assumes you already know what a workflow is.
What is a context? (the essential idea)
Picture your workflow running inside a room. In that room there are several
labelled boxes holding useful information:
the github box holds everything about the repository, the branch, the
commit author, the triggering event;
the secrets box holds your API keys and sensitive tokens;
the env box holds your environment variables;
the matrix box holds the values of your test strategy, if you use one;
and so on.
A context is simply one of those boxes. You reach into it with a special
syntax: ${{ box_name.what_you_want }}.
Here is a minimal workflow that reads three different pieces of information from
the github context and prints them into the run logs.
.github/workflows/context-example.yml
name: My first workflow with contexts
on: push
permissions:
contents: read# Minimal permissions
jobs:
print-info:
runs-on: ubuntu-24.04
steps:
- name: Who triggered this workflow?
env:
ACTOR: ${{ github.actor }}
run: echo "Triggered by $ACTOR"
- name: On which branch?
env:
BRANCH: ${{ github.ref_name }}
run: echo "Branch: $BRANCH"
- name: Which event?
env:
EVENT: ${{ github.event_name }}
run: echo "Event: $EVENT"
You can try it
Create this file in your repository and push it. In the workflow logs you will
see the real values appear.
Anatomy of an expression
Before going further, let us look at the syntax. A GitHub Actions expression
always follows this shape:
Anatomy of the github.actor expression: the opening delimiter marks the start of the expression, github is the context holding the data, actor the property to retrieve; the result is “stephane-robert”.${{ github.actor }}${{ : opening delimiterstart of an expressiongithub: contextbox holding the data (github, env, secrets, matrix...)actor: propertyspecific data to retrieve (actor, ref, event_name...)Result → “stephane-robert”
# GitHub Actions has no slicing function: we truncate the
# GITHUB_SHA shell variable to 7 characters.
IMAGE_TAG="$REPOSITORY:${GITHUB_SHA::7}"
echo "Building $IMAGE_TAG"
docker build -t "$IMAGE_TAG" .
docker push "$IMAGE_TAG"
Reaching the full event
The github.event context holds the complete JSON payload of the webhook
event. Its structure depends on the event type:
steps:
# Print the whole event (useful for debugging)
- name: Debug, see the complete event
env:
EVENT_JSON: ${{ toJSON(github.event) }}
run: echo "$EVENT_JSON"
# For a push: reach the commits
- name: The list of commits (on a push)
env:
COMMITS: ${{ toJSON(github.event.commits) }}
run: echo "$COMMITS"
# For a release: read the tag
- name: The release tag
if: github.event_name == 'release'
env:
TAG: ${{ github.event.release.tag_name }}
run: echo "Release $TAG"
The env context (environment variables)
Environment variables store reusable values inside your workflow. They can be
declared at three different levels, from the most global to the most local:
To reach a step's outputs you must give it an id:. Without an ID, there is
no way to reference it with steps.<id>.outputs.
A practical example: automatic semantic versioning
This example combines an id:, the $GITHUB_OUTPUT file and the job's
outputs: block to compute a version from the Git repository and pass it to the
following jobs.
Automatic version computation
permissions:
contents: read
jobs:
version:
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.calc.outputs.version }}# Expose it to other jobs
Secrets store your sensitive information: API keys, tokens, passwords. They are
encrypted and never printed in the logs, since GitHub masks them
automatically with ***.
How to create a secret
Creating a secret happens in the repository's GitHub interface, in four quick
steps.
Go to Settings, then Secrets and variables, then Actions
Click New repository secret
Give it a name (for example DOCKER_PASSWORD) and a value
The secret is now usable through ${{ secrets.DOCKER_PASSWORD }}
Using secrets safely
The golden rule: a secret travels through an environment variable, never
directly inside a command where it would stay visible.
# ✅ SECURE: the secret stays inside an environment variable
- run: ./my-script.sh
env:
API_KEY: ${{ secrets.API_KEY }}
The GITHUB_TOKEN secret
GitHub creates a token automatically for every workflow run. That token lets you
interact with the repository without configuring a secret:
steps:
- name: Create a release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create v1.0.0 --title "Version 1.0.0"
GITHUB_TOKEN permissions
By default the token holds limited permissions. You can adjust them with the
permissions: block at workflow or job level.
The matrix context (multiple runs)
The matrix context goes with the matrix strategy, which runs the same job
several times with different values. It is ideal for testing across several
operating systems or versions.
To go deeper on the matrix strategy, read the dedicated guide:
Matrix strategy.
The needs and steps contexts (passing data)
These two contexts carry data from one part of the workflow to another. They
are essential to build workflows whose jobs talk to each other.
In the build job, step 1 (id: version) passes its value to step 2 through the steps context and exposes it in the job outputs; the deploy job, declared with needs: [build], reads it through the needs context, with needs.build.outputs.version.Job: deploy, needs: [build]Job: buildsteps context: between stepsneeds context: between jobsStep 1id: versionStep 2steps.version.*outputs:version: ${{ steps.version.outputs.value }}Step: Deploy${{ needs.build.outputs.version }}
TEXT="Build failed on $REPOSITORY. Branch: $BRANCH. Author: $ACTOR. Logs: $RUN_URL"
jq -n --arg text "$TEXT" '{text: $text}' \
| curl -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d @-
Triggering only on certain files
The paths filter only starts the pipeline when certain files changed;
paths-ignore does the opposite. The two cannot be combined for the same event,
so pick one.
Build only when the source code changes
on:
push:
paths:
- 'src/**'
- 'package.json'
jobs:
build:
runs-on: ubuntu-24.04
steps:
- run: npm run build
Debugging contexts
When something does not behave as expected, the first move is to look at what
the contexts actually hold.
Printing a whole context
The toJSON() function serialises an entire context; it is reflex number one
for discovering what is really available at run time.
A debug step
steps:
- name: Debug, see every context
env:
CTX_GITHUB: ${{ toJSON(github) }}
CTX_ENV: ${{ toJSON(env) }}
CTX_JOB: ${{ toJSON(job) }}
CTX_RUNNER: ${{ toJSON(runner) }}
run: |
echo "=== GITHUB ===" && echo "$CTX_GITHUB"
echo ""
echo "=== ENV ===" && echo "$CTX_ENV"
echo ""
echo "=== JOB ===" && echo "$CTX_JOB"
echo ""
echo "=== RUNNER ===" && echo "$CTX_RUNNER"
Turning on debug logs
Two settings turn on verbose mode: ACTIONS_RUNNER_DEBUG (runner diagnostics)
and ACTIONS_STEP_DEBUG (detailed step logs). Create them as a repository
secret or variable with the value true. A workflow env: block does
not have this effect.
Full debug mode
Settings → Secrets and variables → Actions
ACTIONS_RUNNER_DEBUG = true # Runner diagnostics
ACTIONS_STEP_DEBUG = true # Detailed step logs
One-off debugging
For a single run, tick Enable debug logging in Re-run all jobs: nothing
to configure. For longer debugging, create the two entries above, rerun the
workflow, then delete them once you are done.
Common errors
Here are the most frequent traps met with contexts, their cause and their fix.
Symptom
Likely cause
Fix
null or empty
The property does not exist
Check the exact name with toJSON()
YAML error
The expression starts with !
Wrap it in quotes: "!contains(...)"
Secret not found
Wrong name, or missing permissions
Check the exact name in Settings, then Secrets
needs.job.outputs empty
No outputs: declared
Add the outputs: block to the source job
Step ID not found
No id: on the step
Add id: my-step
Swipe to see more
Key points
Contexts are boxes of information
Every context (github, env, secrets, matrix, needs, steps) holds
data reachable through ${{ context.property }}.
github is the most used context
It holds everything about the repository, the triggering event, the branch
and the user.
To pass data between steps: $GITHUB_OUTPUT
Write echo "key=value" >> $GITHUB_OUTPUT and read it with
${{ steps.<id>.outputs.key }}.
To pass data between jobs: outputs plus needs
Declare outputs: in the source job, then read
${{ needs.<job>.outputs.<name> }}.
Status functions control execution
success(), failure(), always() and cancelled() react to the results of
earlier steps.
Debug with toJSON()
When in doubt, print the full content of a context with
toJSON(github) passed through env:.
For the exhaustive reference of each context and each function, the official
GitHub documentation stays the anchor to keep at hand:
Contexts and
Expressions.
Check your understanding
Make sure the essentials of this guide are yours. The questions only
cover what was explained here.
Knowledge check
Check what you have learned with this interactive quiz
6 questions
6 min
70% to pass
How it works
The timer starts when you click Start
Multiple choice, true/false and short answer questions
You can move between questions
Detailed results are shown at the end
Starts the quiz and the timer
⏱️--:--
0 / 6
Review
(0/0)
--:--
0%
0Correct
0To review
0:00Time
-Level
Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.