diff --git a/.gitignore b/.gitignore index 391ef3a..4849d66 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ # Local scratch space /.cache/ + +# Local deploy credentials +/scripts/deploy.env diff --git a/README.md b/README.md index d1f6d0a..9d1d36b 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,28 @@ the VPS webroot. There is one deploy target. For development, run the site locally with `hugo server`. +### Deploying from a workstation + +`scripts/deploy.sh` does the same thing without CI — useful while the Gitea +runner is not set up. It needs `rsync`, so run it from WSL or another Linux +shell, not Git Bash or PowerShell. + +```sh +cp scripts/deploy.env.example scripts/deploy.env # then fill it in (gitignored) +./scripts/deploy.sh --dry-run # preview, upload nothing +./scripts/deploy.sh # build, preview, confirm, deploy +``` + +| Flag | Effect | +|---|---| +| `--dry-run` | Stop after the preview | +| `--yes` | Skip the confirmation prompt | +| `--skip-build` | Deploy the existing `public/`, e.g. after building on Windows | + +The script previews every change and reports how many files would be **deleted** +on the server before asking to continue. Keep the SSH key inside the WSL +filesystem — a key under `/mnt/c` cannot hold `0600`, and ssh will reject it. + ### Required secrets Set these in the Gitea repository settings: diff --git a/scripts/deploy.env.example b/scripts/deploy.env.example new file mode 100644 index 0000000..a499773 --- /dev/null +++ b/scripts/deploy.env.example @@ -0,0 +1,10 @@ +# Copy to scripts/deploy.env (gitignored) and fill in. +# Must match the values used by the Gitea Actions secrets. + +DEPLOY_HOST=server.example.de +DEPLOY_USER=www-deploy +DEPLOY_PATH=/var/www/mcg + +# Private half of the deploy key. Keep it inside the WSL filesystem — +# a key under /mnt/c or /mnt/d cannot hold 0600 and ssh will reject it. +SSH_KEY=$HOME/.ssh/mcg-deploy diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..d1178b0 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# +# Manual deploy from a workstation, for use until the Gitea runner is up. +# Mirrors .gitea/workflows/deploy.yml: same build, same rsync, same guards. +# +# ./scripts/deploy.sh build, preview the changes, confirm, deploy +# ./scripts/deploy.sh --dry-run stop after the preview +# ./scripts/deploy.sh --yes skip the confirmation prompt +# ./scripts/deploy.sh --skip-build deploy whatever is already in public/ +# +# Configuration is read from scripts/deploy.env (gitignored); see +# scripts/deploy.env.example. Environment variables take precedence. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +DRY_RUN=0 +ASSUME_YES=0 +SKIP_BUILD=0 +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + --yes|-y) ASSUME_YES=1 ;; + --skip-build) SKIP_BUILD=1 ;; + -h|--help) sed -n '3,13p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown option: $arg (try --help)" >&2; exit 2 ;; + esac +done + +die() { echo "error: $*" >&2; exit 1; } + +# ---------------------------------------------------------------- config --- + +CONFIG="${DEPLOY_ENV_FILE:-scripts/deploy.env}" +if [ -f "$CONFIG" ]; then + # shellcheck disable=SC1090 + . "$CONFIG" +elif [ -z "${DEPLOY_HOST:-}" ]; then + die "no $CONFIG and no DEPLOY_HOST in the environment. + Copy scripts/deploy.env.example to $CONFIG and fill it in." +fi + +: "${DEPLOY_HOST:?set DEPLOY_HOST}" +: "${DEPLOY_USER:?set DEPLOY_USER}" +: "${DEPLOY_PATH:?set DEPLOY_PATH}" +SSH_KEY="${SSH_KEY:-$HOME/.ssh/mcg-deploy}" + +# rsync runs with --delete, so the destination becomes an exact mirror of +# public/. Refuse to run against an unset, root or shallow path. +case "$DEPLOY_PATH" in + ""|"/"|"/root"|"/home"|"/var"|"/etc"|"/usr"|"/srv"|"/opt") + die "DEPLOY_PATH is unsafe: '$DEPLOY_PATH'" ;; +esac +if [ "$(printf '%s' "$DEPLOY_PATH" | tr -cd '/' | wc -c)" -lt 2 ]; then + die "DEPLOY_PATH '$DEPLOY_PATH' is too shallow to delete into" +fi + +# --------------------------------------------------------- dependencies --- + +for cmd in rsync ssh; do + command -v "$cmd" >/dev/null 2>&1 || die "'$cmd' is not installed. + Run this script from WSL or another Linux shell — Git Bash and + PowerShell do not ship rsync." +done + +# ------------------------------------------------------------- ssh key --- + +[ -f "$SSH_KEY" ] || die "ssh key not found: $SSH_KEY" + +case "$SSH_KEY" in + /mnt/*) + die "the ssh key is on the Windows filesystem ($SSH_KEY). + WSL cannot hold 0600 there, so ssh will reject the key. Copy it into + the WSL home directory instead: + cp '$SSH_KEY' ~/.ssh/mcg-deploy && chmod 600 ~/.ssh/mcg-deploy" ;; +esac + +perms="$(stat -c '%a' "$SSH_KEY" 2>/dev/null || echo '')" +if [ -n "$perms" ] && [ "$perms" != "600" ] && [ "$perms" != "400" ]; then + echo "==> Tightening permissions on $SSH_KEY (was $perms)" + chmod 600 "$SSH_KEY" +fi + +# --------------------------------------------------------------- build --- + +if [ "$SKIP_BUILD" -eq 0 ]; then + command -v hugo >/dev/null 2>&1 || die "hugo is not on PATH. + Either install Hugo 0.164.0 in WSL, or build on the Windows side and + re-run this script with --skip-build." + echo "==> Building" + rm -rf public + hugo --gc --minify +fi + +[ -f public/index.html ] || \ + die "public/index.html missing - refusing to deploy an empty build" + +# -------------------------------------------------------------- deploy --- + +SSH_CMD="ssh -i $SSH_KEY -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" +TARGET="${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/" +RSYNC_OPTS=(-az --delete --checksum) + +preview="$(mktemp)" +trap 'rm -f "$preview"' EXIT + +echo "==> Previewing changes against $TARGET" +rsync "${RSYNC_OPTS[@]}" --itemize-changes --dry-run \ + -e "$SSH_CMD" public/ "$TARGET" | tee "$preview" + +deletions="$(grep -c '^\*deleting' "$preview" || true)" +updates="$(grep -cv '^\*deleting' "$preview" || true)" +echo +echo "==> $updates file(s) to send, $deletions to delete on the server" + +if [ "$DRY_RUN" -eq 1 ]; then + echo "==> Dry run only, nothing was uploaded." + exit 0 +fi + +if [ "$ASSUME_YES" -eq 0 ]; then + printf 'Continue? [y/N] ' + read -r reply + case "$reply" in + [yY]|[yY][eE][sS]) ;; + *) echo "aborted"; exit 1 ;; + esac +fi + +echo "==> Deploying" +rsync "${RSYNC_OPTS[@]}" --human-readable --stats \ + -e "$SSH_CMD" public/ "$TARGET" + +echo "==> Done"