name: 'Preview Environment Keep Alive' permissions: contents: read on: repository_dispatch: types: [preview-environment] jobs: preview-environment: timeout-minutes: 310 runs-on: ubuntu-latest steps: - name: Checkout PR uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: ref: ${{ github.event.client_payload.pr_head_sha }} - name: Login to Docker Hub uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Run compose setup run: | echo "Patching docker-compose.yml..." # change image to localbuild using yq yq eval 'del(.services.server.image)' -i packages/twenty-docker/docker-compose.yml yq eval '.services.server.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml yq eval '.services.server.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml yq eval 'del(.services.worker.image)' -i packages/twenty-docker/docker-compose.yml yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml yq eval '.services.worker.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml echo "Adding SIGN_IN_PREFILLED environment variable to server service..." yq eval '.services.server.environment.SIGN_IN_PREFILLED = "${SIGN_IN_PREFILLED}"' -i packages/twenty-docker/docker-compose.yml echo "Setting up .env file..." cp packages/twenty-docker/.env.example packages/twenty-docker/.env echo "Generating secrets..." echo "" >> packages/twenty-docker/.env echo "# === Randomly generated secrets ===" >> packages/twenty-docker/.env echo "APP_SECRET=$(openssl rand -base64 32)" >> packages/twenty-docker/.env echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 16)" >> packages/twenty-docker/.env echo "SIGN_IN_PREFILLED=true" >> packages/twenty-docker/.env echo "Docker compose build..." cd packages/twenty-docker/ docker compose build working-directory: ./ - name: Create Tunnel id: expose-tunnel env: CLOUDFLARED_VERSION: '2026.3.0' run: | set -euo pipefail # Install cloudflared (pinned for reproducibility) sudo curl -fsSL -o /usr/local/bin/cloudflared \ "https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64" sudo chmod +x /usr/local/bin/cloudflared cloudflared --version # Start an account-less "quick tunnel" pointing at the server container. # Cloudflare prints the assigned https://*.trycloudflare.com URL into the log. log_file="$RUNNER_TEMP/cloudflared.log" : > "$log_file" cloudflared tunnel \ --url http://localhost:3000 \ --no-autoupdate \ --logfile "$log_file" \ --loglevel info \ > "$RUNNER_TEMP/cloudflared.stdout" 2>&1 & pid=$! echo "$pid" > "$RUNNER_TEMP/cloudflared.pid" echo "cloudflared PID: $pid" # Wait up to 2 minutes for the URL to appear; fail fast if cloudflared exits. url='' for _ in $(seq 1 60); do url=$(grep -oE 'https://[a-zA-Z0-9-]+\.trycloudflare\.com' "$log_file" 2>/dev/null | head -n1 || true) [ -n "$url" ] && break if ! kill -0 "$pid" 2>/dev/null; then echo "cloudflared exited before producing a URL" cat "$log_file" || true exit 1 fi sleep 2 done if [ -z "$url" ]; then echo "Timed out waiting for tunnel URL" cat "$log_file" || true exit 1 fi echo "Tunnel URL: $url" echo "tunnel-url=$url" >> "$GITHUB_OUTPUT" - name: Start services with correct SERVER_URL env: TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }} run: | cd packages/twenty-docker/ echo "Setting SERVER_URL to $TUNNEL_URL" sed -i '/SERVER_URL=/d' .env echo "" >> .env echo "SERVER_URL=$TUNNEL_URL" >> .env # Start the services echo "Docker compose up..." docker compose up -d || { echo "Docker compose failed to start" docker compose logs exit 1 } echo "Waiting for services to be ready..." count=0 while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-db-1) = "healthy" ] || [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do sleep 5 count=$((count+1)) if [ $count -gt 60 ]; then echo "Timeout waiting for services to be ready" docker compose logs exit 1 fi echo "Still waiting for services... ($count/60)" done echo "All services are up and running!" working-directory: ./ - name: Seed Dev Workspace run: | cd packages/twenty-docker/ echo "Seeding light dev workspace (Apple only)..." if ! docker compose exec -T server yarn command:prod workspace:seed:dev --light; then echo "❌ Seeding light dev workspace failed. Dumping server logs..." docker compose logs server exit 1 fi working-directory: ./ - name: Output tunnel URL env: TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }} run: | echo "✅ Preview Environment Ready!" echo "🔗 Preview URL: $TUNNEL_URL" echo "⏱️ This environment will be available for 5 hours" echo "## 🚀 Preview Environment Ready!" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "Preview URL: $TUNNEL_URL" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "This environment will automatically shut down after 5 hours." >> "$GITHUB_STEP_SUMMARY" echo "$TUNNEL_URL" > tunnel-url.txt - name: Upload tunnel URL artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: tunnel-url path: tunnel-url.txt retention-days: 1 - name: Keep tunnel alive for 5 hours run: timeout 300m sleep 18000 # Stop on whichever we reach first (300m or 5hour sleep) - name: Cleanup if: always() run: | if [ -f "$RUNNER_TEMP/cloudflared.pid" ]; then kill "$(cat "$RUNNER_TEMP/cloudflared.pid")" 2>/dev/null || true fi cd packages/twenty-docker/ docker compose down -v working-directory: ./