clawdie-ai/scripts/docs-sync.cron.sh
Operator & Claude Code 898d2d495e Finish sudo elimination: scripts + startup-report
Route remaining sudo call sites through hostd-call.sh / hostd:
- scripts/destroy-jails.sh: bastille stop/destroy via hostd-call.sh
- scripts/docs-sync.cron.sh: nginx reload via service-restart op
- scripts/heartbeat.sh: bastille list via hostd-call.sh
- src/startup-report.ts: drop sudo bastille/pkg fallbacks; tighten
  buildStartupReport signature now that hostdData is always supplied

Relies on 537c613 (non-interactive bastille-destroy) so the
yes-pipe in destroy-jails.sh is no longer needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:19:09 +02:00

346 lines
15 KiB
Bash
Executable file

#!/bin/sh
# docs-sync.cron.sh: Documentation sync orchestrator
#
# Automated cron job (runs daily @ 05:00 UTC):
# 1. Pull latest markdown from git
# 2. Compile markdown → HTML (with .docignore filtering)
# 3. Validate output
# 4. Deploy via atomic symlink swap (zero-downtime)
# 5. Cleanup old versions (30-day retention)
#
# Logs:
# /var/log/clawdie-docs-sync.log — detailed output
# .sync-metadata.json — deployment metadata
#
# Lock file prevents concurrent syncs:
# <repo>/tmp/clawdie-docs-sync.lock
set -e
# ────────────────────────────────────────────────────────────────────────────
# Configuration
# ────────────────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
DOCS_DIR="${REPO_DIR}/docs/public"
DOCS_POLICY="${REPO_DIR}/docs/internal/DOCUMENTATION-POLICY.md"
SYNC_METADATA="${DOCS_DIR}/.sync-metadata.json"
COMPILE_SCRIPT="${SCRIPT_DIR}/docs-compile.sh"
LOG_FILE="${LOG_FILE:-/var/log/clawdie-docs-sync.log}"
LOCK_FILE="${LOCK_FILE:-${REPO_DIR}/tmp/clawdie-docs-sync.lock}"
RETENTION_DAYS="${RETENTION_DAYS:-30}"
LANGUAGES="${LANGUAGES:-sl,en,de,hr,sr,ru}" # Slovenian primary; English source; German, Croatian, Serbian, Russian
DATE_FORMAT_SCRIPT="${REPO_DIR}/scripts/date-format.sh"
# Colors (for manual runs; cron will strip these)
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Deployment targets (from .sync-metadata.json)
TARGETS="docs-clawdie-si osa-smilepowered.org"
# ────────────────────────────────────────────────────────────────────────────
# Logging & Locking
# ────────────────────────────────────────────────────────────────────────────
log_msg() {
local msg="$1"
echo "$(bash "$DATE_FORMAT_SCRIPT" display-ts) [docs-sync] $msg" | tee -a "$LOG_FILE"
}
log_err() {
local msg="$1"
echo "$(bash "$DATE_FORMAT_SCRIPT" display-ts) ${RED}[ERROR]${NC} $msg" | tee -a "$LOG_FILE" >&2
}
log_ok() {
local msg="$1"
echo "$(bash "$DATE_FORMAT_SCRIPT" display-ts) ${GREEN}[OK]${NC} $msg" | tee -a "$LOG_FILE"
}
acquire_lock() {
# Prevent concurrent syncs
if [ -f "$LOCK_FILE" ]; then
local lock_age=$(($(date +%s) - $(stat -f%m "$LOCK_FILE" 2>/dev/null || echo 0)))
if [ "$lock_age" -lt 3600 ]; then
log_err "Sync already in progress (lock age: ${lock_age}s)"
exit 1
else
log_msg "Stale lock removed (age: ${lock_age}s)"
rm -f "$LOCK_FILE"
fi
fi
mkdir -p "$(dirname "$LOCK_FILE")"
touch "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT
}
# ────────────────────────────────────────────────────────────────────────────
# Initialization
# ────────────────────────────────────────────────────────────────────────────
log_msg "════════════════════════════════════════════════════════════════════════"
log_msg "Documentation Sync Started"
log_msg "Repository: $REPO_DIR"
log_msg "Docs source: $DOCS_DIR"
acquire_lock
# Verify dependencies
for cmd in git pandoc jq rsync; do
command -v "$cmd" >/dev/null 2>&1 || { log_err "$cmd not found"; exit 1; }
done
cd "$REPO_DIR" || exit 1
# ────────────────────────────────────────────────────────────────────────────
# Step 1: Pull latest markdown from git
# ────────────────────────────────────────────────────────────────────────────
log_msg "Step 1: Fetching latest changes from git..."
if git fetch origin 2>&1 | grep -q "fatal\|error"; then
log_err "git fetch failed"
exit 1
fi
# Check if docs/public has changes
CHANGES=$(git diff HEAD origin/implementation --name-only -- docs/public 2>/dev/null | wc -l || echo 0)
LAST_COMMIT_LOCAL=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
LAST_COMMIT_REMOTE=$(git rev-parse origin/implementation 2>/dev/null || echo "unknown")
if [ "$CHANGES" -gt 0 ]; then
log_msg "Found $CHANGES changes in docs/public — pulling..."
git pull origin implementation --ff-only 2>&1 | grep -E "^(Already|Fast-forward|Merge)" || true
else
log_msg "No changes in docs/public — skipping pull"
fi
CURRENT_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
log_ok "Git sync complete (commit: ${CURRENT_COMMIT:0:7})"
# ────────────────────────────────────────────────────────────────────────────
# Step 2: Compile markdown → HTML for each target
# ────────────────────────────────────────────────────────────────────────────
log_msg "Step 2: Compiling markdown to HTML..."
# Read targets from .sync-metadata.json
# For now, hardcode targets; eventually read from metadata
COMPILE_DIRS=""
for target in $TARGETS; do
case "$target" in
docs-clawdie-si)
COMPILE_DIRS="$COMPILE_DIRS /usr/local/www/docs.clawdie.si"
;;
osa-smilepowered.org)
COMPILE_DIRS="$COMPILE_DIRS /usr/local/www/osa.smilepowered.org"
;;
esac
done
SEMVER=$(git describe --tags --match 'v*' 2>/dev/null | sed 's/^v//' | cut -d'-' -f1 || echo "0.0.0")
BUILD_DATE=$(bash "$DATE_FORMAT_SCRIPT" display-date | tr 'A-Z' 'a-z')
for out_dir in $COMPILE_DIRS; do
if [ ! -d "$out_dir" ]; then
log_err "Output directory not found: $out_dir"
# Non-fatal: skip this target, continue with others
continue
fi
log_msg " Compiling to: $out_dir"
# Compile each language
for lang in $(echo "$LANGUAGES" | tr ',' ' '); do
log_msg " Language: $lang"
if [ "$lang" = "en" ]; then
if ! "$COMPILE_SCRIPT" \
--semver "$SEMVER" \
--date "$BUILD_DATE" \
--filter "$DOCS_DIR/.docignore" \
--source "$DOCS_DIR" \
"$out_dir" 2>&1 | tee -a "$LOG_FILE"; then
log_err "Compilation failed for $out_dir (language: $lang)"
exit 1
fi
continue
fi
if [ ! -d "$DOCS_DIR/$lang" ]; then
log_msg " Language directory missing: $DOCS_DIR/$lang (skipping)"
continue
fi
if ! "$COMPILE_SCRIPT" \
--semver "$SEMVER" \
--date "$BUILD_DATE" \
--filter "$DOCS_DIR/.docignore" \
--source "$DOCS_DIR" \
--language "$lang" \
"$out_dir" 2>&1 | tee -a "$LOG_FILE"; then
log_err "Compilation failed for $out_dir (language: $lang)"
exit 1
fi
done
done
log_ok "Compilation complete (v${SEMVER}, ${BUILD_DATE})"
# ────────────────────────────────────────────────────────────────────────────
# Step 3: Validate compiled output
# ────────────────────────────────────────────────────────────────────────────
log_msg "Step 3: Validating output..."
VALIDATION_OK=1
for out_dir in $COMPILE_DIRS; do
[ ! -d "$out_dir" ] && continue
VERSION_DIR="${out_dir}/docs-v${SEMVER}_${BUILD_DATE}"
# Check essential files exist
[ ! -f "${VERSION_DIR}/index.html" ] && {
log_err "Missing index.html in $VERSION_DIR"
VALIDATION_OK=0
}
# Count HTML files
HTML_COUNT=$(find "$VERSION_DIR" -name "*.html" | wc -l)
[ "$HTML_COUNT" -lt 3 ] && {
log_err "Too few HTML files ($HTML_COUNT) in $VERSION_DIR"
VALIDATION_OK=0
}
log_msg " $out_dir: $HTML_COUNT HTML files ✓"
done
if [ "$VALIDATION_OK" -ne 1 ]; then
log_err "Validation failed — aborting deployment"
exit 1
fi
log_ok "Validation passed"
# ────────────────────────────────────────────────────────────────────────────
# Step 4: Deploy via atomic symlink swap
# ────────────────────────────────────────────────────────────────────────────
log_msg "Step 4: Deploying with atomic symlink swap..."
NGINX_RELOAD_NEEDED=0
for out_dir in $COMPILE_DIRS; do
[ ! -d "$out_dir" ] && continue
SYMLINK_NAME="docs-current"
case "$(basename "$out_dir")" in
*osa*) SYMLINK_NAME="en-current" ;;
*) SYMLINK_NAME="docs-current" ;;
esac
VERSION_DIR="docs-v${SEMVER}_${BUILD_DATE}"
SYMLINK_PATH="${out_dir}/${SYMLINK_NAME}"
# Atomic swap (ln -sfn is atomic on modern filesystems)
if ln -sfn "$VERSION_DIR" "$SYMLINK_PATH" 2>&1; then
log_ok " Symlink swap: $SYMLINK_NAME$VERSION_DIR"
NGINX_RELOAD_NEEDED=1
else
log_err "Failed to update symlink: $SYMLINK_PATH"
exit 1
fi
# Verify symlink points to correct directory
CURRENT=$(readlink "$SYMLINK_PATH" 2>/dev/null || echo "")
if [ "$CURRENT" = "$VERSION_DIR" ]; then
log_ok " Verified: $SYMLINK_PATH$CURRENT"
else
log_err "Symlink verification failed: expected $VERSION_DIR, got $CURRENT"
exit 1
fi
done
# Reload nginx to serve new content
if [ "$NGINX_RELOAD_NEEDED" -eq 1 ]; then
log_msg "Reloading nginx..."
if "${SCRIPT_DIR:-$(dirname "$0")}/hostd-call.sh" service-restart '{"name":"nginx","jail":"cms"}' 2>&1 | tee -a "$LOG_FILE"; then
log_ok "Nginx reloaded (serving v${SEMVER})"
else
log_err "Nginx reload failed"
exit 1
fi
fi
log_ok "Deployment complete (zero-downtime swap)"
# ────────────────────────────────────────────────────────────────────────────
# Step 5: Cleanup old versions (30-day retention)
# ────────────────────────────────────────────────────────────────────────────
log_msg "Step 5: Cleaning up old versions (retention: ${RETENTION_DAYS} days)..."
CLEANUP_COUNT=0
for out_dir in $COMPILE_DIRS; do
[ ! -d "$out_dir" ] && continue
# Find version directories older than RETENTION_DAYS
find "$out_dir" -maxdepth 1 -name "docs-v*" -o -name "*-v*" -type d 2>/dev/null | while read old_dir; do
# Skip current version
SYMLINK_TARGET=$(readlink "${out_dir}/docs-current" 2>/dev/null || echo "")
[ "$(basename "$old_dir")" = "$SYMLINK_TARGET" ] && continue
# Check age
AGE_DAYS=$(($(date +%s) - $(stat -f%m "$old_dir" 2>/dev/null)))
AGE_DAYS=$((AGE_DAYS / 86400))
if [ "$AGE_DAYS" -gt "$RETENTION_DAYS" ]; then
log_msg " Removing $(basename "$old_dir") (${AGE_DAYS} days old)"
rm -rf "$old_dir"
CLEANUP_COUNT=$((CLEANUP_COUNT + 1))
fi
done
done
log_ok "Cleanup complete ($CLEANUP_COUNT old versions removed)"
# ────────────────────────────────────────────────────────────────────────────
# Update metadata
# ────────────────────────────────────────────────────────────────────────────
log_msg "Updating metadata..."
NEXT_SYNC=$(date -d "+1 day" -u +"%Y-%m-%dT05:00:00Z" 2>/dev/null || date -u -v+1d +"%Y-%m-%dT05:00:00Z")
jq \
--arg last_sync "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg last_commit "$CURRENT_COMMIT" \
--arg next_sync "$NEXT_SYNC" \
'.last_sync = $last_sync | .last_commit = $last_commit | .next_scheduled_sync = $next_sync' \
"$SYNC_METADATA" > "${SYNC_METADATA}.tmp" && mv "${SYNC_METADATA}.tmp" "$SYNC_METADATA" || true
log_ok "Metadata updated"
# ────────────────────────────────────────────────────────────────────────────
# Final Summary
# ────────────────────────────────────────────────────────────────────────────
log_msg "════════════════════════════════════════════════════════════════════════"
log_msg "${GREEN}✓ SYNC COMPLETE${NC}"
log_msg "Version: v${SEMVER}"
log_msg "Built: ${BUILD_DATE}"
log_msg "Commit: ${CURRENT_COMMIT:0:7}"
log_msg "Targets: $TARGETS"
log_msg "Cleanup: $CLEANUP_COUNT old versions removed"
log_msg ""
log_msg "Live URLs:"
log_msg " 📚 docs.clawdie.si"
log_msg " 🌐 osa.smilepowered.org"
log_msg ""
exit 0