Home
DevOps & Cloud Engineering / Lesson 8 — GitHub Actions in Depth

GitHub Actions in Depth

Workflows, matrix builds, reusable actions, OIDC, and the patterns that scale to large orgs.


Workflows, Jobs, Steps

Workflows live in .github/workflows/*.yml. Each file is a separate workflow.

Mental model: GitHub Actions runs WORKFLOWS triggered by events. A workflow contains JOBS that run on RUNNERS. Each job is a sequence of STEPS, each of which is a shell command or an ACTION (a reusable unit of work).

Basic structure:

YAML
name: My Workflow

on:                     # what triggers this workflow
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 2 * * *' # daily at 2 AM UTC
  workflow_dispatch:    # manual trigger
    inputs:
      environment:
        type: choice
        options: [staging, production]

jobs:
  my-job:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Hello"

Triggers:
push — code pushed to a branch
pull_request — PR opened, updated, closed
schedule — cron-based
workflow_dispatch — manual trigger
release — when you publish a release
pull_request_target — PR event, but with permissions of the BASE branch (security-sensitive)

Filtering by branch and path:

YAML
on:
  push:
    branches: [main, 'releases/**']
    paths:
      - 'src/**'
      - 'package.json'
    paths-ignore:
      - '**.md'

Critical: pull_request from forks has NO secret access (security). If you need secrets to test PRs from forks, use pull_request_target carefully — review forked code before running it.


Jobs, Sequencing, and Concurrency

Jobs run in parallel by default. Use needs: for sequencing:

YAML
jobs:
  test:
    runs-on: ubuntu-latest
    steps: [...]

  lint:
    runs-on: ubuntu-latest
    steps: [...]

  deploy:
    needs: [test, lint]   # waits for both
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps: [...]

Runners:
ubuntu-latest — most common. Includes git, Node, Python, Docker, AWS CLI, gcloud, kubectl
windows-latest, macos-latest
ubuntu-22.04 — pinned versions
• Larger / GPU runners — paid feature for big builds
• Self-hosted runners — for private network access, specialized hardware, compliance

Concurrency control — prevent overlapping runs:

YAML
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true   # cancel old runs when a new one starts

For deploys:

YAML
concurrency:
  group: deploy-production
  cancel-in-progress: false  # don't cancel — just queue

Outputs from one job to another:

YAML
jobs:
  setup:
    outputs:
      version: ${{ steps.get-version.outputs.version }}
    steps:
      - id: get-version
        run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT

  deploy:
    needs: setup
    steps:
      - run: echo "Deploying ${{ needs.setup.outputs.version }}"

Actions — The Reusable Units

An action is a packaged step you can use in any workflow. You reference them by owner/repo@version.

Common actions:

YAML
- uses: actions/checkout@v4
  with:
    fetch-depth: 0   # full history (default is shallow)

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

- uses: actions/cache@v4
  with:
    path: ~/.cargo
    key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

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

# Download in another job
- uses: actions/download-artifact@v4
  with:
    name: dist

Pinning actions:
@v4 — latest in major version 4 (gets minor/patch updates)
@v4.1.7 — exact version
@<commit-sha> — pin to commit (most secure)

Best practice: pin third-party actions by SHA. For first-party (actions/*, github/*) and trusted vendors (aws-actions/*, docker/*), @vN is fine.

Composite actions (in-team reuse):

YAML
# .github/actions/setup-app/action.yml
name: Setup App
runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: '20'
        cache: 'npm'
    - shell: bash
      run: npm ci

Use it in any workflow:

YAML
- uses: ./.github/actions/setup-app

Matrix Builds

Matrix lets one job definition expand into many parallel runs:

YAML
jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node: [18, 20, 22]

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

Produces 9 parallel jobs (3 OS × 3 Node versions).

Exclude specific combinations:

YAML
matrix:
  os: [ubuntu, macos, windows]
  node: [18, 20]
  exclude:
    - os: windows
      node: 18
  include:
    - os: ubuntu
      node: 22

Matrix is powerful for libraries that must work on many platforms. Use sparingly for apps.


Secrets, Variables & OIDC

NEVER commit secrets. Store them in:
• Repo settings → Secrets and variables → Actions
• Organization-level secrets (shared across repos)
• Environment-level secrets (production-only)

Using them:

YAML
- run: ./deploy.sh
  env:
    DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

Secrets are masked in logs (***). Not available to PRs from forks.

Environments — group secrets and add protections:

YAML
jobs:
  deploy:
    environment: production    # links to GitHub Environment
    runs-on: ubuntu-latest
    steps:
      - run: echo $DB_URL
        env:
          DB_URL: ${{ secrets.DB_URL }}

Environments support required reviewers, wait timers, branch restrictions.

OIDC for cloud — the right way to deploy

Long-lived AWS keys in GitHub Secrets are a security risk. Modern setup: GitHub Actions authenticates via short-lived OIDC tokens.

YAML
permissions:
  id-token: write       # request OIDC token
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/github-deploy
          aws-region: us-east-1
      - run: aws s3 cp file.txt s3://my-bucket/

No AWS keys anywhere. The role can be scoped to exact branch/environment. Compromised secrets become impossible because there are no long-lived secrets.

GCP and Azure have equivalent OIDC integrations. This is the 2026 standard.


Reusable Workflows

As your CI grows, copy-pasting YAML across workflows becomes painful.

Reusable workflow:

YAML
# .github/workflows/test-template.yml
on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: '20'
    secrets:
      NPM_TOKEN:
        required: false

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci
      - run: npm test

Calling it:

YAML
jobs:
  test:
    uses: ./.github/workflows/test-template.yml
    with:
      node-version: '20'
    secrets:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Reusable workflows can be in another repo:

YAML
uses: my-org/shared-workflows/.github/workflows/test.yml@v1

This is how a Platform Engineering team shares CI logic across many repos. Define once; teams call them.

Composite action vs reusable workflow:
• Composite — used as a step inside a job. Lighter weight.
• Reusable workflow — replaces an entire job. Can have its own runs-on, secrets, environments.

Use composite for shared steps. Use reusable for shared pipelines.


Production Patterns

A grab-bag of patterns from real production setups:

1. Required status checks — branch protection requires specific jobs to pass before merge.

2. Deploy on tag:

YAML
on:
  push:
    tags: ['v*']

3. Auto-merge dependabot PRs that pass CI — use Dependabot's config plus a workflow.

4. Cost control:

YAML
expensive-job:
  if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'expensive')

5. PR concurrency cancellation:

YAML
concurrency:
  group: pr-${{ github.event.pull_request.number }}
  cancel-in-progress: true

6. Notify on failure:

YAML
- name: Notify Slack
  if: failure() && github.ref == 'refs/heads/main'
  uses: slackapi/slack-github-action@v1.27.0
  with:
    payload: |
      {"text": "Main branch CI failed: ${{ github.sha }}"}
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

7. Caching beyond setup-node:

YAML
- uses: actions/cache@v4
  with:
    path: |
      ~/.cargo
      target
    key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
    restore-keys: |
      ${{ runner.os }}-cargo-

The next lesson covers build optimization — once CI/CD runs constantly, making it FAST becomes the next leverage point.


⁂ Back to all modules