gabriel / musehub public
publish_muse_release.sh bash
254 lines 10.6 KB
Raw
sha256:4c406127fd42cddb7bd2282519704e86a5ed7129fe2682617e23821e2bddb259 fix: publish_muse_release.sh failed against production on t… Sonnet 5 minor ⚠ breaking 3 days 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 # Staging's instance lives in the same Nonproduction account as the
56 # ambient default credentials (musehub-infra) — no profile needed.
57 PROFILE=""
58 ;;
59 production)
60 INSTANCE_ID="i-043aaed71bef11903"
61 BASE_URL="https://musehub.ai"
62 # Production is a separate AWS account with no static IAM user by
63 # design (see header comment) — the ambient default credentials
64 # never resolve here. Without this, every SSM call below silently
65 # runs against the Nonproduction account instead, which doesn't
66 # contain this instance ID at all: "InvalidInstanceId ... not in a
67 # valid state for account" — a confusing error that looks like a
68 # broken instance rather than a missing --profile.
69 PROFILE="musehub-production"
70 ;;
71 *)
72 die "Usage: bash deploy/publish_muse_release.sh <staging|production>"
73 ;;
74 esac
75
76 # S3 uploads/reads/deletes target the single shared, public-read release
77 # bucket (see header comment) and always use the ambient default
78 # credentials regardless of $ENV — only the per-instance SSM calls need
79 # $PROFILE_ARGS.
80 PROFILE_ARGS=()
81 if [ -n "$PROFILE" ]; then
82 PROFILE_ARGS=(--profile "$PROFILE")
83 fi
84
85 # ── 1. Resolve version ────────────────────────────────────────────────────────
86
87 if [ -z "${MUSE_VERSION:-}" ]; then
88 MUSE_VERSION=$(python3 -c "
89 import re, pathlib
90 t = pathlib.Path('$MUSE_REPO/pyproject.toml').read_text()
91 m = re.search(r'^version\s*=\s*\"([^\"]+)\"', t, re.MULTILINE)
92 print(m.group(1))
93 ")
94 fi
95
96 log "[1/5] Building muse $MUSE_VERSION from $MUSE_REPO"
97
98 # ── 2. Build sdist ────────────────────────────────────────────────────────────
99
100 cd "$MUSE_REPO"
101 python3 -m build --sdist --outdir dist/ 2>&1 | tail -3
102
103 TARBALL="dist/muse-${MUSE_VERSION}.tar.gz"
104 [ -f "$TARBALL" ] || die "Expected tarball not found: $MUSE_REPO/$TARBALL"
105 log " Built: $(du -sh "$TARBALL" | cut -f1) $TARBALL"
106
107 # ── 3. Upload to S3 ───────────────────────────────────────────────────────────
108
109 log "[2/5] Uploading to s3://$S3_BUCKET/"
110 aws s3 cp "$TARBALL" "s3://$S3_BUCKET/muse-${MUSE_VERSION}.tar.gz"
111
112 # ── 4. Pull from S3 into /data/releases/ on the target instance ─────────────
113
114 log "[3/5] Pushing to $ENV ($INSTANCE_ID)"
115
116 # --no-sign-request: this bucket is deliberately public-read (see step 3
117 # above), and the instance's own IAM role is not a reliable way to fetch
118 # from it -- discovered live on production, where an authenticated
119 # `aws s3 cp` got a 403 (an account-level guardrail denying s3:GetObject
120 # outright, overriding the bucket's public Allow) while an anonymous
121 # request succeeded. Downloading anonymously sidesteps the instance role
122 # entirely, matching how install.sh itself fetches releases over plain
123 # HTTPS with no credentials at all.
124 CMD="aws s3 cp --no-sign-request s3://$S3_BUCKET/muse-${MUSE_VERSION}.tar.gz /tmp/muse-${MUSE_VERSION}.tar.gz && \
125 docker run --rm -v musehub_data:/data -v /tmp:/src alpine sh -c \
126 'mkdir -p /data/releases && cp /src/muse-${MUSE_VERSION}.tar.gz /data/releases/muse-${MUSE_VERSION}.tar.gz'"
127
128 CMD_ID=$(aws ssm send-command \
129 --region "$REGION" \
130 "${PROFILE_ARGS[@]}" \
131 --instance-ids "$INSTANCE_ID" \
132 --document-name "AWS-RunShellScript" \
133 --parameters "commands=[\"$CMD\"]" \
134 --comment "publish muse $MUSE_VERSION" \
135 --timeout-seconds 120 \
136 --query "Command.CommandId" \
137 --output text)
138
139 log " SSM command: $CMD_ID — polling..."
140
141 for i in $(seq 1 24); do
142 sleep 5
143 STATUS=$(aws ssm get-command-invocation \
144 --region "$REGION" \
145 "${PROFILE_ARGS[@]}" \
146 --command-id "$CMD_ID" \
147 --instance-id "$INSTANCE_ID" \
148 --query "Status" \
149 --output text 2>/dev/null || echo "Pending")
150 case "$STATUS" in
151 Success) log " ✅ Copy to $ENV succeeded."; break ;;
152 Failed|Cancelled|TimedOut) die "SSM command $STATUS" ;;
153 *) printf '.' >&2 ;;
154 esac
155 done
156 [ "$STATUS" = "Success" ] || die "SSM timed out (status: $STATUS)"
157
158 # ── 5. Clean up old releases (S3 + server, keep newest KEEP_RELEASES) ────────
159
160 log "[4/5] Cleaning up old releases (keeping $KEEP_RELEASES newest)"
161
162 # S3 cleanup — list, prune via PEP 440 precedence (packaging.version.Version,
163 # not sort -V's lexical comparison -- see issue #128), delete the stale set.
164 STALE_S3=$(aws s3 ls "s3://$S3_BUCKET/" \
165 | awk '{print $NF}' \
166 | grep '^muse-.*\.tar\.gz$' \
167 | xargs -r python3 "$SCRIPT_DIR/prune_releases.py" --keep "$KEEP_RELEASES")
168
169 if [ -n "$STALE_S3" ]; then
170 while IFS= read -r key; do
171 log " Deleting s3://$S3_BUCKET/$key"
172 aws s3 rm "s3://$S3_BUCKET/$key"
173 done <<< "$STALE_S3"
174 else
175 log " S3: nothing to remove."
176 fi
177
178 # Server cleanup — list remotely, compute the stale set LOCALLY with the same
179 # prune_releases.py call (Python/packaging availability on the bare EC2 host
180 # is not guaranteed), then send an explicit rm of the exact stale filenames.
181 # The remote shell never sorts or re-implements precedence logic itself.
182 LIST_CMD_ID=$(aws ssm send-command \
183 --region "$REGION" \
184 "${PROFILE_ARGS[@]}" \
185 --instance-ids "$INSTANCE_ID" \
186 --document-name "AWS-RunShellScript" \
187 --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\""]' \
188 --comment "list muse releases for pruning" \
189 --output text \
190 --query "Command.CommandId")
191
192 for i in $(seq 1 24); do
193 sleep 2
194 LIST_STATUS=$(aws ssm get-command-invocation \
195 --region "$REGION" \
196 "${PROFILE_ARGS[@]}" \
197 --command-id "$LIST_CMD_ID" \
198 --instance-id "$INSTANCE_ID" \
199 --query "Status" \
200 --output text 2>/dev/null || echo "Pending")
201 case "$LIST_STATUS" in
202 Success) break ;;
203 Failed|Cancelled|TimedOut) die "SSM list command $LIST_STATUS" ;;
204 *) printf '.' >&2 ;;
205 esac
206 done
207 [ "$LIST_STATUS" = "Success" ] || die "SSM list command timed out (status: $LIST_STATUS)"
208
209 REMOTE_FILES=$(aws ssm get-command-invocation \
210 --region "$REGION" \
211 "${PROFILE_ARGS[@]}" \
212 --command-id "$LIST_CMD_ID" \
213 --instance-id "$INSTANCE_ID" \
214 --query "StandardOutputContent" \
215 --output text)
216
217 STALE_SERVER=$(printf '%s' "$REMOTE_FILES" | xargs -r python3 "$SCRIPT_DIR/prune_releases.py" --keep "$KEEP_RELEASES")
218
219 if [ -n "$STALE_SERVER" ]; then
220 RM_ARGS=$(printf '%s\n' "$STALE_SERVER" | sed 's#^#/data/releases/#' | tr '\n' ' ')
221 aws ssm send-command \
222 --region "$REGION" \
223 "${PROFILE_ARGS[@]}" \
224 --instance-ids "$INSTANCE_ID" \
225 --document-name "AWS-RunShellScript" \
226 --parameters "commands=[\"docker run --rm -v musehub_data:/data alpine sh -c 'rm -v $RM_ARGS'\"]" \
227 --comment "prune stale muse releases" \
228 --output text \
229 --query "Command.CommandId" > /dev/null
230 else
231 log " Server: nothing to remove."
232 fi
233
234 # ── 6. Smoke-test: verify tarball is live ─────────────────────────────────────
235
236 log "[5/5] Verifying $BASE_URL/releases/muse-${MUSE_VERSION}.tar.gz"
237
238 HTTP=$(curl -sI "$BASE_URL/releases/muse-${MUSE_VERSION}.tar.gz" \
239 | grep -i "^HTTP" | awk '{print $2}')
240
241 if [ "$HTTP" = "200" ]; then
242 log " ✅ Live at $BASE_URL/releases/muse-${MUSE_VERSION}.tar.gz"
243 else
244 die "Expected HTTP 200, got: $HTTP"
245 fi
246
247 # ── 7. Full smoke test ────────────────────────────────────────────────────────
248
249 log "[6/6] Running smoke test against $BASE_URL"
250 bash "$SCRIPT_DIR/smoke_muse.sh" --url "$BASE_URL" --version "$MUSE_VERSION"
251
252 log ""
253 log "muse $MUSE_VERSION is live. Install with:"
254 log " curl -fsSL $BASE_URL/install.sh | sh"
File History 1 commit
sha256:4c406127fd42cddb7bd2282519704e86a5ed7129fe2682617e23821e2bddb259 fix: publish_muse_release.sh failed against production on t… Sonnet 5 minor 3 days ago