push.sh
bash
sha256:915b1f581ce1d3bca63153d03c07a5ccbbfbe77de3bf561e75e85f9427dd32a3
fix(musehub#199): remove declare -A from push.sh for bash 3…
Sonnet 5
minor
⚠ breaking
21 hours ago
| 1 | #!/usr/bin/env bash |
| 2 | # MuseHub deploy orchestrator — build, push to ECR, trigger blue-green via SSM. |
| 3 | # |
| 4 | # Usage: |
| 5 | # bash deploy/push.sh staging # deploy to staging only |
| 6 | # bash deploy/push.sh prod # deploy to prod only |
| 7 | # bash deploy/push.sh staging prod # staging first, then prod |
| 8 | # |
| 9 | # What it does: |
| 10 | # 1. Builds a linux/amd64 Docker image from the local repo. |
| 11 | # 2. Tags it with <commit-hash>-<timestamp> for traceability. |
| 12 | # 3. Pushes the image to each target's own ECR (staging and prod are |
| 13 | # separate AWS accounts with separate, non-shared ECR repos). |
| 14 | # 4. Sends an SSM command to each target instance to run deploy.sh, |
| 15 | # which pulls the image and performs a zero-downtime blue-green swap. |
| 16 | # 5. Polls SSM until the deploy completes or fails, streaming the output. |
| 17 | # |
| 18 | # Staging and production are different AWS accounts, each authenticated via |
| 19 | # the operator's own IAM Identity Center SSO session — run `aws sso login |
| 20 | # --profile musehub-nonproduction` (staging) or `--profile musehub-production` |
| 21 | # (prod) first. No shared IAM user is used by either target; the legacy |
| 22 | # `musehub-infra` static credential is retired from this path (kept around |
| 23 | # for now for any scripts that still reference it directly, but no longer |
| 24 | # used by push.sh). |
| 25 | # |
| 26 | # Prerequisites (one-time, already done): |
| 27 | # - `musehub-nonproduction` and `musehub-production` SSO profiles configured |
| 28 | # in ~/.aws/config |
| 29 | # - Docker Desktop running |
| 30 | # - musehub-ec2-ssm / musehub-production-ec2-ssm roles have ecr pull permissions |
| 31 | # - AWS CLI installed on instances (run deploy/bootstrap-instance.sh once) |
| 32 | # |
| 33 | # Rollback to a previous image: |
| 34 | # IMAGE_TAG=<previous-tag> bash deploy/push.sh staging |
| 35 | # (skips build+push, triggers SSM with the specified tag directly) |
| 36 | |
| 37 | set -euo pipefail |
| 38 | |
| 39 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 40 | REPO_DIR="$(dirname "$SCRIPT_DIR")" |
| 41 | ECOSYSTEM_DIR="$(dirname "$REPO_DIR")" |
| 42 | |
| 43 | ECR_REPO="musehub/musehub" |
| 44 | REGION="us-east-1" |
| 45 | |
| 46 | # Per-environment config — staging and prod are separate AWS accounts. |
| 47 | # |
| 48 | # Deliberately `case` dispatch rather than `declare -A`: associative arrays |
| 49 | # require bash 4+, but macOS ships bash 3.2 as /bin/bash with no version |
| 50 | # guard elsewhere in this script, so `declare -A` fails outright for anyone |
| 51 | # running under the system bash rather than a Homebrew-installed bash 4/5. |
| 52 | instance_id_for() { |
| 53 | case "$1" in |
| 54 | staging) echo "i-07547cd20bee2dea5" ;; |
| 55 | prod) echo "i-043aaed71bef11903" ;; |
| 56 | esac |
| 57 | } |
| 58 | ecr_registry_for() { |
| 59 | case "$1" in |
| 60 | staging) echo "992382692655.dkr.ecr.us-east-1.amazonaws.com" ;; |
| 61 | prod) echo "672469410277.dkr.ecr.us-east-1.amazonaws.com" ;; |
| 62 | esac |
| 63 | } |
| 64 | aws_profile_for() { |
| 65 | case "$1" in |
| 66 | staging) echo "musehub-nonproduction" ;; # operator's own SSO session |
| 67 | prod) echo "musehub-production" ;; # operator's own SSO session |
| 68 | esac |
| 69 | } |
| 70 | |
| 71 | # ── Parse targets ───────────────────────────────────────────────────────────── |
| 72 | |
| 73 | if [ $# -eq 0 ]; then |
| 74 | echo "Usage: bash deploy/push.sh [staging] [prod]" |
| 75 | echo " staging deploy to staging.musehub.ai" |
| 76 | echo " prod deploy to musehub.ai" |
| 77 | echo " staging prod staging first, then prod" |
| 78 | echo "" |
| 79 | echo "Rollback (skips build+push, redeploys a previous tag):" |
| 80 | echo " IMAGE_TAG=<tag> bash deploy/push.sh staging" |
| 81 | exit 1 |
| 82 | fi |
| 83 | |
| 84 | TARGETS=() |
| 85 | for arg in "$@"; do |
| 86 | case "$arg" in |
| 87 | staging|prod) TARGETS+=("$arg") ;; |
| 88 | *) echo "Unknown target: $arg (must be staging or prod)" >&2; exit 1 ;; |
| 89 | esac |
| 90 | done |
| 91 | |
| 92 | # ── Helpers ─────────────────────────────────────────────────────────────────── |
| 93 | |
| 94 | log() { echo "[push] $*"; } |
| 95 | die() { echo "[push] ERROR: $*" >&2; exit 1; } |
| 96 | |
| 97 | profile_args() { |
| 98 | local env="$1" |
| 99 | local profile |
| 100 | profile="$(aws_profile_for "$env")" |
| 101 | if [ -n "$profile" ]; then |
| 102 | echo "--profile $profile" |
| 103 | fi |
| 104 | } |
| 105 | |
| 106 | check_auth() { |
| 107 | local env="$1" |
| 108 | local profile |
| 109 | profile="$(aws_profile_for "$env")" |
| 110 | if [ -n "$profile" ]; then |
| 111 | if ! aws sts get-caller-identity --profile "$profile" > /dev/null 2>&1; then |
| 112 | die "No valid SSO session for profile '$profile'. Run: aws sso login --profile $profile" |
| 113 | fi |
| 114 | fi |
| 115 | } |
| 116 | |
| 117 | # ── Image tag ───────────────────────────────────────────────────────────────── |
| 118 | |
| 119 | # If IMAGE_TAG is already set (rollback mode), skip build+push. |
| 120 | if [ -n "${IMAGE_TAG:-}" ]; then |
| 121 | log "Rollback mode — using existing tag: $IMAGE_TAG" |
| 122 | SKIP_BUILD=true |
| 123 | else |
| 124 | COMMIT_HASH=$(muse -C "$REPO_DIR" rev-parse HEAD --json 2>/dev/null \ |
| 125 | | python3 -c "import sys,json; cid=json.load(sys.stdin)['commit_id']; print(cid.removeprefix('sha256:')[:8])" \ |
| 126 | 2>/dev/null || echo "local") |
| 127 | IMAGE_TAG="${COMMIT_HASH}-$(date +%Y%m%d%H%M%S)" |
| 128 | SKIP_BUILD=false |
| 129 | fi |
| 130 | |
| 131 | log "Image tag: $IMAGE_TAG" |
| 132 | |
| 133 | for target in "${TARGETS[@]}"; do |
| 134 | check_auth "$target" |
| 135 | done |
| 136 | |
| 137 | # ── Build ───────────────────────────────────────────────────────────────────── |
| 138 | |
| 139 | LOCAL_TAG="musehub-build:${IMAGE_TAG}" |
| 140 | CRANE_TAR="/tmp/musehub-${IMAGE_TAG}.tar" |
| 141 | |
| 142 | if [ "$SKIP_BUILD" = false ]; then |
| 143 | log "[1/3] Building image for linux/amd64..." |
| 144 | docker build \ |
| 145 | --platform linux/amd64 \ |
| 146 | --tag "$LOCAL_TAG" \ |
| 147 | -f "$REPO_DIR/Dockerfile" \ |
| 148 | "$ECOSYSTEM_DIR" |
| 149 | log "Build complete." |
| 150 | docker save "$LOCAL_TAG" -o "$CRANE_TAR" |
| 151 | |
| 152 | # ── Push to each target's ECR via crane ─────────────────────────────────── |
| 153 | # crane bypasses Docker Desktop's VPNKit proxy, which drops connections |
| 154 | # mid-upload on large layer pushes. Never use `docker push` to ECR. |
| 155 | # Staging and prod are separate AWS accounts, so the same tarball is |
| 156 | # pushed to each target's own registry under its own credentials. |
| 157 | |
| 158 | log "[2/3] Pushing to ECR for: ${TARGETS[*]}" |
| 159 | for target in "${TARGETS[@]}"; do |
| 160 | registry="$(ecr_registry_for "$target")" |
| 161 | image="${registry}/${ECR_REPO}" |
| 162 | eval "aws ecr get-login-password --region \"$REGION\" $(profile_args "$target")" | \ |
| 163 | crane auth login "$registry" --username AWS --password-stdin |
| 164 | crane push "$CRANE_TAR" "${image}:${IMAGE_TAG}" |
| 165 | crane push "$CRANE_TAR" "${image}:latest" |
| 166 | log " Pushed to $target ($registry)" |
| 167 | done |
| 168 | rm -f "$CRANE_TAR" |
| 169 | log "Push complete. Tag: $IMAGE_TAG" |
| 170 | else |
| 171 | log "[1/3] Skipping build (rollback mode)." |
| 172 | log "[2/3] Skipping push (rollback mode) — assumes the tag already exists in each target registry." |
| 173 | fi |
| 174 | |
| 175 | # ── Trigger deploy via SSM ──────────────────────────────────────────────────── |
| 176 | |
| 177 | log "[3/3] Triggering deploy on: ${TARGETS[*]}" |
| 178 | |
| 179 | deploy_to() { |
| 180 | local env="$1" |
| 181 | local instance_id |
| 182 | instance_id="$(instance_id_for "$env")" |
| 183 | local ecr_image |
| 184 | ecr_image="$(ecr_registry_for "$env")/${ECR_REPO}" |
| 185 | local pargs |
| 186 | pargs=$(profile_args "$env") |
| 187 | |
| 188 | # deploy.sh's MUSEHUB_ENV drives its CloudWatch log group — "prod" (the |
| 189 | # CLI target name) must map to "production" (the actual env/log-group name). |
| 190 | local musehub_env |
| 191 | if [ "$env" = "prod" ]; then musehub_env="production"; else musehub_env="staging"; fi |
| 192 | |
| 193 | log "" |
| 194 | log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 195 | log "→ Deploying to $env ($instance_id)" |
| 196 | log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 197 | |
| 198 | local deploy_sh_b64 |
| 199 | deploy_sh_b64=$(base64 -i "$SCRIPT_DIR/deploy.sh" | tr -d '\n') |
| 200 | |
| 201 | local nginx_conf_b64 |
| 202 | nginx_conf_b64=$(base64 -i "$SCRIPT_DIR/nginx-cf.conf" | tr -d '\n') |
| 203 | |
| 204 | local set_slot_b64 |
| 205 | set_slot_b64=$(base64 -i "$SCRIPT_DIR/set-active-slot.sh" | tr -d '\n') |
| 206 | |
| 207 | # ── Fire the deploy command ─────────────────────────────────────────────── |
| 208 | local deploy_cmd_id |
| 209 | deploy_cmd_id=$(eval "aws ssm send-command \ |
| 210 | --region \"$REGION\" \ |
| 211 | $pargs \ |
| 212 | --instance-ids \"$instance_id\" \ |
| 213 | --document-name \"AWS-RunShellScript\" \ |
| 214 | --parameters \"commands=[ |
| 215 | \\\"echo '${deploy_sh_b64}' | base64 -d > /opt/musehub/deploy/deploy.sh && chmod +x /opt/musehub/deploy/deploy.sh\\\", |
| 216 | \\\"echo '${nginx_conf_b64}' | base64 -d > /opt/musehub/deploy/nginx-cf.conf\\\", |
| 217 | \\\"echo '${set_slot_b64}' | base64 -d > /usr/local/bin/musehub-set-slot && chmod +x /usr/local/bin/musehub-set-slot\\\", |
| 218 | \\\"export ECR_IMAGE=${ecr_image}\\\", |
| 219 | \\\"export IMAGE_TAG=${IMAGE_TAG}\\\", |
| 220 | \\\"export MUSEHUB_ENV=${musehub_env}\\\", |
| 221 | \\\"bash /opt/musehub/deploy/deploy.sh\\\" |
| 222 | ]\" \ |
| 223 | --comment \"musehub ${IMAGE_TAG} → ${env}\" \ |
| 224 | --timeout-seconds 600 \ |
| 225 | --query \"Command.CommandId\" \ |
| 226 | --output text") |
| 227 | |
| 228 | log " SSM command: $deploy_cmd_id" |
| 229 | |
| 230 | # ── Stream deploy output live ───────────────────────────────────────────── |
| 231 | # SSM updates StandardOutputContent as the command runs. We poll every 5s, |
| 232 | # diff against what we've already printed, and show new lines immediately. |
| 233 | log " Live output:" |
| 234 | log "" |
| 235 | |
| 236 | local lines_seen=0 |
| 237 | local elapsed=0 |
| 238 | local final_status="" |
| 239 | |
| 240 | while true; do |
| 241 | sleep 5 |
| 242 | elapsed=$((elapsed + 5)) |
| 243 | |
| 244 | local invocation |
| 245 | invocation=$(eval "aws ssm get-command-invocation \ |
| 246 | --region \"$REGION\" \ |
| 247 | $pargs \ |
| 248 | --command-id \"$deploy_cmd_id\" \ |
| 249 | --instance-id \"$instance_id\" \ |
| 250 | --output json" 2>/dev/null || echo '{"Status":"Pending","StandardOutputContent":"","StandardErrorContent":""}') |
| 251 | |
| 252 | local cmd_status all_stdout all_stderr |
| 253 | cmd_status=$(echo "$invocation" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('Status','Pending'))" 2>/dev/null || echo "Pending") |
| 254 | all_stdout=$(echo "$invocation" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('StandardOutputContent',''),end='')" 2>/dev/null || echo "") |
| 255 | all_stderr=$(echo "$invocation" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('StandardErrorContent',''),end='')" 2>/dev/null || echo "") |
| 256 | |
| 257 | # Print any new stdout lines |
| 258 | local total_lines |
| 259 | total_lines=$(echo "$all_stdout" | wc -l) |
| 260 | if [ "$total_lines" -gt "$lines_seen" ]; then |
| 261 | echo "$all_stdout" | tail -n +"$((lines_seen + 1))" |
| 262 | lines_seen=$total_lines |
| 263 | fi |
| 264 | |
| 265 | case "$cmd_status" in |
| 266 | Success) |
| 267 | final_status="success" |
| 268 | break |
| 269 | ;; |
| 270 | Failed|Cancelled|TimedOut|Cancelling) |
| 271 | final_status="failed" |
| 272 | [ -n "$all_stderr" ] && echo "STDERR: $all_stderr" |
| 273 | break |
| 274 | ;; |
| 275 | esac |
| 276 | |
| 277 | if [ "$elapsed" -ge 600 ]; then |
| 278 | die "Deploy timed out after 10 min. SSM command: $deploy_cmd_id" |
| 279 | fi |
| 280 | done |
| 281 | |
| 282 | echo "" |
| 283 | if [ "$final_status" = "success" ]; then |
| 284 | log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 285 | log "✅ $env deploy succeeded." |
| 286 | log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 287 | return 0 |
| 288 | else |
| 289 | log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 290 | log "❌ $env deploy FAILED. Full output:" |
| 291 | log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 292 | eval "aws ssm get-command-invocation \ |
| 293 | --region \"$REGION\" \ |
| 294 | $pargs \ |
| 295 | --command-id \"$deploy_cmd_id\" \ |
| 296 | --instance-id \"$instance_id\" \ |
| 297 | --query \"[StandardOutputContent,StandardErrorContent]\" \ |
| 298 | --output text" 2>/dev/null || true |
| 299 | return 1 |
| 300 | fi |
| 301 | } |
| 302 | |
| 303 | for target in "${TARGETS[@]}"; do |
| 304 | deploy_to "$target" |
| 305 | done |
| 306 | |
| 307 | log "" |
| 308 | log "All done." |
| 309 | log " Tag: $IMAGE_TAG" |
File History
1 commit
sha256:915b1f581ce1d3bca63153d03c07a5ccbbfbe77de3bf561e75e85f9427dd32a3
fix(musehub#199): remove declare -A from push.sh for bash 3…
Sonnet 5
minor
⚠
21 hours ago