#!/usr/bin/env bash set -Eeuo pipefail APP_NAME="${APP_NAME:-dimensionlab-website}" SERVICE_NAME="${SERVICE_NAME:-dimensionlab-website.service}" CONTAINER_NAME="${CONTAINER_NAME:-dimensionlab-website}" IMAGE_REPO="${IMAGE_REPO:-localhost/dimensionlab-website}" CONTAINERFILE="${CONTAINERFILE:-apps/web/Containerfile}" PUBLIC_URL="${PUBLIC_URL:-https://dimensionlab.net/}" TILE_BATCH_URL="${TILE_BATCH_URL:-https://dimensionlab.net/api/dashboard/tiles}" DEPLOY_RESTART_STRATEGY="${DEPLOY_RESTART_STRATEGY:-auto}" DEPLOY_SMOKE_TIMEOUT_SECONDS="${DEPLOY_SMOKE_TIMEOUT_SECONDS:-120}" DEPLOY_CONTAINER_START_TIMEOUT_SECONDS="${DEPLOY_CONTAINER_START_TIMEOUT_SECONDS:-90}" dry_run=false rollback_tag="" release_tag="" latest_tag="${IMAGE_REPO}:latest" container_cli="" deployment_started=false rollback_done=false rollback_in_progress=false usage() { cat <&2 if [ "${deployment_started:-false}" = "true" ] && [ "${rollback_in_progress:-false}" != "true" ]; then rollback || true fi exit 1 } run() { if "$dry_run"; then printf '[deploy:%s] DRY-RUN:' "$APP_NAME" printf ' %q' "$@" printf '\n' return 0 fi "$@" } for arg in "$@"; do case "$arg" in --dry-run) dry_run=true ;; -h|--help) usage exit 0 ;; *) usage >&2 fail "unknown argument: $arg" ;; esac done deployment_ref() { printf '%s' "${DEPLOY_REF:-${GITHUB_REF:-${FORGEJO_REF:-}}}" } deployment_event() { printf '%s' "${DEPLOY_EVENT_NAME:-${GITHUB_EVENT_NAME:-${FORGEJO_EVENT_NAME:-}}}" } require_main_push() { local event local ref event="$(deployment_event)" ref="$(deployment_ref)" if [ -n "$event" ] && [ "$event" != "push" ]; then fail "refusing to deploy for event '$event'; production deploys only run for push" fi if [ -n "$ref" ]; then [ "$ref" = "refs/heads/main" ] || fail "refusing to deploy ref '$ref'; expected refs/heads/main" return 0 fi local branch branch="$(git branch --show-current 2>/dev/null || true)" [ "$branch" = "main" ] || fail "refusing to deploy branch '$branch'; expected main" } select_container_cli() { if [ -n "${DEPLOY_CONTAINER_CLI:-}" ]; then command -v "$DEPLOY_CONTAINER_CLI" >/dev/null 2>&1 || fail "container CLI not found: $DEPLOY_CONTAINER_CLI" container_cli="$DEPLOY_CONTAINER_CLI" return 0 fi if command -v podman >/dev/null 2>&1; then container_cli="podman" return 0 fi if command -v docker >/dev/null 2>&1; then container_cli="docker" return 0 fi fail "podman or docker is required" } current_sha() { if [ -n "${DEPLOY_SHA:-${GITHUB_SHA:-}}" ]; then printf '%s' "${DEPLOY_SHA:-${GITHUB_SHA:-}}" return 0 fi git rev-parse HEAD } tag_existing_latest_for_rollback() { rollback_tag="${IMAGE_REPO}:rollback-$(date -u +%Y%m%d%H%M%S)" if "$container_cli" image inspect "$latest_tag" >/dev/null 2>&1; then log "tagging current latest image as $rollback_tag" run "$container_cli" tag "$latest_tag" "$rollback_tag" else log "no existing $latest_tag image found; rollback image tag will not be created" rollback_tag="" fi } container_systemd_unit() { "$container_cli" inspect "$CONTAINER_NAME" \ --format '{{ index .Config.Labels "PODMAN_SYSTEMD_UNIT" }}' 2>/dev/null || true } require_container_managed_by_service() { local unit if "$dry_run"; then log "DRY-RUN: would require $CONTAINER_NAME to be managed by $SERVICE_NAME" return 0 fi unit="$(container_systemd_unit)" [ "$unit" = "$SERVICE_NAME" ] || fail "refusing to stop $CONTAINER_NAME; expected PODMAN_SYSTEMD_UNIT=$SERVICE_NAME, got '${unit:-unset}'" } validate_restart_strategy() { case "$DEPLOY_RESTART_STRATEGY" in systemctl) if ! "$dry_run" && ! systemctl --user show "$SERVICE_NAME" >/dev/null 2>&1; then fail "systemctl --user cannot access $SERVICE_NAME" fi ;; quadlet-container|kill-container) require_container_managed_by_service ;; auto) if "$dry_run"; then log "DRY-RUN: would validate automatic restart strategy" elif ! command -v systemctl >/dev/null 2>&1 || ! systemctl --user show "$SERVICE_NAME" >/dev/null 2>&1; then require_container_managed_by_service fi ;; *) fail "unknown DEPLOY_RESTART_STRATEGY: $DEPLOY_RESTART_STRATEGY" ;; esac } initialize_submodules() { log "initializing submodules" run git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/" run git submodule update --init --recursive } build_image() { local sha local short_sha sha="$(current_sha)" short_sha="${sha:0:12}" release_tag="${IMAGE_REPO}:${short_sha}" [ -f "$CONTAINERFILE" ] || fail "containerfile not found: $CONTAINERFILE" log "building $release_tag and $latest_tag from $CONTAINERFILE" run "$container_cli" build -f "$CONTAINERFILE" -t "$release_tag" -t "$latest_tag" . } restart_service() { log "restarting $SERVICE_NAME with strategy $DEPLOY_RESTART_STRATEGY" case "$DEPLOY_RESTART_STRATEGY" in systemctl) run systemctl --user restart "$SERVICE_NAME" ;; quadlet-container|kill-container) require_container_managed_by_service run "$container_cli" stop "$CONTAINER_NAME" ;; auto) if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active "$SERVICE_NAME" >/dev/null 2>&1; then run systemctl --user restart "$SERVICE_NAME" else require_container_managed_by_service run "$container_cli" stop "$CONTAINER_NAME" fi ;; *) fail "unknown DEPLOY_RESTART_STRATEGY: $DEPLOY_RESTART_STRATEGY" ;; esac } latest_image_id() { "$container_cli" image inspect "$latest_tag" --format '{{.Id}}' 2>/dev/null || true } container_image_id() { "$container_cli" inspect "$CONTAINER_NAME" --format '{{.Image}}' 2>/dev/null || true } container_running() { local running running="$("$container_cli" inspect "$CONTAINER_NAME" --format '{{.State.Running}}' 2>/dev/null || true)" [ "$running" = "true" ] } wait_for_container_restart() { local expected_image if "$dry_run"; then log "DRY-RUN: would wait for $CONTAINER_NAME to run $latest_tag" return 0 fi expected_image="$(latest_image_id)" [ -n "$expected_image" ] || fail "could not resolve image id for $latest_tag" wait_for_container_image "$expected_image" "new image" || fail "$CONTAINER_NAME did not restart on $latest_tag within ${DEPLOY_CONTAINER_START_TIMEOUT_SECONDS}s" } wait_for_container_image() { local expected_image="$1" local label="$2" local deadline deadline=$((SECONDS + DEPLOY_CONTAINER_START_TIMEOUT_SECONDS)) while [ "$SECONDS" -lt "$deadline" ]; do if container_running && [ "$(container_image_id)" = "$expected_image" ]; then log "$CONTAINER_NAME is running the $label" return 0 fi sleep 2 done return 1 } smoke_get() { local url="$1" curl -fsS --max-time 10 -o /dev/null "$url" } smoke_tiles() { local response response="$( curl -fsS --max-time 20 \ -H "content-type: application/json" \ --data '{"tiles":[{"kind":"status","stripId":"footer-status","id":"system-status"}]}' \ "$TILE_BATCH_URL" )" [[ "$response" == *'"state":"ready"'* ]] } wait_for_smoke() { local deadline if "$dry_run"; then log "DRY-RUN: would smoke check $PUBLIC_URL and $TILE_BATCH_URL" return 0 fi deadline=$((SECONDS + DEPLOY_SMOKE_TIMEOUT_SECONDS)) until smoke_get "$PUBLIC_URL" && smoke_tiles; do if [ "$SECONDS" -ge "$deadline" ]; then fail "smoke checks failed for $PUBLIC_URL and $TILE_BATCH_URL" fi sleep 3 done log "smoke checks passed" } rollback() { local rollback_image if [ "$deployment_started" != "true" ] || [ -z "$rollback_tag" ] || [ "$rollback_done" = "true" ]; then return 0 fi rollback_done=true rollback_in_progress=true printf '[deploy:%s] rolling back to %s\n' "$APP_NAME" "$rollback_tag" >&2 rollback_image="$("$container_cli" image inspect "$rollback_tag" --format '{{.Id}}' 2>/dev/null || true)" "$container_cli" tag "$rollback_tag" "$latest_tag" || true if container_running; then restart_service || true else printf '[deploy:%s] waiting for %s to recover with rollback image\n' "$APP_NAME" "$SERVICE_NAME" >&2 fi if [ -n "$rollback_image" ] && wait_for_container_image "$rollback_image" "rollback image"; then printf '[deploy:%s] rollback image is running\n' "$APP_NAME" >&2 else printf '[deploy:%s] ERROR: rollback image did not become healthy\n' "$APP_NAME" >&2 fi rollback_in_progress=false } on_error() { local status=$? rollback exit "$status" } trap on_error ERR require_main_push select_container_cli validate_restart_strategy log "using container CLI: $container_cli" log "target image: $latest_tag" log "target service: $SERVICE_NAME" initialize_submodules tag_existing_latest_for_rollback build_image deployment_started=true restart_service wait_for_container_restart wait_for_smoke log "deployment finished"