feat: add Bunny CDN deploy script for SPA

Changed files
+259
+259
deploy.sh
···
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + # Bunny CDN Deploy Script for Grain SPA 5 + # Builds the app and syncs dist/ to Bunny Storage 6 + 7 + # Colors for output 8 + RED='\033[0;31m' 9 + GREEN='\033[0;32m' 10 + YELLOW='\033[1;33m' 11 + NC='\033[0m' # No Color 12 + 13 + # Counters 14 + UPLOADED=0 15 + DELETED=0 16 + 17 + # Parse arguments 18 + DRY_RUN=false 19 + VERBOSE=false 20 + SKIP_BUILD=false 21 + 22 + while [[ $# -gt 0 ]]; do 23 + case $1 in 24 + --dry-run) 25 + DRY_RUN=true 26 + shift 27 + ;; 28 + --verbose|-v) 29 + VERBOSE=true 30 + shift 31 + ;; 32 + --skip-build) 33 + SKIP_BUILD=true 34 + shift 35 + ;; 36 + *) 37 + echo -e "${RED}Unknown option: $1${NC}" 38 + exit 1 39 + ;; 40 + esac 41 + done 42 + 43 + # Load .env file if it exists 44 + if [ -f .env ]; then 45 + set -a 46 + source .env 47 + set +a 48 + fi 49 + 50 + # Required environment variables 51 + : "${BUNNY_API_KEY:?BUNNY_API_KEY environment variable is required}" 52 + : "${BUNNY_STORAGE_PASSWORD:?BUNNY_STORAGE_PASSWORD environment variable is required}" 53 + : "${BUNNY_STORAGE_ZONE:?BUNNY_STORAGE_ZONE environment variable is required}" 54 + : "${BUNNY_STORAGE_HOST:?BUNNY_STORAGE_HOST environment variable is required}" 55 + : "${BUNNY_PULLZONE_ID:?BUNNY_PULLZONE_ID environment variable is required}" 56 + 57 + # Configuration 58 + STORAGE_URL="https://${BUNNY_STORAGE_HOST}/${BUNNY_STORAGE_ZONE}" 59 + DIST_DIR="dist" 60 + 61 + echo "Bunny CDN Deploy - Grain SPA" 62 + echo "========================================" 63 + echo "Storage Zone: ${BUNNY_STORAGE_ZONE}" 64 + if [ "$DRY_RUN" = true ]; then 65 + echo -e "${YELLOW}DRY RUN MODE - No changes will be made${NC}" 66 + fi 67 + echo "" 68 + 69 + # Get content type for file 70 + get_content_type() { 71 + local file="$1" 72 + case "$file" in 73 + *.html) echo "text/html" ;; 74 + *.js) echo "application/javascript" ;; 75 + *.css) echo "text/css" ;; 76 + *.json) echo "application/json" ;; 77 + *.svg) echo "image/svg+xml" ;; 78 + *.png) echo "image/png" ;; 79 + *.jpg|*.jpeg) echo "image/jpeg" ;; 80 + *.gif) echo "image/gif" ;; 81 + *.webp) echo "image/webp" ;; 82 + *.woff) echo "font/woff" ;; 83 + *.woff2) echo "font/woff2" ;; 84 + *.ttf) echo "font/ttf" ;; 85 + *.ico) echo "image/x-icon" ;; 86 + *) echo "application/octet-stream" ;; 87 + esac 88 + } 89 + 90 + # Upload a single file 91 + upload_file() { 92 + local local_path="$1" 93 + local remote_path="$2" 94 + 95 + if [ "$VERBOSE" = true ]; then 96 + echo " Uploading: ${remote_path}" 97 + fi 98 + 99 + if [ "$DRY_RUN" = true ]; then 100 + ((UPLOADED++)) 101 + return 0 102 + fi 103 + 104 + local content_type 105 + content_type=$(get_content_type "$local_path") 106 + 107 + local http_code 108 + http_code=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \ 109 + "${STORAGE_URL}/${remote_path}" \ 110 + -H "AccessKey: ${BUNNY_STORAGE_PASSWORD}" \ 111 + -H "Content-Type: ${content_type}" \ 112 + --data-binary "@${local_path}") 113 + 114 + if [[ "$http_code" =~ ^2 ]]; then 115 + ((UPLOADED++)) 116 + return 0 117 + else 118 + echo -e "${RED}Failed to upload ${remote_path}: HTTP ${http_code}${NC}" 119 + return 1 120 + fi 121 + } 122 + 123 + # List all files in remote storage recursively 124 + list_remote_files() { 125 + local path="${1:-}" 126 + local response 127 + response=$(curl -s -X GET "${STORAGE_URL}/${path}" \ 128 + -H "AccessKey: ${BUNNY_STORAGE_PASSWORD}" \ 129 + -H "Accept: application/json") 130 + 131 + echo "$response" | jq -r '.[] | if .IsDirectory then .ObjectName + "/" else .ObjectName end' 2>/dev/null | while read -r item; do 132 + if [[ "$item" == */ ]]; then 133 + # It's a directory, recurse 134 + list_remote_files "${path}${item}" 135 + else 136 + echo "${path}${item}" 137 + fi 138 + done 139 + } 140 + 141 + # Delete a single file from remote 142 + delete_file() { 143 + local remote_path="$1" 144 + 145 + if [ "$VERBOSE" = true ]; then 146 + echo " Deleting: ${remote_path}" 147 + fi 148 + 149 + if [ "$DRY_RUN" = true ]; then 150 + ((DELETED++)) 151 + return 0 152 + fi 153 + 154 + local http_code 155 + http_code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ 156 + "${STORAGE_URL}/${remote_path}" \ 157 + -H "AccessKey: ${BUNNY_STORAGE_PASSWORD}") 158 + 159 + if [[ "$http_code" =~ ^2 ]]; then 160 + ((DELETED++)) 161 + return 0 162 + else 163 + echo -e "${RED}Failed to delete ${remote_path}: HTTP ${http_code}${NC}" 164 + return 1 165 + fi 166 + } 167 + 168 + # Purge pull zone cache 169 + purge_cache() { 170 + echo "Purging CDN cache..." 171 + 172 + if [ "$DRY_RUN" = true ]; then 173 + echo -e "${YELLOW} Would purge pull zone ${BUNNY_PULLZONE_ID}${NC}" 174 + return 0 175 + fi 176 + 177 + local http_code 178 + http_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ 179 + "https://api.bunny.net/pullzone/${BUNNY_PULLZONE_ID}/purgeCache" \ 180 + -H "AccessKey: ${BUNNY_API_KEY}" \ 181 + -H "Content-Type: application/json") 182 + 183 + if [[ "$http_code" =~ ^2 ]]; then 184 + echo -e "${GREEN} Cache purged successfully${NC}" 185 + return 0 186 + else 187 + echo -e "${RED} Failed to purge cache: HTTP ${http_code}${NC}" 188 + return 1 189 + fi 190 + } 191 + 192 + # ============================================ 193 + # MAIN EXECUTION 194 + # ============================================ 195 + 196 + # Step 0: Build the app 197 + if [ "$SKIP_BUILD" = false ]; then 198 + echo "Building app..." 199 + npm run build 200 + echo "" 201 + fi 202 + 203 + # Check dist directory exists 204 + if [ ! -d "$DIST_DIR" ]; then 205 + echo -e "${RED}Error: ${DIST_DIR} directory not found. Run npm run build first.${NC}" 206 + exit 1 207 + fi 208 + 209 + # Build list of local files 210 + LOCAL_FILES_LIST=$(mktemp) 211 + REMOTE_PATHS_LIST=$(mktemp) 212 + trap "rm -f $LOCAL_FILES_LIST $REMOTE_PATHS_LIST" EXIT 213 + 214 + # Find all files in dist 215 + find "$DIST_DIR" -type f | while read -r file; do 216 + # Get path relative to dist/ 217 + remote_path="${file#${DIST_DIR}/}" 218 + echo "$file|$remote_path" >> "$LOCAL_FILES_LIST" 219 + echo "$remote_path" >> "$REMOTE_PATHS_LIST" 220 + done 221 + 222 + # Step 1: Upload all local files 223 + echo "Uploading files..." 224 + while IFS='|' read -r local_path remote_path; do 225 + upload_file "$local_path" "$remote_path" 226 + done < "$LOCAL_FILES_LIST" 227 + 228 + echo "" 229 + 230 + # Step 2: Delete orphaned remote files 231 + echo "Checking for orphaned files..." 232 + REMOTE_FILES=$(list_remote_files) 233 + 234 + if [ -n "$REMOTE_FILES" ]; then 235 + while IFS= read -r remote_file; do 236 + if [ -z "$remote_file" ]; then 237 + continue 238 + fi 239 + if ! grep -qxF "$remote_file" "$REMOTE_PATHS_LIST"; then 240 + delete_file "$remote_file" 241 + fi 242 + done <<< "$REMOTE_FILES" 243 + fi 244 + 245 + echo "" 246 + 247 + # Step 3: Purge CDN cache 248 + purge_cache 249 + 250 + # Summary 251 + echo "" 252 + echo "========================================" 253 + echo -e "${GREEN}Deploy complete!${NC}" 254 + echo " Uploaded: ${UPLOADED} files" 255 + echo " Deleted: ${DELETED} files" 256 + if [ "$DRY_RUN" = true ]; then 257 + echo -e "${YELLOW} (DRY RUN - no actual changes made)${NC}" 258 + fi 259 + echo "========================================"