Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Contexts and expressions in GitHub Actions

By updated

Improve this page

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 }}.

While the workflow runs, your step reads six contexts: github (repository, branch, author), env (environment variables), secrets (keys, tokens), matrix (strategy values), needs (results of previous jobs) and steps (outputs of previous steps), always with the context.property syntax between delimiters.Running workflowYour step: ${{ github.actor }}access via ${{ context.property }}githubrepo, branch, author...envenvironment variablessecretsAPI keys, tokens...matrixstrategy valuesneedsresults of previous jobsstepsoutputs of previous steps
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

A first concrete example

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”
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %
ElementRole
${{ and }}Delimiters: they tell GitHub Actions to evaluate what sits between them
githubContext: the "box" to look into
.actorProperty: the specific piece of data to read

You can reach nested properties with successive dots:

# A simple property
${{ github.actor }}
# A nested property (inside a pull_request event)
${{ github.event.pull_request.title }}
# An even deeper one
${{ github.event.pull_request.user.login }}

The available contexts: an overview

Here are all the contexts you can use. Do not worry, you will not need to know them all by heart; the most useful ones are detailed right after.

ContextWhat it holdsWhen to use it
githubRepository, branch, event, authorAlmost everywhere, the most used one
envEnvironment variablesPassing values between steps
varsConfiguration variables (repo/org)Shared non-sensitive configuration
secretsSecrets (API keys, tokens)Authentication, deployment
stepsResults of earlier stepsReading a step output
needsResults of earlier jobsReading a job output
matrixMatrix strategy valuesMulti-version, multi-OS tests
jobInformation about the running jobRarely used directly
runnerInformation about the execution machinePaths, runner OS
strategyThe matrix configurationRarely used directly
inputsInputs of reusable workflowsWorkflows called by others

The github context (the most important)

This is the context you will use most often. It holds all the information about:

  • the repository (name, owner, URL);
  • the event that triggered the workflow (push, pull_request, and so on);
  • the branch or the tag concerned;
  • the user who triggered the run;
  • the run itself (number, unique ID).
The github context groups four families of properties: repository (.repository, .repository_owner, .ref, .ref_name, .sha), run (.run_id, .run_number, .workflow), event (.event_name, .event, .actor, .triggering_actor) and pull request (.head_ref, .base_ref, .event.pull_request).Repository.repository, .repository_owner, .ref, .ref_name, .shaRun.run_id, .run_number, .workflowgithub.*Event.event_name, .event, .actor, .triggering_actorPull Request.head_ref, .base_ref, .event.pull_request.*
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

The properties you cannot do without

The github context exposes dozens of properties. Here are the ones you will handle day to day, grouped by theme in the tabs below.

steps:
- name: Repository and code information
env:
REPOSITORY: ${{ github.repository }}
OWNER: ${{ github.repository_owner }}
BRANCH: ${{ github.ref_name }}
FULL_REF: ${{ github.ref }}
COMMIT_SHA: ${{ github.sha }}
run: |
echo "Repository: $REPOSITORY" # → "stephane-robert/my-project"
echo "Owner: $OWNER" # → "stephane-robert"
echo "Branch: $BRANCH" # → "main" or "feature/my-branch"
echo "Full ref: $FULL_REF" # → "refs/heads/main" or "refs/tags/v1.0.0"
echo "Commit SHA: $COMMIT_SHA" # → "a1b2c3d4e5f6..." (40 characters)

A practical example: a Docker tag with the short SHA

A very common case is tagging your Docker images with the commit SHA for traceability:

.github/workflows/build-docker.yml
name: Build Docker
on:
push:
branches: [main]
permissions:
contents: read # Reading the code only
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build and push
env:
REPOSITORY: ${{ github.repository }}
run: |
# Tag with the short SHA for traceability.
# 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:

The three levels of environment variables
env:
WORKFLOW_LEVEL: "reachable everywhere" # 1. Workflow level
jobs:
build:
runs-on: ubuntu-24.04
env:
JOB_LEVEL: "reachable inside this job" # 2. Job level
steps:
- name: First step
env:
STEP_LEVEL: "reachable inside this step only" # 3. Step level
run: |
# All three are reachable here
echo "Workflow: $WORKFLOW_LEVEL"
echo "Job: $JOB_LEVEL"
echo "Step: $STEP_LEVEL"
- name: Second step
run: |
# Only workflow and job are reachable
echo "Workflow: $WORKFLOW_LEVEL"
echo "Job: $JOB_LEVEL"
# STEP_LEVEL does not exist here!

Two syntaxes to read a variable

You can reach environment variables in two ways:

SyntaxWhen to use it
${{ env.MY_VAR }}In YAML parameters (with, if, and so on)
$MY_VAR or ${MY_VAR}Inside shell scripts (run)
steps:
- name: Both syntaxes
env:
VERSION: "1.2.3"
run: |
# Shell syntax (recommended inside run:)
echo "Version: $VERSION"

Creating variables dynamically

Sometimes you want to compute a value in one step and reuse it later. That is what the special $GITHUB_OUTPUT file is for:

Passing values between steps
steps:
- name: Compute the version
id: version # The ID is required to reference this step
run: |
# Read the version from a file
VERSION=$(cat VERSION)
echo "Version found: $VERSION"
# Write to GITHUB_OUTPUT so other steps can read it
echo "value=$VERSION" >> $GITHUB_OUTPUT
# You can write several values
echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Use the computed values
env:
VERSION: ${{ steps.version.outputs.value }}
SHA_SHORT: ${{ steps.version.outputs.sha_short }}
run: |
echo "Version: $VERSION"
echo "Short SHA: $SHA_SHORT"

Do not forget the ID

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
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # Required to count the commits
- name: Compute the version
id: calc
run: |
# Version = tag plus the number of commits since
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
COMMITS_SINCE=$(git rev-list "${LATEST_TAG}..HEAD" --count)
VERSION="${LATEST_TAG}-${COMMITS_SINCE}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Computed version: $VERSION"
build:
needs: version
runs-on: ubuntu-24.04
env:
VERSION: ${{ needs.version.outputs.version }}
steps:
- name: Build with the version
run: echo "Building version $VERSION"

The secrets context (sensitive data)

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.

  1. Go to Settings, then Secrets and variables, then Actions
  2. Click New repository secret
  3. Give it a name (for example DOCKER_PASSWORD) and a value
  4. 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.

Good practice: pass it through env
steps:
- name: Log in to Docker Hub
env:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASS: ${{ secrets.DOCKER_PASSWORD }}
run: |
echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin

Mistakes to avoid at all costs

Never print a secret into the logs:

# ❌ DANGEROUS: the secret could appear in clear text
- run: echo ${{ secrets.API_KEY }}
# ❌ DANGEROUS: curl may print the header on error
- run: curl -H "Authorization: Bearer ${{ secrets.API_KEY }}" https://api.example.com

The right way:

# ✅ 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.

Testing across several Node.js versions
permissions:
contents: read
jobs:
test:
strategy:
matrix:
node-version: [18, 20, 22] # 3 values means 3 parallel runs
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ matrix.node-version }} # Uses the matrix value
- run: npm test

This workflow creates three parallel jobs: one with Node.js 18, one with Node.js 20, one with Node.js 22.

A multi-dimension matrix

You can combine several axes:

Testing across several operating systems AND versions
permissions:
contents: read
jobs:
test:
strategy:
matrix:
os: [ubuntu-24.04, windows-2025, macos-15] # 3 different systems
node: [18, 20]
# = 3 systems × 2 versions = 6 parallel jobs
runs-on: ${{ matrix.os }} # A dynamic operating system
steps:
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ matrix.node }}
- run: npm test

Adding special cases with include

Sometimes you want to test one special configuration:

jobs:
test:
strategy:
matrix:
os: [ubuntu-24.04, windows-2025]
node: [18, 20]
include:
# Add a special case: Node 22 on Ubuntu only
- os: ubuntu-24.04
node: 22
experimental: true # A custom property
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ matrix.node }}
- name: Experimental test
if: matrix.experimental == true
run: echo "This is an experimental build"
- run: npm test

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 }}
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

The steps context: between steps of the same job

When you want a step to use a value computed by an earlier step inside the same job:

jobs:
build:
runs-on: ubuntu-24.04
steps:
- name: Generate a build number
id: build-number # The ID is required
run: |
BUILD_NUM=$(date +%Y%m%d%H%M%S)
echo "number=$BUILD_NUM" >> $GITHUB_OUTPUT
- name: Use the number
env:
BUILD_NUMBER: ${{ steps.build-number.outputs.number }}
run: echo "Build #$BUILD_NUMBER"

Properties available on steps.<id>:

PropertyDescription
outputs.<name>Values written to $GITHUB_OUTPUT
outcomeThe result before continue-on-error (success, failure)
conclusionThe final result (success, failure, skipped)

The needs context: between different jobs

When you want a job to use a value computed by an earlier job, it takes more work, because jobs run on different runners.

Step 1: the source job declares its outputs

jobs:
build:
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.version.outputs.value }} # Expose the output
sha: ${{ steps.version.outputs.sha }}
steps:
- name: Compute the version
id: version
run: |
echo "value=1.2.3" >> $GITHUB_OUTPUT
echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT

Step 2: the consuming job uses needs

deploy:
needs: build # Required to reach the outputs
runs-on: ubuntu-24.04
env:
VERSION: ${{ needs.build.outputs.version }}
SHA: ${{ needs.build.outputs.sha }}
BUILD_RESULT: ${{ needs.build.result }}
steps:
- name: Deploy
run: |
echo "Deploying version $VERSION"
echo "Commit: $SHA"
echo "Build status: $BUILD_RESULT"

A complete example: a workflow with dependencies

This example assembles everything above: four jobs chained by needs, passing outputs, with a final conditional notification.

A complete CI/CD workflow
name: CI/CD
on:
push:
branches: [main]
permissions:
contents: read
packages: write # To push to ghcr.io
jobs:
# 1. Build and version computation
build:
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.meta.outputs.version }}
image: ${{ steps.meta.outputs.image }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Metadata
id: meta
env:
RUN_NUMBER: ${{ github.run_number }}
REPOSITORY: ${{ github.repository }}
run: |
VERSION="1.0.$RUN_NUMBER"
IMAGE="ghcr.io/$REPOSITORY:$VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "image=$IMAGE" >> $GITHUB_OUTPUT
- name: Build the image
env:
IMAGE: ${{ steps.meta.outputs.image }}
run: docker build -t "$IMAGE" .
# 2. Tests (depends on the build)
test:
needs: build
runs-on: ubuntu-24.04
env:
IMAGE: ${{ needs.build.outputs.image }}
steps:
- name: Test the image
run: echo "Testing $IMAGE"
# 3. Deployment (depends on the build AND the tests)
deploy:
needs: [build, test]
runs-on: ubuntu-24.04
env:
BUILD_RESULT: ${{ needs.build.result }}
TEST_RESULT: ${{ needs.test.result }}
IMAGE: ${{ needs.build.outputs.image }}
steps:
- name: Deploy
run: |
echo "Build: $BUILD_RESULT"
echo "Tests: $TEST_RESULT"
echo "Deploying $IMAGE"
# 4. Notification (always, even on failure)
notify:
needs: [build, test, deploy]
if: always() # Runs even when a job failed
runs-on: ubuntu-24.04
env:
DEPLOY_RESULT: ${{ needs.deploy.result }}
BUILD_RESULT: ${{ needs.build.result }}
TEST_RESULT: ${{ needs.test.result }}
steps:
- name: Notify
run: |
if [ "$DEPLOY_RESULT" = "success" ]; then
echo "Deployment succeeded"
else
echo "Failure. Build: $BUILD_RESULT, Tests: $TEST_RESULT"
fi

Expressions: operators and functions

Expressions are not only there to read values. You can also compare, combine logically, and call built-in functions.

Comparison operators

Expressions accept the usual comparison and logic operators, most useful inside if: conditions.

# Equality
if: github.ref == 'refs/heads/main'
if: github.event_name != 'pull_request'
# Numeric comparison
if: matrix.node >= 20
if: github.run_number > 100
# Logical operators
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
if: github.actor == 'dependabot[bot]' || github.actor == 'renovate[bot]'
# Negation (mind the quotes!)
if: "!contains(github.event.head_commit.message, '[skip ci]')"

Quotes are mandatory for negation

If your expression starts with !, you must wrap it in quotes. Otherwise YAML misreads it:

# ❌ A YAML syntax error
if: !contains(...)
# ✅ Correct
if: "!contains(...)"

The ternary operator (a trick)

GitHub Actions does not support condition ? value1 : value2 directly. But you can simulate it with && and ||:

env:
# If main then "production", otherwise "staging"
ENVIRONMENT: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
# If a PR then its number, otherwise "N/A"
PR_NUMBER: ${{ github.event.pull_request.number || 'N/A' }}

Status functions

These functions go inside if: conditions to react to the result of earlier steps:

FunctionWhen it returns true
success()The earlier steps or jobs succeeded (the default)
failure()At least one earlier step or job failed
always()Always, including after a cancellation or a failure
cancelled()The workflow was cancelled
steps:
- name: Tests
run: npm test
- name: Failure notification
if: failure() # Only runs when npm test failed
run: echo "The tests failed!"
- name: Cleanup
if: always() # Always runs, whatever the result
run: rm -rf temp/

String manipulation functions

GitHub Actions provides built-in functions to test the content of a string or to format one dynamically.

# contains() searches inside a string or an array
if: contains(github.event.head_commit.message, '[skip ci]')
if: contains(github.event.pull_request.labels.*.name, 'urgent')
# startsWith() and endsWith() check the beginning or the end
if: startsWith(github.ref, 'refs/tags/') # This is a tag
if: endsWith(github.repository, '-demo') # A demo repository
# format() formats a string
run: echo ${{ format('Hello {0}!', github.actor) }}
# → "Hello stephane-robert!"

JSON functions

Two functions convert data between objects and JSON text, which is essential for debugging and for handling data structures.

# toJSON() converts to JSON (perfect for debugging)
- name: Debug the context
env:
CONTEXT: ${{ toJSON(github) }}
run: echo "$CONTEXT"
# fromJSON() parses JSON
- name: Produce some JSON data
run: |
DATA='{"version": "1.2.3", "stable": true}'
echo "$DATA" > data.json
- name: Read the JSON
id: data
run: echo "json=$(cat data.json)" >> $GITHUB_OUTPUT
- name: Use it
env:
VERSION: ${{ fromJSON(steps.data.outputs.json).version }}
run: echo "Version: $VERSION"

The hashFiles() function (for caching)

This function computes a hash of files. It is essential to invalidate caches when the dependencies change:

- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.npm
# The cache is invalidated whenever package-lock.json changes
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

Common patterns (ready-made recipes)

Here are the most frequent use cases. Copy them and adapt.

Deploying by branch

The most frequent case: triggering a different deployment depending on the branch that received the commit.

Conditional deployment
jobs:
deploy:
runs-on: ubuntu-24.04
steps:
- name: Deploy to staging
if: github.ref == 'refs/heads/develop'
run: ./deploy.sh staging
- name: Deploy to production
if: github.ref == 'refs/heads/main'
run: ./deploy.sh production
# An alternative with an environment variable
- name: Deploy (dynamic version)
env:
TARGET: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
run: ./deploy.sh "$TARGET"

Skipping with [skip ci]

This condition avoids starting a build when the commit message carries the [skip ci] marker, which is handy for documentation commits.

Skip CI from the commit message
jobs:
build:
# Does NOT run when the message contains [skip ci]
if: "!contains(github.event.head_commit.message, '[skip ci]')"
runs-on: ubuntu-24.04
steps:
- run: npm run build

Ignoring bots

To avoid rerunning the whole CI on every automatic dependency update, filter the bot accounts with a condition on github.actor.

Ignoring Dependabot and Renovate
jobs:
build:
# Ignores PRs opened by update bots
if: github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]'
runs-on: ubuntu-24.04
steps:
- run: npm test

Telling push and pull_request apart

One workflow can react to both events and reserve certain steps, such as publishing or commenting on a PR, for one or the other.

Different actions per event
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build
run: npm run build
# On push only (merged into main)
- name: Publish
if: github.event_name == 'push'
run: npm publish
# On PRs only
- name: Comment on the PR
if: github.event_name == 'pull_request'
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: echo "Build succeeded for PR #$PR_NUMBER"

Notifying on failure

The failure() function sends an alert only when an earlier step failed, without polluting successful runs.

Alerting Slack on failure
jobs:
build:
runs-on: ubuntu-24.04
steps:
- name: Tests
run: npm test
- name: Notify the failure
if: failure()
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
REPOSITORY: ${{ github.repository }}
BRANCH: ${{ github.ref_name }}
ACTOR: ${{ github.actor }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
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.

SymptomLikely causeFix
null or emptyThe property does not existCheck the exact name with toJSON()
YAML errorThe expression starts with !Wrap it in quotes: "!contains(...)"
Secret not foundWrong name, or missing permissionsCheck the exact name in Settings, then Secrets
needs.job.outputs emptyNo outputs: declaredAdd the outputs: block to the source job
Step ID not foundNo id: on the stepAdd id: my-step

Key points

  1. Contexts are boxes of information

    Every context (github, env, secrets, matrix, needs, steps) holds data reachable through ${{ context.property }}.

  2. github is the most used context

    It holds everything about the repository, the triggering event, the branch and the user.

  3. To pass data between steps: $GITHUB_OUTPUT

    Write echo "key=value" >> $GITHUB_OUTPUT and read it with ${{ steps.<id>.outputs.key }}.

  4. To pass data between jobs: outputs plus needs

    Declare outputs: in the source job, then read ${{ needs.<job>.outputs.<name> }}.

  5. Status functions control execution

    success(), failure(), always() and cancelled() react to the results of earlier steps.

  6. 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

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn