Files
fitness/deploy/sync-to-server.sh

127 lines
5.6 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
die() { printf 'deploy: %s\n' "$*" >&2; exit 1; }
for name in DEPLOY_HOST DEPLOY_USER DEPLOY_ROOT DEPLOY_DOMAIN; do
[[ -n "${!name:-}" ]] || die "set $name before running"
done
command -v git >/dev/null || die 'git is required locally'
command -v ssh >/dev/null || die 'ssh is required locally'
git rev-parse --show-toplevel >/dev/null 2>&1 || die 'run from inside the Git repository'
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"
ref="${DEPLOY_REF:-$(git branch --show-current)}"
[[ -n "$ref" ]] || die 'detached HEAD: set DEPLOY_REF to a branch name'
git check-ref-format --branch "$ref" >/dev/null 2>&1 || die 'DEPLOY_REF is not a valid branch name'
if [[ -n "$(git status --porcelain --untracked-files=normal)" ]]; then
die 'working tree has changes; commit and push the intended release first'
fi
remote_url="$(git remote get-url origin 2>/dev/null)" || die 'Git remote origin is not configured'
remote_sha="$(git ls-remote --heads "$remote_url" "refs/heads/$ref" | awk 'NR == 1 {print $1}')"
[[ "$remote_sha" =~ ^[0-9a-f]{40,64}$ ]] || die "branch '$ref' was not found on origin"
local_sha="$(git rev-parse HEAD)"
[[ "$local_sha" == "$remote_sha" ]] || die 'HEAD must exactly match the commit currently pushed to origin'
port="${DEPLOY_PORT:-2101}"
[[ "$port" =~ ^[0-9]{1,5}$ ]] && (( port > 0 && port < 65536 )) || die 'DEPLOY_PORT must be between 1 and 65535'
[[ "$DEPLOY_HOST" != *$'\n'* && "$DEPLOY_USER" != *$'\n'* && "$DEPLOY_DOMAIN" != *$'\n'* ]] || die 'deployment values cannot contain newlines'
[[ "$DEPLOY_HOST" =~ ^[a-zA-Z0-9._:-]+$ ]] || die 'DEPLOY_HOST must be a hostname or IP address'
[[ "$DEPLOY_USER" =~ ^[a-zA-Z0-9._-]+$ ]] || die 'DEPLOY_USER contains unsupported characters'
[[ "$DEPLOY_DOMAIN" =~ ^[a-zA-Z0-9.-]+$ ]] || die 'DEPLOY_DOMAIN must be a plain domain name'
[[ "$DEPLOY_ROOT" == /home/*/*/* ]] || die 'DEPLOY_ROOT must be a site directory below /home, e.g. /home/siteuser/htdocs/example.com'
[[ "$DEPLOY_ROOT" != *'..'* && "$DEPLOY_ROOT" != *$'\n'* ]] || die 'DEPLOY_ROOT contains an unsafe path component'
printf 'Target: %s@%s:%s domain=%s ref=%s commit=%s\n' \
"$DEPLOY_USER" "$DEPLOY_HOST" "$port" "$DEPLOY_DOMAIN" "$ref" "$remote_sha"
if [[ "${DEPLOY_DRY_RUN:-0}" == 1 ]]; then
printf 'Dry run: local Git and target settings are valid; no SSH connection was made.\n'
exit 0
fi
ssh -p "$port" \
-o BatchMode=yes \
-o ConnectTimeout=10 \
-o StrictHostKeyChecking=yes \
"$DEPLOY_USER@$DEPLOY_HOST" \
bash -s -- "$remote_url" "$ref" "$remote_sha" "$DEPLOY_ROOT" "$DEPLOY_DOMAIN" <<'REMOTE_SCRIPT'
set -euo pipefail
die() { printf 'remote deploy: %s\n' "$*" >&2; exit 1; }
repo_url="$1"
ref="$2"
expected_sha="$3"
root="$4"
domain="$5"
[[ "$root" == /home/*/*/* && "$root" != *'..'* ]] || die 'unsafe deployment root'
[[ "$expected_sha" =~ ^[0-9a-f]{40,64}$ ]] || die 'invalid commit hash'
[[ "$domain" =~ ^[a-zA-Z0-9.-]+$ ]] || die 'invalid domain'
deploy_dir="$root/.deploy"
repo_dir="$deploy_dir/repository.git"
releases_dir="$root/releases"
shared_dir="$root/shared"
release_dir="$releases_dir/$expected_sha"
mkdir -p "$deploy_dir" "$releases_dir" "$shared_dir"
chmod 700 "$deploy_dir" "$shared_dir"
[[ -f "$shared_dir/.env" && ! -L "$shared_dir/.env" ]] || die "missing private regular file $shared_dir/.env; create it before the first deploy"
chmod 600 "$shared_dir/.env"
if [[ ! -d "$repo_dir" ]]; then
git init --bare --quiet "$repo_dir"
git --git-dir="$repo_dir" remote add origin "$repo_url"
else
configured_remote="$(git --git-dir="$repo_dir" remote get-url origin 2>/dev/null || true)"
if [[ -z "$configured_remote" ]]; then
git --git-dir="$repo_dir" remote add origin "$repo_url"
elif [[ "$configured_remote" != "$repo_url" ]]; then
die 'server-side repository origin does not match the local origin'
fi
fi
git --git-dir="$repo_dir" fetch --quiet --no-tags origin \
"+refs/heads/$ref:refs/remotes/origin/$ref"
actual_sha="$(git --git-dir="$repo_dir" rev-parse "refs/remotes/origin/$ref")"
[[ "$actual_sha" == "$expected_sha" ]] || die 'origin moved during deploy; rerun using the new pushed commit'
if [[ -e "$release_dir" ]]; then
[[ -d "$release_dir/public" ]] || die "existing release is incomplete: $release_dir"
[[ -L "$release_dir/.env" ]] || die "existing release has no private environment link: $release_dir"
[[ "$(readlink -f "$release_dir/.env")" == "$(readlink -f "$shared_dir/.env")" ]] || die 'existing release points to a different environment file'
else
staging_dir="$(mktemp -d "$releases_dir/.staging-$expected_sha.XXXXXX")"
trap 'rm -rf -- "$staging_dir"' EXIT
git --git-dir="$repo_dir" archive "$actual_sha" | tar -x -C "$staging_dir"
[[ -d "$staging_dir/public" ]] || die 'release has no public/ web root; configure the app layout before deployment'
[[ ! -e "$staging_dir/.env" && ! -L "$staging_dir/.env" ]] || die 'release must not contain a tracked .env file'
ln -s "$shared_dir/.env" "$staging_dir/.env"
if command -v php >/dev/null 2>&1; then
for php_dir in "$staging_dir/backend" "$staging_dir/public"; do
[[ -d "$php_dir" ]] || continue
while IFS= read -r -d '' php_file; do
php -l "$php_file" >/dev/null || die "PHP syntax check failed: $php_file"
done < <(find "$php_dir" -type f -name '*.php' -print0)
done
fi
mv "$staging_dir" "$release_dir"
trap - EXIT
fi
printf '%s\n' "$domain" > "$shared_dir/.site-domain"
next_link="$root/.current-$expected_sha-$$"
ln -s "$release_dir" "$next_link"
mv -Tf "$next_link" "$root/current"
printf 'Published %s to %s\n' "$actual_sha" "$root/current"
printf 'CloudPanel document root must point to: %s/current/public\n' "$root"
REMOTE_SCRIPT