feat(den): align app cloud worker flow with landing page #43
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Deploy Den | |
| on: | |
| push: | |
| branches: | |
| - dev | |
| paths: | |
| - "services/den/**" | |
| - "services/den-worker-runtime/**" | |
| - ".github/workflows/deploy-den.yml" | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: deploy-den-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| deploy: | |
| runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| if: github.repository == 'different-ai/openwork' | |
| steps: | |
| - name: Validate required secrets | |
| env: | |
| RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} | |
| RENDER_DEN_CONTROL_PLANE_SERVICE_ID: ${{ secrets.RENDER_DEN_CONTROL_PLANE_SERVICE_ID }} | |
| RENDER_OWNER_ID: ${{ secrets.RENDER_OWNER_ID }} | |
| DEN_DATABASE_URL: ${{ secrets.DEN_DATABASE_URL }} | |
| DEN_BETTER_AUTH_SECRET: ${{ secrets.DEN_BETTER_AUTH_SECRET }} | |
| DEN_GITHUB_CLIENT_ID: ${{ secrets.DEN_GITHUB_CLIENT_ID }} | |
| DEN_GITHUB_CLIENT_SECRET: ${{ secrets.DEN_GITHUB_CLIENT_SECRET }} | |
| DEN_GOOGLE_CLIENT_ID: ${{ secrets.DEN_GOOGLE_CLIENT_ID }} | |
| DEN_GOOGLE_CLIENT_SECRET: ${{ secrets.DEN_GOOGLE_CLIENT_SECRET }} | |
| POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_ACCESS_TOKEN }} | |
| POLAR_PRODUCT_ID: ${{ secrets.POLAR_PRODUCT_ID }} | |
| POLAR_BENEFIT_ID: ${{ secrets.POLAR_BENEFIT_ID }} | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| DEN_RENDER_WORKER_PUBLIC_DOMAIN_SUFFIX: ${{ vars.DEN_RENDER_WORKER_PUBLIC_DOMAIN_SUFFIX }} | |
| DEN_POLAR_FEATURE_GATE_ENABLED: ${{ vars.DEN_POLAR_FEATURE_GATE_ENABLED }} | |
| run: | | |
| missing=0 | |
| for key in RENDER_API_KEY RENDER_DEN_CONTROL_PLANE_SERVICE_ID RENDER_OWNER_ID DEN_DATABASE_URL DEN_BETTER_AUTH_SECRET; do | |
| if [ -z "${!key}" ]; then | |
| echo "::error::Missing required secret: $key" | |
| missing=1 | |
| fi | |
| done | |
| vanity_suffix="${DEN_RENDER_WORKER_PUBLIC_DOMAIN_SUFFIX:-openwork.studio}" | |
| if [ -n "$vanity_suffix" ] && [ -z "$VERCEL_TOKEN" ]; then | |
| echo "::error::Missing required secret: VERCEL_TOKEN (required when vanity domains are enabled)" | |
| missing=1 | |
| fi | |
| feature_enabled="${DEN_POLAR_FEATURE_GATE_ENABLED:-false}" | |
| feature_enabled="$(echo "$feature_enabled" | tr '[:upper:]' '[:lower:]')" | |
| if [ "$feature_enabled" = "true" ]; then | |
| for key in POLAR_ACCESS_TOKEN POLAR_PRODUCT_ID POLAR_BENEFIT_ID; do | |
| if [ -z "${!key}" ]; then | |
| echo "::error::Missing required paywall secret: $key" | |
| missing=1 | |
| fi | |
| done | |
| fi | |
| if [ -n "$DEN_GITHUB_CLIENT_ID" ] && [ -z "$DEN_GITHUB_CLIENT_SECRET" ]; then | |
| echo "::error::Missing required secret: DEN_GITHUB_CLIENT_SECRET (required when DEN_GITHUB_CLIENT_ID is set)" | |
| missing=1 | |
| fi | |
| if [ -n "$DEN_GITHUB_CLIENT_SECRET" ] && [ -z "$DEN_GITHUB_CLIENT_ID" ]; then | |
| echo "::error::Missing required secret: DEN_GITHUB_CLIENT_ID (required when DEN_GITHUB_CLIENT_SECRET is set)" | |
| missing=1 | |
| fi | |
| if [ -n "$DEN_GOOGLE_CLIENT_ID" ] && [ -z "$DEN_GOOGLE_CLIENT_SECRET" ]; then | |
| echo "::error::Missing required secret: DEN_GOOGLE_CLIENT_SECRET (required when DEN_GOOGLE_CLIENT_ID is set)" | |
| missing=1 | |
| fi | |
| if [ -n "$DEN_GOOGLE_CLIENT_SECRET" ] && [ -z "$DEN_GOOGLE_CLIENT_ID" ]; then | |
| echo "::error::Missing required secret: DEN_GOOGLE_CLIENT_ID (required when DEN_GOOGLE_CLIENT_SECRET is set)" | |
| missing=1 | |
| fi | |
| if [ "$missing" -ne 0 ]; then | |
| exit 1 | |
| fi | |
| - name: Sync Render env vars and deploy latest commit | |
| env: | |
| RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} | |
| RENDER_DEN_CONTROL_PLANE_SERVICE_ID: ${{ secrets.RENDER_DEN_CONTROL_PLANE_SERVICE_ID }} | |
| RENDER_OWNER_ID: ${{ secrets.RENDER_OWNER_ID }} | |
| DEN_DATABASE_URL: ${{ secrets.DEN_DATABASE_URL }} | |
| DEN_BETTER_AUTH_SECRET: ${{ secrets.DEN_BETTER_AUTH_SECRET }} | |
| DEN_GITHUB_CLIENT_ID: ${{ secrets.DEN_GITHUB_CLIENT_ID }} | |
| DEN_GITHUB_CLIENT_SECRET: ${{ secrets.DEN_GITHUB_CLIENT_SECRET }} | |
| DEN_GOOGLE_CLIENT_ID: ${{ secrets.DEN_GOOGLE_CLIENT_ID }} | |
| DEN_GOOGLE_CLIENT_SECRET: ${{ secrets.DEN_GOOGLE_CLIENT_SECRET }} | |
| DEN_BETTER_AUTH_URL: ${{ vars.DEN_BETTER_AUTH_URL }} | |
| DEN_RENDER_WORKER_PLAN: ${{ vars.DEN_RENDER_WORKER_PLAN }} | |
| DEN_RENDER_WORKER_OPENWORK_VERSION: ${{ vars.DEN_RENDER_WORKER_OPENWORK_VERSION }} | |
| DEN_CORS_ORIGINS: ${{ vars.DEN_CORS_ORIGINS }} | |
| DEN_RENDER_WORKER_PUBLIC_DOMAIN_SUFFIX: ${{ vars.DEN_RENDER_WORKER_PUBLIC_DOMAIN_SUFFIX }} | |
| DEN_RENDER_CUSTOM_DOMAIN_READY_TIMEOUT_MS: ${{ vars.DEN_RENDER_CUSTOM_DOMAIN_READY_TIMEOUT_MS }} | |
| DEN_VERCEL_API_BASE: ${{ vars.DEN_VERCEL_API_BASE }} | |
| DEN_VERCEL_TEAM_ID: ${{ vars.DEN_VERCEL_TEAM_ID }} | |
| DEN_VERCEL_TEAM_SLUG: ${{ vars.DEN_VERCEL_TEAM_SLUG }} | |
| DEN_VERCEL_DNS_DOMAIN: ${{ vars.DEN_VERCEL_DNS_DOMAIN }} | |
| DEN_POLAR_FEATURE_GATE_ENABLED: ${{ vars.DEN_POLAR_FEATURE_GATE_ENABLED }} | |
| DEN_POLAR_API_BASE: ${{ vars.DEN_POLAR_API_BASE }} | |
| DEN_POLAR_SUCCESS_URL: ${{ vars.DEN_POLAR_SUCCESS_URL }} | |
| DEN_POLAR_RETURN_URL: ${{ vars.DEN_POLAR_RETURN_URL }} | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_ACCESS_TOKEN }} | |
| POLAR_PRODUCT_ID: ${{ secrets.POLAR_PRODUCT_ID }} | |
| POLAR_BENEFIT_ID: ${{ secrets.POLAR_BENEFIT_ID }} | |
| run: | | |
| python3 <<'PY' | |
| import json | |
| import os | |
| import time | |
| import urllib.error | |
| import urllib.parse | |
| import urllib.request | |
| api_key = os.environ["RENDER_API_KEY"] | |
| service_id = os.environ["RENDER_DEN_CONTROL_PLANE_SERVICE_ID"] | |
| owner_id = os.environ["RENDER_OWNER_ID"] | |
| openwork_version = os.environ.get("DEN_RENDER_WORKER_OPENWORK_VERSION") | |
| worker_plan = os.environ.get("DEN_RENDER_WORKER_PLAN") or "standard" | |
| configured_cors_origins = os.environ.get("DEN_CORS_ORIGINS") or "" | |
| worker_public_domain_suffix = os.environ.get("DEN_RENDER_WORKER_PUBLIC_DOMAIN_SUFFIX") or "openwork.studio" | |
| custom_domain_ready_timeout_ms = os.environ.get("DEN_RENDER_CUSTOM_DOMAIN_READY_TIMEOUT_MS") or "240000" | |
| vercel_api_base = os.environ.get("DEN_VERCEL_API_BASE") or "https://api.vercel.com" | |
| vercel_team_id = os.environ.get("DEN_VERCEL_TEAM_ID") or "" | |
| vercel_team_slug = os.environ.get("DEN_VERCEL_TEAM_SLUG") or "prologe" | |
| vercel_dns_domain = os.environ.get("DEN_VERCEL_DNS_DOMAIN") or worker_public_domain_suffix | |
| vercel_token = os.environ.get("VERCEL_TOKEN") or "" | |
| paywall_enabled = (os.environ.get("DEN_POLAR_FEATURE_GATE_ENABLED") or "false").lower() == "true" | |
| polar_api_base = os.environ.get("DEN_POLAR_API_BASE") or "https://api.polar.sh" | |
| polar_success_url = os.environ.get("DEN_POLAR_SUCCESS_URL") or "https://app.openwork.software" | |
| polar_return_url = os.environ.get("DEN_POLAR_RETURN_URL") or polar_success_url | |
| polar_access_token = os.environ.get("POLAR_ACCESS_TOKEN") or "" | |
| polar_product_id = os.environ.get("POLAR_PRODUCT_ID") or "" | |
| polar_benefit_id = os.environ.get("POLAR_BENEFIT_ID") or "" | |
| github_client_id = os.environ.get("DEN_GITHUB_CLIENT_ID") or "" | |
| github_client_secret = os.environ.get("DEN_GITHUB_CLIENT_SECRET") or "" | |
| google_client_id = os.environ.get("DEN_GOOGLE_CLIENT_ID") or "" | |
| google_client_secret = os.environ.get("DEN_GOOGLE_CLIENT_SECRET") or "" | |
| better_auth_url = os.environ.get("DEN_BETTER_AUTH_URL") or "https://app.openwork.software" | |
| if bool(github_client_id) != bool(github_client_secret): | |
| raise RuntimeError( | |
| "DEN_GITHUB_CLIENT_ID and DEN_GITHUB_CLIENT_SECRET must either both be set or both be empty" | |
| ) | |
| if bool(google_client_id) != bool(google_client_secret): | |
| raise RuntimeError( | |
| "DEN_GOOGLE_CLIENT_ID and DEN_GOOGLE_CLIENT_SECRET must either both be set or both be empty" | |
| ) | |
| def validate_redirect_url(name: str, value: str): | |
| parsed = urllib.parse.urlparse(value) | |
| if parsed.scheme not in {"http", "https"} or not parsed.netloc: | |
| raise RuntimeError(f"{name} must be an absolute http(s) URL, got: {value}") | |
| validate_redirect_url("DEN_POLAR_SUCCESS_URL", polar_success_url) | |
| validate_redirect_url("DEN_POLAR_RETURN_URL", polar_return_url) | |
| validate_redirect_url("DEN_BETTER_AUTH_URL", better_auth_url) | |
| if paywall_enabled and (not polar_access_token or not polar_product_id or not polar_benefit_id): | |
| raise RuntimeError( | |
| "DEN_POLAR_FEATURE_GATE_ENABLED=true requires POLAR_ACCESS_TOKEN, POLAR_PRODUCT_ID, and POLAR_BENEFIT_ID" | |
| ) | |
| def normalize_origin(value: str) -> str: | |
| trimmed = value.strip() | |
| if trimmed == "*": | |
| return trimmed | |
| return trimmed.rstrip("/") | |
| def build_cors_origins(raw: str, defaults: list[str]) -> str: | |
| candidates: list[str] = [] | |
| if raw.strip(): | |
| candidates.extend(raw.split(",")) | |
| candidates.extend(defaults) | |
| seen = set() | |
| normalized = [] | |
| for value in candidates: | |
| origin = normalize_origin(value) | |
| if not origin or origin in seen: | |
| continue | |
| seen.add(origin) | |
| normalized.append(origin) | |
| if not normalized: | |
| raise RuntimeError("Unable to derive CORS_ORIGINS for Den deployment") | |
| return ",".join(normalized) | |
| headers = { | |
| "Authorization": f"Bearer {api_key}", | |
| "Accept": "application/json", | |
| "Content-Type": "application/json", | |
| } | |
| def request(method: str, path: str, body=None): | |
| url = f"https://api.render.com/v1{path}" | |
| data = None | |
| if body is not None: | |
| data = json.dumps(body).encode("utf-8") | |
| req = urllib.request.Request(url, data=data, method=method, headers=headers) | |
| try: | |
| with urllib.request.urlopen(req, timeout=60) as resp: | |
| text = resp.read().decode("utf-8") | |
| return resp.status, json.loads(text) if text else None | |
| except urllib.error.HTTPError as err: | |
| text = err.read().decode("utf-8", "replace") | |
| raise RuntimeError(f"{method} {path} failed ({err.code}): {text[:600]}") | |
| _, service = request("GET", f"/services/{service_id}") | |
| service_url = (service.get("serviceDetails") or {}).get("url") | |
| if not service_url: | |
| raise RuntimeError(f"Render service {service_id} has no public URL") | |
| cors_origins = build_cors_origins( | |
| configured_cors_origins, | |
| [ | |
| "https://app.openwork.software", | |
| "https://api.openwork.software", | |
| service_url, | |
| ], | |
| ) | |
| env_vars = [ | |
| {"key": "DATABASE_URL", "value": os.environ["DEN_DATABASE_URL"]}, | |
| {"key": "BETTER_AUTH_SECRET", "value": os.environ["DEN_BETTER_AUTH_SECRET"]}, | |
| {"key": "BETTER_AUTH_URL", "value": better_auth_url}, | |
| {"key": "GITHUB_CLIENT_ID", "value": github_client_id}, | |
| {"key": "GITHUB_CLIENT_SECRET", "value": github_client_secret}, | |
| {"key": "GOOGLE_CLIENT_ID", "value": google_client_id}, | |
| {"key": "GOOGLE_CLIENT_SECRET", "value": google_client_secret}, | |
| {"key": "CORS_ORIGINS", "value": cors_origins}, | |
| {"key": "PROVISIONER_MODE", "value": "render"}, | |
| {"key": "RENDER_API_BASE", "value": "https://api.render.com/v1"}, | |
| {"key": "RENDER_API_KEY", "value": api_key}, | |
| {"key": "RENDER_OWNER_ID", "value": owner_id}, | |
| {"key": "RENDER_WORKER_REPO", "value": "https://github.com/different-ai/openwork"}, | |
| {"key": "RENDER_WORKER_BRANCH", "value": "dev"}, | |
| {"key": "RENDER_WORKER_ROOT_DIR", "value": "services/den-worker-runtime"}, | |
| {"key": "RENDER_WORKER_PLAN", "value": worker_plan}, | |
| {"key": "RENDER_WORKER_REGION", "value": "oregon"}, | |
| {"key": "RENDER_WORKER_NAME_PREFIX", "value": "den-worker-openwork"}, | |
| {"key": "RENDER_WORKER_PUBLIC_DOMAIN_SUFFIX", "value": worker_public_domain_suffix}, | |
| {"key": "RENDER_CUSTOM_DOMAIN_READY_TIMEOUT_MS", "value": custom_domain_ready_timeout_ms}, | |
| {"key": "RENDER_PROVISION_TIMEOUT_MS", "value": "900000"}, | |
| {"key": "RENDER_HEALTHCHECK_TIMEOUT_MS", "value": "180000"}, | |
| {"key": "RENDER_POLL_INTERVAL_MS", "value": "5000"}, | |
| {"key": "VERCEL_API_BASE", "value": vercel_api_base}, | |
| {"key": "VERCEL_TOKEN", "value": vercel_token}, | |
| {"key": "VERCEL_TEAM_ID", "value": vercel_team_id}, | |
| {"key": "VERCEL_TEAM_SLUG", "value": vercel_team_slug}, | |
| {"key": "VERCEL_DNS_DOMAIN", "value": vercel_dns_domain}, | |
| {"key": "POLAR_FEATURE_GATE_ENABLED", "value": "true" if paywall_enabled else "false"}, | |
| {"key": "POLAR_API_BASE", "value": polar_api_base}, | |
| {"key": "POLAR_ACCESS_TOKEN", "value": polar_access_token}, | |
| {"key": "POLAR_PRODUCT_ID", "value": polar_product_id}, | |
| {"key": "POLAR_BENEFIT_ID", "value": polar_benefit_id}, | |
| {"key": "POLAR_SUCCESS_URL", "value": polar_success_url}, | |
| {"key": "POLAR_RETURN_URL", "value": polar_return_url}, | |
| ] | |
| if openwork_version: | |
| env_vars.append({"key": "RENDER_WORKER_OPENWORK_VERSION", "value": openwork_version}) | |
| request("PUT", f"/services/{service_id}/env-vars", env_vars) | |
| _, deploy = request("POST", f"/services/{service_id}/deploys", {}) | |
| deploy_id = deploy.get("id") or (deploy.get("deploy") or {}).get("id") | |
| if not deploy_id: | |
| raise RuntimeError(f"Unexpected deploy response: {deploy}") | |
| terminal = {"live", "update_failed", "build_failed", "canceled"} | |
| started = time.time() | |
| while time.time() - started < 1800: | |
| _, deploys = request("GET", f"/services/{service_id}/deploys?limit=1") | |
| latest = deploys[0]["deploy"] if deploys else None | |
| if latest and latest.get("id") == deploy_id and latest.get("status") in terminal: | |
| status = latest.get("status") | |
| if status != "live": | |
| raise RuntimeError(f"Render deploy {deploy_id} ended with {status}") | |
| print(f"Render deploy {deploy_id} is live at {service_url}") | |
| break | |
| time.sleep(10) | |
| else: | |
| raise RuntimeError(f"Timed out waiting for deploy {deploy_id}") | |
| PY |