From 9d71d06a1f3ee294634bf45373bbf1944bf03caf Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Sat, 19 Sep 2026 19:42:42 +0800 Subject: chore: add self-hosted deploy pipeline (blog-deploy) --- deploy/docker-compose.yml | 11 +++ deploy/git-hook/post-receive | 21 +++++ deploy/nginx-blog.conf | 14 ++++ deploy/scripts/build.sh | 153 +++++++++++++++++++++++++++++++++++++ deploy/systemd/blog-deploy.path | 9 +++ deploy/systemd/blog-deploy.service | 6 ++ 6 files changed, 214 insertions(+) create mode 100644 deploy/docker-compose.yml create mode 100755 deploy/git-hook/post-receive create mode 100644 deploy/nginx-blog.conf create mode 100755 deploy/scripts/build.sh create mode 100644 deploy/systemd/blog-deploy.path create mode 100644 deploy/systemd/blog-deploy.service (limited to 'deploy') diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 00000000..916c8d7a --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,11 @@ +services: + blog-nginx: + image: nginx:stable-alpine + container_name: blog-nginx + entrypoint: ["nginx", "-g", "daemon off;"] + ports: + - "127.0.0.1:8091:80" + volumes: + - /home/somhairle/blog-deploy/releases:/var/www/blog:ro + - /home/somhairle/blog-deploy/nginx-blog.conf:/etc/nginx/conf.d/default.conf:ro + restart: unless-stopped diff --git a/deploy/git-hook/post-receive b/deploy/git-hook/post-receive new file mode 100755 index 00000000..65cec98a --- /dev/null +++ b/deploy/git-hook/post-receive @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Runs INSIDE the gitweb container (uid 1000) on git-http-backend pushes. +# Only writes a signal into the bare repo; the host systemd path unit +# (/home/somhairle/git/blog.git/deploy.signal) picks it up and builds. +set -u +ZERO=0000000000000000000000000000000000000000 +GITDIR=$(git rev-parse --git-dir 2>/dev/null) || exit 0 +while read -r oldrev newrev refname; do + case "$refname" in + refs/heads/main) + if [ "$newrev" != "$ZERO" ]; then + if printf '%s %s\n' "$newrev" "$(date -u +%FT%TZ)" > "$GITDIR/deploy.signal" 2>/dev/null; then + echo "blog-deploy: build signal written for $newrev" >&2 + else + echo "blog-deploy: WARNING could not write deploy.signal" >&2 + fi + fi + ;; + esac +done +exit 0 diff --git a/deploy/nginx-blog.conf b/deploy/nginx-blog.conf new file mode 100644 index 00000000..9c8792fd --- /dev/null +++ b/deploy/nginx-blog.conf @@ -0,0 +1,14 @@ +server { + listen 80 default_server; + server_name _; + server_tokens off; + + root /var/www/blog/current; + index index.html; + charset utf-8; + client_max_body_size 0; + + location / { + try_files $uri $uri/ =404; + } +} diff --git a/deploy/scripts/build.sh b/deploy/scripts/build.sh new file mode 100755 index 00000000..b76d6ec0 --- /dev/null +++ b/deploy/scripts/build.sh @@ -0,0 +1,153 @@ +#!/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" diff --git a/deploy/systemd/blog-deploy.path b/deploy/systemd/blog-deploy.path new file mode 100644 index 00000000..0d0aa632 --- /dev/null +++ b/deploy/systemd/blog-deploy.path @@ -0,0 +1,9 @@ +[Unit] +Description=Watch for deploy signals written by blog.git post-receive hook + +[Path] +PathChanged=/home/somhairle/git/blog.git/deploy.signal +Unit=blog-deploy.service + +[Install] +WantedBy=default.target diff --git a/deploy/systemd/blog-deploy.service b/deploy/systemd/blog-deploy.service new file mode 100644 index 00000000..1d67cb80 --- /dev/null +++ b/deploy/systemd/blog-deploy.service @@ -0,0 +1,6 @@ +[Unit] +Description=Blog deploy: build Hexo from blog.git main and publish atomically + +[Service] +Type=oneshot +ExecStart=/home/somhairle/blog-deploy/scripts/build.sh -- cgit v1.2.3