Preview bot resource drift audit #11
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: Preview bot resource drift audit | |
| "on": | |
| schedule: | |
| - cron: "17 3 * * 0" | |
| workflow_dispatch: | |
| inputs: | |
| mode: | |
| description: "Audit mode" | |
| required: true | |
| default: audit-and-cleanup | |
| type: choice | |
| options: | |
| - audit-only | |
| - audit-and-cleanup | |
| permissions: | |
| contents: read | |
| jobs: | |
| audit: | |
| name: Detect and remediate preview bot drift | |
| runs-on: ${{ fromJSON(vars.ACTIONS_RUNNER_LABELS || '["ubuntu-latest"]') }} | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Audit preview resources for bot branches | |
| env: | |
| AUDIT_MODE_INPUT: ${{ inputs.mode }} | |
| GITHUB_EVENT_NAME: ${{ github.event_name }} | |
| NEON_API_KEY: ${{ secrets.NEON_API_KEY }} | |
| NEON_PROJECT_ID: ${{ vars.NEON_PROJECT_ID }} | |
| VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} | |
| VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| if ! command -v jq >/dev/null 2>&1; then | |
| echo "::error::jq is required but not available on this runner." | |
| exit 1 | |
| fi | |
| missing=0 | |
| for var_name in NEON_API_KEY NEON_PROJECT_ID VERCEL_PROJECT_ID VERCEL_TOKEN; do | |
| if [ -z "${!var_name:-}" ]; then | |
| echo "::warning::Missing ${var_name}; skipping preview bot drift audit." | |
| missing=1 | |
| fi | |
| done | |
| if [ "${missing}" -eq 1 ]; then | |
| exit 0 | |
| fi | |
| AUDIT_MODE="audit-and-cleanup" | |
| if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ -n "${AUDIT_MODE_INPUT:-}" ]; then | |
| AUDIT_MODE="${AUDIT_MODE_INPUT}" | |
| fi | |
| echo "Audit mode: ${AUDIT_MODE}" | |
| VERCEL_API_BASE="https://api.vercel.com" | |
| NEON_API_BASE="https://console.neon.tech/api/v2" | |
| TEAM_QUERY="" | |
| if [ -n "${VERCEL_TEAM_ID:-}" ]; then | |
| TEAM_QUERY="teamId=${VERCEL_TEAM_ID}" | |
| fi | |
| api_call() { | |
| local method="$1" | |
| local base_url="$2" | |
| local token="$3" | |
| local path="$4" | |
| local data="${5:-}" | |
| local CONNECT_TIMEOUT="${CONNECT_TIMEOUT:-10}" | |
| local MAX_TIME="${MAX_TIME:-60}" | |
| local resp="" | |
| local stderr_file="" | |
| local curl_status=0 | |
| stderr_file="$(mktemp)" | |
| if [ -n "${data}" ]; then | |
| set +e | |
| resp="$(curl -sS -X "${method}" "${base_url}${path}" \ | |
| --connect-timeout "${CONNECT_TIMEOUT}" \ | |
| --max-time "${MAX_TIME}" \ | |
| -H "Authorization: Bearer ${token}" \ | |
| -H "Accept: application/json" \ | |
| -H "Content-Type: application/json" \ | |
| -d "${data}" \ | |
| -w '\n%{http_code}' 2>"${stderr_file}")" | |
| curl_status=$? | |
| set -e | |
| else | |
| set +e | |
| resp="$(curl -sS -X "${method}" "${base_url}${path}" \ | |
| --connect-timeout "${CONNECT_TIMEOUT}" \ | |
| --max-time "${MAX_TIME}" \ | |
| -H "Authorization: Bearer ${token}" \ | |
| -H "Accept: application/json" \ | |
| -w '\n%{http_code}' 2>"${stderr_file}")" | |
| curl_status=$? | |
| set -e | |
| fi | |
| if [ "${curl_status}" -ne 0 ]; then | |
| if [ -s "${stderr_file}" ]; then | |
| cat "${stderr_file}" >&2 | |
| fi | |
| rm -f "${stderr_file}" | |
| printf '\n' | |
| printf '%s\n' "000" | |
| return 0 | |
| fi | |
| rm -f "${stderr_file}" | |
| # Split resp into body + http code safely even when body contains newlines. | |
| # curl appends the HTTP code as the last line via `-w '\n%{http_code}'`. | |
| local body="" | |
| local http_code="" | |
| body="$(printf '%s' "${resp}" | head -n -1 || true)" | |
| http_code="$(printf '%s' "${resp}" | tail -n 1)" | |
| printf '%s\n' "${body}" | |
| printf '%s\n' "${http_code}" | |
| } | |
| vercel_api() { | |
| local method="$1" | |
| local path="$2" | |
| local data="${3:-}" | |
| api_call "${method}" "${VERCEL_API_BASE}" "${VERCEL_TOKEN}" "${path}" "${data}" | |
| } | |
| neon_api() { | |
| local method="$1" | |
| local path="$2" | |
| local data="${3:-}" | |
| api_call "${method}" "${NEON_API_BASE}" "${NEON_API_KEY}" "${path}" "${data}" | |
| } | |
| # | |
| # Vercel branch-scoped preview env vars (APP_BASE_URL) for bot branches | |
| # | |
| ENV_PATH="/v10/projects/${VERCEL_PROJECT_ID}/env?decrypt=false" | |
| if [ -n "${TEAM_QUERY:-}" ]; then | |
| ENV_PATH="${ENV_PATH}&${TEAM_QUERY}" | |
| fi | |
| ENV_RESP="$(vercel_api GET "${ENV_PATH}")" | |
| ENV_HTTP_CODE="$(printf '%s' "${ENV_RESP}" | tail -n 1)" | |
| ENV_JSON="$(printf '%s' "${ENV_RESP}" | sed '$d')" | |
| if [ "${ENV_HTTP_CODE}" != "200" ]; then | |
| echo "::error::Failed to list Vercel env vars (HTTP ${ENV_HTTP_CODE})." | |
| exit 1 | |
| fi | |
| BOT_ENV_IDS="$(printf '%s' "${ENV_JSON}" | jq -r ' | |
| (.envs // [])[] | |
| | select(.key == "APP_BASE_URL") | |
| | select((.target // []) | index("preview")) | |
| | select((.gitBranch // "") | test("^(dependabot/|renovate/)")) | |
| | .id | |
| ')" | |
| env_found=0 | |
| env_deleted=0 | |
| env_failed=0 | |
| if [ -n "${BOT_ENV_IDS}" ]; then | |
| env_found="$(printf '%s\n' "${BOT_ENV_IDS}" | sed '/^$/d' | wc -l | tr -d ' ')" | |
| fi | |
| if [ "${AUDIT_MODE}" = "audit-and-cleanup" ] && [ "${env_found}" -gt 0 ]; then | |
| while IFS= read -r env_id; do | |
| if [ -z "${env_id}" ] || [ "${env_id}" = "null" ]; then | |
| continue | |
| fi | |
| DELETE_PATH="/v9/projects/${VERCEL_PROJECT_ID}/env/${env_id}" | |
| if [ -n "${TEAM_QUERY:-}" ]; then | |
| DELETE_PATH="${DELETE_PATH}?${TEAM_QUERY}" | |
| fi | |
| DELETE_RESP="$(vercel_api DELETE "${DELETE_PATH}")" | |
| DELETE_HTTP_CODE="$(printf '%s' "${DELETE_RESP}" | tail -n 1)" | |
| if [ "${DELETE_HTTP_CODE}" = "200" ] || [ "${DELETE_HTTP_CODE}" = "204" ]; then | |
| env_deleted=$((env_deleted + 1)) | |
| else | |
| env_failed=$((env_failed + 1)) | |
| echo "::warning::Failed deleting Vercel env id ${env_id} (HTTP ${DELETE_HTTP_CODE})." | |
| fi | |
| done < <(printf '%s\n' "${BOT_ENV_IDS}") | |
| fi | |
| # | |
| # Vercel preview deployments for bot branches/authors (audit only) | |
| # | |
| BOT_DEPLOYMENTS="[]" | |
| DEPLOY_UNTIL="" | |
| NEXT_DEPLOY_UNTIL="" | |
| MAX_DEPLOY_PAGES=50 | |
| deploy_page=1 | |
| while :; do | |
| if [ "${deploy_page}" -gt "${MAX_DEPLOY_PAGES}" ]; then | |
| echo "::warning::Vercel deployments pagination hit page cap (deploy_page=${deploy_page} >= MAX_DEPLOY_PAGES=${MAX_DEPLOY_PAGES}). Stopping pagination with DEPLOY_UNTIL='${DEPLOY_UNTIL}' NEXT_DEPLOY_UNTIL='${NEXT_DEPLOY_UNTIL}' and BOT_DEPLOYMENTS length $(printf '%s' "${BOT_DEPLOYMENTS}" | jq 'length')." | |
| break | |
| fi | |
| DEPLOY_PATH="/v6/deployments?projectId=${VERCEL_PROJECT_ID}&target=preview&limit=100" | |
| if [ -n "${DEPLOY_UNTIL}" ]; then | |
| ENCODED_DEPLOY_UNTIL="$(printf '%s' "${DEPLOY_UNTIL}" | jq -sRr @uri)" | |
| DEPLOY_PATH="${DEPLOY_PATH}&until=${ENCODED_DEPLOY_UNTIL}" | |
| fi | |
| if [ -n "${TEAM_QUERY:-}" ]; then | |
| DEPLOY_PATH="${DEPLOY_PATH}&${TEAM_QUERY}" | |
| fi | |
| DEPLOY_RESP="$(vercel_api GET "${DEPLOY_PATH}")" | |
| DEPLOY_HTTP_CODE="$(printf '%s' "${DEPLOY_RESP}" | tail -n 1)" | |
| DEPLOY_JSON="$(printf '%s' "${DEPLOY_RESP}" | sed '$d')" | |
| if [ "${DEPLOY_HTTP_CODE}" != "200" ]; then | |
| echo "::error::Failed to list Vercel preview deployments (page ${deploy_page}, HTTP ${DEPLOY_HTTP_CODE})." | |
| exit 1 | |
| fi | |
| PAGE_BOT_DEPLOYMENTS="$(printf '%s' "${DEPLOY_JSON}" | jq -r ' | |
| [(.deployments // [])[] as $d | |
| | ($d.meta.githubCommitRef // "") as $ref | |
| | ($d.meta.githubCommitAuthorLogin // "") as $author | |
| | ($d.creator.username // "") as $creator | |
| | select( | |
| ($ref | test("^(refs/heads/)?(dependabot/|renovate/)")) | |
| or ($author == "dependabot[bot]" or $author == "renovate[bot]") | |
| or ($creator == "dependabot[bot]" or $creator == "renovate[bot]") | |
| ) | |
| | { | |
| id: ($d.uid // $d.id // ""), | |
| ref: $ref, | |
| author: $author, | |
| creator: $creator, | |
| url: ($d.url // "") | |
| } | |
| ] | |
| ')" | |
| BOT_DEPLOYMENTS="$(jq -cn \ | |
| --argjson all "${BOT_DEPLOYMENTS}" \ | |
| --argjson page "${PAGE_BOT_DEPLOYMENTS}" \ | |
| '$all + $page')" | |
| NEXT_DEPLOY_UNTIL="$(printf '%s' "${DEPLOY_JSON}" | jq -r '.pagination.next // empty')" | |
| if [ -z "${NEXT_DEPLOY_UNTIL}" ] || [ "${NEXT_DEPLOY_UNTIL}" = "null" ]; then | |
| break | |
| fi | |
| if [ "${NEXT_DEPLOY_UNTIL}" = "${DEPLOY_UNTIL}" ]; then | |
| echo "::warning::Vercel deployments pagination cursor did not advance (deploy_page=${deploy_page}, MAX_DEPLOY_PAGES=${MAX_DEPLOY_PAGES}, DEPLOY_UNTIL='${DEPLOY_UNTIL}', NEXT_DEPLOY_UNTIL='${NEXT_DEPLOY_UNTIL}'). Stopping pagination with BOT_DEPLOYMENTS length $(printf '%s' "${BOT_DEPLOYMENTS}" | jq 'length')." | |
| break | |
| fi | |
| DEPLOY_UNTIL="${NEXT_DEPLOY_UNTIL}" | |
| deploy_page=$((deploy_page + 1)) | |
| done | |
| BOT_DEPLOYMENTS="$(printf '%s' "${BOT_DEPLOYMENTS}" | jq ' | |
| unique_by((.id // "") + "|" + (.ref // "") + "|" + (.url // "")) | |
| ')" | |
| deploy_found="$(printf '%s' "${BOT_DEPLOYMENTS}" | jq 'length')" | |
| if [ "${deploy_found}" -gt 0 ]; then | |
| echo "::warning::Detected bot preview deployments. Manual cleanup may be required." | |
| printf '%s' "${BOT_DEPLOYMENTS}" | jq -r '.[] | "- id=\(.id) ref=\(.ref) author=\(.author) creator=\(.creator) url=\(.url)"' | |
| fi | |
| # | |
| # Neon preview branches for bot branches | |
| # | |
| BOT_NEON_BRANCHES="" | |
| NEON_CURSOR="" | |
| MAX_NEON_BRANCH_PAGES=50 | |
| neon_page=1 | |
| while :; do | |
| if [ "${neon_page}" -gt "${MAX_NEON_BRANCH_PAGES}" ]; then | |
| echo "::warning::Neon branches pagination hit page cap (neon_page=${neon_page} >= MAX_NEON_BRANCH_PAGES=${MAX_NEON_BRANCH_PAGES}). Stopping pagination with NEON_CURSOR='${NEON_CURSOR}'." | |
| break | |
| fi | |
| BRANCHES_PATH="/projects/${NEON_PROJECT_ID}/branches?limit=100" | |
| if [ -n "${NEON_CURSOR}" ]; then | |
| ENCODED_NEON_CURSOR="$(printf '%s' "${NEON_CURSOR}" | jq -sRr @uri)" | |
| BRANCHES_PATH="${BRANCHES_PATH}&cursor=${ENCODED_NEON_CURSOR}" | |
| fi | |
| BRANCHES_RESP="$(neon_api GET "${BRANCHES_PATH}")" | |
| BRANCHES_HTTP_CODE="$(printf '%s' "${BRANCHES_RESP}" | tail -n 1)" | |
| BRANCHES_JSON="$(printf '%s' "${BRANCHES_RESP}" | sed '$d')" | |
| if [ "${BRANCHES_HTTP_CODE}" != "200" ]; then | |
| echo "::error::Failed to list Neon branches (page ${neon_page}, HTTP ${BRANCHES_HTTP_CODE})." | |
| exit 1 | |
| fi | |
| PAGE_BOT_NEON_BRANCHES="$(printf '%s' "${BRANCHES_JSON}" | jq -r ' | |
| (.branches // [])[] | |
| | select((.name // "") | test("^preview/(dependabot/|renovate/)")) | |
| | "\(.id)\t\(.name)" | |
| ')" | |
| if [ -n "${PAGE_BOT_NEON_BRANCHES}" ]; then | |
| if [ -n "${BOT_NEON_BRANCHES}" ]; then | |
| BOT_NEON_BRANCHES="${BOT_NEON_BRANCHES}"$'\n'"${PAGE_BOT_NEON_BRANCHES}" | |
| else | |
| BOT_NEON_BRANCHES="${PAGE_BOT_NEON_BRANCHES}" | |
| fi | |
| fi | |
| NEXT_NEON_CURSOR="$(printf '%s' "${BRANCHES_JSON}" | jq -r '.pagination.next // empty')" | |
| if [ -z "${NEXT_NEON_CURSOR}" ] || [ "${NEXT_NEON_CURSOR}" = "null" ]; then | |
| break | |
| fi | |
| if [ "${NEXT_NEON_CURSOR}" = "${NEON_CURSOR}" ]; then | |
| echo "::warning::Neon branches pagination cursor did not advance (neon_page=${neon_page}, MAX_NEON_BRANCH_PAGES=${MAX_NEON_BRANCH_PAGES}, NEON_CURSOR='${NEON_CURSOR}', NEXT_NEON_CURSOR='${NEXT_NEON_CURSOR}'); stopping pagination." | |
| break | |
| fi | |
| NEON_CURSOR="${NEXT_NEON_CURSOR}" | |
| neon_page=$((neon_page + 1)) | |
| done | |
| if [ -n "${BOT_NEON_BRANCHES}" ]; then | |
| BOT_NEON_BRANCHES="$(printf '%s\n' "${BOT_NEON_BRANCHES}" | sed '/^$/d' | sort -u)" | |
| fi | |
| neon_found=0 | |
| neon_deleted=0 | |
| neon_failed=0 | |
| if [ -n "${BOT_NEON_BRANCHES}" ]; then | |
| neon_found="$(printf '%s\n' "${BOT_NEON_BRANCHES}" | sed '/^$/d' | wc -l | tr -d ' ')" | |
| fi | |
| if [ "${AUDIT_MODE}" = "audit-and-cleanup" ] && [ "${neon_found}" -gt 0 ]; then | |
| while IFS=$'\t' read -r branch_id branch_name; do | |
| if [ -z "${branch_id}" ] || [ "${branch_id}" = "null" ]; then | |
| continue | |
| fi | |
| DELETE_RESP="$(neon_api DELETE "/projects/${NEON_PROJECT_ID}/branches/${branch_id}")" | |
| DELETE_HTTP_CODE="$(printf '%s' "${DELETE_RESP}" | tail -n 1)" | |
| if [ "${DELETE_HTTP_CODE}" = "200" ] || [ "${DELETE_HTTP_CODE}" = "202" ] || [ "${DELETE_HTTP_CODE}" = "204" ]; then | |
| neon_deleted=$((neon_deleted + 1)) | |
| else | |
| neon_failed=$((neon_failed + 1)) | |
| echo "::warning::Failed deleting Neon branch ${branch_name} (${branch_id}) (HTTP ${DELETE_HTTP_CODE})." | |
| fi | |
| done < <(printf '%s\n' "${BOT_NEON_BRANCHES}") | |
| fi | |
| unresolved_env="${env_found}" | |
| unresolved_neon="${neon_found}" | |
| if [ "${AUDIT_MODE}" = "audit-and-cleanup" ]; then | |
| unresolved_env="${env_failed}" | |
| unresolved_neon="${neon_failed}" | |
| fi | |
| # Deployments are audit-only (no reliable deletion API), so they should not | |
| # force a permanent failure once any bot deployment has existed. | |
| unresolved_total=$((unresolved_env + unresolved_neon)) | |
| echo "Summary:" | |
| echo " Vercel bot APP_BASE_URL env vars: found=${env_found}, deleted=${env_deleted}, failed=${env_failed}" | |
| echo " Vercel bot preview deployments: found=${deploy_found} (audit-only)" | |
| echo " Neon bot preview branches: found=${neon_found}, deleted=${neon_deleted}, failed=${neon_failed}" | |
| if [ "${unresolved_total}" -gt 0 ]; then | |
| echo "::error::Preview resource drift remains (unresolved=${unresolved_total})." | |
| exit 1 | |
| fi | |
| echo "No unresolved bot preview resource drift detected." |