Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a665368d02 |
@@ -1,4 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.sh text eol=lf
|
||||
*.ps1 text eol=crlf
|
||||
@@ -37,9 +37,6 @@ containers untouched.
|
||||
`deploy.ps1`. Keep using the root script directly.
|
||||
- [scripts/deploy-remote.sh](scripts/deploy-remote.sh)
|
||||
- Server-side helper that updates only the `goodwalk-svelte` compose project.
|
||||
- [scripts/deploy-from-git.sh](scripts/deploy-from-git.sh)
|
||||
- Standalone server-side entrypoint that pulls from Git, then runs the same
|
||||
compose/nginx deployment steps on the server.
|
||||
- [docker-compose.prod.yml](docker-compose.prod.yml)
|
||||
- Production compose file for the new Svelte app, mail API, and Postgres.
|
||||
- `scripts/export-homepage-content.mjs`
|
||||
@@ -132,69 +129,6 @@ To rebuild and restart only one service, for example the mail API:
|
||||
powershell -ExecutionPolicy Bypass -File .\deploy.ps1 -Force -Service mail-api
|
||||
```
|
||||
|
||||
## Remote Git deploy
|
||||
|
||||
If you want the production server to pull straight from Gitea instead of
|
||||
receiving an uploaded tarball from Windows, use
|
||||
[scripts/deploy-from-git.sh](scripts/deploy-from-git.sh) on the server.
|
||||
|
||||
Recommended credential setup for a private HTTPS repo:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
cat > ~/.netrc <<'EOF'
|
||||
machine g.sublogue.com
|
||||
login YOUR_GITEA_USERNAME
|
||||
password YOUR_READ_ONLY_TOKEN
|
||||
EOF
|
||||
chmod 600 ~/.netrc
|
||||
```
|
||||
|
||||
Install the script on the server and make it executable:
|
||||
|
||||
```bash
|
||||
install -m 0755 scripts/deploy-from-git.sh /usr/local/bin/goodwalk-deploy
|
||||
```
|
||||
|
||||
The remote host must have `git` and `docker`. A host-level `node` install is
|
||||
optional; if it is missing, the script will export homepage content using a
|
||||
temporary `node:22-alpine` container instead.
|
||||
|
||||
Run a full deploy from the repo:
|
||||
|
||||
```bash
|
||||
/usr/local/bin/goodwalk-deploy \
|
||||
--repo-url https://g.sublogue.com/admin/gw-svelte.git \
|
||||
--branch main \
|
||||
--deploy-path /docker/goodwalk-svelte \
|
||||
--compose-file docker-compose.prod.yml \
|
||||
--project-name goodwalk-svelte \
|
||||
--nginx-source nginx/goodwalk.co.nz.svelte.conf.example \
|
||||
--nginx-target /docker/nginx/conf.d/goodwalk.co.nz.conf \
|
||||
--nginx-compose-file /docker/nginx/docker-compose.yml \
|
||||
--nginx-project-name nginx \
|
||||
--maintenance-host-dir /docker/nginx/maintenance \
|
||||
--maintenance-flag /docker/nginx/conf.d/maintenance.flag
|
||||
```
|
||||
|
||||
Deploy a specific commit or tag:
|
||||
|
||||
```bash
|
||||
/usr/local/bin/goodwalk-deploy \
|
||||
--repo-url https://g.sublogue.com/admin/gw-svelte.git \
|
||||
--branch main \
|
||||
--ref <commit-or-tag> \
|
||||
--deploy-path /docker/goodwalk-svelte \
|
||||
--compose-file docker-compose.prod.yml \
|
||||
--project-name goodwalk-svelte \
|
||||
--nginx-source nginx/goodwalk.co.nz.svelte.conf.example \
|
||||
--nginx-target /docker/nginx/conf.d/goodwalk.co.nz.conf \
|
||||
--nginx-compose-file /docker/nginx/docker-compose.yml \
|
||||
--nginx-project-name nginx \
|
||||
--maintenance-host-dir /docker/nginx/maintenance \
|
||||
--maintenance-flag /docker/nginx/conf.d/maintenance.flag
|
||||
```
|
||||
|
||||
## Homepage content sync
|
||||
|
||||
Local development can feel fresher than production because production reads the
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
#!/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"
|
||||
@@ -102,7 +102,7 @@
|
||||
.about-section-gradient {
|
||||
margin: 0 24px 88px;
|
||||
padding: 40px 0;
|
||||
border-radius: 36px;
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(180deg, #f5efe6 0%, #f9f6ef 100%);
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
}
|
||||
|
||||
.about-contact-card {
|
||||
border-radius: 36px;
|
||||
border-radius: 28px;
|
||||
background: #fff;
|
||||
padding: 42px 48px;
|
||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
.booking-page-hero h1 {
|
||||
margin: 0 0 14px;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(32px, 4vw, 52px);
|
||||
font-size: clamp(34px, 4vw, 56px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: #fff;
|
||||
|
||||
@@ -9,24 +9,6 @@
|
||||
{ label: 'Facebook', href: 'https://facebook.com/goodwalk.nz', external: true },
|
||||
{ label: 'Google', href: 'https://g.page/r/CUsvrWPhkYrAEB0', external: true }
|
||||
];
|
||||
|
||||
const aboutLink: LinkItem = { label: 'About Us', href: '/about' };
|
||||
|
||||
function withAboutLink(links: LinkItem[]) {
|
||||
if (links.some((link) => link.href === aboutLink.href || link.label === aboutLink.label)) {
|
||||
return links;
|
||||
}
|
||||
|
||||
const contactIndex = links.findIndex((link) => link.href === '/contact-us');
|
||||
|
||||
if (contactIndex === -1) {
|
||||
return [...links, aboutLink];
|
||||
}
|
||||
|
||||
return [...links.slice(0, contactIndex), aboutLink, ...links.slice(contactIndex)];
|
||||
}
|
||||
|
||||
$: navigationLinks = withAboutLink(footer.navigationLinks);
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
@@ -56,9 +38,9 @@
|
||||
</div>
|
||||
|
||||
<div class="footer-explore">
|
||||
<p class="footer-col-label">Explore Goodwalk</p>
|
||||
<p class="footer-col-label">Explore</p>
|
||||
<ul class="footer-nav">
|
||||
{#each navigationLinks as link}
|
||||
{#each footer.navigationLinks as link}
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import Footer from './Footer.svelte';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
|
||||
describe('Footer', () => {
|
||||
it('adds the About Us link when footer content omits it', () => {
|
||||
const footer = {
|
||||
...homepageContent.footer,
|
||||
navigationLinks: homepageContent.footer.navigationLinks.filter((link) => link.href !== '/about')
|
||||
};
|
||||
|
||||
render(Footer, { footer });
|
||||
|
||||
const aboutLinks = screen.getAllByRole('link', { name: 'About Us' });
|
||||
|
||||
expect(aboutLinks).toHaveLength(1);
|
||||
expect(aboutLinks[0]).toHaveAttribute('href', '/about');
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="instagram-stage">
|
||||
<div class="instagram-panel">
|
||||
<div class="instagram-copy">
|
||||
<span class="instagram-kicker">Daily walks, happy dogs</span>
|
||||
<span class="eyebrow instagram-kicker">Daily walks, happy dogs</span>
|
||||
<h2>{instagram.title}</h2>
|
||||
<p class="instagram-blurb">See our dogs in action — walks, play, and happy pups</p>
|
||||
<a href={instagram.href} target="_blank" rel="noopener" class="btn btn-green instagram-button">
|
||||
@@ -43,7 +43,7 @@
|
||||
position: relative;
|
||||
min-height: 150px;
|
||||
padding: 24px 320px 24px 44px;
|
||||
border-radius: 24px;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.52), transparent 42%),
|
||||
linear-gradient(135deg, rgba(255, 252, 242, 0.98), rgba(255, 243, 198, 0.96));
|
||||
@@ -63,16 +63,9 @@
|
||||
}
|
||||
|
||||
.instagram-kicker {
|
||||
display: inline-flex;
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(33, 48, 33, 0.08);
|
||||
color: var(--green);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
/* All visual styling comes from the shared .eyebrow utility. */
|
||||
display: inline-block;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.instagram-copy :global(h2) {
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
.legal-card {
|
||||
padding: 40px 44px;
|
||||
border-radius: 32px;
|
||||
border-radius: 28px;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
||||
}
|
||||
@@ -172,7 +172,7 @@
|
||||
|
||||
.legal-card {
|
||||
padding: 28px 22px;
|
||||
border-radius: 24px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.legal-section + .legal-section {
|
||||
|
||||
@@ -349,7 +349,7 @@
|
||||
gap: 18px;
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
padding: 18px 18px 18px 20px;
|
||||
border-radius: 24px;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(247, 243, 232, 0.98));
|
||||
box-shadow:
|
||||
@@ -460,7 +460,7 @@
|
||||
|
||||
@media (hover: hover) {
|
||||
.pricing-plan-card:hover {
|
||||
transform: translateY(-8px) scale(1.012);
|
||||
transform: translateY(-6px) scale(1.012);
|
||||
box-shadow: 0 22px 44px rgba(17, 20, 24, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -547,7 +547,7 @@
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
padding: 18px 18px 16px;
|
||||
border-radius: 20px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.meet-greet-copy p {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<section class="service-hero">
|
||||
<div class="service-inner service-hero-grid">
|
||||
<div class="service-hero-copy">
|
||||
<p class="service-eyebrow">{pageContent.hero.eyebrow}</p>
|
||||
<p class="eyebrow">{pageContent.hero.eyebrow}</p>
|
||||
<h1>{pageContent.hero.title}</h1>
|
||||
|
||||
{#each pageContent.hero.paragraphs as paragraph}
|
||||
@@ -63,7 +63,7 @@
|
||||
{#if pageContent.highlight}
|
||||
<section use:reveal class="service-highlight reveal-block">
|
||||
<div class="service-inner service-highlight-copy">
|
||||
<p class="service-highlight-eyebrow">{pageContent.highlight.eyebrow}</p>
|
||||
<p class="eyebrow service-highlight-eyebrow">{pageContent.highlight.eyebrow}</p>
|
||||
<h2>{pageContent.highlight.title}</h2>
|
||||
</div>
|
||||
|
||||
@@ -351,16 +351,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.service-eyebrow {
|
||||
margin: 0 0 18px;
|
||||
color: var(--green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.service-hero-copy h1,
|
||||
.service-section-heading h2,
|
||||
.service-highlight-copy h2 {
|
||||
@@ -405,12 +395,13 @@
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Decorative dingbat eyebrow on the highlight section. Inherits sizing /
|
||||
* colour from the shared .eyebrow utility; this rule only adjusts the
|
||||
* downward spacing relative to the H2 underneath.
|
||||
*/
|
||||
.service-highlight-eyebrow {
|
||||
margin: 0 0 16px;
|
||||
color: var(--yellow);
|
||||
font-family: var(--font-head);
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.service-highlight-image img {
|
||||
@@ -478,7 +469,7 @@
|
||||
@media (hover: hover) {
|
||||
.service-plan-card:hover,
|
||||
.service-benefit-card:hover {
|
||||
transform: translateY(-8px) scale(1.012);
|
||||
transform: translateY(-6px) scale(1.012);
|
||||
box-shadow: 0 22px 44px rgba(17, 20, 24, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -688,10 +679,6 @@
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
.service-highlight-eyebrow {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.service-extra-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -326,7 +326,7 @@
|
||||
.testimonial-stage {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
border-radius: 28px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 30px rgba(20, 24, 20, 0.06);
|
||||
min-height: 620px;
|
||||
|
||||
@@ -249,7 +249,7 @@ section {
|
||||
|
||||
.service-card {
|
||||
background: var(--off-white);
|
||||
border-radius: 20px;
|
||||
border-radius: 28px;
|
||||
padding: 40px 32px;
|
||||
text-align: center;
|
||||
transition:
|
||||
@@ -280,7 +280,7 @@ section {
|
||||
|
||||
@media (hover: hover) {
|
||||
.service-card:hover {
|
||||
transform: translateY(-6px) scale(1.01);
|
||||
transform: translateY(-6px) scale(1.012);
|
||||
box-shadow: 0 18px 38px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -355,7 +355,7 @@ footer {
|
||||
|
||||
.value-card {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-radius: 16px;
|
||||
border-radius: 28px;
|
||||
padding: 32px 28px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
transition:
|
||||
@@ -366,7 +366,7 @@ footer {
|
||||
|
||||
@media (hover: hover) {
|
||||
.value-card:hover {
|
||||
transform: translateY(-5px);
|
||||
transform: translateY(-6px) scale(1.012);
|
||||
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.14);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
@@ -390,7 +390,7 @@ footer {
|
||||
|
||||
.testimonial-card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
border-radius: 28px;
|
||||
padding: 36px 32px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||
transition:
|
||||
@@ -400,7 +400,7 @@ footer {
|
||||
|
||||
@media (hover: hover) {
|
||||
.testimonial-card:hover {
|
||||
transform: translateY(-4px);
|
||||
transform: translateY(-6px) scale(1.012);
|
||||
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.09);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,13 +35,28 @@
|
||||
|
||||
.hero-text h1 {
|
||||
font-family: var(--font-head);
|
||||
font-size: 50.2px;
|
||||
font-size: clamp(34px, 4vw, 56px);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
margin-bottom: 28px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/*
|
||||
* Shared eyebrow utility for the small uppercase label that introduces a
|
||||
* heading. Use this instead of bespoke per-section kickers.
|
||||
*/
|
||||
.eyebrow {
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--green);
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.hero-text h1 .hero-heading-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user