GitHub Actions: Mastering YAML Syntax

GitHub Actions: Mastering YAML Syntax and Triggers
A GitHub Actions workflow is a YAML file in .github/workflows/. Every aspect of what runs, when it runs, and how jobs relate to each other is controlled by the YAML structure. Understanding the syntax deeply — not just copying examples — is what separates engineers who can debug failing pipelines from those who cannot.
This guide covers the complete workflow structure, every major trigger type with practical examples, job configuration including runners and permissions, step types (run vs uses), expressions and context objects, environment variables and secrets, and the concurrency and conditional execution patterns used in production pipelines.
Workflow File Structure
# .github/workflows/ci.yml
name: CI Pipeline # Display name in the GitHub Actions UI
on: # Trigger(s) — when does this workflow run?
push:
branches: [main]
env: # Workflow-level environment variables
NODE_VERSION: '20'
jobs: # One or more jobs to run
test: # Job ID (used in 'needs' references)
name: Run Tests # Display name in the UI
runs-on: ubuntu-latest # Runner operating system
steps: # Sequential steps within the job
- name: Checkout code
uses: actions/checkout@v4
- name: Run tests
run: npm testEvery workflow must have on: (trigger) and jobs: (work to do). Everything else is optional.
Triggers: The on: Block
Push and pull_request
on:
push:
branches:
- main
- 'release/**' # Glob pattern: matches release/1.0, release/2.0, etc.
tags:
- 'v*' # Any tag starting with v
paths:
- 'src/**' # Only trigger if files in src/ changed
- '!src/**/*.md' # Except markdown files
pull_request:
branches: [main]
types:
- opened
- synchronize # New commits pushed to the PR branch
- reopened
- ready_for_reviewThe paths filter is powerful: a monorepo can use it to run only the relevant workflows when specific packages change.
Schedule (cron jobs)
on:
schedule:
- cron: '0 2 * * 1' # Every Monday at 2:00 AM UTC
- cron: '0 6 * * *' # Every day at 6:00 AM UTC
# Cron syntax: minute hour day-of-month month day-of-week
# '0 2 * * 1' = minute 0, hour 2, any day, any month, Monday (1)Scheduled workflows only run on the default branch. If the repository has no activity for 60 days, GitHub disables the schedule.
workflow_dispatch (manual trigger)
on:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
type: choice
options: [staging, production]
default: staging
debug_mode:
description: 'Enable debug logging'
type: boolean
default: false
image_tag:
description: 'Docker image tag to deploy'
required: true
type: stringAccess inputs in the workflow:
- run: echo "Deploying to ${{ inputs.environment }}"
- if: inputs.debug_mode
run: echo "Debug mode enabled"workflow_run (trigger after another workflow)
on:
workflow_run:
workflows: ["CI Pipeline"] # Must match the name: of the other workflow exactly
types: [completed]
branches: [main]This workflow runs after "CI Pipeline" completes on main. Check the status:
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- run: echo "CI passed — deploying"Runners
jobs:
test:
runs-on: ubuntu-latest # Latest Ubuntu (changes over time)
test-specific:
runs-on: ubuntu-22.04 # Pinned to specific OS version (more stable)
windows-test:
runs-on: windows-latest # Windows Server
macos-test:
runs-on: macos-14 # macOS Sonoma
self-hosted:
runs-on: [self-hosted, linux, x64, gpu] # Your own machine with labelsGitHub-hosted runners are fresh VMs for every job. They include common tools (Node.js, Python, Java, Docker) pre-installed.
Jobs: Dependencies and Outputs
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
build:
needs: [lint, test] # Both must succeed before build starts
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.build.outputs.tag }} # Expose for downstream jobs
steps:
- uses: actions/checkout@v4
- name: Build
id: build # Step ID required to reference outputs
run: |
TAG="${{ github.sha }}"
docker build -t my-app:$TAG .
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploying image tag ${{ needs.build.outputs.image-tag }}"Conditionally running jobs
jobs:
deploy-staging:
if: github.ref == 'refs/heads/main' # Only on main branch
runs-on: ubuntu-latest
steps:
- run: echo "Deploying to staging"
deploy-production:
if: startsWith(github.ref, 'refs/tags/v') # Only on version tags
runs-on: ubuntu-latest
steps:
- run: echo "Deploying to production"Steps: run vs uses
steps:
# run: executes shell commands
- name: Install dependencies
run: npm ci
# Multi-line run
- name: Build and test
run: |
npm run build
npm test
echo "Build complete: $(date)"
# Run with specific shell
- name: PowerShell step
shell: pwsh
run: Get-ChildItem
# uses: calls a pre-built action
- uses: actions/checkout@v4 # Official action (pinned to v4)
- uses: actions/setup-node@v4 # Setup Node.js
with:
node-version: '20'
cache: 'npm'
- uses: actions/upload-artifact@v4 # Upload files
with:
name: test-results
path: ./test-results/Always pin actions to a specific version (@v4, not @latest) to prevent unexpected breaking changes.
Expressions and Contexts
Expressions evaluate to a value. They appear inside ${{ }}:
# GitHub context — metadata about the workflow run
${{ github.sha }} # Full commit SHA
${{ github.ref }} # Branch or tag: refs/heads/main
${{ github.actor }} # Username who triggered the workflow
${{ github.event_name }} # Event that triggered: push, pull_request, etc.
${{ github.repository }} # owner/repo
# Secrets and variables
${{ secrets.MY_SECRET }} # Secret value (never logged)
${{ vars.MY_VAR }} # Non-secret variable (visible in logs)
# Runner context
${{ runner.os }} # Linux, Windows, macOS
${{ runner.arch }} # X64, ARM64
# Job context
${{ job.status }} # success, failure, cancelled
# Steps context
${{ steps.my-step.outputs.result }} # Output from step with id: my-stepConditional expressions
steps:
- name: Deploy to prod
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: ./deploy.sh
- name: Notify on failure
if: failure() # Only runs if a previous step failed
run: ./notify-slack.sh
- name: Always clean up
if: always() # Runs regardless of previous step results
run: ./cleanup.sh
- name: Skip on draft PR
if: github.event.pull_request.draft == false
run: ./expensive-test.shEnvironment Variables
# Workflow-level env (available to all jobs)
env:
NODE_ENV: production
APP_VERSION: '2.4.1'
jobs:
deploy:
# Job-level env (available to all steps in this job)
env:
DEPLOY_TARGET: staging
steps:
- name: Deploy
# Step-level env (available only to this step)
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
run: ./deploy.sh
- name: Computed env vars
run: |
# Write to GITHUB_ENV to set env vars for subsequent steps
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
echo "GIT_SHORT_SHA=${GITHUB_SHA:0:7}" >> "$GITHUB_ENV"
- name: Use computed vars
run: echo "Built at $BUILD_TIME, SHA $GIT_SHORT_SHA"Permissions
By default, GitHub Actions has broad repository permissions. Explicitly restrict to least-privilege:
# Restrict all jobs in the workflow
permissions:
contents: read # Read repository code only
jobs:
deploy:
# Override for specific jobs that need more
permissions:
contents: read
id-token: write # Required for OIDC authentication
deployments: write # Required to create deployment eventsConcurrency: Preventing Duplicate Runs
# Cancel any in-progress run for the same branch when a new one starts
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: trueWithout concurrency control, pushing 3 commits quickly starts 3 parallel workflow runs on the same branch — all fighting for the same resources. With it, the second push cancels the first run and the third push cancels the second.
For deployment workflows, use cancel-in-progress: false to prevent a deployment from being cancelled mid-way:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false # Let the current deployment completeMatrix Builds
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: ['18', '20', '22']
# Produces 6 parallel jobs: 2 OS × 3 Node versions
# Exclude specific combinations
exclude:
- os: windows-latest
node: '18' # Skip Node 18 on Windows
# Include additional combinations not in the matrix
include:
- os: ubuntu-latest
node: '20'
coverage: true # Extra variable for this specific combination
fail-fast: false # Don't cancel other matrix jobs if one fails
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm test
- if: matrix.coverage
run: npm run coverageReusable Workflow Caller
jobs:
deploy-staging:
uses: my-org/.github/.github/workflows/deploy.yml@main
with:
environment: staging
image_tag: ${{ needs.build.outputs.tag }}
secrets:
AWS_ROLE_ARN: ${{ secrets.STAGING_ROLE_ARN }}
DATABASE_URL: ${{ secrets.STAGING_DB_URL }}Frequently Asked Questions
Q: Why does my workflow not trigger even though I pushed to main?
Check: (1) the workflow file is in .github/workflows/ with a .yml extension; (2) the on: trigger matches your event — push to branches: [main] requires a push to the main branch specifically; (3) the YAML has no syntax errors — use the Actions tab to see if GitHub parsed the file. If the file has errors, no run will appear. The paths: filter silently skips workflows if no matching files changed.
Q: How do I pass data between jobs?
Use job outputs. The producing job declares outputs: and a step writes to $GITHUB_OUTPUT. The consuming job references needs.job-id.outputs.output-name:
# Producer
produce:
outputs:
result: ${{ steps.compute.outputs.value }}
steps:
- id: compute
run: echo "value=hello" >> "$GITHUB_OUTPUT"
# Consumer
consume:
needs: produce
steps:
- run: echo "${{ needs.produce.outputs.result }}"Q: Can I run a step only on certain operating systems in a matrix?
Yes, using if with the matrix context:
- name: Linux-only setup
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get install -y some-package
- name: Windows-only setup
if: runner.os == 'Windows'
shell: pwsh
run: choco install some-packageQ: What is the difference between env and vars contexts?
secrets (${{ secrets.NAME }}) are encrypted and never appear in logs — they are redacted. vars (${{ vars.NAME }}) are non-secret configuration values that are visible in logs. Use secrets for passwords, tokens, and keys. Use vars for non-sensitive configuration like region names, cluster names, or feature flag values that do not need encryption.
Key Takeaway
GitHub Actions YAML has a clear hierarchy: on: defines when, jobs: defines what, steps: defines how. Expressions (${{ }}) inject dynamic values from contexts, secrets, variables, and step outputs. Conditionals (if:) enable selective execution based on branch, event type, or previous step results. Concurrency controls prevent duplicate runs and race conditions in deployments. Matrix builds test across multiple OS and runtime combinations with minimal duplication. Understanding these mechanisms deeply lets you build workflows that are maintainable, debuggable, and efficient — not just workflows that happen to work.
Read next: Continuous Integration: Building a Bulletproof Pipeline →
Part of the GitHub Mastery Course — engineering the automation.
