220 lines
4.9 KiB
Bash
220 lines
4.9 KiB
Bash
#!/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
|