Skip to content

10 β€” GitHub Actions CI/CD Pipeline

← Previous: 09 β€” Workload Identity Federation (Keyless GitHub Actions Auth)

βœ… Free for most usage. GitHub Actions gives 2,000 free minutes/month for private repositories and unlimited minutes for public repositories. Each deploy run (tests + build + push + deploy) takes roughly 5–10 minutes, giving you ~200–400 free deploys/month on a private repo. After the free tier, additional minutes cost $0.008/minute.

What is GitHub Actions?

GitHub Actions is GitHub's built-in automation platform. You define workflows in YAML files inside .github/workflows/. GitHub runs them on its own servers (called runners) in response to events β€” a push, a pull request, a schedule.

The workflow here runs on every push to main and every pull request targeting main. It has two jobs:

  • test β€” runs on all pushes and PRs; blocks the deploy if it fails
  • deploy β€” runs only on pushes to main (not on PRs); only runs if test passes

Create the workflow file

Create .github/workflows/deploy.yml at the repo root:

name: Test & Deploy

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

env:
  REGION: southamerica-east1
  IMAGE: southamerica-east1-docker.pkg.dev/mycoolproject-prod/mycoolproject-repo/app

jobs:
  # ── Job 1: test ──────────────────────────────────────────────────────────────
  # Runs on every push and PR. Blocks deploy if tests fail.
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        # Checks out your repo code onto the runner

      - uses: astral-sh/setup-uv@v4
        with:
          working-directory: web
        # Installs uv (the package manager) on the runner

      - name: Install dependencies
        run: cd web && uv sync --frozen
        # Installs all Python dependencies from uv.lock
        # --frozen: fail if lockfile is out of date

      - name: Run tests
        run: cd web && uv run manage.py test web/tests --settings=core.settings.test
        # Runs the Django test suite using SQLite in-memory (no DB needed)
        env:
          SECRET_KEY: ci-secret-not-real
          # test.py requires SECRET_KEY to be set; this dummy value is fine for tests

  # ── Job 2: deploy ────────────────────────────────────────────────────────────
  # Runs only on pushes to main, only if the test job passed.
  deploy:
    runs-on: ubuntu-latest
    needs: test                              # wait for test job to succeed
    if: github.ref == 'refs/heads/main'      # skip on pull requests

    permissions:
      contents: read
      id-token: write                        # required for Workload Identity token exchange

    steps:
      - uses: actions/checkout@v4

      # Authenticate to GCP using Workload Identity (no JSON keys)
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}

      # Install gcloud CLI on the runner
      - uses: google-github-actions/setup-gcloud@v2

      # Configure Docker to push to Artifact Registry
      - name: Configure Docker
        run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet

      # Build the Docker image with two tags:
      # - :latest (always points to newest)
      # - :<git-sha> (unique per commit β€” enables precise rollbacks)
      - name: Build image
        run: |
          docker build \
            -t ${{ env.IMAGE }}:${{ github.sha }} \
            -t ${{ env.IMAGE }}:latest \
            .

      # Push both tags to Artifact Registry
      - name: Push image
        run: docker push --all-tags ${{ env.IMAGE }}

      # Update the migrate job to use the new image, then run it
      # This applies any new database migrations before traffic hits the new code
      - name: Run migrations
        run: |
          gcloud run jobs update migrate \
            --image=${{ env.IMAGE }}:${{ github.sha }} \
            --region=${{ env.REGION }}
          gcloud run jobs execute migrate \
            --region=${{ env.REGION }} \
            --wait

      # Deploy the new image to Cloud Run
      # Cloud Run creates a new revision and shifts 100% of traffic to it
      - name: Deploy to Cloud Run
        run: |
          gcloud run deploy mycoolproject \
            --image=${{ env.IMAGE }}:${{ github.sha }} \
            --region=${{ env.REGION }}

Workflow-level env block

At the top of the workflow, before the jobs: section, there is an env: block:

env:
  REGION: southamerica-east1
  IMAGE: southamerica-east1-docker.pkg.dev/mycoolproject-prod/mycoolproject-repo/app

This defines workflow-level environment variables β€” constants available to every job and every step in the file. They are referenced later as ${{ env.REGION }} and ${{ env.IMAGE }}. The benefit is that if you ever change your region or project name, you update it in exactly one place instead of hunting through every gcloud command.

Variable Value Why it's here
REGION southamerica-east1 The GCP region where Cloud Run, Artifact Registry, and Cloud SQL all live. Repeated in every gcloud and docker command.
IMAGE southamerica-east1-docker.pkg.dev/mycoolproject-prod/mycoolproject-repo/app The full Artifact Registry path for the Docker image. Broken down: <region>-docker.pkg.dev/<project>/<repository>/<image-name>. Used when building, pushing, and deploying.

env: vs secrets: β€” env: is for non-sensitive configuration you are comfortable committing to your repo. secrets: (used for GCP_WORKLOAD_IDENTITY_PROVIDER and GCP_SERVICE_ACCOUNT) is for sensitive values stored encrypted in GitHub, never visible in logs or the repo history.


What happens on each push to main

push to main
    β”‚
    β”œβ”€β”€ test job
    β”‚     β”œβ”€β”€ checkout code
    β”‚     β”œβ”€β”€ install uv + dependencies
    β”‚     └── run Django test suite (171 tests, ~90 seconds)
    β”‚                   β”‚
    β”‚              fails? β†’ deploy job is skipped, broken code never reaches prod
    β”‚              passes? ↓
    β”‚
    └── deploy job
          β”œβ”€β”€ authenticate to GCP (Workload Identity)
          β”œβ”€β”€ docker build (uses layer cache β€” fast if deps unchanged)
          β”œβ”€β”€ docker push β†’ Artifact Registry
          β”œβ”€β”€ update migrate job image
          β”œβ”€β”€ run migrate job β†’ applies DB migrations β†’ waits for completion
          └── gcloud run deploy β†’ new Cloud Run revision goes live

What happens on a pull request

Only the test job runs. The deploy job is skipped because github.ref is not refs/heads/main. This means every PR gets its tests run, but the live site is only updated when code merges to main.


Rollback

If a bad deploy slips through, roll back to the previous revision in seconds:

# Lists recent revisions with their names and traffic split β€” find the last good revision name here.
gcloud run revisions list --service=mycoolproject --region=southamerica-east1

# Shifts 100% of traffic to a specific revision, instantly rolling back the live site.
# Replace mycoolproject-<previous-revision> with the revision name from the list command above.
# Result: the site immediately serves the old revision β€” no rebuild needed.
gcloud run services update-traffic mycoolproject \
  --region=southamerica-east1 \
  --to-revisions=mycoolproject-<previous-revision>=100

Or re-run the previous commit's GitHub Actions workflow β€” it redeploys the image tagged with that commit's SHA.