From 56452302f2dac6c66c34c9b90d9b50a23e6ea7a3 Mon Sep 17 00:00:00 2001 From: Sourav Chandhok <114119117+souravC01@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:57:30 -0400 Subject: [PATCH] ci: deploy backend to Cloud Run with keyless authentication --- .dockerignore | 1 + .github/workflows/ci.yml | 124 ++++++++++++++++++++++++++++---- .gitignore | 1 + docs/cloud-run-cicd.md | 86 ++++++++++++++++++++++ scripts/setup-cloud-run-cicd.sh | 56 +++++++++++++++ 5 files changed, 255 insertions(+), 13 deletions(-) create mode 100644 docs/cloud-run-cicd.md create mode 100644 scripts/setup-cloud-run-cicd.sh diff --git a/.dockerignore b/.dockerignore index 5aeaaae..5762b5f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,3 +21,4 @@ scratch .DS_Store Thumbs.db *.log +gha-creds-*.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fa4e61..4cfed15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,9 +15,49 @@ name: CI/CD Pipeline concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + queue: ${{ github.ref == 'refs/heads/main' && 'max' || 'single' }} + +permissions: + contents: read jobs: + backend-changes: + name: Detect Backend Changes + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.changes.outputs.changed }} + steps: + - name: Checkout repository history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check backend deployment paths + id: changes + env: + BEFORE_SHA: ${{ github.event.before }} + AFTER_SHA: ${{ github.sha }} + shell: bash + run: | + set -euo pipefail + if [[ "$BEFORE_SHA" == "0000000000000000000000000000000000000000" ]]; then + BEFORE_SHA=$(git hash-object -t tree /dev/null) + elif ! git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then + git fetch --no-tags origin "$BEFORE_SHA" + fi + if git diff --quiet "$BEFORE_SHA" "$AFTER_SHA" -- \ + backend/ requirements.txt requirements/ Dockerfile.cloudrun .github/workflows/ci.yml; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + result=$? + if [[ "$result" -ne 1 ]]; then + exit "$result" + fi + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + backend-ci: name: Backend Tests & Validation runs-on: ubuntu-latest @@ -115,6 +155,14 @@ jobs: push: false tags: jobhelperguru:ci-test + - name: Build Cloud Run container image + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.cloudrun + push: false + tags: jobhelperguru-backend:ci-test + ci-success: name: CI Pipeline Passed needs: [backend-ci, frontend-ci, docker-ci] @@ -123,18 +171,68 @@ jobs: - name: Validate All Required Checks Passed run: echo "All backend, frontend, and Docker validation checks passed successfully." - deploy-render: - name: Trigger Render Deployment - needs: ci-success - if: github.ref == 'refs/heads/main' && github.event_name == 'push' + deploy-cloud-run: + name: Deploy Cloud Run Backend + needs: [ci-success, backend-changes] + if: >- + github.event_name == 'push' && github.ref == 'refs/heads/main' && + needs.backend-changes.outputs.changed == 'true' runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + id-token: write + env: + IMAGE: us-east5-docker.pkg.dev/jobhelperguru/jobhelperguru/jobhelperguru-backend:${{ github.sha }} steps: - - name: Trigger Render Deploy Hook + - name: Checkout repository + uses: actions/checkout@v4 + + # Build before requesting short-lived credentials. + - name: Build backend image + run: docker build --file Dockerfile.cloudrun --tag "$IMAGE" . + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 + with: + project_id: jobhelperguru + workload_identity_provider: projects/999060759573/locations/global/workloadIdentityPools/jobhelperguru-github/providers/github-main + service_account: jobhelperguru-github-deployer@jobhelperguru.iam.gserviceaccount.com + + - name: Set up Google Cloud CLI + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3 + + - name: Push backend image run: | - if [ -z "${{ secrets.RENDER_DEPLOY_HOOK_URL }}" ]; then - echo "::warning ::RENDER_DEPLOY_HOOK_URL secret is not configured in repository secrets. Skipping deploy hook trigger." - else - echo "Triggering Render deployment..." - curl -f -s -S -X POST "${{ secrets.RENDER_DEPLOY_HOOK_URL }}" - echo "Render deploy hook triggered successfully!" - fi + gcloud auth configure-docker us-east5-docker.pkg.dev --quiet + docker push "$IMAGE" + + - name: Update Cloud Run image + run: | + gcloud run services update jobhelperguru-backend \ + --project=jobhelperguru \ + --region=us-east5 \ + --image="$IMAGE" \ + --quiet + + - name: Verify production health through Vercel + shell: python + run: | + import json + import time + import urllib.request + + url = "https://jobhelperguru.vercel.app/api/health" + for attempt in range(12): + try: + with urllib.request.urlopen(url, timeout=20) as response: + health = json.load(response) + assert health.get("status") == "ok", "Backend is not healthy" + assert health.get("database") == "postgresql", "Unexpected database" + assert health.get("object_storage") == "cloudflare_r2", "Unexpected object storage" + print("Production health verified: ok, postgresql, cloudflare_r2") + break + except Exception: + if attempt == 11: + raise + time.sleep(10) diff --git a/.gitignore b/.gitignore index 15697ce..e2ec16f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ dist/ .DS_Store scratch/ docs/superpowers/ +gha-creds-*.json diff --git a/docs/cloud-run-cicd.md b/docs/cloud-run-cicd.md new file mode 100644 index 0000000..88fb700 --- /dev/null +++ b/docs/cloud-run-cicd.md @@ -0,0 +1,86 @@ +# Backend continuous deployment + +The frontend stays on Vercel. The backend stays on the existing Cloud Run service +`jobhelperguru-backend` in project `jobhelperguru`, region `us-east5`. +The Render legacy redirect and its `render-legacy-redirect` branch are independent. +The main CI workflow no longer calls the Render deploy hook. + +## One-time Google Cloud setup + +Run `bash scripts/setup-cloud-run-cicd.sh` from an administrator's authenticated +Google Cloud Shell after reviewing the script. It checks the existing project, +Artifact Registry repository, and runtime service account before creating the +dedicated federation resources. It stops if the dedicated account or pool already +exists; inspect partial setup before resuming individual commands. + +The deployer is `jobhelperguru-github-deployer@jobhelperguru.iam.gserviceaccount.com`. +Its resource-scoped grants are: + +| Role | Resource | +| --- | --- | +| Artifact Registry Writer | `jobhelperguru` repository in `us-east5` | +| Cloud Run Developer | Existing `jobhelperguru-backend` service | +| Service Account User | Existing `jobhelperguru-runner` runtime account | + +The dedicated pool `jobhelperguru-github` and provider `github-main` accept only +GitHub OIDC tokens matching repository ID `1355476075`, owner ID `114119117`, +repository `souravC01/JobHelperGuru`, branch `refs/heads/main`, event `push`, and +workflow `souravC01/JobHelperGuru/.github/workflows/ci.yml@refs/heads/main`. +The deployer impersonation binding additionally requires the exact main-branch +OIDC subject. Pull requests, forks, other branches, manual dispatches, and other +workflow files cannot use this provider. No service-account JSON keys or new +GitHub secrets are needed. The provider identifier and account email in the +workflow are public configuration. + +## Deployment behavior + +Existing backend tests, frontend tests/build, Docker validation, and `ci-success` +remain. Docker validation also builds `Dockerfile.cloudrun` before a PR is merged. + +On pushes to `main`, native Git compares the push's before and after commits for +changes to `backend/`, `requirements.txt`, `requirements/`, `Dockerfile.cloudrun`, +or `.github/workflows/ci.yml`. This covers all commits in a push, including deleted +files. An initial branch push compares against an empty tree. An unavailable +previous commit is fetched; a comparison failure fails the detector. + +After CI succeeds and a relevant change is found, the workflow builds the backend, +requests short-lived credentials using `google-github-actions/auth`, pushes +`us-east5-docker.pkg.dev/jobhelperguru/jobhelperguru/jobhelperguru-backend:`, +and runs `gcloud run services update` with only the image, project, and region. +Building precedes authentication so a long build does not consume the credentials' +short lifetime. Credential files are excluded from Git and Docker build contexts. + +The image update preserves environment variables, secret references, scaling, +public access, traffic policy, and the runtime account. Production currently routes +100 percent of traffic to the latest revision. Main runs are not cancelled when a +new push arrives, so an in-progress deployment can finish. Main runs use GitHub's +maximum queue of 100 pending runs so a frontend-only push cannot replace a pending +backend deployment. Other branches retain cancellation of superseded CI runs. + +The final step retries `https://jobhelperguru.vercel.app/api/health` and requires +`status=ok`, `database=postgresql`, and `object_storage=cloudflare_r2`. If deployment +or health verification fails, the workflow fails and needs investigation; it does +not automatically roll back. + +## Verification and recovery + +Before merging, verify workflow syntax and all PR CI checks. After the first main +run, verify its successful deployment step and Cloud Run's latest ready revision +and image SHA, then compare the service configuration with the pre-deployment +snapshot. Health alone does not prove which image is serving traffic. + +To restore a previously verified image, an authorized operator can run: + +```bash +gcloud run services update jobhelperguru-backend \ + --project=jobhelperguru --region=us-east5 \ + --image=us-east5-docker.pkg.dev/jobhelperguru/jobhelperguru/jobhelperguru-backend:PREVIOUS_SHA +``` + +Use an existing verified SHA from Artifact Registry. To stop future backend +deployments, disable this federation provider or revert the deployment job. Do not +restore the Render deploy hook. Follow the same Vercel health check after recovery. + +References: [Google federation setup](https://cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines), +[Cloud Run deployment roles](https://cloud.google.com/run/docs/deploying), and +[Google authentication action](https://github.com/google-github-actions/auth). diff --git a/scripts/setup-cloud-run-cicd.sh b/scripts/setup-cloud-run-cicd.sh new file mode 100644 index 0000000..c980722 --- /dev/null +++ b/scripts/setup-cloud-run-cicd.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# One-time bootstrap. Run as a project administrator in Google Cloud Shell. +set -euo pipefail + +project=jobhelperguru +project_number=999060759573 +region=us-east5 +pool=jobhelperguru-github +provider=github-main +deployer=jobhelperguru-github-deployer@jobhelperguru.iam.gserviceaccount.com +runtime=jobhelperguru-runner@jobhelperguru.iam.gserviceaccount.com + +actual_number=$(gcloud projects describe "$project" --format='value(projectNumber)') +[[ "$actual_number" == "$project_number" ]] || { echo 'Unexpected project number'; exit 1; } +actual_runtime=$(gcloud run services describe jobhelperguru-backend \ + --project="$project" --region="$region" --format='value(spec.template.spec.serviceAccountName)') +[[ "$actual_runtime" == "$runtime" ]] || { echo 'Unexpected runtime service account'; exit 1; } +gcloud artifacts repositories describe jobhelperguru --project="$project" --location="$region" --format='value(name)' + +gcloud services enable iam.googleapis.com iamcredentials.googleapis.com sts.googleapis.com --project="$project" + +# Creation fails closed if these dedicated resources already exist. Inspect any +# partial setup before resuming instead of replacing an existing trust policy. +gcloud iam service-accounts create jobhelperguru-github-deployer \ + --project="$project" --display-name='JobHelperGuru GitHub deployer' +gcloud iam workload-identity-pools create "$pool" \ + --project="$project" --location=global --display-name='JobHelperGuru GitHub' + +# Numeric IDs prevent a renamed or recreated repository or owner from inheriting +# access. Also require the exact repository, branch, event, and workflow file. +condition="assertion.repository_id == '1355476075' && assertion.repository_owner_id == '114119117' && assertion.repository == 'souravC01/JobHelperGuru' && assertion.ref == 'refs/heads/main' && assertion.event_name == 'push' && assertion.workflow_ref == 'souravC01/JobHelperGuru/.github/workflows/ci.yml@refs/heads/main' && assertion.sub == 'repo:souravC01/JobHelperGuru:ref:refs/heads/main'" +gcloud iam workload-identity-pools providers create-oidc "$provider" \ + --project="$project" --location=global --workload-identity-pool="$pool" \ + --display-name='GitHub main CI workflow' \ + --issuer-uri='https://token.actions.githubusercontent.com' \ + --attribute-mapping='google.subject=assertion.sub,attribute.repository_id=assertion.repository_id,attribute.repository_owner_id=assertion.repository_owner_id' \ + --attribute-condition="$condition" + +gcloud iam service-accounts add-iam-policy-binding "$deployer" \ + --project="$project" --role=roles/iam.workloadIdentityUser \ + --member="principal://iam.googleapis.com/projects/$project_number/locations/global/workloadIdentityPools/$pool/subject/repo:souravC01/JobHelperGuru:ref:refs/heads/main" \ + --condition=None +gcloud artifacts repositories add-iam-policy-binding jobhelperguru \ + --project="$project" --location="$region" \ + --role=roles/artifactregistry.writer --member="serviceAccount:$deployer" --condition=None +gcloud run services add-iam-policy-binding jobhelperguru-backend \ + --project="$project" --region="$region" \ + --role=roles/run.developer --member="serviceAccount:$deployer" --condition=None +gcloud iam service-accounts add-iam-policy-binding "$runtime" \ + --project="$project" --role=roles/iam.serviceAccountUser \ + --member="serviceAccount:$deployer" --condition=None + +echo 'Keyless deployment identity configured. No service-account keys were created.' +gcloud iam workload-identity-pools providers describe "$provider" \ + --project="$project" --location=global --workload-identity-pool="$pool" \ + --format='yaml(name,state,attributeMapping,attributeCondition,oidc)'