gabriel / musehub public
publish_muse_release.sh bash
220 lines 8.9 KB
Raw
sha256:32d3e5cf25fc1830ced478019e268f8a82ad098f031d3b06421b68e2340e3314 feat(deploy): publish_muse_release.sh now supports producti… Sonnet 5 minor ⚠ breaking 1 day ago
1 #!/usr/bin/env bash
2 # Publish a new muse CLI release to staging or production.
3 #
4 # Usage:
5 # bash deploy/publish_muse_release.sh staging # builds current version from ~/ecosystem/muse
6 # bash deploy/publish_muse_release.sh production
7 # MUSE_VERSION=0.2.1 bash deploy/publish_muse_release.sh staging # override version label
8 #
9 # The environment argument is required, always — no default. Given
10 # `production` really does publish to the public install.sh/releases URL,
11 # an accidental default (staging or otherwise) is exactly the kind of
12 # mistake this script should make impossible, not convenient.
13 #
14 # What it does:
15 # 1. Builds the muse sdist from ~/ecosystem/muse.
16 # 2. Uploads the tarball to s3://musehub-releases/muse-{version}.tar.gz
17 # (single shared, public-read bucket — see musehub staging #185 Phase 6
18 # for why this doesn't need per-environment IAM/bucket-policy changes).
19 # 3. SSMs to the target instance to pull the tarball from S3 into the
20 # musehub_data Docker volume's /data/releases/ (confirmed identically
21 # named on both staging and production — do not assume this for any
22 # future environment without checking first).
23 # 4. Removes stale tarballs from S3 and the target instance (keeps the 3
24 # most recent).
25 # 5. Verifies the tarball is live via {url}/releases/muse-{version}.tar.gz.
26 # 6. Runs deploy/smoke_muse.sh — installs into a throwaway venv and runs
27 # 20 CLI checks against the real published build.
28 #
29 # Prerequisites:
30 # - Python 3.14 + build package (pip install build)
31 # - AWS CLI configured with credentials that can reach the target account —
32 # staging uses the musehub-infra IAM user (Nonproduction account);
33 # production has no IAM users by design, so publishing there requires an
34 # IAM Identity Center SSO admin session, not a static credential.
35 # - ~/ecosystem/muse checked out at the version you want to ship
36
37 set -euo pipefail
38
39 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
40 MUSE_REPO="${MUSE_REPO:-$HOME/ecosystem/muse}"
41 S3_BUCKET="musehub-releases"
42 REGION="us-east-1"
43 KEEP_RELEASES=3 # number of tarballs to keep on S3 and server
44
45 log() { printf '\033[1;34m%s\033[0m\n' "$*" >&2; }
46 die() { printf '\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
47
48 # ── 0. Resolve environment (required, no default) ────────────────────────────
49
50 ENV="${1:-}"
51 case "$ENV" in
52 staging)
53 INSTANCE_ID="i-07547cd20bee2dea5"
54 BASE_URL="https://staging.musehub.ai"
55 ;;
56 production)
57 INSTANCE_ID="i-043aaed71bef11903"
58 BASE_URL="https://musehub.ai"
59 ;;
60 *)
61 die "Usage: bash deploy/publish_muse_release.sh <staging|production>"
62 ;;
63 esac
64
65 # ── 1. Resolve version ────────────────────────────────────────────────────────
66
67 if [ -z "${MUSE_VERSION:-}" ]; then
68 MUSE_VERSION=$(python3 -c "
69 import re, pathlib
70 t = pathlib.Path('$MUSE_REPO/pyproject.toml').read_text()
71 m = re.search(r'^version\s*=\s*\"([^\"]+)\"', t, re.MULTILINE)
72 print(m.group(1))
73 ")
74 fi
75
76 log "[1/5] Building muse $MUSE_VERSION from $MUSE_REPO"
77
78 # ── 2. Build sdist ────────────────────────────────────────────────────────────
79
80 cd "$MUSE_REPO"
81 python3 -m build --sdist --outdir dist/ 2>&1 | tail -3
82
83 TARBALL="dist/muse-${MUSE_VERSION}.tar.gz"
84 [ -f "$TARBALL" ] || die "Expected tarball not found: $MUSE_REPO/$TARBALL"
85 log " Built: $(du -sh "$TARBALL" | cut -f1) $TARBALL"
86
87 # ── 3. Upload to S3 ───────────────────────────────────────────────────────────
88
89 log "[2/5] Uploading to s3://$S3_BUCKET/"
90 aws s3 cp "$TARBALL" "s3://$S3_BUCKET/muse-${MUSE_VERSION}.tar.gz"
91
92 # ── 4. Pull from S3 into /data/releases/ on the target instance ─────────────
93
94 log "[3/5] Pushing to $ENV ($INSTANCE_ID)"
95
96 CMD="aws s3 cp s3://$S3_BUCKET/muse-${MUSE_VERSION}.tar.gz /tmp/muse-${MUSE_VERSION}.tar.gz && \
97 docker run --rm -v musehub_data:/data -v /tmp:/src alpine sh -c \
98 'mkdir -p /data/releases && cp /src/muse-${MUSE_VERSION}.tar.gz /data/releases/muse-${MUSE_VERSION}.tar.gz'"
99
100 CMD_ID=$(aws ssm send-command \
101 --region "$REGION" \
102 --instance-ids "$INSTANCE_ID" \
103 --document-name "AWS-RunShellScript" \
104 --parameters "commands=[\"$CMD\"]" \
105 --comment "publish muse $MUSE_VERSION" \
106 --timeout-seconds 120 \
107 --query "Command.CommandId" \
108 --output text)
109
110 log " SSM command: $CMD_ID — polling..."
111
112 for i in $(seq 1 24); do
113 sleep 5
114 STATUS=$(aws ssm get-command-invocation \
115 --region "$REGION" \
116 --command-id "$CMD_ID" \
117 --instance-id "$INSTANCE_ID" \
118 --query "Status" \
119 --output text 2>/dev/null || echo "Pending")
120 case "$STATUS" in
121 Success) log " ✅ Copy to $ENV succeeded."; break ;;
122 Failed|Cancelled|TimedOut) die "SSM command $STATUS" ;;
123 *) printf '.' >&2 ;;
124 esac
125 done
126 [ "$STATUS" = "Success" ] || die "SSM timed out (status: $STATUS)"
127
128 # ── 5. Clean up old releases (S3 + server, keep newest KEEP_RELEASES) ────────
129
130 log "[4/5] Cleaning up old releases (keeping $KEEP_RELEASES newest)"
131
132 # S3 cleanup — list, prune via PEP 440 precedence (packaging.version.Version,
133 # not sort -V's lexical comparison -- see issue #128), delete the stale set.
134 STALE_S3=$(aws s3 ls "s3://$S3_BUCKET/" \
135 | awk '{print $NF}' \
136 | grep '^muse-.*\.tar\.gz$' \
137 | xargs -r python3 "$SCRIPT_DIR/prune_releases.py" --keep "$KEEP_RELEASES")
138
139 if [ -n "$STALE_S3" ]; then
140 while IFS= read -r key; do
141 log " Deleting s3://$S3_BUCKET/$key"
142 aws s3 rm "s3://$S3_BUCKET/$key"
143 done <<< "$STALE_S3"
144 else
145 log " S3: nothing to remove."
146 fi
147
148 # Server cleanup — list remotely, compute the stale set LOCALLY with the same
149 # prune_releases.py call (Python/packaging availability on the bare EC2 host
150 # is not guaranteed), then send an explicit rm of the exact stale filenames.
151 # The remote shell never sorts or re-implements precedence logic itself.
152 LIST_CMD_ID=$(aws ssm send-command \
153 --region "$REGION" \
154 --instance-ids "$INSTANCE_ID" \
155 --document-name "AWS-RunShellScript" \
156 --parameters 'commands=["docker run --rm -v musehub_data:/data alpine sh -c \"ls /data/releases/muse-*.tar.gz 2>/dev/null | xargs -r -n1 basename\""]' \
157 --comment "list muse releases for pruning" \
158 --output text \
159 --query "Command.CommandId")
160
161 for i in $(seq 1 24); do
162 sleep 2
163 LIST_STATUS=$(aws ssm get-command-invocation \
164 --region "$REGION" \
165 --command-id "$LIST_CMD_ID" \
166 --instance-id "$INSTANCE_ID" \
167 --query "Status" \
168 --output text 2>/dev/null || echo "Pending")
169 case "$LIST_STATUS" in
170 Success) break ;;
171 Failed|Cancelled|TimedOut) die "SSM list command $LIST_STATUS" ;;
172 *) printf '.' >&2 ;;
173 esac
174 done
175 [ "$LIST_STATUS" = "Success" ] || die "SSM list command timed out (status: $LIST_STATUS)"
176
177 REMOTE_FILES=$(aws ssm get-command-invocation \
178 --region "$REGION" \
179 --command-id "$LIST_CMD_ID" \
180 --instance-id "$INSTANCE_ID" \
181 --query "StandardOutputContent" \
182 --output text)
183
184 STALE_SERVER=$(printf '%s' "$REMOTE_FILES" | xargs -r python3 "$SCRIPT_DIR/prune_releases.py" --keep "$KEEP_RELEASES")
185
186 if [ -n "$STALE_SERVER" ]; then
187 RM_ARGS=$(printf '%s\n' "$STALE_SERVER" | sed 's#^#/data/releases/#' | tr '\n' ' ')
188 aws ssm send-command \
189 --region "$REGION" \
190 --instance-ids "$INSTANCE_ID" \
191 --document-name "AWS-RunShellScript" \
192 --parameters "commands=[\"docker run --rm -v musehub_data:/data alpine sh -c 'rm -v $RM_ARGS'\"]" \
193 --comment "prune stale muse releases" \
194 --output text \
195 --query "Command.CommandId" > /dev/null
196 else
197 log " Server: nothing to remove."
198 fi
199
200 # ── 6. Smoke-test: verify tarball is live ─────────────────────────────────────
201
202 log "[5/5] Verifying $BASE_URL/releases/muse-${MUSE_VERSION}.tar.gz"
203
204 HTTP=$(curl -sI "$BASE_URL/releases/muse-${MUSE_VERSION}.tar.gz" \
205 | grep -i "^HTTP" | awk '{print $2}')
206
207 if [ "$HTTP" = "200" ]; then
208 log " ✅ Live at $BASE_URL/releases/muse-${MUSE_VERSION}.tar.gz"
209 else
210 die "Expected HTTP 200, got: $HTTP"
211 fi
212
213 # ── 7. Full smoke test ────────────────────────────────────────────────────────
214
215 log "[6/6] Running smoke test against $BASE_URL"
216 bash "$SCRIPT_DIR/smoke_muse.sh" --url "$BASE_URL" --version "$MUSE_VERSION"
217
218 log ""
219 log "muse $MUSE_VERSION is live. Install with:"
220 log " curl -fsSL $BASE_URL/install.sh | sh"
File History 1 commit
sha256:32d3e5cf25fc1830ced478019e268f8a82ad098f031d3b06421b68e2340e3314 feat(deploy): publish_muse_release.sh now supports producti… Sonnet 5 minor 1 day ago