Beginner
This tutorial covers core GitHub Actions concepts through 28 self-contained, heavily annotated workflow examples. Each example is a complete, runnable .github/workflows/*.yml file demonstrating one focused concept. The examples progress from the minimal workflow structure through triggers, job configuration, common actions, environment variables, conditionals, and job dependencies — spanning 0–35% of GitHub Actions features.
Workflow File Structure
Example 1: Minimal Workflow File
The smallest valid GitHub Actions workflow contains three required top-level keys: name, on, and jobs. Understanding this skeleton prevents the most common beginner mistake — missing required sections.
# .github/workflows/minimal.yml
# The workflow name appears in the GitHub Actions UI tab.
# Choose names that describe WHAT this workflow does, not HOW.
name: Minimal Workflow
# "on" defines WHEN this workflow runs.
# Every workflow must declare at least one triggering event.
on:
# "push" fires whenever commits are pushed to the repository.
# Without branch filters it triggers on ALL branches.
push:
# "jobs" is a map of one or more jobs to run.
# Every workflow must contain at least one job.
jobs:
# "say-hello" is the job ID — used in logs and as a reference target.
# Job IDs are lowercase, can contain hyphens, and must be unique within the workflow.
say-hello:
# "runs-on" specifies the runner (virtual machine) that executes this job.
# "ubuntu-latest" is the most common choice for Linux-based CI.
runs-on: ubuntu-latest
# "steps" is the ordered list of tasks inside this job.
# Steps run sequentially; if one fails the job stops (by default).
steps:
# Each step needs at minimum a "run" or "uses" key.
# "run" executes shell commands directly on the runner.
- run: echo "Hello from GitHub Actions"
# => Prints: Hello from GitHub Actions
# => This single shell command is the entire job payload here.Key takeaway: Every valid workflow needs name, on, and jobs — the jobs block must contain at least one job with runs-on and steps.
Why it matters: Understanding the required skeleton saves debugging time when creating new workflows from scratch. Teams that skip a required key receive cryptic YAML parse errors rather than helpful messages. Knowing this three-key contract lets you scaffold any workflow confidently before adding triggers and steps.
Example 2: Workflow Name and Job Name
Names at the workflow level and at the job level serve different UX purposes in the GitHub Actions interface. Descriptive names make audit trails readable.
# .github/workflows/named-workflow.yml
# Workflow-level name: appears as the top-level entry in the
# "Actions" tab of the repository and in status badges.
name: Build and Test Pipeline
on:
push:
jobs:
# Job ID: machine-readable, used for "needs:" references.
# Must match [a-zA-Z_][a-zA-Z0-9_-]* pattern.
build-app:
# "name" at job level: human-readable label shown in the UI sidebar.
# Distinct from the job ID — can contain spaces and special chars.
name: Compile Application
# ubuntu-latest resolves to the latest GitHub-hosted Ubuntu runner.
# GitHub updates this periodically; pin to "ubuntu-22.04" for stability.
runs-on: ubuntu-latest
steps:
# Step "name" appears as a collapsible section in the Actions log UI.
# Good step names describe the intent, not the command.
- name: Print build message
# => Step label "Print build message" appears in UI.
run: echo "Building application..."
# => Prints: Building application...
- name: Confirm completion
run: echo "Build step complete"
# => Prints: Build step completeKey takeaway: Workflow name, job name, and step name are all optional but critically improve readability in the GitHub Actions UI — they appear as human-readable labels separate from IDs.
Why it matters: In production pipelines with many jobs and steps, descriptive names reduce the time to locate a failure. Teams with generic names like “Run tests” spend minutes finding the right log; teams with names like “Run unit tests — auth module” find failures instantly. Naming is free and pays dividends at 2 AM during an incident.
Example 3: The on Key with a Single Event
The on key is the entry point for all GitHub Actions triggers. At its simplest it accepts a single event name as a bare string.
graph TD
A["Developer pushes commit"]:::blue --> B["GitHub detects push event"]:::orange
B --> C["on: push matches"]:::teal
C --> D["Workflow queued"]:::purple
D --> E["Runner executes jobs"]:::brown
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000,color:#fff,stroke-width:2px
classDef brown fill:#CA9161,stroke:#000,color:#fff,stroke-width:2px
# .github/workflows/single-event.yml
name: Single Event Trigger
# A bare string value for "on" means: trigger on exactly this one event.
# This is the shortest valid "on" syntax.
on: push
# => Workflow triggers whenever any branch receives a push.
# => Equivalent to: on: { push: {} }
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Confirm trigger
# GITHUB_EVENT_NAME is automatically set by the runner.
# It will always be "push" when this workflow runs.
run: echo "Triggered by event: $GITHUB_EVENT_NAME"
# => Prints: Triggered by event: pushKey takeaway: on: push (bare string) is the shortest valid trigger and fires on every push to any branch in the repository.
Why it matters: Many teams start with a bare on: push to get a workflow running quickly, then add filters later. Understanding that the bare form means “no filters applied” prevents accidental cross-branch runs during early pipeline development.
Example 4: The push Trigger
The push event is the most common trigger. Understanding its default behavior — firing on all branches — and how it differs from a filtered push prevents accidental CI runs on unwanted branches.
# .github/workflows/push-trigger.yml
name: Push Trigger Demo
# "on" accepts a map when you need to configure event options.
# Here we configure the "push" event specifically.
on:
# "push" as a key (not a bare string) allows adding sub-options.
# With no sub-options, it triggers on every branch and tag push.
push:
# No branch filters here — this means ALL branches trigger the workflow.
# This is intentional for demonstration; in practice add branch filters.
jobs:
log-push:
name: Log Push Context
runs-on: ubuntu-latest
steps:
- name: Show pushed branch
# GITHUB_REF contains the full ref, e.g. "refs/heads/main"
# GITHUB_SHA is the commit SHA that triggered the run.
run: |
echo "Ref: $GITHUB_REF"
echo "SHA: $GITHUB_SHA"
# => Ref: refs/heads/<branch-name>
# => SHA: <40-char commit hash>
- name: Show actor
# GITHUB_ACTOR is the login of the user who triggered the event.
run: echo "Pushed by: $GITHUB_ACTOR"
# => Prints: Pushed by: <github-username>Key takeaway: The push event without filters runs on every branch; the runner injects context via GITHUB_REF, GITHUB_SHA, and GITHUB_ACTOR environment variables automatically.
Why it matters: Unfiltered push triggers are the most common cause of unexpected workflow runs in open-source repositories where contributors push feature branches. Knowing the defaults makes the decision to add branch filters deliberate rather than accidental.
Triggers
Example 5: The pull_request Trigger
The pull_request event fires when a PR is opened, synchronized (new commits), or reopened. It is the standard trigger for pre-merge validation checks.
graph LR
A["PR opened / new commit pushed"]:::blue --> B["pull_request event fires"]:::orange
B --> C["Workflow runs on PR branch"]:::teal
C --> D{"Checks pass?"}:::orange
D -->|Yes| E["Merge allowed"]:::teal
D -->|No| F["Merge blocked"]:::purple
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000,color:#fff,stroke-width:2px
# .github/workflows/pull-request-trigger.yml
name: PR Validation
on:
# "pull_request" fires on PR open, synchronize, and reopen by default.
# The code checked out is the MERGE COMMIT (base + head merged together).
pull_request:
# No filters here — fires for PRs targeting any base branch.
# Common production usage: add "branches: [main]" to limit scope.
jobs:
validate:
name: Run Validation
runs-on: ubuntu-latest
steps:
- name: Show PR context
run: |
# GITHUB_HEAD_REF: the source branch of the PR
echo "PR from branch: $GITHUB_HEAD_REF"
# => Prints: PR from branch: feature/my-feature
# GITHUB_BASE_REF: the target branch of the PR
echo "PR targeting branch: $GITHUB_BASE_REF"
# => Prints: PR targeting branch: main
- name: Simulate validation
run: echo "All checks passed"
# => Prints: All checks passed
# => Status check appears green on the PR page in GitHub UIKey takeaway: pull_request triggers run against the merge commit and provide GITHUB_HEAD_REF/GITHUB_BASE_REF to identify source and target branches.
Why it matters: Making pull_request the required status check for protected branches enforces that all code passes CI before merging. This is the single most impactful use of GitHub Actions in team development — it shifts quality checking left and removes the “it worked on my machine” problem.
Example 6: Multiple Triggers on One Workflow
A single workflow can respond to multiple events. Declaring both push and pull_request is a common pattern for running CI both on direct pushes and on PR checks.
# .github/workflows/multi-trigger.yml
name: CI on Push and PR
# "on" accepts a map with multiple event keys.
# Both events independently trigger this entire workflow.
on:
# Trigger 1: fires on every push to any branch
push:
# Trigger 2: fires when a PR is opened, updated, or reopened
pull_request:
jobs:
ci:
name: Run CI
runs-on: ubuntu-latest
steps:
- name: Identify trigger source
run: |
# GITHUB_EVENT_NAME will be either "push" or "pull_request"
# depending on which event fired this run.
echo "This run was triggered by: $GITHUB_EVENT_NAME"
# => Prints: This run was triggered by: push
# => OR: This run was triggered by: pull_request
- name: Run tests
run: echo "Running test suite..."
# => Both push runs and PR runs execute this step.
# => This avoids maintaining two separate workflow files.Key takeaway: Listing multiple events under on causes all listed events to independently trigger the same workflow — useful for running the same CI logic on pushes and pull requests.
Why it matters: A single shared workflow reduces maintenance. Without multi-trigger support, teams duplicate workflow files — one for push CI and one for PR checks — leading to drift between the two. Combining them ensures the same test suite runs in both contexts with one file to maintain.
Example 7: Branch Filters on push
Branch filters limit which branches activate the push trigger. This is essential for preventing CI runs on every feature branch push when only main needs it.
# .github/workflows/branch-filter-push.yml
name: Main Branch CI
on:
push:
# "branches" is a list of branch name patterns.
# The workflow ONLY runs when commits are pushed to these branches.
branches:
# Exact match — only the "main" branch
- main
# Glob pattern — any branch starting with "release/"
- "release/*"
# => Pushes to "feature/login" will NOT trigger this workflow.
# => Pushes to "main" or "release/1.0" WILL trigger it.
jobs:
deploy-check:
name: Deployment Readiness Check
runs-on: ubuntu-latest
steps:
- name: Confirm branch filter
run: |
echo "Running on filtered branch: $GITHUB_REF_NAME"
# => GITHUB_REF_NAME contains the short branch name (e.g. "main")
# => This step only ever runs on main or release/* branches.Key takeaway: The branches filter under push (or pull_request) limits the trigger to specific branch name patterns, including glob syntax like release/*.
Why it matters: Without branch filters, every push to every feature branch triggers CI, wasting runner minutes and cluttering the Actions tab. Branch filters focus expensive CI runs where they matter — release branches and the main trunk — reducing both cost and noise.
Example 8: Path Filters on push
Path filters restrict triggers to pushes that touch specific files or directories. This dramatically reduces unnecessary CI runs in monorepos.
# .github/workflows/path-filter-push.yml
name: Backend CI
on:
push:
branches:
- main
# "paths" is a list of file/directory patterns.
# The workflow ONLY runs if at least one changed file matches a pattern.
paths:
# Run CI when anything in the backend/ directory changes.
- "backend/**"
# Also run if the shared config file changes.
- "config/shared.yml"
# => A push changing only "frontend/index.html" will NOT trigger this.
# => A push changing "backend/app.go" WILL trigger this.
jobs:
backend-tests:
name: Backend Test Suite
runs-on: ubuntu-latest
steps:
- name: Run backend tests
run: echo "Running backend-specific tests..."
# => Only runs when backend/** or config/shared.yml changed.
# => Frontend-only changes never reach this step.Key takeaway: paths filters fire the workflow only when at least one changed file matches the pattern list — powerful for monorepos where backend and frontend CI should be independent.
Why it matters: In a monorepo with ten services, a single documentation typo fix should not trigger all ten CI pipelines. Path filters cut CI costs proportionally to the number of independent services, and they speed up developer feedback loops by only running relevant checks.
Example 9: The schedule Trigger (Cron Syntax)
The schedule trigger runs workflows on a time-based schedule using cron syntax. It is the standard way to run nightly builds, dependency scans, and scheduled reports.
graph TD
A["UTC clock reaches cron expression"]:::blue --> B["GitHub queues scheduled run"]:::orange
B --> C["No commit needed — time-based"]:::teal
C --> D["Workflow executes on default branch"]:::purple
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000,color:#fff,stroke-width:2px
# .github/workflows/scheduled.yml
name: Nightly Dependency Scan
on:
schedule:
# "cron" follows standard cron syntax: minute hour day-of-month month day-of-week
# All times are UTC. There is no built-in timezone conversion.
- cron: "0 2 * * *"
# => Field breakdown:
# => 0 = minute 0 (on the hour)
# => 2 = hour 2 (2:00 AM UTC)
# => * = every day of the month
# => * = every month
# => * = every day of the week
# => Net result: runs at 02:00 UTC every day.
# You can add multiple cron entries to run at different times.
- cron: "0 14 * * 1"
# => Every Monday at 14:00 UTC (second independent schedule).
jobs:
scan:
name: Run Dependency Scan
runs-on: ubuntu-latest
steps:
- name: Announce scan start
run: |
echo "Scheduled scan starting at: $(date -u)"
# => Prints: Scheduled scan starting at: <current UTC datetime>
# => The run always uses code from the default branch (usually main).Key takeaway: schedule with cron syntax runs workflows on a time basis independent of code changes; multiple cron entries can coexist under a single schedule key.
Why it matters: Security vulnerability databases update continuously. A nightly schedule trigger runs dependency audits, license scans, or dead-link checks automatically without requiring anyone to push code. Teams that skip scheduled runs often discover vulnerabilities only when an incident forces a reactive audit.
Example 10: The workflow_dispatch Trigger (Manual Run)
workflow_dispatch allows a workflow to be triggered manually from the GitHub UI or via the API. It supports optional inputs to parameterize the run.
# .github/workflows/manual-dispatch.yml
name: Manual Deployment
on:
# "workflow_dispatch" enables the "Run workflow" button in the GitHub UI.
# Without any inputs, the button appears with no form fields.
workflow_dispatch:
# "inputs" defines form fields shown in the UI before the run starts.
inputs:
environment:
# "description" is the label shown next to the input field in the UI.
description: "Target deployment environment"
# "required: true" means the UI enforces a non-empty value before running.
required: true
# "default" pre-populates the field in the UI.
default: "staging"
# "type: choice" renders a dropdown instead of a free-text input.
type: choice
# "options" lists the allowed values for the dropdown.
options:
- staging
- production
# => Developer picks "staging" or "production" before clicking Run.
dry-run:
description: "Simulate without making changes"
required: false
# "type: boolean" renders a checkbox in the UI.
type: boolean
default: false
jobs:
deploy:
name: Deploy to Environment
runs-on: ubuntu-latest
steps:
- name: Show chosen parameters
run: |
# inputs are accessed via github.event.inputs in expressions,
# but also available as env vars prefixed with INPUT_ in shell.
echo "Environment: ${{ github.event.inputs.environment }}"
# => Prints: Environment: staging (or production, per selection)
echo "Dry run: ${{ github.event.inputs.dry-run }}"
# => Prints: Dry run: false (or true if checkbox was checked)
- name: Simulate deploy
run: echo "Deploying to ${{ github.event.inputs.environment }}..."
# => Prints: Deploying to staging...Key takeaway: workflow_dispatch adds a manual “Run workflow” button with optional typed inputs — choice dropdowns, booleans, and strings — accessible from the GitHub UI and the REST API.
Why it matters: Fully automated pipelines still need escape hatches for one-off deployments, hotfix releases, or manual approvals in regulated environments. workflow_dispatch provides that without the security risk of exposing SSH access or running ad-hoc scripts locally.
The runs-on Key
Example 11: Ubuntu, Windows, and macOS Runners
GitHub provides hosted runners for three operating systems. Choosing the right OS is critical for platform-specific tests and build artifacts.
# .github/workflows/runner-os.yml
name: Multi-OS Runner Demo
on:
push:
jobs:
# Job 1: Linux runner — cheapest, fastest, most common
linux-job:
name: Linux Runner
# ubuntu-latest maps to the current latest Ubuntu LTS on GitHub-hosted runners.
runs-on: ubuntu-latest
steps:
- name: Show OS
run: |
uname -a
# => Prints: Linux <hostname> <kernel-version> ... x86_64 GNU/Linux
echo "Shell: $SHELL"
# => Prints: Shell: /bin/bash (default shell on ubuntu runners)
# Job 2: Windows runner — needed for .NET, MSBuild, PowerShell-specific tasks
windows-job:
name: Windows Runner
# windows-latest maps to the current latest Windows Server on GitHub runners.
runs-on: windows-latest
steps:
- name: Show OS
# On Windows runners, "run" defaults to PowerShell.
run: |
[System.Environment]::OSVersion.VersionString
# => Prints: Microsoft Windows <version>
Write-Host "Runner: windows-latest"
# => Prints: Runner: windows-latest
# Job 3: macOS runner — needed for iOS/macOS builds, Xcode
macos-job:
name: macOS Runner
# macos-latest maps to the current latest macOS on GitHub runners.
# macOS runners are more expensive (10x Linux per minute).
runs-on: macos-latest
steps:
- name: Show OS
run: |
sw_vers
# => Prints: ProductName: macOS / ProductVersion: 14.x / BuildVersion: ...
echo "Shell: $SHELL"
# => Prints: Shell: /bin/zsh (default shell on macOS runners)Key takeaway: runs-on accepts ubuntu-latest, windows-latest, or macos-latest for GitHub-hosted runners; jobs run concurrently (different jobs, not steps) since they are independent.
Why it matters: Cross-platform compatibility tests catch line-ending bugs, path separator issues, and OS-specific API differences before users encounter them. Mobile teams absolutely require macOS runners for Xcode builds. Explicitly understanding cost differences (macos-latest is ~10x Linux) enables informed architectural decisions about when multi-OS CI is worth the spend.
Example 12: Pinning a Specific Runner Version
Pinning to a specific runner version (e.g., ubuntu-22.04) prevents breaking changes when GitHub updates ubuntu-latest to a new OS version.
# .github/workflows/pinned-runner.yml
name: Stable Runner Version
on:
push:
jobs:
stable-build:
name: Build on Pinned Runner
# ubuntu-22.04 is pinned — will NOT silently update when GitHub releases ubuntu-24.04.
# Contrast with ubuntu-latest, which updates automatically and can break builds.
runs-on: ubuntu-22.04
# => Runner: Ubuntu 22.04 LTS (Jammy Jellyfish)
# => This version remains stable until GitHub explicitly deprecates it.
steps:
- name: Verify Ubuntu version
run: |
cat /etc/os-release | grep VERSION=
# => Prints: VERSION="22.04.x LTS (Jammy Jellyfish)"
# => Guaranteed — because runner is pinned, not "latest"
- name: Check preinstalled tools
run: |
node --version
# => Prints: v<version preinstalled on ubuntu-22.04 image>
python3 --version
# => Prints: Python 3.10.x (specific to ubuntu-22.04 image)Key takeaway: Pinning to ubuntu-22.04 (or ubuntu-24.04) instead of ubuntu-latest prevents surprise breakage when GitHub updates the latest alias to a new OS major version.
Why it matters: In production, silent runner upgrades have broken builds by changing the default Python version, OpenSSL behavior, or preinstalled library versions. Pinned runners make CI deterministic — the same runner image runs in the same environment until the team consciously upgrades, making upgrades a planned event rather than an accidental one.
Steps
Example 13: The run Step with Multi-Line Commands
The run key executes shell commands. Multi-line commands use the YAML block scalar | to preserve newlines.
# .github/workflows/run-step.yml
name: Run Step Patterns
on:
push:
jobs:
run-demo:
runs-on: ubuntu-latest
steps:
# Pattern 1: single-line run
- name: Single command
run: echo "Single line command"
# => Executes: echo "Single line command"
# => Prints: Single line command
# Pattern 2: multi-line run using "|" (literal block scalar)
# Each line becomes a separate shell command in sequence.
- name: Multi-line commands
run: |
echo "Line 1"
echo "Line 2"
echo "Line 3"
# => Executes each echo in order, in the same shell session.
# => Prints:
# => Line 1
# => Line 2
# => Line 3
# Pattern 3: chaining commands — if one fails, the step fails
- name: Chained with exit code check
run: |
mkdir -p /tmp/ci-workspace
# => Creates directory (and parents) if it doesn't exist
echo "workspace ready" > /tmp/ci-workspace/flag.txt
# => Writes "workspace ready" into flag.txt
cat /tmp/ci-workspace/flag.txt
# => Prints: workspace readyKey takeaway: The | YAML block scalar preserves newlines, making each indented line a separate shell command in the same shell session — variable assignments in one line are visible in subsequent lines.
Why it matters: Multi-step shell logic is extremely common in CI — create a directory, build artifacts, move them, then verify. Using | keeps related commands in one named step with one log entry rather than scattered across many steps, improving log readability and grouping atomic operations.
Example 14: The uses Step with actions/checkout
uses invokes a reusable action by its repository reference. actions/checkout is the most used action — it clones the repository onto the runner so subsequent steps can access the code.
graph LR
A["Job starts on runner"]:::blue --> B["actions/checkout runs"]:::orange
B --> C["Repository cloned to GITHUB_WORKSPACE"]:::teal
C --> D["Subsequent steps access source code"]:::purple
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000,color:#fff,stroke-width:2px
# .github/workflows/checkout-action.yml
name: Checkout Demo
on:
push:
jobs:
use-code:
runs-on: ubuntu-latest
steps:
# Without this step, GITHUB_WORKSPACE is empty — no source code available.
# Always put checkout as the FIRST step in jobs that need source code.
- name: Checkout repository
# "uses" references a public action at owner/repo@version.
# Always pin to a specific version tag (v4), never use @main.
uses: actions/checkout@v4
# => Clones the repository at the triggering commit SHA.
# => Working directory: $GITHUB_WORKSPACE (e.g. /home/runner/work/repo/repo)
# => The default fetch depth is 1 (shallow clone) for speed.
- name: Verify checkout
run: |
ls -la
# => Lists files in the repository root — confirms code is present.
echo "Workspace: $GITHUB_WORKSPACE"
# => Prints: Workspace: /home/runner/work/<repo>/<repo>
- name: Read a file from the repo
run: cat README.md
# => Reads README.md from the checked-out repo.
# => This would fail completely if checkout step was missing.Key takeaway: actions/checkout@v4 clones the triggering commit into $GITHUB_WORKSPACE; it must precede any step that reads source files, and version pinning (@v4) is required for supply-chain security.
Why it matters: Forgetting actions/checkout is the single most common beginner mistake — the runner starts with an empty workspace and all file-reading steps silently fail. Understanding that the runner is a fresh VM with no code pre-loaded explains why checkout is always the first step in any workflow that builds, tests, or lints source code.
Example 15: The uses Step with actions/setup-node
actions/setup-node installs a specific Node.js version on the runner. Pinning the Node.js version prevents “works on my machine, fails in CI” problems caused by version differences.
# .github/workflows/setup-node.yml
name: Node.js Setup
on:
push:
jobs:
node-job:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
# => Repository cloned to workspace.
- name: Set up Node.js
uses: actions/setup-node@v4
# "with" passes named inputs to the action.
# Each action defines its own accepted inputs (documented in its README).
with:
# "node-version" specifies the exact Node.js version to install.
# Use the same version as your local .nvmrc or .node-version file.
node-version: "20"
# => Installs Node.js 20.x (latest patch of the 20 major).
# => Adds node and npm to PATH for all subsequent steps.
- name: Verify Node.js version
run: |
node --version
# => Prints: v20.x.x
npm --version
# => Prints: 10.x.x (npm version bundled with Node 20)
- name: Install dependencies
run: npm ci
# => Runs clean install from package-lock.json.
# => Faster and more deterministic than npm install in CI.
- name: Run app
run: node -e "console.log('Node.js is ready')"
# => Prints: Node.js is readyKey takeaway: actions/setup-node@v4 with node-version: "20" installs the specified Node.js major version and adds it to PATH, making node and npm available for all subsequent steps.
Why it matters: GitHub-hosted runners have a pre-installed Node.js, but its version changes with runner image updates. Explicit setup-node ties CI to the same Node.js version declared in your project’s tooling files, eliminating an entire class of “different behavior in CI vs local” bugs related to API changes between Node.js majors.
Example 16: The uses Step with actions/setup-python
actions/setup-python works identically to setup-node but for Python. The pattern generalizes: most language ecosystems have an official actions/setup-* action.
# .github/workflows/setup-python.yml
name: Python Setup
on:
push:
jobs:
python-job:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
# => Repository cloned to workspace.
- name: Set up Python
uses: actions/setup-python@v5
with:
# "python-version" pins the Python version.
# Accepts exact versions ("3.11.0"), minor ("3.11"), or range ("3.x").
python-version: "3.11"
# => Installs Python 3.11.x (latest patch).
# => Adds python, python3, and pip to PATH.
# => Also installs pip for package management.
- name: Verify Python installation
run: |
python --version
# => Prints: Python 3.11.x
pip --version
# => Prints: pip 23.x.x from /opt/hostedtoolcache/Python/3.11.x/...
- name: Install packages
run: pip install requests
# => Installs the requests library from PyPI.
# => Available to subsequent steps in this job.
- name: Run Python script
run: python -c "import requests; print('Python ready, requests version:', requests.__version__)"
# => Prints: Python ready, requests version: 2.x.xKey takeaway: actions/setup-python@v5 pins the Python interpreter version identically to how setup-node pins Node.js — the pattern of uses: actions/setup-* + with: { version: "x.y" } is universal across language setup actions.
Why it matters: Python 3.9 and 3.11 have meaningful behavioral differences in typing, match statements, and standard library APIs. Pinning the version in CI ensures the test suite runs against the same interpreter version as development, making CI failures actionable rather than artifacts of interpreter version drift.
The with Key (Action Inputs)
Example 17: Passing Inputs to Actions with with
with is the standard mechanism for passing configuration to any action. Understanding how inputs map to action behavior is essential for using any third-party action.
# .github/workflows/with-inputs.yml
name: Action Inputs Demo
on:
push:
jobs:
configure-actions:
runs-on: ubuntu-latest
steps:
- name: Checkout with full history
uses: actions/checkout@v4
with:
# By default, checkout does a shallow clone (depth: 1) for speed.
# Setting fetch-depth: 0 fetches ALL history — needed for git log, tags.
fetch-depth: 0
# => Fetches complete git history, not just the latest commit.
# => Required for tools that analyze commit history (e.g., semantic-release).
- name: Set up Node.js with caching
uses: actions/setup-node@v4
with:
node-version: "20"
# "cache" enables automatic caching of the package manager's cache directory.
# Subsequent runs restore from cache instead of downloading all packages.
cache: "npm"
# => Caches ~/.npm directory between runs keyed on package-lock.json hash.
# => Can save 30-120 seconds on each run for large dependency trees.
- name: Verify git history
run: |
git log --oneline | head -5
# => Prints: last 5 commit messages (possible only with full history checkout)Key takeaway: Every action defines its own with inputs in its action.yml; fetch-depth: 0 on actions/checkout and cache: "npm" on actions/setup-node are two of the most commonly needed non-default inputs.
Why it matters: Default action settings are optimized for the common case, but real projects often need non-default behavior. Deep clones are required for changelog generation and semantic versioning. Caching can cut CI duration significantly. Knowing that with is the universal input mechanism means you can configure any action without learning new syntax per action.
Environment Variables
Example 18: Setting env at the Workflow Level
Workflow-level env defines environment variables available to all jobs and steps in the workflow. It is the right place for constants that apply everywhere.
graph TD
A["Workflow-level env"]:::blue --> B["Job 1 inherits env"]:::teal
A --> C["Job 2 inherits env"]:::teal
B --> D["All steps in Job 1 see env"]:::purple
C --> E["All steps in Job 2 see env"]:::purple
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000,color:#fff,stroke-width:2px
# .github/workflows/env-workflow.yml
name: Workflow-Level Environment Variables
# Workflow-level "env" makes these variables available in ALL jobs and steps.
# Use this for constants that must be consistent across the entire workflow.
env:
# Convention: use SCREAMING_SNAKE_CASE for environment variable names.
APP_NAME: my-application
# => Available as $APP_NAME in every shell step of every job.
NODE_VERSION: "20"
# => Centralizing versions here means updating in one place updates everywhere.
LOG_LEVEL: info
# => Every step that reads LOG_LEVEL gets the same value.
on:
push:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Use workflow env vars
run: |
echo "App: $APP_NAME"
# => Prints: App: my-application
echo "Node version: $NODE_VERSION"
# => Prints: Node version: 20
echo "Log level: $LOG_LEVEL"
# => Prints: Log level: info
test:
runs-on: ubuntu-latest
steps:
- name: Same env vars in second job
run: echo "Testing $APP_NAME with log level $LOG_LEVEL"
# => Prints: Testing my-application with log level info
# => Workflow-level env is NOT re-declared here but is still available.Key takeaway: Workflow-level env propagates to all jobs and steps — centralizing constants like app names and version strings prevents inconsistencies caused by copy-paste across individual steps.
Why it matters: Constants scattered across many run steps are a maintenance hazard. When the app name changes or a version needs bumping, every occurrence must be found and updated. Workflow-level env creates a single source of truth at the top of the file, reducing human error and making diffs cleaner during reviews.
Example 19: Setting env at the Job Level
Job-level env overrides or supplements workflow-level variables for a specific job without affecting other jobs.
# .github/workflows/env-job.yml
name: Job-Level Environment Variables
on:
push:
# Workflow-level baseline — visible to all jobs
env:
ENVIRONMENT: development
# => Default value applied to all jobs unless overridden.
jobs:
dev-job:
name: Dev Build
runs-on: ubuntu-latest
# No job-level env — inherits workflow-level ENVIRONMENT=development
steps:
- name: Check environment
run: echo "Running in: $ENVIRONMENT"
# => Prints: Running in: development (from workflow-level env)
staging-job:
name: Staging Build
runs-on: ubuntu-latest
# Job-level "env" overrides workflow-level variables for THIS job only.
env:
ENVIRONMENT: staging
# => Overrides workflow-level ENVIRONMENT for all steps in this job.
STAGING_URL: "https://staging.example.com"
# => Additional variable available only in this job.
steps:
- name: Check environment
run: |
echo "Running in: $ENVIRONMENT"
# => Prints: Running in: staging (job-level overrides workflow-level)
echo "Staging URL: $STAGING_URL"
# => Prints: Staging URL: https://staging.example.comKey takeaway: Job-level env overrides workflow-level env for the same key within that job’s scope — the override does not affect sibling jobs, enabling per-environment configuration with shared defaults.
Why it matters: Multi-environment workflows (dev/staging/production) need different database URLs, API endpoints, and feature flags per job. Job-level env overrides provide this without duplicating the entire workflow or creating multiple files — critical for keeping environment-specific differences minimal and auditable.
Example 20: Setting env at the Step Level
Step-level env scopes variables to a single step, preventing accidental variable leakage between steps while enabling step-specific configuration.
# .github/workflows/env-step.yml
name: Step-Level Environment Variables
on:
push:
jobs:
demo:
runs-on: ubuntu-latest
steps:
- name: Step with its own env
# Step-level "env" is available ONLY within this step's shell session.
# It does NOT leak to subsequent steps.
env:
GREETING: "Hello from step env"
DEBUG_MODE: "true"
# => Both variables exist only during this step's execution.
run: |
echo "$GREETING"
# => Prints: Hello from step env
echo "Debug mode: $DEBUG_MODE"
# => Prints: Debug mode: true
- name: Verify no variable leakage
run: |
# GREETING was defined only in the previous step's env.
# It is NOT available here — the variable is empty/unset.
echo "GREETING is: '${GREETING:-unset}'"
# => Prints: GREETING is: 'unset'
# => Confirms step-level env does not persist across steps.
- name: Inject API key for one step only
env:
# Sensitive values (pulled from GitHub Secrets) should be
# scoped to the minimum necessary steps using step-level env.
API_TOKEN: ${{ secrets.MY_API_TOKEN }}
# => API_TOKEN is only set during this step — minimizes exposure window.
run: |
echo "Token length: ${#API_TOKEN}"
# => Prints: Token length: <N> (never prints the actual token value)Key takeaway: Step-level env is scoped to that step’s shell process and does not leak to later steps — ideal for secret injection with minimal exposure surface.
Why it matters: Leaking secrets between steps is a real attack surface: a later step could accidentally log, upload, or transmit a secret that was set by a previous step. Step-level env for secret injection follows the principle of least privilege — the token exists only when the step that genuinely needs it is running.
The working-directory Key
Example 21: Changing the Working Directory for run Steps
By default, run steps execute from $GITHUB_WORKSPACE (the repository root). working-directory redirects execution to a subdirectory without cd commands.
# .github/workflows/working-directory.yml
name: Working Directory Demo
on:
push:
jobs:
mono-repo-build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# => Full monorepo cloned to $GITHUB_WORKSPACE
- name: Prepare demo directories
# Creates a simulated monorepo structure for this example.
run: |
mkdir -p frontend backend
echo '{"name":"frontend"}' > frontend/package.json
echo "print('backend')" > backend/app.py
# => Creates frontend/ and backend/ subdirectories in workspace.
- name: Build frontend
# "working-directory" sets the shell's CWD for this step only.
# Equivalent to "cd frontend && ..." but cleaner and safer.
working-directory: frontend
run: |
pwd
# => Prints: /home/runner/work/<repo>/<repo>/frontend
ls
# => Prints: package.json
- name: Run backend
# Each step with working-directory resets independently.
# This step is in backend/, not frontend/ (no state leaks between steps).
working-directory: backend
run: |
pwd
# => Prints: /home/runner/work/<repo>/<repo>/backend
python3 app.py
# => Prints: backend
- name: Default directory after working-directory steps
run: |
pwd
# => Prints: /home/runner/work/<repo>/<repo> (back to workspace root)
# => working-directory does not permanently change the CWD.Key takeaway: working-directory changes the CWD for a single run step without affecting subsequent steps — each step always resets to $GITHUB_WORKSPACE as its default.
Why it matters: Monorepo projects with separate frontend, backend, and infrastructure subdirectories need each CI step to run in the right directory. Explicit working-directory keys make the directory context readable in the YAML rather than buried in cd commands inside long run blocks.
The if Conditional
Example 22: Skipping Steps with if Conditionals
if conditionals control whether a step executes at all. They evaluate GitHub Actions expressions and are the primary mechanism for branch-specific, event-specific, or failure-contingent steps.
graph TD
A["Step evaluates if condition"]:::blue --> B{"Condition true?"}:::orange
B -->|Yes| C["Step executes"]:::teal
B -->|No| D["Step skipped — shown as skipped in UI"]:::brown
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef brown fill:#CA9161,stroke:#000,color:#fff,stroke-width:2px
# .github/workflows/if-conditional.yml
name: Conditional Steps
on:
push:
jobs:
conditional-demo:
runs-on: ubuntu-latest
steps:
- name: Always runs
run: echo "This step always runs"
# => Prints: This step always runs
# => No "if" key means implicit "if: true" — always executes.
- name: Only on main branch
# github.ref_name is the short branch name (e.g. "main", "feature/x").
# This step ONLY executes when pushed directly to the main branch.
if: github.ref_name == 'main'
run: echo "Running deployment preparation on main"
# => Executes: only when branch is "main"
# => Skipped: on all other branches (step shown as "skipped" in UI)
- name: Only on push event (not PR)
# github.event_name holds the triggering event name.
if: github.event_name == 'push'
run: echo "This is a direct push, not a pull_request"
# => Executes: when workflow triggered by a push event
# => Skipped: if triggered by pull_request or workflow_dispatch
- name: Skip on forks
# github.repository_owner can detect if the action runs on the
# original repo vs. a fork. Forks should not have deploy access.
if: github.repository_owner == 'my-org'
run: echo "Running in the official organization repository"
# => Executes: only when the repo owner matches 'my-org'
# => Skipped: on forks (different owner)Key takeaway: if accepts GitHub Actions expressions without ${{ }} at the step level; use github.ref_name, github.event_name, and github.repository_owner for the most common conditional patterns.
Why it matters: Without if conditionals, every deployment step would run even on feature branches, and notifications would fire on every commit. Conditionals enforce CI stage separation: lint and test on every branch, deploy only from main, notify only on failure — all within a single workflow file.
Example 23: if on Jobs
if conditionals work at the job level too, preventing entire jobs from running based on context — more efficient than skipping individual steps.
# .github/workflows/if-job.yml
name: Job-Level Conditionals
on:
push:
jobs:
# This job runs on every push to every branch.
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- run: echo "Running tests..."
# => Runs on all branches unconditionally.
# This job ONLY runs when pushing to main.
# The entire job (including runner provisioning) is skipped on other branches.
deploy:
name: Deploy to Production
runs-on: ubuntu-latest
# Job-level "if" prevents the job from being queued at all when false.
# More efficient than step-level "if" — no runner is provisioned.
if: github.ref_name == 'main'
# => Job executes: only on pushes to the "main" branch.
# => Job skipped: on feature/*, hotfix/*, and all other branch names.
steps:
- run: echo "Deploying to production..."
# => Only reached when the job-level if is satisfied.
# This job only runs for pull requests.
pr-checks:
name: PR-Specific Checks
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
# => Runs: only when triggered by a pull_request event.
# => Skipped: on direct pushes (even to main).
steps:
- run: echo "Running PR-specific quality checks..."
# => Semantic versioning check, PR title lint, etc.Key takeaway: Job-level if prevents runner provisioning entirely for skipped jobs, which is more efficient than step-level skipping — no billed runner minutes for skipped jobs.
Why it matters: Provisioning a runner takes 20–40 seconds and costs runner minutes even if all steps are skipped. Job-level if keeps the bill down and keeps the workflow run graph clean: skipped jobs appear as dotted boxes in the visualization rather than executing and showing all green-but-skipped steps.
The needs Key (Job Dependencies)
Example 24: Sequential Jobs with needs
By default, all jobs in a workflow run in parallel. needs declares a dependency: the dependent job waits until all listed jobs complete successfully.
graph LR
A["test job"]:::blue -->|needs| C["deploy job"]:::teal
B["lint job"]:::orange -->|needs| C
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
# .github/workflows/needs-dependency.yml
name: Sequential Pipeline with Needs
on:
push:
jobs:
# Stage 1: These two jobs run IN PARALLEL — no "needs" between them.
lint:
name: Lint Code
runs-on: ubuntu-latest
steps:
- run: echo "Linting..."
# => Runs at the same time as the "test" job.
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- run: echo "Testing..."
# => Runs at the same time as the "lint" job.
# Stage 2: This job ONLY runs after BOTH lint and test succeed.
deploy:
name: Deploy
runs-on: ubuntu-latest
# "needs" accepts a single job ID or a list of job IDs.
# The deploy job waits for all listed jobs to finish with success.
needs: [lint, test]
# => deploy starts: only after lint AND test both pass.
# => deploy is skipped: if lint OR test fails (unless "if: always()" overrides).
steps:
- run: echo "Deploying — lint and tests passed!"
# => Guaranteed: lint succeeded AND test succeeded before this runs.Key takeaway: needs: [job-a, job-b] forces sequential execution — the dependent job waits until all listed jobs succeed; failure in any listed job skips the dependent job.
Why it matters: Deploying before tests pass is a critical production risk. needs encodes the pipeline contract in the YAML itself: tests must pass before deployment, and build must succeed before tests run. This replaces ad-hoc scripts that manually check exit codes, making the dependency graph visible and enforced by GitHub.
Example 25: Accessing Outputs from Required Jobs
needs enables dependent jobs to access outputs from their dependencies, passing data between jobs without external storage.
# .github/workflows/needs-outputs.yml
name: Job Outputs via Needs
on:
push:
jobs:
# Produces a version string as an output for downstream jobs.
version:
name: Calculate Version
runs-on: ubuntu-latest
# "outputs" declares variables this job will expose to dependent jobs.
outputs:
# Format: output-name: ${{ steps.step-id.outputs.output-name }}
app-version: ${{ steps.calc.outputs.version }}
# => app-version will be available in jobs that declare needs: [version]
steps:
- name: Calculate version
id: calc
# => "id" is required here so the step's outputs can be referenced above.
run: |
VERSION="1.0.${{ github.run_number }}"
# => Example: 1.0.42 (run_number increments with each workflow run)
# Use the special $GITHUB_OUTPUT file to set step outputs.
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# => Writes "version=1.0.42" to the output file.
# => This makes ${{ steps.calc.outputs.version }} equal to "1.0.42"
# Consumes the version output from the "version" job.
build:
name: Build with Version
runs-on: ubuntu-latest
needs: version
# => Waits for the version job to complete successfully.
steps:
- name: Use version from previous job
run: |
echo "Building version: ${{ needs.version.outputs.app-version }}"
# => Prints: Building version: 1.0.42
# => needs.<job-id>.outputs.<output-name> is the access pattern.Key takeaway: Job outputs defined via echo "key=value" >> "$GITHUB_OUTPUT" are accessible in dependent jobs via ${{ needs.<job-id>.outputs.<key> }} — enabling structured data passing between jobs without external storage.
Why it matters: Passing artifacts between jobs via outputs (version strings, file paths, computed values) is cleaner and more auditable than writing to temporary files on shared storage. It makes the data flow explicit in the YAML, helping reviewers understand what each job produces and what depends on it.
timeout-minutes and continue-on-error
Example 26: Setting timeout-minutes
timeout-minutes caps the maximum time a job can run. Without it, a hung test or infinite loop consumes runner minutes until GitHub’s default 6-hour limit.
# .github/workflows/timeout.yml
name: Workflow with Timeouts
on:
push:
jobs:
bounded-job:
name: Job with Timeout
runs-on: ubuntu-latest
# "timeout-minutes" cancels the ENTIRE JOB if it exceeds this duration.
# Default if omitted: 360 minutes (6 hours) — expensive for runaway processes.
timeout-minutes: 10
# => Job is forcibly cancelled after 10 minutes regardless of step status.
# => Runner time billing stops immediately on cancellation.
steps:
# Step-level timeout-minutes is also valid — limits a single step.
- name: Quick operation
timeout-minutes: 2
# => This step specifically is cancelled if it takes more than 2 minutes.
run: |
echo "Starting quick task..."
# => Prints: Starting quick task...
sleep 1
# => Sleeps 1 second — well within 2-minute timeout.
echo "Quick task done"
# => Prints: Quick task done
- name: Bounded integration test
timeout-minutes: 5
# => If the test suite takes more than 5 minutes, the step is cancelled.
# => The job still fails (step failure = job failure by default).
run: echo "Running integration tests (timeout: 5 min)"
# => Prints: Running integration tests (timeout: 5 min)Key takeaway: timeout-minutes at the job or step level caps execution time — without it, a hung process runs until GitHub’s 6-hour default, consuming the full cost of the runner.
Why it matters: Integration tests that wait for a service that never starts, or builds caught in an infinite dependency resolution loop, can silently consume hours of runner time before the team notices. A 10-minute job timeout provides a safety net that makes runaway CI both visible and cheap — the job fails fast and clearly.
Example 27: Using continue-on-error
continue-on-error: true marks a step or job as non-blocking: even if it fails, subsequent steps (or the job itself) continue. The overall job/workflow is still marked as failed, but execution is not halted.
# .github/workflows/continue-on-error.yml
name: Continue on Error Demo
on:
push:
jobs:
resilient-job:
runs-on: ubuntu-latest
steps:
- name: Critical step — must succeed
run: echo "This step must pass"
# => If this fails, the job stops immediately (default behavior).
- name: Optional linting check
# "continue-on-error: true" means:
# - If this step FAILS, the next step still runs.
# - The step is marked with an orange warning icon (not red failure).
# - The overall job still reports failure (step failure is recorded).
continue-on-error: true
run: |
echo "Running optional lint..."
# Simulate a lint warning/failure (exit 1).
exit 1
# => Step exits with code 1 (failure).
# => Normally this would stop the job — but continue-on-error overrides that.
- name: Step after the optional failure
run: echo "This step runs even though the lint step failed"
# => Prints: This step runs even though the lint step failed
# => Execution continued past the failing step due to continue-on-error.
- name: Required step — still runs
run: echo "Pipeline completes despite optional failure"
# => Final step executes normally.
# => The job will show as failed in the UI (because the lint step failed),
# => but all steps that could run did run.Key takeaway: continue-on-error: true allows a failing step to be non-blocking — subsequent steps still run, but the job is ultimately marked as failed in GitHub’s UI.
Why it matters: Some CI checks are informational rather than blocking — coverage trend checks, experimental linters, or canary feature tests. continue-on-error captures their results in the logs without blocking the deploy pipeline, preventing alert fatigue while still surfacing the signal.
Advanced Beginner Patterns
Example 28: Combining Triggers, Env, Needs, and Conditionals
A realistic beginner-level workflow combines multiple triggers, environment variables, job dependencies, and conditionals into a coherent pipeline pattern.
graph TD
A["push or pull_request event"]:::blue --> B["lint job"]:::orange
A --> C["test job"]:::orange
B --> D{"needs: lint, test"}:::teal
C --> D
D --> E["deploy job — if: main branch only"]:::purple
classDef blue fill:#0173B2,stroke:#000,color:#fff,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000,color:#fff,stroke-width:2px
classDef teal fill:#029E73,stroke:#000,color:#fff,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000,color:#fff,stroke-width:2px
# .github/workflows/realistic-pipeline.yml
# A realistic beginner pipeline combining the major concepts from this tutorial.
name: Application CI/CD Pipeline
on:
# Trigger 1: Run on direct pushes to main and release branches.
push:
branches:
- main
- "release/*"
# Trigger 2: Run as a PR check on all pull requests targeting main.
pull_request:
branches:
- main
# Workflow-level constants — single source of truth for the entire pipeline.
env:
NODE_VERSION: "20"
APP_NAME: my-app
jobs:
# Job 1: Code quality check — always runs.
lint:
name: Lint Code
runs-on: ubuntu-latest
timeout-minutes: 5
# => Cancelled after 5 minutes — lint should never take that long.
steps:
- name: Checkout repository
uses: actions/checkout@v4
# => Clones code so linter has files to analyze.
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
# => Uses NODE_VERSION from workflow-level env (avoids hardcoding "20" twice).
cache: "npm"
# => Caches node_modules between runs for speed.
- name: Install dependencies
run: npm ci
# => Installs from package-lock.json — deterministic, no version drift.
- name: Run linter
run: npm run lint
# => Runs ESLint (or whatever lint script is configured in package.json).
# Job 2: Test suite — always runs, in parallel with lint.
test:
name: Run Tests
runs-on: ubuntu-latest
timeout-minutes: 15
# => Test suite gets 15 minutes; longer timeout than lint.
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run test suite
run: npm test
env:
# Step-level env for the test command — test-specific configuration.
NODE_ENV: test
# => Tells the app it is running in test mode (mock databases, etc.).
# Job 3: Deploy — ONLY on main branch, ONLY after lint AND test pass.
deploy:
name: Deploy Application
runs-on: ubuntu-latest
# Sequential dependency: both lint and test must succeed before deploy.
needs: [lint, test]
# Conditional: deploy only when pushing to main, not on PRs.
if: github.ref_name == 'main' && github.event_name == 'push'
# => This job: runs only on direct pushes to main after CI passes.
# => PRs targeting main run lint and test but never reach deploy.
steps:
- uses: actions/checkout@v4
- name: Deploy application
env:
# Secret injected at the step level — minimizes exposure window.
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
# => DEPLOY_TOKEN is only available during this one step.
run: |
echo "Deploying $APP_NAME to production..."
# => APP_NAME comes from workflow-level env.
echo "Deploy triggered by: $GITHUB_ACTOR"
# => GITHUB_ACTOR: the GitHub user who pushed to main.
# Actual deploy command would use $DEPLOY_TOKEN here.
echo "Deployment complete"
# => In real usage: call your deploy script, CLI, or API here.Key takeaway: A production-ready pipeline combines multiple triggers with branch filters, shared env constants, parallel jobs, needs dependencies, and if conditionals to encode the full CI/CD policy in a single readable YAML file.
Why it matters: This pattern — parallel lint/test feeding a conditional deploy — is the backbone of thousands of production pipelines. Mastering these eight concepts (triggers, runners, steps, actions, env, working-directory, if, needs) covers the vast majority of real-world GitHub Actions usage. Every additional feature is an extension of this foundation.