G
GuideDevOps
Lesson 3 of 11

GitHub Actions

Part of the CI/CD Pipelines tutorial series.

GitHub Actions is a CI/CD platform built directly into GitHub that automates your build, test, and deployment pipeline whenever you push code or open a pull request. It's free for public repositories and offers generous limits for private ones.

1. Terminology & Architecture

Core Concepts

TermDescription
WorkflowThe automated process defined in a YAML file (the entire CI/CD pipeline).
EventWhat triggers the workflow (e.g., push, pull_request, schedule).
JobA set of steps that run sequentially on the same runner.
StepAn individual task (shell command, Action, or script).
ActionA reusable application (e.g., actions/checkout@v4, custom actions).
RunnerThe server that executes the workflow (GitHub-hosted or self-hosted).

File Structure

.github/
├── workflows/
   ├── ci.yml                    # Lint, build, test on PR
   ├── deploy-staging.yml        # Deploy to staging on develop push
   ├── deploy-production.yml     # Deploy to prod on main push
   ├── security-scan.yml         # Nightly security scans
   ├── release.yml               # Create releases on tag push
   └── cleanup.yml               # Weekly cleanup tasks
├── dependabot.yml                # Dependency updates
└── CODEOWNERS                     # Required reviewers

2. Workflow Events: What Triggers Execution?

Push Event

on:
  push:
    branches:
      - main
      - develop
      - 'release/**'           # Matches release/v1, release/v2, etc.
    paths:
      - 'src/**'               # Only trigger if src/ changed
      - 'package.json'
    tags:
      - 'v*'                   # Semantic versioning tags

Pull Request Event

on:
  pull_request:
    types:
      - opened
      - synchronize            # New commits pushed to PR
      - reopened
    branches:
      - main
      - develop
    paths:
      - 'src/**'

Schedule (Cron)

on:
  schedule:
    - cron: '0 2 * * 1-5'      # 2 AM UTC, Mon-Fri (nightly builds)
    - cron: '0 0 * * 0'        # Midnight UTC, every Sunday (weekly)

Manual Trigger (workflow_dispatch)

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Which environment to deploy to?'
        required: true
        default: staging
        type: choice
        options:
          - staging
          - production
      version:
        description: 'Version to deploy'
        required: false
        type: string

Workflow Trigger

on:
  workflow_run:
    workflows: ["CI"]           # Triggered after CI workflow completes
    types:
      - completed
    branches:
      - main

Repository Dispatch (Via API)

on:
  repository_dispatch:
    types:
      - trigger-deployment

Trigger via curl:

curl -X POST https://api.github.com/repos/USER/REPO/dispatches \
  -H "Authorization: token YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"event_type":"trigger-deployment","client_payload":{"ref":"main"}}'

3. Workflow Syntax: Complete Reference

Basic Structure

name: CI/CD Pipeline                          # Workflow name (shown in Actions tab)
 
on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]
 
env:                                          # Workflow-level environment variables
  NODE_ENV: production
  REGISTRY: ghcr.io
 
jobs:
  lint:                                       # Job ID (used in dependencies)
    name: Code Quality                        # Job display name
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run lint

Job Configuration

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 30                       # Job timeout (default 360 min)
    concurrency:
      group: ${{ github.workflow }}-${{ github.ref }}
      cancel-in-progress: true                # Cancel previous runs on new push
    
    strategy:
      matrix:                                 # Test on multiple versions
        node-version: [ 18, 20, 22 ]
        os: [ ubuntu-latest, windows-latest ]
      max-parallel: 4                         # Run max 4 jobs in parallel
      fail-fast: false                        # Don't cancel other matrix jobs
    
    environment: production                   # Require deployment approval
    
    outputs:
      artifact-id: ${{ steps.build.outputs.id }}  # Share outputs with other jobs
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0                      # Get full history for versioning

4. Steps & Context Variables

Running Scripts

steps:
  - name: Run build
    run: npm run build
    working-directory: ./frontend             # Change working directory
    shell: bash                               # Explicit shell (bash, sh, pwsh, cmd)
    env:
      DEBUG: true                             # Step-level environment variable

Using Actions

  - name: Setup Node.js
    uses: actions/setup-node@v4
    with:
      node-version: '20'
      cache: 'npm'                            # Enable dependency caching
      cache-dependency-path: 'package-lock.json'

Context Variables

  - name: Print context
    run: |
      echo "GitHub Actor: ${{ github.actor }}"
      echo "Repository: ${{ github.repository }}"
      echo "Branch: ${{ github.ref }}"
      echo "Commit: ${{ github.sha }}"
      echo "Event Name: ${{ github.event_name }}"
      echo "PR Number: ${{ github.event.pull_request.number }}"

Conditional Execution

  - name: Notify on failure
    if: failure()                             # Run only if previous step failed
    run: curl -X POST webhook.site/...
 
  - name: Deploy to production
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    run: ./deploy.sh
 
  - name: Run tests
    if: contains(github.head_ref, 'test')    # If branch name contains 'test'
    run: npm test

Job Dependencies & Ordering

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - run: npm run lint
 
  build:
    runs-on: ubuntu-latest
    needs: lint                               # Wait for lint job to finish
    steps:
      - run: npm run build
 
  test:
    runs-on: ubuntu-latest
    needs: [lint, build]                      # Wait for multiple jobs
    steps:
      - run: npm test
 
  deploy:
    runs-on: ubuntu-latest
    needs: test
    if: success()                             # Only run if test succeeded
    steps:
      - run: ./deploy.sh

5. Matrix Strategy: Test Multiple Configurations

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        node-version: [16, 18, 20, 22]
        os: [ubuntu-latest, macos-latest]
        include:
          - node-version: 20
            os: ubuntu-latest
            experimental: true                # Add custom property
        exclude:
          - node-version: 16
            os: macos-latest                 # Don't test Node 16 on macOS
    
    steps:
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      
      - run: npm test
        if: ${{ !matrix.experimental }}      # Skip experimental configs

Result: 7 parallel jobs (16+18+20+22 on Ubuntu, 18+20+22 on macOS, minus 16 on macOS)


6. Caching & Artifacts

Dependency Caching

  - name: Cache npm dependencies
    uses: actions/cache@v3
    with:
      path: ~/.npm
      key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
      restore-keys: |
        ${{ runner.os }}-npm-

Upload Artifacts

  - name: Build
    run: npm run build
 
  - name: Upload build artifacts
    uses: actions/upload-artifact@v3
    if: always()                              # Upload even if build failed
    with:
      name: build-${{ matrix.os }}-${{ matrix.node-version }}
      path: dist/
      retention-days: 5                       # Delete after 5 days

Download Artifacts

  deploy:
    needs: build
    steps:
      - name: Download artifacts
        uses: actions/download-artifact@v3
        with:
          name: build-ubuntu-latest-20
          path: ./dist
      
      - run: npm run deploy

7. Secrets & Variables: Managing Sensitive Data

Repository Secrets

Setting up: Settings → Secrets and variables → Actions → New repository secret

  - name: Deploy
    env:
      API_KEY: ${{ secrets.PROD_API_KEY }}
      DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
      DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
    run: ./deploy.sh

Repository Variables (Non-sensitive)

  - name: Build
    env:
      REGISTRY: ${{ vars.REGISTRY }}          # ghcr.io
      REGISTRY_USERNAME: ${{ vars.REGISTRY_USERNAME }}
    run: docker build -t $REGISTRY/myapp .

Environment-specific Secrets

  1. Create GitHub Environment: Settings → Environments → New environment
  2. Add secrets specific to that environment
  3. Reference in workflow:
jobs:
  deploy-production:
    environment:
      name: production
      url: https://prod.example.com
    steps:
      - run: ./deploy.sh
        env:
          API_KEY: ${{ secrets.API_KEY }}    # Uses prod environment secret

Masking Secrets in Logs

echo "::add-mask::${SENSITIVE_VALUE}"
echo "Using token: $SENSITIVE_VALUE"  # Logs show: Using token: ***

8. Community Actions: Popular & Useful

Check out code

- uses: actions/checkout@v4

Setup languages

- uses: actions/setup-node@v4
  with:
    node-version: '20'
 
- uses: actions/setup-python@v4
  with:
    python-version: '3.11'
 
- uses: actions/setup-go@v4
  with:
    go-version: '1.21'

Publish results

- uses: actions/upload-artifact@v3
  with:
    name: test-results
    path: junit.xml
 
- uses: actions/download-artifact@v3
  with:
    name: test-results

Create releases

- uses: actions/create-release@v1
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  with:
    tag_name: ${{ github.ref }}
    release_name: Release ${{ github.ref }}
    draft: false
    prerelease: false

Docker operations

- uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2
 
- uses: docker/build-push-action@v4
  with:
    push: true
    tags: myregistry/myapp:latest

Deploy to AWS

- uses: aws-actions/configure-aws-credentials@v2
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }}
    aws-region: us-east-1
 
- run: aws s3 sync dist/ s3://my-bucket/

9. Self-Hosted Runners

Use Case

  • Private repositories
  • Large workflows needing resources
  • Custom environment requirements
  • On-premises deployments

Setup

# 1. Go to repository Settings → Actions → Runners → New self-hosted runner
# 2. Download and run setup
 
./config.sh --url https://github.com/USER/REPO --token TOKEN
./run.sh

Use in Workflow

jobs:
  build:
    runs-on: self-hosted                  # Use self-hosted runner
    # or with labels:
    runs-on: [self-hosted, linux, x64]
    steps:
      - run: npm test

10. Real-World Examples

Full CI/CD Pipeline for Node.js

name: Full CI/CD
 
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  lint:
    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 run format:check
 
  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 run test:coverage
      - uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info
 
  build:
    runs-on: ubuntu-latest
    needs: test
    outputs:
      image-tag: ${{ steps.image.outputs.tag }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v2
      - id: image
        run: echo "tag=ghcr.io/${{ github.repository }}:${{ github.sha }}" >> $GITHUB_OUTPUT
      - uses: docker/build-push-action@v4
        with:
          push: ${{ github.event_name == 'push' }}
          tags: ${{ steps.image.outputs.tag }}
 
  deploy-staging:
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/develop' && github.event_name == 'push'
    environment:
      name: staging
      url: https://staging.example.com
    steps:
      - uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }}
          aws-region: us-east-1
      - run: aws ecs update-service --cluster staging --service myapp --force-new-deployment
 
  deploy-production:
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment:
      name: production
      url: https://example.com
    steps:
      - uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }}
          aws-region: us-east-1
      - run: aws ecs update-service --cluster production --service myapp --force-new-deployment
      - name: Notify deployment
        run: curl -X POST ${{ secrets.SLACK_WEBHOOK }} -d "Deployed to production"

Security Scanning Workflow

name: Security Scan
 
on:
  push:
    branches: [main]
  schedule:
    - cron: '0 2 * * 0'  # Weekly Sunday 2 AM
 
jobs:
  sast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: github/super-linter@v4
        env:
          DEFAULT_BRANCH: main
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
  dependency-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dependency-check/Dependency-Check_Action@main
        with:
          project: 'myapp'
          path: '.'
          format: 'JSON'
 
  secret-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: main
          head: HEAD

11. Optimization: Speed & Cost

Caching Best Practices

- name: Cache dependencies
  uses: actions/cache@v3
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-

Avoid Redundant Work

- name: Build
  run: npm run build
 
- name: Test
  needs: build              # Run only after build succeeds
  run: npm test

Parallel Jobs

strategy:
  matrix:
    test-suite: [unit, integration, e2e]
 
jobs:
  test:
    name: ${{ matrix.test-suite }} tests
    runs-on: ubuntu-latest
    steps:
      - run: npm run test:${{ matrix.test-suite }}

12. Debugging & Troubleshooting

Enable Debug Logging

# In workflow:
- name: Debug info
  run: echo ${{ toJson(github) }}
 
# Or set secret: ACTIONS_STEP_DEBUG = true

Check Job Status

- name: Print job status
  if: always()              # Run regardless of previous step
  run: |
    echo "Job status: ${{ job.status }}"
    echo "Failure context: ${{ failure() }}"

Use tee to Save Logs

- name: Build with logging
  run: npm run build 2>&1 | tee build.log
 
- uses: actions/upload-artifact@v3
  if: failure()
  with:
    name: build-logs
    path: build.log

13. Best Practices

DO:

  • Use specific action versions (e.g., @v4 not @main)
  • Cache dependencies
  • Run linting first (cheap, fast)
  • Use matrix for testing multiple configs
  • Mask sensitive output with ::add-mask::
  • Set reasonable timeouts
  • Require approval for production deployments
  • Document your workflows with comments
  • Use branch protection rules requiring CI to pass

DON'T:

  • Hardcode secrets in YAML
  • Use @latest for actions (unpredictable)
  • Run expensive operations unnecessarily
  • Have overly complex single jobs (split into smaller jobs)
  • Ignore test failures in matrices
  • Use continue-on-error: true without good reason
  • Run the same workflow on every push and pull request

14. GitHub Actions Limits & Pricing

FeatureFree PlanPro/TeamEnterprise
Workflow minutes/month2,0003,00050,000
Concurrent jobs2040180
Matrix buildsUnlimitedUnlimitedUnlimited
Storage500 MB2 GB50 GB
Self-hosted runnersUnlimited freeUnlimited freeUnlimited free

Summary

GitHub Actions provides a powerful, integrated CI/CD solution:

  • Free for public repos, generous limits for private
  • Event-driven: Triggered by push, PR, schedule, or webhook
  • Flexible: Runs tests, builds, deploys, releases
  • Reusable: Community actions from GitHub Marketplace
  • Secure: Built-in secret management
  • Fast: Parallel job execution and caching
  • Observable: Detailed logs and status checks

Start simple (lint → test → build), then expand to deployment strategies (canary, blue-green) as your team grows.