diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..aeb9b30 --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,84 @@ +# See deployment-plan.md's "CI/CD plan" section for the full rationale. +# +# Shape: every push (any branch) lints, typechecks, and auto-deploys to a +# single shared staging environment (its own Cloudflare Workers + its own +# Supabase project — never shares data with production). Production is +# never touched automatically — promoting to it is a manual "Deploy" button +# click on a main-branch pipeline run in the Woodpecker UI (Woodpecker's +# deploy event), which is why deploy-production is gated on `event: deploy` +# rather than `event: push`. +# +# Debian-based node image (not Alpine) for every step: the Supabase CLI's +# downloaded binary has had musl/Alpine compatibility issues in the past. +# Woodpecker shares one workspace across all steps in a pipeline run, so +# `npm ci` in the install step is enough for every later step to reuse. + +steps: + - name: install + image: node:22-bookworm + commands: + - npm ci + + - name: lint + image: node:22-bookworm + commands: + - npm run lint + + - name: typecheck + image: node:22-bookworm + commands: + - npx tsc -b + + - name: deploy-staging + image: node:22-bookworm + when: + - event: push + environment: + VITE_SUPABASE_URL: + from_secret: staging_supabase_url + VITE_SUPABASE_ANON_KEY: + from_secret: staging_supabase_anon_key + CLOUDFLARE_API_TOKEN: + from_secret: cloudflare_api_token + CLOUDFLARE_ACCOUNT_ID: + from_secret: cloudflare_account_id + SUPABASE_ACCESS_TOKEN: + from_secret: supabase_access_token + SUPABASE_PROJECT_REF: + from_secret: staging_supabase_project_ref + SUPABASE_DB_PASSWORD: + from_secret: staging_supabase_db_password + commands: + - npm run build + - npx wrangler deploy --config wrangler.app.jsonc --env staging + - npx wrangler deploy --config wrangler.site.jsonc --env staging + - npx supabase db push --project-ref $SUPABASE_PROJECT_REF --password "$SUPABASE_DB_PASSWORD" + - npx supabase functions deploy admin-user-action --project-ref $SUPABASE_PROJECT_REF + + - name: deploy-production + image: node:22-bookworm + when: + - event: deploy + branch: main + evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"' + environment: + VITE_SUPABASE_URL: + from_secret: prod_supabase_url + VITE_SUPABASE_ANON_KEY: + from_secret: prod_supabase_anon_key + CLOUDFLARE_API_TOKEN: + from_secret: cloudflare_api_token + CLOUDFLARE_ACCOUNT_ID: + from_secret: cloudflare_account_id + SUPABASE_ACCESS_TOKEN: + from_secret: supabase_access_token + SUPABASE_PROJECT_REF: + from_secret: prod_supabase_project_ref + SUPABASE_DB_PASSWORD: + from_secret: prod_supabase_db_password + commands: + - npm run build + - npx wrangler deploy --config wrangler.app.jsonc + - npx wrangler deploy --config wrangler.site.jsonc + - npx supabase db push --project-ref $SUPABASE_PROJECT_REF --password "$SUPABASE_DB_PASSWORD" + - npx supabase functions deploy admin-user-action --project-ref $SUPABASE_PROJECT_REF diff --git a/deployment-plan.md b/deployment-plan.md index cddf6b9..cbbf59d 100644 --- a/deployment-plan.md +++ b/deployment-plan.md @@ -1,11 +1,11 @@ # Deployment & CI/CD planning notes -Discussion notes only — nothing here has been implemented yet. Written so a -different Claude Code conversation on this repo can pick up where this one -left off without re-deriving it. Nothing below is committed to as final; it's -a record of what was discussed and concluded, plus open decisions. +Started as discussion-only notes; hosting and CI/CD are now both live (see +their sections below for what's actually implemented vs. still open). +Written so a different Claude Code conversation on this repo can pick up +where this one left off without re-deriving it. -## Hosting shape (discussed, not yet implemented) +## Hosting shape — **implemented** The app is a static frontend (Vite build, no server of its own) + Supabase backend (Postgres, Auth, RLS, one Edge Function). [organized-ideas.md](organized-ideas.md) @@ -110,79 +110,81 @@ Estimated cost: **$0/mo** to start (Supabase Free + Vercel/Netlify/Cloudflare Pages free tier + Resend free tier for auth email), **$25/mo** once you want Supabase Pro (no project auto-pause, higher limits across the board). -## CI/CD plan: Gitea + Woodpecker, pushbutton staging/prod +## CI/CD: Woodpecker, auto-staging / manual-prod — **implemented** Repo is self-hosted on Gitea (`git.halfbinary.net/aarbit/av-planner`) with -Woodpecker CI already in use elsewhere. No `.woodpecker.yml` exists yet in -this repo. +Woodpecker CI already in use elsewhere. `.woodpecker.yml` now exists at the +repo root. The flow below supersedes an earlier version of this section that +proposed manual pushbutton deploys for *both* staging and production — +revisited and simplified to auto-deploy staging instead. -**Desired flow**: PR merges to `main` → pipeline runs lint/typecheck/build -and stops → a human clicks a "Deploy" button to push to staging, and -separately clicks it again to promote to production. Not fully automatic on -merge — deploys to both environments are manual/pushbutton. +**Actual flow**: any push (any branch) runs lint + typecheck, then +auto-deploys to a shared staging environment — no button needed. Production +is never touched automatically; promoting to it is a manual **Deploy** +button click (Woodpecker's built-in deploy event) on a `main`-branch +pipeline run, typing `production` as the deploy target +(`$CI_PIPELINE_DEPLOY_TARGET`). Requires **"Allow deployments"** enabled in +the repo's Woodpecker project settings. -**Mechanism (confirmed against current Woodpecker docs)**: Woodpecker has a -built-in **deploy event** for exactly this. Enable **"Allow deployments"** in -the repo's Woodpecker project settings, and any successful pipeline run gets -a **Deploy** button in the UI. Clicking it re-runs that pipeline with -`event: deploy` and a `deploy_to` string you type (e.g. `staging` or -`production`), exposed to steps as `$CI_PIPELINE_DEPLOY_TARGET`. Steps gate -on it, e.g.: +**Two fully separate environments exist, each with its own everything** (no +shared data or infra between them, or with production): -```yaml -steps: - - name: deploy-staging - when: - - event: deploy - evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "staging"' - - name: deploy-prod - when: - - event: deploy - evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"' -``` +| | Staging | Production | +|---|---|---| +| Supabase project | `onovxozixdxpdngxafxt` ("Diagrav Staging") | `wmombshptajyrawdbroj` ("Diagrav") | +| App Worker | `diagrav-app-staging` → `staging-app.diagrav.com` | `diagrav-app` → `app.diagrav.com` | +| Site Worker | `diagrav-site-staging` → `staging.diagrav.com` | `diagrav-site` → `diagrav.com` | -**What needs to exist**: -1. **Two Supabase projects** — staging and prod. This exactly fills the free - tier's "2 active projects" cap (see above) — no free-tier headroom left - for a third project if one is ever wanted later. -2. **Two frontend hosting targets** — two sites/deployments (SaaS host or - self-hosted alongside Gitea/Woodpecker — not yet decided which). -3. **Known wrinkle**: because Vite bakes `VITE_SUPABASE_URL`/`VITE_SUPABASE_ANON_KEY` - in at build time, you can't build once and promote the identical artifact - to both environments — each deploy target needs its own build with its - own env vars baked in. Not hard, just means "deploy" reruns the build - rather than reusing one artifact. A non-issue at this app's scale. -4. **Migrations run per-target** via Supabase CLI, scripted per environment: - ```bash - supabase link --project-ref $SUPABASE_PROJECT_REF - supabase db push - supabase functions deploy admin-user-action - ``` -5. **Secrets needed in Woodpecker** (scoped per target): `SUPABASE_ACCESS_TOKEN`, - staging/prod `SUPABASE_PROJECT_REF`, staging/prod `VITE_SUPABASE_URL` / - `VITE_SUPABASE_ANON_KEY`, plus whatever the frontend host needs (API - token, or SSH key if self-hosted). -6. **Google OAuth**: needs its own authorized redirect URI added in Google - Cloud console for each of the staging and prod domains — one-time setup, - easy to forget. +This uses both of Supabase Free's "2 active projects" slots — no headroom +for a third project if one's ever wanted later (see cost findings above). -**Difficulty estimate discussed**: roughly a focused afternoon — nothing here -fights the tooling (no containers to orchestrate, no server process to run). -Breakdown: `.woodpecker.yml` (~1–2 hrs), second Supabase project + migration -check (~30 min), frontend hosting target wiring (~30–60 min), secrets + -"Allow deployments" toggle (~15 min), end-to-end test/debug (~1–2 hrs). +**How each piece works**: +- **Wrangler**: `wrangler.app.jsonc` / `wrangler.site.jsonc` each gained an + `env.staging` block (own `name` + own `routes`) — `assets.directory` isn't + per-environment in Wrangler, which is fine, since it's rebuilt with + different `VITE_*` values before each deploy anyway (the "known wrinkle" + below). +- **Known wrinkle (as anticipated)**: Vite bakes `VITE_SUPABASE_URL`/ + `VITE_SUPABASE_ANON_KEY` in at build time, so `npm run build` reruns once + per target with that target's env vars set, rather than promoting one + artifact between environments. +- **Migrations**: `supabase db push --project-ref $REF --password + $SUPABASE_DB_PASSWORD` per target (no persistent `supabase link` — every + command that needs a project takes `--project-ref` directly, which also + meant this never touched the local repo's own `supabase link` state, + still pointed at production for everyday local dev). +- **Google OAuth**: staging reuses production's *same* OAuth client rather + than a new one — just one more authorized redirect URI + (`https://onovxozixdxpdngxafxt.supabase.co/auth/v1/callback`) added to it + in Google Cloud Console, since a client can authorize many redirect URIs. +- **SMTP**: staging reuses the same Resend account/verified domain as + production, with a distinct `smtp_sender_name` ("Diagrav (Staging)") so + the two are distinguishable in an inbox. +- **Staging catalog data**: seeded once by applying `supabase/seed.sql` + directly against the new project (not part of the pipeline — a one-time + setup step, same as production's original seeding). + +**Secrets in Woodpecker** (repo Settings → Secrets): `cloudflare_api_token`, +`cloudflare_account_id`, `supabase_access_token` (shared); per-target +`{staging,prod}_supabase_url`, `{staging,prod}_supabase_anon_key`, +`{staging,prod}_supabase_project_ref`, `{staging,prod}_supabase_db_password`. + +**Outstanding**: the production `supabase_db_password` secret — Supabase +doesn't expose an existing project's DB password via the Management API, so +this needs either the password from wherever it was originally saved, or a +deliberate decision to rotate it (a live production credential change, +not something to do silently). The staging path has been fully verified +end-to-end (migrations, seed, Auth, a real sign-in through the deployed +app); production's manual deploy path has not been exercised yet — no +reason to touch real prod while proving the pipeline out. ## Open decisions (not yet made) -- Which frontend host: SaaS (Vercel/Netlify/Cloudflare Pages) vs self-hosted - alongside the existing Gitea/Woodpecker infra. -- Where the marketing site (`site/`, see above) lives relative to the app — - leaning subdomain (`diagrav.com` + `app.diagrav.com`, per the links already - in `site/index.html`) over same-domain/different-path, but not finalized — - and whether it gets its own line in the eventual `.woodpecker.yml` or - piggybacks on the frontend deploy step. +- Frontend host and the marketing site's placement are both **decided and + live**, superseding the two bullets that used to be here: Cloudflare + Workers (assets-only, no server code) for both, subdomain split + (`diagrav.com` marketing site + `app.diagrav.com` app), matching the + links already in `site/index.html`. - Whether/when to add diagram-snapshot retention (the storage-risk item above) — not done yet, just identified. -- Actual `.woodpecker.yml` has not been written yet — this doc captures the - plan/shape only, per the user's request to keep this conversation - discussion-only and not make repo changes here. +- The production `supabase_db_password` gap noted above. diff --git a/wrangler.app.jsonc b/wrangler.app.jsonc index a75b6d0..fe1b4c8 100644 --- a/wrangler.app.jsonc +++ b/wrangler.app.jsonc @@ -1,7 +1,9 @@ // Deploys the built Vite app (dist/) as an assets-only Cloudflare Worker. // No server-side code — this is purely static hosting for app.diagrav.com. -// Build first (with production VITE_* env vars baked in), then: -// npx wrangler deploy --config wrangler.app.jsonc +// Build first (with the right VITE_* env vars baked in for the target +// environment — see the CI/CD section of deployment-plan.md), then: +// npx wrangler deploy --config wrangler.app.jsonc (production) +// npx wrangler deploy --config wrangler.app.jsonc --env staging { "$schema": "./node_modules/wrangler/config-schema.json", "name": "diagrav-app", @@ -15,5 +17,21 @@ "pattern": "app.diagrav.com", "custom_domain": true } - ] + ], + // Own Worker, own route, own build (with staging's VITE_SUPABASE_* baked + // in) — never shares runtime state with production. `assets.directory` + // isn't overridable per-environment in Wrangler, which is fine here: it's + // the same dist/ folder either way, just rebuilt with different env vars + // immediately before each deploy. + "env": { + "staging": { + "name": "diagrav-app-staging", + "routes": [ + { + "pattern": "staging-app.diagrav.com", + "custom_domain": true + } + ] + } + } } diff --git a/wrangler.site.jsonc b/wrangler.site.jsonc index 3c13a52..1501636 100644 --- a/wrangler.site.jsonc +++ b/wrangler.site.jsonc @@ -1,7 +1,8 @@ // Deploys the hand-written marketing site (site/) as an assets-only // Cloudflare Worker for the bare diagrav.com domain. No build step — // serves the folder as-is: -// npx wrangler deploy --config wrangler.site.jsonc +// npx wrangler deploy --config wrangler.site.jsonc (production) +// npx wrangler deploy --config wrangler.site.jsonc --env staging { "$schema": "./node_modules/wrangler/config-schema.json", "name": "diagrav-site", @@ -15,5 +16,18 @@ "pattern": "diagrav.com", "custom_domain": true } - ] + ], + // Own Worker, own route — the marketing site has no build step/env vars, + // so this is just a separate deploy target, not a separate build. + "env": { + "staging": { + "name": "diagrav-site-staging", + "routes": [ + { + "pattern": "staging.diagrav.com", + "custom_domain": true + } + ] + } + } }