Files
.pouch/skills/builder/scripts/publish_docker.sh
T
2026-08-25 15:26:29 +08:00

183 lines
6.1 KiB
Bash
Executable File

#!/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