#!/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: # /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