#!/usr/bin/env bash # Blog deploy: build Hexo from blog.git main, verify strictly, publish atomically. # Triggered by systemd user path unit watching /home/somhairle/git/blog.git/deploy.signal # (signal written by the post-receive hook inside the gitweb container at $GIT_DIR/deploy.signal). # Failure policy: any failed step aborts before the current symlink is touched; # the previous release always stays live. set -uo pipefail export PATH="/home/somhairle/.hermes/node/bin:$PATH" DEPLOY=/home/somhairle/blog-deploy GITDIR=/home/somhairle/git/blog.git SIGNAL="$GITDIR/deploy.signal" SRC="$DEPLOY/work/src" RELEASES="$DEPLOY/releases" LOGDIR="$DEPLOY/logs" LOCK="$DEPLOY/work/build.lock" STATE="$DEPLOY/work/last-built.signal" PREV="$DEPLOY/work/current.prev" KEEP=5 MAX_LOOP=5 mkdir -p "$LOGDIR" "$RELEASES" "$DEPLOY/work" exec 9>"$LOCK" if ! flock -n 9; then echo "$(date -u '+%FT%TZ') build already running; extra trigger coalesced" >> "$LOGDIR/build.log" exit 0 fi read_signal() { if [ -f "$SIGNAL" ]; then cat "$SIGNAL"; else echo none; fi; } RC_LAST=0 n=0 while :; do n=$((n + 1)) SIG_START=$(read_signal) TS=$(date -u +%Y%m%dT%H%M%SZ) LOG="$LOGDIR/build-$TS.log" RC_LAST=0 ( echo "==== build start $TS (signal: $SIG_START) ====" if [ -d "$SRC/.git" ]; then git -C "$SRC" fetch --quiet "$GITDIR" main || { echo "FAILED: git fetch"; exit 1; } git -C "$SRC" reset --hard --quiet FETCH_HEAD || { echo "FAILED: git reset"; exit 1; } git -C "$SRC" clean -fdxq -e node_modules || { echo "FAILED: git clean"; exit 1; } else git clone --quiet "$GITDIR" "$SRC" || { echo "FAILED: git clone"; exit 1; } git -C "$SRC" checkout --quiet main || { echo "FAILED: git checkout"; exit 1; } fi SHA=$(git -C "$SRC" rev-parse HEAD) || { echo "FAILED: rev-parse"; exit 1; } echo "building commit $SHA" cd "$SRC" || { echo "FAILED: cd src"; exit 1; } npm install --no-save --no-audit --no-fund || { echo "FAILED: npm install"; exit 1; } npx hexo clean || { echo "FAILED: hexo clean"; exit 1; } npx hexo generate || { echo "FAILED: hexo generate"; exit 1; } ) >> "$LOG" 2>&1 RC_LAST=$? # gate 1: hexo can exit 0 while logging errors -> scan the log if [ "$RC_LAST" -eq 0 ]; then if grep -nE 'ERROR|FATAL|CoercionError' "$LOG" >/dev/null 2>&1; then echo "==== build $TS REJECTED: errors found in build log ====" >> "$LOG" grep -nE 'ERROR|FATAL|CoercionError' "$LOG" | head -20 >> "$LOG" RC_LAST=1 fi fi # gate 2: required artifacts (index, css, latest diary page, moon photo) if [ "$RC_LAST" -eq 0 ]; then if [ ! -s "$SRC/public/index.html" ] \ || ! find "$SRC/public" -name '*.css' -print -quit 2>/dev/null | grep -q . \ || ! find "$SRC/public/2026/09/19" -name index.html -print -quit 2>/dev/null | grep -q . \ || [ ! -s "$SRC/public/img/日记/二〇二六年九月十九日.jpeg" ]; then echo "==== build $TS REJECTED: required artifacts missing ====" >> "$LOG" RC_LAST=1 fi fi # publish: every step checked; only switch current after all copies succeed if [ "$RC_LAST" -eq 0 ]; then SHORT=$(git -C "$SRC" rev-parse --short HEAD 2>/dev/null) || SHORT="" if [ -z "$SHORT" ]; then echo "==== build $TS REJECTED: cannot determine short sha ====" >> "$LOG" RC_LAST=1 else DEST_NAME="v-$TS-$SHORT" OK=1 readlink "$RELEASES/current" > "$PREV" 2>/dev/null || true mkdir -p "$RELEASES/$DEST_NAME" || { echo "FAILED: mkdir $DEST_NAME" >> "$LOG"; OK=0; } if [ "$OK" -eq 1 ]; then rsync -a "$SRC/public/" "$RELEASES/$DEST_NAME/" || { echo "FAILED: rsync" >> "$LOG"; OK=0; } fi if [ "$OK" -eq 1 ]; then chmod -R a+rX "$RELEASES/$DEST_NAME" || { echo "FAILED: chmod" >> "$LOG"; OK=0; } fi if [ "$OK" -eq 1 ]; then ln -sfn "$DEST_NAME" "$RELEASES/.current.tmp" || { echo "FAILED: ln" >> "$LOG"; OK=0; } fi if [ "$OK" -eq 1 ]; then mv -Tf "$RELEASES/.current.tmp" "$RELEASES/current" || { echo "FAILED: mv" >> "$LOG"; OK=0; } fi if [ "$OK" -eq 1 ]; then # gate 3: sanity through the (relative) symlink; rollback within same lock if broken if [ -s "$RELEASES/current/index.html" ] && [ -s "$RELEASES/current/img/日记/二〇二六年九月十九日.jpeg" ]; then echo "published $DEST_NAME (current -> $DEST_NAME)" printf '%s\n' "$SIG_START" > "$STATE" || true else echo "==== build $TS REJECTED after switch, rolling back ====" >> "$LOG" prev=$(cat "$PREV" 2>/dev/null || true) if [ -n "$prev" ]; then ln -sfn "$prev" "$RELEASES/.current.tmp" \ && mv -Tf "$RELEASES/.current.tmp" "$RELEASES/current" \ && echo "rolled back to $prev" >> "$LOG" \ || echo "ROLLBACK FAILED: current -> $DEST_NAME stays" >> "$LOG" fi rm -rf -- "$RELEASES/$DEST_NAME" RC_LAST=1 fi fi if [ "$OK" -eq 0 ]; then rm -rf -- "$RELEASES/$DEST_NAME" echo "==== build $TS FAILED at publish, previous release kept ====" >> "$LOG" RC_LAST=1 fi fi fi if [ "$RC_LAST" -ne 0 ]; then echo "==== build $TS FAILED, previous release kept ====" >> "$LOG" fi # consume signals queued while we were building; never drop a push SIG_END=$(read_signal) if [ "$SIG_END" != "$SIG_START" ]; then if [ "$n" -ge "$MAX_LOOP" ]; then echo "==== queued signals exceed MAX_LOOP=$MAX_LOOP, deferring to next trigger ====" >> "$LOG" break fi echo "==== new signal queued during build, rebuilding ====" >> "$LOG" continue fi break done ls -1t "$LOGDIR"/build-*.log 2>/dev/null | tail -n +21 | xargs -r rm -f ls -1dt "$RELEASES"/v-* 2>/dev/null | tail -n +"$((KEEP + 1))" | while IFS= read -r old; do live=$(readlink -f "$RELEASES/current" 2>/dev/null || true) if [ "$live" != "$(readlink -f "$old")" ]; then rm -rf -- "$old" echo "pruned $old" >> "$LOGDIR/build.log" fi done exit "$RC_LAST"