Files

179 lines
5.7 KiB
Bash
Executable File

#!/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"
# PHP runs as uid/gid 1000 inside the container. act_runner often owns
# shared/.env as 0600, which makes env() empty and config:cache write NULLs.
run chgrp "$php_group" "$DEPLOY_ROOT/shared/.env"
run chmod 640 "$DEPLOY_ROOT/shared/.env"
run docker exec --user "$php_user" --workdir "$container_release" \
"$PHP_CONTAINER" composer install \
--no-dev --no-interaction --prefer-dist --optimize-autoloader
# Keep the release readable by the PHP runtime group. Do not chown on the host:
# act_runner cannot change ownership of files created inside the container.
run docker exec "$PHP_CONTAINER" sh -c \
"chown -R ${php_user} '${container_release}' && chmod -R g+rwX '${container_release}'"
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
# Ask queue workers to exit after the current job (cache signal).
artisan queue:restart || true
# Restart host daemons so they pick up the new `current` release.
if command -v systemctl >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
sudo -n systemctl try-restart new-pet-queue-worker.service new-pet-ai-worker.service || true
else
systemctl try-restart new-pet-queue-worker.service new-pet-ai-worker.service 2>/dev/null || true
fi
fi
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
for old_release in "${old_releases[@]}"; do
release_name="$(basename "$old_release")"
# Delete via the PHP container as root so vendor trees are removable.
run docker exec "$PHP_CONTAINER" \
rm -rf "$CONTAINER_DEPLOY_ROOT/releases/$release_name"
done
fi
fi
trap - EXIT
printf 'Deployment succeeded: %s\n' "$release_id"