Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ scratch
.DS_Store
Thumbs.db
*.log
gha-creds-*.json
124 changes: 111 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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)
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ dist/
.DS_Store
scratch/
docs/superpowers/
gha-creds-*.json
86 changes: 86 additions & 0 deletions docs/cloud-run-cicd.md
Original file line number Diff line number Diff line change
@@ -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:<commit-sha>`,
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).
56 changes: 56 additions & 0 deletions scripts/setup-cloud-run-cicd.sh
Original file line number Diff line number Diff line change
@@ -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)'
Loading