Deployment control plane & secretless CI/CD (admin portal)
Date: 2026-06-25 · Status: Accepted (direction) — phased; Phase 0 actionable now, the bespoke portal deferred to Phase 2. Nothing built yet.
Related: docs/decisions/2026-06-21-developer-portal-hosting-and-auth.md (the portal/Caddy→Cloudflare move), the marketsite (now [email protected]:RewHaven/marketsite.git, a CF Worker), the app's lib/app/configurations/production.dart build-time config (SENTRY_DSN, SUPABASE_URL, SUPABASE_ANON_KEY, MIXPANEL_TOKEN).
Context
We want an admin portal that can deploy Rewhaven across its three delivery surfaces — Cloudflare (the marketsite Worker; later the developer portal), Google Play (the Flutter Android app), and Supabase (migrations, edge functions, secret/key rotation) — and expose that capability to Claude Code via an MCP / agentic interface.
Two hard constraints frame the design:
- No long-lived secrets in CI/CD.
- No long-lived secrets in local agents (the laptop / Claude Code env).
- The portal may hold a tool to rotate secrets, but never persists a secret anywhere except the secrets manager — not in a DB, not in app state, not in a file. It orchestrates rotation through the manager; it does not keep a copy.
Deploys today are local bash scripts (scripts/deploy-*.sh) that build a
Flutter web bundle and serve it behind a host Caddy on DuckDNS subdomains. CI
(.github/workflows/ci.yml) does build/test/codegen/analyze only — zero deploy
steps. There is no Play release pipeline, no Worker deploy in this repo, and no
key-rotation automation. So this ADR records a target direction, not a
migration of something already wired.
Decision
Split the system into two planes; neither stores a long-lived cloud secret.
| Plane | Role | Holds |
|---|---|---|
| Control plane — the portal / MCP | authenticate caller → enforce RBAC → trigger a pipeline → monitor → offer rollback → audit | only its own federated identity + read/trigger rights. No CF / Play / Supabase secret. |
| Execution plane — CI/CD | actually runs the deploy | short-lived creds minted per run via OIDC, or none (provider-side git CI). No stored secret. |
The agent (Claude Code) operates at the control plane, behind RBAC. It can ask to deploy or roll back, but never touches a cloud credential; the irreversible step runs in the execution plane with a token that lives ~minutes.
The portal does not execute deploys — it kicks off the (secretless) CI/CD, watches it, and can trigger rollback. This is what removes the "credential honeypot" objection: the portal is an orchestrator, not a vault.
Secret/key rotation (the explicit constraint)
- The portal exposes a
rotate-secrettool. It reads/writes only via the secrets-manager API (GCP Secret Manager / Cloudflare Secrets Store / Vault — TBD). The plaintext secret never lands in the portal's DB, logs, or memory beyond the in-flight call. - Rotation is sequenced and verified:
create new version → migrate every consumer → verify health → revoke old. Never revoke-then-hope. - Rotation is the most-gated action:
sec-adminrole + explicit human confirm. Rollback for rotation = reactivate the prior version, iff not yet revoked.
How each surface stays secretless (verified against current docs)
| Surface | Secretless execution | Rollback / monitor |
|---|---|---|
| Cloudflare | Workers Builds — push to branch, Cloudflare builds + deploys. Deploy cred lives in the CF↔repo connection; GitHub Actions / the laptop never hold a CF token. (CF has no GitHub-OIDC token federation — Workers Builds is the secretless path.) | Versioned deployments: 100 recent versions; wrangler rollback <id>, wrangler deployments status; gradual/canary 10%→100% traffic split. |
| Google Play | GitHub Actions OIDC → GCP Workload Identity Federation → impersonate a Play-publisher service account → short-lived token → fastlane / Gradle Play Publisher uploads. No service-account JSON stored anywhere. | Halt / roll back the staged-rollout track; portal reads rollout %. |
| Supabase | Supabase GitHub integration / branching deploys migrations + edge functions (cred on Supabase's side). | ⚠️ Migrations are forward-only — "rollback" = a forward-fix migration. Per-surface rollback semantics differ and the portal must model each correctly. |
Build-time config the prod app needs (SENTRY_DSN, MIXPANEL_TOKEN; the
SUPABASE_ANON_KEY is public-ish) is fetched at build time from the secrets
manager via OIDC, or classified as public config — not pasted into GitHub
Actions secrets.
Root-of-trust caveat (be honest about "no secrets")
"No secrets" means no long-lived secret in a CI config file or an agent .env.
There is always a root of trust — the OIDC trust relationship, a GitHub App key,
your human SSO. That root is one key, held in a managed KMS / secrets
manager and accessed via workload identity, rotatable and audited — not copied
into config. The threat model is honest only if this is stated.
RBAC model (why the portal exists at all)
Provider-native RBAC is coarse; the portal is the policy enforcement point. Indicative roles (each MCP tool call carries caller identity → policy check before trigger):
| Role | Can |
|---|---|
viewer | read deploy status, logs, rollout %, which secret version is active |
deployer:staging | trigger non-prod deploys / preview URLs |
releaser:prod | promote to prod, Play release, prod rollback (+ confirm) |
sec-admin:keys | rotate secrets (+ explicit confirm; most-gated) |
Irreversible ops (prod deploy, Play publish, rotation, prod rollback) require the higher role and an explicit confirmation step — agents excel at trigger/observe/propose/diagnose; the commit stays role-gated and confirmed.
Phased build (build less first; bespoke portal last)
- Phase 0 — make CI secretless (do regardless). OIDC→GCP WIF for Play,
Workers Builds for CF, Supabase git integration. Move
scripts/deploy-*.shinto these pipelines. Foundation; independently valuable. - Phase 1 — control plane v0 = GitHub Environments (≈no custom code). Required
reviewers = RBAC v0;
workflow_dispatch= trigger; deployment history + logs = monitor; the GitHub MCP lets the agent trigger/observe within those gates. - Phase 2 — the bespoke portal / MCP, only for the gap GitHub can't express: unified cross-provider rollback, custom roles, a single MCP surface across all three, and the key-rotation workflow. Holds no cloud secret — it triggers the Phase-0 pipelines and brokers rotation through the secrets manager.
Consequences
- Now: keep using the local deploy scripts; start Phase 0. No portal code.
- Foundation first: secretless CI (Phase 0) is a prerequisite for everything else and unblocks agent-driven ops via the existing CF + Supabase MCP servers.
- The portal is deferred until Phase 0/1 expose a concrete gap (cross-provider rollback / custom roles / one agent surface) — avoids building a control plane ahead of the pain.
- Per-surface rollback is not uniform — CF = version promote, Play = track halt, Supabase schema = forward-fix, Supabase secret = reactivate-if-not-revoked. The eventual portal must encode these differences, not pretend they're one verb.
- Open: which secrets manager (GCP Secret Manager vs Cloudflare Secrets Store vs Vault); where the control plane runs (a CF Worker with a binding, or a GCP workload) and thus where its single root key lives.