Skip to content

Allow cron overlap to terminate stale workflow runs #673

Allow cron overlap to terminate stale workflow runs

Allow cron overlap to terminate stale workflow runs #673

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 ))