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 iftestpasses
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:vssecrets:βenv:is for non-sensitive configuration you are comfortable committing to your repo.secrets:(used forGCP_WORKLOAD_IDENTITY_PROVIDERandGCP_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.
π Navigation
- 01 β GCP Project Setup
- 02 β Artifact Registry
- 03 β Cloud SQL (PostgreSQL Database)
- 04 β Secret Manager
- 05 β Cloud Storage (Media & Static Files)
- 06 β Dockerfile
- 07 β First Deploy
- 08 β Custom Domain & SSL
- 09 β Workload Identity Federation (Keyless GitHub Actions Auth)
- 10 β GitHub Actions CI/CD Pipeline (Current chapter)
- 11 β Quick Reference
- 12 β Bonus: Custom Email (@domain.cl)