Post

YAML & GitHub Actions Walkthrough

Full YAML syntax guide and GitHub Actions walkthrough — triggers, jobs, steps, secrets, matrix builds, reusable workflows, and real-world CI/CD pipelines

YAML & GitHub Actions Walkthrough

YAML & GitHub Actions Walkthrough


Part 1 — YAML

YAML (YAML Ain’t Markup Language) is a human-readable data serialisation format. It’s the config language for GitHub Actions, Kubernetes, Docker Compose, Ansible, and more. Getting it wrong silently breaks things, so understanding it properly matters.


Basic Rules

  • Indentation is structure — use spaces only, never tabs
  • 2 spaces per indent level is the convention (4 also works, just be consistent)
  • Case sensitiveName and name are different keys
  • # starts a comment
  • A YAML file can contain multiple documents separated by ---

Scalars — Strings, Numbers, Booleans, Null

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Strings — quotes are optional unless the value contains special characters
name: Mohamed
greeting: "Hello, World!"
path: 'C:\Users\user'          # single quotes: no escape sequences
multiword: this is fine too    # no quotes needed for plain text

# Avoid ambiguity — quote these
version: "1.0"                 # unquoted 1.0 is a float
yes_string: "yes"              # unquoted yes/no/true/false = boolean
port: "8080"                   # unquoted integers stay integers — pick a side

# Numbers
count: 42
price: 3.14
hex: 0xFF
scientific: 1.2e10

# Booleans
debug: true
enabled: false
# These also parse as booleans in YAML 1.1 (avoid using them as strings):
# yes, no, on, off, True, False, TRUE, FALSE

# Null
value: null
empty:              # also null
explicit: ~         # also null

Strings — Multiline

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Literal block scalar |  — preserves newlines
script: |
  #!/bin/bash
  echo "Hello"
  echo "World"
# Result: "#!/bin/bash\necho \"Hello\"\necho \"World\"\n"

# Folded block scalar >  — newlines become spaces (good for long sentences)
description: >
  This is a very long description
  that spans multiple lines but
  will be joined into one paragraph.
# Result: "This is a very long description that spans multiple lines but will be joined into one paragraph.\n"

# Chomp modifiers
literal_strip: |-        # | strip trailing newline
  line one
  line two

literal_keep: |+         # | keep all trailing newlines
  line one
  line two


# Inline — escape sequences work in double-quoted strings
escaped: "first line\nsecond line\ttabbed"

Collections — Lists and Maps

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# List (sequence) — block style
fruits:
  - apple
  - banana
  - cherry

# List — flow style (inline)
fruits: [apple, banana, cherry]

# Map (mapping) — block style
person:
  name: Mohamed
  age: 22
  city: Dubai

# Map — flow style (inline)
person: {name: Mohamed, age: 22}

# List of maps
users:
  - name: Alice
    role: admin
  - name: Bob
    role: developer

# Nested maps
database:
  primary:
    host: db-primary.internal
    port: 5432
  replica:
    host: db-replica.internal
    port: 5432

# Mixed nesting
config:
  servers:
    - name: web-1
      ip: 10.0.0.1
      tags: [web, nginx]
    - name: db-1
      ip: 10.0.0.2
      tags: [db, postgres]

Anchors and Aliases — DRY in YAML

Anchors (&) define a reusable block. Aliases (*) reference it. << merges a map.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Define anchor
defaults: &defaults
  timeout: 30
  retries: 3
  log_level: info

# Merge anchor into another map
development:
  <<: *defaults
  debug: true

production:
  <<: *defaults
  log_level: warn

# development becomes:
# timeout: 30
# retries: 3
# log_level: info
# debug: true

GitHub Actions uses anchors extensively to avoid repeating env: blocks.


Common Gotchas

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Colon in a value — must quote
url: "https://example.com"     # unquoted colon breaks parsing
message: "Error: not found"    # same issue

# Value starting with { or [ — must quote
json: '{"key": "value"}'
list: '[1, 2, 3]'

# Indentation after a block scalar
key: |
  line one
next_key: value    # this must be at the same indent as 'key'

# Empty string vs null
empty_string: ""   # empty string
null_value:        # null (no value at all)

# Octal integers in YAML 1.1
mode: 0755         # parsed as octal 493 in old parsers — quote it: "0755"

Part 2 — GitHub Actions

GitHub Actions is a CI/CD platform built into GitHub. Workflows are YAML files stored in .github/workflows/.


Anatomy of a Workflow

1
2
3
4
5
.github/
└── workflows/
    ├── ci.yml          # runs on every push/PR
    ├── deploy.yml      # runs on merge to main
    └── nightly.yml     # runs on a schedule

Top-level structure:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
name: CI Pipeline          # shown in GitHub UI
run-name: "Build ${{ github.sha }}"   # optional — shown per-run

on: ...                    # when to trigger

env:                       # workflow-level env vars
  NODE_VERSION: "20"

jobs:
  job-name:                # one or more jobs
    runs-on: ubuntu-latest
    steps:
      - name: Step name
        run: echo "hello"

Triggers (on:)

Push and pull_request

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
on:
  push:
    branches:
      - main
      - "release/**"      # glob patterns
    paths:
      - "src/**"          # only trigger if these paths changed
      - "!docs/**"        # ! = exclude
    tags:
      - "v*"              # trigger on version tags

  pull_request:
    branches:
      - main
    types:                # default: opened, synchronize, reopened
      - opened
      - synchronize
      - reopened
      - ready_for_review

Manual trigger

1
2
3
4
5
6
7
8
9
10
11
12
13
on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"
        type: choice
        options: [staging, production]
      dry_run:
        description: "Dry run?"
        type: boolean
        default: false

Run it from: GitHub repo → Actions → select workflow → Run workflow

Scheduled trigger

1
2
3
4
on:
  schedule:
    - cron: "0 2 * * *"       # daily at 2 AM UTC
    - cron: "0 9 * * MON"     # every Monday at 9 AM

Other triggers

1
2
3
4
5
6
7
8
9
10
11
on:
  release:
    types: [published]        # when a GitHub release is published

  issues:
    types: [opened, labeled]  # on issue events

  workflow_call:              # called from another workflow (reusable)

  repository_dispatch:        # external HTTP trigger via API
    types: [deploy]

Jobs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
jobs:
  build:
    name: Build and Test         # display name
    runs-on: ubuntu-latest       # runner

    # Run only on main branch
    if: github.ref == 'refs/heads/main'

    # Timeout (default 360 min)
    timeout-minutes: 30

    # Environment (for secrets and protection rules)
    environment: production

    # Outputs — pass data to dependent jobs
    outputs:
      version: ${{ steps.get-version.outputs.version }}

    steps:
      - ...

Runner options

1
2
3
4
5
6
runs-on: ubuntu-latest      # Linux (most common)
runs-on: ubuntu-22.04       # specific version
runs-on: windows-latest     # Windows
runs-on: macos-latest       # macOS
runs-on: self-hosted        # your own runner
runs-on: [self-hosted, linux, gpu]   # labeled self-hosted

Job dependencies

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo "running tests"

  build:
    needs: test                  # waits for test to pass
    runs-on: ubuntu-latest
    steps:
      - run: echo "building"

  deploy:
    needs: [test, build]         # waits for both
    runs-on: ubuntu-latest
    steps:
      - run: echo "deploying"

Steps

Each step is either a shell command (run) or an action (uses).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
steps:
  # Shell command
  - name: Run tests
    run: npm test

  # Multiline shell command
  - name: Build and lint
    run: |
      npm ci
      npm run lint
      npm run build

  # Use an action from the marketplace
  - name: Checkout code
    uses: actions/checkout@v4

  # Action with inputs
  - name: Setup Node.js
    uses: actions/setup-node@v4
    with:
      node-version: "20"
      cache: "npm"

  # Step-level env vars
  - name: Deploy
    run: ./deploy.sh
    env:
      API_KEY: ${{ secrets.API_KEY }}
      ENV: production

  # Conditional step
  - name: Notify on failure
    if: failure()
    run: curl -X POST ${{ secrets.SLACK_WEBHOOK }} -d '{"text":"Build failed"}'

  # Continue even if step fails
  - name: Run optional check
    run: ./optional-check.sh
    continue-on-error: true

  # Set output for use in later steps
  - name: Get version
    id: get-version
    run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT

  - name: Use version
    run: echo "Deploying version ${{ steps.get-version.outputs.version }}"

Contexts and Expressions

Syntax

1
${{ expression }}

Key contexts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# github context — event info
${{ github.sha }}               # commit SHA
${{ github.ref }}               # refs/heads/main
${{ github.ref_name }}          # main
${{ github.event_name }}        # push, pull_request, etc.
${{ github.actor }}             # username who triggered
${{ github.repository }}        # owner/repo
${{ github.run_id }}            # unique run ID
${{ github.run_number }}        # incrementing run number

# env context
${{ env.NODE_VERSION }}

# secrets context
${{ secrets.MY_SECRET }}

# steps context — outputs from earlier steps
${{ steps.step-id.outputs.value }}
${{ steps.step-id.outcome }}    # success, failure, skipped

# jobs context (in later jobs)
${{ needs.build.outputs.version }}
${{ needs.build.result }}       # success, failure, skipped, cancelled

# runner context
${{ runner.os }}                # Linux, Windows, macOS

Expressions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Conditionals
if: github.ref == 'refs/heads/main'
if: github.event_name == 'pull_request'
if: contains(github.ref, 'release')
if: startsWith(github.ref, 'refs/tags/v')
if: failure()                   # step/job failed
if: always()                    # run regardless of outcome
if: cancelled()
if: success()                   # default

# Functions
contains(github.ref, 'main')
startsWith(github.ref, 'refs/tags')
endsWith(github.actor, 'bot')
format('Hello {0}', github.actor)
join(matrix.os, ',')
toJSON(github.event)
fromJSON('{"key":"value"}')

Secrets and Variables

Setting secrets

Go to: Repo → Settings → Secrets and variables → Actions → New repository secret

Levels:

  • Repository secrets — available to that repo only
  • Environment secrets — only available when job targets that environment
  • Organisation secrets — shared across repos

Using secrets

1
2
3
4
5
6
7
steps:
  - name: Deploy
    env:
      DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    run: ./deploy.sh

Secrets are masked in logs — GitHub replaces the value with ***.

Variables (non-secret config)

1
2
3
# Repo → Settings → Secrets and variables → Variables
${{ vars.APP_ENV }}
${{ vars.REGISTRY_URL }}

Matrix Builds

Run the same job across multiple configurations in parallel.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: ["18", "20", "22"]
      fail-fast: false       # don't cancel all if one fails
      max-parallel: 4        # max concurrent jobs

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm test

This creates 3 × 3 = 9 jobs running in parallel.

Include and exclude

1
2
3
4
5
6
7
8
9
10
11
strategy:
  matrix:
    os: [ubuntu-latest, windows-latest]
    node: ["18", "20"]
    include:
      - os: ubuntu-latest
        node: "22"         # add an extra combination
        experimental: true
    exclude:
      - os: windows-latest
        node: "18"         # skip this specific combination

Caching

Speed up workflows by caching dependencies between runs.

1
2
3
4
5
6
7
8
9
10
- name: Cache node_modules
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

- name: Install dependencies
  run: npm ci

The cache key includes a hash of package-lock.json — a new key is created when dependencies change.

setup-* actions often have built-in caching:

1
2
3
4
- uses: actions/setup-node@v4
  with:
    node-version: "20"
    cache: "npm"           # handles caching automatically

Artifacts

Artifacts persist files between jobs or let you download build outputs.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
          retention-days: 7

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/
      - run: ./deploy.sh dist/

Environments and Deployment Protection

Environments add protection rules (required reviewers, wait timers) before deployment.

1
2
3
4
5
6
7
8
jobs:
  deploy-prod:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.com       # shown as link in GitHub UI
    steps:
      - run: ./deploy.sh

Go to Repo → Settings → Environments to configure:

  • Required reviewers (must approve before job runs)
  • Wait timer
  • Deployment branch rules (only deploy from main)

Reusable Workflows

Call one workflow from another — like a function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# .github/workflows/deploy.yml — the reusable workflow
on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
    secrets:
      deploy_key:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh ${{ inputs.environment }}
        env:
          DEPLOY_KEY: ${{ secrets.deploy_key }}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# .github/workflows/ci.yml — calls the reusable workflow
jobs:
  deploy-staging:
    uses: ./.github/workflows/deploy.yml
    with:
      environment: staging
    secrets:
      deploy_key: ${{ secrets.STAGING_KEY }}

  deploy-prod:
    needs: deploy-staging
    uses: ./.github/workflows/deploy.yml
    with:
      environment: production
    secrets:
      deploy_key: ${{ secrets.PROD_KEY }}

Composite Actions

Package multiple steps into a reusable action stored in your repo.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# .github/actions/setup-app/action.yml
name: Setup App
description: Install dependencies and configure environment
inputs:
  node-version:
    description: Node.js version
    default: "20"
runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: npm
    - run: npm ci
      shell: bash
    - run: cp .env.example .env
      shell: bash
1
2
3
4
5
6
7
# Use it in any workflow
steps:
  - uses: actions/checkout@v4
  - uses: ./.github/actions/setup-app
    with:
      node-version: "20"
  - run: npm test

Real-World CI/CD Pipeline

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
name: CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # ── 1. Lint and Test ──────────────────────────────────────────
  test:
    name: Lint & Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: npm

      - run: npm ci
      - run: npm run lint
      - run: npm test -- --coverage

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

  # ── 2. Build Docker Image ─────────────────────────────────────
  build:
    name: Build Image
    runs-on: ubuntu-latest
    needs: test
    permissions:
      contents: read
      packages: write

    outputs:
      image: ${{ steps.meta.outputs.tags }}
      digest: ${{ steps.push.outputs.digest }}

    steps:
      - uses: actions/checkout@v4

      - name: Log in to registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=sha,prefix=sha-
            type=semver,pattern={{version}}

      - name: Build and push
        id: push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  # ── 3. Deploy to Staging ──────────────────────────────────────
  deploy-staging:
    name: Deploy → Staging
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main'
    environment:
      name: staging
      url: https://staging.myapp.com

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to staging
        run: |
          echo "Deploying ${{ needs.build.outputs.image }}"
          # kubectl set image deployment/app app=${{ needs.build.outputs.image }}
        env:
          KUBECONFIG_DATA: ${{ secrets.STAGING_KUBECONFIG }}

  # ── 4. Deploy to Production (manual approval) ─────────────────
  deploy-prod:
    name: Deploy → Production
    runs-on: ubuntu-latest
    needs: deploy-staging
    environment:
      name: production
      url: https://myapp.com

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to production
        run: echo "Deploying to production"
        env:
          KUBECONFIG_DATA: ${{ secrets.PROD_KUBECONFIG }}

Useful Actions from the Marketplace

ActionUse
actions/checkout@v4Clone the repository
actions/setup-node@v4Install Node.js
actions/setup-python@v5Install Python
actions/setup-go@v5Install Go
actions/cache@v4Cache files between runs
actions/upload-artifact@v4Upload files
actions/download-artifact@v4Download files
docker/login-action@v3Log in to a container registry
docker/build-push-action@v5Build and push Docker images
docker/metadata-action@v5Generate image tags and labels
hashicorp/setup-terraform@v3Install Terraform
aws-actions/configure-aws-credentials@v4Configure AWS credentials
azure/login@v2Log in to Azure
google-github-actions/auth@v2Authenticate to GCP

Quick Reference

YAML

PatternSyntax
Stringkey: value
Quoted stringkey: "value: with colon"
Multiline literalkey: \| then indented block
Multiline foldedkey: > then indented block
List- item under key, or [a, b, c]
Mapkey: value pairs, or {k: v}
Anchor&name on a block
Alias*name to reference
Merge<<: *anchor

GitHub Actions

ConceptNotes
Workflow file.github/workflows/*.yml
Manual triggeron: workflow_dispatch
Scheduledon: schedule: cron: "..."
Job dependencyneeds: [job1, job2]
Conditionalif: github.ref == 'refs/heads/main'
Secret${{ secrets.NAME }}
Step outputecho "key=value" >> $GITHUB_OUTPUT
Step output use${{ steps.id.outputs.key }}
Matrixstrategy: matrix: os: [...]
Reusable workflowon: workflow_call + uses: ./.github/workflows/x.yml
Cacheactions/cache@v4 with key
Artifactactions/upload-artifact@v4 + download-artifact
This post is licensed under CC BY 4.0 by the author.