push.sh
bash
sha256:1221bc046bda400826d150dab864512fec7e57281420893a59522c2f335fc85b
Merge 'fix/production-deploy-target' into 'dev' — proposal:…
Human
4 days 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 with different auth: |
| 19 | # - staging (Nonproduction, 992382692655): musehub-infra IAM user |
| 20 | # (default AWS CLI profile — long-lived shared credential, legacy) |
| 21 | # - prod (Production, 672469410277): the operator's own IAM Identity |
| 22 | # Center SSO session — run `aws sso login --profile musehub-production` |
| 23 | # first. No shared IAM user exists in Production by design. |
| 24 | # |
| 25 | # Prerequisites (one-time, already done): |
| 26 | # - AWS CLI configured (musehub-infra as default profile, for staging) |
| 27 | # - `musehub-production` SSO profile configured in ~/.aws/config, for prod |
| 28 | # - Docker Desktop running |
| 29 | # - musehub-infra has ecr push permissions (musehub-ecr-push policy) |
| 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 | declare -A INSTANCE=( |
| 48 | [staging]="i-07547cd20bee2dea5" |
| 49 | [prod]="i-043aaed71bef11903" |
| 50 | ) |
| 51 | declare -A ECR_REGISTRY=( |
| 52 | [staging]="992382692655.dkr.ecr.us-east-1.amazonaws.com" |
| 53 | [prod]="672469410277.dkr.ecr.us-east-1.amazonaws.com" |
| 54 | ) |
| 55 | declare -A AWS_PROFILE=( |
| 56 | [staging]="" # default profile (musehub-infra) |
| 57 | [prod]="musehub-production" # operator's own SSO session |
| 58 | ) |
| 59 | |
| 60 | # ── Parse targets ───────────────────────────────────────────────────────────── |
| 61 | |
| 62 | if [ $# -eq 0 ]; then |
| 63 | echo "Usage: bash deploy/push.sh [staging] [prod]" |
| 64 | echo " staging deploy to staging.musehub.ai" |
| 65 | echo " prod deploy to musehub.ai" |
| 66 | echo " staging prod staging first, then prod" |
| 67 | echo "" |
| 68 | echo "Rollback (skips build+push, redeploys a previous tag):" |
| 69 | echo " IMAGE_TAG=<tag> bash deploy/push.sh staging" |
| 70 | exit 1 |
| 71 | fi |
| 72 | |
| 73 | TARGETS=() |
| 74 | for arg in "$@"; do |
| 75 | case "$arg" in |
| 76 | staging|prod) TARGETS+=("$arg") ;; |
| 77 | *) echo "Unknown target: $arg (must be staging or prod)" >&2; exit 1 ;; |
| 78 | esac |
| 79 | done |
| 80 | |
| 81 | # ── Helpers ─────────────────────────────────────────────────────────────────── |
| 82 | |
| 83 | log() { echo "[push] $*"; } |
| 84 | die() { echo "[push] ERROR: $*" >&2; exit 1; } |
| 85 | |
| 86 | profile_args() { |
| 87 | local env="$1" |
| 88 | local profile="${AWS_PROFILE[$env]}" |
| 89 | if [ -n "$profile" ]; then |
| 90 | echo "--profile $profile" |
| 91 | fi |
| 92 | } |
| 93 | |
| 94 | check_auth() { |
| 95 | local env="$1" |
| 96 | local profile="${AWS_PROFILE[$env]}" |
| 97 | if [ -n "$profile" ]; then |
| 98 | if ! aws sts get-caller-identity --profile "$profile" > /dev/null 2>&1; then |
| 99 | die "No valid SSO session for profile '$profile'. Run: aws sso login --profile $profile" |
| 100 | fi |
| 101 | fi |
| 102 | } |
| 103 | |
| 104 | # ── Image tag ───────────────────────────────────────────────────────────────── |
| 105 | |
| 106 | # If IMAGE_TAG is already set (rollback mode), skip build+push. |
| 107 | if [ -n "${IMAGE_TAG:-}" ]; then |
| 108 | log "Rollback mode — using existing tag: $IMAGE_TAG" |
| 109 | SKIP_BUILD=true |
| 110 | else |
| 111 | COMMIT_HASH=$(muse -C "$REPO_DIR" rev-parse HEAD --json 2>/dev/null \ |
| 112 | | python3 -c "import sys,json; cid=json.load(sys.stdin)['commit_id']; print(cid.removeprefix('sha256:')[:8])" \ |
| 113 | 2>/dev/null || echo "local") |
| 114 | IMAGE_TAG="${COMMIT_HASH}-$(date +%Y%m%d%H%M%S)" |
| 115 | SKIP_BUILD=false |
| 116 | fi |
| 117 | |
| 118 | log "Image tag: $IMAGE_TAG" |
| 119 | |
| 120 | for target in "${TARGETS[@]}"; do |
| 121 | check_auth "$target" |
| 122 | done |
| 123 | |
| 124 | # ── Build ───────────────────────────────────────────────────────────────────── |
| 125 | |
| 126 | LOCAL_TAG="musehub-build:${IMAGE_TAG}" |
| 127 | CRANE_TAR="/tmp/musehub-${IMAGE_TAG}.tar" |
| 128 | |
| 129 | if [ "$SKIP_BUILD" = false ]; then |
| 130 | log "[1/3] Building image for linux/amd64..." |
| 131 | docker build \ |
| 132 | --platform linux/amd64 \ |
| 133 | --tag "$LOCAL_TAG" \ |
| 134 | -f "$REPO_DIR/Dockerfile" \ |
| 135 | "$ECOSYSTEM_DIR" |
| 136 | log "Build complete." |
| 137 | docker save "$LOCAL_TAG" -o "$CRANE_TAR" |
| 138 | |
| 139 | # ── Push to each target's ECR via crane ─────────────────────────────────── |
| 140 | # crane bypasses Docker Desktop's VPNKit proxy, which drops connections |
| 141 | # mid-upload on large layer pushes. Never use `docker push` to ECR. |
| 142 | # Staging and prod are separate AWS accounts, so the same tarball is |
| 143 | # pushed to each target's own registry under its own credentials. |
| 144 | |
| 145 | log "[2/3] Pushing to ECR for: ${TARGETS[*]}" |
| 146 | for target in "${TARGETS[@]}"; do |
| 147 | registry="${ECR_REGISTRY[$target]}" |
| 148 | image="${registry}/${ECR_REPO}" |
| 149 | eval "aws ecr get-login-password --region \"$REGION\" $(profile_args "$target")" | \ |
| 150 | crane auth login "$registry" --username AWS --password-stdin |
| 151 | crane push "$CRANE_TAR" "${image}:${IMAGE_TAG}" |
| 152 | crane push "$CRANE_TAR" "${image}:latest" |
| 153 | log " Pushed to $target ($registry)" |
| 154 | done |
| 155 | rm -f "$CRANE_TAR" |
| 156 | log "Push complete. Tag: $IMAGE_TAG" |
| 157 | else |
| 158 | log "[1/3] Skipping build (rollback mode)." |
| 159 | log "[2/3] Skipping push (rollback mode) — assumes the tag already exists in each target registry." |
| 160 | fi |
| 161 | |
| 162 | # ── Trigger deploy via SSM ──────────────────────────────────────────────────── |
| 163 | |
| 164 | log "[3/3] Triggering deploy on: ${TARGETS[*]}" |
| 165 | |
| 166 | deploy_to() { |
| 167 | local env="$1" |
| 168 | local instance_id="${INSTANCE[$env]}" |
| 169 | local ecr_image="${ECR_REGISTRY[$env]}/${ECR_REPO}" |
| 170 | local profile="${AWS_PROFILE[$env]}" |
| 171 | local pargs |
| 172 | pargs=$(profile_args "$env") |
| 173 | |
| 174 | log "" |
| 175 | log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 176 | log "→ Deploying to $env ($instance_id)" |
| 177 | log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 178 | |
| 179 | local deploy_sh_b64 |
| 180 | deploy_sh_b64=$(base64 -i "$SCRIPT_DIR/deploy.sh" | tr -d '\n') |
| 181 | |
| 182 | local nginx_conf_b64 |
| 183 | nginx_conf_b64=$(base64 -i "$SCRIPT_DIR/nginx-cf.conf" | tr -d '\n') |
| 184 | |
| 185 | local set_slot_b64 |
| 186 | set_slot_b64=$(base64 -i "$SCRIPT_DIR/set-active-slot.sh" | tr -d '\n') |
| 187 | |
| 188 | # ── Fire the deploy command ─────────────────────────────────────────────── |
| 189 | local deploy_cmd_id |
| 190 | deploy_cmd_id=$(eval "aws ssm send-command \ |
| 191 | --region \"$REGION\" \ |
| 192 | $pargs \ |
| 193 | --instance-ids \"$instance_id\" \ |
| 194 | --document-name \"AWS-RunShellScript\" \ |
| 195 | --parameters \"commands=[ |
| 196 | \\\"echo '${deploy_sh_b64}' | base64 -d > /opt/musehub/deploy/deploy.sh && chmod +x /opt/musehub/deploy/deploy.sh\\\", |
| 197 | \\\"echo '${nginx_conf_b64}' | base64 -d > /opt/musehub/deploy/nginx-cf.conf\\\", |
| 198 | \\\"echo '${set_slot_b64}' | base64 -d > /usr/local/bin/musehub-set-slot && chmod +x /usr/local/bin/musehub-set-slot\\\", |
| 199 | \\\"export ECR_IMAGE=${ecr_image}\\\", |
| 200 | \\\"export IMAGE_TAG=${IMAGE_TAG}\\\", |
| 201 | \\\"bash /opt/musehub/deploy/deploy.sh\\\" |
| 202 | ]\" \ |
| 203 | --comment \"musehub ${IMAGE_TAG} → ${env}\" \ |
| 204 | --timeout-seconds 600 \ |
| 205 | --query \"Command.CommandId\" \ |
| 206 | --output text") |
| 207 | |
| 208 | log " SSM command: $deploy_cmd_id" |
| 209 | |
| 210 | # ── Stream deploy output live ───────────────────────────────────────────── |
| 211 | # SSM updates StandardOutputContent as the command runs. We poll every 5s, |
| 212 | # diff against what we've already printed, and show new lines immediately. |
| 213 | log " Live output:" |
| 214 | log "" |
| 215 | |
| 216 | local lines_seen=0 |
| 217 | local elapsed=0 |
| 218 | local final_status="" |
| 219 | |
| 220 | while true; do |
| 221 | sleep 5 |
| 222 | elapsed=$((elapsed + 5)) |
| 223 | |
| 224 | local invocation |
| 225 | invocation=$(eval "aws ssm get-command-invocation \ |
| 226 | --region \"$REGION\" \ |
| 227 | $pargs \ |
| 228 | --command-id \"$deploy_cmd_id\" \ |
| 229 | --instance-id \"$instance_id\" \ |
| 230 | --output json" 2>/dev/null || echo '{"Status":"Pending","StandardOutputContent":"","StandardErrorContent":""}') |
| 231 | |
| 232 | local cmd_status all_stdout all_stderr |
| 233 | cmd_status=$(echo "$invocation" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('Status','Pending'))" 2>/dev/null || echo "Pending") |
| 234 | all_stdout=$(echo "$invocation" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('StandardOutputContent',''),end='')" 2>/dev/null || echo "") |
| 235 | all_stderr=$(echo "$invocation" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('StandardErrorContent',''),end='')" 2>/dev/null || echo "") |
| 236 | |
| 237 | # Print any new stdout lines |
| 238 | local total_lines |
| 239 | total_lines=$(echo "$all_stdout" | wc -l) |
| 240 | if [ "$total_lines" -gt "$lines_seen" ]; then |
| 241 | echo "$all_stdout" | tail -n +"$((lines_seen + 1))" |
| 242 | lines_seen=$total_lines |
| 243 | fi |
| 244 | |
| 245 | case "$cmd_status" in |
| 246 | Success) |
| 247 | final_status="success" |
| 248 | break |
| 249 | ;; |
| 250 | Failed|Cancelled|TimedOut|Cancelling) |
| 251 | final_status="failed" |
| 252 | [ -n "$all_stderr" ] && echo "STDERR: $all_stderr" |
| 253 | break |
| 254 | ;; |
| 255 | esac |
| 256 | |
| 257 | if [ "$elapsed" -ge 600 ]; then |
| 258 | die "Deploy timed out after 10 min. SSM command: $deploy_cmd_id" |
| 259 | fi |
| 260 | done |
| 261 | |
| 262 | echo "" |
| 263 | if [ "$final_status" = "success" ]; then |
| 264 | log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 265 | log "✅ $env deploy succeeded." |
| 266 | log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 267 | return 0 |
| 268 | else |
| 269 | log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 270 | log "❌ $env deploy FAILED. Full output:" |
| 271 | log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 272 | eval "aws ssm get-command-invocation \ |
| 273 | --region \"$REGION\" \ |
| 274 | $pargs \ |
| 275 | --command-id \"$deploy_cmd_id\" \ |
| 276 | --instance-id \"$instance_id\" \ |
| 277 | --query \"[StandardOutputContent,StandardErrorContent]\" \ |
| 278 | --output text" 2>/dev/null || true |
| 279 | return 1 |
| 280 | fi |
| 281 | } |
| 282 | |
| 283 | for target in "${TARGETS[@]}"; do |
| 284 | deploy_to "$target" |
| 285 | done |
| 286 | |
| 287 | log "" |
| 288 | log "All done." |
| 289 | log " Tag: $IMAGE_TAG" |
File History
15 commits
sha256:1221bc046bda400826d150dab864512fec7e57281420893a59522c2f335fc85b
Merge 'fix/production-deploy-target' into 'dev' — proposal:…
Human
4 days ago
sha256:20ae2cb8b3425921e96f2131287a153721794504a8d0688f67cc2e3cd158e3ef
Merge 'fix/production-backups-r2-bucket' into 'dev' — propo…
Human
4 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226
Merge branch 'fix/two-column-scroll-layout' into dev
Human
55 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454
chore: bump version to 0.2.0.dev2 — nightly.2, matching muse
Sonnet 4.6
patch
58 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2
chore: bump version to 0.2.0rc15 for musehub#113 fix release
Sonnet 4.6
patch
61 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352
Merge branch 'task/version-tags-phase3-server' into dev
Human
63 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53
merge: rescue snapshot-recovery hardening (c00aa21d) into d…
Opus 4.8
minor
⚠
76 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a
fix: remove false-positive proposal_comments index drop fro…
Sonnet 4.6
patch
80 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3
feat: render markdown mists as HTML with heading anchor links
Sonnet 4.6
patch
81 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6
Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump …
Human
82 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee
Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump …
Human
82 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c
Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo…
Human
82 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd
Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop…
Human
82 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f
fix: use wire_bytes not mpack_bytes_raw in compute_object_b…
Sonnet 4.6
patch
94 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583
rename: delta_add → delta_upsert across wire format, models…
Sonnet 4.6
patch
97 days ago