Every time you push code and spend the next ten minutes manually running tests and deploying by hand, you’re doing work a machine could do for you. GitHub Actions lets you define exactly that automation in YAML files that live in your repository — no external CI service required. This guide walks through building a real pipeline that tests on every pull request and deploys on every merge to main.

How GitHub Actions Works

Actions are triggered by events (push, pull_request, schedule, etc.) and run workflows — YAML files in .github/workflows/. Each workflow has jobs, each job runs on a runner (a fresh VM), and each job has steps that run sequentially.

Event → Workflow → Jobs (parallel by default) → Steps (sequential)

A Basic CI Workflow

Create .github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

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

      - name: Run tests
        run: pytest --tb=short -q

      - name: Lint
        run: ruff check .

Push this file and GitHub immediately starts running the workflow. Every subsequent pull request will show a green or red status check before you can merge.

Caching Dependencies

By default, every run reinstalls dependencies from scratch. Caching the pip download cache cuts minutes off each run:

      - name: Cache pip packages
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: $-pip-$
          restore-keys: |
            $-pip-

The cache key includes a hash of requirements.txt, so the cache is invalidated whenever dependencies change.

Matrix Builds: Test Across Multiple Versions

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

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python $
        uses: actions/setup-python@v5
        with:
          python-version: $

      - name: Install and test
        run: |
          pip install -r requirements.txt
          pytest

This runs three parallel jobs — one per Python version — and reports them all in the pull request.

A Deployment Workflow

Separation of concerns: the CI workflow runs on every PR, the deploy workflow runs only on pushes to main. Create .github/workflows/deploy.yml:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production

    steps:
      - uses: actions/checkout@v4

      - name: Build Docker image
        run: docker build -t myapp:$ .

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: $
          password: $

      - name: Push image
        run: |
          docker tag myapp:$ myuser/myapp:latest
          docker push myuser/myapp:latest

      - name: Deploy to server
        uses: appleboy/ssh-action@v1
        with:
          host: $
          username: $
          key: $
          script: |
            docker pull myuser/myapp:latest
            docker compose up -d --no-deps api

Managing Secrets

Never hardcode credentials in workflow files. Store them in Settings → Secrets and variables → Actions and reference them as $. Secret values are masked in logs.

For per-environment secrets (staging vs production), use Environments (environment: production in the job), which lets you require manual approval before a deployment job runs.

Useful Workflow Patterns

Run a job only on specific file changes:

on:
  push:
    paths:
      - "src/**"
      - "requirements.txt"

Share data between jobs:

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image_tag: $
    steps:
      - id: tag
        run: echo "tag=$" >> $GITHUB_OUTPUT

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

Conditional steps:

      - name: Notify Slack on failure
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          payload: '{"text":"Deploy failed on $"}'
        env:
          SLACK_WEBHOOK_URL: $

Conclusion

GitHub Actions gives you a full CI/CD pipeline without leaving your repository. The CI workflow on pull requests catches regressions before they merge, the deployment workflow automates what would otherwise be a manual, error-prone release process, and secrets management keeps credentials out of your codebase. Start with a simple test job and add deployment steps incrementally — a working pipeline you understand beats a sophisticated one that nobody maintains.