Skip to main content

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:

  1. No long-lived secrets in CI/CD.
  2. No long-lived secrets in local agents (the laptop / Claude Code env).
  3. 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.

PlaneRoleHolds
Control plane — the portal / MCPauthenticate caller → enforce RBACtrigger a pipeline → monitor → offer rollbackauditonly its own federated identity + read/trigger rights. No CF / Play / Supabase secret.
Execution plane — CI/CDactually runs the deployshort-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-secret tool. 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-admin role + explicit human confirm. Rollback for rotation = reactivate the prior version, iff not yet revoked.

How each surface stays secretless (verified against current docs)

SurfaceSecretless executionRollback / monitor
CloudflareWorkers 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 PlayGitHub 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 %.
SupabaseSupabase 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):

RoleCan
viewerread deploy status, logs, rollout %, which secret version is active
deployer:stagingtrigger non-prod deploys / preview URLs
releaser:prodpromote to prod, Play release, prod rollback (+ confirm)
sec-admin:keysrotate 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-*.sh into 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.