Back to Software Engineering & Delivery Mastery Series CI/CD Platform Deep Dive

CI/CD Platform Deep Dive: GitHub Actions

May 14, 2026 Wasil Zafar 50 min read

Master GitHub Actions from fundamentals to advanced patterns — workflow syntax, reusable workflows, matrix builds, OIDC authentication, self-hosted runners, secrets management, caching strategies, and production-grade CI/CD pipelines.

Table of Contents

  1. Introduction
  2. Core Concepts
  3. Workflow Syntax Deep Dive
  4. Actions Marketplace
  5. Matrix Builds
  6. Reusable Workflows
  7. Composite Actions
  8. OIDC Authentication
  9. Self-Hosted Runners
  10. Secrets & Environments
  11. Caching & Artifacts
  12. Advanced Patterns
  13. Performance Optimization
  14. Security Best Practices
  15. Exercises

Introduction

GitHub Actions launched in November 2019 and within three years became the dominant CI/CD platform for open-source software and a major player in enterprise. Its power lies in proximity to code — CI/CD lives in the same platform as your pull requests, issues, code reviews, and package registry. No separate tool. No separate authentication. No context switching.

Before GitHub Actions, teams had to choose between hosted CI services (Travis CI, CircleCI) that required syncing code, or self-managed tools (Jenkins) that demanded dedicated infrastructure. GitHub Actions eliminated that tradeoff by making CI/CD a native feature of the world's largest code hosting platform.

Key Insight: GitHub Actions' killer advantage isn't its YAML syntax or its runner fleet — it's the network effect. With 100M+ developers on GitHub, the Actions Marketplace has become the largest ecosystem of reusable CI/CD components ever created.

Market Position

As of 2026, GitHub Actions processes over 2 billion workflow runs per month. It's the default CI/CD for virtually all open-source projects and has overtaken Jenkins in new enterprise adoption. Key factors driving adoption include zero-setup for GitHub repositories, generous free tier (2,000 minutes/month for public repos), and deep integration with the GitHub ecosystem (PR checks, branch protection, deployments API).

This deep dive covers everything you need to build production-grade CI/CD pipelines with GitHub Actions — from basic workflow files to advanced patterns like OIDC authentication, organizational reusable workflows, and Kubernetes-based autoscaling runners.

Core Concepts

GitHub Actions has a clear hierarchy of concepts. Understanding how they relate is essential before writing your first workflow.

GitHub Actions Architecture
flowchart TD
    A[Repository Event] -->|Triggers| B[Workflow]
    B --> C[Job 1]
    B --> D[Job 2]
    B --> E[Job 3]
    C --> F[Step 1: Checkout]
    C --> G[Step 2: Setup Node]
    C --> H[Step 3: Run Tests]
    D --> I[Step 1: Build]
    D --> J[Step 2: Push Image]
    E --> K[Step 1: Deploy]
    
    F -.->|Runs on| L[Runner: ubuntu-latest]
    I -.->|Runs on| M[Runner: ubuntu-latest]
    K -.->|Runs on| N[Runner: self-hosted]
    
    style A fill:#3B9797,color:#fff
    style B fill:#132440,color:#fff
    style L fill:#16476A,color:#fff
    style M fill:#16476A,color:#fff
    style N fill:#BF092F,color:#fff
                            

The core components:

  • Workflow — A YAML file in .github/workflows/ that defines an automated process. A repository can have unlimited workflows.
  • Event — A trigger that starts a workflow: push, pull_request, schedule, manual dispatch, webhook, etc.
  • Job — A set of steps that execute on the same runner. Jobs run in parallel by default, or sequentially when dependencies are declared.
  • Step — A single task within a job. Either runs a shell command (run:) or uses an action (uses:).
  • Action — A reusable unit of code. Can be JavaScript, Docker, or composite. Published to the Marketplace or defined locally.
  • Runner — The machine that executes jobs. GitHub provides hosted runners (Ubuntu, Windows, macOS) or you can use self-hosted runners.

Runners

GitHub-hosted runners are virtual machines provisioned fresh for each job. They come pre-loaded with common tools (Git, Docker, Node.js, Python, Java, .NET, etc.) and are automatically cleaned up after each job — ensuring a pristine environment every time.

Runner Types Comparison

GitHub-Hosted: 2-core CPU, 7GB RAM (standard), 14GB RAM (large). Available in Ubuntu, Windows, macOS. Ephemeral — destroyed after each job. Free for public repos, metered for private repos.

Self-Hosted: Your own machines registered with GitHub. Any OS, any hardware. Persistent state between jobs (unless configured as ephemeral). Free to use, but you pay for infrastructure.

Larger Runners: GitHub-managed runners with 4-64 cores, up to 256GB RAM. Available on GitHub Team and Enterprise plans. Ideal for heavy compilation or integration tests.

Workflow Syntax Deep Dive

Every GitHub Actions workflow is a YAML file stored at .github/workflows/<name>.yml. The file defines when to run (triggers), where to run (runners), and what to run (steps).

Here's a complete, production-ready CI workflow that demonstrates the core syntax:

# .github/workflows/ci.yml
name: CI Pipeline

on:
  push:
    branches: [main, develop]
    paths-ignore:
      - '*.md'
      - 'docs/**'
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  NODE_VERSION: '20'
  REGISTRY: ghcr.io

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    needs: lint
    strategy:
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: test-results-${{ matrix.node-version }}
          path: coverage/

  build:
    runs-on: ubuntu-latest
    needs: test
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: ${{ env.REGISTRY }}/${{ github.repository }}:${{ github.sha }}

Trigger Types

GitHub Actions supports over 35 event types. The most commonly used:

# Push and PR triggers with path/branch filtering
on:
  push:
    branches: [main, 'release/**']
    tags: ['v*']
    paths:
      - 'src/**'
      - 'package.json'
  pull_request:
    types: [opened, synchronize, reopened]
    branches: [main]

# Scheduled triggers (cron syntax, UTC)
on:
  schedule:
    - cron: '0 6 * * 1-5'  # Weekdays at 6 AM UTC

# Manual trigger with typed inputs
on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Release version (e.g., 1.2.3)'
        required: true
        type: string
      dry_run:
        description: 'Dry run mode'
        required: false
        type: boolean
        default: true

# Cross-repository trigger via webhook
on:
  repository_dispatch:
    types: [deploy-trigger]

# Reusable workflow trigger
on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
    secrets:
      deploy_key:
        required: true
Common Pitfall: The pull_request event runs workflows on the merge commit (head branch merged into base), not on the PR head ref directly. This means the workflow has access to a combined view of both branches. For fork-based PRs, pull_request_target runs with base branch permissions — be careful with untrusted code.

Actions Marketplace

The GitHub Actions Marketplace hosts 20,000+ actions. These are pre-built, reusable steps that handle common CI/CD tasks. Understanding how to find, evaluate, and securely use marketplace actions is a critical skill.

Essential First-Party Actions

# actions/checkout — Clone your repository
- uses: actions/checkout@v4
  with:
    fetch-depth: 0          # Full history (needed for git log, versioning)
    submodules: recursive   # Include git submodules
    token: ${{ secrets.PAT }}  # For private submodules

# actions/setup-node — Install and cache Node.js
- uses: actions/setup-node@v4
  with:
    node-version-file: '.nvmrc'  # Read version from .nvmrc
    cache: 'npm'                  # Cache npm dependencies
    registry-url: 'https://npm.pkg.github.com'  # GitHub Packages

# actions/cache — Cache arbitrary directories
- uses: actions/cache@v4
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
      gradle-${{ runner.os }}-

# actions/upload-artifact / download-artifact — Pass data between jobs
- uses: actions/upload-artifact@v4
  with:
    name: build-output
    path: dist/
    retention-days: 5

Pinning & Security

Third-party actions execute code in your workflow with access to your secrets and GITHUB_TOKEN. Pinning to a full commit SHA (not a tag) prevents supply-chain attacks where a tag is moved to malicious code:

# BAD: Tag can be moved to malicious code
- uses: some-org/some-action@v2

# GOOD: Pinned to immutable commit SHA
- uses: some-org/some-action@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
  # SHA corresponds to v2.1.0 release

# BEST: Pin SHA + add comment with version for readability
- uses: docker/build-push-action@4a13e500e55cf31b7a5d59a38ab2040ab0f42f56 # v5.1.0
  with:
    push: true
    tags: myapp:latest
Security Rule: Never use @main or @master references for third-party actions in production workflows. A compromised upstream repository could inject code into every workflow run. Always pin to a full 40-character SHA and verify it matches a known release.

Matrix Builds

Matrix builds let you test across multiple configurations simultaneously — different OS versions, language versions, dependency versions, or any combination. Each matrix permutation creates a separate job that runs in parallel.

# .github/workflows/matrix-test.yml
name: Cross-Platform Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false  # Don't cancel other jobs if one fails
      max-parallel: 6   # Limit concurrent jobs
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        python-version: ['3.10', '3.11', '3.12']
        include:
          # Add specific combinations with extra variables
          - os: ubuntu-latest
            python-version: '3.12'
            experimental: true
            coverage: true
        exclude:
          # Skip expensive macOS + old Python combinations
          - os: macos-latest
            python-version: '3.10'

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'
      - run: pip install -e ".[test]"
      - run: pytest --cov=${{ matrix.coverage && '--cov-report=xml' || '' }}
      - if: matrix.coverage
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

For dynamic matrices generated at runtime:

jobs:
  generate-matrix:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4
      - id: set-matrix
        run: |
          # Generate matrix from changed directories
          CHANGED=$(git diff --name-only HEAD~1 | grep '^services/' | cut -d/ -f2 | sort -u)
          MATRIX=$(echo "$CHANGED" | jq -R -s -c 'split("\n") | map(select(. != "")) | {service: .}')
          echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"

  test:
    needs: generate-matrix
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }}
    steps:
      - uses: actions/checkout@v4
      - run: cd services/${{ matrix.service }} && make test

Reusable Workflows

Reusable workflows let you define a workflow once and call it from multiple other workflows — within the same repository, across repositories in an organization, or from any public repository. They're the functions of GitHub Actions.

# .github/workflows/reusable-deploy.yml (the reusable workflow)
name: Reusable Deploy

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      image_tag:
        required: true
        type: string
      replicas:
        required: false
        type: number
        default: 3
    secrets:
      KUBE_CONFIG:
        required: true
      SLACK_WEBHOOK:
        required: false
    outputs:
      deployment_url:
        description: "The deployment URL"
        value: ${{ jobs.deploy.outputs.url }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    outputs:
      url: ${{ steps.deploy.outputs.url }}
    steps:
      - uses: actions/checkout@v4
      - name: Configure kubectl
        run: |
          echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > kubeconfig
          export KUBECONFIG=kubeconfig
      - name: Deploy
        id: deploy
        run: |
          kubectl set image deployment/app app=${{ inputs.image_tag }}
          kubectl scale deployment/app --replicas=${{ inputs.replicas }}
          URL=$(kubectl get ingress app -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
          echo "url=https://$URL" >> "$GITHUB_OUTPUT"
      - name: Notify Slack
        if: secrets.SLACK_WEBHOOK != ''
        run: |
          curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
            -H 'Content-Type: application/json' \
            -d '{"text":"Deployed to ${{ inputs.environment }}: ${{ steps.deploy.outputs.url }}"}'
# .github/workflows/release.yml (the caller workflow)
name: Release

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

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}
    steps:
      - uses: actions/checkout@v4
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: ${{ steps.meta.outputs.tags }}

  deploy-staging:
    needs: build
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: staging
      image_tag: ${{ needs.build.outputs.image_tag }}
      replicas: 2
    secrets:
      KUBE_CONFIG: ${{ secrets.STAGING_KUBE_CONFIG }}
      SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}

  deploy-production:
    needs: [build, deploy-staging]
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: production
      image_tag: ${{ needs.build.outputs.image_tag }}
      replicas: 5
    secrets:
      KUBE_CONFIG: ${{ secrets.PROD_KUBE_CONFIG }}
      SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}

Composite Actions

Composite actions bundle multiple steps into a single reusable action. Unlike reusable workflows (which create separate workflow runs), composite actions execute inline within the calling job — sharing the same runner, filesystem, and environment variables.

# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Install dependencies, configure environment, and warm caches'
inputs:
  node-version:
    description: 'Node.js version'
    required: false
    default: '20'
  install-command:
    description: 'Package install command'
    required: false
    default: 'npm ci'
outputs:
  cache-hit:
    description: 'Whether the cache was hit'
    value: ${{ steps.cache.outputs.cache-hit }}

runs:
  using: 'composite'
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}

    - name: Cache node_modules
      id: cache
      uses: actions/cache@v4
      with:
        path: node_modules
        key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

    - name: Install dependencies
      if: steps.cache.outputs.cache-hit != 'true'
      shell: bash
      run: ${{ inputs.install-command }}

    - name: Verify installation
      shell: bash
      run: |
        echo "Node version: $(node --version)"
        echo "NPM version: $(npm --version)"
        echo "Dependencies installed: $(ls node_modules | wc -l) packages"

Use composite actions when you want to share steps that run within a single job. Use reusable workflows when you need separate jobs with their own runners, environments, and concurrency controls.

OIDC Authentication

OpenID Connect (OIDC) eliminates the need for long-lived cloud credentials in your repository secrets. Instead, workflows request short-lived tokens directly from cloud providers, authenticated by GitHub's OIDC provider. This is the gold standard for GitHub Actions authentication to AWS, Azure, and GCP.

OIDC Authentication Flow
sequenceDiagram
    participant W as GitHub Workflow
    participant G as GitHub OIDC Provider
    participant C as Cloud Provider (AWS/Azure/GCP)
    participant R as Cloud Resources
    
    W->>G: Request OIDC token
    G->>W: JWT with claims (repo, branch, workflow)
    W->>C: Present JWT + assume role
    C->>C: Validate JWT signature & claims
    C->>W: Short-lived credentials (15 min)
    W->>R: Access resources with temp credentials
                            
# OIDC with AWS
name: Deploy to AWS
on:
  push:
    branches: [main]

permissions:
  id-token: write   # Required for OIDC
  contents: read

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

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: us-east-1
          # No access keys needed!

      - name: Deploy to S3
        run: aws s3 sync ./dist s3://my-bucket/
# OIDC with Azure
- name: Azure Login via OIDC
  uses: azure/login@v2
  with:
    client-id: ${{ secrets.AZURE_CLIENT_ID }}
    tenant-id: ${{ secrets.AZURE_TENANT_ID }}
    subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
    # Uses federated identity credential — no client secret needed

- name: Deploy to Azure Web App
  uses: azure/webapps-deploy@v3
  with:
    app-name: my-web-app
    package: ./dist
Security Benefit: OIDC credentials are short-lived (typically 15 minutes), scoped to a specific workflow run, and cannot be exfiltrated for later use. If your repository is compromised, attackers cannot extract reusable credentials because none exist.

Self-Hosted Runners

Self-hosted runners are machines you manage that connect to GitHub to execute workflow jobs. Use them when you need specific hardware (GPUs, ARM processors), network access (internal services, databases), persistent state (large dependencies), or compliance requirements (data residency).

# Using self-hosted runners with labels
jobs:
  gpu-tests:
    runs-on: [self-hosted, linux, gpu, cuda-12]
    steps:
      - uses: actions/checkout@v4
      - run: python -m pytest tests/gpu/ --gpu-device=0

  deploy-internal:
    runs-on: [self-hosted, linux, production-vpc]
    steps:
      - uses: actions/checkout@v4
      - run: kubectl apply -f k8s/

Actions Runner Controller (ARC) for Kubernetes autoscales runners based on workflow demand:

# Kubernetes: RunnerDeployment with autoscaling
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
  name: ci-runners
spec:
  replicas: 1
  template:
    spec:
      repository: my-org/my-repo
      labels:
        - self-hosted
        - linux
        - k8s
      ephemeral: true
---
apiVersion: actions.summerwind.dev/v1alpha1
kind: HorizontalRunnerAutoscaler
metadata:
  name: ci-runners-autoscaler
spec:
  scaleTargetRef:
    kind: RunnerDeployment
    name: ci-runners
  minReplicas: 1
  maxReplicas: 20
  scaleUpTriggers:
    - githubEvent:
        workflowJob: {}
      duration: "30m"

Secrets & Environments

GitHub provides three levels of secrets: repository (accessible by all workflows in a repo), environment (accessible only in jobs targeting that environment), and organization (shared across repos with policy controls).

# Environment-specific deployment with protection rules
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: ${{ steps.deploy.outputs.url }}
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        id: deploy
        env:
          # Environment secrets are only available in jobs
          # targeting the 'production' environment
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}
        run: |
          ./deploy.sh
          echo "url=https://app.example.com" >> "$GITHUB_OUTPUT"

Environment protection rules allow you to require:

  • Required reviewers — specific people must approve before deployment
  • Wait timer — delay between approval and execution (0-43200 minutes)
  • Branch restrictions — only allow deployments from specific branches
  • Custom rules — call external APIs to validate deployment readiness

Caching & Artifacts

Caching and artifacts serve different purposes: caches persist data between workflow runs (dependencies, build caches), while artifacts pass data between jobs within a single run or preserve outputs for download.

# Advanced caching with fallback restore keys
- name: Cache Gradle dependencies
  uses: actions/cache@v4
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
      ~/.gradle/configuration-cache
    key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle.lockfile') }}
    restore-keys: |
      gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*') }}
      gradle-${{ runner.os }}-

# Passing build artifacts between jobs
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
          retention-days: 1
          if-no-files-found: error

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - run: aws s3 sync dist/ s3://my-bucket/

Advanced Patterns

Production workflows require patterns beyond basic build-test-deploy. Here are the most useful advanced patterns:

Conditional Execution

# Skip jobs based on commit message, changed files, or context
jobs:
  deploy:
    if: |
      github.event_name == 'push' &&
      github.ref == 'refs/heads/main' &&
      !contains(github.event.head_commit.message, '[skip deploy]')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy only if source changed
        if: steps.changes.outputs.src == 'true'
        run: ./deploy.sh

Concurrency Control

# Cancel in-progress deployments when new ones start
concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

# Queue deployments (don't cancel, wait in line)
concurrency:
  group: production-deploy
  cancel-in-progress: false

Job Outputs & Dependent Jobs

jobs:
  version:
    runs-on: ubuntu-latest
    outputs:
      new_version: ${{ steps.bump.outputs.version }}
      should_release: ${{ steps.check.outputs.release }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - id: check
        run: |
          if git log --oneline HEAD~1..HEAD | grep -q "feat\|fix"; then
            echo "release=true" >> "$GITHUB_OUTPUT"
          else
            echo "release=false" >> "$GITHUB_OUTPUT"
          fi
      - id: bump
        if: steps.check.outputs.release == 'true'
        run: |
          NEW_VERSION=$(npm version patch --no-git-tag-version | tr -d 'v')
          echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"

  release:
    needs: version
    if: needs.version.outputs.should_release == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Releasing v${{ needs.version.outputs.new_version }}"

Performance Optimization

Workflow execution time directly impacts developer productivity. A 10-minute CI pipeline that runs 50 times daily consumes 8+ hours of waiting time. Here are proven strategies to reduce pipeline duration:

Performance Optimization Strategies

1. Path Filtering — Only run jobs when relevant files change. Use paths: in triggers or dorny/paths-filter action for per-job filtering.

2. Dependency Caching — Cache node_modules, ~/.gradle, ~/.cache/pip between runs. First run is slow; subsequent runs skip installation entirely.

3. Job Parallelization — Split test suites across parallel jobs. Use matrix builds or test-splitting tools like junit-split.

4. Larger Runners — For CPU-bound tasks (compilation, bundling), a 4-core runner can be 3x faster than the standard 2-core runner.

5. Docker Layer Caching — Use docker/build-push-action with cache-from and cache-to to reuse Docker build layers.

# Docker layer caching with GitHub Actions cache backend
- uses: docker/build-push-action@v5
  with:
    push: true
    tags: myapp:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

# Path-based job filtering
- uses: dorny/paths-filter@v3
  id: changes
  with:
    filters: |
      backend:
        - 'backend/**'
      frontend:
        - 'frontend/**'
      infra:
        - 'terraform/**'

- name: Run backend tests
  if: steps.changes.outputs.backend == 'true'
  run: cd backend && make test

Security Best Practices

GitHub Actions workflows run code with access to your secrets, source code, and deployment infrastructure. A single misconfiguration can expose credentials or allow supply-chain attacks.

# Principle of Least Privilege: restrict GITHUB_TOKEN permissions
permissions:
  contents: read        # Read-only access to repo contents
  pull-requests: write  # Only what's needed for PR comments
  # All other permissions are 'none'

# For workflows that only read code:
permissions: read-all

# For workflows that need no token at all:
permissions: {}

Critical Security Rules:

  • Pin actions to SHA — Never use @main or mutable tags for third-party actions
  • Use permissions: — Always declare minimal permissions at the workflow or job level
  • Avoid pull_request_target with checkout — This gives fork PRs access to your secrets
  • Never echo secrets — GitHub redacts known secrets, but transformed values may leak
  • Restrict environment access — Use branch protection + environment protection rules together
  • Audit third-party actions — Review source code before using. Prefer official (actions/) or verified publishers
# DANGEROUS: Never do this — exposes secrets to fork PRs
on:
  pull_request_target:
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # UNSAFE!
      - run: npm test
        env:
          API_KEY: ${{ secrets.API_KEY }}  # Accessible to attacker code!

# SAFE: Separate privileged operations from untrusted code
on:
  pull_request:
jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - run: npm test  # No secrets access, runs on merge commit

Exercises

Exercise 1: Multi-Stage CI Pipeline

Create a complete CI workflow with three stages: lint → test (matrix: Node 18, 20, 22 on Ubuntu and macOS) → build. Use caching for node_modules, upload test coverage as artifacts, and configure concurrency to cancel in-progress runs on the same branch.

Exercise 2: Reusable Deployment Workflow

Build a reusable workflow (workflow_call) that accepts environment name, Docker image tag, and replica count as inputs. It should deploy to Kubernetes using OIDC authentication (no static secrets). Create a caller workflow that deploys to staging automatically and production with manual approval.

Exercise 3: Monorepo CI with Path Filtering

Design a workflow for a monorepo with frontend/, backend/, and infrastructure/ directories. Each should only build/test when its files change. Use a dynamic matrix generated from git diff to determine which services to test.

Exercise 4: Security Hardening Audit

Take an existing workflow and apply all security best practices: pin all actions to SHA, add explicit permissions: block with least privilege, replace static cloud credentials with OIDC, add environment protection rules, and configure CODEOWNERS for the .github/ directory.