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
| Keyword | Required | Purpose |
|---|---|---|
name: | No | Human-readable name displayed in the Actions tab. If omitted, GitHub uses the filename. |
on: | Yes | Defines which events trigger the workflow — push, pull_request, schedule, workflow_dispatch, etc. |
permissions: | No | Scopes the GITHUB_TOKEN permissions for the entire workflow. Follows least-privilege principle. |
env: | No | Workflow-level environment variables available to every job and step. |
defaults: | No | Default settings for all run: steps — typically the shell and working directory. |
jobs: | Yes | A 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-Key | Purpose | Example |
|---|---|---|
on.push.branches | Only trigger on pushes to specific branches | branches: [main, develop] |
on.push.paths | Only trigger when specific files change | paths: ['src/**', 'Dockerfile'] |
on.pull_request.types | Filter which PR activities trigger the workflow | types: [opened, synchronize] |
on.schedule | Cron-based trigger | - cron: '0 2 * * *' |
on.workflow_dispatch | Manual trigger with optional inputs | inputs: environment: … |
permissions: — GITHUB_TOKEN Scoping
By default, GITHUB_TOKEN gets broad permissions. Best practice is to restrict it:
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:
# 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:
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-Key | Purpose |
|---|---|
jobs.<id>.runs-on | Runner selection — ubuntu-latest, windows-latest, macos-latest, or a self-hosted label. |
jobs.<id>.needs | Job dependencies — this job waits for the listed jobs to complete before starting. |
jobs.<id>.if | Conditional execution — the job runs only when the expression evaluates to true. |
jobs.<id>.strategy.matrix | Matrix builds — run the same job across multiple configurations (OS, language version, etc.). |
jobs.<id>.env | Job-level environment variables — available to all steps in this job. |
jobs.<id>.steps | An 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-Key | Purpose |
|---|---|
steps[*].uses | Reference a pre-built action — format: owner/repo@ref (e.g., actions/checkout@v4). |
steps[*].with | Input parameters passed to the action referenced by uses:. |
steps[*].run | Inline shell command executed on the runner. |
steps[*].name | Human-readable step label displayed in the Actions UI logs. |
steps[*].id | Unique string ID for the step — used to reference its outputs in later steps. |
steps[*].if | Conditional execution — the step runs only when the expression evaluates to true. |
steps[*].env | Step-level environment variables — available only within this step. |
steps[*].continue-on-error | When 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:
# ─── 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 }}
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:
| Mistake | What Happens | Fix |
|---|---|---|
| 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. |
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
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
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
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
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
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
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
# 10 different repos, each with identical 80-line workflow # Bug fix = editing 10 files across 10 repos
# .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
- 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 }}
- 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
on: Filters Workenv:env: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:
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:
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:
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:
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.
- Tabs: YAML requires spaces for indentation. A single tab character anywhere in the file causes this error. Search for
\t(tab characters) in your editor and replace with spaces. - Indentation: A key at the wrong indentation level breaks the YAML structure. Common: a
run:line that isn't nested insidesteps:, orwith:at the same level asuses:instead of one level deeper. - Special characters: Unquoted colons, brackets, or braces in values. Example:
run: echo "key: value"can be misinterpreted. Userun: |(block scalar) for complex commands. - Fix strategy: Paste your YAML into an online YAML validator (e.g., yamllint.com). The error will point to the exact line and column. Also try the GitHub Actions workflow editor — it validates in real time.
Scenario: "Expression syntax error"
A step or job fails with an error about a malformed expression. The ${{ }} syntax is wrong.
- Missing
${{ }}: Inrun:orwith:blocks, expressions must be wrapped:${{ github.sha }}. Writinggithub.shawithout the wrapper treats it as a literal string. - Unbalanced braces:
${{ github.sha }(missing closing brace) or${ github.sha }}(missing opening brace). - Nested quotes:
${{ 'it's broken' }}— the single quote inside the string terminates it early. Escape or use double quotes:${{ "it's fine" }}. - Fix strategy: Check the exact error message in the workflow run logs — it usually includes the expression that failed and the position of the syntax error.
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.
- Wrong directory: The file must be in
.github/workflows/. Not.github/workflow/(missing "s"), notgithub/workflows/(missing leading dot), not the repository root. - Wrong file extension: The file must end in
.ymlor.yaml. A.txt,.json, or no extension won't be discovered. - Wrong branch: For
pushevents, the workflow file must exist on the branch being pushed to. If you added the file onmainbut pushed tofeature/xyzwhere the file doesn't exist, it won't trigger. - Actions disabled: Check Settings → Actions → General. If Actions is disabled for the repository or restricted to specific users, workflows won't run.
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
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.
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).
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.
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.
${{ 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
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.
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.
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.
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.
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
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.
Use a single workflow file with four jobs chained by needs:: (1) build — on: push: branches: [main]. Checks out code, builds Docker image, pushes to registry with github.sha tag. (2) test — needs: build. Pulls the image and runs container-level integration tests. (3) deploy-staging — needs: test. Deploys to staging via Helm or Kubernetes apply. No condition — runs automatically after tests pass. (4) deploy-production — needs: 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.
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.
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.
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) test — if: 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-docker — if: 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:
# .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
| Metric | Before (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 time | 12–15 minutes | 3–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 savings | Approaching the 3,000 paid minutes threshold | Well 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
name:gives the workflow a human-readable label in the Actions UIon:defines triggers —push,pull_request,schedule,workflow_dispatch, with filters for branches, paths, and typespermissions:scopes theGITHUB_TOKENto least privilege — only grant what the workflow needsenv:sets environment variables at three levels: workflow, job, and step — each scoping to its contextdefaults:sets the default shell and working directory for allrun:stepsjobs:defines parallel (default) or sequential (needs:) units of work, each on its own runnerstrategy.matrix:runs the same job across multiple configurations (OS, language version) in parallelsteps:are sequential tasks within a job —uses:for actions,run:for commandsif:on jobs or steps enables conditional execution based on branch, event type, or any expressioncontinue-on-error:lets non-critical steps fail without breaking the entire job- YAML requires spaces (not tabs), consistent indentation, and
${{ }}wrapping for expressions inrun:/with:blocks