Add a zip archive as a second source for exo-deploy
Until now a release could only come from the bare repo on the VPS, so code that never reached this server's git remote could not be deployed at all. exo-deploy now also takes a zip of the landing source directories: exo-deploy --zip /tmp/exo-20260101.zip medcenterphysio exo-deploy --zip /tmp/exo-20260101.zip all Everything after the source lands in the release directory is unchanged and shared by both modes — npm ci, the build, the %VITE_SITE_URL% check, prune, gzip, the atomic swap, the health check with rollback, the KEEP=3 rotation. The archive therefore carries sources, never a build: VITE_SITE_URL keeps coming from /etc/exo/<app>.build.env rather than from whoever packed the zip. Any .env that travelled inside the archive is dropped before the build. The archive is unpacked once, node_modules/dist/__MACOSX/.DS_Store skipped, and every target app is validated up front — a missing directory or a missing package-lock.json is reported for all four at once, before anything on disk moves. The landing directory is located by its package.json, so both fitnes/... and bundle/fitnes/... (what Finder's Compress produces) work. Also new, and shared by both modes: `all` deploys the four in apps.conf order, stopping at the first failure, and each release records where it came from in .deploy-source, so a live release can still be traced once the deploy output is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fc6a6dde8b
commit
5dd6bbc217
+66
-4
@@ -52,7 +52,7 @@ No database, no Redis, no Docker, no PM2. See §14 of the plan for why not Docke
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
apt update && apt full-upgrade -y
|
apt update && apt full-upgrade -y
|
||||||
apt install -y git curl nginx ufw fail2ban unattended-upgrades gzip
|
apt install -y git curl nginx ufw fail2ban unattended-upgrades gzip unzip
|
||||||
timedatectl set-timezone Europe/Moscow
|
timedatectl set-timezone Europe/Moscow
|
||||||
dpkg-reconfigure --priority=low unattended-upgrades
|
dpkg-reconfigure --priority=low unattended-upgrades
|
||||||
|
|
||||||
@@ -188,14 +188,75 @@ Order matters: nginx needs the port-80 vhost live before certbot can validate.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
install -m 755 bin/exo-deploy /usr/local/bin/
|
install -m 755 bin/exo-deploy /usr/local/bin/
|
||||||
sudo -u exo exo-deploy medcenterphysio main
|
sudo -u exo exo-deploy medcenterphysio main # one app
|
||||||
|
sudo -u exo exo-deploy all # all four, in apps.conf order
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploying from a zip archive
|
||||||
|
|
||||||
|
`exo-deploy` takes its code from one of two places: the bare repo (above) or a zip
|
||||||
|
archive sitting on the server. The archive path exists for code that is not in git on
|
||||||
|
this box — a handover build, a contractor's snapshot, a hotfix from a laptop.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
exo-deploy <app|all> [git-ref] # from the bare repo, default ref main
|
||||||
|
exo-deploy --zip <archive.zip> <app|all> # from an archive
|
||||||
|
```
|
||||||
|
|
||||||
|
**The archive holds sources, not a build.** Everything after the source lands in the
|
||||||
|
release directory is identical in both modes — `npm ci`, `npm run build`, the
|
||||||
|
`%VITE_SITE_URL%` check, `npm prune`, gzip, the atomic `current` swap, the health check
|
||||||
|
with automatic rollback. That is the point: `VITE_SITE_URL` comes from
|
||||||
|
`/etc/exo/<app>.build.env` on this server and never from whoever packed the archive.
|
||||||
|
|
||||||
|
Expected layout — the landing directories as they sit in the repo, either at the root of
|
||||||
|
the archive or under one wrapping directory (what Finder's "Compress" produces):
|
||||||
|
|
||||||
|
```
|
||||||
|
fitnes/ hotel/ medcenterphysio/ medcenterstart/ # or bundle/fitnes/ ...
|
||||||
|
package.json, package-lock.json, index.html,
|
||||||
|
src/, server/, shared/, scripts/, public/, tsconfig*.json, vite.config.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
`package-lock.json` is required — `npm ci` refuses to run without it, and `exo-deploy`
|
||||||
|
says so up front for every app in the archive rather than failing halfway through.
|
||||||
|
`node_modules/`, `dist/`, `__MACOSX/` and `.DS_Store` are skipped during unpacking, and
|
||||||
|
any `.env` / `.env.local` that travelled inside the archive is deleted before the build.
|
||||||
|
|
||||||
|
Pack it on the dev machine, from the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zip -r exo-$(date +%Y%m%d).zip fitnes hotel medcenterphysio medcenterstart \
|
||||||
|
-x '*/node_modules/*' '*/dist/*' '*/.env' '*/.env.local' '*/.DS_Store' '*/legacy/*'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then ship and deploy. `/tmp` works; the file only has to be readable by `exo`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp exo-20260101.zip <server>:/tmp/
|
||||||
|
sudo -u exo exo-deploy --zip /tmp/exo-20260101.zip all
|
||||||
|
```
|
||||||
|
|
||||||
|
`all` runs the four in `apps.conf` order and **stops at the first failure** — landings
|
||||||
|
already swapped in stay on their new release, the one that failed rolls itself back.
|
||||||
|
Rerun for the rest once the cause is fixed.
|
||||||
|
|
||||||
|
Each release records where it came from, so a live one can be traced long after the
|
||||||
|
deploy output has scrolled away:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat /srv/exo/<app>/current/.deploy-source
|
||||||
|
# zip exo-20260101.zip sha256=9f86d0...
|
||||||
|
# deployed 2026-01-01T09:12:44Z
|
||||||
```
|
```
|
||||||
|
|
||||||
## Runbook
|
## Runbook
|
||||||
|
|
||||||
| Task | Command |
|
| Task | Command |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Deploy | `sudo -u exo exo-deploy <app> [ref]` |
|
| Deploy | `sudo -u exo exo-deploy <app\|all> [ref]` |
|
||||||
|
| Deploy from a zip | `sudo -u exo exo-deploy --zip <archive.zip> <app\|all>` |
|
||||||
|
| What is live | `cat /srv/exo/<app>/current/.deploy-source` |
|
||||||
| Rollback | `ln -sfn /srv/exo/<app>/releases/<older> /srv/exo/<app>/current && sudo systemctl restart exo@<app>` |
|
| Rollback | `ln -sfn /srv/exo/<app>/releases/<older> /srv/exo/<app>/current && sudo systemctl restart exo@<app>` |
|
||||||
| Tail logs | `journalctl -u exo@<app> -f` |
|
| Tail logs | `journalctl -u exo@<app> -f` |
|
||||||
| All leads, last hour | `journalctl -u 'exo@*' --since '1 hour ago' \| grep '\[lead\]'` |
|
| All leads, last hour | `journalctl -u 'exo@*' --since '1 hour ago' \| grep '\[lead\]'` |
|
||||||
@@ -239,7 +300,8 @@ Also enable reg.ru VPS snapshots — a full-image restore beats rebuilding under
|
|||||||
limiter, single-process by design. Horizontal scaling needs a shared store first.
|
limiter, single-process by design. Horizontal scaling needs a shared store first.
|
||||||
Not a concern at landing-page traffic; nginx `limit_req` is the second layer.
|
Not a concern at landing-page traffic; nginx `limit_req` is the second layer.
|
||||||
- **No CI.** Deployment is a manual `exo-deploy`. A GitHub Actions job that SSHes and
|
- **No CI.** Deployment is a manual `exo-deploy`. A GitHub Actions job that SSHes and
|
||||||
runs it is a natural follow-up once the flow is proven.
|
runs it is a natural follow-up once the flow is proven. Until then `--zip` is the way
|
||||||
|
to deploy code that never reached this server's git remote.
|
||||||
- **One Metrika counter for four domains.** All four landings carry the same
|
- **One Metrika counter for four domains.** All four landings carry the same
|
||||||
Yandex.Metrika counter (`112352796`) in `index.html`, so reports mix the domains and
|
Yandex.Metrika counter (`112352796`) in `index.html`, so reports mix the domains and
|
||||||
every domain has to be listed in the counter's settings, or its hits get filtered.
|
every domain has to be listed in the counter's settings, or its hits get filtered.
|
||||||
|
|||||||
+251
-75
@@ -1,95 +1,271 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build one landing from git into a fresh release directory and swap it in.
|
# Build landings into fresh release directories and swap them in.
|
||||||
# Install as /usr/local/bin/exo-deploy (mode 755); run as the `exo` user.
|
# Install as /usr/local/bin/exo-deploy (mode 755); run as the `exo` user.
|
||||||
#
|
#
|
||||||
# sudo -u exo exo-deploy medcenterphysio # deploys origin/main
|
# sudo -u exo exo-deploy medcenterphysio # from git, origin/main
|
||||||
# sudo -u exo exo-deploy medcenterphysio my-branch
|
# sudo -u exo exo-deploy medcenterphysio my-branch
|
||||||
|
# sudo -u exo exo-deploy --zip /tmp/exo.zip medcenterphysio
|
||||||
|
# sudo -u exo exo-deploy --zip /tmp/exo.zip all # all four, in order
|
||||||
|
#
|
||||||
|
# The zip must contain the landing source directories (fitnes/, hotel/, ...),
|
||||||
|
# not a prebuilt dist/: the build always runs here, so VITE_SITE_URL comes from
|
||||||
|
# /etc/exo/<app>.build.env and never from whoever packed the archive.
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
APP="${1:?usage: exo-deploy <fitnes|hotel|medcenterphysio|medcenterstart> [git-ref]}"
|
APPS_FALLBACK=(fitnes hotel medcenterphysio medcenterstart)
|
||||||
REF="${2:-main}"
|
EXO_ROOT="${EXO_ROOT:-/srv/exo}"
|
||||||
BASE="/srv/exo/$APP"
|
EXO_ETC="${EXO_ETC:-/etc/exo}"
|
||||||
ENVFILE="/etc/exo/$APP.env"
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
BUILDENV="/etc/exo/$APP.build.env"
|
|
||||||
KEEP=3
|
KEEP=3
|
||||||
REL="$BASE/releases/$(date -u +%Y%m%d-%H%M%S)"
|
|
||||||
|
|
||||||
for f in "$ENVFILE" "$BUILDENV"; do
|
usage() {
|
||||||
[[ -r $f ]] || { echo "FATAL: cannot read $f"; exit 1; }
|
cat <<'USAGE'
|
||||||
|
usage:
|
||||||
|
exo-deploy <app|all> [git-ref] deploy from the bare repo (default ref: main)
|
||||||
|
exo-deploy --zip <archive.zip> <app|all> deploy from a zip of the landing sources
|
||||||
|
|
||||||
|
apps: fitnes | hotel | medcenterphysio | medcenterstart | all
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
fatal() { echo "FATAL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
sha256() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
|
||||||
|
else shasum -a 256 "$1" | cut -d' ' -f1; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# apps.conf is the source of truth when the script is run from the repo; the
|
||||||
|
# installed copy in /usr/local/bin has no repo next to it, hence the fallback.
|
||||||
|
known_apps() {
|
||||||
|
if [[ -r $HERE/../apps.conf ]]; then
|
||||||
|
awk -F'|' '/^[a-z]/ { print $1 }' "$HERE/../apps.conf"
|
||||||
|
else
|
||||||
|
printf '%s\n' "${APPS_FALLBACK[@]}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- arguments
|
||||||
|
|
||||||
|
ZIP=""
|
||||||
|
POS=()
|
||||||
|
while (($#)); do
|
||||||
|
case $1 in
|
||||||
|
--zip) ZIP="${2:-}"; [[ -n $ZIP ]] || fatal "--zip needs a path"; shift 2 ;;
|
||||||
|
--zip=*) ZIP="${1#--zip=}"; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
--) shift; while (($#)); do POS+=("$1"); shift; done ;;
|
||||||
|
-*) usage >&2; fatal "unknown option $1" ;;
|
||||||
|
*) POS+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
done
|
done
|
||||||
[[ -d $BASE/repo ]] || { echo "FATAL: no bare repo at $BASE/repo"; exit 1; }
|
|
||||||
|
|
||||||
PORT="$(sed -n 's/^PORT=//p' "$ENVFILE")"
|
APP="${POS[0]:-}"
|
||||||
[[ -n $PORT ]] || { echo "FATAL: PORT not set in $ENVFILE"; exit 1; }
|
REF="${POS[1]:-}"
|
||||||
|
[[ -n $APP ]] || { usage >&2; exit 1; }
|
||||||
|
[[ -z $ZIP || -z $REF ]] || fatal "--zip takes no git ref (got '$REF')"
|
||||||
|
[[ -n $ZIP ]] || REF="${REF:-main}"
|
||||||
|
|
||||||
echo "==> fetching $REF"
|
# Resolve the archive against the caller's directory, then leave it: the usual
|
||||||
git -C "$BASE/repo" fetch --prune origin '+refs/heads/*:refs/heads/*'
|
# invocation is `sudo -u exo` from root's shell, and `exo` cannot read /root —
|
||||||
git -C "$BASE/repo" rev-parse --verify "$REF^{commit}" >/dev/null
|
# GNU find complains it cannot restore that cwd. Everything below is absolute.
|
||||||
|
if [[ -n $ZIP && $ZIP != /* ]]; then ZIP="$PWD/$ZIP"; fi
|
||||||
|
cd /
|
||||||
|
|
||||||
echo "==> extracting $APP/ into $REL"
|
ALL_APPS=()
|
||||||
mkdir -p "$REL"
|
while IFS= read -r a; do
|
||||||
git -C "$BASE/repo" archive "$REF" "$APP" | tar -x -C "$REL" --strip-components=1
|
if [[ -n $a ]]; then ALL_APPS+=("$a"); fi
|
||||||
[[ -f $REL/package.json ]] || { echo "FATAL: $APP/ not found at $REF"; rm -rf "$REL"; exit 1; }
|
done < <(known_apps)
|
||||||
|
[[ ${#ALL_APPS[@]} -gt 0 ]] || fatal "no apps found — check apps.conf"
|
||||||
|
|
||||||
# 2.6-3.9 MB of base64-embedded reference HTML that is never served.
|
TARGETS=()
|
||||||
rm -rf "$REL/legacy"
|
if [[ $APP == all ]]; then
|
||||||
|
TARGETS=("${ALL_APPS[@]}")
|
||||||
# Public build-time vars only. The amoCRM token stays in $ENVFILE, which
|
else
|
||||||
# systemd injects at runtime; dotenv does not override real env vars, so the
|
for a in "${ALL_APPS[@]}"; do
|
||||||
# two never collide.
|
if [[ $a == "$APP" ]]; then TARGETS=("$APP"); fi
|
||||||
cp "$BUILDENV" "$REL/.env"
|
done
|
||||||
|
[[ ${#TARGETS[@]} -gt 0 ]] || fatal "unknown app '$APP' — known: ${ALL_APPS[*]} | all"
|
||||||
cd "$REL"
|
|
||||||
|
|
||||||
# devDependencies are REQUIRED here: vite, typescript and tailwindcss all live
|
|
||||||
# there and `npm run build` needs them. --omit=dev breaks the build.
|
|
||||||
echo "==> npm ci"
|
|
||||||
npm ci --no-audit --no-fund
|
|
||||||
|
|
||||||
echo "==> npm run build"
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Fail loudly rather than shipping broken SEO tags or an empty bundle.
|
|
||||||
[[ -s dist/client/index.html ]] || { echo "FATAL: dist/client/index.html missing or empty"; exit 1; }
|
|
||||||
if grep -q '%VITE_SITE_URL%' dist/client/index.html; then
|
|
||||||
echo "FATAL: VITE_SITE_URL was not substituted — check $BUILDENV"; exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Runtime needs only express/zod/dotenv/tsx: ~130 MB -> ~40 MB per release.
|
# ------------------------------------------------------- archive, if any
|
||||||
echo "==> pruning devDependencies"
|
|
||||||
npm prune --omit=dev
|
|
||||||
|
|
||||||
# Precompress for nginx gzip_static. .webp/.woff2 omitted on purpose.
|
STAGE=""
|
||||||
find dist/client -type f \
|
cleanup() { if [[ -n $STAGE ]]; then rm -rf "$STAGE"; fi; }
|
||||||
\( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' -o -name '*.xml' -o -name '*.txt' \) \
|
trap cleanup EXIT
|
||||||
-exec gzip -9 -k -f {} +
|
|
||||||
|
|
||||||
echo "==> swapping $BASE/current -> $REL"
|
# Shallowest <app>/ directory in the archive that holds a package.json, so both
|
||||||
PREV="$(readlink -f "$BASE/current" 2>/dev/null || true)"
|
# `fitnes/...` and `bundle/fitnes/...` (what Finder produces) work.
|
||||||
ln -sfn "$REL" "$BASE/current.tmp"
|
app_src() {
|
||||||
mv -Tf "$BASE/current.tmp" "$BASE/current" # single rename(2): atomic
|
local app="$1" d
|
||||||
|
while IFS= read -r d; do
|
||||||
sudo systemctl restart "exo@$APP"
|
if [[ -f $d/package.json ]]; then
|
||||||
|
printf '%s\n' "$d"
|
||||||
echo "==> waiting for health on 127.0.0.1:$PORT"
|
return 0
|
||||||
for _ in $(seq 1 20); do
|
fi
|
||||||
if curl -fsS --max-time 2 "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then break; fi
|
done < <(find "$STAGE" -maxdepth 4 -type d -name "$app" \
|
||||||
sleep 1
|
| awk '{ print gsub(/\//,"/"), $0 }' | sort -n | cut -d' ' -f2-)
|
||||||
done
|
return 1
|
||||||
HEALTH="$(curl -fsS --max-time 5 "http://127.0.0.1:$PORT/api/health")" || {
|
|
||||||
echo "FATAL: health check failed. Rolling back."
|
|
||||||
[[ -n $PREV ]] && { ln -sfn "$PREV" "$BASE/current.tmp"; mv -Tf "$BASE/current.tmp" "$BASE/current"; sudo systemctl restart "exo@$APP"; }
|
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
echo " $HEALTH"
|
|
||||||
grep -q '"ok":true' <<<"$HEALTH" || { echo "FATAL: health not ok"; exit 1; }
|
|
||||||
grep -q '"amo":true' <<<"$HEALTH" || echo " WARNING: amo=false — /api/leads/* will return 503. Check AMO_* in $ENVFILE."
|
|
||||||
|
|
||||||
# Prune old releases, never the live one.
|
if [[ -n $ZIP ]]; then
|
||||||
CURRENT="$(readlink -f "$BASE/current")"
|
[[ -r $ZIP ]] || fatal "cannot read $ZIP"
|
||||||
ls -1dt "$BASE"/releases/*/ 2>/dev/null | tail -n "+$((KEEP+1))" | while read -r old; do
|
command -v unzip >/dev/null 2>&1 || fatal "unzip is not installed (apt install -y unzip)"
|
||||||
[[ "$(readlink -f "$old")" == "$CURRENT" ]] && continue
|
|
||||||
rm -rf "$old"
|
STAGE="$(mktemp -d)"
|
||||||
|
echo "==> unpacking $ZIP"
|
||||||
|
# node_modules/dist are rebuilt here; __MACOSX/.DS_Store are Finder litter.
|
||||||
|
# unzip exits 1 and chatters on stderr for every exclude pattern the archive
|
||||||
|
# happens not to contain, which is normal here — filter it, keep the rest.
|
||||||
|
UNZIP_ERR="$(unzip -q -o "$ZIP" -d "$STAGE" \
|
||||||
|
-x 'node_modules/*' '*/node_modules/*' 'dist/*' '*/dist/*' \
|
||||||
|
'__MACOSX/*' '*/__MACOSX/*' '.DS_Store' '*/.DS_Store' 2>&1)" || {
|
||||||
|
rc=$?
|
||||||
|
(( rc <= 1 )) || { echo "$UNZIP_ERR" >&2; fatal "unzip failed (exit $rc)"; }
|
||||||
|
}
|
||||||
|
echo "$UNZIP_ERR" | grep -v 'excluded filename not matched' | grep . >&2 || true
|
||||||
|
|
||||||
|
# Validate every target up front, and report all of them at once: better to
|
||||||
|
# hear about a missing lock file now than after two landings are already live.
|
||||||
|
PROBLEMS=()
|
||||||
|
for app in "${TARGETS[@]}"; do
|
||||||
|
if ! src="$(app_src "$app")"; then
|
||||||
|
PROBLEMS+=("no $app/ directory with a package.json inside")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ ! -f $src/package-lock.json ]]; then
|
||||||
|
PROBLEMS+=("$app/package-lock.json is missing — npm ci needs it")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo " found $app -> ${src#$STAGE/}"
|
||||||
|
done
|
||||||
|
if [[ ${#PROBLEMS[@]} -gt 0 ]]; then
|
||||||
|
for p in "${PROBLEMS[@]}"; do echo "FATAL: $(basename "$ZIP"): $p" >&2; done
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ZIP_SHA="$(sha256 "$ZIP")"
|
||||||
|
SOURCE_DESC="zip $(basename "$ZIP") sha256=$ZIP_SHA"
|
||||||
|
else
|
||||||
|
SOURCE_DESC="" # filled per app, it carries the resolved commit
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- deploy one
|
||||||
|
|
||||||
|
deploy_app() {
|
||||||
|
local APP="$1"
|
||||||
|
local BASE="$EXO_ROOT/$APP"
|
||||||
|
local ENVFILE="$EXO_ETC/$APP.env"
|
||||||
|
local BUILDENV="$EXO_ETC/$APP.build.env"
|
||||||
|
local REL="$BASE/releases/$(date -u +%Y%m%d-%H%M%S)"
|
||||||
|
local PORT PREV HEALTH CURRENT src desc
|
||||||
|
|
||||||
|
for f in "$ENVFILE" "$BUILDENV"; do
|
||||||
|
[[ -r $f ]] || fatal "cannot read $f"
|
||||||
|
done
|
||||||
|
|
||||||
|
PORT="$(sed -n 's/^PORT=//p' "$ENVFILE")"
|
||||||
|
[[ -n $PORT ]] || fatal "PORT not set in $ENVFILE"
|
||||||
|
|
||||||
|
mkdir -p "$REL"
|
||||||
|
|
||||||
|
if [[ -n $ZIP ]]; then
|
||||||
|
src="$(app_src "$APP")" || fatal "$ZIP: no $APP/ directory with a package.json inside"
|
||||||
|
echo "==> copying $APP/ out of $(basename "$ZIP") into $REL"
|
||||||
|
cp -a "$src/." "$REL/"
|
||||||
|
desc="$SOURCE_DESC"
|
||||||
|
# Whatever local config the packer had in there must not survive: the build
|
||||||
|
# env below is the only .env a release is allowed to carry.
|
||||||
|
rm -f "$REL/.env" "$REL/.env.local"
|
||||||
|
else
|
||||||
|
[[ -d $BASE/repo ]] || fatal "no bare repo at $BASE/repo"
|
||||||
|
echo "==> fetching $REF"
|
||||||
|
git -C "$BASE/repo" fetch --prune origin '+refs/heads/*:refs/heads/*'
|
||||||
|
git -C "$BASE/repo" rev-parse --verify "$REF^{commit}" >/dev/null
|
||||||
|
|
||||||
|
echo "==> extracting $APP/ into $REL"
|
||||||
|
git -C "$BASE/repo" archive "$REF" "$APP" | tar -x -C "$REL" --strip-components=1
|
||||||
|
[[ -f $REL/package.json ]] || { rm -rf "$REL"; fatal "$APP/ not found at $REF"; }
|
||||||
|
desc="git $REF $(git -C "$BASE/repo" rev-parse --short "$REF")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2.6-3.9 MB of base64-embedded reference HTML that is never served.
|
||||||
|
rm -rf "$REL/legacy"
|
||||||
|
|
||||||
|
# So a live release can say where it came from long after the deploy scrolled
|
||||||
|
# off the screen.
|
||||||
|
printf '%s\n%s\n' "$desc" "deployed $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$REL/.deploy-source"
|
||||||
|
|
||||||
|
# Public build-time vars only. The amoCRM token stays in $ENVFILE, which
|
||||||
|
# systemd injects at runtime; dotenv does not override real env vars, so the
|
||||||
|
# two never collide.
|
||||||
|
cp "$BUILDENV" "$REL/.env"
|
||||||
|
|
||||||
|
cd "$REL"
|
||||||
|
|
||||||
|
# devDependencies are REQUIRED here: vite, typescript and tailwindcss all live
|
||||||
|
# there and `npm run build` needs them. --omit=dev breaks the build.
|
||||||
|
echo "==> npm ci"
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
echo "==> npm run build"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Fail loudly rather than shipping broken SEO tags or an empty bundle.
|
||||||
|
[[ -s dist/client/index.html ]] || fatal "dist/client/index.html missing or empty"
|
||||||
|
if grep -q '%VITE_SITE_URL%' dist/client/index.html; then
|
||||||
|
fatal "VITE_SITE_URL was not substituted — check $BUILDENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Runtime needs only express/zod/dotenv/tsx: ~130 MB -> ~40 MB per release.
|
||||||
|
echo "==> pruning devDependencies"
|
||||||
|
npm prune --omit=dev
|
||||||
|
|
||||||
|
# Precompress for nginx gzip_static. .webp/.woff2 omitted on purpose.
|
||||||
|
find dist/client -type f \
|
||||||
|
\( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' -o -name '*.xml' -o -name '*.txt' \) \
|
||||||
|
-exec gzip -9 -k -f {} +
|
||||||
|
|
||||||
|
echo "==> swapping $BASE/current -> $REL"
|
||||||
|
PREV="$(readlink -f "$BASE/current" 2>/dev/null || true)"
|
||||||
|
ln -sfn "$REL" "$BASE/current.tmp"
|
||||||
|
mv -Tf "$BASE/current.tmp" "$BASE/current" # single rename(2): atomic
|
||||||
|
|
||||||
|
sudo systemctl restart "exo@$APP"
|
||||||
|
|
||||||
|
echo "==> waiting for health on 127.0.0.1:$PORT"
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
if curl -fsS --max-time 2 "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
HEALTH="$(curl -fsS --max-time 5 "http://127.0.0.1:$PORT/api/health")" || {
|
||||||
|
echo "FATAL: health check failed. Rolling back." >&2
|
||||||
|
if [[ -n $PREV ]]; then
|
||||||
|
ln -sfn "$PREV" "$BASE/current.tmp"
|
||||||
|
mv -Tf "$BASE/current.tmp" "$BASE/current"
|
||||||
|
sudo systemctl restart "exo@$APP"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo " $HEALTH"
|
||||||
|
grep -q '"ok":true' <<<"$HEALTH" || fatal "health not ok"
|
||||||
|
grep -q '"amo":true' <<<"$HEALTH" || echo " WARNING: amo=false — /api/leads/* will return 503. Check AMO_* in $ENVFILE."
|
||||||
|
|
||||||
|
# Prune old releases, never the live one.
|
||||||
|
CURRENT="$(readlink -f "$BASE/current")"
|
||||||
|
ls -1dt "$BASE"/releases/*/ 2>/dev/null | tail -n "+$((KEEP+1))" | while read -r old; do
|
||||||
|
[[ "$(readlink -f "$old")" == "$CURRENT" ]] && continue
|
||||||
|
rm -rf "$old"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> deployed $APP [$desc] -> $REL"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ run
|
||||||
|
|
||||||
|
n=0
|
||||||
|
for app in "${TARGETS[@]}"; do
|
||||||
|
n=$((n + 1))
|
||||||
|
if [[ ${#TARGETS[@]} -gt 1 ]]; then echo "### [$n/${#TARGETS[@]}] $app"; fi
|
||||||
|
deploy_app "$app"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "==> deployed $APP @ $REF ($(git -C "$BASE/repo" rev-parse --short "$REF")) -> $REL"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user