Auto-Update Script Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Implement bin/update.sh — a shell script that auto-updates from origin/main with mode-aware restarts, rollback support, env file sync, and notification on success/failure.
Architecture: Pure shell script sourcing bin/lib/ helpers. A new bin/lib/update.sh library holds the update logic, following the pattern of bin/lib/health_check.sh and bin/lib/security_check.sh. The main script bin/update.sh parses flags and calls the library. bin/setup_cron.sh gets a new prompt for auto-updates.
Tech Stack: Bash, git, existing bin/lib/ helpers (colors, paths, logging, checks, health_check for detect_mode).
Task 1: Create the update library skeleton
Files:
- Create:
bin/lib/update.sh
Step 1: Create bin/lib/update.sh with state, logging, and mode detection
#!/usr/bin/env bash
#
# Auto-update library.
# Pulls from origin/main, syncs deps, migrates, restarts services.
# Source this file — do not execute directly.
#
[[ -n "${_LIB_UPDATE_LOADED:-}" ]] && return 0
_LIB_UPDATE_LOADED=1
_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$_LIB_DIR/colors.sh"
source "$_LIB_DIR/paths.sh"
source "$_LIB_DIR/logging.sh"
source "$_LIB_DIR/checks.sh"
source "$_LIB_DIR/health_check.sh"
# --- State ---
_up_dry_run=false
_up_rollback_enabled=false
_up_json_mode=false
_up_log_file="$PROJECT_DIR/update.log"
_up_auto_env=false
_up_saved_sha=""
_up_new_sha=""
_up_failed_step=""
_up_mode=""
# --- Logging ---
_up_log() {
local level="$1"
shift
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
local msg="[$timestamp] [$level] $*"
echo "$msg" >> "$_up_log_file"
if [ "$_up_json_mode" = false ]; then
case "$level" in
INFO) info "$@" ;;
OK) success "$@" ;;
WARN) warn "$@" ;;
ERROR) error "$@" ;;
esac
fi
}
# --- Notification (best-effort) ---
_up_notify() {
local title="$1" message="$2" severity="${3:-info}"
if [ "$_up_dry_run" = true ]; then
_up_log "INFO" "Dry run: would notify — $title: $message"
return 0
fi
if [ ! -d "$PROJECT_DIR/.venv" ] || ! command_exists uv; then
_up_log "WARN" "Cannot notify (no .venv or uv) — $title: $message"
return 0
fi
# Best-effort: suppress all errors
uv run python manage.py test_notify \
--non-interactive \
--title "$title" \
--message "$message" \
--severity "$severity" \
&>/dev/null || true
}
Step 2: Verify syntax
Run: bash -n bin/lib/update.sh Expected: no output (clean parse)
Step 3: Commit
git add bin/lib/update.sh
git commit -m "feat: add update library skeleton with logging and notification"
Task 2: Implement the update check (git fetch + compare)
Files:
- Modify:
bin/lib/update.sh
Step 1: Add the update check function after the notification section
# --- Update check ---
_up_check_for_updates() {
_up_log "INFO" "Fetching from origin/main..."
if [ "$_up_dry_run" = true ]; then
_up_log "INFO" "Dry run: would run git fetch origin main"
# Still fetch in dry-run so we can report what would change
fi
if ! git -C "$PROJECT_DIR" fetch origin main --quiet 2>/dev/null; then
_up_log "ERROR" "git fetch failed — check network connectivity and git remote config"
return 1
fi
_up_saved_sha=$(git -C "$PROJECT_DIR" rev-parse HEAD)
local remote_sha
remote_sha=$(git -C "$PROJECT_DIR" rev-parse origin/main)
if [ "$_up_saved_sha" = "$remote_sha" ]; then
_up_log "INFO" "Already up to date ($(_up_short_sha "$_up_saved_sha"))"
return 2 # Special: up to date, not an error
fi
local commit_count
commit_count=$(git -C "$PROJECT_DIR" rev-list HEAD..origin/main --count)
_up_log "INFO" "Update available: $commit_count new commit(s) ($(_up_short_sha "$_up_saved_sha") → $(_up_short_sha "$remote_sha"))"
return 0
}
_up_short_sha() {
echo "${1:0:7}"
}
Step 2: Verify syntax
Run: bash -n bin/lib/update.sh Expected: no output
Step 3: Commit
git add bin/lib/update.sh
git commit -m "feat: add git fetch and update check logic"
Task 3: Implement the update steps (pull, sync, migrate, restart)
Files:
- Modify:
bin/lib/update.sh
Step 1: Add the update step functions
# --- Update steps ---
_up_pull() {
_up_log "INFO" "Pulling from origin/main..."
if [ "$_up_dry_run" = true ]; then
_up_log "INFO" "Dry run: would run git pull origin main"
return 0
fi
if ! git -C "$PROJECT_DIR" pull origin main --quiet 2>&1; then
_up_failed_step="git pull"
_up_log "ERROR" "git pull failed"
return 1
fi
_up_new_sha=$(git -C "$PROJECT_DIR" rev-parse HEAD)
_up_log "OK" "Pulled to $(_up_short_sha "$_up_new_sha")"
}
_up_sync_deps() {
_up_mode=$(detect_mode)
_up_log "INFO" "Syncing dependencies (mode: $_up_mode)..."
if [ "$_up_dry_run" = true ]; then
_up_log "INFO" "Dry run: would sync dependencies for $_up_mode mode"
return 0
fi
# Docker mode: deps are handled by docker compose build in restart step
if [ "$_up_mode" = "docker" ]; then
_up_log "INFO" "Docker mode — deps will sync during image rebuild"
return 0
fi
if ! command_exists uv; then
_up_failed_step="uv sync"
_up_log "ERROR" "uv not found — cannot sync dependencies"
return 1
fi
local sync_cmd="uv sync"
if [ "$_up_mode" = "dev" ]; then
sync_cmd="uv sync --all-extras --dev"
fi
if ! (cd "$PROJECT_DIR" && $sync_cmd 2>&1); then
_up_failed_step="uv sync"
_up_log "ERROR" "Dependency sync failed"
return 1
fi
_up_log "OK" "Dependencies synced"
}
_up_migrate() {
_up_log "INFO" "Running database migrations..."
if [ "$_up_dry_run" = true ]; then
_up_log "INFO" "Dry run: would run migrations"
return 0
fi
# Docker mode: migrations run inside the container
if [ "$_up_mode" = "docker" ]; then
_up_log "INFO" "Docker mode — migrations will run inside container"
return 0
fi
if ! (cd "$PROJECT_DIR" && uv run python manage.py migrate --no-input 2>&1); then
_up_failed_step="migrate"
_up_log "ERROR" "Database migration failed"
return 1
fi
_up_log "OK" "Migrations applied"
}
_up_restart() {
_up_log "INFO" "Restarting services (mode: $_up_mode)..."
if [ "$_up_dry_run" = true ]; then
_up_log "INFO" "Dry run: would restart services for $_up_mode mode"
return 0
fi
case "$_up_mode" in
dev)
_up_log "INFO" "Dev mode — no restart needed (runserver auto-reloads)"
;;
prod|systemd)
if ! sudo systemctl restart server-monitoring server-monitoring-celery 2>&1; then
_up_failed_step="restart"
_up_log "ERROR" "systemd restart failed"
return 1
fi
_up_log "OK" "systemd services restarted"
;;
docker)
local compose_file="$PROJECT_DIR/deploy/docker/docker-compose.yml"
if ! docker compose -f "$compose_file" up -d --build 2>&1; then
_up_failed_step="restart"
_up_log "ERROR" "docker compose restart failed"
return 1
fi
_up_log "OK" "Docker containers rebuilt and restarted"
;;
*)
_up_log "WARN" "Unknown mode '$_up_mode' — skipping restart"
;;
esac
}
Step 2: Verify syntax
Run: bash -n bin/lib/update.sh Expected: no output
Step 3: Commit
git add bin/lib/update.sh
git commit -m "feat: add update steps (pull, sync, migrate, restart)"
Task 4: Implement env file sync
Files:
- Modify:
bin/lib/update.sh
Step 1: Add the env sync function after _up_pull and before _up_sync_deps
# --- Env file sync ---
_up_sync_env() {
local env_file="$PROJECT_DIR/.env"
local sample_file="$PROJECT_DIR/.env.sample"
if [ ! -f "$sample_file" ]; then
_up_log "INFO" "No .env.sample found — skipping env sync"
return 0
fi
if [ ! -f "$env_file" ]; then
_up_log "WARN" ".env not found — skipping env sync"
return 0
fi
# Find keys in .env.sample that are missing from .env
local missing_keys=()
local missing_lines=()
while IFS= read -r line; do
# Skip comments and blank lines
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "$line" ]] && continue
local key
key=$(echo "$line" | cut -d'=' -f1 | tr -d '[:space:]')
[ -z "$key" ] && continue
if ! grep -q "^${key}=" "$env_file" 2>/dev/null; then
missing_keys+=("$key")
missing_lines+=("$line")
fi
done < "$sample_file"
if [ "${#missing_keys[@]}" -eq 0 ]; then
_up_log "INFO" ".env is up to date with .env.sample"
return 0
fi
if [ "$_up_auto_env" = true ]; then
_up_log "INFO" "Auto-appending ${#missing_keys[@]} missing key(s) to .env"
echo "" >> "$env_file"
echo "# --- Added by update.sh ($(date '+%Y-%m-%d %H:%M:%S')) ---" >> "$env_file"
for line in "${missing_lines[@]}"; do
echo "$line" >> "$env_file"
_up_log "INFO" " Added: $line"
done
else
_up_log "WARN" "${#missing_keys[@]} new key(s) in .env.sample not in .env:"
for i in "${!missing_keys[@]}"; do
_up_log "WARN" " ${missing_keys[$i]} (sample: ${missing_lines[$i]})"
done
_up_log "WARN" "Run with --auto-env to add them automatically, or add manually to .env"
fi
}
Step 2: Verify syntax
Run: bash -n bin/lib/update.sh Expected: no output
Step 3: Commit
git add bin/lib/update.sh
git commit -m "feat: add env file sync (--auto-env flag)"
Task 5: Implement rollback
Files:
- Modify:
bin/lib/update.sh
Step 1: Add the rollback function
# --- Rollback ---
_up_rollback() {
if [ -z "$_up_saved_sha" ]; then
_up_log "ERROR" "No saved SHA to rollback to"
return 1
fi
_up_log "WARN" "Rolling back to $(_up_short_sha "$_up_saved_sha")..."
if ! git -C "$PROJECT_DIR" reset --hard "$_up_saved_sha" 2>&1; then
_up_log "ERROR" "git reset failed — manual intervention required"
return 1
fi
_up_log "INFO" "Code rolled back. Re-syncing dependencies..."
# Re-sync deps and migrate after rollback
_up_sync_deps || true
_up_migrate || true
_up_restart || true
_up_log "OK" "Rollback complete"
}
Step 2: Verify syntax
Run: bash -n bin/lib/update.sh Expected: no output
Step 3: Commit
git add bin/lib/update.sh
git commit -m "feat: add rollback support for failed updates"
Task 6: Implement the orchestrator
Files:
- Modify:
bin/lib/update.sh
Step 1: Add the main run_update orchestrator function
# --- Orchestrator ---
run_update() {
_up_log "INFO" "=== Update check started ==="
# Check for updates
_up_check_for_updates
local check_result=$?
if [ "$check_result" -eq 2 ]; then
# Up to date
if [ "$_up_json_mode" = true ]; then
printf '{"status":"up_to_date","sha":"%s"}\n' "$_up_saved_sha"
fi
return 0
elif [ "$check_result" -ne 0 ]; then
# Fetch failed
if [ "$_up_json_mode" = true ]; then
printf '{"status":"error","step":"fetch","message":"git fetch failed"}\n'
fi
return 1
fi
# Run update steps
local steps=("_up_pull" "_up_sync_env" "_up_sync_deps" "_up_migrate" "_up_restart")
local failed=false
for step in "${steps[@]}"; do
if ! $step; then
failed=true
break
fi
done
if [ "$failed" = true ]; then
local commit_count
commit_count=$(git -C "$PROJECT_DIR" rev-list "$_up_saved_sha"..HEAD --count 2>/dev/null || echo "?")
local err_msg="Failed at step: $_up_failed_step. Rolled back: "
if [ "$_up_rollback_enabled" = true ]; then
_up_rollback
err_msg="${err_msg}yes"
else
err_msg="${err_msg}no"
fi
_up_log "ERROR" "$err_msg"
_up_notify "Update Failed" "$err_msg" "critical"
if [ "$_up_json_mode" = true ]; then
printf '{"status":"error","step":"%s","old_sha":"%s","rolled_back":%s}\n' \
"$_up_failed_step" "$_up_saved_sha" \
"$([ "$_up_rollback_enabled" = true ] && echo "true" || echo "false")"
fi
_up_log "INFO" "=== Update failed ==="
return 1
fi
# Success
local commit_count
commit_count=$(git -C "$PROJECT_DIR" rev-list "$_up_saved_sha".."$_up_new_sha" --count 2>/dev/null || echo "?")
_up_log "OK" "Update complete: $(_up_short_sha "$_up_saved_sha") → $(_up_short_sha "$_up_new_sha") ($commit_count commits)"
_up_notify \
"Update Succeeded" \
"Updated from $(_up_short_sha "$_up_saved_sha") to $(_up_short_sha "$_up_new_sha") ($commit_count commits)" \
"success"
if [ "$_up_json_mode" = true ]; then
printf '{"status":"updated","old_sha":"%s","new_sha":"%s","commits":%s,"mode":"%s"}\n' \
"$_up_saved_sha" "$_up_new_sha" "$commit_count" "$_up_mode"
fi
_up_log "INFO" "=== Update succeeded ==="
return 0
}
Step 2: Verify syntax
Run: bash -n bin/lib/update.sh Expected: no output
Step 3: Commit
git add bin/lib/update.sh
git commit -m "feat: add update orchestrator with JSON output and notifications"
Task 7: Create the main script
Files:
- Create:
bin/update.sh
Step 1: Create bin/update.sh
#!/bin/bash
#
# Auto-update script for server-maintanence
# Pulls from origin/main, syncs deps, migrates, restarts.
#
set -e
# Source shared libraries
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/update.sh"
cd "$PROJECT_DIR"
# Parse flags
for arg in "$@"; do
case $arg in
--rollback) _up_rollback_enabled=true ;;
--auto-env) _up_auto_env=true ;;
--dry-run) _up_dry_run=true ;;
--json) _up_json_mode=true ;;
--help|-h)
echo "Usage: bin/update.sh [OPTIONS]"
echo ""
echo "Check for updates and apply them from origin/main."
echo "Syncs dependencies, runs migrations, and restarts services."
echo ""
echo "Options:"
echo " --rollback Revert to previous version on failure"
echo " --auto-env Auto-append new .env.sample keys to .env"
echo " --dry-run Show what would happen without applying"
echo " --json Output as JSON"
echo " --help, -h Show this help"
exit 0
;;
esac
done
run_update
Step 2: Make executable and verify syntax
Run: chmod +x bin/update.sh && bash -n bin/update.sh Expected: no output
Step 3: Smoke test
Run: bin/update.sh --help Expected: usage text showing all flags
Run: bin/update.sh --dry-run Expected: shows what would happen (fetch, compare, report)
Step 4: Commit
git add bin/update.sh
git commit -m "feat: add bin/update.sh entry point"
Task 8: Update setup_cron.sh
Files:
- Modify:
bin/setup_cron.sh
Step 1: Add auto-update prompt after the health check cron is added
After line 91 (after the health check cron job is added via crontab), add the auto-update prompt:
# --- Auto-update option ---
echo ""
read -p "Enable automatic updates (pulls from origin/main on same schedule)? [y/N] " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
UPDATE_CMD="cd $PROJECT_DIR && $BIN_DIR/update.sh --rollback --auto-env >> $PROJECT_DIR/update.log 2>&1"
UPDATE_ID="# server-maintanence auto-update"
# Remove existing update job if present
crontab -l 2>/dev/null | grep -v -F "$UPDATE_ID" | crontab -
# Add update job on same schedule
(crontab -l 2>/dev/null || true; echo "$CRON_SCHEDULE $UPDATE_CMD $UPDATE_ID") | crontab -
success "Auto-update cron job added (with --rollback enabled)"
info "Update log: $PROJECT_DIR/update.log"
fi
The update cron uses --rollback by default so unattended updates are safe.
Step 2: Verify syntax
Run: bash -n bin/setup_cron.sh Expected: no output
Step 3: Commit
git add bin/setup_cron.sh
git commit -m "feat: add auto-update option to cron setup"
Task 9: Write bats tests
Files:
- Create:
bin/tests/test_update.bats
Step 1: Create bats test file
#!/usr/bin/env bats
setup() {
load 'test_helper/common-setup'
_common_setup
}
@test "update.sh passes syntax check" {
run bash -n "$BIN_DIR/update.sh"
assert_success
}
@test "update library passes syntax check" {
run bash -n "$LIB_DIR/update.sh"
assert_success
}
@test "update.sh --help shows usage" {
run "$BIN_DIR/update.sh" --help
assert_success
assert_output --partial "Usage"
assert_output --partial "--rollback"
assert_output --partial "--dry-run"
assert_output --partial "--json"
assert_output --partial "--auto-env"
}
@test "update.sh --dry-run does not modify repo" {
local sha_before
sha_before=$(git -C "$PROJECT_DIR" rev-parse HEAD)
run "$BIN_DIR/update.sh" --dry-run
local sha_after
sha_after=$(git -C "$PROJECT_DIR" rev-parse HEAD)
assert_equal "$sha_before" "$sha_after"
}
@test "update.sh --dry-run --json outputs JSON" {
run "$BIN_DIR/update.sh" --dry-run --json
[[ "${output}" == "{"* ]]
}
@test "setup_cron.sh passes syntax check" {
run bash -n "$BIN_DIR/setup_cron.sh"
assert_success
}
Step 2: Run the tests
Run: bin/tests/test_helper/bats-core/bin/bats bin/tests/test_update.bats Expected: all tests pass
Step 3: Commit
git add bin/tests/test_update.bats
git commit -m "test: add bats tests for update.sh"
Task 10: Update docs
Files:
- Modify:
bin/README.md
Step 1: Add sm-update to the Quick Command Reference table
Add row: | \sm-update` | — | — | Auto-update from origin/main |`
Step 2: Add update.sh section before the check_security.sh section
---
### `update.sh` — Auto-Update
Checks for updates from `origin/main` and applies them. Syncs dependencies, runs migrations, and restarts services based on the detected deployment mode.
\`\`\`bash
# Check and apply updates
./bin/update.sh
# Dry run (show what would happen)
./bin/update.sh --dry-run
# Enable automatic rollback on failure
./bin/update.sh --rollback
# JSON output (for CI or monitoring)
./bin/update.sh --json
\`\`\`
**What it does:**
1. `git fetch origin main` — check for new commits
2. `git pull origin main` — apply changes
3. `uv sync` — sync dependencies (mode-aware)
4. `python manage.py migrate` — apply database migrations
5. Restart services (systemd, docker compose, or skip for dev)
6. Notify on success or failure (best-effort)
**Flags:**
- `--rollback` — revert to previous version if any step fails
- `--auto-env` — auto-append new `.env.sample` keys to `.env`
- `--dry-run` — preview without applying
- `--json` — JSON output
**Exit codes:** `0` = up to date or updated, `1` = error.
**Cron:** Run `./bin/setup_cron.sh` and answer "y" to the auto-update prompt.
Step 3: Also add a note to the setup_cron.sh section mentioning the new auto-update option
In the existing setup_cron.sh section of bin/README.md, add a bullet under “What it does”:
- Optionally sets up automatic updates (`bin/update.sh --rollback`)
Step 4: Commit
git add bin/README.md
git commit -m "docs: add update.sh to bin/README.md"