#!/usr/bin/env bash # # Pull the latest commit, rebuild the image and restart the stack. # Rolls back to the previous commit if the new container never turns healthy. # # Usage: ./scripts/update.sh [--force] [--branch ] # --force rebuild and restart even when there is no new commit # --branch branch to deploy (default: the currently checked out branch) set -Eeuo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_DIR" FORCE=0 BRANCH="" HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-120}" while [ $# -gt 0 ]; do case "$1" in --force) FORCE=1; shift ;; --branch) BRANCH="${2:?--branch needs a value}"; shift 2 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } err() { printf '\033[1;31m!!\033[0m %s\n' "$*" >&2; } compose() { docker compose "$@"; } # --- preconditions ----------------------------------------------------------- command -v git >/dev/null || { err "git not found"; exit 1; } docker compose version >/dev/null 2>&1 || { err "docker compose not available"; exit 1; } [ -f .env ] || { err ".env missing — copy .env.example to .env and fill it in"; exit 1; } if [ -n "$(git status --porcelain)" ]; then err "working tree has uncommitted changes — refusing to update" git status --short >&2 exit 1 fi [ -n "$BRANCH" ] || BRANCH="$(git rev-parse --abbrev-ref HEAD)" PREV_COMMIT="$(git rev-parse HEAD)" # --- fetch ------------------------------------------------------------------- log "Fetching origin/$BRANCH" git fetch --quiet origin "$BRANCH" TARGET_COMMIT="$(git rev-parse "origin/$BRANCH")" if [ "$PREV_COMMIT" = "$TARGET_COMMIT" ] && [ "$FORCE" -eq 0 ]; then log "Already at $(git rev-parse --short HEAD) — nothing to do (use --force to rebuild anyway)" exit 0 fi log "Updating $(git rev-parse --short "$PREV_COMMIT") -> $(git rev-parse --short "$TARGET_COMMIT")" git checkout --quiet "$BRANCH" git merge --ff-only --quiet "origin/$BRANCH" # --- health helper ----------------------------------------------------------- container_id() { compose ps -q app; } wait_for_health() { local deadline=$((SECONDS + HEALTH_TIMEOUT)) local cid status while [ "$SECONDS" -lt "$deadline" ]; do cid="$(container_id)" if [ -n "$cid" ]; then status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-healthcheck{{end}}' "$cid" 2>/dev/null || echo missing)" case "$status" in healthy|no-healthcheck) return 0 ;; unhealthy) return 1 ;; esac fi sleep 3 done return 1 } rollback() { err "Deployment failed — rolling back to $(git rev-parse --short "$PREV_COMMIT")" compose logs --tail 50 app >&2 || true git reset --hard --quiet "$PREV_COMMIT" compose build app compose up -d app if wait_for_health; then err "Rolled back successfully. The stack runs the previous commit again." else err "ROLLBACK ALSO UNHEALTHY — manual intervention required." fi exit 1 } # --- build & restart --------------------------------------------------------- log "Building image" compose build app || rollback log "Restarting stack" compose up -d app || rollback log "Waiting for healthcheck (timeout ${HEALTH_TIMEOUT}s)" wait_for_health || rollback # --- cleanup ----------------------------------------------------------------- log "Pruning dangling images" docker image prune -f >/dev/null log "Deployed $(git rev-parse --short HEAD) — stack is healthy"