Back to Software Engineering & Delivery Mastery Series GitHub Actions Bootcamp

Module 3: Jobs, Steps, and Workflow Structure

June 2, 2026 Wasil Zafar 32 min read

Master GitHub Actions workflow structure — creating and configuring jobs, parallel vs sequential execution, job dependencies, matrix strategies, multi-OS runners, shells, outputs, and concurrency controls.

Table of Contents

  1. Creating and Configuring Jobs
  2. Parallel vs Sequential Execution
  3. Dependent Jobs & Conditions
  4. Multiple OS & Runners
  5. Shells & Working Directories
  6. Running Multi-line Commands
  7. Job and Step Outputs
  8. Concurrency Controls
  9. Exercises

Creating and Configuring Jobs

A job is the fundamental execution unit in GitHub Actions. Each job runs on a fresh virtual machine (or container), executes a series of steps, and produces an exit status. Understanding how to define, name, and configure jobs is the foundation of every workflow.

Workflow Structure Hierarchy
flowchart TD
    A[Workflow File] --> B[on: triggers]
    A --> C[jobs:]
    C --> D[Job 1]
    C --> E[Job 2]
    C --> F[Job N]
    D --> D1[runs-on: runner]
    D --> D2[steps:]
    D2 --> D3[Step 1: uses action]
    D2 --> D4[Step 2: run command]
    D2 --> D5[Step 3: uses action]
    E --> E1[runs-on: runner]
    E --> E2[steps:]

    style A fill:#132440,color:#fff
    style C fill:#3B9797,color:#fff
    style D fill:#16476A,color:#fff
    style E fill:#16476A,color:#fff
    style F fill:#16476A,color:#fff
                            

The jobs: Key

Every workflow must contain at least one job. Jobs are defined under the top-level jobs: key. Each job has a unique job ID (the YAML key) that serves as an internal identifier used for dependencies, outputs, and API references.

# .github/workflows/basic-jobs.yml
name: Basic Job Structure

on: push

jobs:
  # Job ID: must be unique within the workflow
  # Rules: start with letter or _, contain only alphanumeric, -, _
  build:
    name: "Build Application"    # Display name in GitHub UI
    runs-on: ubuntu-latest       # Runner environment
    steps:
      - uses: actions/checkout@v4
      - run: echo "Building..."

  test:
    name: "Run Tests"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Testing..."

  deploy:
    name: "Deploy to Production"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Deploying..."
Job ID vs Display Name: The job ID (e.g., build) is used internally for needs: references and API calls. The name: property is what appears in the GitHub UI. Keep job IDs short and lowercase (kebab-case). Use name: for human-readable descriptions. If you omit name:, GitHub shows the job ID in the UI.

Runner Selection with runs-on:

The runs-on: property specifies which type of virtual machine hosts the job. GitHub provides hosted runners with pre-installed software, or you can use self-hosted runners for custom environments.

# .github/workflows/runner-examples.yml
name: Runner Selection Examples

on: push

jobs:
  # GitHub-hosted runners (managed by GitHub)
  linux-job:
    runs-on: ubuntu-latest      # Ubuntu 22.04 (latest LTS)
    steps:
      - run: uname -a

  linux-specific:
    runs-on: ubuntu-22.04       # Pin to specific version
    steps:
      - run: cat /etc/os-release

  windows-job:
    runs-on: windows-latest     # Windows Server 2022
    steps:
      - run: systeminfo | findstr /C:"OS Name"

  macos-job:
    runs-on: macos-latest       # macOS 14 (Sonoma) on Apple Silicon
    steps:
      - run: sw_vers

  macos-intel:
    runs-on: macos-13           # macOS 13 on Intel (x86_64)
    steps:
      - run: uname -m

  # Self-hosted runner (your own infrastructure)
  custom-runner:
    runs-on: [self-hosted, linux, x64, gpu]  # Label-based selection
    steps:
      - run: nvidia-smi

  # Larger GitHub-hosted runners (requires GitHub Team/Enterprise)
  large-runner:
    runs-on: ubuntu-latest-16-cores  # 16-core runner
    steps:
      - run: nproc
GitHub-Hosted Runner Specifications
RunnervCPUsRAMStorageArchitecture
ubuntu-latest416 GB14 GB SSDx86_64
windows-latest416 GB14 GB SSDx86_64
macos-latest3 (M1)7 GB14 GB SSDARM64
macos-134 (Intel)14 GB14 GB SSDx86_64
ubuntu-latest-16-cores1664 GB150 GB SSDx86_64

Running Jobs in Parallel vs Sequentially

By default, all jobs in a workflow run in parallel. This is one of GitHub Actions' most powerful features — independent jobs execute simultaneously, dramatically reducing total pipeline time. To enforce sequential execution, you use the needs: keyword.

Parallel vs Sequential Execution
flowchart LR
    subgraph "Default: All Parallel"
        direction TB
        A1[lint] ~~~ B1[test]
        B1 ~~~ C1[build]
        C1 ~~~ D1[security-scan]
    end

    subgraph "With needs: Sequential"
        direction TB
        A2[lint] --> B2[test]
        B2 --> C2[build]
        C2 --> D2[deploy]
    end

    subgraph "Hybrid: Fan-out/Fan-in"
        direction TB
        A3[setup] --> B3[test-unit]
        A3 --> C3[test-integration]
        A3 --> D3[test-e2e]
        B3 --> E3[deploy]
        C3 --> E3
        D3 --> E3
    end

    style A1 fill:#3B9797,color:#fff
    style B1 fill:#3B9797,color:#fff
    style C1 fill:#3B9797,color:#fff
    style D1 fill:#3B9797,color:#fff
    style A2 fill:#16476A,color:#fff
    style B2 fill:#16476A,color:#fff
    style C2 fill:#16476A,color:#fff
    style D2 fill:#16476A,color:#fff
    style A3 fill:#132440,color:#fff
    style B3 fill:#3B9797,color:#fff
    style C3 fill:#3B9797,color:#fff
    style D3 fill:#3B9797,color:#fff
    style E3 fill:#BF092F,color:#fff
                            

Default Parallel Behavior

When jobs have no needs: property, they all start simultaneously. This is ideal for independent validation steps that don't depend on each other's output.

# .github/workflows/parallel-jobs.yml
name: Parallel Validation

on:
  pull_request:
    branches: [main]

jobs:
  # All 4 jobs start at the same time
  lint:
    name: "Lint Code"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint

  typecheck:
    name: "Type Check"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx tsc --noEmit

  unit-tests:
    name: "Unit Tests"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test -- --coverage

  security-audit:
    name: "Security Audit"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm audit --audit-level=high

With 4 parallel jobs, if each takes ~2 minutes, total wall-clock time is ~2 minutes. Running them sequentially would take ~8 minutes. Parallelism scales linearly with available runners.

Sequencing with needs:

The needs: property creates dependencies between jobs. A job won't start until all jobs listed in its needs: have completed successfully.

# .github/workflows/sequential-pipeline.yml
name: Sequential Build Pipeline

on:
  push:
    branches: [main]

jobs:
  install:
    name: "Install Dependencies"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - uses: actions/cache/save@v4
        with:
          path: node_modules
          key: modules-${{ hashFiles('package-lock.json') }}

  lint:
    name: "Lint"
    needs: install           # Waits for install to finish
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache/restore@v4
        with:
          path: node_modules
          key: modules-${{ hashFiles('package-lock.json') }}
      - run: npm run lint

  test:
    name: "Test"
    needs: install           # Also waits for install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache/restore@v4
        with:
          path: node_modules
          key: modules-${{ hashFiles('package-lock.json') }}
      - run: npm test

  build:
    name: "Build"
    needs: [lint, test]      # Waits for BOTH lint AND test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache/restore@v4
        with:
          path: node_modules
          key: modules-${{ hashFiles('package-lock.json') }}
      - run: npm run build

  deploy:
    name: "Deploy"
    needs: build             # Waits for build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Deploying to production..."
Fan-Out/Fan-In Pattern: In the workflow above, lint and test both depend on install but run in parallel with each other. Then build waits for both to complete. This "diamond" pattern maximizes parallelism while maintaining correctness — the optimal structure for most CI pipelines.

Dependent Jobs and Conditional Dependencies

By default, a job with needs: only runs if all dependency jobs succeeded. If any dependency fails or is skipped, the dependent job is automatically skipped. You can override this behavior with status check functions.

Default Behavior: Skip on Failure

# .github/workflows/default-failure.yml
name: Default Failure Behavior

on: push

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: exit 1       # This job FAILS

  deploy:
    needs: test           # deploy is SKIPPED because test failed
    runs-on: ubuntu-latest
    steps:
      - run: echo "This never runs"

  notify:
    needs: test           # notify is also SKIPPED
    runs-on: ubuntu-latest
    steps:
      - run: echo "This also never runs"

Conditional Dependencies with Status Functions

Use if: with status check functions to control when dependent jobs execute regardless of upstream results:

# .github/workflows/conditional-dependencies.yml
name: Conditional Job Dependencies

on: push

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build

  test:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  # Runs ONLY if all previous jobs succeeded (default behavior)
  deploy:
    needs: [build, test]
    if: success()                # Explicit: same as default
    runs-on: ubuntu-latest
    steps:
      - run: echo "All checks passed — deploying"

  # Runs ONLY if a previous job failed
  rollback:
    needs: [build, test]
    if: failure()                # Runs when ANY dependency failed
    runs-on: ubuntu-latest
    steps:
      - run: echo "Something failed — initiating rollback"
      - run: ./scripts/rollback.sh

  # ALWAYS runs regardless of success/failure/cancellation
  cleanup:
    needs: [build, test]
    if: always()                 # Runs no matter what
    runs-on: ubuntu-latest
    steps:
      - run: echo "Cleaning up temporary resources"
      - run: ./scripts/cleanup-test-env.sh

  # Runs even if a dependency was cancelled
  notify:
    needs: [build, test, deploy]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Send notification
        run: |
          if [ "${{ needs.deploy.result }}" == "success" ]; then
            echo "Deployment succeeded"
          elif [ "${{ needs.test.result }}" == "failure" ]; then
            echo "Tests failed — deployment skipped"
          elif [ "${{ needs.build.result }}" == "failure" ]; then
            echo "Build failed — nothing ran"
          else
            echo "Workflow was cancelled"
          fi
Critical Distinction:
  • success() — Returns true only if ALL dependency jobs succeeded (default behavior)
  • failure() — Returns true if ANY dependency job failed
  • always() — Always returns true (job runs even if dependencies were cancelled)
  • cancelled() — Returns true only if the workflow was explicitly cancelled
Without if: always(), notification and cleanup jobs will be skipped when earlier jobs fail — exactly when you need them most.

Accessing Dependency Results

# .github/workflows/check-results.yml
name: Check Dependency Results

on: push

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - run: npm test

  integration-tests:
    runs-on: ubuntu-latest
    steps:
      - run: npm run test:integration

  report:
    needs: [unit-tests, integration-tests]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Generate status report
        run: |
          echo "## Test Results" >> $GITHUB_STEP_SUMMARY
          echo "| Job | Result |" >> $GITHUB_STEP_SUMMARY
          echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY
          echo "| Unit Tests | ${{ needs.unit-tests.result }} |" >> $GITHUB_STEP_SUMMARY
          echo "| Integration Tests | ${{ needs.integration-tests.result }} |" >> $GITHUB_STEP_SUMMARY

      - name: Fail if any test failed
        if: needs.unit-tests.result == 'failure' || needs.integration-tests.result == 'failure'
        run: |
          echo "::error::One or more test suites failed"
          exit 1

Using Multiple Operating Systems and Runners

The matrix strategy is one of GitHub Actions' most powerful features. It lets you run the same job across multiple configurations — different OS versions, language versions, or any combination of parameters — from a single job definition.

Basic Matrix Strategy

# .github/workflows/matrix-basic.yml
name: Multi-OS Test Matrix

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    name: "Test on ${{ matrix.os }} / Node ${{ matrix.node-version }}"
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node-version: ['18', '20', '22']
      fail-fast: false    # Don't cancel other matrix jobs if one fails

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm test
      - run: node --version

This single job definition generates 9 parallel jobs (3 OS × 3 Node versions). Each combination runs independently on its own runner.

Advanced Matrix: Include, Exclude, and Custom Properties

# .github/workflows/matrix-advanced.yml
name: Advanced Matrix Strategy

on: push

jobs:
  build:
    name: "Build: ${{ matrix.os }} / ${{ matrix.arch }} / ${{ matrix.compiler }}"
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        arch: [x64]
        compiler: [gcc, clang]

        # Exclude impossible combinations
        exclude:
          - os: windows-latest
            compiler: gcc         # GCC not standard on Windows runners

        # Add specific extra combinations
        include:
          - os: windows-latest
            arch: x64
            compiler: msvc        # Windows uses MSVC instead
            cmake-flags: '-G "Visual Studio 17 2022"'

          - os: ubuntu-latest
            arch: arm64
            compiler: gcc
            runs-on-override: [self-hosted, linux, arm64]

          - os: macos-latest
            arch: arm64
            compiler: clang
            cmake-flags: '-DCMAKE_OSX_ARCHITECTURES=arm64'

    # Override runs-on for specific matrix entries
    # (uses the default from matrix.os unless overridden)

    steps:
      - uses: actions/checkout@v4

      - name: Configure build
        run: |
          echo "OS: ${{ matrix.os }}"
          echo "Arch: ${{ matrix.arch }}"
          echo "Compiler: ${{ matrix.compiler }}"
          echo "Extra flags: ${{ matrix.cmake-flags }}"

      - name: Build (Linux/macOS)
        if: runner.os != 'Windows'
        run: |
          mkdir build && cd build
          cmake .. -DCMAKE_C_COMPILER=${{ matrix.compiler }} ${{ matrix.cmake-flags }}
          make -j$(nproc)

      - name: Build (Windows)
        if: runner.os == 'Windows'
        run: |
          mkdir build
          cd build
          cmake .. ${{ matrix.cmake-flags }}
          cmake --build . --config Release

Matrix with max-parallel

# .github/workflows/matrix-throttled.yml
name: Throttled Matrix

on: push

jobs:
  deploy-regions:
    name: "Deploy to ${{ matrix.region }}"
    runs-on: ubuntu-latest
    strategy:
      max-parallel: 2       # Only 2 regions at a time (rolling deploy)
      fail-fast: true       # Stop if any region fails
      matrix:
        region:
          - us-east-1
          - us-west-2
          - eu-west-1
          - eu-central-1
          - ap-southeast-1

    steps:
      - uses: actions/checkout@v4
      - name: Deploy to ${{ matrix.region }}
        run: |
          echo "Deploying to ${{ matrix.region }}..."
          ./scripts/deploy.sh --region ${{ matrix.region }}
      - name: Health check
        run: |
          ./scripts/health-check.sh --region ${{ matrix.region }}
          echo "Region ${{ matrix.region }} is healthy"
Matrix Strategy Options:
  • fail-fast: true (default) — Cancel all in-progress matrix jobs when one fails. Use for CI where any failure means the commit is broken.
  • fail-fast: false — Let all matrix jobs complete regardless of failures. Use for cross-platform testing where you want to see ALL failures, not just the first.
  • max-parallel: N — Limit concurrent matrix jobs. Use for deployments (rolling updates) or when hitting API rate limits.

Working with Shells and Working Directories

Each run: step executes a shell command. By default, GitHub Actions uses bash on Linux/macOS and pwsh (PowerShell Core) on Windows. You can override the shell per-step or set a default for the entire job.

Available Shell Options

# .github/workflows/shells.yml
name: Shell Examples

on: push

jobs:
  shell-demos:
    runs-on: ubuntu-latest
    steps:
      # Default: bash (Linux/macOS)
      - name: Bash (default on Linux)
        run: |
          echo "Shell: $SHELL"
          echo "PID: $$"
          [[ -f /etc/os-release ]] && echo "Linux detected"

      # Explicit bash with error handling flags
      - name: Bash with custom options
        shell: bash
        run: |
          set -euxo pipefail    # exit on error, undefined vars, pipe fails
          echo "Strict mode enabled"
          ls /nonexistent || echo "Handled gracefully"

      # PowerShell Core (cross-platform)
      - name: PowerShell Core
        shell: pwsh
        run: |
          Write-Host "PowerShell version: $($PSVersionTable.PSVersion)"
          Get-ChildItem -Path . | Select-Object Name, Length

      # Python as a shell (great for complex logic)
      - name: Python shell
        shell: python
        run: |
          import os
          import json
          data = {"runner": os.environ.get("RUNNER_OS"), "job": "shell-demos"}
          print(json.dumps(data, indent=2))

      # sh (POSIX shell - more portable, fewer features)
      - name: POSIX sh
        shell: sh
        run: |
          echo "Strictly POSIX-compatible"
          # No [[ ]], no arrays, no process substitution

  windows-shells:
    runs-on: windows-latest
    steps:
      # Default: pwsh (PowerShell Core on Windows)
      - name: PowerShell Core (default on Windows)
        run: |
          Write-Host "OS: $env:RUNNER_OS"
          Get-Process | Select-Object -First 5

      # Legacy PowerShell (Windows PowerShell 5.1)
      - name: Windows PowerShell 5.1
        shell: powershell
        run: |
          Write-Host "Legacy PowerShell: $($PSVersionTable.PSVersion)"

      # CMD (Command Prompt)
      - name: CMD shell
        shell: cmd
        run: |
          echo OS is %OS%
          dir /b

      # Bash on Windows (Git Bash)
      - name: Bash on Windows
        shell: bash
        run: |
          echo "Git Bash on Windows"
          uname -s    # Outputs MINGW64_NT-...

Setting Default Shell for an Entire Job

# .github/workflows/default-shell.yml
name: Default Shell Configuration

on: push

jobs:
  powershell-job:
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: pwsh              # All run: steps use PowerShell Core
        working-directory: ./src # All run: steps start in ./src

    steps:
      - uses: actions/checkout@v4

      - name: This uses pwsh automatically
        run: |
          Write-Host "Working in: $(Get-Location)"
          Get-ChildItem | Format-Table Name, Length

      - name: Override for one step
        shell: bash              # Override the default for this step only
        run: echo "Back to bash for this step"

      - name: Back to default pwsh
        run: Write-Host "pwsh again"

Working Directory

# .github/workflows/working-directory.yml
name: Working Directory Examples

on: push

jobs:
  monorepo-build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Set working directory per step
      - name: Build frontend
        working-directory: ./packages/frontend
        run: |
          npm ci
          npm run build

      - name: Build backend
        working-directory: ./packages/backend
        run: |
          npm ci
          npm run build

      - name: Run integration tests
        working-directory: ./tests/integration
        run: |
          npm ci
          npm test

  # Set default working directory for all steps
  api-service:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ./services/api

    steps:
      - uses: actions/checkout@v4
      - run: npm ci              # Runs in ./services/api/
      - run: npm test            # Runs in ./services/api/
      - run: npm run build       # Runs in ./services/api/

      - name: Upload from project root
        working-directory: .     # Override back to repo root
        run: ls -la services/api/dist/

Running Multi-line Commands

YAML provides two block scalar styles for multi-line strings: literal blocks (|) and folded blocks (>). Understanding the difference is essential for writing readable workflow commands.

Literal Block (|) — Preserves Newlines

The pipe | preserves every newline exactly as written. Each line becomes a separate command in the shell. This is the most common style for multi-line run: steps.

# .github/workflows/multiline.yml
name: Multi-line Command Styles

on: push

jobs:
  literal-blocks:
    runs-on: ubuntu-latest
    steps:
      # | preserves newlines — each line is a separate command
      - name: Multiple commands (literal block)
        run: |
          echo "Step 1: Install dependencies"
          npm ci
          echo "Step 2: Run linter"
          npm run lint
          echo "Step 3: Run tests"
          npm test
          echo "Step 4: Build"
          npm run build

      # |+ preserves trailing newlines (rarely needed)
      - name: With trailing newlines
        run: |+
          echo "This has a trailing newline"

      # |- strips trailing newlines
      - name: Without trailing newlines
        run: |-
          echo "No trailing newline in the string"

Folded Block (>) — Joins Lines

The greater-than > folds newlines into spaces, creating a single long line. Empty lines still create paragraph breaks. Use this for long single commands that you want to wrap visually.

# Folded block: newlines become spaces
jobs:
  folded-blocks:
    runs-on: ubuntu-latest
    steps:
      # > joins lines with spaces — becomes one long command
      - name: Long single command (folded)
        run: >
          curl -X POST
          -H "Authorization: Bearer ${{ secrets.TOKEN }}"
          -H "Content-Type: application/json"
          -d '{"status": "success", "sha": "${{ github.sha }}"}'
          https://api.example.com/webhooks/ci-status

      # Equivalent to:
      # curl -X POST -H "Authorization: Bearer ..." -H "Content-Type: application/json" -d '...' https://...

      # >- strips trailing newline from folded block
      - name: Folded without trailing newline
        run: >-
          docker build
          --tag myapp:${{ github.sha }}
          --build-arg VERSION=${{ github.ref_name }}
          --file docker/Dockerfile.production
          .

Complex Multi-step Scripts

# .github/workflows/complex-scripts.yml
name: Complex Script Patterns

on: push

jobs:
  advanced-scripting:
    runs-on: ubuntu-latest
    steps:
      # Shell functions and conditionals
      - name: Script with functions
        run: |
          # Define reusable function
          check_status() {
            local service=$1
            local url=$2
            local status=$(curl -s -o /dev/null -w "%{http_code}" "$url")
            if [ "$status" -eq 200 ]; then
              echo "✓ $service is healthy (HTTP $status)"
              return 0
            else
              echo "✗ $service is unhealthy (HTTP $status)"
              return 1
            fi
          }

          # Use the function
          check_status "API" "https://api.example.com/health"
          check_status "Frontend" "https://www.example.com"

      # Here-doc for generating files
      - name: Generate config file
        run: |
          cat > config.json << 'EOF'
          {
            "version": "${{ github.sha }}",
            "environment": "production",
            "features": {
              "dark_mode": true,
              "beta_users": false
            }
          }
          EOF
          echo "Generated config:"
          cat config.json

      # Loop over multiple items
      - name: Process multiple services
        run: |
          services=("auth" "api" "worker" "scheduler")
          for service in "${services[@]}"; do
            echo "Processing $service..."
            docker build -t "myapp-$service:latest" "./services/$service"
            docker push "myapp-$service:latest"
          done

      # Error handling with trap
      - name: With cleanup on failure
        run: |
          cleanup() {
            echo "Cleaning up temporary files..."
            rm -rf /tmp/build-artifacts
          }
          trap cleanup EXIT

          mkdir -p /tmp/build-artifacts
          echo "Building..."
          # If any command fails, cleanup runs automatically
          npm run build -- --output /tmp/build-artifacts
Shell Error Handling: By default, GitHub Actions runs bash with set -eo pipefail. This means: (1) the step fails immediately if any command returns non-zero (-e), (2) pipe failures are detected (-o pipefail). If you need to handle errors yourself, use || true or explicit if checks. Never disable set -e globally — it hides real failures.

Job and Step Outputs

Outputs are the mechanism for passing data between steps within a job and between jobs in a workflow. GitHub Actions uses environment files ($GITHUB_OUTPUT and $GITHUB_ENV) for this communication.

Step Outputs with $GITHUB_OUTPUT

# .github/workflows/step-outputs.yml
name: Step Outputs

on: push

jobs:
  compute:
    runs-on: ubuntu-latest
    steps:
      # Set an output from a step
      - name: Determine version
        id: version      # Required: gives the step a referenceable ID
        run: |
          # Write key=value pairs to $GITHUB_OUTPUT
          echo "semver=1.2.3" >> "$GITHUB_OUTPUT"
          echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
          echo "timestamp=$(date -u +%Y%m%d%H%M%S)" >> "$GITHUB_OUTPUT"
          echo "is_release=${{ startsWith(github.ref, 'refs/tags/v') }}" >> "$GITHUB_OUTPUT"

      # Use outputs from previous step
      - name: Use version info
        run: |
          echo "Version: ${{ steps.version.outputs.semver }}"
          echo "Short SHA: ${{ steps.version.outputs.sha_short }}"
          echo "Timestamp: ${{ steps.version.outputs.timestamp }}"
          echo "Is release: ${{ steps.version.outputs.is_release }}"

      # Multi-line outputs (use delimiters)
      - name: Set multi-line output
        id: changelog
        run: |
          # Use heredoc-style delimiters for multi-line values
          echo "content<> "$GITHUB_OUTPUT"
          echo "## Changes in this release" >> "$GITHUB_OUTPUT"
          echo "- Fixed login bug" >> "$GITHUB_OUTPUT"
          echo "- Added dark mode" >> "$GITHUB_OUTPUT"
          echo "- Improved performance" >> "$GITHUB_OUTPUT"
          echo "EOF" >> "$GITHUB_OUTPUT"

      - name: Display changelog
        run: |
          echo "${{ steps.changelog.outputs.content }}"

Environment Variables with $GITHUB_ENV

# .github/workflows/env-vars.yml
name: Environment Variables

on: push

jobs:
  env-demo:
    runs-on: ubuntu-latest
    # Job-level environment variables
    env:
      APP_NAME: my-application
      LOG_LEVEL: info

    steps:
      # Step-level env vars (override job-level)
      - name: Step with custom env
        env:
          LOG_LEVEL: debug      # Overrides job-level value
          DB_HOST: localhost
        run: |
          echo "App: $APP_NAME"      # From job env
          echo "Log: $LOG_LEVEL"     # debug (step override)
          echo "DB: $DB_HOST"        # From step env

      # Dynamically set env vars for ALL subsequent steps
      - name: Set dynamic env vars
        run: |
          echo "BUILD_TAG=build-$(date +%s)" >> "$GITHUB_ENV"
          echo "DEPLOY_URL=https://${{ github.repository_owner }}.github.io" >> "$GITHUB_ENV"

          # Multi-line env var
          echo "RELEASE_NOTES<> "$GITHUB_ENV"
          echo "Release $(date +%Y-%m-%d)" >> "$GITHUB_ENV"
          echo "Commit: ${{ github.sha }}" >> "$GITHUB_ENV"
          echo "EOF" >> "$GITHUB_ENV"

      # Subsequent steps can access the dynamic vars
      - name: Use dynamic env vars
        run: |
          echo "Build tag: $BUILD_TAG"
          echo "Deploy URL: $DEPLOY_URL"
          echo "Release notes: $RELEASE_NOTES"

Passing Data Between Jobs

Jobs run on separate runners, so they don't share filesystem or memory. To pass data between jobs, you must declare job outputs that map step outputs to job-level outputs.

# .github/workflows/job-outputs.yml
name: Passing Data Between Jobs

on: push

jobs:
  # Job 1: Compute values and declare outputs
  prepare:
    runs-on: ubuntu-latest
    # Declare job-level outputs (maps step outputs to job outputs)
    outputs:
      version: ${{ steps.meta.outputs.version }}
      environment: ${{ steps.meta.outputs.environment }}
      should-deploy: ${{ steps.meta.outputs.should-deploy }}
      image-tag: ${{ steps.meta.outputs.image-tag }}
    steps:
      - uses: actions/checkout@v4

      - name: Compute metadata
        id: meta
        run: |
          # Determine version from package.json
          VERSION=$(node -p "require('./package.json').version")
          echo "version=$VERSION" >> "$GITHUB_OUTPUT"

          # Determine environment from branch
          if [[ "${{ github.ref_name }}" == "main" ]]; then
            echo "environment=production" >> "$GITHUB_OUTPUT"
            echo "should-deploy=true" >> "$GITHUB_OUTPUT"
          elif [[ "${{ github.ref_name }}" == "develop" ]]; then
            echo "environment=staging" >> "$GITHUB_OUTPUT"
            echo "should-deploy=true" >> "$GITHUB_OUTPUT"
          else
            echo "environment=development" >> "$GITHUB_OUTPUT"
            echo "should-deploy=false" >> "$GITHUB_OUTPUT"
          fi

          # Build image tag
          echo "image-tag=$VERSION-${{ github.sha }}" >> "$GITHUB_OUTPUT"

  # Job 2: Use outputs from Job 1
  build:
    needs: prepare
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build with version from previous job
        run: |
          echo "Building version: ${{ needs.prepare.outputs.version }}"
          echo "Image tag: ${{ needs.prepare.outputs.image-tag }}"
          echo "Target env: ${{ needs.prepare.outputs.environment }}"

      - name: Docker build
        run: |
          docker build \
            --tag "myapp:${{ needs.prepare.outputs.image-tag }}" \
            --label "version=${{ needs.prepare.outputs.version }}" \
            .

  # Job 3: Conditionally deploy based on Job 1 output
  deploy:
    needs: [prepare, build]
    if: needs.prepare.outputs.should-deploy == 'true'
    runs-on: ubuntu-latest
    environment: ${{ needs.prepare.outputs.environment }}
    steps:
      - name: Deploy
        run: |
          echo "Deploying ${{ needs.prepare.outputs.image-tag }}"
          echo "To environment: ${{ needs.prepare.outputs.environment }}"
Data Flow Between Jobs
flowchart LR
    subgraph "Job: prepare"
        A1[Step: compute metadata] -->|"$GITHUB_OUTPUT"| A2[Step Outputs]
        A2 -->|"outputs: mapping"| A3[Job Outputs]
    end

    subgraph "Job: build"
        B1["needs.prepare.outputs.version"]
        B2["needs.prepare.outputs.image-tag"]
    end

    subgraph "Job: deploy"
        C1["needs.prepare.outputs.environment"]
        C2["if: needs.prepare.outputs.should-deploy"]
    end

    A3 --> B1
    A3 --> B2
    A3 --> C1
    A3 --> C2

    style A1 fill:#3B9797,color:#fff
    style A3 fill:#132440,color:#fff
    style B1 fill:#16476A,color:#fff
    style B2 fill:#16476A,color:#fff
    style C1 fill:#BF092F,color:#fff
    style C2 fill:#BF092F,color:#fff
                            
Output Limitations:
  • Outputs are always strings — even numbers and booleans become string values
  • Maximum output size is 1 MB per step — for larger data, use artifacts
  • Outputs are available only to direct dependents (jobs that list this job in needs:)
  • For sharing files between jobs, use actions/upload-artifact and actions/download-artifact

Using Concurrency to Control Workflow Execution

The concurrency key prevents multiple workflow runs from executing simultaneously for the same logical unit. This is essential for deployments (don't deploy twice simultaneously), expensive operations (GPU jobs), and PR workflows (only the latest commit matters).

Concurrency Groups

# .github/workflows/concurrency-basic.yml
name: Deploy with Concurrency

on:
  push:
    branches: [main]

# Workflow-level concurrency: only one deploy at a time
concurrency:
  group: production-deploy      # Unique identifier for this concurrency group
  cancel-in-progress: false     # Queue new runs (don't cancel the running one)

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: |
          echo "Deploying — this is safe from concurrent deploys"
          ./scripts/deploy.sh --env production
          sleep 60  # Simulate long deploy

When a new workflow run is triggered while another is already running in the same concurrency group:

  • cancel-in-progress: false (default) — The new run is queued and waits for the current run to finish
  • cancel-in-progress: true — The currently running workflow is cancelled and the new one starts immediately

Dynamic Concurrency Groups

# .github/workflows/concurrency-dynamic.yml
name: PR Workflow with Concurrency

on:
  pull_request:
    branches: [main]

# Cancel previous runs for the same PR
# Each PR gets its own concurrency group
concurrency:
  group: pr-${{ github.event.pull_request.number }}
  cancel-in-progress: true    # Cancel stale runs when new commits are pushed

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  preview:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run build
      - name: Deploy preview
        run: echo "Preview URL: https://pr-${{ github.event.pull_request.number }}.preview.example.com"

Per-Environment Concurrency

# .github/workflows/concurrency-per-env.yml
name: Multi-Environment Deploy

on:
  workflow_dispatch:
    inputs:
      environment:
        type: choice
        options: [staging, production]

jobs:
  deploy:
    runs-on: ubuntu-latest
    # Each environment has its own concurrency group
    # Staging deploys don't block production deploys
    concurrency:
      group: deploy-${{ inputs.environment }}
      cancel-in-progress: false

    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to ${{ inputs.environment }}
        run: |
          echo "Deploying to ${{ inputs.environment }}"
          ./scripts/deploy.sh --env "${{ inputs.environment }}"

Job-Level Concurrency

# .github/workflows/concurrency-job-level.yml
name: Mixed Concurrency

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    # No concurrency — tests can run in parallel for different commits
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    # Job-level concurrency — only one deploy at a time
    concurrency:
      group: production-deploy
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/deploy.sh

  notify:
    needs: deploy
    runs-on: ubuntu-latest
    # Notifications can overlap — no concurrency needed
    steps:
      - run: echo "Deployment complete"

Branch-Based Concurrency Patterns

# .github/workflows/concurrency-branch.yml
name: Branch-Aware Concurrency

on:
  push:
    branches: ['**']
  pull_request:
    branches: [main]

# Pattern: group by ref (branch name or PR number)
# - For push to main: group = "ci-refs/heads/main" (one at a time on main)
# - For push to feature/x: group = "ci-refs/heads/feature/x" (one per branch)
# - For PRs: group = "ci-refs/pull/42/merge" (one per PR)
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
  # Cancel stale runs on feature branches but NOT on main
  # Main branch runs always complete (never cancelled)

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test
      - run: npm run build
Concurrency Best Practices:
  • Deployments: Use cancel-in-progress: false — never interrupt an active deployment; queue the next one
  • PR CI: Use cancel-in-progress: true — only the latest commit matters; cancel stale checks
  • Main branch CI: Use cancel-in-progress: false — every commit to main should be verified
  • Group naming: Make groups specific enough to avoid unintended blocking (e.g., include branch name, PR number, or environment)
Concurrency Behavior Comparison
sequenceDiagram
    participant Push1 as Push #1
    participant Push2 as Push #2
    participant Push3 as Push #3

    Note over Push1,Push3: cancel-in-progress: false (Queue)
    Push1->>Push1: Running...
    Push2->>Push2: Queued (waiting)
    Push1->>Push1: Complete
    Push2->>Push2: Running...
    Push3->>Push3: Queued (waiting)
    Push2->>Push2: Complete
    Push3->>Push3: Running...
    Push3->>Push3: Complete

    Note over Push1,Push3: cancel-in-progress: true (Replace)
    Push1->>Push1: Running...
    Push2->>Push1: Cancelled!
    Push2->>Push2: Running...
    Push3->>Push2: Cancelled!
    Push3->>Push3: Running...
    Push3->>Push3: Complete
                            

Exercises

Exercise 1: Fan-Out/Fan-In Pipeline

Create a workflow with the following job dependency graph: (1) a setup job that installs dependencies and caches them, (2) three parallel jobs — lint, unit-test, and integration-test — that all depend on setup, and (3) a deploy job that only runs after all three parallel jobs succeed. Add a notify job using if: always() that reports the final status. Verify the dependency graph in the GitHub Actions UI.

Exercise 2: Cross-Platform Matrix with Exclusions

Build a matrix strategy that tests a Node.js library across 3 operating systems (Ubuntu, Windows, macOS) and 3 Node versions (18, 20, 22). Exclude the combination of macos-latest + Node 18 (not supported). Include an extra combination of ubuntu-latest + Node 22 with an additional environment variable EXPERIMENTAL=true. Use fail-fast: false and verify all matrix jobs run to completion.

Exercise 3: Job Outputs Pipeline

Create a two-job workflow where Job 1 (analyze) reads the package.json version, determines whether the version changed compared to the previous commit, and outputs version, changed (true/false), and changelog (multi-line). Job 2 (release) depends on Job 1 and only runs if changed == 'true'. Job 2 should use the version and changelog outputs to create a GitHub release.

Exercise 4: Concurrency-Controlled Deployment

Implement a deployment workflow with the following concurrency rules: (1) Only one deployment per environment can run at a time (use dynamic group deploy-${{ env }}). (2) PR preview deployments should cancel previous previews for the same PR (cancel-in-progress: true). (3) Production deployments should never be cancelled — new runs must queue. Test by triggering multiple rapid deployments and observing the queuing/cancellation behavior in the Actions tab.

Next in the Bootcamp

In Module 4: Expressions and Conditional Execution, we'll dive deep into GitHub Actions expression syntax — context objects, operators, status functions, type coercion, and building sophisticated conditional logic for jobs and steps.