Add CI/CD pipeline: Woodpecker, auto-staging / manual-prod
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
Adds .woodpecker.yml (lint + typecheck on every push, auto-deploy to a shared staging environment on every push, manual Deploy-button promotion to production on main) and env.staging blocks in both wrangler configs so staging gets its own Workers (diagrav-app-staging/diagrav-site-staging at staging-app.diagrav.com/staging.diagrav.com) rather than sharing anything with production. Staging also got its own fully separate Supabase Cloud project (own database, own Auth config reusing the same Google OAuth client with an extra redirect URI, own Resend-backed SMTP) — migrated, seeded with the public catalog, and verified end-to-end with a real sign-in through the deployed app before wiring any of this into CI. Updates deployment-plan.md's CI/CD section to match what actually got built, superseding the earlier manual-pushbutton-for-both-environments version of the plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
This commit is contained in:
@@ -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
|
||||||
+71
-69
@@ -1,11 +1,11 @@
|
|||||||
# Deployment & CI/CD planning notes
|
# Deployment & CI/CD planning notes
|
||||||
|
|
||||||
Discussion notes only — nothing here has been implemented yet. Written so a
|
Started as discussion-only notes; hosting and CI/CD are now both live (see
|
||||||
different Claude Code conversation on this repo can pick up where this one
|
their sections below for what's actually implemented vs. still open).
|
||||||
left off without re-deriving it. Nothing below is committed to as final; it's
|
Written so a different Claude Code conversation on this repo can pick up
|
||||||
a record of what was discussed and concluded, plus open decisions.
|
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
|
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)
|
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
|
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).
|
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
|
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
|
Woodpecker CI already in use elsewhere. `.woodpecker.yml` now exists at the
|
||||||
this repo.
|
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
|
**Actual flow**: any push (any branch) runs lint + typecheck, then
|
||||||
and stops → a human clicks a "Deploy" button to push to staging, and
|
auto-deploys to a shared staging environment — no button needed. Production
|
||||||
separately clicks it again to promote to production. Not fully automatic on
|
is never touched automatically; promoting to it is a manual **Deploy**
|
||||||
merge — deploys to both environments are manual/pushbutton.
|
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
|
**Two fully separate environments exist, each with its own everything** (no
|
||||||
built-in **deploy event** for exactly this. Enable **"Allow deployments"** in
|
shared data or infra between them, or with production):
|
||||||
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.:
|
|
||||||
|
|
||||||
```yaml
|
| | Staging | Production |
|
||||||
steps:
|
|---|---|---|
|
||||||
- name: deploy-staging
|
| Supabase project | `onovxozixdxpdngxafxt` ("Diagrav Staging") | `wmombshptajyrawdbroj` ("Diagrav") |
|
||||||
when:
|
| App Worker | `diagrav-app-staging` → `staging-app.diagrav.com` | `diagrav-app` → `app.diagrav.com` |
|
||||||
- event: deploy
|
| Site Worker | `diagrav-site-staging` → `staging.diagrav.com` | `diagrav-site` → `diagrav.com` |
|
||||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "staging"'
|
|
||||||
- name: deploy-prod
|
|
||||||
when:
|
|
||||||
- event: deploy
|
|
||||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
|
|
||||||
```
|
|
||||||
|
|
||||||
**What needs to exist**:
|
This uses both of Supabase Free's "2 active projects" slots — no headroom
|
||||||
1. **Two Supabase projects** — staging and prod. This exactly fills the free
|
for a third project if one's ever wanted later (see cost findings above).
|
||||||
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.
|
|
||||||
|
|
||||||
**Difficulty estimate discussed**: roughly a focused afternoon — nothing here
|
**How each piece works**:
|
||||||
fights the tooling (no containers to orchestrate, no server process to run).
|
- **Wrangler**: `wrangler.app.jsonc` / `wrangler.site.jsonc` each gained an
|
||||||
Breakdown: `.woodpecker.yml` (~1–2 hrs), second Supabase project + migration
|
`env.staging` block (own `name` + own `routes`) — `assets.directory` isn't
|
||||||
check (~30 min), frontend hosting target wiring (~30–60 min), secrets +
|
per-environment in Wrangler, which is fine, since it's rebuilt with
|
||||||
"Allow deployments" toggle (~15 min), end-to-end test/debug (~1–2 hrs).
|
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)
|
## Open decisions (not yet made)
|
||||||
|
|
||||||
- Which frontend host: SaaS (Vercel/Netlify/Cloudflare Pages) vs self-hosted
|
- Frontend host and the marketing site's placement are both **decided and
|
||||||
alongside the existing Gitea/Woodpecker infra.
|
live**, superseding the two bullets that used to be here: Cloudflare
|
||||||
- Where the marketing site (`site/`, see above) lives relative to the app —
|
Workers (assets-only, no server code) for both, subdomain split
|
||||||
leaning subdomain (`diagrav.com` + `app.diagrav.com`, per the links already
|
(`diagrav.com` marketing site + `app.diagrav.com` app), matching the
|
||||||
in `site/index.html`) over same-domain/different-path, but not finalized —
|
links already in `site/index.html`.
|
||||||
and whether it gets its own line in the eventual `.woodpecker.yml` or
|
|
||||||
piggybacks on the frontend deploy step.
|
|
||||||
- Whether/when to add diagram-snapshot retention (the storage-risk item
|
- Whether/when to add diagram-snapshot retention (the storage-risk item
|
||||||
above) — not done yet, just identified.
|
above) — not done yet, just identified.
|
||||||
- Actual `.woodpecker.yml` has not been written yet — this doc captures the
|
- The production `supabase_db_password` gap noted above.
|
||||||
plan/shape only, per the user's request to keep this conversation
|
|
||||||
discussion-only and not make repo changes here.
|
|
||||||
|
|||||||
+20
-2
@@ -1,7 +1,9 @@
|
|||||||
// Deploys the built Vite app (dist/) as an assets-only Cloudflare Worker.
|
// 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.
|
// No server-side code — this is purely static hosting for app.diagrav.com.
|
||||||
// Build first (with production VITE_* env vars baked in), then:
|
// Build first (with the right VITE_* env vars baked in for the target
|
||||||
// npx wrangler deploy --config wrangler.app.jsonc
|
// 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",
|
"$schema": "./node_modules/wrangler/config-schema.json",
|
||||||
"name": "diagrav-app",
|
"name": "diagrav-app",
|
||||||
@@ -15,5 +17,21 @@
|
|||||||
"pattern": "app.diagrav.com",
|
"pattern": "app.diagrav.com",
|
||||||
"custom_domain": true
|
"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
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -1,7 +1,8 @@
|
|||||||
// Deploys the hand-written marketing site (site/) as an assets-only
|
// Deploys the hand-written marketing site (site/) as an assets-only
|
||||||
// Cloudflare Worker for the bare diagrav.com domain. No build step —
|
// Cloudflare Worker for the bare diagrav.com domain. No build step —
|
||||||
// serves the folder as-is:
|
// 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",
|
"$schema": "./node_modules/wrangler/config-schema.json",
|
||||||
"name": "diagrav-site",
|
"name": "diagrav-site",
|
||||||
@@ -15,5 +16,18 @@
|
|||||||
"pattern": "diagrav.com",
|
"pattern": "diagrav.com",
|
||||||
"custom_domain": true
|
"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
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user