All checks were successful
Test and deploy / Verify production build (push) Successful in 4m54s
Fast-forward the deployment checkout to the exact healthy image commit while refusing tracked changes, divergence, or an unexpected branch. Ignore release metadata stored beside the checkout.
199 lines
5.9 KiB
Bash
199 lines
5.9 KiB
Bash
#!/usr/bin/env bash
|
|
set -Eeuo pipefail
|
|
|
|
deploy_root="${1:?deployment root is required}"
|
|
release_id="${2:?release id is required}"
|
|
image="${3:?runtime image is required}"
|
|
sequence="${4:?deployment sequence is required}"
|
|
incoming_compose="${5:?incoming Compose file is required}"
|
|
|
|
if [[ "$deploy_root" != /* || "$deploy_root" == "/" ]]; then
|
|
echo "DEPLOY_PATH must be an absolute directory other than /." >&2
|
|
exit 2
|
|
fi
|
|
if [[ ! "$release_id" =~ ^[0-9a-f]{7,64}$ ]]; then
|
|
echo "The release id must be a Git commit SHA." >&2
|
|
exit 2
|
|
fi
|
|
if [[ "$image" != "amiirkhl-portfolio:$release_id" ]]; then
|
|
echo "The runtime image must be tagged with the release id." >&2
|
|
exit 2
|
|
fi
|
|
if [[ ! "$sequence" =~ ^[1-9][0-9]*$ ]]; then
|
|
echo "The deployment sequence must be a positive integer." >&2
|
|
exit 2
|
|
fi
|
|
if [[ ! -f "$incoming_compose" ]]; then
|
|
echo "Incoming Compose file not found: $incoming_compose" >&2
|
|
exit 2
|
|
fi
|
|
if ! command -v flock >/dev/null 2>&1; then
|
|
echo "flock is required to serialize deployments." >&2
|
|
exit 2
|
|
fi
|
|
|
|
releases_dir="$deploy_root/releases"
|
|
release_dir="$releases_dir/$release_id"
|
|
current_link="$deploy_root/current"
|
|
staging_dir="$releases_dir/.incoming-$release_id-$$"
|
|
|
|
cleanup() {
|
|
rm -f -- "$incoming_compose"
|
|
if [[ -d "$staging_dir" ]]; then
|
|
rm -rf -- "$staging_dir"
|
|
fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
mkdir -p -- "$releases_dir"
|
|
exec 9>"$deploy_root/.deploy.lock"
|
|
flock -x 9
|
|
|
|
sync_checkout() {
|
|
local branch remote_release
|
|
|
|
if [[ ! -d "$deploy_root/.git" ]]; then
|
|
echo "Deployment root is not a Git checkout: $deploy_root" >&2
|
|
return 1
|
|
fi
|
|
if ! git -C "$deploy_root" diff --quiet --ignore-submodules -- \
|
|
|| ! git -C "$deploy_root" diff --cached --quiet --ignore-submodules --; then
|
|
echo "Refusing to update a checkout with tracked changes: $deploy_root" >&2
|
|
return 1
|
|
fi
|
|
|
|
branch="$(git -C "$deploy_root" symbolic-ref --quiet --short HEAD || true)"
|
|
if [[ "$branch" != "main" ]]; then
|
|
echo "Deployment checkout must be on main, not ${branch:-detached HEAD}." >&2
|
|
return 1
|
|
fi
|
|
|
|
git -C "$deploy_root" fetch --quiet \
|
|
origin "+refs/heads/main:refs/remotes/origin/main"
|
|
remote_release="$(git -C "$deploy_root" rev-parse refs/remotes/origin/main)"
|
|
if [[ "$remote_release" != "$release_id" ]]; then
|
|
echo "origin/main is $remote_release, expected deployed release $release_id." >&2
|
|
return 1
|
|
fi
|
|
|
|
git -C "$deploy_root" merge --ff-only --quiet refs/remotes/origin/main
|
|
if [[ "$(git -C "$deploy_root" rev-parse HEAD)" != "$release_id" ]]; then
|
|
echo "Checkout did not advance to deployed release $release_id." >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
if ! docker image inspect "$image" >/dev/null 2>&1; then
|
|
echo "Verified runtime image is missing from the production Docker daemon: $image" >&2
|
|
exit 1
|
|
fi
|
|
|
|
previous_release=""
|
|
if [[ -L "$current_link" ]]; then
|
|
previous_release="$(readlink -f -- "$current_link" || true)"
|
|
fi
|
|
|
|
if [[ -n "$previous_release" && -f "$previous_release/release.env" ]]; then
|
|
current_sequence="$(sed -n 's/^DEPLOY_SEQUENCE=//p' "$previous_release/release.env")"
|
|
current_release="$(basename "$previous_release")"
|
|
if [[ "$current_release" == "$release_id" ]]; then
|
|
sync_checkout
|
|
echo "Release and server checkout are already active: $release_id"
|
|
exit 0
|
|
fi
|
|
if [[ "$current_sequence" =~ ^[1-9][0-9]*$ ]] && (( sequence <= current_sequence )); then
|
|
echo "Refusing stale deployment sequence $sequence; current sequence is $current_sequence." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
if [[ ! -d "$release_dir" ]]; then
|
|
mkdir -- "$staging_dir"
|
|
install -m 600 "$incoming_compose" "$staging_dir/compose.yaml"
|
|
{
|
|
printf 'PORTFOLIO_IMAGE=%s\n' "$image"
|
|
printf 'DEPLOY_SEQUENCE=%s\n' "$sequence"
|
|
} > "$staging_dir/release.env"
|
|
mv -- "$staging_dir" "$release_dir"
|
|
fi
|
|
|
|
if [[ ! -f "$release_dir/compose.yaml" || ! -f "$release_dir/release.env" ]]; then
|
|
echo "Release metadata is incomplete: $release_dir" >&2
|
|
exit 2
|
|
fi
|
|
|
|
compose() {
|
|
local directory="$1"
|
|
shift
|
|
docker compose \
|
|
--project-name portfolio \
|
|
--file "$directory/compose.yaml" \
|
|
--env-file "$directory/release.env" \
|
|
"$@"
|
|
}
|
|
|
|
wait_until_healthy() {
|
|
local directory="$1"
|
|
local container_id status
|
|
|
|
container_id="$(compose "$directory" ps -q portfolio-app)"
|
|
if [[ -z "$container_id" ]]; then
|
|
echo "The portfolio-app container was not created." >&2
|
|
return 1
|
|
fi
|
|
|
|
for _ in {1..30}; do
|
|
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")"
|
|
case "$status" in
|
|
healthy)
|
|
return 0
|
|
;;
|
|
unhealthy|exited|dead)
|
|
echo "Container entered state: $status" >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
sleep 2
|
|
done
|
|
|
|
echo "Container did not become healthy within 60 seconds." >&2
|
|
return 1
|
|
}
|
|
|
|
activate() {
|
|
local directory="$1"
|
|
local release_image
|
|
|
|
release_image="$(sed -n 's/^PORTFOLIO_IMAGE=//p' "$directory/release.env")"
|
|
if [[ -z "$release_image" ]] || ! docker image inspect "$release_image" >/dev/null 2>&1; then
|
|
echo "Release image is unavailable: ${release_image:-<unset>}" >&2
|
|
return 1
|
|
fi
|
|
|
|
compose "$directory" config --quiet
|
|
compose "$directory" up --detach --no-build --pull never --remove-orphans
|
|
wait_until_healthy "$directory"
|
|
}
|
|
|
|
if activate "$release_dir"; then
|
|
new_link="$deploy_root/.current-$release_id-$$"
|
|
ln -s -- "$release_dir" "$new_link"
|
|
mv -Tf -- "$new_link" "$current_link"
|
|
if ! sync_checkout; then
|
|
echo "Deployment is healthy, but the server checkout could not be updated." >&2
|
|
exit 1
|
|
fi
|
|
echo "Deployment succeeded: $release_id ($image)"
|
|
exit 0
|
|
fi
|
|
|
|
echo "Deployment failed; attempting rollback." >&2
|
|
if [[ -n "$previous_release" && -f "$previous_release/compose.yaml" && -f "$previous_release/release.env" ]]; then
|
|
activate "$previous_release"
|
|
echo "Rollback succeeded: $(basename "$previous_release")" >&2
|
|
else
|
|
echo "No image-based previous release is available for automatic rollback." >&2
|
|
fi
|
|
|
|
exit 1
|