Skip to content

GitHub Actions

Master GitHub Actions for CI/CD automation.

Workflow Basics

Workflow File Structure

# .github/workflows/ci.yml
name: CI  # Workflow name (shown in GitHub UI)

on:  # Trigger events
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:  # Global environment variables
  NODE_VERSION: "20"
  PYTHON_VERSION: "3.12"

jobs:  # Jobs to run
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Hello"

Trigger Events

on:
  # Push to specific branches
  push:
    branches:
      - main
      - 'release/**'
    paths:
      - 'src/**'
      - '!src/**/*.md'  # Ignore markdown files

  # Pull requests
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened]

  # Scheduled runs (cron syntax)
  schedule:
    - cron: '0 0 * * *'  # Daily at midnight UTC

  # Manual trigger
  workflow_dispatch:
    inputs:
      environment:
        description: 'Environment to deploy to'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production

  # Triggered by another workflow
  workflow_call:
    inputs:
      config:
        required: true
        type: string
    secrets:
      token:
        required: true

Jobs and Steps

Job Configuration

jobs:
  build:
    name: Build Application
    runs-on: ubuntu-latest

    # Job-level environment
    environment:
      name: staging
      url: https://staging.example.com

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

    # Conditionally run
    if: github.event_name == 'push'

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

    steps:
      - uses: actions/checkout@v4

Step Types

steps:
  # Use an action
  - uses: actions/checkout@v4
    with:
      fetch-depth: 0

  # Run a command
  - run: bun install

  # Run multiple commands
  - run: |
      bun install
      bun run build
      bun test

  # Named step with condition
  - name: Deploy
    if: github.ref == 'refs/heads/main'
    run: ./deploy.sh

  # Use specific shell
  - name: Run PowerShell
    shell: pwsh
    run: Write-Host "Hello"

Job Dependencies

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Linting"

  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Testing"

  build:
    needs: [lint, test]  # Runs after lint AND test
    runs-on: ubuntu-latest
    steps:
      - run: echo "Building"

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying"

Matrix Builds

Basic Matrix

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.11", "3.12"]
        os: [ubuntu-latest, macos-latest]

    steps:
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - run: pytest

Matrix with Include/Exclude

strategy:
  matrix:
    python-version: ["3.11", "3.12"]
    os: [ubuntu-latest, macos-latest]

    # Add specific combinations
    include:
      - python-version: "3.12"
        os: ubuntu-latest
        experimental: true

    # Remove specific combinations
    exclude:
      - python-version: "3.11"
        os: macos-latest

Fail-Fast Control

strategy:
  fail-fast: false  # Continue other matrix jobs if one fails
  matrix:
    version: [10, 12, 14]

Caching

Dependency Caching

# Python with pip
- uses: actions/setup-python@v5
  with:
    python-version: '3.12'
    cache: 'pip'  # Built-in caching

# Node with Bun
- uses: oven-sh/setup-bun@v2

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

Custom Caching

- name: Cache build artifacts
  uses: actions/cache@v4
  with:
    path: |
      ~/.cache
      node_modules
      .next/cache
    key: ${{ runner.os }}-build-${{ hashFiles('**/bun.lock') }}
    restore-keys: |
      ${{ runner.os }}-build-

Docker Layer Caching

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: myapp:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

Secrets and Variables

Using Secrets

steps:
  - name: Deploy
    env:
      DATABASE_URL: ${{ secrets.DATABASE_URL }}
      API_KEY: ${{ secrets.API_KEY }}
    run: ./deploy.sh

  - name: Login to Docker
    run: |
      echo "${{ secrets.DOCKER_PASSWORD }}" | \
        docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin

Environment-Specific Secrets

jobs:
  deploy:
    environment: production  # Uses production secrets
    steps:
      - run: ./deploy.sh
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}  # production value

Variables (Non-Sensitive)

# Repository or organization variables
env:
  DEPLOYMENT_REGION: ${{ vars.DEPLOYMENT_REGION }}
  APP_NAME: ${{ vars.APP_NAME }}

Reusable Workflows

Create Reusable Workflow

# .github/workflows/reusable-build.yml
name: Reusable Build

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      node-version:
        required: false
        type: string
        default: '20'
    secrets:
      npm-token:
        required: true
    outputs:
      image-tag:
        description: "Built image tag"
        value: ${{ jobs.build.outputs.tag }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.tag.outputs.tag }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}

      - name: Build
        env:
          NPM_TOKEN: ${{ secrets.npm-token }}
        run: bun run build

      - id: tag
        run: echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT

Use Reusable Workflow

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  build:
    uses: ./.github/workflows/reusable-build.yml
    with:
      environment: production
      node-version: '20'
    secrets:
      npm-token: ${{ secrets.NPM_TOKEN }}

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying ${{ needs.build.outputs.image-tag }}"

Composite Actions

Create Composite Action

# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Setup Python and Node environment'

inputs:
  python-version:
    description: 'Python version'
    required: false
    default: '3.12'
  node-version:
    description: 'Node version'
    required: false
    default: '20'

runs:
  using: 'composite'
  steps:
    - uses: actions/setup-python@v5
      with:
        python-version: ${{ inputs.python-version }}
        cache: 'pip'

    - uses: oven-sh/setup-bun@v2

    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}

    - name: Install dependencies
      shell: bash
      run: |
        pip install -e ".[dev]"
        bun install

Use Composite Action

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

      - uses: ./.github/actions/setup-project
        with:
          python-version: '3.12'
          node-version: '20'

      - run: pytest && bun test

Services (Containers)

Database Services

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7
        ports:
          - 6379:6379

    steps:
      - uses: actions/checkout@v4

      - name: Run tests
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/testdb
          REDIS_URL: redis://localhost:6379
        run: pytest

Outputs and Artifacts

Job Outputs

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.version }}
    steps:
      - id: version
        run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying version ${{ needs.build.outputs.version }}"

Artifacts

# Upload
- uses: actions/upload-artifact@v4
  with:
    name: build-output
    path: dist/
    retention-days: 5

# Download
- uses: actions/download-artifact@v4
  with:
    name: build-output
    path: dist/

Expressions and Contexts

Contexts

steps:
  - run: |
      echo "Repository: ${{ github.repository }}"
      echo "Branch: ${{ github.ref_name }}"
      echo "SHA: ${{ github.sha }}"
      echo "Actor: ${{ github.actor }}"
      echo "Event: ${{ github.event_name }}"
      echo "Runner OS: ${{ runner.os }}"
      echo "Job status: ${{ job.status }}"

Conditional Expressions

steps:
  # Run on specific branch
  - if: github.ref == 'refs/heads/main'
    run: echo "On main"

  # Run on pull request
  - if: github.event_name == 'pull_request'
    run: echo "PR"

  # Run on success
  - if: success()
    run: echo "Previous step succeeded"

  # Run on failure
  - if: failure()
    run: echo "Previous step failed"

  # Always run (even if canceled)
  - if: always()
    run: echo "Cleanup"

  # Complex condition
  - if: |
      github.event_name == 'push' &&
      github.ref == 'refs/heads/main' &&
      !contains(github.event.head_commit.message, '[skip ci]')
    run: ./deploy.sh

Best Practices

Practice Implementation
Pin action versions uses: actions/checkout@v4
Use caching Reduce build times
Fail fast Quick checks first
Use environments Control deployments
Store secrets securely Never hardcode
Use matrix builds Test multiple configs
Limit concurrency Prevent resource waste
Add timeouts Prevent hanging jobs