#!/usr/bin/env bash set -euo pipefail usage() { sed -n '2,30p' "$0" } # Build and publish a Docker image with buildx. Configuration comes from the # environment first (optionally loaded from the project root .env); flags # override. # # Usage: # publish_docker.sh [--registry HOST] [--repository PATH] [--tag TAG] \ # [--platform LIST] [options] # # Environment: # DOCKER_REGISTRY Required (or --registry) # DOCKER_REPOSITORY Optional, default: git repository name (or --repository) # IMAGE_TAG Optional, default: version.sh --docker (or --tag) # PLATFORMS Optional, default: linux/amd64 (or --platform) # DOCKER_DOCKERFILE Optional, default: Dockerfile (--file) # DOCKER_CONTEXT Optional, default: . (--context) # DOCKER_BUILDER Optional buildx builder name (--builder) # ALLOW_UNCOMMITTED=1 Publish despite a dirty working tree # # Options: # --load Load a single-platform image instead of pushing # --dry-run Print the resolved build without executing it # -h, --help Show this help project_root=$(git rev-parse --show-toplevel 2>/dev/null || true) # Load project .env without printing values; explicitly exported shell values keep precedence. if [[ -n "$project_root" && -f "$project_root/.env" ]]; then while IFS='=' read -r key value; do key=${key%%[[:space:]]*} [[ -z "$key" || "$key" == \#* ]] && continue if [[ -n "${!key:-}" ]]; then continue # shell value already set: wins over .env fi value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'} printf -v "$key" '%s' "$value" export "$key" done < <(grep -v '^[[:space:]]*$' "$project_root/.env") fi git_repo_name= if [[ -n "$project_root" ]]; then git_repo_name=$(basename "$(git -C "$project_root" rev-parse --show-toplevel)") fi registry=${DOCKER_REGISTRY:-} repository=${DOCKER_REPOSITORY:-$git_repo_name} tag=${IMAGE_TAG:-} platform=${PLATFORMS:-linux/amd64} dockerfile=${DOCKER_DOCKERFILE:-Dockerfile} build_context=${DOCKER_CONTEXT:-.} builder=${DOCKER_BUILDER:-} mode=push dry_run=false while (($#)); do case "$1" in --registry) registry=$2; shift 2 ;; --repository) repository=$2; shift 2 ;; --tag) tag=$2; shift 2 ;; --platform) platform=$2; shift 2 ;; --file) dockerfile=$2; shift 2 ;; --context) build_context=$2; shift 2 ;; --builder) builder=$2; shift 2 ;; --load) mode=load; shift ;; --dry-run) dry_run=true; shift ;; -h|--help) usage; exit 0 ;; *) echo "Error: unknown argument: $1" >&2; usage >&2; exit 2 ;; esac done if [[ -n "$registry" && ( "$registry" == *://* || "$registry" == */* ) ]]; then echo "Error: registry must be a bare host without scheme or slash: $registry" >&2 exit 2 fi if [[ -z "$registry" ]]; then echo "Error: DOCKER_REGISTRY (or --registry) is required." >&2 echo "Set it in the environment or the project root .env." >&2 usage >&2 exit 2 fi if [[ -z "$repository" || "$repository" == /* || "$repository" == */ || "$repository" != */* ]]; then echo "Error: repository must be namespace/name without leading or trailing slash: $repository" >&2 exit 2 fi script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) version_sh=$script_dir/version.sh if [[ -z "$tag" ]]; then if [[ ! -x "$version_sh" ]]; then echo "Error: version.sh not found next to publish_docker.sh: $version_sh" >&2 exit 2 fi if [[ -z "$project_root" ]]; then echo "Error: IMAGE_TAG (or --tag) is required outside a git repository." >&2 exit 2 fi tag=$("$version_sh" -C "$project_root" --docker) || { echo "Error: failed to derive IMAGE_TAG from Git ancestry." >&2 exit 2 } fi if [[ "$tag" == *:* || "$tag" == */* ]]; then echo "Error: tag must not contain : or /: $tag" >&2 exit 2 fi # Official-shaped tags (X.Y.Z or vX.Y.Z) are only legal on that exact Git tag. if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ && -x "$version_sh" && -n "$project_root" ]]; then derived=$("$version_sh" -C "$project_root" --docker) || true expected=${tag#v} if [[ "$derived" != "$expected" ]]; then echo "Error: IMAGE_TAG $tag looks official but HEAD is $derived" >&2 echo "Official X.Y.Z is allowed only when HEAD exact-matches vX.Y.Z." >&2 exit 2 fi fi if [[ "$tag" == latest && ${ALLOW_LATEST:-0} != 1 && "$mode" == push ]]; then echo "Error: refusing to publish floating tag 'latest'; pass an explicit version." >&2 echo "Set ALLOW_LATEST=1 only when the user explicitly asked for 'latest'." >&2 exit 3 fi if [[ "$mode" == load && "$platform" == *,* ]]; then echo "Error: --load cannot be combined with multiple platforms: $platform" >&2 exit 2 fi if [[ ! -f "$dockerfile" ]]; then echo "Error: Dockerfile not found: $dockerfile" >&2 exit 2 fi if [[ ! -d "$build_context" ]]; then echo "Error: build context not found: $build_context" >&2 exit 2 fi if [[ "$dry_run" == false ]] && ! command -v docker >/dev/null 2>&1; then echo "Error: docker is required." >&2 exit 2 fi # Dirty-tree gate: publishing uncommitted content requires explicit opt-in. if [[ "$mode" == push && "$dry_run" == false && -n "$project_root" ]] \ && git -C "$project_root" rev-parse HEAD >/dev/null 2>&1; then if [[ ${ALLOW_UNCOMMITTED:-0} != 1 ]] && ! git -C "$project_root" diff-index --quiet HEAD -- 2>/dev/null; then echo "Error: working tree has uncommitted changes; refusing to publish." >&2 echo "Commit first, or set ALLOW_UNCOMMITTED=1 to publish anyway." >&2 exit 3 fi fi image_ref="${registry}/${repository}:${tag}" build_cmd=(docker buildx build --file "$dockerfile" --platform "$platform" --tag "$image_ref") if [[ -n "$builder" ]]; then build_cmd+=(--builder "$builder") fi if [[ "$mode" == push ]]; then build_cmd+=(--push) else build_cmd+=(--load) fi build_cmd+=("$build_context") printf 'Image: %s\n' "$image_ref" printf 'Platform: %s\n' "$platform" printf 'Dockerfile: %s\n' "$dockerfile" printf 'Context: %s\n' "$build_context" printf 'Mode: %s\n' "$mode" if [[ "$dry_run" == true ]]; then printf 'Command: %s\n' "${build_cmd[*]}" exit 0 fi "${build_cmd[@]}" if [[ "$mode" == push ]]; then docker buildx imagetools inspect "$image_ref" fi