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.
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..."
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
| Runner | vCPUs | RAM | Storage | Architecture |
|---|---|---|---|---|
ubuntu-latest | 4 | 16 GB | 14 GB SSD | x86_64 |
windows-latest | 4 | 16 GB | 14 GB SSD | x86_64 |
macos-latest | 3 (M1) | 7 GB | 14 GB SSD | ARM64 |
macos-13 | 4 (Intel) | 14 GB | 14 GB SSD | x86_64 |
ubuntu-latest-16-cores | 16 | 64 GB | 150 GB SSD | x86_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.
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..."
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
success()— Returns true only if ALL dependency jobs succeeded (default behavior)failure()— Returns true if ANY dependency job failedalways()— Always returns true (job runs even if dependencies were cancelled)cancelled()— Returns true only if the workflow was explicitly cancelled
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"
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
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 }}"
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
- 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-artifactandactions/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 finishcancel-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
- 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)
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
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.
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.
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.
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.