name: Deploy Den v2 on: push: branches: - dev paths: - "services/den-v2/**" - "packages/den-db/**" - "packages/utils/**" - ".github/workflows/deploy-den-v2.yml" workflow_dispatch: inputs: render_service_id: description: "Optional Render service id override for test/staging deploys" required: false type: string permissions: contents: read concurrency: group: deploy-den-v2-${{ github.ref }} cancel-in-progress: true jobs: deploy: runs-on: ubuntu-latest 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: ${{ inputs.render_service_id || 'srv-d6sajsua2pns7383mis0' }} RENDER_OWNER_ID: ${{ secrets.RENDER_OWNER_ID }} DEN_DATABASE_URL: ${{ secrets.DEN_DATABASE_URL || secrets.DATABASE_URL }} DEN_DATABASE_HOST: ${{ secrets.DEN_DATABASE_HOST || secrets.DATABASE_HOST }} DEN_DATABASE_USERNAME: ${{ secrets.DEN_DATABASE_USERNAME || secrets.DATABASE_USERNAME }} DEN_DATABASE_PASSWORD: ${{ secrets.DEN_DATABASE_PASSWORD || secrets.DATABASE_PASSWORD }} 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 }} DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }} 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_PROVISIONER_MODE: ${{ vars.DEN_PROVISIONER_MODE }} DEN_RENDER_WORKER_PUBLIC_DOMAIN_SUFFIX: ${{ vars.DEN_RENDER_WORKER_PUBLIC_DOMAIN_SUFFIX }} DEN_DAYTONA_WORKER_PROXY_BASE_URL: ${{ vars.DEN_DAYTONA_WORKER_PROXY_BASE_URL }} 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_BETTER_AUTH_SECRET; do if [ -z "${!key}" ]; then echo "::error::Missing required secret: $key" missing=1 fi done if [ -z "$DEN_DATABASE_URL" ]; then for key in DEN_DATABASE_HOST DEN_DATABASE_USERNAME DEN_DATABASE_PASSWORD; do if [ -z "${!key}" ]; then echo "::error::Missing required database secret: $key (required when DEN_DATABASE_URL is not set)" missing=1 fi done fi 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:]')" provisioner_mode="${DEN_PROVISIONER_MODE:-daytona}" provisioner_mode="$(echo "$provisioner_mode" | tr '[:upper:]' '[:lower:]')" if [ "$provisioner_mode" = "daytona" ]; then if [ -z "$DAYTONA_API_KEY" ]; then echo "::error::Missing required secret: DAYTONA_API_KEY (required when DEN_PROVISIONER_MODE=daytona)" missing=1 fi if [ -z "$DEN_DAYTONA_WORKER_PROXY_BASE_URL" ]; then echo "::error::Missing required variable: DEN_DAYTONA_WORKER_PROXY_BASE_URL (required when DEN_PROVISIONER_MODE=daytona)" missing=1 fi fi 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: ${{ inputs.render_service_id || 'srv-d6sajsua2pns7383mis0' }} RENDER_OWNER_ID: ${{ secrets.RENDER_OWNER_ID }} DEN_DATABASE_URL: ${{ secrets.DEN_DATABASE_URL || secrets.DATABASE_URL }} DEN_DATABASE_HOST: ${{ secrets.DEN_DATABASE_HOST || secrets.DATABASE_HOST }} DEN_DATABASE_USERNAME: ${{ secrets.DEN_DATABASE_USERNAME || secrets.DATABASE_USERNAME }} DEN_DATABASE_PASSWORD: ${{ secrets.DEN_DATABASE_PASSWORD || secrets.DATABASE_PASSWORD }} 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 }} DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }} DEN_BETTER_AUTH_URL: ${{ vars.DEN_BETTER_AUTH_URL }} DEN_PROVISIONER_MODE: ${{ vars.DEN_PROVISIONER_MODE }} 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_DAYTONA_API_URL: ${{ vars.DEN_DAYTONA_API_URL }} DEN_DAYTONA_TARGET: ${{ vars.DEN_DAYTONA_TARGET }} DEN_DAYTONA_SNAPSHOT: ${{ vars.DEN_DAYTONA_SNAPSHOT }} DEN_DAYTONA_WORKER_PROXY_BASE_URL: ${{ vars.DEN_DAYTONA_WORKER_PROXY_BASE_URL }} DEN_DAYTONA_SIGNED_PREVIEW_EXPIRES_SECONDS: ${{ vars.DEN_DAYTONA_SIGNED_PREVIEW_EXPIRES_SECONDS }} DEN_DAYTONA_OPENWORK_VERSION: ${{ vars.DEN_DAYTONA_OPENWORK_VERSION }} 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" provisioner_mode = (os.environ.get("DEN_PROVISIONER_MODE") or "daytona").strip().lower() or "daytona" 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 "" daytona_api_url = os.environ.get("DEN_DAYTONA_API_URL") or "https://app.daytona.io/api" daytona_api_key = os.environ.get("DAYTONA_API_KEY") or "" daytona_target = os.environ.get("DEN_DAYTONA_TARGET") or "" daytona_snapshot = os.environ.get("DEN_DAYTONA_SNAPSHOT") or "" daytona_worker_proxy_base_url = os.environ.get("DEN_DAYTONA_WORKER_PROXY_BASE_URL") or "" daytona_signed_preview_expires_seconds = os.environ.get("DEN_DAYTONA_SIGNED_PREVIEW_EXPIRES_SECONDS") or "86400" daytona_openwork_version = os.environ.get("DEN_DAYTONA_OPENWORK_VERSION") 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 provisioner_mode == "daytona": if not daytona_api_key: raise RuntimeError("DEN_PROVISIONER_MODE=daytona requires DAYTONA_API_KEY") if not daytona_worker_proxy_base_url: raise RuntimeError("DEN_PROVISIONER_MODE=daytona requires DEN_DAYTONA_WORKER_PROXY_BASE_URL") validate_redirect_url("DEN_DAYTONA_WORKER_PROXY_BASE_URL", daytona_worker_proxy_base_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": "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": provisioner_mode}, {"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}, ] database_url = os.environ.get("DEN_DATABASE_URL") or "" database_host = os.environ.get("DEN_DATABASE_HOST") or "" database_username = os.environ.get("DEN_DATABASE_USERNAME") or "" database_password = os.environ.get("DEN_DATABASE_PASSWORD") or "" if database_url: env_vars.append({"key": "DATABASE_URL", "value": database_url}) else: env_vars.extend( [ {"key": "DATABASE_HOST", "value": database_host}, {"key": "DATABASE_USERNAME", "value": database_username}, {"key": "DATABASE_PASSWORD", "value": database_password}, ] ) if provisioner_mode == "daytona": env_vars.extend( [ {"key": "DAYTONA_API_URL", "value": daytona_api_url}, {"key": "DAYTONA_API_KEY", "value": daytona_api_key}, {"key": "DAYTONA_TARGET", "value": daytona_target}, {"key": "DAYTONA_SNAPSHOT", "value": daytona_snapshot}, {"key": "DAYTONA_WORKER_PROXY_BASE_URL", "value": daytona_worker_proxy_base_url}, {"key": "DAYTONA_SIGNED_PREVIEW_EXPIRES_SECONDS", "value": daytona_signed_preview_expires_seconds}, ] ) if daytona_openwork_version: env_vars.append({"key": "DAYTONA_OPENWORK_VERSION", "value": daytona_openwork_version}) 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