Skip to content

Instantly share code, notes, and snippets.

@jth0
Created October 7, 2025 05:03
Show Gist options
  • Save jth0/bcebb77b4817c472008f83e31d3e7f85 to your computer and use it in GitHub Desktop.
Save jth0/bcebb77b4817c472008f83e31d3e7f85 to your computer and use it in GitHub Desktop.
Shell script to build & deploy a docker image for Posit Workbench in GCP Artifact Registry (GAR)
#!/usr/bin/env bash
# Script for building & pushing a docker image to Google Cloud (GCP) Artifact Registry (GAR)
# Not a ton of "special sauce" here, and you may be able to tell by the emoji that it's been
# AI-enhanced. Major happy paths tested, not all edge cases have been fully vetted though.
# So far only found one hallucination and one set of parameters where code was added but the
# help output and runtime didn't actually reference the var/param or run the code...
set -euo pipefail
# Script configuration and defaults
SCRIPT_NAME="$(basename "$0")"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Default values - these are all overridable with CLI parameters
DEFAULT_REGION="us-central1" # Let's go, No Coast!
DEFAULT_PROJECT="MyProjectNameHere"
DEFAULT_REPO="MyhRepoNameHere"
DEFAULT_IMAGE="MyImageNameHere"
DEFAULT_TAG="test" # A novel idea...
DEFAULT_R_VERSION="4.4.3" # Manually put here, but should get parsed/updated based on base image selected and/or latest found
DEFAULT_PYTHON_VERSION="3.12.11" # ditto
# Initialize variables with defaults
REGION="${DEFAULT_REGION}"
PROJECT="${DEFAULT_PROJECT}"
REPO="${DEFAULT_REPO}"
IMAGE="${DEFAULT_IMAGE}"
TAG="${DEFAULT_TAG}"
R_VERSION="${DEFAULT_R_VERSION}"
PYTHON_VERSION="${DEFAULT_PYTHON_VERSION}"
# Variables for Dockerfile parsing
DOCKERFILE_BASE_IMAGE=""
DOCKERFILE_R_VERSION=""
DOCKERFILE_PYTHON_VERSION=""
DOCKERFILE_UPDATED=false
# Configuration flags
SKIP_CONFIRMATION=false
SKIP_PUSH=false
NO_CACHE=true
PULL=true
PLATFORM="linux/amd64"
AUTO_UPDATE=false
# Track if versions were explicitly specified
R_VERSION_SPECIFIED=false
PYTHON_VERSION_SPECIFIED=false
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${BLUE}ℹ️ $1${NC}"
}
log_success() {
echo -e "${GREEN}✅ $1${NC}"
}
log_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
log_error() {
echo -e "${RED}❌ $1${NC}" >&2
}
log_header() {
echo -e "${CYAN}🐳 $1${NC}"
}
log_update() {
echo -e "${MAGENTA}🔄 $1${NC}"
}
# Help function
show_help() {
cat << EOF
${SCRIPT_NAME} - Build and deploy Docker images to Google Artifact Registry
USAGE:
${SCRIPT_NAME} [OPTIONS]
OPTIONS:
-t, --tag TAG Docker image tag (default: ${DEFAULT_TAG})
-r, --region REGION GCP region (default: ${DEFAULT_REGION})
-p, --project PROJECT GCP project ID (default: ${DEFAULT_PROJECT})
-R, --repo REPO Artifact Registry repository (default: ${DEFAULT_REPO})
-i, --image IMAGE Image name (default: ${DEFAULT_IMAGE})
--r-version VERSION R version (default: ${DEFAULT_R_VERSION})
--python-version VERSION Python version (default: ${DEFAULT_PYTHON_VERSION})
--platform PLATFORM Target platform (default: ${PLATFORM})
--cache Use Docker cache (default: no-cache)
--no-pull Don't pull base images
--skip-push Build only, don't push to registry
--auto-update Automatically check for and prompt to update base image
--yes Skip confirmation prompt
-h, --help Show this help message
EXAMPLES:
${SCRIPT_NAME} --tag v1.2.3
${SCRIPT_NAME} --tag latest --yes
${SCRIPT_NAME} --tag dev --r-version 4.4.1 --python-version 3.11.10
${SCRIPT_NAME} --skip-push --tag local-test --auto-update
EOF
}
# Parse command line arguments
parse_args() {
while [[ $# -gt 0 ]]; do
case $1 in
-t|--tag)
TAG="$2"
shift 2
;;
-r|--region)
REGION="$2"
shift 2
;;
-p|--project)
PROJECT="$2"
shift 2
;;
-R|--repo)
REPO="$2"
shift 2
;;
-i|--image)
IMAGE="$2"
shift 2
;;
--r-version)
R_VERSION="$2"
R_VERSION_SPECIFIED=true
shift 2
;;
--python-version)
PYTHON_VERSION="$2"
PYTHON_VERSION_SPECIFIED=true
shift 2
;;
--platform)
PLATFORM="$2"
shift 2
;;
--cache)
NO_CACHE=false
shift
;;
--no-pull)
PULL=false
shift
;;
--skip-push)
SKIP_PUSH=true
shift
;;
--auto-update)
AUTO_UPDATE=true
shift
;;
--yes)
SKIP_CONFIRMATION=true
shift
;;
-h|--help)
show_help
exit 0
;;
*)
log_error "Unknown option: $1"
echo "Use --help for usage information."
exit 1
;;
esac
done
}
# Parse Dockerfile to extract base image and versions
parse_dockerfile() {
local dockerfile="${SCRIPT_DIR}/Dockerfile"
if [[ ! -f "$dockerfile" ]]; then
log_error "Dockerfile not found in ${SCRIPT_DIR}"
exit 1
fi
log_info "Parsing Dockerfile for base image and versions..."
# Extract base image from FROM instruction (get the active one, not commented)
DOCKERFILE_BASE_IMAGE=$(grep -E "^FROM " "$dockerfile" | head -1 | awk '{print $2}' | tr -d '\r')
if [[ -z "$DOCKERFILE_BASE_IMAGE" ]]; then
log_error "Could not find FROM instruction in Dockerfile"
exit 1
fi
# Parse versions from the base image tag
# Format: rstudio/workbench-session:ubuntu2204-r4.4.3_4.3.3-py3.12.11_3.11.13
local image_tag="${DOCKERFILE_BASE_IMAGE##*:}"
# Extract R versions (primary and secondary)
# Pattern: r4.4.3_4.3.3 -> we want the first one (4.4.3)
if [[ "$image_tag" =~ r([0-9]+\.[0-9]+\.[0-9]+)_ ]]; then
DOCKERFILE_R_VERSION="${BASH_REMATCH[1]}"
else
log_warning "Could not parse R version from base image tag: $image_tag"
DOCKERFILE_R_VERSION="${DEFAULT_R_VERSION}"
fi
# Extract Python versions (primary and secondary)
# Pattern: py3.12.11_3.11.13 -> we want the first one (3.12.11)
if [[ "$image_tag" =~ py([0-9]+\.[0-9]+\.[0-9]+)_ ]]; then
DOCKERFILE_PYTHON_VERSION="${BASH_REMATCH[1]}"
else
log_warning "Could not parse Python version from base image tag: $image_tag"
DOCKERFILE_PYTHON_VERSION="${DEFAULT_PYTHON_VERSION}"
fi
log_success "Dockerfile parsed successfully"
echo " • Base Image: ${DOCKERFILE_BASE_IMAGE}"
echo " • R Version (from tag): ${DOCKERFILE_R_VERSION}"
echo " • Python Version (from tag): ${DOCKERFILE_PYTHON_VERSION}"
echo
}
# Update Dockerfile with new base image
update_dockerfile_base_image() {
local new_base_image="$1"
local dockerfile="${SCRIPT_DIR}/Dockerfile"
log_update "Updating Dockerfile with new base image: ${new_base_image}"
# Create backup
cp "$dockerfile" "${dockerfile}.backup.$(date +%Y%m%d_%H%M%S)"
# Update the FROM line
sed -i.tmp "s|^FROM .*|FROM ${new_base_image}|" "$dockerfile"
rm -f "${dockerfile}.tmp"
DOCKERFILE_BASE_IMAGE="$new_base_image"
DOCKERFILE_UPDATED=true
log_success "Dockerfile updated successfully"
}
# Check version specifications and warn if unnecessary
check_version_warnings() {
local warnings_shown=false
if [[ "$R_VERSION_SPECIFIED" = true ]]; then
if [[ "$R_VERSION" = "$DOCKERFILE_R_VERSION" ]]; then
log_warning "R version ${R_VERSION} matches the base image version"
log_info " The --r-version parameter is not needed - the base image already has R ${DOCKERFILE_R_VERSION}"
warnings_shown=true
else
log_info "Custom R version specified: ${R_VERSION} (will override base image's ${DOCKERFILE_R_VERSION})"
fi
fi
if [[ "$PYTHON_VERSION_SPECIFIED" = true ]]; then
if [[ "$PYTHON_VERSION" = "$DOCKERFILE_PYTHON_VERSION" ]]; then
log_warning "Python version ${PYTHON_VERSION} matches the base image version"
log_info " The --python-version parameter is not needed - the base image already has Python ${DOCKERFILE_PYTHON_VERSION}"
warnings_shown=true
else
log_info "Custom Python version specified: ${PYTHON_VERSION} (will override base image's ${DOCKERFILE_PYTHON_VERSION})"
fi
fi
if [[ "$warnings_shown" = true ]]; then
echo
log_info "💡 Tip: Remove unnecessary version parameters for faster builds that use base image versions"
echo
fi
}
# Validate prerequisites
validate_prerequisites() {
log_info "Validating prerequisites..."
# Check if Docker is available
if ! command -v docker &> /dev/null; then
log_error "Docker is not installed or not in PATH"
exit 1
fi
# Check if gcloud is available
if ! command -v gcloud &> /dev/null; then
log_error "gcloud CLI is not installed or not in PATH"
exit 1
fi
# Check if Dockerfile exists
if [[ ! -f "${SCRIPT_DIR}/Dockerfile" ]]; then
log_error "Dockerfile not found in ${SCRIPT_DIR}"
exit 1
fi
# Check Docker daemon
if ! docker info &> /dev/null; then
log_error "Docker daemon is not running"
exit 1
fi
# Check for optional tools
if ! command -v jq &> /dev/null; then
log_warning "jq not found - base image update checks will be limited"
fi
if ! command -v curl &> /dev/null; then
log_warning "curl not found - base image update checks will be limited"
fi
log_success "Prerequisites validated"
}
# Build full image name
build_image_name() {
FULL_IMAGE="${REGION}-docker.pkg.dev/${PROJECT}/${REPO}/${IMAGE}:${TAG}"
}
# Show build summary
show_summary() {
log_header "Docker Build Summary"
echo
echo "📋 Build Configuration:"
echo " • Image Name: ${FULL_IMAGE}"
echo " • Base Image: ${DOCKERFILE_BASE_IMAGE}$([ "$DOCKERFILE_UPDATED" = true ] && echo " (updated)" || echo "")"
echo " • Platform: ${PLATFORM}"
echo " • R Version: ${R_VERSION}$([ "$R_VERSION_SPECIFIED" = false ] && echo " (default)" || echo "")"
echo " • Python Version: ${PYTHON_VERSION}$([ "$PYTHON_VERSION_SPECIFIED" = false ] && echo " (default)" || echo "")"
echo " • Build Context: ${SCRIPT_DIR}"
echo " • Use Cache: $([ "$NO_CACHE" = true ] && echo "No" || echo "Yes")"
echo " • Pull Base: $([ "$PULL" = true ] && echo "Yes" || echo "No")"
echo
echo "🚀 Actions to be performed:"
echo " 1. Configure Docker authentication for Artifact Registry"
echo " 2. Build Docker image with specified parameters"
if [[ "$SKIP_PUSH" = false ]]; then
echo " 3. Push image to Google Artifact Registry"
else
echo " 3. Skip pushing to registry (--skip-push specified)"
fi
echo
}
# Confirm before proceeding
confirm_action() {
if [[ "$SKIP_CONFIRMATION" = true ]]; then
return 0
fi
echo -n "Do you want to proceed with the build? [y/N]: "
read -r response
case "$response" in
[yY][eE][sS]|[yY])
return 0
;;
*)
log_info "Operation cancelled by user"
exit 0
;;
esac
}
# Configure Docker authentication to GCP Artifact Registry (GAR)
# If this fails, most likely just need to run `gcloud auth login` or `glcoud auth application-default login` -- not sure which
configure_docker_auth() {
log_info "Configuring Docker for Artifact Registry..."
if ! gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet; then
log_error "Failed to configure Docker authentication"
exit 1
fi
log_success "Docker authentication configured"
}
# Build Docker image
build_image() {
log_info "Building image ${FULL_IMAGE}..."
# Build docker command
local docker_cmd=(
"docker" "build"
"--platform=${PLATFORM}"
"--provenance=false" # disable attestations / extra images in deployment
"--build-arg" "R_VERSION=${R_VERSION}" # the Dockerfile refers to this parameter when installing/updating R pkgs
"--build-arg" "PYTHON_VERSION=${PYTHON_VERSION}" # the Dockerfile doesn't touch this but I left it anyway
"-t" "${FULL_IMAGE}"
)
if [[ "$NO_CACHE" = true ]]; then
docker_cmd+=("--no-cache")
fi
if [[ "$PULL" = true ]]; then
docker_cmd+=("--pull")
fi
docker_cmd+=("${SCRIPT_DIR}")
# Execute build command
if ! "${docker_cmd[@]}"; then
log_error "Docker build failed"
exit 1
fi
log_success "Image built successfully"
}
# Push image to registry
push_image() {
if [[ "$SKIP_PUSH" = true ]]; then
log_info "Skipping image push (--skip-push specified)"
return 0
fi
log_info "Pushing image to Artifact Registry..."
if ! docker push "${FULL_IMAGE}"; then
log_error "Failed to push image to registry"
exit 1
fi
log_success "Image pushed successfully"
}
# Enhanced base image update checking with version parsing
check_base_image_updates() {
if [[ -z "$DOCKERFILE_BASE_IMAGE" ]]; then
log_warning "Could not determine base image from Dockerfile"
return 0
fi
log_info "Current base image: ${DOCKERFILE_BASE_IMAGE}"
log_info " • Current R version: ${DOCKERFILE_R_VERSION}"
log_info " • Current Python version: ${DOCKERFILE_PYTHON_VERSION}"
echo
# Ask user if they want to check for newer versions
if [[ "$AUTO_UPDATE" = false && "$SKIP_CONFIRMATION" = false ]]; then
echo -n "Would you like to check for newer versions of the base image? [y/N]: "
read -r response
case "$response" in
[yY][eE][sS]|[yY])
check_available_versions
;;
*)
log_info "Skipping version check - continuing with current base image"
return 0
;;
esac
elif [[ "$AUTO_UPDATE" = true ]]; then
log_info "Auto-update enabled - checking for newer versions..."
check_available_versions
fi
}
# Function to check and list available versions
check_available_versions() {
local image_name tag_part
if [[ "$DOCKERFILE_BASE_IMAGE" == *":"* ]]; then
image_name="${DOCKERFILE_BASE_IMAGE%:*}"
tag_part="${DOCKERFILE_BASE_IMAGE##*:}"
else
image_name="$DOCKERFILE_BASE_IMAGE"
tag_part="latest"
fi
log_info "Checking available versions for ${image_name}..."
# Method 1: Try using Docker Hub API for rstudio images
if [[ "$image_name" == "rstudio/workbench-session" ]]; then
check_rstudio_versions "$image_name" "$tag_part"
else
# Method 2: Try using skopeo for other registries
check_versions_with_skopeo "$image_name" "$tag_part"
fi
}
# Check rstudio/workbench-session versions using Docker Hub API
check_rstudio_versions() {
local image_name="$1"
local current_tag="$2"
log_info "Querying Docker Hub for ${image_name} tags..."
# Use Docker Hub API to get tags
local api_url="https://hub.docker.com/v2/repositories/${image_name}/tags/?page_size=100"
local available_tags
if command -v curl &> /dev/null && command -v jq &> /dev/null; then
available_tags=$(curl -s "$api_url" | jq -r '.results[].name' 2>/dev/null)
if [[ $? -eq 0 && -n "$available_tags" ]]; then
# Filter for ubuntu2204 tags and extract version info
local ubuntu_tags=$(echo "$available_tags" | grep "ubuntu2204-r" | sort -V)
if [[ -n "$ubuntu_tags" ]]; then
log_success "Found available versions:"
echo
local current_found=false
local newer_versions=()
local current_r_version current_python_version
# Parse current versions for comparison
if [[ "$current_tag" =~ r([0-9]+\.[0-9]+\.[0-9]+)_ ]]; then
current_r_version="${BASH_REMATCH[1]}"
fi
if [[ "$current_tag" =~ py([0-9]+\.[0-9]+\.[0-9]+)_ ]]; then
current_python_version="${BASH_REMATCH[1]}"
fi
echo "Available tags (showing last 10):"
echo "$ubuntu_tags" | tail -10 | while read -r tag; do
local tag_r_version tag_python_version
# Parse versions from tag
if [[ "$tag" =~ r([0-9]+\.[0-9]+\.[0-9]+)_ ]]; then
tag_r_version="${BASH_REMATCH[1]}"
fi
if [[ "$tag" =~ py([0-9]+\.[0-9]+\.[0-9]+)_ ]]; then
tag_python_version="${BASH_REMATCH[1]}"
fi
# Mark current version and potential newer versions
if [[ "$tag" == "$current_tag" ]]; then
echo " ${tag} (CURRENT) - R:${tag_r_version}, Python:${tag_python_version}"
current_found=true
else
echo " ${tag} - R:${tag_r_version}, Python:${tag_python_version}"
# Simple version comparison (newer if R or Python version is higher)
if [[ -n "$tag_r_version" && -n "$current_r_version" ]] &&
version_greater_than "$tag_r_version" "$current_r_version"; then
newer_versions+=("$tag")
elif [[ -n "$tag_python_version" && -n "$current_python_version" ]] &&
version_greater_than "$tag_python_version" "$current_python_version"; then
newer_versions+=("$tag")
fi
fi
done
echo
# Show newer versions if found
if [[ ${#newer_versions[@]} -gt 0 ]]; then
log_update "Potentially newer versions detected:"
for version in "${newer_versions[@]}"; do
echo " • $version"
done
echo
prompt_for_update "$image_name"
else
log_success "Your current version appears to be up to date!"
fi
else
log_warning "No ubuntu2204 tags found"
fallback_version_check "$image_name"
fi
else
log_warning "Failed to query Docker Hub API"
fallback_version_check "$image_name"
fi
else
log_warning "curl or jq not available for API queries"
fallback_version_check "$image_name"
fi
}
# Check versions using skopeo (for non-Docker Hub registries)
check_versions_with_skopeo() {
local image_name="$1"
local current_tag="$2"
if command -v skopeo &> /dev/null && command -v jq &> /dev/null; then
log_info "Using skopeo to check available tags..."
local available_tags
available_tags=$(skopeo list-tags "docker://${image_name}" 2>/dev/null | jq -r '.Tags[]' 2>/dev/null)
if [[ $? -eq 0 && -n "$available_tags" ]]; then
echo "Available tags:"
echo "$available_tags" | sort -V | tail -10
echo
prompt_for_update "$image_name"
else
log_warning "Failed to query registry with skopeo"
fallback_version_check "$image_name"
fi
else
log_warning "skopeo or jq not available"
fallback_version_check "$image_name"
fi
}
# Fallback when automatic checking fails
fallback_version_check() {
local image_name="$1"
log_info "💡 To manually check for newer versions:"
if [[ "$image_name" == "rstudio/workbench-session" ]]; then
echo " Visit: https://hub.docker.com/r/rstudio/workbench-session/tags"
else
echo " Run: docker search ${image_name}"
echo " Or visit the registry where this image is hosted"
fi
echo
echo -n "Have you checked and found a newer version you'd like to use? [y/N]: "
read -r response
case "$response" in
[yY][eE][sS]|[yY])
prompt_for_manual_update "$image_name"
;;
*)
log_info "Continuing with current base image"
;;
esac
}
# Prompt user to select or manually enter a new version
prompt_for_update() {
local image_name="$1"
echo -n "Would you like to update to a newer version? [y/N]: "
read -r response
case "$response" in
[yY][eE][sS]|[yY])
prompt_for_manual_update "$image_name"
;;
*)
log_info "Continuing with current base image"
;;
esac
}
# Manual update prompt
prompt_for_manual_update() {
local image_name="$1"
echo -n "Enter the new tag (e.g., ubuntu2204-r4.4.3_4.3.3-py3.12.11_3.11.13): "
read -r new_tag
if [[ -n "$new_tag" ]]; then
local new_image="${image_name}:${new_tag}"
log_info "New base image will be: ${new_image}"
echo -n "Proceed with this update? [y/N]: "
read -r confirm
case "$confirm" in
[yY][eE][sS]|[yY])
update_dockerfile_base_image "$new_image"
# Re-parse to get new versions
parse_dockerfile
;;
*)
log_info "Update cancelled"
;;
esac
else
log_info "No tag provided - continuing with current image"
fi
}
# Simple version comparison function
version_greater_than() {
local version1="$1"
local version2="$2"
# Convert versions to comparable format
local v1_major v1_minor v1_patch
local v2_major v2_minor v2_patch
IFS='.' read -r v1_major v1_minor v1_patch <<< "$version1"
IFS='.' read -r v2_major v2_minor v2_patch <<< "$version2"
# Compare major version
if [[ "$v1_major" -gt "$v2_major" ]]; then
return 0
elif [[ "$v1_major" -lt "$v2_major" ]]; then
return 1
fi
# Compare minor version
if [[ "$v1_minor" -gt "$v2_minor" ]]; then
return 0
elif [[ "$v1_minor" -lt "$v2_minor" ]]; then
return 1
fi
# Compare patch version
if [[ "$v1_patch" -gt "$v2_patch" ]]; then
return 0
else
return 1
fi
}
# Main execution
main() {
log_header "Docker Build & Deploy Script"
echo
# Parse command line arguments
parse_args "$@"
# Build full image name
build_image_name
# Parse Dockerfile for base image and versions
parse_dockerfile
# Check for base image updates
check_base_image_updates
# Check for version warnings
check_version_warnings
# Validate prerequisites
validate_prerequisites
# Show summary and confirm
show_summary
confirm_action
echo
log_header "Starting build process..."
# Execute build steps
configure_docker_auth
build_image
push_image
echo
log_success "Build process completed successfully!"
log_success "Image ready: ${FULL_IMAGE}"
if [[ "$DOCKERFILE_UPDATED" = true ]]; then
log_info "📝 Dockerfile was updated during this build"
log_info " Backup saved with timestamp"
fi
if [[ "$SKIP_PUSH" = false ]]; then
echo
log_info "You can now use this image in your deployments:"
echo " docker pull ${FULL_IMAGE}"
echo " docker run ${FULL_IMAGE}"
fi
}
# Run main function with all arguments
main "$@"
@jth0
Copy link
Author

jth0 commented Oct 7, 2025

Buyer beware - make sure your AI checks my AI's work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment