This commit is contained in:
2026-05-05 22:47:14 +12:00
parent 0eed557f95
commit 71cdc809c6
5 changed files with 609 additions and 1 deletions
+500
View File
@@ -0,0 +1,500 @@
#!/usr/bin/env bash
set -Eeuo pipefail
REPO_URL=""
BRANCH="main"
REF=""
DEPLOY_PATH=""
COMPOSE_FILE=""
PROJECT_NAME=""
SERVICE_NAME=""
NGINX_SOURCE=""
NGINX_TARGET=""
NGINX_COMPOSE_FILE=""
NGINX_PROJECT_NAME=""
MAINTENANCE_HOST_DIR=""
MAINTENANCE_FLAG_PATH=""
VERIFY_URL="https://www.goodwalk.co.nz/api/health"
SKIP_SITE_CHECK=0
usage() {
cat <<'EOF'
Usage:
deploy-from-git.sh --repo-url <url> --branch <name> --deploy-path <path> --compose-file <name> --project-name <name>
deploy-from-git.sh --repo-url <url> [--branch <name>] [--ref <commit-or-tag>] --deploy-path <path> --compose-file <name> --project-name <name> [--service <name>]
deploy-from-git.sh --repo-url <url> [--branch <name>] [--ref <commit-or-tag>] --deploy-path <path> --compose-file <name> --project-name <name> \
[--service <name>] [--nginx-source <path>] [--nginx-target <path>] \
[--nginx-compose-file <path>] [--nginx-project-name <name>] \
[--maintenance-host-dir <path>] [--maintenance-flag <path>] \
[--verify-url <url>] [--skip-site-check]
This script clones or fetches the application repo on the server, exports the
homepage content payload, updates only the main Goodwalk compose project, and
optionally updates the shared nginx stack plus maintenance mode handling.
Authentication for private HTTPS repos is expected to come from ~/.netrc,
git-credential, or another Git-supported credential mechanism already present
on the server.
EOF
}
fail() {
echo "[deploy-git] ERROR: $*" >&2
exit 1
}
assert_command() {
command -v "$1" >/dev/null 2>&1 || fail "Required command '$1' is not installed on the server"
}
run_homepage_export() {
local export_script="$1"
local output_path="$2"
if command -v node >/dev/null 2>&1; then
node --experimental-strip-types "$export_script" "$output_path"
return
fi
echo "[deploy-git] Host node not found; exporting homepage content via temporary node:22-alpine container"
docker run --rm \
-v "$CHECKOUT_DIR:/app" \
-w /app \
node:22-alpine \
node --experimental-strip-types "${export_script#/app/}" "${output_path#/app/}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--repo-url)
REPO_URL="${2:-}"
shift 2
;;
--branch)
BRANCH="${2:-}"
shift 2
;;
--ref)
REF="${2:-}"
shift 2
;;
--deploy-path)
DEPLOY_PATH="${2:-}"
shift 2
;;
--compose-file)
COMPOSE_FILE="${2:-}"
shift 2
;;
--project-name)
PROJECT_NAME="${2:-}"
shift 2
;;
--service)
SERVICE_NAME="${2:-}"
shift 2
;;
--nginx-source)
NGINX_SOURCE="${2:-}"
shift 2
;;
--nginx-target)
NGINX_TARGET="${2:-}"
shift 2
;;
--nginx-compose-file)
NGINX_COMPOSE_FILE="${2:-}"
shift 2
;;
--nginx-project-name)
NGINX_PROJECT_NAME="${2:-}"
shift 2
;;
--maintenance-host-dir)
MAINTENANCE_HOST_DIR="${2:-}"
shift 2
;;
--maintenance-flag)
MAINTENANCE_FLAG_PATH="${2:-}"
shift 2
;;
--verify-url)
VERIFY_URL="${2:-}"
shift 2
;;
--skip-site-check)
SKIP_SITE_CHECK=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$REPO_URL" ]] || fail "--repo-url is required"
[[ -n "$BRANCH" ]] || fail "--branch is required"
[[ -n "$DEPLOY_PATH" ]] || fail "--deploy-path is required"
[[ -n "$COMPOSE_FILE" ]] || fail "--compose-file is required"
[[ -n "$PROJECT_NAME" ]] || fail "--project-name is required"
if [[ -n "$SERVICE_NAME" ]]; then
SERVICE_NAME="$(printf '%s' "$SERVICE_NAME" | xargs)"
fi
[[ "$DEPLOY_PATH" != "/" ]] || fail "Refusing to deploy to /"
nginx_args=("$NGINX_SOURCE" "$NGINX_TARGET" "$NGINX_COMPOSE_FILE" "$NGINX_PROJECT_NAME")
nginx_args_present=0
for value in "${nginx_args[@]}"; do
if [[ -n "$value" ]]; then
nginx_args_present=1
break
fi
done
if (( nginx_args_present )); then
[[ -n "$NGINX_SOURCE" ]] || fail "--nginx-source is required when nginx deployment is enabled"
[[ -n "$NGINX_TARGET" ]] || fail "--nginx-target is required when nginx deployment is enabled"
[[ -n "$NGINX_COMPOSE_FILE" ]] || fail "--nginx-compose-file is required when nginx deployment is enabled"
[[ -n "$NGINX_PROJECT_NAME" ]] || fail "--nginx-project-name is required when nginx deployment is enabled"
[[ -n "$MAINTENANCE_HOST_DIR" ]] || fail "--maintenance-host-dir is required when nginx deployment is enabled"
[[ -n "$MAINTENANCE_FLAG_PATH" ]] || fail "--maintenance-flag is required when nginx deployment is enabled"
fi
assert_command git
assert_command docker
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD=(docker-compose)
else
fail "Docker Compose is not installed on the server"
fi
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/goodwalk-git-deploy.XXXXXX")"
CHECKOUT_DIR="$WORK_DIR/repo"
PAYLOAD_DIR="$WORK_DIR/payload"
MAINTENANCE_ACTIVE=0
clear_maintenance_flag() {
if (( MAINTENANCE_ACTIVE )) && (( nginx_args_present )); then
echo "[deploy-git] Clearing maintenance flag at $MAINTENANCE_FLAG_PATH"
rm -f "$MAINTENANCE_FLAG_PATH" || true
MAINTENANCE_ACTIVE=0
fi
}
cleanup() {
clear_maintenance_flag
rm -rf "$WORK_DIR"
}
copy_checkout_to_payload() {
mkdir -p "$PAYLOAD_DIR"
if command -v rsync >/dev/null 2>&1; then
rsync -a \
--exclude '.git' \
--exclude '.env' \
--exclude '.env.*' \
"$CHECKOUT_DIR"/ "$PAYLOAD_DIR"/
return
fi
while IFS= read -r -d '' item; do
relative_path="${item#"$CHECKOUT_DIR"/}"
case "$relative_path" in
.git|.git/*|.env|.env.*)
continue
;;
esac
destination="$PAYLOAD_DIR/$relative_path"
if [[ -d "$item" ]]; then
mkdir -p "$destination"
continue
fi
mkdir -p "$(dirname "$destination")"
cp -f "$item" "$destination"
done < <(find "$CHECKOUT_DIR" -mindepth 1 -print0)
}
copy_payload_to_deploy() {
mkdir -p "$DEPLOY_PATH"
if command -v rsync >/dev/null 2>&1; then
rsync -a \
--exclude '.env' \
--exclude '.env.*' \
"$PAYLOAD_DIR"/ "$DEPLOY_PATH"/
return
fi
while IFS= read -r -d '' item; do
relative_path="${item#"$PAYLOAD_DIR"/}"
if [[ "$relative_path" == ".env" || "$relative_path" == .env.* ]]; then
continue
fi
destination="$DEPLOY_PATH/$relative_path"
if [[ -d "$item" ]]; then
mkdir -p "$destination"
continue
fi
mkdir -p "$(dirname "$destination")"
cp -f "$item" "$destination"
done < <(find "$PAYLOAD_DIR" -mindepth 1 -print0)
}
merge_env_file() {
local template="$1"
local live="$2"
[[ -f "$template" ]] || { echo "[deploy-git] No env template at $template, skipping merge"; return 0; }
[[ -f "$live" ]] || { echo "[deploy-git] No live .env at $live, skipping merge"; return 0; }
local added diffs backup
added="$(mktemp)"
diffs="$(mktemp)"
backup="${live}.bak.$(date -u +%Y%m%dT%H%M%SZ)"
awk -v live="$live" -v added_log="$added" -v diff_log="$diffs" '
function trim(s) { sub(/^[ \t]+/,"",s); sub(/[ \t]+$/,"",s); return s }
BEGIN {
while ((getline line < live) > 0) {
if (line ~ /^[ \t]*#/ || line ~ /^[ \t]*$/) continue
eq = index(line, "=")
if (eq == 0) continue
k = trim(substr(line, 1, eq-1))
v = substr(line, eq+1)
live_keys[k] = v
live_seen[k] = 1
}
close(live)
}
/^[ \t]*#/ || /^[ \t]*$/ { next }
{
eq = index($0, "=")
if (eq == 0) next
k = trim(substr($0, 1, eq-1))
v = substr($0, eq+1)
if (!(k in live_seen)) {
print k "=" v >> added_log
} else if (live_keys[k] != v) {
print k " (template=" v " | live=" live_keys[k] ")" >> diff_log
}
}
' "$template"
if [[ -s "$added" ]]; then
cp "$live" "$backup"
echo "[deploy-git] Adding env keys present in template but missing from $live:"
sed 's/^/ + /' "$added"
{
printf '\n# Appended by deploy-from-git.sh on %s from deploy.env.template\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
cat "$added"
} >> "$live"
echo "[deploy-git] Backup of previous .env written to $backup"
else
echo "[deploy-git] .env is up to date with template (no missing keys)"
fi
if [[ -s "$diffs" ]]; then
echo "[deploy-git] NOTE: these keys exist in both files but values differ. Live values are PRESERVED:"
sed 's/^/ ! /' "$diffs"
echo "[deploy-git] If a live value is stale, edit $live and re-deploy."
fi
rm -f "$added" "$diffs"
}
check_site() {
if (( SKIP_SITE_CHECK )) || [[ -z "$VERIFY_URL" ]]; then
return 0
fi
echo "[deploy-git] Checking production site: $VERIFY_URL"
if command -v curl >/dev/null 2>&1; then
local http_code
if http_code="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 30 -L "$VERIFY_URL" 2>/dev/null)"; then
echo "[deploy-git] Site responded with HTTP $http_code"
else
echo "[deploy-git] WARNING: production site check failed for $VERIFY_URL" >&2
fi
return 0
fi
if command -v wget >/dev/null 2>&1; then
if wget --spider --server-response --timeout=30 "$VERIFY_URL" >/tmp/goodwalk-site-check.$$ 2>&1; then
awk '/^ HTTP\// { code=$2 } END { if (code != "") printf "[deploy-git] Site responded with HTTP %s\n", code }' /tmp/goodwalk-site-check.$$
else
echo "[deploy-git] WARNING: production site check failed for $VERIFY_URL" >&2
fi
rm -f /tmp/goodwalk-site-check.$$
return 0
fi
echo "[deploy-git] WARNING: curl/wget not available; skipping site check" >&2
}
trap cleanup EXIT
echo "[deploy-git] Deploying main Goodwalk stack from Git"
echo "[deploy-git] Repo URL: $REPO_URL"
echo "[deploy-git] Branch: $BRANCH"
if [[ -n "$REF" ]]; then
echo "[deploy-git] Requested ref: $REF"
fi
echo "[deploy-git] Target deployment path: $DEPLOY_PATH"
echo "[deploy-git] Compose file: $COMPOSE_FILE"
echo "[deploy-git] Docker project: $PROJECT_NAME"
if [[ -n "$SERVICE_NAME" ]]; then
echo "[deploy-git] Target service: $SERVICE_NAME"
fi
if (( nginx_args_present )); then
echo "[deploy-git] Nginx config source: $NGINX_SOURCE"
echo "[deploy-git] Nginx config target: $NGINX_TARGET"
echo "[deploy-git] Nginx compose file: $NGINX_COMPOSE_FILE"
echo "[deploy-git] Nginx project: $NGINX_PROJECT_NAME"
echo "[deploy-git] Maintenance host dir: $MAINTENANCE_HOST_DIR"
echo "[deploy-git] Maintenance flag path: $MAINTENANCE_FLAG_PATH"
fi
echo "[deploy-git] Cloning repository into: $CHECKOUT_DIR"
git clone "$REPO_URL" "$CHECKOUT_DIR"
git -C "$CHECKOUT_DIR" fetch --tags --prune origin
if [[ -n "$REF" ]]; then
git -C "$CHECKOUT_DIR" checkout --detach "$REF"
else
git -C "$CHECKOUT_DIR" checkout -B "$BRANCH" "origin/$BRANCH"
fi
DEPLOYED_REVISION="$(git -C "$CHECKOUT_DIR" rev-parse HEAD)"
echo "[deploy-git] Using repo revision: $DEPLOYED_REVISION"
EXPORT_SCRIPT="$CHECKOUT_DIR/scripts/export-homepage-content.mjs"
[[ -f "$EXPORT_SCRIPT" ]] || fail "Homepage export script not found: $EXPORT_SCRIPT"
echo "[deploy-git] Exporting current homepage content for PostgreSQL sync"
run_homepage_export "/app/scripts/export-homepage-content.mjs" "/app/deploy-data/homepage-content.json"
echo "[deploy-git] Preparing deployment payload"
copy_checkout_to_payload
[[ -f "$PAYLOAD_DIR/$COMPOSE_FILE" ]] || fail "Compose file missing from repo checkout: $COMPOSE_FILE"
if [[ -f "$DEPLOY_PATH/.env" ]]; then
echo "[deploy-git] Preserving existing $DEPLOY_PATH/.env"
fi
echo "[deploy-git] Copying application files into $DEPLOY_PATH"
copy_payload_to_deploy
[[ -f "$DEPLOY_PATH/$COMPOSE_FILE" ]] || fail "Compose file missing after copy: $DEPLOY_PATH/$COMPOSE_FILE"
if [[ ! -f "$DEPLOY_PATH/.env" ]]; then
if [[ -f "$DEPLOY_PATH/deploy.env.template" ]]; then
echo "[deploy-git] No remote .env found. Creating $DEPLOY_PATH/.env from deploy.env.template"
cp "$DEPLOY_PATH/deploy.env.template" "$DEPLOY_PATH/.env"
else
fail "Remote .env is missing and deploy.env.template was not present"
fi
fi
merge_env_file "$DEPLOY_PATH/deploy.env.template" "$DEPLOY_PATH/.env"
cd "$DEPLOY_PATH"
echo "[deploy-git] Validating compose configuration"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" config >/dev/null
if [[ -n "$SERVICE_NAME" ]]; then
AVAILABLE_SERVICES="$("${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" config --services)"
if ! grep -Fxq "$SERVICE_NAME" <<<"$AVAILABLE_SERVICES"; then
fail "Service '$SERVICE_NAME' was not found in $COMPOSE_FILE. Available services: $(tr '\n' ',' <<<"$AVAILABLE_SERVICES" | sed 's/,$//')"
fi
fi
if (( nginx_args_present )); then
[[ -f "$DEPLOY_PATH/$NGINX_SOURCE" ]] || fail "Nginx config missing from deployment payload: $DEPLOY_PATH/$NGINX_SOURCE"
[[ -f "$NGINX_COMPOSE_FILE" ]] || fail "Nginx compose file was not found on the server: $NGINX_COMPOSE_FILE"
MAINTENANCE_HTML_SRC="$DEPLOY_PATH/nginx/maintenance.html"
MAINTENANCE_LOGO_SRC="$DEPLOY_PATH/nginx/logo.png"
[[ -f "$MAINTENANCE_HTML_SRC" ]] || fail "Maintenance page missing from deployment payload: $MAINTENANCE_HTML_SRC"
[[ -f "$MAINTENANCE_LOGO_SRC" ]] || fail "Maintenance logo missing from deployment payload: $MAINTENANCE_LOGO_SRC"
NGINX_CID="$(docker ps -qf name=^nginx$ | head -n1 || true)"
[[ -n "$NGINX_CID" ]] || fail "Shared nginx container is not running (expected name 'nginx'). Bring it up before deploying."
if ! docker inspect -f '{{range .Mounts}}{{.Source}}|{{.Destination}}{{println}}{{end}}' "$NGINX_CID" \
| grep -Fxq "${MAINTENANCE_HOST_DIR}|/var/www/maintenance"; then
fail "nginx container is missing the maintenance bind mount.
Expected: ${MAINTENANCE_HOST_DIR}:/var/www/maintenance:ro
One-time setup on the droplet:
mkdir -p ${MAINTENANCE_HOST_DIR}/m
# add this volume to ${NGINX_COMPOSE_FILE}:
# - ${MAINTENANCE_HOST_DIR}:/var/www/maintenance:ro
${COMPOSE_CMD[*]} -p ${NGINX_PROJECT_NAME} -f ${NGINX_COMPOSE_FILE} up -d"
fi
FLAG_DIR="$(dirname "$MAINTENANCE_FLAG_PATH")"
[[ -d "$FLAG_DIR" ]] || fail "Maintenance flag directory does not exist on host: $FLAG_DIR"
echo "[deploy-git] Writing maintenance assets to host bind dir: $MAINTENANCE_HOST_DIR"
mkdir -p "$MAINTENANCE_HOST_DIR/m"
install -m 0644 "$MAINTENANCE_HTML_SRC" "$MAINTENANCE_HOST_DIR/maintenance.html"
install -m 0644 "$MAINTENANCE_LOGO_SRC" "$MAINTENANCE_HOST_DIR/m/logo.png"
echo "[deploy-git] Updating shared nginx config (pre-rebuild) so maintenance routing is active"
mkdir -p "$(dirname "$NGINX_TARGET")"
cp "$DEPLOY_PATH/$NGINX_SOURCE" "$NGINX_TARGET"
echo "[deploy-git] Validating nginx configuration"
"${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -t
echo "[deploy-git] Reloading shared nginx so the new config (incl. maintenance routing) is live"
"${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -s reload
echo "[deploy-git] Engaging maintenance page via host flag: $MAINTENANCE_FLAG_PATH"
: > "$MAINTENANCE_FLAG_PATH"
MAINTENANCE_ACTIVE=1
fi
if [[ -n "$SERVICE_NAME" ]]; then
echo "[deploy-git] Stopping only the Goodwalk service: $SERVICE_NAME"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true
echo "[deploy-git] Rebuilding and starting only the Goodwalk service: $SERVICE_NAME"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --build "$SERVICE_NAME"
else
echo "[deploy-git] Stopping only the Goodwalk project containers"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop || true
echo "[deploy-git] Rebuilding and starting only the Goodwalk project containers"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --build --remove-orphans
fi
echo "[deploy-git] Current Goodwalk container status"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps
if [[ -z "$SERVICE_NAME" || "$SERVICE_NAME" == "app" || "$SERVICE_NAME" == "db" ]]; then
echo "[deploy-git] Syncing homepage content into PostgreSQL"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" exec -T app node scripts/sync-homepage-content.mjs
fi
clear_maintenance_flag
check_site
echo "[deploy-git] Remote deployment finished"
echo "[deploy-git] Deployed revision: $DEPLOYED_REVISION"