diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100644 index 0000000..f8721ae --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,219 @@ +#!/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