Basics Lesson 3 of 14

Workflow YAML Anatomy

Line-by-line breakdown of every key, keyword, and pattern in a GitHub Actions workflow file.

🧒 Simple Explanation (ELI5)

Think of a GitHub Actions workflow file as a recipe card pinned to the kitchen wall.

Every recipe card starts with a title — that's the name: at the top. Then you write "When to cook""Make this when guests arrive" or "Make this every Sunday morning." That's the on: section. Guests arriving = a push event. Every Sunday morning = a schedule.

Below that, you list the dishes you need to prepare — those are jobs. Maybe you're making a salad and a main course. Each dish is prepared on its own counter (the runner). The salad and the main course can be made at the same time by different chefs (parallel jobs). But dessert can't start until the main course is done — that's a needs: dependency.

Inside each dish, you have step-by-step cooking instructions — those are steps. "Chop the onions" (run: chop onions). "Use the pre-made sauce from the store" (uses: store/pasta-sauce@v2). Some instructions have conditions: "If the guest is vegetarian, skip the meat step" (if: guest == 'vegetarian').

Indentation matters. Ingredients are nested under each dish. Steps are nested under each job. If you indent something wrong, the recipe makes no sense — the oven doesn't know if "salt" belongs to the salad or the dessert. YAML indentation works the same way: it defines which block owns what.

🔧 Technical — Keyword-by-Keyword Breakdown

Every GitHub Actions workflow is a YAML file in .github/workflows/. Here's every top-level and nested keyword you'll encounter, in the order they typically appear:

Top-Level Keys

KeywordRequiredPurpose
name:NoHuman-readable name displayed in the Actions tab. If omitted, GitHub uses the filename.
on:YesDefines which events trigger the workflow — push, pull_request, schedule, workflow_dispatch, etc.
permissions:NoScopes the GITHUB_TOKEN permissions for the entire workflow. Follows least-privilege principle.
env:NoWorkflow-level environment variables available to every job and step.
defaults:NoDefault settings for all run: steps — typically the shell and working directory.
jobs:YesA map of all job definitions. At least one job is required.

on: — Trigger Configuration

The on: key accepts events with optional filters. Common sub-keys:

Sub-KeyPurposeExample
on.push.branchesOnly trigger on pushes to specific branchesbranches: [main, develop]
on.push.pathsOnly trigger when specific files changepaths: ['src/**', 'Dockerfile']
on.pull_request.typesFilter which PR activities trigger the workflowtypes: [opened, synchronize]
on.scheduleCron-based trigger- cron: '0 2 * * *'
on.workflow_dispatchManual trigger with optional inputsinputs: environment: …

permissions: — GITHUB_TOKEN Scoping

By default, GITHUB_TOKEN gets broad permissions. Best practice is to restrict it:

yaml
permissions:
  contents: read      # Can read repo contents (checkout)
  packages: write     # Can push to GitHub Container Registry
  pull-requests: none # No PR access needed

You can set permissions at the workflow level (applies to all jobs) or at the job level (overrides the workflow level for that job).

env: — Environment Variables

Environment variables can be set at three levels, each scoping to its context:

yaml
# Workflow-level — available in ALL jobs and steps
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    # Job-level — available in all steps of THIS job only
    env:
      NODE_ENV: production
    steps:
      - name: Deploy
        # Step-level — available only in THIS step
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: echo "Deploying with $DEPLOY_TOKEN"

defaults: — Default Shell and Working Directory

Sets default options for all run: steps so you don't repeat yourself:

yaml
defaults:
  run:
    shell: bash
    working-directory: ./src

jobs: — Job Definitions

The jobs: map contains one or more job definitions. Each job is identified by a unique key (the job ID):

Sub-KeyPurpose
jobs.<id>.runs-onRunner selection — ubuntu-latest, windows-latest, macos-latest, or a self-hosted label.
jobs.<id>.needsJob dependencies — this job waits for the listed jobs to complete before starting.
jobs.<id>.ifConditional execution — the job runs only when the expression evaluates to true.
jobs.<id>.strategy.matrixMatrix builds — run the same job across multiple configurations (OS, language version, etc.).
jobs.<id>.envJob-level environment variables — available to all steps in this job.
jobs.<id>.stepsAn ordered array of steps — the actual tasks the job performs.

steps: — Step Configuration

Each step is an item in the steps: array. A step uses either uses: (pre-built action) or run: (shell command), never both:

Sub-KeyPurpose
steps[*].usesReference a pre-built action — format: owner/repo@ref (e.g., actions/checkout@v4).
steps[*].withInput parameters passed to the action referenced by uses:.
steps[*].runInline shell command executed on the runner.
steps[*].nameHuman-readable step label displayed in the Actions UI logs.
steps[*].idUnique string ID for the step — used to reference its outputs in later steps.
steps[*].ifConditional execution — the step runs only when the expression evaluates to true.
steps[*].envStep-level environment variables — available only within this step.
steps[*].continue-on-errorWhen true, the job continues even if this step fails. Useful for non-critical steps like notifications.

💻 Annotated Full Workflow

Here's a production-grade workflow with every single line annotated. Read the comments carefully — this is the most important section of this lesson:

yaml
# ─── WORKFLOW NAME ───────────────────────────────────────────
# name: Human-readable label that appears in the Actions tab.
#   This is what you and your team see when browsing workflow runs.
name: Build and Deploy

# ─── TRIGGERS ────────────────────────────────────────────────
# on: Defines WHEN this workflow runs. Without this, nothing triggers.
on:

  # push: Triggers when commits are pushed to a matching branch.
  push:
    # branches: Only trigger on pushes to 'main' or 'develop'.
    #   Pushes to feature branches are ignored — they use pull_request instead.
    branches: [main, develop]
    # paths: Only trigger when files in these directories change.
    #   If you only change README.md, this workflow won't run — saving minutes.
    paths:
      - 'src/**'
      - 'Dockerfile'

  # pull_request: Triggers when a PR targeting a matching branch is opened or updated.
  pull_request:
    # types: Only trigger on these specific PR activities.
    #   'opened' — PR is created.  'synchronize' — new commits pushed to PR branch.
    #   Without this filter, events like 'labeled' or 'closed' would also trigger the workflow.
    types: [opened, synchronize]

  # workflow_dispatch: Adds a "Run workflow" button in the Actions UI.
  #   Allows manual triggering with custom inputs — perfect for controlled deployments.
  workflow_dispatch:
    inputs:
      environment:
        # description: Help text shown next to the input field in the UI.
        description: 'Target environment'
        # required: User must provide this value before the workflow can start.
        required: true
        # default: Pre-selected value when the dropdown loads.
        default: 'staging'
        # type: Input type — 'choice' renders a dropdown, 'string' renders a text field.
        type: choice
        # options: The allowed values for the dropdown.
        options: [staging, production]

# ─── PERMISSIONS ─────────────────────────────────────────────
# permissions: Restricts what the GITHUB_TOKEN can do.
#   This follows the principle of least privilege — only grant what's needed.
#   Without this block, the token gets broad default permissions.
permissions:
  contents: read     # Can clone the repo (needed by actions/checkout)
  packages: write    # Can push Docker images to GitHub Container Registry (ghcr.io)

# ─── WORKFLOW-LEVEL ENVIRONMENT VARIABLES ────────────────────
# env: Variables available to EVERY job and step in this workflow.
#   Use this for values shared across multiple jobs — like registry URLs.
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
  #   ${{ github.repository }} resolves to 'owner/repo' (e.g., 'octocat/my-app').
  #   The ${{ }} syntax is a GitHub Actions expression — evaluated at runtime.

# ─── JOBS ────────────────────────────────────────────────────
# jobs: The meat of the workflow. Each key below is a separate job.
#   Jobs run in parallel by default unless 'needs:' creates dependencies.
jobs:

  # ─── JOB 1: LINT ──────────────────────────────────────────
  # 'lint' is the job ID — unique within this workflow.
  #   Used by other jobs to reference it (e.g., needs: lint).
  lint:
    # runs-on: The runner (VM) that executes this job.
    #   'ubuntu-latest' = a fresh Ubuntu VM managed by GitHub.
    runs-on: ubuntu-latest

    # steps: Ordered list of tasks. Runs top-to-bottom, stops on first failure.
    steps:
      # Step 1: Clone the repository onto the runner.
      #   uses: references a pre-built action. actions/checkout@v4 clones your repo.
      #   Without this, the runner has an empty filesystem — no source code.
      - uses: actions/checkout@v4

      # Step 2: Run the linter.
      #   run: executes a shell command directly. 'npm run lint' runs your linting script.
      - run: npm run lint

  # ─── JOB 2: TEST ──────────────────────────────────────────
  test:
    runs-on: ubuntu-latest

    # needs: This job waits for 'lint' to succeed before starting.
    #   If lint fails, test is skipped. This creates a sequential dependency:
    #   lint → test (instead of both running in parallel).
    needs: lint

    # strategy.matrix: Run this SAME job multiple times with different configurations.
    #   Here we test against Node.js 18, 20, and 22 — three parallel job instances.
    strategy:
      matrix:
        node-version: [18, 20, 22]

    steps:
      - uses: actions/checkout@v4

      # Setup Node.js with the version from the current matrix combination.
      #   ${{ matrix.node-version }} resolves to 18, 20, or 22 depending on the instance.
      - uses: actions/setup-node@v4
        # with: Passes input parameters to the action.
        #   'node-version' is an input defined by actions/setup-node.
        with:
          node-version: ${{ matrix.node-version }}

      # Install dependencies using the lockfile (npm ci is stricter than npm install).
      - run: npm ci

      # Run the test suite. If any test fails, this step fails → job fails → workflow fails.
      - run: npm test

  # ─── JOB 3: BUILD ─────────────────────────────────────────
  build:
    runs-on: ubuntu-latest

    # needs: Waits for the 'test' job (all matrix instances) to pass.
    needs: test

    # if: Conditional — this job ONLY runs when the push is to the 'main' branch.
    #   On pull_request events or pushes to 'develop', this entire job is skipped.
    #   github.ref contains the full ref: 'refs/heads/main', 'refs/heads/develop', etc.
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v4

      # Log in to GitHub Container Registry using the GITHUB_TOKEN.
      #   docker/login-action@v3 handles the docker login command for you.
      - uses: docker/login-action@v3
        with:
          # registry: Which registry to authenticate with.
          registry: ${{ env.REGISTRY }}
          # username: The GitHub user who triggered the workflow.
          username: ${{ github.actor }}
          # password: The auto-generated GITHUB_TOKEN — no need to create a secret.
          #   secrets.GITHUB_TOKEN is provided automatically by GitHub.
          password: ${{ secrets.GITHUB_TOKEN }}

      # Build the Docker image and push it to the registry in a single step.
      #   docker/build-push-action@v5 handles docker build + docker push.
      - uses: docker/build-push-action@v5
        with:
          # push: true means the image is pushed to the registry after building.
          push: true
          # tags: The full image name + tag. Uses the commit SHA for traceability.
          #   Result: ghcr.io/octocat/my-app:abc123def456
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
💡
Key Takeaway

This single file orchestrates linting, testing across three Node.js versions, and building + pushing a Docker image — all triggered automatically. The needs: keyword ensures the pipeline flows lint → test → build, and the if: condition ensures Docker images are only built from the main branch.

⚠️ YAML Gotchas — Common Mistakes and Fixes

YAML is deceptively simple. These are the most common mistakes that break GitHub Actions workflows:

MistakeWhat HappensFix
Tabs instead of spaces YAML does not allow tabs for indentation. The workflow file fails to parse with "Invalid workflow file" error. Configure your editor to insert spaces (2 spaces per indent is standard). In VS Code: "editor.insertSpaces": true.
Inconsistent indentation Mixing 2-space and 4-space indentation causes keys to be parsed at the wrong nesting level. A step might be parsed as a job-level key, or ignored entirely. Pick one indent size (2 spaces recommended) and use it consistently. Use a YAML linter to catch drift.
Unquoted on: In some YAML parsers, on is interpreted as the boolean true (YAML spec says on, yes, true are all truthy). GitHub Actions handles this correctly, but other tools may not. For maximum portability, quote it: "on":. In GitHub Actions, bare on: works fine, but be aware of this gotcha when using YAML in other contexts.
Missing ${{ }} around expressions Writing if: github.ref == 'refs/heads/main' works (GitHub auto-wraps if: values). But in run: or with: blocks, github.ref without ${{ }} is treated as a literal string, not an expression. Always use ${{ }} in run:, with:, and env: blocks. In if: blocks, the wrapping is optional but recommended for clarity.
Branch filter syntax Writing branches: main (a string) instead of branches: [main] (an array) may cause unexpected behavior with some YAML parsers. Both work in GitHub Actions, but the array form is safer. Always use the array syntax: branches: [main, develop]. This is explicit, unambiguous, and consistent.
Colon in run: values Writing run: echo "key: value" can confuse YAML parsers because key: value looks like a nested mapping. The step may fail to parse. Use the block scalar form for multi-line or complex commands: run: | followed by the command on the next line, indented.
Wrong path to workflow file Placing the file in .github/workflow/ (missing "s") or github/workflows/ (missing dot) means GitHub never discovers it. No error is shown — the workflow simply doesn't exist. The path must be exactly .github/workflows/filename.yml. The directory is case-sensitive on Linux runners.
Duplicate keys Defining the same key twice (e.g., two env: blocks at the same level) causes YAML to silently use the last one. Your first set of variables vanishes. Each YAML key must appear only once at its nesting level. Merge multiple env vars into a single env: block.
⚠️
Pro Tip

Install the YAML extension in VS Code (redhat.vscode-yaml) and the GitHub Actions extension (github.vscode-github-actions). Together they provide real-time YAML validation, auto-completion for GitHub Actions keywords, and inline documentation — catching most of these gotchas as you type.

🔄 Bad → Good — YAML Refactoring Examples

Real-world workflows often start messy and get cleaned up. Here are five common patterns showing how a beginner workflow evolves into a production-grade one.

Refactor 1: Hardcoded Values → Environment Variables

Bad — Hardcoded everywhere
yaml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t myacr.azurecr.io/webapp:latest .
      - run: docker push myacr.azurecr.io/webapp:latest
  deploy:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - run: helm upgrade --install webapp ./charts/webapp --set image.repository=myacr.azurecr.io/webapp --set image.tag=latest
Good — DRY with env variables and SHA tagging
yaml
env:
  REGISTRY: ${{ vars.ACR_NAME }}.azurecr.io
  IMAGE: webapp
  TAG: ${{ github.sha }}

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ env.TAG }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
  deploy:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/checkout@v4
      - run: |
          helm upgrade --install ${{ env.IMAGE }} ./charts/${{ env.IMAGE }} \
            --set image.repository=${{ env.REGISTRY }}/${{ env.IMAGE }} \
            --set image.tag=${{ env.TAG }} \
            --atomic --wait --timeout 5m

What improved: Single source of truth for registry/image names, immutable SHA tag instead of :latest, Docker layer caching, Helm --atomic for safe rollbacks.

Refactor 2: Monolithic Workflow → Fail-Fast Pipeline

Bad — Everything in one job
yaml
jobs:
  everything:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: docker build -t myapp .
      - run: docker push myapp
      - run: helm upgrade --install myapp ./charts
Good — Separated jobs with dependencies
yaml
jobs:
  lint:                        # Fast feedback — fails in seconds
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint

  test:
    needs: lint                # Only run if lint passes
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test -- --coverage

  build-and-push:
    needs: test
    if: github.ref == 'refs/heads/main'  # Only build on main
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    environment: production    # Manual approval gate
    steps:
      - uses: actions/checkout@v4
      - run: helm upgrade --install myapp ./charts --atomic --wait

What improved: Lint failures stop the pipeline in seconds (not after a 10-min build). Docker build only runs on main. Production deploy requires manual approval. Each stage has clear responsibility.

Refactor 3: No Permissions → Least Privilege

Bad — Default broad permissions
yaml
name: Deploy
on: push
# No permissions block — GITHUB_TOKEN gets full read/write access
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh
Good — Explicit least-privilege permissions
yaml
name: Deploy
on:
  push:
    branches: [main]

permissions:
  contents: read       # Only checkout — can't push code or delete branches
  id-token: write      # OIDC token for Azure login
  packages: write      # Push Docker images to GHCR

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - run: ./deploy.sh

What improved: Explicit permissions limit blast radius if a step is compromised. OIDC replaces stored secrets. Branch filter prevents accidental non-main deploys. Environment gate adds approval.

Refactor 4: Repeated Steps → Reusable Workflow

Bad — Copy-pasted across 10 repos
yaml
# 10 different repos, each with identical 80-line workflow
# Bug fix = editing 10 files across 10 repos
Good — Central reusable workflow
yaml
# .github/workflows/ci.yml in each repo — 8 lines
name: CI
on: [push, pull_request]
jobs:
  ci:
    uses: my-org/.github/.github/workflows/standard-ci.yml@v2
    with:
      node-version: '20'
    secrets: inherit

What improved: Single source of truth. Bug fix in one file propagates to all repos. Version pinning (@v2) lets teams upgrade on their schedule. secrets: inherit passes all secrets without listing each one.

Refactor 5: Fragile run: Commands → Proper Actions

Bad — Brittle shell commands
yaml
      - run: |
          curl -fsSL https://get.docker.com | sh
          echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u user --password-stdin
          docker build -t myapp:${{ github.sha }} .
          docker push myapp:${{ github.sha }}
Good — Purpose-built actions
yaml
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

What improved: No piping curl output to shell (security risk). No manual Docker install (pre-installed). Automatic Buildx setup with caching. GITHUB_TOKEN instead of stored Docker password. Actions handle edge cases that raw shell misses.

📊 Workflow YAML Hierarchy

YAML Structure — From Workflow to Step
Workflow File (.yml)
name
on (triggers)
permissions
env (workflow-level)
defaults
jobs (map)
job-id-1
job-id-2
job-id-3
Per Job
runs-on
needs
if
strategy.matrix
env (job-level)
Per Step
uses / run
with
name, id
if
env (step-level)
continue-on-error
Trigger Flow — How on: Filters Work
Git Event (push)
Branch Filter (main?)
Path Filter (src/**?)
Workflow Runs
Environment Variable Scope
Workflow env:
→ available to →
Job env:
→ available to →
Step env:

⌨️ Hands-on Exercises

Build these workflows to practice every concept covered in this lesson. Each exercise focuses on a specific YAML feature.

Exercise 1: Workflow with workflow_dispatch Inputs

Create a workflow that can be triggered manually with a custom input for the log message:

yaml
name: Manual Greeting

on:
  workflow_dispatch:
    inputs:
      greeting:
        description: 'What greeting to display'
        required: true
        default: 'Hello from SKILLY!'
        type: string
      environment:
        description: 'Target environment'
        required: true
        default: 'dev'
        type: choice
        options: [dev, staging, production]

jobs:
  greet:
    runs-on: ubuntu-latest
    steps:
      - name: Display the greeting
        run: |
          echo "Greeting: ${{ github.event.inputs.greeting }}"
          echo "Environment: ${{ github.event.inputs.environment }}"
          echo "Triggered by: ${{ github.actor }}"
          echo "Run ID: ${{ github.run_id }}"

What to verify: Push this file, then go to Actions → "Manual Greeting" → "Run workflow". You should see the input fields. Fill them in, run it, and check the step output matches your inputs.

Exercise 2: Path Filters — Only Trigger on src/ Changes

Create a workflow that only runs CI when source code changes — not when docs or config files are updated:

yaml
name: Smart CI

on:
  push:
    branches: [main]
    paths:
      - 'src/**'
      - 'package.json'
      - 'package-lock.json'
    # paths-ignore could also be used:
    # paths-ignore: ['docs/**', '*.md', '.gitignore']

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Show changed files
        run: |
          echo "This workflow only runs when src/ or package files change."
          echo "Changes to README.md, docs/, or config files are ignored."
          git diff --name-only HEAD~1 || echo "First commit"

What to verify: Push a change to README.md — the workflow should not trigger. Then push a change to any file inside src/ — it should trigger.

Exercise 3: Matrix Strategy with Two Node.js Versions

Create a workflow that tests your code against Node.js 18 and 20 in parallel:

yaml
name: Matrix Test

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20]
      # fail-fast: false  # Uncomment to run all matrix combinations even if one fails
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - name: Show Node version
        run: |
          echo "Testing with Node.js $(node --version)"
          echo "Matrix value: ${{ matrix.node-version }}"

What to verify: After pushing, the Actions tab should show two job instances running in parallel — one for Node 18, one for Node 20. Each should display a different Node.js version in the logs.

Exercise 4: Conditional Step — Only Runs on main

Add a deploy step that only executes when code is pushed directly to main:

yaml
name: Conditional Deploy

on:
  push:
    branches: [main, develop]

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

      - name: Build
        run: echo "Building the application..."

      - name: Run tests
        run: echo "All tests passed ✅"

      # This step ONLY runs on the main branch.
      # On 'develop', it's skipped — you'll see a grey "skipped" icon.
      - name: Deploy to production
        if: github.ref == 'refs/heads/main'
        run: |
          echo "🚀 Deploying to production!"
          echo "Branch: ${{ github.ref }}"
          echo "SHA: ${{ github.sha }}"

      # This step runs on every branch EXCEPT main.
      - name: Deploy to staging
        if: github.ref != 'refs/heads/main'
        run: |
          echo "📦 Deploying to staging environment"
          echo "Branch: ${{ github.ref }}"

What to verify: Push to main — "Deploy to production" runs, "Deploy to staging" is skipped. Push to develop — the opposite happens.

🐛 Debugging Common Issues

Scenario: "Invalid workflow file"

GitHub shows this error in the Actions tab or when trying to run the workflow. The workflow file has a YAML syntax error and cannot be parsed at all.

Scenario: "Expression syntax error"

A step or job fails with an error about a malformed expression. The ${{ }} syntax is wrong.

Scenario: "Workflow not found"

You pushed the YAML file but nothing shows up in the Actions tab. The workflow doesn't exist as far as GitHub is concerned.

💡
Debug Logging

Add the repository secret ACTIONS_STEP_DEBUG with value true (Settings → Secrets → Actions). This enables verbose debug output for all workflow runs, showing internal action logic, expression evaluations, and environment details. Remove the secret when done — debug logs are noisy and can leak sensitive information in the output.

🎯 Interview Questions

Beginner

Q: What does the on: key do in a GitHub Actions workflow?

The on: key defines which events trigger the workflow. Without it, the workflow never runs. It accepts one or more event types — push, pull_request, schedule, workflow_dispatch, release, and many more. Each event can have sub-filters that narrow when the trigger fires: branches limits which branches activate it, paths limits which file changes activate it, and types limits which sub-events (e.g., PR opened vs. closed) activate it. You can combine multiple events: on: [push, pull_request] means the workflow triggers on both.

Q: What is the difference between uses: and run: in a workflow step?

uses: executes a pre-built action — reusable code packaged by someone else (or yourself) and referenced by owner/repo@version. Example: uses: actions/checkout@v4 clones your repository. run: executes a shell command directly on the runner — your own custom code. Example: run: npm test. A step can have either uses: or run:, but never both. Think of uses: as calling a library function and run: as writing inline code. Most workflows combine both: uses: for setup (checkout, install tools) and run: for project-specific commands (build, test, deploy).

Q: What does needs: do in a job definition?

The needs: key creates a dependency between jobs. By default, all jobs in a workflow run in parallel on separate runners. When you add needs: lint to a job, that job waits for the lint job to complete successfully before starting. If the dependency fails, the dependent job is skipped. You can specify multiple dependencies: needs: [build, test] waits for both jobs. This lets you create sequential pipelines: lint → test → build → deploy, while still allowing independent jobs (like lint and security-scan) to run in parallel.

Q: Where must a workflow YAML file be placed for GitHub to discover it?

Workflow files must be in the .github/workflows/ directory at the root of your repository. The file must have a .yml or .yaml extension. The filename can be anything — ci.yml, deploy.yaml, my-workflow.yml. GitHub automatically discovers and registers all valid YAML files in this directory. If the file is in the wrong location (missing dot, missing "s", wrong extension), GitHub silently ignores it — no error is shown, the workflow simply doesn't exist.

Q: What is ${{ github.sha }} and why is it commonly used in Docker image tags?

${{ github.sha }} is a GitHub context expression that resolves to the full 40-character commit SHA that triggered the workflow (e.g., a1b2c3d4e5f6...). It's used in Docker image tags because it provides traceability: you can look at any running container, see its image tag, and instantly know which exact commit produced it. Unlike version numbers (which can be reused) or latest (which is always overwritten), a commit SHA is unique and immutable. Example: ghcr.io/my-org/my-app:a1b2c3d4 tells you exactly which code is in that image.

Intermediate

Q: How do path filters work in the on: block, and when would you use them?

Path filters (paths: and paths-ignore:) restrict a workflow to only trigger when specific files are included in the push or PR. paths: ['src/**', 'Dockerfile'] means the workflow only runs if at least one changed file matches those patterns. paths-ignore: ['docs/**', '*.md'] is the inverse — run unless only the ignored files changed. You cannot use both in the same event. Use case: Monorepos — a frontend team's CI only runs when frontend/ changes, not when backend code is modified. This saves CI minutes and reduces noise. Path filters also combine with branch filters: the push must match both the branch and path filter to trigger.

Q: Explain the permissions: block and why it matters for security.

The permissions: block scopes the access level of the automatically-generated GITHUB_TOKEN. By default, this token has broad read/write permissions to the repository. The permissions: block lets you restrict it following the principle of least privilege — only grant what the workflow actually needs. For example, a CI workflow that only runs tests needs contents: read (to checkout code) and nothing else. A workflow that pushes Docker images needs packages: write. Without this block, a compromised step or action could use the default token to push code, delete branches, or modify releases. Setting permissions: at the workflow level applies to all jobs; setting it at the job level overrides for that specific job. Organizations can also set a default permission policy that applies to all repositories.

Q: What is continue-on-error and when should you use it?

continue-on-error: true on a step tells GitHub Actions to mark the step as successful even if it fails, allowing subsequent steps (and the overall job) to continue. Without it, a failing step causes all remaining steps to be skipped and the job to fail. Use cases: (1) Non-critical steps like sending a Slack notification — you don't want a notification failure to block your deployment. (2) Experimental or flaky tests you want to monitor without breaking CI. (3) Steps that produce optional reports. Caution: Don't overuse it — masking real failures leads to silent bugs. If you need a job to run after a failure (e.g., cleanup), use if: always() instead, which still reports the real pass/fail status.

Q: What is a matrix strategy and how does it reduce workflow duplication?

A strategy.matrix definition creates multiple instances of the same job, each with different variable values. Instead of writing three separate test jobs for Node 18, 20, and 22, you define one job with matrix: node-version: [18, 20, 22] and GitHub spawns three parallel runners automatically. You can combine multiple dimensions: os: [ubuntu-latest, windows-latest] with node-version: [18, 20] produces 4 combinations (2×2). Use exclude: to skip specific combos and include: to add extra ones. The fail-fast option (default: true) cancels remaining matrix jobs when one fails — set it to false if you want all combinations to complete regardless.

Q: How do workflow_dispatch inputs work and what types are available?

workflow_dispatch inputs are parameters that users fill in when manually triggering a workflow from the Actions UI or API. Each input has a description, required flag, and default value. Available types: string (free text), choice (dropdown with predefined options), boolean (checkbox), and environment (selects from configured GitHub Environments). Inputs are accessed in the workflow via ${{ github.event.inputs.input_name }} (or ${{ inputs.input_name }} in reusable workflows). Common use: a deploy workflow with a choice input for the target environment (staging/production) and a boolean input for "skip tests". This gives teams controlled, auditable manual triggers with parameterized execution.

Scenario-Based

Q: You're given this broken YAML. Find and fix the errors:
name: CI
on: push
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo "hello"

There are two errors: (1) Indentation of branches:: When on: push is on the same line, YAML treats push as a scalar value (not a mapping), so branches: on the next line becomes an orphaned key. Fix: put push: on its own line under on:. (2) Indentation of steps:: steps: must be nested inside the test: job (indented under it), not at the same level. Currently, steps: is a sibling of test:, not a child. Fixed YAML: on:\n push:\n branches: [main]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo "hello". The takeaway: indentation defines hierarchy in YAML. Every sub-key must be indented deeper than its parent.

Q: Design a workflow that builds a Docker image on merge to main, runs containers tests, and deploys to staging automatically — but only deploys to production with a manual approval. How would you structure the YAML?

Use a single workflow file with four jobs chained by needs:: (1) buildon: push: branches: [main]. Checks out code, builds Docker image, pushes to registry with github.sha tag. (2) testneeds: build. Pulls the image and runs container-level integration tests. (3) deploy-stagingneeds: test. Deploys to staging via Helm or Kubernetes apply. No condition — runs automatically after tests pass. (4) deploy-productionneeds: deploy-staging. Uses environment: production with required reviewers configured in GitHub settings. This creates an approval gate: the job pauses until a designated reviewer clicks "Approve" in the Actions UI. The reviewer sees the staging deployment results before deciding. The key mechanism is GitHub Environments with protection rules — not the YAML itself, but the environment configuration in the repository settings.

Q: Your workflow uses on: push: paths: ['src/**'] but developers report that changes to src/utils/helper.ts sometimes don't trigger the workflow. What could be wrong?

Several possible causes: (1) Branch filter mismatch: If the workflow also has branches: [main] and the developer pushed to a feature branch, the branch filter blocks it — path filters and branch filters are AND-ed, not OR-ed. (2) Empty commits or merge commits: If the push is a merge commit that GitHub determines has no "new" file changes relative to the base, path filters may not match. (3) Workflow file doesn't exist on the branch: For push events, the workflow YAML must exist on the branch being pushed to. If the file was only added on main and the developer is on a feature branch without it, nothing triggers. (4) Rate limiting or outage: GitHub Actions occasionally has delays or outages — check githubstatus.com. Debug: Add a second workflow with on: push (no filters) that logs ${{ toJSON(github.event) }} to confirm the push event is reaching GitHub and see which files GitHub detected as changed.

Q: A teammate writes if: github.ref == 'main' in a job condition and the job is always skipped. What's the issue?

The value of github.ref is the full ref path, not just the branch name. On a push to main, github.ref is refs/heads/main, not main. The condition should be: if: github.ref == 'refs/heads/main'. Alternatively, you can use endsWith(github.ref, '/main') or github.ref_name == 'main' (ref_name is the short branch name without the refs/heads/ prefix). This is one of the most common gotchas in GitHub Actions — the full ref format trips up many developers, especially those coming from other CI systems where the branch name is a simple string.

Q: You need a single workflow that runs linting on all branches, full tests on PRs to main, and Docker build only on merge to main. How would you structure the on: and if: blocks?

Use a multi-event trigger with job-level conditions: on:\n push:\n branches: ['**'] # All branches\n pull_request:\n branches: [main]. Then three jobs: (1) lint — no if: condition, runs on every push and PR. (2) testif: github.event_name == 'pull_request' or include needs: lint. This ensures tests only run on PRs targeting main, not on every push to feature branches. (3) build-dockerif: github.event_name == 'push' && github.ref == 'refs/heads/main'. This runs only when code is merged (pushed) to main, not on PRs or feature branches. The key insight: combining events in on: with conditions in if: gives you fine-grained control over which jobs run for which scenarios — all in a single workflow file.

🌍 Real-World Use Case

A startup with a JavaScript monorepo (frontend React app + backend Node.js API + shared library) was wasting 40+ CI minutes per push because every change triggered the full test suite for all three projects.

The Problem

Their original workflow had a single on: push trigger with no filters. Every commit — even a typo fix in the frontend README — triggered backend integration tests, frontend E2E tests, and shared library unit tests. With 15 developers pushing multiple times per day, they were burning through GitHub Actions minutes and developers waited 12+ minutes for CI on every PR, even for trivial changes.

The Solution: Path Filtering

They split their single workflow into three, each scoped with path filters:

yaml
# .github/workflows/ci-frontend.yml
name: Frontend CI
on:
  push:
    paths: ['frontend/**', 'shared/**']
  pull_request:
    paths: ['frontend/**', 'shared/**']

# .github/workflows/ci-backend.yml
name: Backend CI
on:
  push:
    paths: ['backend/**', 'shared/**']
  pull_request:
    paths: ['backend/**', 'shared/**']

# .github/workflows/ci-shared.yml
name: Shared Library CI
on:
  push:
    paths: ['shared/**']
  pull_request:
    paths: ['shared/**']

The Result

MetricBefore (1 workflow, no filters)After (3 workflows, path filters)
CI minutes per day~480 minutes (all tests, every push)~120 minutes (only relevant tests)
Average PR wait time12–15 minutes3–5 minutes (only affected tests run)
Developer frustration"I changed one line in the frontend and waited 15 minutes for backend tests to pass""CI for my frontend change passed in 3 minutes"
Monthly cost savingsApproaching the 3,000 paid minutes thresholdWell within the 2,000 free minutes

Key insight: Both frontend and backend workflows include shared/** in their path filters. This ensures that changes to the shared library trigger tests in both consuming projects — catching cross-project regressions without running the full monorepo suite on every commit.

📝 Summary

← Back to GitHub Actions Course