gongxiangp2/scripts/git-incremental-deploy.ps1
2026-05-29 19:07:01 +08:00

588 lines
20 KiB
PowerShell

#Requires -Version 5.1
<#
.SYNOPSIS
Git-based incremental deployment for gongxiangp2.
.DESCRIPTION
Source of truth is the Git repository. The build server fetches only Git
deltas, builds a Docker image with cache, pushes commit-tagged images to the
internal registry, and the production server pulls the selected image.
This script intentionally does not upload a source tarball from the local
workstation and does not build on the production server.
#>
param(
[ValidateSet('production', 'shadow')]
[string]$Target = 'production',
[string]$RepoUrl = 'http://git.1080p.hk/lunanting/gongxiangp2.git',
[string]$Branch = 'main',
[string]$Commit = '',
[string]$GitUsername = $env:GIT_DEPLOY_USERNAME,
[string]$GitPassword = $env:GIT_DEPLOY_PASSWORD,
[string]$GitAuthHeader = $env:GIT_AUTH_HEADER,
[string]$BuildServerIP = '192.168.42.134',
[string]$BuildServerUser = 'root',
[string]$BuildServerPassword = $env:BUILD_SERVER_PASSWORD,
[string]$RemoteBuildDir = '/root/youyidian-3d-git-build',
[string]$ProdServerIP = '192.168.58.20',
[string]$ProdServerUser = 'root',
[int]$RegistryPort = 5000,
[string]$ImageName = 'youyidian-3d-material',
[string]$DownloadTokenSecret = $env:DOWNLOAD_TOKEN_SECRET,
[switch]$DryRun,
[switch]$NoCache,
[switch]$SkipBuild,
[switch]$SkipPush,
[switch]$SkipDeploy,
[switch]$KeepOldImages,
[switch]$AggressiveBuildCacheCleanup,
[switch]$NoRollback
)
$ErrorActionPreference = 'Stop'
$targetConfig = @{
production = @{
Port = 4320
ContainerName = 'youyidian-3d-material'
EnvFile = '.env'
PublicAppUrl = 'https://sc.jiansun.vip'
DingtalkCallbackOrigin = 'https://sc.jiansun.vip'
S3PublicDomain = 'https://s3.jiansun.vip/gongxiangpan'
}
shadow = @{
Port = 5320
ContainerName = 'youyidian-3d-shadow'
EnvFile = '.env.shadow'
PublicAppUrl = 'http://192.168.58.20:5320'
DingtalkCallbackOrigin = 'http://192.168.58.20:5320'
S3PublicDomain = 'https://s3.jiansun.vip/gongxiangpan'
}
}
function Write-Info {
param([string]$Message)
Write-Host "[INFO] $Message" -ForegroundColor Cyan
}
function Write-Success {
param([string]$Message)
Write-Host "[OK] $Message" -ForegroundColor Green
}
function Write-Warning-Custom {
param([string]$Message)
Write-Host "[WARN] $Message" -ForegroundColor Yellow
}
function Write-Error-Custom {
param([string]$Message)
Write-Host "[ERR] $Message" -ForegroundColor Red
}
function Write-Step {
param([string]$Message)
Write-Host ""
Write-Host "==================================================" -ForegroundColor Cyan
Write-Host $Message -ForegroundColor Magenta
Write-Host "==================================================" -ForegroundColor Cyan
}
function ConvertTo-ShellSingleQuoted {
param([AllowNull()][string]$Value)
if ($null -eq $Value) {
return "''"
}
return "'" + $Value.Replace("'", "'\''") + "'"
}
function Get-PlinkPath {
$candidates = @(
'C:\Program Files\PuTTY\plink.exe',
'C:\Program Files (x86)\PuTTY\plink.exe'
)
foreach ($candidate in $candidates) {
if (Test-Path $candidate) {
return $candidate
}
}
$fromPath = Get-Command plink.exe -ErrorAction SilentlyContinue
if ($fromPath) {
return $fromPath.Source
}
throw 'Cannot find plink.exe. Please install PuTTY or add plink.exe to PATH.'
}
function Get-PscpPath {
$candidates = @(
'C:\Program Files\PuTTY\pscp.exe',
'C:\Program Files (x86)\PuTTY\pscp.exe'
)
foreach ($candidate in $candidates) {
if (Test-Path $candidate) {
return $candidate
}
}
$fromPath = Get-Command pscp.exe -ErrorAction SilentlyContinue
if ($fromPath) {
return $fromPath.Source
}
throw 'Cannot find pscp.exe. Please install PuTTY or add pscp.exe to PATH.'
}
function Protect-LogLine {
param([string]$Line)
$masked = $Line
foreach ($secret in @($GitPassword, $BuildServerPassword, $DownloadTokenSecret, $GitAuthHeader)) {
if (-not [string]::IsNullOrEmpty($secret)) {
$masked = $masked.Replace($secret, '***')
}
}
return $masked
}
function Invoke-BuildServerScript {
param([string]$ScriptText)
$plinkPath = Get-PlinkPath
$pscpPath = Get-PscpPath
$scriptId = [guid]::NewGuid().ToString('N')
$localTemp = Join-Path ([System.IO.Path]::GetTempPath()) "git-incremental-deploy-$scriptId.sh"
$remoteTemp = "/tmp/git-incremental-deploy-$scriptId.sh"
[System.IO.File]::WriteAllText($localTemp, $ScriptText, [System.Text.UTF8Encoding]::new($false))
Write-Info "Connecting to build server $BuildServerUser@$BuildServerIP"
try {
$remoteSpec = "${BuildServerUser}@${BuildServerIP}:$remoteTemp"
& $pscpPath -batch -pw $BuildServerPassword $localTemp $remoteSpec 2>&1 |
ForEach-Object {
$line = Protect-LogLine -Line ($_.ToString())
if ($line.Trim()) {
Write-Host $line -ForegroundColor Gray
}
}
if ($LASTEXITCODE -ne 0) {
return $LASTEXITCODE
}
$command = "bash '$remoteTemp'; rc=`$?; rm -f '$remoteTemp'; exit `$rc"
& $plinkPath -batch -pw $BuildServerPassword "$BuildServerUser@$BuildServerIP" $command 2>&1 |
ForEach-Object {
$line = Protect-LogLine -Line ($_.ToString())
if ($line -match '^\[OK\]') {
Write-Host $line -ForegroundColor Green
}
elseif ($line -match '^\[WARN\]') {
Write-Host $line -ForegroundColor Yellow
}
elseif ($line -match '^\[ERR\]|error|failed|fatal' ) {
Write-Host $line -ForegroundColor Red
}
else {
Write-Host $line -ForegroundColor Gray
}
}
return $LASTEXITCODE
}
finally {
Remove-Item $localTemp -Force -ErrorAction SilentlyContinue
}
}
if ([string]::IsNullOrWhiteSpace($BuildServerPassword)) {
throw 'Build server password is required. Pass -BuildServerPassword or set BUILD_SERVER_PASSWORD.'
}
if ([string]::IsNullOrWhiteSpace($GitAuthHeader) -and -not [string]::IsNullOrWhiteSpace($GitUsername) -and -not [string]::IsNullOrWhiteSpace($GitPassword)) {
$pair = "${GitUsername}:${GitPassword}"
$basic = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair))
$GitAuthHeader = "Authorization: Basic $basic"
}
if ($RepoUrl -match '^https?://' -and [string]::IsNullOrWhiteSpace($GitAuthHeader)) {
Write-Warning-Custom 'HTTP Git repository has no auth header. This is OK only if the repository is public or credentials are already configured on the build server.'
}
$cfg = $targetConfig[$Target]
$rollbackFlag = if ($NoRollback) { '0' } else { '1' }
$tokens = @{
'__REPO_URL__' = ConvertTo-ShellSingleQuoted $RepoUrl
'__BRANCH__' = ConvertTo-ShellSingleQuoted $Branch
'__COMMIT__' = ConvertTo-ShellSingleQuoted $Commit
'__GIT_AUTH_HEADER__' = ConvertTo-ShellSingleQuoted $GitAuthHeader
'__REMOTE_BUILD_DIR__' = ConvertTo-ShellSingleQuoted $RemoteBuildDir
'__TARGET__' = ConvertTo-ShellSingleQuoted $Target
'__BUILD_SERVER_IP__' = ConvertTo-ShellSingleQuoted $BuildServerIP
'__PROD_SERVER_IP__' = ConvertTo-ShellSingleQuoted $ProdServerIP
'__PROD_SERVER_USER__' = ConvertTo-ShellSingleQuoted $ProdServerUser
'__REGISTRY_PORT__' = ConvertTo-ShellSingleQuoted ([string]$RegistryPort)
'__IMAGE_NAME__' = ConvertTo-ShellSingleQuoted $ImageName
'__CONTAINER_NAME__' = ConvertTo-ShellSingleQuoted $cfg.ContainerName
'__PORT__' = ConvertTo-ShellSingleQuoted ([string]$cfg.Port)
'__ENV_FILE__' = ConvertTo-ShellSingleQuoted $cfg.EnvFile
'__PUBLIC_APP_URL__' = ConvertTo-ShellSingleQuoted $cfg.PublicAppUrl
'__DINGTALK_CALLBACK_ORIGIN__' = ConvertTo-ShellSingleQuoted $cfg.DingtalkCallbackOrigin
'__S3_PUBLIC_DOMAIN__' = ConvertTo-ShellSingleQuoted $cfg.S3PublicDomain
'__DOWNLOAD_TOKEN_SECRET__' = ConvertTo-ShellSingleQuoted $DownloadTokenSecret
'__DRY_RUN__' = ConvertTo-ShellSingleQuoted ($(if ($DryRun) { '1' } else { '0' }))
'__NO_CACHE__' = ConvertTo-ShellSingleQuoted ($(if ($NoCache) { '1' } else { '0' }))
'__SKIP_BUILD__' = ConvertTo-ShellSingleQuoted ($(if ($SkipBuild) { '1' } else { '0' }))
'__SKIP_PUSH__' = ConvertTo-ShellSingleQuoted ($(if ($SkipPush) { '1' } else { '0' }))
'__SKIP_DEPLOY__' = ConvertTo-ShellSingleQuoted ($(if ($SkipDeploy) { '1' } else { '0' }))
'__KEEP_OLD_IMAGES__' = ConvertTo-ShellSingleQuoted ($(if ($KeepOldImages) { '1' } else { '0' }))
'__AGGRESSIVE_CLEANUP__' = ConvertTo-ShellSingleQuoted ($(if ($AggressiveBuildCacheCleanup) { '1' } else { '0' }))
'__ROLLBACK_ON_FAILURE__' = ConvertTo-ShellSingleQuoted $rollbackFlag
}
$remoteTemplate = @'
set -Eeuo pipefail
REPO_URL=__REPO_URL__
BRANCH=__BRANCH__
COMMIT_INPUT=__COMMIT__
GIT_AUTH_HEADER=__GIT_AUTH_HEADER__
REMOTE_BUILD_DIR=__REMOTE_BUILD_DIR__
TARGET=__TARGET__
BUILD_SERVER_IP=__BUILD_SERVER_IP__
PROD_SERVER_IP=__PROD_SERVER_IP__
PROD_SERVER_USER=__PROD_SERVER_USER__
REGISTRY_PORT=__REGISTRY_PORT__
IMAGE_NAME=__IMAGE_NAME__
CONTAINER_NAME=__CONTAINER_NAME__
PORT=__PORT__
ENV_FILE=__ENV_FILE__
PUBLIC_APP_URL=__PUBLIC_APP_URL__
DINGTALK_CALLBACK_ORIGIN=__DINGTALK_CALLBACK_ORIGIN__
S3_PUBLIC_DOMAIN=__S3_PUBLIC_DOMAIN__
DOWNLOAD_TOKEN_SECRET=__DOWNLOAD_TOKEN_SECRET__
DRY_RUN=__DRY_RUN__
NO_CACHE=__NO_CACHE__
SKIP_BUILD=__SKIP_BUILD__
SKIP_PUSH=__SKIP_PUSH__
SKIP_DEPLOY=__SKIP_DEPLOY__
KEEP_OLD_IMAGES=__KEEP_OLD_IMAGES__
AGGRESSIVE_CLEANUP=__AGGRESSIVE_CLEANUP__
ROLLBACK_ON_FAILURE=__ROLLBACK_ON_FAILURE__
log() { printf '[INFO] %s\n' "$*"; }
ok() { printf '[OK] %s\n' "$*"; }
warn() { printf '[WARN] %s\n' "$*"; }
fail() { printf '[ERR] %s\n' "$*" >&2; exit 1; }
git_cmd() {
if [ -n "$GIT_AUTH_HEADER" ]; then
git -c "http.extraHeader=$GIT_AUTH_HEADER" "$@"
else
git "$@"
fi
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || fail "Missing command on build server: $1"
}
start_remote_step() {
printf '\n==================================================\n'
printf '%s\n' "$1"
printf '==================================================\n'
}
start_remote_step "Preflight"
require_cmd git
require_cmd docker
require_cmd curl
require_cmd ssh
ok "Build server tools are available"
REMOTE_HEAD="$(git_cmd ls-remote --heads "$REPO_URL" "$BRANCH" | awk '{print $1}' | head -n 1 || true)"
if [ -z "$REMOTE_HEAD" ]; then
fail "Cannot read Git branch $BRANCH from $REPO_URL"
fi
ok "Git branch $BRANCH is reachable: ${REMOTE_HEAD:0:12}"
ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=8 "$PROD_SERVER_USER@$PROD_SERVER_IP" "echo OK" >/tmp/git-deploy-prod-check.log 2>&1 || {
cat /tmp/git-deploy-prod-check.log >&2 || true
fail "Cannot reach production server from build server"
}
ok "Production server is reachable from build server"
curl -fsS "http://localhost:${REGISTRY_PORT}/v2/" >/dev/null 2>&1 || {
fail "Docker registry is not reachable on build server port ${REGISTRY_PORT}"
}
ok "Docker registry is reachable"
if [ "$DRY_RUN" = "1" ]; then
log "Dry run completed. No code, image, or container was changed."
exit 0
fi
start_remote_step "Git Sync"
if [ ! -d "$REMOTE_BUILD_DIR/.git" ]; then
warn "Git worktree not found. Creating a fresh clone at $REMOTE_BUILD_DIR"
rm -rf "$REMOTE_BUILD_DIR"
mkdir -p "$(dirname "$REMOTE_BUILD_DIR")"
git_cmd clone "$REPO_URL" "$REMOTE_BUILD_DIR"
else
log "Using existing Git worktree: $REMOTE_BUILD_DIR"
fi
cd "$REMOTE_BUILD_DIR"
git remote set-url origin "$REPO_URL"
git_cmd fetch --prune origin "$BRANCH"
TARGET_REF="origin/$BRANCH"
if [ -n "$COMMIT_INPUT" ]; then
TARGET_REF="$COMMIT_INPUT"
fi
git checkout -B "$BRANCH" "origin/$BRANCH" >/dev/null 2>&1 || git checkout "$BRANCH"
git reset --hard "$TARGET_REF"
git clean -fdx
COMMIT_SHA="$(git rev-parse HEAD)"
SHORT_SHA="$(git rev-parse --short=12 HEAD)"
COMMIT_TAG="${TARGET}-${SHORT_SHA}"
MOVING_TAG="${TARGET}-latest"
REGISTRY_COMMIT="${BUILD_SERVER_IP}:${REGISTRY_PORT}/${IMAGE_NAME}:${COMMIT_TAG}"
REGISTRY_MOVING="${BUILD_SERVER_IP}:${REGISTRY_PORT}/${IMAGE_NAME}:${MOVING_TAG}"
test -f Dockerfile || fail "Dockerfile missing after Git sync"
test -f package.json || fail "package.json missing after Git sync"
test -f "app/api/uploads/[...path]/route.ts" || fail "app/api/uploads fallback route missing"
test -f "app/uploads/[...path]/route.ts" || fail "app/uploads fallback route missing"
ok "Synced to commit $COMMIT_SHA"
log "Image tag: $COMMIT_TAG"
if [ "$SKIP_BUILD" != "1" ]; then
start_remote_step "Docker Build"
docker pull "$REGISTRY_MOVING" >/dev/null 2>&1 || true
build_args=()
if [ "$NO_CACHE" = "1" ]; then
build_args+=(--no-cache)
elif docker image inspect "$REGISTRY_MOVING" >/dev/null 2>&1; then
build_args+=(--cache-from "$REGISTRY_MOVING")
fi
DOCKER_BUILDKIT=1 docker build "${build_args[@]}" \
-t "$IMAGE_NAME:$COMMIT_TAG" \
-t "$IMAGE_NAME:$MOVING_TAG" \
-t "$REGISTRY_COMMIT" \
-t "$REGISTRY_MOVING" \
.
ok "Docker image built: $REGISTRY_COMMIT"
else
warn "Skipping Docker build by request"
fi
if [ "$SKIP_PUSH" != "1" ]; then
start_remote_step "Push Image"
docker push "$REGISTRY_COMMIT"
docker push "$REGISTRY_MOVING"
ok "Pushed image tags: $COMMIT_TAG, $MOVING_TAG"
else
warn "Skipping image push by request"
fi
if [ "$SKIP_DEPLOY" != "1" ]; then
start_remote_step "Deploy on Production Server"
log "Ensuring production server can access image $REGISTRY_COMMIT"
if ssh -o StrictHostKeyChecking=no "$PROD_SERVER_USER@$PROD_SERVER_IP" "docker pull '$REGISTRY_COMMIT'" >/tmp/git-deploy-prod-pull.log 2>&1; then
ok "Production server pulled image from registry"
else
warn "Production server could not pull image directly; streaming image from build server"
cat /tmp/git-deploy-prod-pull.log >&2 || true
docker save "$REGISTRY_COMMIT" | ssh -o StrictHostKeyChecking=no "$PROD_SERVER_USER@$PROD_SERVER_IP" "docker load"
ok "Image loaded on production server via SSH stream"
fi
PROD_PAYLOAD="$(cat <<'PROD_SCRIPT'
set -Eeuo pipefail
log() { printf '[INFO] %s\n' "$*"; }
ok() { printf '[OK] %s\n' "$*"; }
warn() { printf '[WARN] %s\n' "$*"; }
fail() { printf '[ERR] %s\n' "$*" >&2; exit 1; }
start_container() {
local image="$1"
local deploy_version
deploy_version="$(date +%s)"
run_args=(
docker run -d
--name "$CONTAINER_NAME"
--network "$NETWORK_NAME"
-p "${PORT}:3000"
-v "${UPLOADS_MOUNT}:/app/uploads"
--env-file "$ENV_FILE_PATH"
-e "USE_REDIS=true"
-e "REDIS_URL=redis://gongxiangpan-redis:6379"
-e "NEXTAUTH_URL=$PUBLIC_APP_URL"
-e "APP_PUBLIC_URL=$PUBLIC_APP_URL"
-e "NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN=$DINGTALK_CALLBACK_ORIGIN"
-e "S3_PUBLIC_DOMAIN=$S3_PUBLIC_DOMAIN"
-e "DISABLE_CACHE=true"
-e "DEPLOYMENT_VERSION=$deploy_version"
-e "DEPLOYMENT_COMMIT=$COMMIT_SHA"
--memory=6g
--memory-reservation=512m
--restart=unless-stopped
)
if [ -n "$DOWNLOAD_TOKEN_SECRET" ]; then
run_args+=(-e "DOWNLOAD_TOKEN_SECRET=$DOWNLOAD_TOKEN_SECRET")
fi
"${run_args[@]}" "$image"
}
require_file() {
[ -f "$1" ] || fail "Missing required file: $1"
}
command -v docker >/dev/null 2>&1 || fail "Missing docker on production server"
command -v curl >/dev/null 2>&1 || warn "curl not found; health check will use docker logs only"
require_file "$ENV_FILE_PATH"
[ -d "$UPLOADS_MOUNT" ] || fail "Uploads mount does not exist: $UPLOADS_MOUNT"
docker network inspect "$NETWORK_NAME" >/dev/null 2>&1 || fail "Docker network missing: $NETWORK_NAME"
log "Checking image $REGISTRY_COMMIT"
if docker image inspect "$REGISTRY_COMMIT" >/dev/null 2>&1; then
ok "Image already available on production server"
else
docker pull "$REGISTRY_COMMIT"
fi
OLD_IMAGE="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || true)"
if [ -n "$OLD_IMAGE" ]; then
log "Current container image: $OLD_IMAGE"
else
warn "No existing container found"
fi
docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker rm "$CONTAINER_NAME" >/dev/null 2>&1 || true
log "Starting new container $CONTAINER_NAME"
start_container "$REGISTRY_COMMIT" >/tmp/git-deploy-new-container-id.txt
cat /tmp/git-deploy-new-container-id.txt
healthy=0
for i in $(seq 1 12); do
running="$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || echo false)"
if [ "$running" != "true" ]; then
sleep 5
continue
fi
if command -v curl >/dev/null 2>&1; then
if curl -fsS --max-time 8 "http://127.0.0.1:${PORT}/api/health" >/tmp/git-deploy-health.json 2>/tmp/git-deploy-health.err; then
healthy=1
break
fi
else
healthy=1
break
fi
sleep 5
done
if [ "$healthy" != "1" ]; then
warn "New container failed health check"
docker logs --tail 120 "$CONTAINER_NAME" 2>&1 || true
if [ "$ROLLBACK_ON_FAILURE" = "1" ] && [ -n "$OLD_IMAGE" ]; then
warn "Rolling back to $OLD_IMAGE"
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
start_container "$OLD_IMAGE" >/tmp/git-deploy-rollback-container-id.txt
cat /tmp/git-deploy-rollback-container-id.txt
fi
fail "Deployment failed"
fi
ok "Container is healthy on port $PORT"
docker logs --tail 20 "$CONTAINER_NAME" 2>&1 || true
PROD_SCRIPT
)"
export_script="$(printf '%s' "$PROD_PAYLOAD" | base64 -w 0)"
ssh -o StrictHostKeyChecking=no "$PROD_SERVER_USER@$PROD_SERVER_IP" \
"TARGET='$TARGET' CONTAINER_NAME='$CONTAINER_NAME' PORT='$PORT' REGISTRY_COMMIT='$REGISTRY_COMMIT' COMMIT_SHA='$COMMIT_SHA' ENV_FILE_PATH='/mnt/PoolOne/apps/youyidian-3d/$ENV_FILE' PUBLIC_APP_URL='$PUBLIC_APP_URL' DINGTALK_CALLBACK_ORIGIN='$DINGTALK_CALLBACK_ORIGIN' S3_PUBLIC_DOMAIN='$S3_PUBLIC_DOMAIN' DOWNLOAD_TOKEN_SECRET='$DOWNLOAD_TOKEN_SECRET' NETWORK_NAME='youyidian-3d_app-network' UPLOADS_MOUNT='/mnt/PoolOne/apps/youyidian-3d/uploads' ROLLBACK_ON_FAILURE='$ROLLBACK_ON_FAILURE' bash -lc \"printf '%s' '$export_script' | base64 -d | bash\""
ok "Production deployment command completed"
else
warn "Skipping production deployment by request"
fi
start_remote_step "Cleanup"
if [ "$KEEP_OLD_IMAGES" = "1" ]; then
warn "Skipping cleanup by request"
else
docker image prune -f >/dev/null 2>&1 || true
if [ "$AGGRESSIVE_CLEANUP" = "1" ]; then
docker builder prune -af --filter until=168h >/dev/null 2>&1 || true
ok "Build server cleanup completed, including old build cache"
else
ok "Build server image cleanup completed; build cache preserved"
fi
ssh -o StrictHostKeyChecking=no "$PROD_SERVER_USER@$PROD_SERVER_IP" \
"docker image prune -af --filter until=6h >/dev/null 2>&1 || true; docker container prune -f --filter until=6h >/dev/null 2>&1 || true"
ok "Production server cleanup completed"
fi
start_remote_step "Deployment Summary"
ok "Target: $TARGET"
ok "Commit: $COMMIT_SHA"
ok "Image: $REGISTRY_COMMIT"
ok "Container: $CONTAINER_NAME"
'@
$remoteScript = $remoteTemplate
foreach ($key in $tokens.Keys) {
$remoteScript = $remoteScript.Replace($key, $tokens[$key])
}
Write-Step "Git Incremental Deploy"
Write-Info "Target: $Target"
Write-Info "Repository: $RepoUrl"
Write-Info "Branch: $Branch"
if ($Commit) {
Write-Info "Commit override: $Commit"
}
if ($DryRun) {
Write-Warning-Custom 'DryRun mode: no code, image, or production container will be changed.'
}
$exitCode = Invoke-BuildServerScript -ScriptText $remoteScript
if ($exitCode -ne 0) {
Write-Error-Custom "Git incremental deployment failed with exit code $exitCode"
exit $exitCode
}
Write-Success 'Git incremental deployment finished.'