#!/usr/bin/env bash set -euo pipefail # ============================================================================ # build-and-push.sh # # Purpose: # - Build & push Docker images for each service under ./containers/* # - Two modes: # t (test) = only push :dev # r (release) = push :, :dev, :latest, then commit + tag and # push a release/ branch + tag to origin. # Version is read from the top of changelog.md. # # Usage: # ./build-and-push.sh [mode] # - mode = t -> test build, push :dev only # - mode = r -> release build, version taken from changelog.md # - omitted -> prompt (default: t) # # Requirements: # - docs/changelog.md (relative to repo root), with the most recent release # at the top as: # ## vX.Y.Z — YYYY-MM-DD # (the version is parsed from the first such line) # - One Dockerfile per service under ./containers//Dockerfile # # ---------------------------------------------------------------------------- # Self-update notice (for humans AND for Claude): # # The canonical/master copy of this script lives at: # /docker/develop/shared-integrations/tooling/docker-build-and-push/build-and-push.sh # # When this script is copied into a project repo, it may drift from master. # On startup the script compares its own SCRIPT_VERSION to the master copy and # warns if a newer version exists. It does NOT auto-overwrite (too risky during # a release run). # # CLAUDE INSTRUCTION: # If you (Claude) are about to invoke this script in a project repo, first # check whether the master copy at the path above is newer (different # SCRIPT_VERSION or differing file contents). If so, copy the master over # the local copy BEFORE running, then proceed. Mention the update to the user. # ============================================================================ SCRIPT_VERSION="1.2.0" SCRIPT_MASTER_PATH="/docker/develop/shared-integrations/tooling/docker-build-and-push/build-and-push.sh" DOCKER_REGISTRY="gitea.oskamp.info" DOCKER_NAMESPACE="ivooskamp" CHANGELOG_FILE="docs/changelog.md" CONTAINERS_DIR="containers" # --- Self-update check ------------------------------------------------------- # Compare this script to the canonical master copy and warn (don't overwrite). self_update_check() { local self_path="${BASH_SOURCE[0]}" # Resolve to absolute path so a comparison against itself is detected. local self_abs self_abs="$(cd "$(dirname "$self_path")" 2>/dev/null && pwd)/$(basename "$self_path")" || self_abs="$self_path" if [[ "$self_abs" == "$SCRIPT_MASTER_PATH" ]]; then return 0 # We ARE the master copy. fi if [[ ! -f "$SCRIPT_MASTER_PATH" ]]; then return 0 # Master not reachable from this host; silently skip. fi local master_version master_version="$(grep -m1 -E '^SCRIPT_VERSION=' "$SCRIPT_MASTER_PATH" | sed -E 's/.*"([^"]+)".*/\1/')" if [[ -n "$master_version" && "$master_version" != "$SCRIPT_VERSION" ]]; then echo "[WARN] A newer build-and-push.sh is available." echo " local : $SCRIPT_VERSION" echo " master : $master_version ($SCRIPT_MASTER_PATH)" echo " Update with: cp \"$SCRIPT_MASTER_PATH\" \"$self_abs\"" echo "" elif ! cmp -s "$self_abs" "$SCRIPT_MASTER_PATH"; then echo "[WARN] Local build-and-push.sh differs from master (same SCRIPT_VERSION=$SCRIPT_VERSION)." echo " master : $SCRIPT_MASTER_PATH" echo " Diff or update with: cp \"$SCRIPT_MASTER_PATH\" \"$self_abs\"" echo "" fi } self_update_check # --- Input: prompt if missing ------------------------------------------------ MODE="${1:-}" if [[ -z "${MODE}" ]]; then echo "Select build type: [t] test build (push :dev only), [r] release build (default: t)" read -r MODE MODE="${MODE:-t}" fi case "$MODE" in t|test) MODE="t" ;; r|release) MODE="r" ;; *) echo "[ERROR] Unknown mode '$MODE' (use 't' for test or 'r' for release)." exit 1 ;; esac # --- Helpers ----------------------------------------------------------------- check_docker_ready() { if ! docker info >/dev/null 2>&1; then echo "[ERROR] Docker daemon not reachable. Is Docker running and do you have permission to use it?" exit 1 fi } ensure_registry_login() { local cfg="${HOME}/.docker/config.json" if [[ ! -f "$cfg" ]]; then echo "[ERROR] Docker config not found at $cfg. Please login: docker login ${DOCKER_REGISTRY}" exit 1 fi if ! grep -q "\"${DOCKER_REGISTRY}\"" "$cfg"; then echo "[ERROR] No registry auth found for ${DOCKER_REGISTRY}. Please run: docker login ${DOCKER_REGISTRY}" exit 1 fi } validate_repo_component() { local comp="$1" if [[ ! "$comp" =~ ^[a-z0-9]+([._-][a-z0-9]+)*$ ]]; then echo "[ERROR] Invalid repository component '$comp'." echo " Must match: ^[a-z0-9]+([._-][a-z0-9]+)*$ (lowercase, digits, ., _, - as separators)." return 1 fi } validate_tag() { local tag="$1" local len="${#tag}" if (( len < 1 || len > 128 )); then echo "[ERROR] Invalid tag length ($len). Must be between 1 and 128 characters." return 1 fi if [[ ! "$tag" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]]; then echo "[ERROR] Invalid tag '$tag'. Allowed: [A-Za-z0-9_.-], must start with alphanumeric or underscore." return 1 fi } # Parse the first "## vX.Y.Z ..." heading from changelog.md. # Accepts: ## v1.0.3 — 2026-04-24 # ## v1.0.3 - 2026-04-24 # ## v1.0.3 read_version_from_changelog() { if [[ ! -f "$CHANGELOG_FILE" ]]; then echo "[ERROR] $CHANGELOG_FILE not found in $(pwd)." >&2 exit 1 fi local line # Match lines starting with "## v.." line="$(grep -m1 -E '^##[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+' "$CHANGELOG_FILE" || true)" if [[ -z "$line" ]]; then echo "[ERROR] No release heading found in $CHANGELOG_FILE (expected e.g. '## v1.0.3 — 2026-04-24' near the top)." >&2 exit 1 fi # Extract the vX.Y.Z token local version version="$(echo "$line" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n1)" if [[ -z "$version" ]]; then echo "[ERROR] Could not parse version from line: $line" >&2 exit 1 fi echo "$version" } # --- Preflight --------------------------------------------------------------- if [[ ! -d "$CONTAINERS_DIR" ]]; then echo "[ERROR] '$CONTAINERS_DIR' directory missing. Expected ./${CONTAINERS_DIR}// with a Dockerfile." exit 1 fi check_docker_ready ensure_registry_login validate_repo_component "$DOCKER_NAMESPACE" # Informational: show branch and HEAD if this happens to be a git repo. BRANCH_INFO="" HEAD_INFO="" if [[ -d ".git" ]]; then BRANCH_INFO="$(git branch --show-current 2>/dev/null || echo unknown)" HEAD_INFO="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" echo "[INFO] Repo: $(pwd)" echo "[INFO] Current branch: $BRANCH_INFO" echo "[INFO] HEAD (sha): $HEAD_INFO" else echo "[INFO] Repo: $(pwd) (not a git checkout)" fi # --- Determine version (release only) ---------------------------------------- VERSION="" if [[ "$MODE" == "r" ]]; then VERSION="$(read_version_from_changelog)" echo "[INFO] Release version (from $CHANGELOG_FILE): $VERSION" validate_tag "$VERSION" validate_tag "latest" # Ask for confirmation so you never accidentally re-push an old version or a wrong one. read -r -p "Proceed building & pushing as ${VERSION}? [y/N] " CONFIRM CONFIRM="${CONFIRM:-N}" if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then echo "[INFO] Aborted by user." exit 0 fi else echo "[INFO] Test build: only :dev will be pushed." fi validate_tag "dev" # --- Build & push per service ------------------------------------------------ shopt -s nullglob services=( "$CONTAINERS_DIR"/* ) if [[ ${#services[@]} -eq 0 ]]; then echo "[ERROR] No services found under $CONTAINERS_DIR" exit 1 fi BUILT_IMAGES=() for svc_path in "${services[@]}"; do [[ -d "$svc_path" ]] || continue svc="$(basename "$svc_path")" dockerfile="$svc_path/Dockerfile" validate_repo_component "$svc" if [[ ! -f "$dockerfile" ]]; then echo "[WARNING] Skipping '${svc}': Dockerfile not found in ${svc_path}" continue fi IMAGE_BASE="${DOCKER_REGISTRY}/${DOCKER_NAMESPACE}/${svc}" if [[ "$MODE" == "r" ]]; then echo "============================================================" echo "[INFO] Building ${svc} -> tags: ${VERSION}, dev, latest" echo "============================================================" docker build \ -t "${IMAGE_BASE}:${VERSION}" \ -t "${IMAGE_BASE}:dev" \ -t "${IMAGE_BASE}:latest" \ "$svc_path" docker push "${IMAGE_BASE}:${VERSION}" docker push "${IMAGE_BASE}:dev" docker push "${IMAGE_BASE}:latest" BUILT_IMAGES+=("${IMAGE_BASE}:${VERSION}" "${IMAGE_BASE}:dev" "${IMAGE_BASE}:latest") else echo "============================================================" echo "[INFO] Test build ${svc} -> tag: dev" echo "============================================================" docker build -t "${IMAGE_BASE}:dev" "$svc_path" docker push "${IMAGE_BASE}:dev" BUILT_IMAGES+=("${IMAGE_BASE}:dev") fi done # --- Summary ----------------------------------------------------------------- echo "" echo "============================================================" if [[ "$MODE" == "r" ]]; then echo "[SUMMARY] Release build & push complete: $VERSION" else echo "[SUMMARY] Test build & push complete (:dev only)" fi if [[ -n "$BRANCH_INFO" ]]; then echo "[INFO] Branch: $BRANCH_INFO HEAD: $HEAD_INFO" fi echo "[INFO] Images pushed:" for img in "${BUILT_IMAGES[@]}"; do echo " - $img" done echo "============================================================" echo "" # --- Git: commit + release branch + tag (release mode only) ----------------- if [[ "$MODE" == "r" ]]; then if [[ ! -d ".git" ]]; then echo "[WARN] Not a git checkout — skipping git commit/tag/push." exit 0 fi RELEASE_BRANCH="release/${VERSION}" echo "[INFO] Preparing git release: branch=${RELEASE_BRANCH}, tag=${VERSION}" # Stage everything and commit only if there's something to commit. git add -A if git diff --cached --quiet; then echo "[INFO] Working tree clean — no commit needed." else git commit -m "Release ${VERSION}" fi # Create or move the annotated tag to current HEAD. if git rev-parse -q --verify "refs/tags/${VERSION}" >/dev/null; then echo "[WARN] Tag ${VERSION} already exists locally — leaving it as-is." else git tag -a "${VERSION}" -m "Release ${VERSION}" fi git push origin "HEAD:refs/heads/${RELEASE_BRANCH}" git push origin "refs/tags/${VERSION}" echo "[INFO] Pushed ${RELEASE_BRANCH} and tag ${VERSION} to origin." fi