#!/usr/bin/env bash set -euo pipefail set +x required=(DEPLOY_PATH EXPECTED_DEPLOY_SHA ROLLOUT_AUDIENCE STAGING_URL) for key in "${required[@]}"; do if [ -z "${!key:-}" ]; then echo "required staging rollout input is missing: $key" >&2 exit 1 fi done sha_pattern='^[0-9a-f]{40}$' uuid_pattern='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' [[ "$EXPECTED_DEPLOY_SHA" =~ $sha_pattern ]] || { echo "invalid expected deployment SHA" >&2 exit 1 } case "$ROLLOUT_AUDIENCE" in paused|smoke_only|public) ;; *) echo "invalid rollout audience" >&2; exit 1 ;; esac smoke_user_ids="${SYNTHETIC_SMOKE_USER_IDS:-}" if [ "$ROLLOUT_AUDIENCE" = "smoke_only" ]; then [ -n "$smoke_user_ids" ] || { echo "smoke_only requires at least one synthetic user UUID" >&2 exit 1 } IFS=',' read -ra smoke_users <<<"$smoke_user_ids" for user_id in "${smoke_users[@]}"; do [[ "$user_id" =~ $uuid_pattern ]] || { echo "invalid synthetic smoke user UUID" >&2 exit 1 } done else [ -z "$smoke_user_ids" ] || { echo "synthetic smoke users are only valid for smoke_only" >&2 exit 1 } fi state_directory="$DEPLOY_PATH/.state" env_file="$DEPLOY_PATH/.env.staging" install -d -m 700 "$state_directory" exec 9>"$state_directory/mutation.lock" flock -n 9 || { echo "another staging mutation holds the host lock" >&2 exit 75 } compose_files=( -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml -f deploy/docker-compose.staging.yml ) [ -f "$env_file" ] && [ ! -L "$env_file" ] || { echo "staging environment file is missing or unsafe" >&2 exit 1 } EXPECTED_STAGING_ENV_OWNER_UID="$(stat -c '%u' "$DEPLOY_PATH" 2>/dev/null || stat -f '%u' "$DEPLOY_PATH")" EXPECTED_STAGING_ENV_OWNER_GID="$(stat -c '%g' "$DEPLOY_PATH" 2>/dev/null || stat -f '%g' "$DEPLOY_PATH")" [[ "$EXPECTED_STAGING_ENV_OWNER_UID" =~ ^[0-9]+$ && "$EXPECTED_STAGING_ENV_OWNER_GID" =~ ^[0-9]+$ ]] || { echo "staging deployment owner is invalid" >&2 exit 1 } export EXPECTED_STAGING_ENV_OWNER_UID bash "$DEPLOY_PATH/deploy/validate-staging-env.sh" "$env_file" current_sha="$(<"$state_directory/deployed-revision")" [ "$current_sha" = "$EXPECTED_DEPLOY_SHA" ] || { echo "deployed staging revision does not match the approved rollout SHA" >&2 exit 1 } case "$ROLLOUT_AUDIENCE" in public) creation_enabled=true smoke_sha="$EXPECTED_DEPLOY_SHA" smoke_user_ids="" ;; smoke_only) creation_enabled=true smoke_sha="" ;; paused) creation_enabled=false smoke_sha="" smoke_user_ids="" ;; esac backup="$(mktemp "$state_directory/rectification-rollout-backup.XXXXXX")" temporary="$(mktemp "$DEPLOY_PATH/.env.staging.rollout.XXXXXX")" declare -a compose=() cleanup() { rm -f -- "$backup" "$temporary"; } rollback() { local status=$? cp -p -- "$backup" "$env_file" if [ "${#compose[@]}" -gt 0 ]; then "${compose[@]}" up -d --no-build --pull never --force-recreate --no-deps web rectification-v4-worker >/dev/null 2>&1 || true fi exit "$status" } trap cleanup EXIT cp -p -- "$env_file" "$backup" awk \ -v create="$creation_enabled" \ -v migrations="true" \ -v smoke_sha="$smoke_sha" \ -v smoke_users="$smoke_user_ids" \ -v agent_enabled="$creation_enabled" ' BEGIN { values["RECTIFICATION_V3_CREATE_ENABLED"] = create values["RECTIFICATION_V3_MIGRATIONS_READY"] = migrations values["RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA"] = smoke_sha values["RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS"] = smoke_users values["RECTIFICATION_AGENT_V5_ENABLED"] = agent_enabled values["RECTIFICATION_AGENT_V5_SHADOW"] = "false" values["RECTIFICATION_AGENT_V5_CANARY_PERCENT"] = "100" } { split($0, parts, "=") key = parts[1] if (key in values) { if (!(key in written)) print key "=" values[key] written[key] = 1 next } print } END { for (key in values) if (!(key in written)) print key "=" values[key] } ' "$env_file" >"$temporary" chown "$EXPECTED_STAGING_ENV_OWNER_UID:$EXPECTED_STAGING_ENV_OWNER_GID" "$temporary" chmod 600 "$temporary" cd "$DEPLOY_PATH" bash deploy/validate-staging-env.sh "$temporary" staging.jyotisha.chat deploy/Caddyfile.staging mv -f -- "$temporary" "$env_file" trap rollback ERR web_container="$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1)" [ -n "$web_container" ] || { echo "staging web container is missing" >&2 false } export WEB_IMAGE="$(docker inspect --format '{{.Config.Image}}' "$web_container")" export APP_ENV_FILE='../.env.staging' export DATABASE_ENV_FILE='../.env.staging.database' export CADDYFILE_PATH='./Caddyfile.staging' export SITE_ADDRESS='https://staging.jyotisha.chat' export GITHUB_SHA="$EXPECTED_DEPLOY_SHA" compose=(docker compose -p jyotisha-staging --env-file .env.staging "${compose_files[@]}") "${compose[@]}" config --quiet "${compose[@]}" up -d --no-build --pull never --force-recreate --no-deps web rectification-v4-worker for service in web rectification-v4-worker; do container="$(docker ps -q --filter 'label=com.docker.compose.project=jyotisha-staging' --filter "label=com.docker.compose.service=$service" | head -n 1)" [ -n "$container" ] || { echo "staging $service container is missing after rollout" >&2 false } runtime_env="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$container")" grep -Fxq "RECTIFICATION_AGENT_V5_ENABLED=$creation_enabled" <<<"$runtime_env" grep -Fxq "RECTIFICATION_AGENT_V5_SHADOW=false" <<<"$runtime_env" grep -Fxq "RECTIFICATION_AGENT_V5_CANARY_PERCENT=100" <<<"$runtime_env" done health="" for _ in $(seq 1 30); do health="$(curl --fail --silent --show-error "$STAGING_URL/api/health" 2>/dev/null || true)" expected_ready=false [ "$ROLLOUT_AUDIENCE" = public ] && expected_ready=true if grep -Fq "\"gitCommit\":\"$EXPECTED_DEPLOY_SHA\"" <<<"$health" && grep -Fq "\"creationAudience\":\"$ROLLOUT_AUDIENCE\"" <<<"$health" && grep -Fq "\"readyForNewCases\":$expected_ready" <<<"$health"; then trap - ERR printf 'rectification rollout audience=%s deployed_sha=%s ready_for_new_cases=%s\n' \ "$ROLLOUT_AUDIENCE" "$EXPECTED_DEPLOY_SHA" "$([ "$ROLLOUT_AUDIENCE" = public ] && echo true || echo false)" exit 0 fi sleep 2 done echo "staging rollout health verification failed" >&2 false