#!/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