eratic / deploy / deploy.sh
968 words
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
#!/usr/bin/env bash
#
# Build every site and publish it.
#
#   npm run deploy               build, upload, go live
#   npm run deploy -- --dry-run  build and report sizes, change nothing
#
# Safe to run at any time from any state, including the first time: it creates
# whatever is missing, uploads into a fresh directory, and only then flips a
# symlink. The live site is either entirely the old release or entirely the new
# one — never a half-uploaded mixture.
#
# Going back is `./deploy/rollback.sh`, which flips the symlink the other way.
#
# Run ./deploy/bootstrap.sh once before the first deploy.

set -euo pipefail

SERVER="${ERATIC_SERVER:-root@178.104.116.235}"
SSH_KEY="${ERATIC_SSH_KEY:-$HOME/.ssh/id_ed25519_eratic}"
REMOTE_ROOT="${ERATIC_REMOTE_ROOT:-/opt/web}"
PROJECT="${ERATIC_PROJECT:-eratic}"
KEEP_RELEASES="${ERATIC_KEEP_RELEASES:-5}"
SITES=(home blog fav io)
HOSTS=(eratic.ro blog.eratic.ro fav.eratic.ro io.eratic.ro)

SITE_ROOT="$REMOTE_ROOT/sites/$PROJECT"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

DRY_RUN=0
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1

bold() { printf '\033[1m%s\033[0m\n' "$*"; }
info() { printf '  %s\n' "$*"; }
ok()   { printf '\033[32m✓\033[0m %s\n' "$*"; }
fail() { printf '\033[31m✗ %s\033[0m\n' "$*" >&2; exit 1; }

remote() { ssh -o BatchMode=yes -o ConnectTimeout=20 -i "$SSH_KEY" "$SERVER" "$@"; }

# ---------------------------------------------------------------------------
# 1. Preflight
# ---------------------------------------------------------------------------

bold "Preflight"

[[ -f "$SSH_KEY" ]] || fail "SSH key not found at $SSH_KEY (set ERATIC_SSH_KEY)"
remote true 2>/dev/null || fail "cannot reach $SERVER — has ./deploy/bootstrap.sh been run?"
ok "server reachable"

if git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1; then
	GIT_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo nogit)"
	if [[ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]]; then
		info "note: uncommitted changes present — deploying them anyway"
		GIT_SHA="${GIT_SHA}-dirty"
	fi
else
	GIT_SHA="nogit"
fi

RELEASE="$(date -u +%Y%m%d-%H%M%S)-${GIT_SHA}"
info "release $RELEASE"

# ---------------------------------------------------------------------------
# 2. Build
#
# `npm run build` runs photos:check first, so a photograph carrying GPS
# coordinates stops the deploy here rather than reaching the internet.
# ---------------------------------------------------------------------------

bold "Build"
( cd "$REPO_ROOT" && npm run build ) || fail "build failed — nothing was deployed"

for site in "${SITES[@]}"; do
	[[ -f "$REPO_ROOT/sites/$site/dist/index.html" ]] \
		|| fail "sites/$site/dist/index.html missing — the build produced nothing for $site"
done
ok "all four sites built"

if (( DRY_RUN )); then
	bold "Dry run — stopping before upload"
	for site in "${SITES[@]}"; do
		printf '  %-6s %s\n' "$site" "$(du -sh "$REPO_ROOT/sites/$site/dist" | cut -f1)"
	done
	exit 0
fi

# ---------------------------------------------------------------------------
# 3. Upload
#
# --link-dest hard-links every unchanged file against the release that is
# currently live, so publishing one new post transfers one post and costs
# almost no extra disk.
# ---------------------------------------------------------------------------

bold "Upload"
remote "mkdir -p '$SITE_ROOT/releases' '$SITE_ROOT/media' '$REMOTE_ROOT/caddy/etc/conf.d'"

CURRENT_TARGET="$(remote "readlink '$SITE_ROOT/current' 2>/dev/null || true")"

# rsync only creates the final path component, and --mkpath is not in the
# rsync macOS ships. Make the directories up front.
remote "mkdir -p $(printf "'$SITE_ROOT/releases/$RELEASE/%s' " "${SITES[@]}")"

for site in "${SITES[@]}"; do
	link_dest=()
	if [[ -n "$CURRENT_TARGET" ]]; then
		link_dest=(--link-dest="$SITE_ROOT/$CURRENT_TARGET/$site")
	fi

	# The +"${...}" form is required because macOS ships bash 3.2, where
	# expanding an empty array under `set -u` is an "unbound variable" error.
	rsync -az --delete \
		${link_dest[@]+"${link_dest[@]}"} \
		-e "ssh -o BatchMode=yes -i $SSH_KEY" \
		"$REPO_ROOT/sites/$site/dist/" \
		"$SERVER:$SITE_ROOT/releases/$RELEASE/$site/"
	info "$site uploaded"
done
ok "release staged"

# ---------------------------------------------------------------------------
# 4. Go live
#
# A RELATIVE symlink, because Caddy mounts the sites directory and resolves
# this from inside the container. An absolute path would point at a directory
# that does not exist there.
#
# `mv -T` replaces the symlink atomically — there is no instant at which
# `current` is missing.
# ---------------------------------------------------------------------------

bold "Go live"
remote "cd '$SITE_ROOT' && ln -sfn 'releases/$RELEASE' current.tmp && mv -Tf current.tmp current"
ok "current → releases/$RELEASE"

# ---------------------------------------------------------------------------
# 5. Web server config
#
# Uploaded every time so the config and the content it serves are always the
# same version. Caddy validates the entire conf.d set before loading any of it,
# so a mistake here fails loudly and the previous config keeps serving.
# ---------------------------------------------------------------------------

bold "Web server"
rsync -az -e "ssh -o BatchMode=yes -i $SSH_KEY" \
	"$REPO_ROOT/deploy/eratic.caddy" \
	"$SERVER:$REMOTE_ROOT/caddy/etc/conf.d/$PROJECT.caddy"

if remote "docker exec web-caddy caddy validate --adapter caddyfile --config /etc/caddy/Caddyfile" >/dev/null 2>&1; then
	remote "docker exec web-caddy caddy reload --adapter caddyfile --config /etc/caddy/Caddyfile" >/dev/null
	ok "caddy reloaded"
else
	remote "rm -f '$REMOTE_ROOT/caddy/etc/conf.d/$PROJECT.caddy'"
	fail "caddy rejected the new config — it was removed and the running config is untouched"
fi

# ---------------------------------------------------------------------------
# 6. Tidy old releases
# ---------------------------------------------------------------------------

bold "Housekeeping"
remote "cd '$SITE_ROOT/releases' && ls -1t | tail -n +$((KEEP_RELEASES + 1)) | xargs -r rm -rf"
KEPT="$(remote "ls -1 '$SITE_ROOT/releases' | wc -l | tr -d ' '")"
DISK="$(remote "df -h / | awk 'NR==2{print \$4\" free\"}'")"
ok "$KEPT release(s) kept · $DISK"

# ---------------------------------------------------------------------------
# 7. Prove it works
#
# Certificates are issued on the first request for a hostname, which can take
# a few seconds, so the first deploy retries before giving up.
# ---------------------------------------------------------------------------

bold "Verify"
FAILED=0
for host in "${HOSTS[@]}"; do
	code=000
	for attempt in 1 2 3 4 5; do
		code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 25 "https://$host/" 2>/dev/null || echo 000)"
		[[ "$code" == "200" ]] && break
		sleep 4
	done

	if [[ "$code" == "200" ]]; then
		ok "https://$host → 200"
	else
		printf '\033[31m✗\033[0m https://%s → %s\n' "$host" "$code"
		FAILED=1
	fi
done

# The feeds are what other people's software depends on. A 200 on the homepage
# with a broken feed is a silent failure for every subscriber.
for host in blog.eratic.ro fav.eratic.ro io.eratic.ro; do
	for path in /rss.xml /feed.json; do
		code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 "https://$host$path" 2>/dev/null || echo 000)"
		[[ "$code" == "200" ]] || { printf '\033[31m✗\033[0m https://%s%s → %s\n' "$host" "$path" "$code"; FAILED=1; }
	done
done
(( FAILED )) || ok "all feeds serving"

(( FAILED == 0 )) || fail "deployed, but verification failed — see above"

bold "Done — $RELEASE is live"

static mirror of HEAD · about · clone: git clone https://git.makefuture.ro/eratic.git