312 lines
10 KiB
Bash
Executable File
312 lines
10 KiB
Bash
Executable File
#!/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 :<version>, :dev, :latest, then commit + tag and
|
|
# push a release/<version> 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/<service>/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<digits>.<digits>.<digits>"
|
|
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}/<service>/ 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
|