diff --git a/.github/workflows/coder-preview.yml b/.github/workflows/coder-preview.yml new file mode 100644 index 000000000000..627b97f3fd07 --- /dev/null +++ b/.github/workflows/coder-preview.yml @@ -0,0 +1,60 @@ +name: Coder PR Preview +on: + pull_request: + types: [labeled, unlabeled, synchronize, closed] + +permissions: + deployments: write + contents: read + +env: + CODER_URL: https://coder.sentry.dev + +jobs: + deploy: + if: | + (github.event.action == 'labeled' && github.event.label.name == 'deploy-preview') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'deploy-preview')) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Compute workspace name + id: name + run: | + WORKSPACE_NAME=$(scripts/coder-preview/compute-name.sh sentry "${{ github.event.pull_request.number }}") + echo "workspace_name=${WORKSPACE_NAME}" >> "$GITHUB_OUTPUT" + + - name: Deploy preview + env: + CODER_SESSION_TOKEN: ${{ secrets.CODER_SESSION_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_URL: ${{ github.event.pull_request.html_url }} + WORKSPACE_NAME: ${{ steps.name.outputs.workspace_name }} + TEMPLATE_NAME: sentry-devbox + run: scripts/coder-preview/deploy.sh + + destroy: + if: | + github.event.action == 'closed' || + (github.event.action == 'unlabeled' && github.event.label.name == 'deploy-preview') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Compute workspace name + id: name + run: | + WORKSPACE_NAME=$(scripts/coder-preview/compute-name.sh sentry "${{ github.event.pull_request.number }}") + echo "workspace_name=${WORKSPACE_NAME}" >> "$GITHUB_OUTPUT" + + - name: Destroy preview + env: + CODER_SESSION_TOKEN: ${{ secrets.CODER_SESSION_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + WORKSPACE_NAME: ${{ steps.name.outputs.workspace_name }} + run: scripts/coder-preview/destroy.sh diff --git a/scripts/coder-preview/compute-name.sh b/scripts/coder-preview/compute-name.sh new file mode 100755 index 000000000000..ab9ac3aeb3d3 --- /dev/null +++ b/scripts/coder-preview/compute-name.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Maps repo + PR number to a deterministic workspace name. +# Usage: compute-name.sh +# repo: "sentry" or "getsentry" +# pr_number: numeric PR number + +REPO="${1:?Usage: compute-name.sh }" +PR_NUMBER="${2:?Usage: compute-name.sh }" + +case "$REPO" in + sentry|getsentry) ;; + *) echo "Unknown repo: $REPO" >&2; exit 1 ;; +esac + +echo "pr-${REPO}-${PR_NUMBER}" diff --git a/scripts/coder-preview/deploy.sh b/scripts/coder-preview/deploy.sh new file mode 100755 index 000000000000..fac43ff2c149 --- /dev/null +++ b/scripts/coder-preview/deploy.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deploy a Coder preview workspace for a GitHub PR. +# +# Required env vars: +# CODER_URL - Coder server URL (e.g. https://coder.sentry.dev) +# CODER_SESSION_TOKEN - API token for the ci-bot Coder user +# GH_TOKEN - GitHub token for Deployments API +# GITHUB_REPOSITORY - owner/repo (e.g. getsentry/sentry) +# PR_NUMBER - PR number +# PR_URL - Full GitHub PR URL +# WORKSPACE_NAME - Computed workspace name (e.g. pr-sentry-12345) +# TEMPLATE_NAME - Coder template name (e.g. sentry-devbox) + +CODER_API="${CODER_URL}/api/v2" +AUTH_HEADER="Coder-Session-Token: ${CODER_SESSION_TOKEN}" +MACHINE_TYPE="e2-standard-8" + +# curl wrapper that shows response body on failure +api() { + local response http_code + response=$(curl -sS -w "\n%{http_code}" "$@") + http_code=$(echo "$response" | tail -1) + response=$(echo "$response" | sed '$d') + + if [[ "$http_code" -ge 400 ]]; then + echo "ERROR: HTTP ${http_code}" >&2 + echo "$response" >&2 + return 1 + fi + echo "$response" +} + +# ---------- GitHub Deployment: in_progress ---------- + +echo "Creating GitHub Deployment (in_progress)..." +DEPLOYMENT_ID=$(api -X POST \ + -H "Authorization: token ${GH_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/deployments" \ + -d "$(jq -n \ + --arg ref "refs/pull/${PR_NUMBER}/head" \ + --arg env "pr-preview-${PR_NUMBER}" \ + '{ + ref: $ref, + environment: $env, + auto_merge: false, + required_contexts: [], + transient_environment: true, + production_environment: false, + description: "Coder PR preview workspace" + }')" | jq -r '.id') + +echo "Deployment ID: ${DEPLOYMENT_ID}" + +update_deployment_status() { + local state="$1" + local url="${2:-}" + local description="${3:-}" + + local body + body=$(jq -n \ + --arg state "$state" \ + --arg url "$url" \ + --arg description "$description" \ + '{ + state: $state, + environment_url: (if $url != "" then $url else null end), + description: (if $description != "" then $description else null end), + auto_inactive: false + }') + + api -X POST \ + -H "Authorization: token ${GH_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + -d "$body" > /dev/null +} + +# ---------- Look up template ---------- + +echo "Looking up template '${TEMPLATE_NAME}'..." +TEMPLATE_ID=$(api \ + -H "${AUTH_HEADER}" \ + "${CODER_API}/templates" | jq -r ".[] | select(.name == \"${TEMPLATE_NAME}\") | .id") + +if [ -z "$TEMPLATE_ID" ]; then + echo "ERROR: Template '${TEMPLATE_NAME}' not found" >&2 + update_deployment_status "failure" "" "Template not found" + exit 1 +fi + +ACTIVE_VERSION_ID=$(api \ + -H "${AUTH_HEADER}" \ + "${CODER_API}/templates/${TEMPLATE_ID}" | jq -r '.active_version_id') + +echo "Template ID: ${TEMPLATE_ID}, Active Version: ${ACTIVE_VERSION_ID}" + +# ---------- Build parameter payload (shared across create/start/rebuild) ---------- + +BUILD_PARAMS=$(jq -n \ + --arg version_id "$ACTIVE_VERSION_ID" \ + --arg pr_url "$PR_URL" \ + --arg machine_type "$MACHINE_TYPE" \ + '{ + template_version_id: $version_id, + transition: "start", + rich_parameter_values: [ + {name: "branch", value: $pr_url}, + {name: "start_devserver", value: "true"}, + {name: "machine_type", value: $machine_type} + ] + }') + +# ---------- Check if workspace already exists ---------- + +echo "Checking for existing workspace '${WORKSPACE_NAME}'..." +WORKSPACE_JSON=$(api \ + -H "${AUTH_HEADER}" \ + "${CODER_API}/workspaces?q=owner:me+name:${WORKSPACE_NAME}" | jq -r '.workspaces[0] // empty') + +if [ -n "$WORKSPACE_JSON" ]; then + WORKSPACE_ID=$(echo "$WORKSPACE_JSON" | jq -r '.id') + WORKSPACE_STATUS=$(echo "$WORKSPACE_JSON" | jq -r '.latest_build.status') + echo "Workspace exists: ${WORKSPACE_ID} (status: ${WORKSPACE_STATUS})" + + # Wait for any in-progress build to finish before triggering a new one + while [[ "$WORKSPACE_STATUS" == "starting" || "$WORKSPACE_STATUS" == "stopping" || "$WORKSPACE_STATUS" == "canceling" || "$WORKSPACE_STATUS" == "pending" || "$WORKSPACE_STATUS" == "deleting" ]]; do + echo " Build in progress (${WORKSPACE_STATUS}), waiting..." + sleep 10 + # 410 means workspace was fully deleted + if ! WORKSPACE_STATUS=$(api \ + -H "${AUTH_HEADER}" \ + "${CODER_API}/workspaces/${WORKSPACE_ID}" | jq -r '.latest_build.status'); then + WORKSPACE_STATUS="deleted" + fi + done + + if [ "$WORKSPACE_STATUS" = "deleted" ]; then + echo "Workspace was deleted, creating fresh..." + WORKSPACE_JSON="" + else + echo "Workspace is ${WORKSPACE_STATUS}, triggering build with updated branch..." + api -X POST \ + -H "${AUTH_HEADER}" \ + -H "Content-Type: application/json" \ + "${CODER_API}/workspaces/${WORKSPACE_ID}/builds" \ + -d "$BUILD_PARAMS" > /dev/null + fi +fi + +if [ -z "$WORKSPACE_JSON" ]; then + # ---------- Create new workspace ---------- + echo "Creating workspace '${WORKSPACE_NAME}'..." + WORKSPACE_JSON=$(api -X POST \ + -H "${AUTH_HEADER}" \ + -H "Content-Type: application/json" \ + "${CODER_API}/users/me/workspaces" \ + -d "$(jq -n \ + --arg name "$WORKSPACE_NAME" \ + --argjson params "$BUILD_PARAMS" \ + '$params + {name: $name}')") + WORKSPACE_ID=$(echo "$WORKSPACE_JSON" | jq -r '.id') + echo "Created workspace: ${WORKSPACE_ID}" +fi + +# ---------- Poll until running ---------- + +echo "Waiting for workspace to be running..." +MAX_WAIT=300 # 5 minutes +ELAPSED=0 +POLL_INTERVAL=10 + +while [ "$ELAPSED" -lt "$MAX_WAIT" ]; do + STATUS=$(api \ + -H "${AUTH_HEADER}" \ + "${CODER_API}/workspaces/${WORKSPACE_ID}" | jq -r '.latest_build.status') + + echo " Status: ${STATUS} (${ELAPSED}s elapsed)" + + case "$STATUS" in + running) + echo "Workspace is running!" + break + ;; + failed|canceled) + echo "ERROR: Workspace build ${STATUS}" >&2 + update_deployment_status "failure" "" "Workspace build ${STATUS}" + exit 1 + ;; + esac + + sleep "$POLL_INTERVAL" + ELAPSED=$((ELAPSED + POLL_INTERVAL)) +done + +if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then + echo "ERROR: Timed out waiting for workspace" >&2 + update_deployment_status "failure" "" "Workspace build timed out" + exit 1 +fi + +# ---------- Wait for agent to connect ---------- + +echo "Waiting for agent to connect..." +MAX_AGENT_WAIT=300 # 5 minutes +AGENT_ELAPSED=0 + +while [ "$AGENT_ELAPSED" -lt "$MAX_AGENT_WAIT" ]; do + AGENT_STATUS=$(api \ + -H "${AUTH_HEADER}" \ + "${CODER_API}/workspaces/${WORKSPACE_ID}" | jq -r '.latest_build.resources[]?.agents[]?.status // empty' | head -1) + + echo " Agent: ${AGENT_STATUS:-no agent yet} (${AGENT_ELAPSED}s elapsed)" + + if [ "$AGENT_STATUS" = "connected" ]; then + echo "Agent connected!" + break + fi + + sleep 10 + AGENT_ELAPSED=$((AGENT_ELAPSED + 10)) +done + +if [ "$AGENT_ELAPSED" -ge "$MAX_AGENT_WAIT" ]; then + echo "ERROR: Agent did not connect within timeout" >&2 + update_deployment_status "failure" "" "Agent failed to connect" + exit 1 +fi + +# ---------- Wait for devserver to be healthy ---------- + +echo "Waiting for devserver health check..." +MAX_HEALTH_WAIT=600 # 10 minutes — devserver takes a while to start +HEALTH_ELAPSED=0 + +while [ "$HEALTH_ELAPSED" -lt "$MAX_HEALTH_WAIT" ]; do + APP_HEALTH=$(api \ + -H "${AUTH_HEADER}" \ + "${CODER_API}/workspaces/${WORKSPACE_ID}" | jq -r '.latest_build.resources[]?.agents[]?.apps[]? | select(.slug == "sentry-dev") | .health' | head -1) + + echo " Devserver app health: ${APP_HEALTH:-unknown} (${HEALTH_ELAPSED}s elapsed)" + + if [ "$APP_HEALTH" = "healthy" ]; then + echo "Devserver is healthy!" + break + fi + + sleep 15 + HEALTH_ELAPSED=$((HEALTH_ELAPSED + 15)) +done + +if [ "$HEALTH_ELAPSED" -ge "$MAX_HEALTH_WAIT" ]; then + echo "ERROR: Devserver did not become healthy within timeout" >&2 + update_deployment_status "failure" "" "Devserver health check timed out" + exit 1 +fi + +OWNER_NAME=$(api \ + -H "${AUTH_HEADER}" \ + "${CODER_API}/users/me" | jq -r '.username') + +PREVIEW_URL="https://sentry-dev--${WORKSPACE_NAME}--${OWNER_NAME}.coder.sentry.dev" +echo "Preview URL: ${PREVIEW_URL}" + +# ---------- GitHub Deployment: success ---------- + +update_deployment_status "success" "$PREVIEW_URL" "Coder preview workspace running" + +echo "Done! Deployment marked as success." diff --git a/scripts/coder-preview/destroy.sh b/scripts/coder-preview/destroy.sh new file mode 100755 index 000000000000..5989cc88ea5a --- /dev/null +++ b/scripts/coder-preview/destroy.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Destroy a Coder preview workspace and mark GitHub Deployments inactive. +# +# Required env vars: +# CODER_URL - Coder server URL (e.g. https://coder.sentry.dev) +# CODER_SESSION_TOKEN - API token for the ci-bot Coder user +# GH_TOKEN - GitHub token for Deployments API +# GITHUB_REPOSITORY - owner/repo (e.g. getsentry/sentry) +# PR_NUMBER - PR number +# WORKSPACE_NAME - Computed workspace name (e.g. pr-sentry-12345) + +CODER_API="${CODER_URL}/api/v2" +AUTH_HEADER="Coder-Session-Token: ${CODER_SESSION_TOKEN}" + +# curl wrapper that shows response body on failure +api() { + local response http_code + response=$(curl -sS -w "\n%{http_code}" "$@") + http_code=$(echo "$response" | tail -1) + response=$(echo "$response" | sed '$d') + + if [[ "$http_code" -ge 400 ]]; then + echo "ERROR: HTTP ${http_code}" >&2 + echo "$response" >&2 + return 1 + fi + echo "$response" +} + +# ---------- Delete Coder workspace ---------- + +echo "Looking up workspace '${WORKSPACE_NAME}'..." +WORKSPACE_JSON=$(api \ + -H "${AUTH_HEADER}" \ + "${CODER_API}/workspaces?q=owner:me+name:${WORKSPACE_NAME}" | jq -r '.workspaces[0] // empty') + +if [ -n "$WORKSPACE_JSON" ]; then + WORKSPACE_ID=$(echo "$WORKSPACE_JSON" | jq -r '.id') + WORKSPACE_STATUS=$(echo "$WORKSPACE_JSON" | jq -r '.latest_build.status') + echo "Found workspace ${WORKSPACE_ID} (status: ${WORKSPACE_STATUS})" + + # Wait for any active build to finish before deleting + while [[ "$WORKSPACE_STATUS" == "starting" || "$WORKSPACE_STATUS" == "stopping" || "$WORKSPACE_STATUS" == "deleting" ]]; do + echo " Build in progress (${WORKSPACE_STATUS}), waiting..." + sleep 10 + WORKSPACE_STATUS=$(api \ + -H "${AUTH_HEADER}" \ + "${CODER_API}/workspaces/${WORKSPACE_ID}" | jq -r '.latest_build.status') + done + + if [ "$WORKSPACE_STATUS" = "deleted" ]; then + echo "Workspace already deleted." + else + echo "Deleting workspace..." + api -X POST \ + -H "${AUTH_HEADER}" \ + -H "Content-Type: application/json" \ + "${CODER_API}/workspaces/${WORKSPACE_ID}/builds" \ + -d '{"transition": "delete"}' > /dev/null + echo "Workspace deletion initiated." + fi +else + echo "Workspace '${WORKSPACE_NAME}' not found, nothing to delete." +fi + +# ---------- Mark GitHub Deployments inactive ---------- + +echo "Marking GitHub Deployments inactive for PR #${PR_NUMBER}..." +ENVIRONMENT="pr-preview-${PR_NUMBER}" + +DEPLOYMENTS=$(api \ + -H "Authorization: token ${GH_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/deployments?environment=${ENVIRONMENT}") + +DEPLOYMENT_COUNT=$(echo "$DEPLOYMENTS" | jq 'length') +echo "Found ${DEPLOYMENT_COUNT} deployment(s) for environment '${ENVIRONMENT}'" + +echo "$DEPLOYMENTS" | jq -r '.[].id' | while read -r dep_id; do + echo " Marking deployment ${dep_id} as inactive..." + api -X POST \ + -H "Authorization: token ${GH_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/deployments/${dep_id}/statuses" \ + -d '{"state": "inactive"}' > /dev/null +done + +echo "Done! All deployments marked inactive."