Skip to content

GitHub Actions - Primer

Why This Matters

Timeline: GitHub Actions launched in October 2018 (originally HCL-based syntax) and switched to YAML in August 2019. By 2023 it became the most popular CI/CD platform, overtaking Jenkins. The "Actions" marketplace has over 20,000 community-contributed actions.

GitHub Actions is the most widely used CI/CD platform, integrated directly into the world's largest code hosting service. Every push, pull request, issue, or schedule trigger can launch automated workflows — build, test, deploy, release, scan, notify. For DevOps teams, understanding GitHub Actions is not optional: it is where most open-source projects run CI, where most startups build their deployment pipelines, and where most enterprise teams are consolidating their automation. The security model (secrets, OIDC, permissions), the caching system, and the reusable workflow patterns are the difference between a fast, secure pipeline and a slow, vulnerable one.

Core Concepts

1. Workflow Syntax Fundamentals

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

on:
  push:
    branches: [main]
    paths:
      - 'src/**'
      - 'tests/**'
      - '.github/workflows/ci.yml'
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 6 * * 1'  # Monday 6am UTC
  workflow_dispatch:       # manual trigger
    inputs:
      environment:
        description: 'Target environment'
        required: true
        default: 'staging'
        type: choice
        options: [staging, production]

permissions:
  contents: read
  packages: write

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

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        run: pytest --cov=app --cov-fail-under=90 -v

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: htmlcov/
          retention-days: 7

2. Matrix Builds

jobs:
  test:
    strategy:
      fail-fast: false          # don't cancel other jobs if one fails
      matrix:
        os: [ubuntu-latest, macos-latest]
        python-version: ['3.10', '3.11', '3.12']
        exclude:
          - os: macos-latest
            python-version: '3.10'
        include:
          - os: ubuntu-latest
            python-version: '3.12'
            coverage: true       # extra variable for one combination

    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - run: pip install -r requirements.txt
      - run: pytest -v
      - if: matrix.coverage
        run: pytest --cov=app --cov-report=xml

3. Secrets and Security

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production       # requires environment approval rules
    permissions:
      id-token: write             # needed for OIDC
      contents: read
    steps:
      # Use repository secrets
      - name: Deploy
        env:
          API_KEY: ${{ secrets.DEPLOY_API_KEY }}
        run: ./deploy.sh

      # OIDC authentication to AWS (no static credentials)
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-deploy
          aws-region: us-east-1

      # Never echo secrets — they're masked in logs, but be careful
      # with indirect exposure (error messages, log files, artifacts)

Security hardening:

# Pin actions to SHA (not tags — tags can be moved)
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1

# Remember: tags are pointers that can be moved after you audit them.
# A malicious maintainer can update v4 to inject code. SHAs are immutable.

# Restrict permissions (principle of least privilege)
permissions:
  contents: read   # never give write unless needed

# Limit environment access
environment:
  name: production
  url: https://prod.example.com

War story: In January 2024, a popular third-party GitHub Action (tj-actions/changed-files) was compromised via a supply chain attack. The attacker modified the tag to inject code that exfiltrated secrets from CI runs. Organizations that pinned to SHA were unaffected. This is why security teams mandate SHA pinning for all third-party actions.

Gotcha: GitHub-hosted runners have a 6-hour maximum job timeout and 10 GB of storage. If your build produces large artifacts or runs long integration tests, you will hit these limits. Self-hosted runners have no such limits but require you to manage security (runners execute arbitrary code from PRs by default on public repos).

4. Caching and Artifacts

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

      # Cache dependencies (restored across runs)
      - uses: actions/cache@v4
        with:
          path: |
            ~/.cache/pip
            node_modules
          key: deps-${{ runner.os }}-${{ hashFiles('**/requirements.txt', '**/package-lock.json') }}
          restore-keys: |
            deps-${{ runner.os }}-

      # Upload build artifacts (shared between jobs)
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
          retention-days: 5

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      # Download artifacts from build job
      - uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/

5. Reusable Workflows

# .github/workflows/reusable-deploy.yml (the reusable workflow)
name: Deploy
on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      image_tag:
        required: true
        type: string
    secrets:
      DEPLOY_TOKEN:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to ${{ inputs.environment }}
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: |
          echo "Deploying ${{ inputs.image_tag }} to ${{ inputs.environment }}"
          helm upgrade myapp ./chart \
            --set image.tag=${{ inputs.image_tag }} \
            -f values-${{ inputs.environment }}.yaml
# .github/workflows/ci.yml (the caller)
name: CI
on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make test

  deploy-staging:
    needs: test
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: staging
      image_tag: ${{ github.sha }}
    secrets:
      DEPLOY_TOKEN: ${{ secrets.STAGING_DEPLOY_TOKEN }}

  deploy-production:
    needs: deploy-staging
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: production
      image_tag: ${{ github.sha }}
    secrets:
      DEPLOY_TOKEN: ${{ secrets.PROD_DEPLOY_TOKEN }}

6. Docker Build and Push

jobs:
  build-image:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/metadata-action@v5
        id: meta
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=semver,pattern={{version}}

      - 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

7. Composite Actions (Custom Actions)

# .github/actions/setup-and-test/action.yml
name: 'Setup and Test'
description: 'Install deps and run tests'
inputs:
  python-version:
    description: 'Python version'
    required: false
    default: '3.11'
outputs:
  coverage:
    description: 'Test coverage percentage'
    value: ${{ steps.test.outputs.coverage }}
runs:
  using: 'composite'
  steps:
    - uses: actions/setup-python@v5
      with:
        python-version: ${{ inputs.python-version }}
        cache: 'pip'
    - run: pip install -r requirements.txt
      shell: bash
    - id: test
      run: |
        COV=$(pytest --cov=app --cov-report=term | grep TOTAL | awk '{print $4}')
        echo "coverage=$COV" >> "$GITHUB_OUTPUT"
      shell: bash
# Usage in a workflow
- uses: ./.github/actions/setup-and-test
  with:
    python-version: '3.12'

8. Job Dependencies and Conditionals

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ruff check .

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pytest

  deploy:
    needs: [lint, test]                    # both must pass
    if: github.ref == 'refs/heads/main'    # only on main
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying..."

  notify:
    needs: [deploy]
    if: always()                           # run even if deploy fails
    runs-on: ubuntu-latest
    steps:
      - if: needs.deploy.result == 'failure'
        run: echo "Deploy failed!"

Quick Reference

# Trigger types
on: [push, pull_request]              # basic
on: { push: { branches: [main] } }    # filtered
on: { schedule: [{ cron: '0 0 * * *' }] }  # scheduled
on: workflow_dispatch                  # manual
on: workflow_call                      # reusable workflow

# Key expressions
${{ github.sha }}                      # commit SHA
${{ github.ref_name }}                 # branch name
${{ github.event.pull_request.number }} # PR number
${{ secrets.MY_SECRET }}               # repository secret
${{ vars.MY_VARIABLE }}                # repository variable
${{ needs.job_id.outputs.key }}        # output from another job
${{ hashFiles('**/lockfile') }}        # file content hash (for caching)

# Useful gh CLI commands for debugging
gh run list                            # list recent runs
gh run view <id>                       # view run details
gh run view <id> --log                 # view full logs
gh run rerun <id>                      # rerun a failed run
gh run watch <id>                      # live tail a running workflow

Wiki Navigation

Prerequisites