#!/usr/bin/env bash set -Eeuo pipefail if [ "$(id -u)" -ne 0 ]; then exec sudo -n bash "$0" "$@"; fi SHA=${1:?Release SHA is required} [[ "$SHA" =~ ^[0-9a-f]{40}$ ]] || exit 1 ROOT=/opt/prompt PREVIOUS=$(readlink -f "$ROOT/current") test -f "$PREVIOUS/compose.yml" BASE=$(sed -n 's/^RELEASE_SHA=//p' "$PREVIOUS/release.env") test "$(git -c safe.directory="$PWD" rev-parse HEAD)" = "$SHA" # Never let a manually invoked fast release bypass the change policy. export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0="$PWD" test "$(bash scripts/release-mode.sh "$BASE")" = web IMAGE=$(sed -n 's/^PROMPT_IMAGE=//p' "$PREVIOUS/release.env") [[ "$IMAGE" =~ ^prompt:[0-9a-f]{40}$ ]] || exit 1 BUILDER="prompt-tests:${IMAGE#prompt:}" docker image inspect "$BUILDER" >/dev/null RELEASE="$ROOT/releases/$SHA" CACHE="$ROOT/web-cache/${IMAGE#prompt:}" mkdir -p "$ROOT/web-artifacts" "$ROOT/web-cache" "$ROOT/releases" exec 8>"$ROOT/.web-build.lock" flock -w 300 8 # Build away from the running application. Only the compiler cache is shared. ARTIFACT=$(mktemp -d "$ROOT/web-artifacts/$SHA.XXXXXX") mkdir -p "$CACHE" CONTAINER="prompt-web-build-$SHA" cleanup(){ docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; } trap cleanup EXIT # The existing validated test image contains the matching Node/toolchain/dependencies. # Replacing source in a disposable container does not commit/export image layers. docker run --name "$CONTAINER" --user 0:0 \ --mount "type=bind,src=$PWD,dst=/source,readonly" \ --mount "type=bind,src=$ARTIFACT,dst=/output" \ --mount "type=bind,src=$CACHE,dst=/app/.next/cache" \ --entrypoint bash "$BUILDER" -ceu ' cd /app rm -rf src public scripts tests db seed cp -a /source/src /source/public /source/scripts /source/tests /source/db /source/seed ./ cp /source/package.json /source/package-lock.json /source/tsconfig.json /source/next-env.d.ts /source/next.config.ts /source/postcss.config.mjs ./ npm run typecheck npm test node node_modules/next/dist/bin/next build node scripts/prepare-standalone.mjs npm run test:smoke cp -a .next/standalone/. /output/ chmod -R a+rX /output ' cleanup # Serialize cutover with full deployments and recheck the running release. exec 9>"$ROOT/.deploy.lock" flock -w 300 9 test "$(readlink -f "$ROOT/current")" = "$PREVIOUS" LATEST=$(git -c safe.directory="$PWD" ls-remote origin refs/heads/main | cut -f1) if [ "$LATEST" != "$SHA" ]; then echo 'Skipping stale Web release'; exit 0; fi mkdir -p "$RELEASE" cp "$PREVIOUS/release.env" "$RELEASE/release.env" sed -i "s/^RELEASE_SHA=.*/RELEASE_SHA=$SHA/" "$RELEASE/release.env" cat > "$RELEASE/web.override.yml" < "$RELEASE/compose.yml" compose_at(){ local dir="$1"; shift; env -u PROMPT_IMAGE -u PROMPT_TEST_IMAGE -u RELEASE_SHA -u WEB_PORT -u WEB_BIND_ADDRESS docker compose -p prompt --env-file /etc/prompt.env --env-file "$dir/release.env" -f "$dir/compose.yml" "$@"; } CUTOVER=0 rollback(){ code=$? trap - ERR if [ "$CUTOVER" -eq 1 ]; then compose_at "$PREVIOUS" up -d --no-deps --no-build --wait --wait-timeout 120 web || true; fi exit "$code" } trap rollback ERR CUTOVER=1 compose_at "$RELEASE" up -d --no-deps --no-build --wait --wait-timeout 120 web compose_at "$RELEASE" exec -T web node /app/container-health.mjs compose_at "$RELEASE" exec -T web node -e ' (async()=>{ for(const path of ["/", "/agent/index.md", "/tools/skills"]){ const r=await fetch("http://127.0.0.1:3000"+path); if(!r.ok)throw Error(path+" returned "+r.status); if(path.endsWith(".md")&&!r.headers.get("content-type")?.includes("text/markdown"))throw Error("Expected Markdown"); } })().catch(e=>{console.error(e.message);process.exit(1)})' ln -sfn "$RELEASE" "$ROOT/.current.next" mv -Tf "$ROOT/.current.next" "$ROOT/current" echo "Web-only release verified: $SHA; runtime image=$IMAGE; worker and infrastructure unchanged"