#!/usr/bin/env bash set -Eeuo pipefail umask 077 SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" SERVICE_NAME="todolist.service" REMOTE_NAME="origin" RELEASE_BRANCH="main" HEALTH_URL="http://127.0.0.1:8081/api/todos" BACKUP_DIR="$HOME/backups/todolist" LOCK_FILE="${XDG_RUNTIME_DIR:-/tmp}/todolist-release.lock" CURRENT_STEP="initialization" OLD_COMMIT="" NEW_COMMIT="" BACKUP_FILE="" step() { CURRENT_STEP="$1" printf '\n==> %s\n' "$CURRENT_STEP" } fail() { echo "Error: $*" >&2 exit 1 } show_failure_context() { local exit_code=$? printf '\nRelease failed during: %s\n' "$CURRENT_STEP" >&2 if [[ -n "$OLD_COMMIT" ]]; then echo "Previous commit: $OLD_COMMIT" >&2 fi if [[ -n "$NEW_COMMIT" ]]; then echo "Current commit: $NEW_COMMIT" >&2 fi if [[ -n "$BACKUP_FILE" && -f "$BACKUP_FILE" ]]; then echo "Database backup: $BACKUP_FILE" >&2 fi systemctl --user status "$SERVICE_NAME" --no-pager >&2 || true journalctl --user -u "$SERVICE_NAME" -n 50 --no-pager >&2 || true exit "$exit_code" } trap show_failure_context ERR cd "$PROJECT_DIR" step "Preflight checks" for command_name in git uv mysqldump curl systemctl flock; do command -v "$command_name" >/dev/null 2>&1 || fail "required command not found: $command_name" done [[ -d ".git" ]] || fail "$PROJECT_DIR is not a Git repository" [[ -r ".env" ]] || fail "$PROJECT_DIR/.env is missing or unreadable" exec 9>"$LOCK_FILE" flock -n 9 || fail "another TodoList release is already running" CURRENT_BRANCH="$(git branch --show-current)" [[ "$CURRENT_BRANCH" == "$RELEASE_BRANCH" ]] || fail "release must run on $RELEASE_BRANCH; current branch is $CURRENT_BRANCH" if [[ -n "$(git status --porcelain)" ]]; then echo "Error: Git working tree is not clean" >&2 git status --short >&2 exit 1 fi systemctl --user is-active --quiet "$SERVICE_NAME" || fail "$SERVICE_NAME is not currently active" OLD_COMMIT="$(git rev-parse HEAD)" echo "Project: $PROJECT_DIR" echo "Branch: $CURRENT_BRANCH" echo "Service: $SERVICE_NAME" echo "Current: $OLD_COMMIT" step "Fetch latest code" git fetch --prune "$REMOTE_NAME" "$RELEASE_BRANCH" REMOTE_COMMIT="$(git rev-parse "$REMOTE_NAME/$RELEASE_BRANCH")" MERGE_BASE="$(git merge-base HEAD "$REMOTE_NAME/$RELEASE_BRANCH")" [[ "$MERGE_BASE" == "$OLD_COMMIT" ]] || fail "local branch contains unpushed commits or has diverged from $REMOTE_NAME/$RELEASE_BRANCH" step "Load database settings" eval "$( uv run python - <<'PYENV' import shlex from dotenv import dotenv_values required_keys = ( "MYSQL_HOST", "MYSQL_PORT", "MYSQL_USER", "MYSQL_PASSWORD", "MYSQL_DATABASE", ) values = dotenv_values(".env") missing = [key for key in required_keys if not values.get(key)] if missing: raise SystemExit( "Missing required variables in .env: " + ", ".join(missing) ) for key in required_keys: print(f"export {key}={shlex.quote(str(values[key]))}") PYENV )" step "Back up database" mkdir -p "$BACKUP_DIR" chmod 700 "$BACKUP_DIR" TIMESTAMP="$(date '+%Y%m%d-%H%M%S')" BACKUP_FILE="$BACKUP_DIR/${MYSQL_DATABASE}-${TIMESTAMP}-${REMOTE_COMMIT:0:12}.sql" MYSQL_PWD="$MYSQL_PASSWORD" mysqldump \ --no-tablespaces \ --single-transaction \ --quick \ --skip-lock-tables \ --host="$MYSQL_HOST" \ --port="$MYSQL_PORT" \ --user="$MYSQL_USER" \ "$MYSQL_DATABASE" \ > "$BACKUP_FILE" [[ -s "$BACKUP_FILE" ]] || fail "database backup file is empty" echo "Backup: $BACKUP_FILE" step "Update working tree" git merge --ff-only "$REMOTE_NAME/$RELEASE_BRANCH" NEW_COMMIT="$(git rev-parse HEAD)" echo "Updated to: $NEW_COMMIT" step "Synchronize Python dependencies" uv sync --frozen step "Run code checks" uv run ruff check . uv run ruff format --check . uv run python -c "import main" step "Apply database migrations" uv run alembic upgrade head uv run alembic current --check-heads step "Restart TodoList user service" systemctl --user daemon-reload systemctl --user restart "$SERVICE_NAME" systemctl --user is-active --quiet "$SERVICE_NAME" step "Run health check" HEALTHY="false" for attempt in {1..15}; do if curl \ --fail \ --silent \ --show-error \ --max-time 3 \ "$HEALTH_URL" \ >/dev/null; then HEALTHY="true" break fi echo "Health check attempt $attempt/15 failed; retrying..." sleep 1 done if [[ "$HEALTHY" != "true" ]]; then echo "Error: health check failed: $HEALTH_URL" >&2 systemctl --user status "$SERVICE_NAME" --no-pager >&2 || true journalctl --user -u "$SERVICE_NAME" -n 50 --no-pager >&2 || true exit 1 fi printf '\nTodoList release succeeded\n' echo "Previous commit: $OLD_COMMIT" echo "Current commit: $NEW_COMMIT" echo "Database backup: $BACKUP_FILE" echo "Health check: $HEALTH_URL" git log -1 --oneline