Repository navigation
Expand file tree
/
Copy pathdeploy.sh
More file actions
executable file
·227 lines (200 loc) · 9.39 KB
/
Copy pathdeploy.sh
File metadata and controls
executable file
·227 lines (200 loc) · 9.39 KB
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
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
#!/usr/bin/env bash
# deploy.sh — atomic symlink-swap deployment with automatic rollback.
#
# The layout this manages:
#
# /var/www/myapp/
# ├── releases/
# │ ├── 20260823T161500Z/ ← previous
# │ └── 20260823T171200Z/ ← just built
# ├── shared/ ← persists across releases (.env, storage, uploads)
# └── current -> releases/20260823T171200Z
#
# The swap itself is a single `ln -sfn` followed by `mv`, which is atomic on
# POSIX filesystems: no request ever observes a half-updated document root.
# If the health check fails after the swap, `current` goes back to the previous
# release and the bad one is left on disk for inspection.
set -euo pipefail
# ── Configuration ───────────────────────────────────────────────────────────
APP_ROOT=${APP_ROOT:?APP_ROOT must be set (e.g. /var/www/myapp)}
REPO_URL=${REPO_URL:-}
BRANCH=${BRANCH:-main}
KEEP_RELEASES=${KEEP_RELEASES:-5}
HEALTH_URL=${HEALTH_URL:-}
HEALTH_TIMEOUT=${HEALTH_TIMEOUT:-30}
BUILD_CMD=${BUILD_CMD:-}
RELOAD_CMD=${RELOAD_CMD:-}
# Paths that must survive a release: config, user uploads, logs. Each is
# replaced in the new release by a symlink into shared/.
SHARED_PATHS=${SHARED_PATHS:-".env storage"}
readonly RELEASES="$APP_ROOT/releases"
readonly SHARED="$APP_ROOT/shared"
readonly CURRENT="$APP_ROOT/current"
STAMP=$(date -u '+%Y%m%dT%H%M%SZ')
readonly STAMP
readonly NEW_RELEASE="$RELEASES/$STAMP"
log() { printf '\033[1;34m▸\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m✓\033[0m %s\n' "$*"; }
# die aborts the deploy. Once the symlink swap has happened, ROLLBACK_ARMED is
# set and die routes through rollback() instead of exiting directly — otherwise
# an explicit `die` (a failed health check, say) would bypass the ERR trap and
# leave the broken release live, which is precisely the case rollback exists for.
ROLLBACK_ARMED=0
die() {
printf '\033[1;31m✗\033[0m %s\n' "$*" >&2
if [[ $ROLLBACK_ARMED -eq 1 ]]; then
rollback
fi
exit 1
}
# atomic_symlink <target> <linkname>
# Repoints a symlink without ever unlinking it first.
#
# The naive `ln -sfn target link` unlinks and recreates, leaving a window where
# `link` does not exist — a request landing there gets a 404. Instead we create
# a temporary symlink and rename(2) it over the old one, which is atomic.
#
# `mv -T` is required to make GNU mv replace the symlink rather than follow it
# into the directory it points at. BSD/macOS mv has no -T, so there we fall back
# to `ln -sfh`, which is a very short unlink+symlink rather than a true rename.
# That fallback is not atomic; it exists so the script is testable on a Mac, and
# production targets (Linux) always take the atomic path.
atomic_symlink() {
local target=$1 link=$2
local tmp="${link}.tmp.$$"
ln -sfn "$target" "$tmp"
if mv -T "$tmp" "$link" 2>/dev/null; then
return 0
fi
rm -f "$tmp"
if ln -sfh "$target" "$link" 2>/dev/null; then
return 0
fi
# Last resort for a shell with neither: still better than leaving no link.
rm -rf "$link" && ln -sfn "$target" "$link"
}
# ── Rollback ────────────────────────────────────────────────────────────────
# PREVIOUS is captured before the swap so the trap can restore it. It stays
# empty on a first deploy, where there is nothing to roll back to.
PREVIOUS=""
[[ -L $CURRENT ]] && PREVIOUS=$(readlink -f "$CURRENT")
rollback() {
ROLLBACK_ARMED=0 # never re-enter
trap - ERR
printf '\033[1;31m✗ deploy failed — rolling back\033[0m\n' >&2
if [[ -n $PREVIOUS && -d $PREVIOUS ]]; then
atomic_symlink "$PREVIOUS" "$CURRENT"
if [[ -n $RELOAD_CMD ]]; then
eval "$RELOAD_CMD" || printf ' reload command failed during rollback\n' >&2
fi
printf '\033[1;33m↩ restored %s\033[0m\n' "$(basename "$PREVIOUS")" >&2
printf ' the failed release is left at %s for inspection\n' "$NEW_RELEASE" >&2
else
printf ' no previous release to restore (first deploy)\n' >&2
fi
exit 1
}
# ── Prepare ─────────────────────────────────────────────────────────────────
log "Deploying $BRANCH to $APP_ROOT as release $STAMP"
mkdir -p "$RELEASES" "$SHARED"
if [[ -n $REPO_URL ]]; then
log "Cloning $REPO_URL ($BRANCH)"
# --depth 1 keeps the transfer small; a release directory is a snapshot,
# not a working repository, so history here has no value.
git clone --depth 1 --branch "$BRANCH" --quiet "$REPO_URL" "$NEW_RELEASE" \
|| die "clone failed — nothing has changed on the server"
else
# No repo URL: the caller has already staged the build (CI rsync, artifact
# download). Expect it at $STAGING.
STAGING=${STAGING:?set REPO_URL to clone, or STAGING to deploy a prepared directory}
[[ -d $STAGING ]] || die "STAGING directory does not exist: $STAGING"
log "Copying staged build from $STAGING"
mkdir -p "$NEW_RELEASE"
cp -a "$STAGING/." "$NEW_RELEASE/"
fi
# Everything past this point can leave a partial release behind, so arm the trap.
trap rollback ERR
# ── Link shared state ───────────────────────────────────────────────────────
# Done before the build: a build step usually needs .env to exist.
log "Linking shared paths"
for path in $SHARED_PATHS; do
src="$SHARED/$path"
dest="$NEW_RELEASE/$path"
if [[ ! -e $src ]]; then
# Seed shared/ from whatever the release shipped, so the first deploy
# promotes the repo's copy rather than failing on a dangling link.
if [[ -e $dest ]]; then
mkdir -p "$(dirname "$src")"
mv "$dest" "$src"
log " seeded shared/$path from the release"
else
mkdir -p "$src"
log " created empty shared/$path"
fi
fi
rm -rf "$dest"
mkdir -p "$(dirname "$dest")"
ln -sfn "$src" "$dest"
done
ok "Shared paths linked"
# ── Build ───────────────────────────────────────────────────────────────────
# Runs inside the new release, before it is live. A build failure here costs a
# directory on disk and nothing else — the old release is still serving.
if [[ -n $BUILD_CMD ]]; then
log "Building"
( cd "$NEW_RELEASE" && eval "$BUILD_CMD" ) || die "build failed — $CURRENT untouched"
ok "Build complete"
fi
# ── Atomic swap ─────────────────────────────────────────────────────────────
# ln -sfn creates the link under a temp name; mv -T replaces the old symlink in
# one rename(2). Writing the symlink in place would briefly unlink `current`,
# and any request landing in that window gets a 404 from the web server.
log "Swapping $CURRENT -> $STAMP"
atomic_symlink "$NEW_RELEASE" "$CURRENT"
ROLLBACK_ARMED=1
ok "Swap complete"
if [[ -n $RELOAD_CMD ]]; then
log "Reloading application"
eval "$RELOAD_CMD"
fi
# ── Health check ────────────────────────────────────────────────────────────
# The whole point of the rollback path. Without this the deploy "succeeds"
# while serving 500s.
if [[ -n $HEALTH_URL ]]; then
log "Health check: $HEALTH_URL (timeout ${HEALTH_TIMEOUT}s)"
deadline=$(( SECONDS + HEALTH_TIMEOUT ))
healthy=0
while (( SECONDS < deadline )); do
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "$HEALTH_URL" 2>/dev/null) || code=000
if [[ $code == 2* ]]; then
healthy=1
break
fi
sleep 2
done
[[ $healthy -eq 1 ]] || die "health check never returned 2xx (last status: ${code:-none})"
ok "Health check passed"
fi
trap - ERR
ROLLBACK_ARMED=0
# ── Prune old releases ──────────────────────────────────────────────────────
# Kept last so a pruning bug can never delete something the deploy still needs.
# The currently-linked release is excluded explicitly rather than relying on
# it being newest — a rollback makes an older release the live one.
log "Pruning to the last $KEEP_RELEASES releases"
# find -printf '%f' is GNU-only, so the basename is stripped with sed instead.
# Release directory names are our own UTC timestamps, so they contain no
# newlines or spaces and sort lexicographically in chronological order.
live=$(basename "$(readlink -f "$CURRENT")")
pruned=0
while IFS= read -r old; do
[[ -n $old ]] || continue
[[ $old == "$live" ]] && continue
rm -rf "${RELEASES:?}/$old"
pruned=$(( pruned + 1 ))
done < <(find "$RELEASES" -mindepth 1 -maxdepth 1 -type d 2>/dev/null \
| sed 's|.*/||' | sort -r | tail -n +$(( KEEP_RELEASES + 1 )))
if [[ $pruned -gt 0 ]]; then
log " removed $pruned old release(s)"
fi
ok "Deployed $STAMP"