#!/usr/bin/env bash set -Eeuo pipefail required_variables=( SOURCE_DIR DEPLOY_ROOT CONTAINER_DEPLOY_ROOT PHP_CONTAINER HEALTHCHECK_URL BACKUP_COMMAND ) for variable_name in "${required_variables[@]}"; do test -n "${!variable_name:-}" || { printf 'ERROR: %s is required.\n' "$variable_name" >&2 exit 1 } done release_id="${RELEASE_ID:-$(date -u +%Y%m%d%H%M%S)}" keep_releases="${KEEP_RELEASES:-5}" php_user="${PHP_RUNTIME_USER:-1000:1000}" php_group="${PHP_RUNTIME_GROUP:-1000}" dry_run="${DRY_RUN:-false}" host_release="$DEPLOY_ROOT/releases/$release_id" container_release="$CONTAINER_DEPLOY_ROOT/releases/$release_id" previous_release="" maintenance_enabled=false run() { if test "$dry_run" = "true"; then printf 'DRY-RUN:' printf ' %q' "$@" printf '\n' return 0 fi "$@" } artisan() { run docker exec --user "$php_user" --workdir "$container_release" \ "$PHP_CONTAINER" php artisan "$@" } restore_previous_release() { if test "$dry_run" != "true" && test -n "$previous_release"; then ln -s "$previous_release" "$DEPLOY_ROOT/.current.rollback" mv -Tf "$DEPLOY_ROOT/.current.rollback" "$DEPLOY_ROOT/current" fi } recover() { exit_code=$? if test "$exit_code" -ne 0; then printf 'Deployment failed; restoring the previous code release.\n' >&2 restore_previous_release fi if test "$maintenance_enabled" = "true" && test -L "$DEPLOY_ROOT/current"; then docker exec --user "$php_user" \ --workdir "$CONTAINER_DEPLOY_ROOT/current" \ "$PHP_CONTAINER" php artisan up >/dev/null 2>&1 || true fi exit "$exit_code" } trap recover EXIT trap 'exit 143' TERM trap 'exit 130' INT test -d "$SOURCE_DIR" || { printf 'ERROR: Source directory does not exist: %s\n' "$SOURCE_DIR" >&2 exit 1 } test -f "$DEPLOY_ROOT/shared/.env" || { printf 'ERROR: Shared production .env does not exist.\n' >&2 exit 1 } test -d "$DEPLOY_ROOT/shared/storage" || { printf 'ERROR: Shared storage directory does not exist.\n' >&2 exit 1 } command -v flock >/dev/null 2>&1 || { printf 'ERROR: flock is required on the production host.\n' >&2 exit 1 } exec 9>"$DEPLOY_ROOT/.deploy.lock" flock -w 600 9 || { printf 'ERROR: Another deployment still holds the production lock.\n' >&2 exit 1 } if test -L "$DEPLOY_ROOT/current"; then previous_release="$(readlink "$DEPLOY_ROOT/current")" if test "$dry_run" != "true"; then docker exec --user "$php_user" \ --workdir "$CONTAINER_DEPLOY_ROOT/current" \ "$PHP_CONTAINER" php artisan down --retry=30 maintenance_enabled=true fi fi run mkdir -p "$host_release" run rsync -a --delete \ --exclude='.env' \ --exclude='node_modules' \ --exclude='storage' \ --exclude='vendor' \ "$SOURCE_DIR/" "$host_release/" run chgrp -R "$php_group" "$host_release" run chmod -R g+rwX "$host_release" run ln -s ../../shared/.env "$host_release/.env" run ln -s ../../shared/storage "$host_release/storage" run docker exec --user "$php_user" --workdir "$container_release" \ "$PHP_CONTAINER" composer install \ --no-dev --no-interaction --prefer-dist --optimize-autoloader if test "$dry_run" != "true"; then bash -c "$BACKUP_COMMAND" fi artisan migrate --force artisan storage:link artisan optimize:clear artisan config:cache artisan route:cache artisan view:cache run ln -s "releases/$release_id" "$DEPLOY_ROOT/.current.next" run mv -Tf "$DEPLOY_ROOT/.current.next" "$DEPLOY_ROOT/current" if test "$dry_run" != "true"; then docker exec "$PHP_CONTAINER" kill -USR2 1 docker exec --user "$php_user" \ --workdir "$CONTAINER_DEPLOY_ROOT/current" \ "$PHP_CONTAINER" php artisan up maintenance_enabled=false curl --fail --silent --show-error \ --retry 6 --retry-delay 5 --retry-connrefused \ "$HEALTHCHECK_URL" >/dev/null active_target="$(readlink "$DEPLOY_ROOT/current")" mapfile -t old_releases < <( ls -1dt "$DEPLOY_ROOT"/releases/* 2>/dev/null | awk -v keep="$keep_releases" -v active="$DEPLOY_ROOT/$active_target" ' $0 != active { seen++; if (seen >= keep) print } ' ) if test "${#old_releases[@]}" -gt 0; then rm -rf -- "${old_releases[@]}" fi fi trap - EXIT printf 'Deployment succeeded: %s\n' "$release_id"