diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 new file mode 100644 index 0000000..711853a --- /dev/null +++ b/scripts/deploy.ps1 @@ -0,0 +1,135 @@ +# Deploy Idle Fantasy Save Viewer to production. +# Usage (from repo root): .\scripts\deploy.ps1 + +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$BashScript = Join-Path $ScriptDir "deploy.sh" + +if (Get-Command bash -ErrorAction SilentlyContinue) { + & bash $BashScript + exit $LASTEXITCODE +} + +$DeployHost = if ($env:DEPLOY_HOST) { $env:DEPLOY_HOST } else { "root@10.0.0.5" } +$DeployDir = if ($env:DEPLOY_DIR) { $env:DEPLOY_DIR } else { "/opt/apps/Idle-Fantasy-Save-Viewer" } +$HealthUrl = if ($env:DEPLOY_HEALTH_URL) { $env:DEPLOY_HEALTH_URL } else { "http://127.0.0.1:5000/" } +$HealthRetries = if ($env:DEPLOY_HEALTH_RETRIES) { [int]$env:DEPLOY_HEALTH_RETRIES } else { 20 } +$HealthInterval = if ($env:DEPLOY_HEALTH_INTERVAL) { [int]$env:DEPLOY_HEALTH_INTERVAL } else { 2 } + +function Write-Step([string]$Message) { + Write-Host "==> $Message" +} + +function Fail([string]$Message) { + Write-Error $Message + exit 1 +} + +$RepoRoot = git rev-parse --show-toplevel 2>$null +if (-not $RepoRoot) { Fail "Not inside a git repository." } +Set-Location $RepoRoot + +$Branch = git rev-parse --abbrev-ref HEAD +if ($Branch -eq "HEAD") { Fail "Detached HEAD – checkout a branch before deploying." } + +$Status = git status --porcelain +if ($Status) { Fail "Working tree is not clean. Commit or stash changes before deploying." } + +$Upstream = git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>$null +if (-not $Upstream) { Fail "Branch '$Branch' has no upstream. Run: git push -u origin $Branch" } + +$LocalSha = git rev-parse HEAD +Write-Step "Local branch: $Branch ($LocalSha)" +Write-Step "Pushing $Branch to origin…" +git push origin $Branch +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Step "Deploying to ${DeployHost}:${DeployDir}" + +$RemoteArgs = @( + $DeployDir, + $Branch, + $LocalSha, + $HealthUrl, + [string]$HealthRetries, + [string]$HealthInterval +) -join " " + +$RemoteScript = @' +set -euo pipefail +REMOTE_DIR="$1" +BRANCH="$2" +EXPECTED_SHA="$3" +HEALTH_URL="$4" +HEALTH_RETRIES="$5" +HEALTH_INTERVAL="$6" + +info() { printf '==> [remote] %s\n' "$*"; } +die() { printf 'ERROR: [remote] %s\n' "$*" >&2; exit 1; } + +command -v docker >/dev/null 2>&1 || die "docker not found on remote host" +docker compose version >/dev/null 2>&1 || die "docker compose not available on remote host" +command -v curl >/dev/null 2>&1 || die "curl not found on remote host" + +[[ -d "$REMOTE_DIR/.git" ]] || die "Directory is not a git repo: $REMOTE_DIR" + +cd "$REMOTE_DIR" + +if [[ -n "$(git status --porcelain)" ]]; then + die "Remote working tree is dirty. Resolve local changes on the server first." +fi + +info "Fetching origin…" +git fetch origin + +REMOTE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +if [[ "$REMOTE_BRANCH" != "$BRANCH" ]]; then + info "Checking out branch $BRANCH" + git checkout "$BRANCH" +fi + +info "Fast-forwarding to origin/$BRANCH" +git pull --ff-only origin "$BRANCH" + +ACTUAL_SHA="$(git rev-parse HEAD)" +if [[ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]]; then + die "Remote SHA mismatch after pull (expected $EXPECTED_SHA, got $ACTUAL_SHA)." +fi + +info "Rebuilding and starting containers…" +docker compose up -d --build --remove-orphans + +info "Waiting for health check ($HEALTH_URL)…" +ok=0 +for ((i = 1; i <= HEALTH_RETRIES; i++)); do + if curl -fsS -o /dev/null "$HEALTH_URL"; then + ok=1 + break + fi + sleep "$HEALTH_INTERVAL" +done + +if [[ "$ok" -ne 1 ]]; then + die "Health check failed after $((HEALTH_RETRIES * HEALTH_INTERVAL))s." +fi + +info "Health check OK" + +info "Pruning stopped containers…" +docker container prune -f >/dev/null + +info "Pruning dangling images…" +docker image prune -f >/dev/null + +info "Service status:" +docker compose ps +'@ + +$EncodedScript = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($RemoteScript)) +$SshCommand = "echo $EncodedScript | base64 -d | bash -s -- $RemoteArgs" + +ssh -o BatchMode=yes $DeployHost $SshCommand +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Step "Deployment finished successfully." diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..a050f28 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Deploy Idle Fantasy Save Viewer to production. +# Usage (from repo root): bash scripts/deploy.sh + +set -euo pipefail + +REMOTE_HOST="${DEPLOY_HOST:-root@10.0.0.5}" +REMOTE_DIR="${DEPLOY_DIR:-/opt/apps/Idle-Fantasy-Save-Viewer}" +HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:5000/}" +HEALTH_RETRIES="${DEPLOY_HEALTH_RETRIES:-20}" +HEALTH_INTERVAL="${DEPLOY_HEALTH_INTERVAL:-2}" + +info() { printf '==> %s\n' "$*"; } +err() { printf 'ERROR: %s\n' "$*" >&2; } + +die() { + err "$@" + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1" +} + +require_cmd git +require_cmd ssh + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || die "Not inside a git repository." +cd "$ROOT" + +BRANCH="$(git rev-parse --abbrev-ref HEAD)" +if [[ "$BRANCH" == "HEAD" ]]; then + die "Detached HEAD – checkout a branch before deploying." +fi + +if [[ -n "$(git status --porcelain)" ]]; then + die "Working tree is not clean. Commit or stash changes before deploying." +fi + +if ! git rev-parse --abbrev-ref --symbolic-full-name "@{u}" >/dev/null 2>&1; then + die "Branch '$BRANCH' has no upstream. Run: git push -u origin $BRANCH" +fi + +LOCAL_SHA="$(git rev-parse HEAD)" + +info "Local branch: $BRANCH ($LOCAL_SHA)" + +info "Pushing $BRANCH to origin…" +git push origin "$BRANCH" + +REMOTE_SHA="$(git rev-parse HEAD)" + +info "Deploying to $REMOTE_HOST:$REMOTE_DIR" + +ssh -o BatchMode=yes "$REMOTE_HOST" bash -s -- \ + "$REMOTE_DIR" \ + "$BRANCH" \ + "$REMOTE_SHA" \ + "$HEALTH_URL" \ + "$HEALTH_RETRIES" \ + "$HEALTH_INTERVAL" <<'REMOTE_SCRIPT' +set -euo pipefail + +REMOTE_DIR="$1" +BRANCH="$2" +EXPECTED_SHA="$3" +HEALTH_URL="$4" +HEALTH_RETRIES="$5" +HEALTH_INTERVAL="$6" + +info() { printf '==> [remote] %s\n' "$*"; } +die() { printf 'ERROR: [remote] %s\n' "$*" >&2; exit 1; } + +command -v docker >/dev/null 2>&1 || die "docker not found on remote host" +docker compose version >/dev/null 2>&1 || die "docker compose not available on remote host" +command -v curl >/dev/null 2>&1 || die "curl not found on remote host" + +[[ -d "$REMOTE_DIR/.git" ]] || die "Directory is not a git repo: $REMOTE_DIR" + +cd "$REMOTE_DIR" + +if [[ -n "$(git status --porcelain)" ]]; then + die "Remote working tree is dirty. Resolve local changes on the server first." +fi + +info "Fetching origin…" +git fetch origin + +REMOTE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +if [[ "$REMOTE_BRANCH" != "$BRANCH" ]]; then + info "Checking out branch $BRANCH" + git checkout "$BRANCH" +fi + +info "Fast-forwarding to origin/$BRANCH" +git pull --ff-only origin "$BRANCH" + +ACTUAL_SHA="$(git rev-parse HEAD)" +if [[ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]]; then + die "Remote SHA mismatch after pull (expected $EXPECTED_SHA, got $ACTUAL_SHA)." +fi + +info "Rebuilding and starting containers…" +docker compose up -d --build --remove-orphans + +info "Waiting for health check ($HEALTH_URL)…" +ok=0 +for ((i = 1; i <= HEALTH_RETRIES; i++)); do + if curl -fsS -o /dev/null "$HEALTH_URL"; then + ok=1 + break + fi + sleep "$HEALTH_INTERVAL" +done + +if [[ "$ok" -ne 1 ]]; then + die "Health check failed after $((HEALTH_RETRIES * HEALTH_INTERVAL))s." +fi + +info "Health check OK" + +info "Pruning stopped containers…" +docker container prune -f >/dev/null + +info "Pruning dangling images…" +docker image prune -f >/dev/null + +info "Service status:" +docker compose ps +REMOTE_SCRIPT + +info "Deployment finished successfully."