#!/usr/bin/env bash # # Build every Fennec Hub image and push it to Docker Hub. # # ./build-and-push.sh all # build + push all three images # ./build-and-push.sh build # build locally, don't push # ./build-and-push.sh all -s admin # just the admin UI # ./build-and-push.sh all -t 2026.09.05 # explicit tag # # Configuration lives in .env (see .env.example); the image catalogue lives in # images.conf. Run --help for the full option list. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" CATALOGUE="$SCRIPT_DIR/images.conf" # ---------------------------------------------------------------- output ---- if [[ -t 1 ]]; then C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m' C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_BLUE=$'\033[34m' else C_RESET=; C_BOLD=; C_DIM=; C_RED=; C_GREEN=; C_YELLOW=; C_BLUE= fi log() { printf '%s\n' "${C_BLUE}==>${C_RESET} $*"; } info() { printf '%s\n' " ${C_DIM}$*${C_RESET}"; } ok() { printf '%s\n' "${C_GREEN} ✓${C_RESET} $*"; } warn() { printf '%s\n' "${C_YELLOW} !${C_RESET} $*" >&2; } die() { printf '%s\n' "${C_RED}error:${C_RESET} $*" >&2; exit 1; } # ------------------------------------------------------------ config ---- [[ -f "$SCRIPT_DIR/.env" ]] && { set -a; . "$SCRIPT_DIR/.env"; set +a; } DOCKERHUB_NAMESPACE="${DOCKERHUB_NAMESPACE:-fennecsm}" DOCKERHUB_USERNAME="${DOCKERHUB_USERNAME:-$DOCKERHUB_NAMESPACE}" DOCKERHUB_TOKEN="${DOCKERHUB_TOKEN:-}" PLATFORMS="${PLATFORMS:-linux/amd64}" PUSH_LATEST="${PUSH_LATEST:-true}" TAG="${TAG:-}" # Angular build memory. Empty => use the per-Dockerfile defaults. Set these to # override both frontends at once without editing the Dockerfiles. NODE_MAX_OLD_SPACE="${NODE_MAX_OLD_SPACE:-}" NG_BUILD_MAX_WORKERS="${NG_BUILD_MAX_WORKERS:-}" SERVICES="" NO_CACHE="" DRY_RUN=false BUILDER_NAME="fennec-release" usage() { cat < [options] ${C_BOLD}Commands${C_RESET} build Build images locally (no push). Single platform only. push Build and push images to Docker Hub. all Alias for push (login + build + push). login Log in to Docker Hub and exit. list Show the image catalogue and the tags that would be used. ${C_BOLD}Options${C_RESET} -s, --service Only this image; repeatable. Keys: $(cut -d'|' -f1 "$CATALOGUE" 2>/dev/null | grep -v '^#' | tr -d ' ' | grep . | paste -sd, -) -t, --tag Tag to apply (default: auto -) -n, --namespace Docker Hub namespace (default: $DOCKERHUB_NAMESPACE) -p, --platforms

Target platforms (default: $PLATFORMS) --no-latest Do not also tag/push :latest --no-cache Build without the Docker layer cache --dry-run Print the docker commands instead of running them -h, --help This message ${C_BOLD}Examples${C_RESET} $(basename "$0") list $(basename "$0") build -s admin $(basename "$0") all -t 2026.09.05 -p linux/amd64,linux/arm64 EOF } # -------------------------------------------------------------- args ---- [[ $# -eq 0 ]] && { usage; exit 1; } COMMAND="$1"; shift while [[ $# -gt 0 ]]; do case "$1" in -s|--service) SERVICES="$SERVICES $2"; shift 2 ;; -t|--tag) TAG="$2"; shift 2 ;; -n|--namespace) DOCKERHUB_NAMESPACE="$2"; shift 2 ;; -p|--platforms) PLATFORMS="$2"; shift 2 ;; --no-latest) PUSH_LATEST=false; shift ;; --no-cache) NO_CACHE="--no-cache"; shift ;; --dry-run) DRY_RUN=true; shift ;; -h|--help) usage; exit 0 ;; *) die "unknown option: $1 (try --help)" ;; esac done # ------------------------------------------------------------- helpers ---- run() { if $DRY_RUN; then printf '%s\n' " ${C_DIM}[dry-run]${C_RESET} $*" else "$@" fi } # Auto tag: -. # Each project is its own git repo; the repo root is not, so fall back # gracefully when no sha is available. make_tag() { local sha sha="$(git -C "$REPO_ROOT/fennec-hub-multi-tenant-shared-db" rev-parse --short=7 HEAD 2>/dev/null || true)" local stamp stamp="$(date -u +%Y%m%dT%H%MZ)" if [[ -n "$sha" ]]; then printf '%s-%s' "$sha" "$stamp" else printf '%s' "$stamp" fi } # Emits "key|image|context|dockerfile|description" for each selected image. selected_images() { [[ -f "$CATALOGUE" ]] || die "catalogue not found: $CATALOGUE" local line key image context dockerfile description while IFS='|' read -r key image context dockerfile description; do key="$(printf '%s' "$key" | tr -d '[:space:]')" [[ -z "$key" || "$key" == \#* ]] && continue if [[ -n "${SERVICES// /}" ]]; then [[ " $SERVICES " == *" $key "* ]] || continue fi image="$(printf '%s' "$image" | tr -d '[:space:]')" context="$(printf '%s' "$context" | tr -d '[:space:]')" dockerfile="$(printf '%s' "$dockerfile" | tr -d '[:space:]')" description="$(printf '%s' "$description" | sed 's/^ *//; s/ *$//')" printf '%s|%s|%s|%s|%s\n' "$key" "$image" "$context" "$dockerfile" "$description" done < "$CATALOGUE" } validate_selection() { local found=false k while IFS='|' read -r k _; do found=true; done < <(selected_images) $found || die "no images matched --service '${SERVICES# }'. Run '$(basename "$0") list'." } docker_login() { if [[ -n "$DOCKERHUB_TOKEN" ]]; then log "Logging in to Docker Hub as $DOCKERHUB_USERNAME" if $DRY_RUN; then info "[dry-run] docker login -u $DOCKERHUB_USERNAME --password-stdin" else printf '%s' "$DOCKERHUB_TOKEN" \ | docker login -u "$DOCKERHUB_USERNAME" --password-stdin >/dev/null \ || die "docker login failed" ok "authenticated" fi elif docker system info 2>/dev/null | grep -q "Username:"; then ok "already logged in to Docker Hub" else log "Logging in to Docker Hub as $DOCKERHUB_USERNAME" warn "DOCKERHUB_TOKEN is not set — docker will prompt for a password/token" $DRY_RUN || docker login -u "$DOCKERHUB_USERNAME" || die "docker login failed" fi } ensure_builder() { # A multi-platform build needs a buildx builder backed by the # docker-container driver; the default "docker" driver cannot do it. if [[ "$PLATFORMS" != *,* ]]; then return 0 fi if docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then info "using buildx builder '$BUILDER_NAME'" else log "Creating buildx builder '$BUILDER_NAME' (multi-platform)" run docker buildx create --name "$BUILDER_NAME" --driver docker-container --bootstrap fi run docker buildx use "$BUILDER_NAME" } build_image() { local key="$1" image="$2" context="$3" dockerfile="$4" push="$5" local repo="$DOCKERHUB_NAMESPACE/$image" local -a args=() [[ -f "$REPO_ROOT/$dockerfile" ]] || die "$key: dockerfile not found: $dockerfile" [[ -d "$REPO_ROOT/$context" ]] || die "$key: build context not found: $context" args+=(buildx build) args+=(--file "$REPO_ROOT/$dockerfile") args+=(--platform "$PLATFORMS") args+=(--tag "$repo:$TAG") [[ "$PUSH_LATEST" == "true" ]] && args+=(--tag "$repo:latest") args+=(--label "org.opencontainers.image.title=$image") args+=(--label "org.opencontainers.image.version=$TAG") args+=(--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)") [[ -n "$NO_CACHE" ]] && args+=("$NO_CACHE") # Angular memory knobs — only the frontend images declare these ARGs, and # passing an unused --build-arg is a warning, so scope them to the frontends. if [[ "$key" != "backend" ]]; then [[ -n "$NODE_MAX_OLD_SPACE" ]] && args+=(--build-arg "NODE_MAX_OLD_SPACE=$NODE_MAX_OLD_SPACE") [[ -n "$NG_BUILD_MAX_WORKERS" ]] && args+=(--build-arg "NG_BUILD_MAX_WORKERS=$NG_BUILD_MAX_WORKERS") fi if [[ "$push" == "true" ]]; then args+=(--push) else args+=(--load) fi args+=("$REPO_ROOT/$context") log "${C_BOLD}$key${C_RESET} -> $repo:$TAG" info "context=$context dockerfile=$dockerfile platforms=$PLATFORMS" run docker "${args[@]}" ok "$key done" } do_build() { local push="$1" validate_selection # Multi-platform results cannot land in the local image store, so catch the # conflict before spending time on a builder or a build. if [[ "$push" != "true" && "$PLATFORMS" == *,* ]]; then die "multi-platform builds must be pushed; use 'push'/'all', or -p linux/amd64" fi [[ -n "$TAG" ]] || TAG="$(make_tag)" ensure_builder log "Namespace ${C_BOLD}$DOCKERHUB_NAMESPACE${C_RESET} tag ${C_BOLD}$TAG${C_RESET}$( [[ "$PUSH_LATEST" == "true" ]] && printf ' (+ latest)' )" local key image context dockerfile _desc local -a pushed=() while IFS='|' read -r key image context dockerfile _desc; do build_image "$key" "$image" "$context" "$dockerfile" "$push" pushed+=("$DOCKERHUB_NAMESPACE/$image:$TAG") done < <(selected_images) echo if [[ "$push" == "true" ]]; then log "Pushed to Docker Hub:" else log "Built locally:" fi printf ' %s\n' "${pushed[@]}" if [[ "$push" == "true" ]]; then echo info "Deploy with: cd docker-release && TAG=$TAG docker compose up -d" fi } do_list() { [[ -n "$TAG" ]] || TAG="$(make_tag)" log "Namespace ${C_BOLD}$DOCKERHUB_NAMESPACE${C_RESET} tag ${C_BOLD}$TAG${C_RESET} platforms ${C_BOLD}$PLATFORMS${C_RESET}" echo printf ' %-10s %-40s %s\n' "KEY" "IMAGE" "DESCRIPTION" local key image context dockerfile desc while IFS='|' read -r key image context dockerfile desc; do printf ' %-10s %-40s %s\n' "$key" "$DOCKERHUB_NAMESPACE/$image" "$desc" done < <(selected_images) } # ------------------------------------------------------------ dispatch ---- command -v docker >/dev/null 2>&1 || die "docker is not installed or not on PATH" case "$COMMAND" in list) do_list ;; login) docker_login ;; build) do_build false ;; push|all) docker_login do_build true ;; -h|--help|help) usage ;; *) die "unknown command: $COMMAND (try --help)" ;; esac