diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 85e2ae5..553d32b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -72,6 +72,22 @@ jobs: --certificate-identity-regexp="github.com/Pyronewbic/casecomp" \ "${{ env.IMAGE }}@${{ steps.digest.outputs.digest }}" || true + - name: Generate container SBOM (Syft) + uses: anchore/sbom-action@v0 + with: + image: "${{ env.IMAGE }}@${{ steps.digest.outputs.digest }}" + format: spdx-json + output-file: /tmp/sbom.spdx.json + upload-artifact: false + + - name: Attest SBOM to image + run: | + cosign attest --yes \ + --oidc-issuer=https://token.actions.githubusercontent.com \ + --predicate /tmp/sbom.spdx.json \ + --type spdxjson \ + "${{ env.IMAGE }}@${{ steps.digest.outputs.digest }}" + - name: Attest SLSA provenance run: | cat > /tmp/provenance.json << 'PROV' diff --git a/CLAUDE.md b/CLAUDE.md index 3efd070..d6edf5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +69,7 @@ Strict palette — no deviations: - **Grade probability distribution:** `gradeDistribution` field in response (e.g. `{"8": 65, "8.5": 12, "7.5": 23}`). Computed from overall + confidence. - **Centering hint:** POST `/api/grade` accepts optional `centeringHint` with user-measured ratios, appended to centering prompts. - **Shareable reports:** `GET /api/grade/report/:id` returns PNG card (SVG→sharp→PNG) with scores, distribution, limiter. -- **ML dataset pipeline:** track-prices passively collects graded slab images (eBay sold) into `grading-dataset` Firestore collection. Parses PSA/BGS/CGC/TAG grade from listing title. `GET /api/grading-dataset/stats` (owner-only) monitors collection. +- **ML dataset pipeline:** graded slab images passively collected into `grading-dataset` Firestore collection from eBay sold (track-prices + /api/sold), magi sold (track-prices), and search sold results (/api/search). Parses PSA/BGS/CGC/TAG grade from listing title or grade label. `GET /api/grading-dataset/stats` (owner-only) monitors collection. - **Roadmap:** multi-pass median for deterministic grading, defect heatmap overlay, accuracy calibration once dataset reaches ~500 images. ## Set browser @@ -112,9 +112,9 @@ Strict palette — no deviations: - GCP: Cloud Run in asia-south1 + us-central1, Firestore (asia-south1), HTTPS LB (global, geo-routes), Cloud CDN, Secret Manager, Cloud Scheduler - Terraform: GCS state, 8 files by resource type, CI plan/apply, `for_each` over regions - **CI (ci.yml):** single workflow. Jobs: unit, api (demo-based, continue-on-error), smoke (non-blocking), codeql, scan (SBOM+Grype), audit (npm+lockfile-lint), secrets (gitleaks). Required: unit + codeql. -- **Deploy:** Kaniko v1.23.2 --reproducible → cosign sign → SLSA provenance → deploy by digest → both regions → health check → OWASP ZAP DAST +- **Deploy:** Kaniko v1.23.2 --reproducible → cosign sign → SBOM attest (Syft SPDX from container) → SLSA provenance attest → deploy by digest → both regions → health check → OWASP ZAP DAST - **Custom Wolfi base image:** gcr.io/casecomp-495718/casecomp-node24. Built with apko. 9 smoke tests. 0 CVEs. -- **Supply chain:** Dependabot, lockfile-lint, Socket.dev, pre-commit hook (blocks .env, secrets, large files) +- **Supply chain:** SBOM + SLSA attestations on image digest, Dependabot, lockfile-lint, Socket.dev, pre-commit hook (blocks .env, secrets, large files) - **Binary Authorization:** ENFORCED on both Cloud Run services - **Secret workflow:** Add to secrets.tf → CI creates → `gcloud secrets versions add` for value. Never `gcloud secrets create`. - Secrets: EBAY_CLIENT_ID/SECRET, ANTHROPIC_API_KEY, TOGETHER_API_KEY, PSA_AUTH_TOKEN, CASECOMP_API_KEY, CASECOMP_SANDBOX_KEY, RESEND_API_KEY, CASECOMP_JWT_SECRET, GOOGLE_OAUTH_CLIENT_ID, CASECOMP_ADMIN_SUB diff --git a/README.md b/README.md index 5cb771b..d4e6100 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ curl -X POST -H "Authorization: Bearer $CASECOMP_KEY" \ - **Container signing** - Sigstore cosign keyless signing via GitHub OIDC, logged to Rekor transparency log - **Deploy by digest** - immutable image SHA, not mutable `:latest` tag -- **SBOM** - Syft SPDX JSON generated per deploy, uploaded as build artifact +- **SBOM attestation** - Syft SPDX JSON generated from built container image, cosign-attested to image digest, also uploaded as build artifact - **Vulnerability scanning** - Grype scans SBOM for CVEs, SARIF uploaded to GitHub Security tab - **SAST** - CodeQL static analysis on every PR + weekly schedule - **Binary Authorization** - GCP policy enforced on both Cloud Run services @@ -130,7 +130,7 @@ curl -X POST -H "Authorization: Bearer $CASECOMP_KEY" \ - **Multi-region** - Cloud Run in asia-south1 + us-central1, global LB auto geo-routes to nearest - **Custom base image** - Wolfi + apko Node 24 image, manual rebuild, zero CVEs by design - **RASP** - runtime request inspection for SQLi, XSS, command injection, path traversal, NoSQL injection, prototype pollution. Per-IP anomaly scoring, bot fingerprinting, Firestore event logging -- **Supply chain** - SLSA provenance, Dependabot, lockfile-lint, Socket.dev, pre-commit secret blocking +- **Supply chain** - SLSA provenance attestation, Dependabot, lockfile-lint, Socket.dev, pre-commit secret blocking ## Claude Code Skills @@ -212,12 +212,12 @@ Load unpacked from `extension/` in `chrome://extensions`. ## Tests -434 tests across three layers. CI required checks: unit + codeql. Smoke is non-blocking. +486 tests across three layers. CI required checks: unit + codeql. Smoke is non-blocking. | Suite | Count | Command | Covers | |-------|------:|---------|--------| -| **Unit** | 266 | `yarn test:unit` | Filters, grading, query builder, card identity, condition detection, image preprocessing, email alerts, portfolio ROI, CSV export, autocomplete, JWT auth, price trends | -| **API** | 97 | `yarn test:api` | Search, sold, PSA, grade, auth, admin keys, arbitrage, price history, alerts, share pages, portfolio CRUD, card view, upload-url, analytics, collection tracking | +| **Unit** | 312 | `yarn test:unit` | Filters, grading, query builder, card identity, condition detection, image preprocessing, email alerts, portfolio ROI, CSV export, autocomplete, JWT auth, price trends, RASP detection | +| **API** | 103 | `yarn test:api` | Search, sold, PSA, grade, auth, admin keys, arbitrage, price history, alerts, share pages, portfolio CRUD, card view, upload-url, analytics, collection tracking | | **Smoke** | 71 | `yarn test:smoke` | API root page, detail panel, tabs, PSA stats, arbitrage, mobile viewport, portfolio, autocomplete, search filters | ## Contributing diff --git a/api.js b/api.js index f80809a..d89f184 100644 --- a/api.js +++ b/api.js @@ -366,7 +366,10 @@ app.get("/api/search", apiAuthMiddleware, (req, res, next) => { req._errorType = result.counts.activeTotal = Object.values(result.activeByCountry).reduce((n, arr) => n + arr.length, 0); } - if (result.sold?.length) recordSoldPrices(q, result.sold, result.source).catch(() => {}); + if (result.sold?.length) { + recordSoldPrices(q, result.sold, result.source).catch(() => {}); + saveGradedImages(result.sold, result.source).catch(() => {}); + } getOrCreateCard(q, { source: result.source, lang: config.language }).catch(() => {}); trackSearchFrequency(q).catch(() => {}); res.json(result); @@ -414,6 +417,7 @@ app.get("/api/sold", apiAuthMiddleware, (req, res, next) => { req._errorType = " soldSource = soldRes.source; } + if (sold.length) saveGradedImages(sold, soldSource).catch(() => {}); res.json({ query: q, sold, soldSource, counts: { sold: sold.length } }); } catch (e) { logError(req._errorType || "api", e.message, req.originalUrl, req.requestId); @@ -2090,6 +2094,7 @@ app.post("/api/track-prices", authMiddleware, async (req, res) => { magiSold = magiRes.sold || []; if (magiSold.length) { await recordSoldPrices(card, magiSold, "magi"); + saveGradedImages(magiSold, "magi").catch(() => {}); } } catch (e) { logError("track-prices", `Magi fetch failed for "${card}": ${e.message}`, "/api/track-prices"); diff --git a/docs/internals.md b/docs/internals.md index 8eb321a..481d77d 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -26,7 +26,7 @@ lib/ card-database.js TCGdex card DB (29K EN+JP cards), set browser, rarity card-identity.js Canonical IDs, set resolution, SET_TOTAL_MAP demo.js Sample data (3 multi-source cards) - grading-dataset.js ML slab image collection from eBay sold listings + grading-dataset.js ML slab image collection from sold listings (eBay, magi, search) price-history.js Sold comp tracking + TCGPlayer seeding data/ analytics.js Request analytics (Firestore, 30d TTL) @@ -50,8 +50,8 @@ public/admin/ Admin panel (keys, stats, errors) extension/ Chrome extension: queue auto-join, drop intel terraform/ GCP infra (Cloud Run, Firestore, LB, CDN, Scheduler) test/ - unit-test.js 266 unit tests - api-test.js 97 API integration tests + unit-test.js 312 unit tests + api-test.js 103 API integration tests smoke-test.js 71 Playwright UI smoke tests ``` @@ -82,7 +82,7 @@ Both `casecomp-api` and `casecomp-site` run in asia-south1 (Mumbai) and us-centr | Artifact Registry (frontend) | us (multi-region) | `us-docker.pkg.dev`, accessible from both regions | | GCR (API) | us (multi-region) | `gcr.io`, accessible globally | -Deploy workflow: build once → cosign sign → deploy to both regions via GitHub Actions matrix (parallel, fail-fast: false). +Deploy workflow: build once → cosign sign → SBOM attest → SLSA attest → deploy to both regions via GitHub Actions matrix (parallel, fail-fast: false) → health check → ZAP DAST. ## Caching @@ -153,7 +153,7 @@ Use `--refresh` to delete all cache files before a run. | `api-analytics` | userId + ts desc | Per-user analytics | | `price-history` | cardKey + recordedAt desc | Card price history | -**ML dataset pipeline**: `track-prices` passively saves graded slab images (PSA/BGS/CGC/TAG) from eBay sold listings into `grading-dataset` Firestore collection. `GET /api/grading-dataset/stats` monitors progress. +**ML dataset pipeline**: graded slab images (PSA/BGS/CGC/TAG) are passively collected into `grading-dataset` Firestore collection from multiple sources: eBay sold (via `track-prices` and `/api/sold`), magi sold (via `track-prices`), and any search with sold results (`/api/search`). Grade is parsed from listing title or grade label. `GET /api/grading-dataset/stats` monitors collection progress. ## Security pipeline @@ -163,8 +163,8 @@ Three workflows: `ci.yml` (all checks), `deploy.yml` (build + sign + deploy), `t | Job | What | Required? | |-----|------|-----------| -| unit | 172 unit tests | Yes | -| smoke | 74 Playwright smoke tests | No (continue-on-error) | +| unit | 312 unit tests | Yes | +| smoke | 71 Playwright smoke tests | No (continue-on-error) | | codeql | SAST for JavaScript/TypeScript | Yes | | scan | SBOM (Syft) + Grype vulnerability scan | No | | audit | npm audit + lockfile-lint | No | @@ -176,7 +176,8 @@ Three workflows: `ci.yml` (all checks), `deploy.yml` (build + sign + deploy), `t |------|------| | Kaniko v1.23.2 | Build with `--reproducible`, dual tags | | Cosign sign | Keyless signing via GitHub OIDC → Sigstore Rekor | -| Cosign attest | SLSA provenance attestation | +| SBOM attest | Syft SPDX JSON from container image, cosign-attested to digest | +| SLSA attest | Provenance attestation (builder, source, commit, entrypoint) | | Deploy | Matrix deploy to asia-south1 + us-central1 | **Other tools:** diff --git a/lib/cards/grading-dataset.js b/lib/cards/grading-dataset.js index 73e4e1a..5258124 100644 --- a/lib/cards/grading-dataset.js +++ b/lib/cards/grading-dataset.js @@ -16,15 +16,16 @@ export async function saveGradedImages(items, source) { const batch = fs.batch(); for (const item of items) { - if (!item.listingGradeLabel || !item.imageUrl) continue; + if (!item.imageUrl) continue; - const gradeMatch = item.listingGradeLabel.match(/(?:PSA|BGS|CGC|TAG)\s*(\d+\.?\d*)/i); + const gradeSource = item.listingGradeLabel || item.title || ""; + const gradeMatch = gradeSource.match(/(?:PSA|BGS|CGC|TAG)\s*(\d+\.?\d*)/i); if (!gradeMatch) continue; const grade = parseFloat(gradeMatch[1]); if (grade < 1 || grade > 10) continue; - const provider = item.listingGradeLabel.match(/PSA|BGS|CGC|TAG/i)?.[0]?.toUpperCase() || "UNKNOWN"; + const provider = gradeSource.match(/PSA|BGS|CGC|TAG/i)?.[0]?.toUpperCase() || "UNKNOWN"; const docId = `${source}_${item.itemId || Date.now()}_${saved}`; batch.set(fs.collection(COLLECTION).doc(docId), { diff --git a/scripts/migrate-gcp-project.sh b/scripts/migrate-gcp-project.sh new file mode 100755 index 0000000..a8777ac --- /dev/null +++ b/scripts/migrate-gcp-project.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +set -euo pipefail + +# GCP project migration script for casecomp infrastructure. +# Migrates from casecomp-495718 to a new project with $300 free trial credits. +# Run interactively — each section prompts before executing. + +OLD_PROJECT="casecomp-495718" +OLD_PROJECT_NUMBER="129850122606" +OLD_IMAGE="gcr.io/${OLD_PROJECT}/casecomp-api" +OLD_STATE_BUCKET="casecomp-terraform-state" +DEPLOY_SA="casecomp-deploy" +REGIONS=("asia-south1" "us-central1") +SECRETS=( + EBAY_CLIENT_ID EBAY_CLIENT_SECRET ANTHROPIC_API_KEY PSA_AUTH_TOKEN + CASECOMP_API_KEY CASECOMP_SANDBOX_KEY RESEND_API_KEY CASECOMP_JWT_SECRET + GOOGLE_OAUTH_CLIENT_ID CASECOMP_ADMIN_SUB TOGETHER_API_KEY +) +GITHUB_REPO="Pyronewbic/casecomp" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log() { echo -e "${GREEN}[+]${NC} $1"; } +warn() { echo -e "${YELLOW}[!]${NC} $1"; } +err() { echo -e "${RED}[x]${NC} $1"; exit 1; } + +confirm() { + echo "" + warn "$1" + read -rp "Continue? [y/N] " ans + [[ "$ans" =~ ^[Yy]$ ]] || { echo "Skipped."; return 1; } +} + +# ── 0. Preflight ───────────────────────────────────────────── + +echo "=== Casecomp GCP Project Migration ===" +echo "" +echo "Source: ${OLD_PROJECT}" +echo "Regions: ${REGIONS[*]}" +echo "Secrets: ${#SECRETS[@]}" +echo "" + +read -rp "Enter NEW project ID: " NEW_PROJECT +[[ -z "$NEW_PROJECT" ]] && err "Project ID required" +NEW_IMAGE="gcr.io/${NEW_PROJECT}/casecomp-api" +NEW_STATE_BUCKET="${NEW_PROJECT}-terraform-state" + +echo "" +log "Target project: ${NEW_PROJECT}" +log "New image: ${NEW_IMAGE}" +log "State bucket: ${NEW_STATE_BUCKET}" + +# ── 1. Create project & link billing ───────────────────────── + +if confirm "Step 1: Create project and link billing"; then + gcloud projects create "$NEW_PROJECT" --name="Casecomp" 2>/dev/null || \ + warn "Project may already exist, continuing..." + + echo "" + echo "Available billing accounts:" + gcloud billing accounts list --format="table(name, displayName, open)" + echo "" + read -rp "Enter billing account ID (e.g. 01XXXX-XXXXXX-XXXXXX): " BILLING_ID + gcloud billing projects link "$NEW_PROJECT" --billing-account="$BILLING_ID" + log "Project created and billing linked." +fi + +gcloud config set project "$NEW_PROJECT" + +# ── 2. Enable APIs ─────────────────────────────────────────── + +if confirm "Step 2: Enable required APIs"; then + APIS=( + run.googleapis.com + compute.googleapis.com + firestore.googleapis.com + cloudbuild.googleapis.com + binaryauthorization.googleapis.com + containeranalysis.googleapis.com + secretmanager.googleapis.com + cloudscheduler.googleapis.com + monitoring.googleapis.com + iam.googleapis.com + iamcredentials.googleapis.com + ) + gcloud services enable "${APIS[@]}" --project="$NEW_PROJECT" + log "APIs enabled." +fi + +# ── 3. Terraform state bucket ──────────────────────────────── + +if confirm "Step 3: Create terraform state bucket"; then + gsutil mb -p "$NEW_PROJECT" -l asia-south1 "gs://${NEW_STATE_BUCKET}" 2>/dev/null || \ + warn "Bucket may already exist" + gsutil versioning set on "gs://${NEW_STATE_BUCKET}" + log "State bucket ready: ${NEW_STATE_BUCKET}" +fi + +# ── 4. Service account + Workload Identity for GitHub Actions ─ + +if confirm "Step 4: Create deploy service account + Workload Identity Federation"; then + SA_EMAIL="${DEPLOY_SA}@${NEW_PROJECT}.iam.gserviceaccount.com" + + gcloud iam service-accounts create "$DEPLOY_SA" \ + --display-name="Casecomp Deploy" \ + --project="$NEW_PROJECT" 2>/dev/null || warn "SA may exist" + + SA_ROLES=( + roles/run.admin + roles/iam.serviceAccountUser + roles/storage.admin + roles/cloudbuild.builds.editor + roles/secretmanager.secretAccessor + roles/binaryauthorization.attestorsEditor + roles/containeranalysis.notes.editor + roles/monitoring.editor + roles/cloudscheduler.admin + ) + + for role in "${SA_ROLES[@]}"; do + gcloud projects add-iam-policy-binding "$NEW_PROJECT" \ + --member="serviceAccount:${SA_EMAIL}" \ + --role="$role" \ + --condition=None --quiet + done + + gcloud iam workload-identity-pools create github-pool \ + --location=global \ + --display-name="GitHub Actions" \ + --project="$NEW_PROJECT" 2>/dev/null || warn "Pool may exist" + + NEW_PROJECT_NUMBER=$(gcloud projects describe "$NEW_PROJECT" --format='value(projectNumber)') + + gcloud iam workload-identity-pools providers create-oidc github-provider \ + --location=global \ + --workload-identity-pool=github-pool \ + --issuer-uri="https://token.actions.githubusercontent.com" \ + --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \ + --attribute-condition="assertion.repository=='${GITHUB_REPO}'" \ + --project="$NEW_PROJECT" 2>/dev/null || warn "Provider may exist" + + gcloud iam service-accounts add-iam-policy-binding "$SA_EMAIL" \ + --role="roles/iam.workloadIdentityUser" \ + --member="principalSet://iam.googleapis.com/projects/${NEW_PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/attribute.repository/${GITHUB_REPO}" \ + --project="$NEW_PROJECT" + + WIF_PROVIDER="projects/${NEW_PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/providers/github-provider" + log "Workload Identity Federation configured." + log "WIF provider: ${WIF_PROVIDER}" + log "Service account: ${SA_EMAIL}" +fi + +# ── 5. Secrets ─────────────────────────────────────────────── + +if confirm "Step 5: Copy secrets from old project"; then + for secret in "${SECRETS[@]}"; do + VALUE=$(gcloud secrets versions access latest --secret="$secret" --project="$OLD_PROJECT" 2>/dev/null) || { + warn "Could not read ${secret} from old project, skipping" + continue + } + + gcloud secrets create "$secret" --replication-policy=automatic --project="$NEW_PROJECT" 2>/dev/null || true + echo -n "$VALUE" | gcloud secrets versions add "$secret" --data-file=- --project="$NEW_PROJECT" + log "Copied: ${secret}" + done + + COMPUTE_SA="${NEW_PROJECT_NUMBER}-compute@developer.gserviceaccount.com" + for secret in "${SECRETS[@]}"; do + gcloud secrets add-iam-policy-binding "$secret" \ + --member="serviceAccount:${COMPUTE_SA}" \ + --role="roles/secretmanager.secretAccessor" \ + --project="$NEW_PROJECT" --quiet + done + log "All secrets copied and IAM bound." +fi + +# ── 6. Firestore ───────────────────────────────────────────── + +if confirm "Step 6: Create Firestore database"; then + gcloud firestore databases create \ + --location=asia-south1 \ + --type=firestore-native \ + --project="$NEW_PROJECT" 2>/dev/null || warn "Firestore may already exist" + log "Firestore ready." + + if confirm "Export Firestore data from old project? (takes a few minutes)"; then + EXPORT_BUCKET="gs://${OLD_PROJECT}-firestore-export" + gsutil mb -p "$OLD_PROJECT" "$EXPORT_BUCKET" 2>/dev/null || true + gcloud firestore export "$EXPORT_BUCKET/migration-$(date +%Y%m%d)" --project="$OLD_PROJECT" + + IMPORT_BUCKET="gs://${NEW_PROJECT}-firestore-import" + gsutil mb -p "$NEW_PROJECT" "$IMPORT_BUCKET" 2>/dev/null || true + gsutil -m cp -r "${EXPORT_BUCKET}/migration-$(date +%Y%m%d)/**" "${IMPORT_BUCKET}/migration/" + gcloud firestore import "${IMPORT_BUCKET}/migration/" --project="$NEW_PROJECT" + log "Firestore data imported." + fi +fi + +# ── 7. Container image ────────────────────────────────────── + +if confirm "Step 7: Copy container image to new project"; then + LATEST_DIGEST=$(gcloud container images describe "${OLD_IMAGE}:latest" \ + --format='value(image_summary.digest)' --project="$OLD_PROJECT") + + docker pull "${OLD_IMAGE}@${LATEST_DIGEST}" + docker tag "${OLD_IMAGE}@${LATEST_DIGEST}" "${NEW_IMAGE}:latest" + docker push "${NEW_IMAGE}:latest" + log "Image pushed: ${NEW_IMAGE}:latest" +fi + +# ── 8. Update terraform config ─────────────────────────────── + +if confirm "Step 8: Update terraform files for new project"; then + cd terraform/ + + sed -i.bak "s|${OLD_PROJECT}|${NEW_PROJECT}|g" variables.tf + sed -i.bak "s|${OLD_STATE_BUCKET}|${NEW_STATE_BUCKET}|g" main.tf + sed -i.bak "s|${OLD_PROJECT}|${NEW_PROJECT}|g" secrets.tf + sed -i.bak "s|${OLD_PROJECT}|${NEW_PROJECT}|g" firestore.tf + + rm -f *.bak + + warn "Remove import blocks from secrets.tf and firestore.tf — they reference old resource IDs." + warn "Review all .tf files before running terraform init." + + cd .. + log "Terraform files updated. Run:" + echo " cd terraform" + echo " terraform init -reconfigure" + echo " terraform plan -var 'alert_email=YOUR_EMAIL'" + echo " terraform apply -var 'alert_email=YOUR_EMAIL'" +fi + +# ── 9. Update GitHub Actions ───────────────────────────────── + +if confirm "Step 9: Update GitHub Actions workflows"; then + NEW_PROJECT_NUMBER=$(gcloud projects describe "$NEW_PROJECT" --format='value(projectNumber)') + WIF_PROVIDER="projects/${NEW_PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/providers/github-provider" + SA_EMAIL="${DEPLOY_SA}@${NEW_PROJECT}.iam.gserviceaccount.com" + + for wf in .github/workflows/deploy.yml .github/workflows/terraform.yml .github/workflows/base-image.yml; do + if [[ -f "$wf" ]]; then + sed -i.bak "s|${OLD_PROJECT}|${NEW_PROJECT}|g" "$wf" + sed -i.bak "s|projects/${OLD_PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/providers/github-provider|${WIF_PROVIDER}|g" "$wf" + sed -i.bak "s|${DEPLOY_SA}@${OLD_PROJECT}.iam.gserviceaccount.com|${SA_EMAIL}|g" "$wf" + rm -f "${wf}.bak" + log "Updated: ${wf}" + fi + done + + log "GitHub Actions workflows updated." +fi + +# ── 10. DNS cutover ────────────────────────────────────────── + +echo "" +warn "Step 10: DNS cutover (manual)" +echo " After terraform apply creates the new LB, get the new IP:" +echo " gcloud compute addresses describe cardscrapebot-ip --global --project=${NEW_PROJECT}" +echo "" +echo " Update Cloudflare DNS:" +echo " api.casecomp.xyz → new IP (A record, proxied)" +echo " casecomp.xyz → new IP (A record, proxied)" +echo "" +echo " Wait for SSL certificates to provision (~15 min)." + +# ── Summary ────────────────────────────────────────────────── + +echo "" +echo "=== Migration Summary ===" +echo "Old: ${OLD_PROJECT} (${OLD_PROJECT_NUMBER})" +echo "New: ${NEW_PROJECT}" +echo "" +echo "Remaining manual steps:" +echo " 1. Remove import blocks from terraform (secrets.tf, firestore.tf)" +echo " 2. terraform init -reconfigure && terraform plan && terraform apply" +echo " 3. Verify Cloud Run services are healthy" +echo " 4. Update Cloudflare DNS to new LB IP" +echo " 5. Wait for managed SSL certs" +echo " 6. Update Google OAuth redirect URIs in Google Cloud Console" +echo " 7. Verify GitHub Actions deploy works (push to main)" +echo " 8. Decommission old project when stable"