DevOpsGitHub

GitHub Actions: Mastering YAML Syntax

TT
TopicTrick Team
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

yaml
# .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 test

Every workflow must have on: (trigger) and jobs: (work to do). Everything else is optional.


Triggers: The on: Block

Push and pull_request

yaml
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_review

The paths filter is powerful: a monorepo can use it to run only the relevant workflows when specific packages change.

Schedule (cron jobs)

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

yaml
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: string

Access inputs in the workflow:

yaml
- run: echo "Deploying to ${{ inputs.environment }}"
- if: inputs.debug_mode
  run: echo "Debug mode enabled"

workflow_run (trigger after another workflow)

yaml
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:

yaml
jobs:
  deploy:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    steps:
      - run: echo "CI passed — deploying"

Runners

yaml
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 labels

GitHub-hosted runners are fresh VMs for every job. They include common tools (Node.js, Python, Java, Docker) pre-installed.


Jobs: Dependencies and Outputs

yaml
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

yaml
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

yaml
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 ${{ }}:

yaml
# 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-step

Conditional expressions

yaml
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.sh

Environment Variables

yaml
# 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:

yaml
# 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 events

Concurrency: Preventing Duplicate Runs

yaml
# Cancel any in-progress run for the same branch when a new one starts
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Without 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:

yaml
concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false  # Let the current deployment complete

Matrix Builds

yaml
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 coverage

Reusable Workflow Caller

yaml
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:

yaml
# 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:

yaml
- 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-package

Q: 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.