Allow cron overlap to terminate stale workflow runs #673
Workflow file for this run
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: NodeJS Frontend Preview | |
| on: | |
| pull_request: | |
| branches: [main] | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| inputs: | |
| timeout_minutes: | |
| description: 'Preview timeout in minutes' | |
| required: false | |
| default: '5' | |
| type: string | |
| jobs: | |
| preview: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 # small buffer above 10-minute tunnel lifetime | |
| env: | |
| PREVIEW_TIMEOUT_MINUTES: ${{ github.event.inputs.timeout_minutes || '5' }} | |
| NGROK_AUTHTOKEN: ${{ secrets.NGROK_AUTHTOKEN }} | |
| MADE_HOME: ${{ format('{0}/workspace/', github.workspace) }} | |
| MADE_WORKSPACE_HOME: ${{ format('{0}/workspace/', github.workspace) }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }} | |
| - name: Authenticate GitHub CLI | |
| env: | |
| GH_AUTH_TOKEN: ${{ secrets.GH_AUTH_TOKEN }} | |
| run: | | |
| echo "$GH_AUTH_TOKEN" | gh auth login --with-token | |
| gh auth status | |
| - name: Verify triggering actor is repo admin | |
| id: admin | |
| env: | |
| REPOSITORY: ${{ github.repository }} | |
| TRIGGER_ACTOR: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login || github.triggering_actor }} | |
| run: | | |
| permission=$(gh api \ | |
| -H "Accept: application/vnd.github+json" \ | |
| "/repos/$REPOSITORY/collaborators/$TRIGGER_ACTOR/permission" \ | |
| --jq '.permission // ""' 2>/dev/null || echo '') | |
| if [[ "$permission" == "admin" ]]; then | |
| echo "is_admin=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "is_admin=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Require admin privileges for preview | |
| if: ${{ steps.admin.outputs.is_admin != 'true' }} | |
| env: | |
| TRIGGER_ACTOR: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login || github.triggering_actor }} | |
| run: | | |
| echo "::error::Preview deployment requires repository admin privileges. Triggering actor: $TRIGGER_ACTOR" | |
| exit 1 | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| - name: Install jq | |
| run: sudo apt-get update && sudo apt-get install -y jq | |
| - name: Install latest ngrok CLI (v4) | |
| run: | | |
| echo "🔧 Installing official ngrok CLI (v4)..." | |
| curl -s https://ngrok-agent.s3.amazonaws.com/ngrok.asc | \ | |
| sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null | |
| echo "deb https://ngrok-agent.s3.amazonaws.com buster main" | \ | |
| sudo tee /etc/apt/sources.list.d/ngrok.list | |
| sudo apt-get update -y | |
| sudo apt-get install -y ngrok | |
| echo "✅ Installed $(ngrok version)" | |
| - name: Detect Node.js frontend directory | |
| id: detect | |
| run: | | |
| echo "🔍 Searching for Node.js frontend in this PR branch..." | |
| # Look for frontend specifically (exclude root package.json) | |
| frontend_package="" | |
| if [ -f "packages/frontend/package.json" ]; then | |
| # Check if it has vite or other frontend dev script | |
| if jq -e '.scripts.dev and (.dependencies.react or .devDependencies.vite)' packages/frontend/package.json > /dev/null 2>&1; then | |
| frontend_package="packages/frontend" | |
| fi | |
| fi | |
| if [ -z "$frontend_package" ]; then | |
| echo "::warning::No frontend package detected in packages/frontend. This is expected for backend-only changes." | |
| echo "🪄 Skipping preview deployment — no frontend present." | |
| echo "frontend_found=false" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| echo "✅ Found frontend package at: $frontend_package" | |
| echo "frontend_dir=$frontend_package" >> $GITHUB_OUTPUT | |
| echo "frontend_found=true" >> $GITHUB_OUTPUT | |
| - name: Set up Python | |
| if: ${{ steps.detect.outputs.frontend_found == 'true' }} | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| - name: Install UV | |
| if: ${{ steps.detect.outputs.frontend_found == 'true' }} | |
| run: curl -LsSf https://astral.sh/uv/install.sh | sh | |
| # ------------------------------------------------------ | |
| # Install OpenCode (binary install) | |
| # ------------------------------------------------------ | |
| - name: Install opencode | |
| if: ${{ steps.detect.outputs.frontend_found == 'true' }} | |
| run: | | |
| set -euo pipefail | |
| INSTALL_DIR="$HOME/.opencode/bin" | |
| mkdir -p "$INSTALL_DIR" | |
| TMPDIR="$(mktemp -d)" | |
| trap 'rm -rf "$TMPDIR"' EXIT | |
| ARCHIVE_PATH="$TMPDIR/opencode.tar.gz" | |
| DOWNLOAD_URL="https://github.com/anomalyco/opencode/releases/latest/download/opencode-linux-x64.tar.gz" | |
| # Robust download function with retry logic and fallback | |
| download_with_retry() { | |
| local url="$1" | |
| local output="$2" | |
| local max_attempts=3 | |
| echo "🔄 Downloading OpenCode CLI from GitHub releases..." | |
| # Try curl with retries | |
| for attempt in $(seq 1 $max_attempts); do | |
| echo "📥 Attempt $attempt/$max_attempts using curl..." | |
| if curl --connect-timeout 30 --max-time 120 --retry-connrefused \ | |
| --fail --silent --show-error --location \ | |
| "$url" -o "$output"; then | |
| echo "✅ Download successful with curl on attempt $attempt" | |
| return 0 | |
| else | |
| local exit_code=$? | |
| echo "❌ curl attempt $attempt failed (exit code: $exit_code)" | |
| if [ $attempt -lt $max_attempts ]; then | |
| local delay=$((attempt * 5)) | |
| echo "⏳ Waiting ${delay}s before retry..." | |
| sleep $delay | |
| fi | |
| fi | |
| done | |
| # Fallback to wget if curl failed | |
| echo "🔄 Trying fallback method with wget..." | |
| if wget --timeout=30 --tries=2 --waitretry=5 -q "$url" -O "$output"; then | |
| echo "✅ Download successful with wget fallback" | |
| return 0 | |
| else | |
| echo "❌ wget fallback also failed" | |
| return 1 | |
| fi | |
| } | |
| # Download with robust retry logic | |
| if ! download_with_retry "$DOWNLOAD_URL" "$ARCHIVE_PATH"; then | |
| echo "💥 Failed to download OpenCode CLI after all retry attempts and fallbacks" | |
| echo "🔍 This is likely a temporary CDN issue. Please retry the workflow." | |
| exit 1 | |
| fi | |
| # Validate download | |
| if [ ! -f "$ARCHIVE_PATH" ] || [ ! -s "$ARCHIVE_PATH" ]; then | |
| echo "❌ Downloaded file is missing or empty" | |
| exit 1 | |
| fi | |
| file_size=$(stat -f%z "$ARCHIVE_PATH" 2>/dev/null || stat -c%s "$ARCHIVE_PATH" 2>/dev/null || echo "0") | |
| if [ "$file_size" -lt 100000 ]; then | |
| echo "❌ Downloaded file seems too small ($file_size bytes), likely corrupted" | |
| exit 1 | |
| fi | |
| echo "✅ Downloaded file validated ($file_size bytes)" | |
| # Extract and install | |
| tar -xzf "$ARCHIVE_PATH" -C "$TMPDIR" | |
| install "$TMPDIR/opencode" "$INSTALL_DIR/opencode" | |
| echo "$INSTALL_DIR" >> "$GITHUB_PATH" | |
| # ------------------------------------------------------ | |
| # Configure OpenCode | |
| # ------------------------------------------------------ | |
| - name: Configure opencode | |
| run: | | |
| mkdir -p "$HOME/.config/opencode" | |
| cp .github/opencode/config.json "$HOME/.config/opencode/config.json" | |
| check_file() { | |
| local path="$1" | |
| local label="$2" | |
| if [ -s "$path" ]; then | |
| echo "✅ $label is present and non-empty ($path)" | |
| else | |
| echo "❌ $label is missing or empty ($path)" | |
| exit 1 | |
| fi | |
| } | |
| check_file "$HOME/.config/opencode/config.json" "config.json" | |
| - name: Prepare opencode skills directory | |
| if: ${{ steps.detect.outputs.frontend_found == 'true' }} | |
| run: | | |
| mkdir -p "$HOME/.opencode/skills" | |
| - name: Run opencode success check | |
| if: ${{ steps.detect.outputs.frontend_found == 'true' }} | |
| continue-on-error: true | |
| run: "opencode run 'Say: ✅ SUCCESS!'" | |
| - name: Install dependencies | |
| if: ${{ steps.detect.outputs.frontend_found == 'true' }} | |
| run: make install | |
| - name: Debug frontend dependencies | |
| if: ${{ steps.detect.outputs.frontend_found == 'true' }} | |
| run: | | |
| echo "🔍 Debugging frontend setup..." | |
| echo "Node version: $(node --version)" | |
| echo "NPM version: $(npm --version)" | |
| echo "Frontend package.json:" | |
| cat packages/frontend/package.json | |
| echo "" | |
| echo "Checking if vite is available:" | |
| cd packages/frontend && npx vite --version || echo "❌ Vite not found" | |
| echo "Checking node_modules:" | |
| ls -la packages/frontend/node_modules/.bin/ | grep vite || echo "❌ No vite binary found" | |
| - name: Start MADE services (frontend + backend) | |
| if: ${{ steps.detect.outputs.frontend_found == 'true' }} | |
| run: | | |
| echo "🚀 Starting MADE services with make run..." | |
| # Start both frontend and backend using make run | |
| nohup make run > services.log 2>&1 & | |
| SERVICES_PID=$! | |
| echo "Services started with PID $SERVICES_PID" | |
| echo "⏳ Waiting for services to become available..." | |
| # Wait a bit and check if the process is still running | |
| sleep 5 | |
| if ! kill -0 $SERVICES_PID 2>/dev/null; then | |
| echo "❌ Services process died unexpectedly!" | |
| echo "🔍 Full services.log output:" | |
| cat services.log | |
| exit 1 | |
| fi | |
| # Wait for backend API | |
| for i in {1..30}; do | |
| if curl -f http://localhost:3000/api/repositories > /dev/null 2>&1; then | |
| echo "✅ Backend API is ready on port 3000" | |
| break | |
| fi | |
| if [ $i -eq 30 ]; then | |
| echo "❌ Backend API never became ready" | |
| echo "🔍 Services log:" | |
| tail -n 30 services.log || true | |
| exit 1 | |
| fi | |
| sleep 3 | |
| done | |
| # Wait for frontend | |
| for i in {1..30}; do | |
| if nc -z localhost 5173 2>/dev/null; then | |
| echo "✅ Frontend is ready on port 5173" | |
| break | |
| fi | |
| if [ $i -eq 30 ]; then | |
| echo "❌ Frontend never became ready on port 5173" | |
| echo "🔍 Services log:" | |
| tail -n 30 services.log || true | |
| exit 1 | |
| fi | |
| sleep 2 | |
| done | |
| echo "🔍 Last few lines of services.log:" | |
| tail -n 15 services.log || true | |
| - name: Start ngrok tunnel (v4 CLI, verbose) | |
| if: ${{ steps.detect.outputs.frontend_found == 'true' }} | |
| id: ngrok | |
| env: | |
| NGROK_AUTHTOKEN: ${{ secrets.NGROK_AUTHTOKEN }} | |
| run: | | |
| echo "🌐 Establishing ngrok tunnel (v4, verbose mode enabled)..." | |
| echo "🔑 Using NGROK_AUTHTOKEN from environment (not stored on disk)." | |
| echo "🔎 Checking for active services..." | |
| frontend_ready=false | |
| backend_ready=false | |
| # Check frontend (primary target for preview) | |
| if nc -z localhost 5173; then | |
| frontend_ready=true | |
| target_port=5173 | |
| echo "✅ Frontend detected on port 5173 (primary target)" | |
| fi | |
| # Check backend API | |
| if nc -z localhost 3000; then | |
| backend_ready=true | |
| echo "✅ Backend API detected on port 3000" | |
| fi | |
| # Fallback to other common ports if frontend not found | |
| if [ "$frontend_ready" = false ]; then | |
| for port in 3000 8080; do | |
| echo " Testing fallback port $port..." | |
| if nc -z localhost $port; then | |
| target_port=$port | |
| echo "✅ Detected service on fallback port $target_port" | |
| break | |
| fi | |
| done | |
| fi | |
| if [ -z "$target_port" ]; then | |
| echo "❌ Could not detect active services on ports (5173, 3000, 8080)." | |
| echo "🪵 Showing last 20 lines of services.log:" | |
| tail -n 20 services.log || true | |
| exit 1 | |
| fi | |
| echo "🎯 Using port $target_port for preview (frontend: $frontend_ready, backend: $backend_ready)" | |
| echo "🚀 Starting ngrok for port $target_port (v4 CLI)..." | |
| ngrok version | |
| # Start ngrok v4 CLI directly with environment auth token | |
| nohup ngrok http $target_port --log=stdout > ngrok.log 2>&1 & | |
| NGROK_PID=$! | |
| echo "🧩 ngrok process started with PID $NGROK_PID" | |
| sleep 3 | |
| echo "⏳ Waiting for ngrok API (http://127.0.0.1:4040) to become available..." | |
| for i in {1..30}; do | |
| url=$(curl -s --max-time 2 http://127.0.0.1:4040/api/tunnels | grep -o 'https://[^"]*' | head -n 1 || true) | |
| if [ -n "$url" ]; then | |
| echo "✅ ngrok tunnel established successfully: $url" | |
| echo "preview_url=$url" >> $GITHUB_OUTPUT | |
| break | |
| fi | |
| echo " ...still waiting ($i/30)" | |
| # Show partial ngrok log every 5 iterations for visibility | |
| if (( i % 5 == 0 )); then | |
| echo "🔍 Partial ngrok log:" | |
| tail -n 10 ngrok.log || true | |
| fi | |
| sleep 2 | |
| done | |
| if [ -z "$url" ]; then | |
| echo "❌ ngrok tunnel failed to start within timeout." | |
| echo "🧾 Full ngrok log output for debugging:" | |
| cat ngrok.log || true | |
| echo "🧨 Killing ngrok process PID $NGROK_PID" | |
| kill $NGROK_PID || true | |
| exit 1 | |
| fi | |
| echo "🎉 ngrok setup complete and verified." | |
| - name: Send Telegram message | |
| if: ${{ steps.detect.outputs.frontend_found == 'true' && success() }} | |
| env: | |
| BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} | |
| CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} | |
| run: | | |
| REPO=${{ github.repository }} | |
| PREVIEW_URL="${{ steps.ngrok.outputs.preview_url }}" | |
| TIMEOUT_MINUTES=${{ env.PREVIEW_TIMEOUT_MINUTES }} | |
| WORKFLOW_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| PR_NUMBER=${{ github.event.pull_request.number }} | |
| PR_TITLE="${{ github.event.pull_request.title }}" | |
| AUTHOR="${{ github.event.pull_request.user.login }}" | |
| ESCAPED_TITLE=$(echo "$PR_TITLE" | sed 's/[-_*\[\]()~`>#+=|{}.!]/\\&/g') | |
| ESCAPED_AUTHOR=$(echo "$AUTHOR" | sed 's/[-_*\[\]()~`>#+=|{}.!]/\\&/g') | |
| ESCAPED_REPO=$(echo "$REPO" | sed 's/[-_*\[\]()~`>#+=|{}.!]/\\&/g') | |
| MSG=$(printf '%s\n' \ | |
| "🚀 *Preview ready for PR \\#$PR_NUMBER in $ESCAPED_REPO*" \ | |
| "" \ | |
| "*Title:* $ESCAPED_TITLE" \ | |
| "*Author:* $ESCAPED_AUTHOR" \ | |
| "" \ | |
| "*Live Preview:* [Open Preview]($PREVIEW_URL)" \ | |
| "*Workflow:* [View Run]($WORKFLOW_URL)" \ | |
| "⏳ *This preview will expire in ${TIMEOUT_MINUTES} minutes\.*" \ | |
| "" \ | |
| "[View on GitHub](https://github.com/$REPO/pull/$PR_NUMBER)" | |
| ) | |
| else | |
| # Manual dispatch | |
| BRANCH_NAME="${{ github.ref_name }}" | |
| ACTOR="${{ github.actor }}" | |
| ESCAPED_BRANCH=$(echo "$BRANCH_NAME" | sed 's/[-_*\[\]()~`>#+=|{}.!]/\\&/g') | |
| ESCAPED_ACTOR=$(echo "$ACTOR" | sed 's/[-_*\[\]()~`>#+=|{}.!]/\\&/g') | |
| ESCAPED_REPO=$(echo "$REPO" | sed 's/[-_*\[\]()~`>#+=|{}.!]/\\&/g') | |
| MSG=$(printf '%s\n' \ | |
| "🚀 *Manual preview deployed for $ESCAPED_REPO*" \ | |
| "" \ | |
| "*Branch:* $ESCAPED_BRANCH" \ | |
| "*Triggered by:* $ESCAPED_ACTOR" \ | |
| "" \ | |
| "*Live Preview:* [Open Preview]($PREVIEW_URL)" \ | |
| "*Workflow:* [View Run]($WORKFLOW_URL)" \ | |
| "⏳ *This preview will expire in ${TIMEOUT_MINUTES} minutes\.*" | |
| ) | |
| fi | |
| curl -s -X POST "https://api.telegram.org/bot$BOT_TOKEN/sendMessage" \ | |
| -d "chat_id=$CHAT_ID" \ | |
| -d "parse_mode=MarkdownV2" \ | |
| --data-urlencode "text=$MSG" | |
| - name: Keep ngrok alive | |
| if: ${{ steps.detect.outputs.frontend_found == 'true' }} | |
| run: | | |
| echo "⏳ Keeping ngrok tunnel alive for $PREVIEW_TIMEOUT_MINUTES minutes..." | |
| sleep $(( PREVIEW_TIMEOUT_MINUTES * 60 )) |