#!/usr/bin/env bash set -euo pipefail # ============================================================================ # build-and-push.sh # # Purpose: # - Build & push Docker images for each service under ./containers/* # - Branch model: `main` is permanent and tracked by the prod stack # (image `:latest`). `dev` is a short-lived branch tracked by the dev # stack (image `:dev`); it is recreated from `main` at the start of each # cycle and deleted after each release. Tags `vX.Y.Z` cover rollback — # no `release/` snapshot branches. # - Two modes: # t (test) = build & push :dev. Switches to (or creates) the `dev` # branch from `main` if not already on it, commits any # pending changes, and pushes `dev` to origin so the dev # stack picks up the matching compose/config alongside # the :dev image. # r (release) = MUST be run from `main`. Build & push :, # :dev, :latest. Fast-forwards `main` against # `origin/main`, merges `dev` into `main` if it exists # (local or remote), creates a `Release vX.Y.Z` commit, # tags it, pushes `main` and the tag, and deletes the # `dev` branch locally and on 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.7.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. If it differs, offer to # copy master over the local copy and re-exec with the same arguments so the # build runs against the up-to-date script. # # Skip with: SKIP_SELF_UPDATE=1 ./build-and-push.sh ... self_update_check() { if [[ "${SKIP_SELF_UPDATE:-0}" == "1" ]]; then return 0 fi 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 reason="" master_version="$(grep -m1 -E '^SCRIPT_VERSION=' "$SCRIPT_MASTER_PATH" | sed -E 's/.*"([^"]+)".*/\1/')" if [[ -n "$master_version" && "$master_version" != "$SCRIPT_VERSION" ]]; then reason="version" elif ! cmp -s "$self_abs" "$SCRIPT_MASTER_PATH"; then reason="contents" else return 0 # Identical to master. fi echo "[WARN] Local build-and-push.sh differs from master." if [[ "$reason" == "version" ]]; then echo " local : $SCRIPT_VERSION" echo " master : $master_version ($SCRIPT_MASTER_PATH)" else echo " Same SCRIPT_VERSION ($SCRIPT_VERSION) but file contents differ." echo " master : $SCRIPT_MASTER_PATH" fi # Prompt only when stdin is a TTY; in non-interactive runs, abort safely so # an unattended release never silently runs against a stale script. if [[ ! -t 0 ]]; then echo "[ERROR] Non-interactive shell — refusing to auto-update." echo " Re-run interactively, or set SKIP_SELF_UPDATE=1 to bypass," echo " or update manually: cp \"$SCRIPT_MASTER_PATH\" \"$self_abs\"" exit 1 fi local reply read -r -p "Update local script from master and re-run? [Y/n] " reply reply="${reply:-Y}" if [[ ! "$reply" =~ ^[Yy]$ ]]; then echo "[INFO] Continuing with local version $SCRIPT_VERSION (not updated)." echo "" return 0 fi if ! cp "$SCRIPT_MASTER_PATH" "$self_abs"; then echo "[ERROR] Failed to copy master to $self_abs (read-only filesystem?)." echo " Continuing with local version $SCRIPT_VERSION." echo "" return 0 fi chmod +x "$self_abs" 2>/dev/null || true echo "[INFO] Updated $self_abs from master. Re-executing..." echo "" # Re-exec with original arguments. SKIP_SELF_UPDATE=1 prevents an # update loop if cp somehow didn't take. export SKIP_SELF_UPDATE=1 exec "$self_abs" "$@" } 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 # --- Release preflight (BEFORE any docker work) ------------------------------ # All git-side validation for a release happens here so a wrong-branch / dirty # tree / stale main / conflicting dev / pre-existing tag aborts the run before # anything is built or pushed to the registry. dev is merged into main now so # the version we read from changelog.md reflects the merged state, not main's # pre-merge state. VERSION="" DEV_MERGED=0 if [[ "$MODE" == "r" ]]; then if [[ ! -d ".git" ]]; then echo "[ERROR] Release mode requires a git checkout." exit 1 fi CURRENT_BRANCH="$(git symbolic-ref --short -q HEAD || echo)" if [[ "$CURRENT_BRANCH" != "main" ]]; then echo "[ERROR] Release build must run from 'main' branch. Current: ${CURRENT_BRANCH:-}." echo " Switch with: git checkout main" exit 1 fi if ! git diff --quiet HEAD -- || ! git diff --cached --quiet; then echo "[ERROR] Working tree has uncommitted changes. Commit or stash them on the appropriate branch before releasing." git status --short exit 1 fi echo "[INFO] Fetching origin..." git fetch origin main if git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then git fetch origin dev fi if ! git merge --ff-only origin/main 2>/dev/null; then echo "[ERROR] Local main has diverged from origin/main. Resolve manually before releasing." exit 1 fi # Merge dev into main BEFORE reading the version, so changelog.md reflects # the bumped state that dev brings in. if git show-ref --verify --quiet refs/heads/dev; then echo "[INFO] Merging local dev into main..." if ! git merge --no-ff dev -m "Release (merge dev)"; then echo "[ERROR] Merge of dev into main failed (conflict). Resolve manually and re-run." exit 1 fi DEV_MERGED=1 elif git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then echo "[INFO] Fetching and merging origin/dev into main..." git fetch origin dev:dev if ! git merge --no-ff dev -m "Release (merge dev)"; then echo "[ERROR] Merge of dev into main failed (conflict). Resolve manually and re-run." exit 1 fi DEV_MERGED=1 else echo "[INFO] No dev branch found — releasing main as-is." fi VERSION="$(read_version_from_changelog)" echo "[INFO] Release version (from $CHANGELOG_FILE, post-merge): $VERSION" validate_tag "$VERSION" validate_tag "latest" # Tag collision = abort. A re-release of an existing version with different # content would silently move what consumers think v0.X.Y points to. if git rev-parse -q --verify "refs/tags/${VERSION}" >/dev/null; then echo "[ERROR] Tag ${VERSION} already exists locally. Bump $CHANGELOG_FILE to a new version before releasing." exit 1 fi if git ls-remote --exit-code --tags origin "refs/tags/${VERSION}" >/dev/null 2>&1; then echo "[ERROR] Tag ${VERSION} already exists on origin. Bump $CHANGELOG_FILE to a new version before releasing." exit 1 fi # 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. Note: dev has been merged into local main; reset with 'git reset --hard origin/main' if you want to undo." 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: release commit + tag + push (release mode only) ------------------- # Preflight (branch, clean tree, ff origin/main, dev merge, tag collision, # version parse) already ran BEFORE the build. dev is already merged into # local main. We only need to land the Release commit, tag, and push. if [[ "$MODE" == "r" ]]; then echo "[INFO] Finalising release: version=${VERSION}" # Produce a clean Release commit at the tip. Preflight guarantees the working # tree was clean at start; any post-build artefacts would be unexpected, so # commit with --allow-empty to keep the release marker isolated. if git diff --quiet HEAD -- && git diff --cached --quiet; then git commit --allow-empty -m "Release ${VERSION}" else echo "[WARN] Working tree changed during the build — staging and including in release commit." git add -A git commit -m "Release ${VERSION}" fi git tag -a "${VERSION}" -m "Release ${VERSION}" # Push main first (triggers prod webhook), then the tag. git push origin main git push origin "refs/tags/${VERSION}" echo "[INFO] Pushed main and tag ${VERSION} to origin." # Clean up dev branch — local and remote. if [[ "$DEV_MERGED" == "1" ]]; then if git show-ref --verify --quiet refs/heads/dev; then git branch -D dev echo "[INFO] Deleted local dev branch." fi if git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then git push origin --delete dev echo "[INFO] Deleted remote dev branch." fi fi fi # --- Git: dev branch commit + push (test mode only) ------------------------- if [[ "$MODE" == "t" ]]; then if [[ ! -d ".git" ]]; then echo "[WARN] Not a git checkout — skipping dev branch commit/push." exit 0 fi CURRENT_BRANCH="$(git symbolic-ref --short -q HEAD || echo)" # Ensure we are on the dev branch. Create it if needed. if [[ "$CURRENT_BRANCH" != "dev" ]]; then if git show-ref --verify --quiet refs/heads/dev; then echo "[INFO] Switching to existing local dev branch." git checkout dev elif git ls-remote --exit-code --heads origin dev >/dev/null 2>&1; then echo "[INFO] Checking out remote dev branch." git fetch origin dev git checkout -b dev origin/dev else echo "[INFO] Creating new dev branch from main." git fetch origin main git checkout -b dev origin/main fi fi # Stage and commit if there are changes. git add -A if git diff --cached --quiet; then echo "[INFO] Working tree clean — pushing current HEAD to dev." else git commit -m "Dev build $(date '+%Y-%m-%d %H:%M')" fi # Non-force push. Diverged origin/dev fails hard — resolve manually. git push -u origin dev echo "[INFO] Pushed dev to origin." fi