foss_breakglass_mirror_v2/scripts/breakglass-sync.sh
Piers Cockram 46c6e5a9c3 breakglass-sync: per-owner soft budget (OWNER_BUDGET_SEC, default 2h)
Once an owner consumes more than OWNER_BUDGET_SEC seconds, the script
stops starting new repos for that owner and moves on to the next.
The in-flight repo finishes naturally — we never kill mid-push.
ONLY_REPO mode bypasses the budget (single-repo runs are intentional).

Surfaced via a new OWNERS_DEFERRED counter in the summary line + an
OWNER_BUDGET_EXCEEDED audit event per owner.

Originally added after signalapp was starved by openclaw repeatedly
consuming the whole window. With 12 owners and a 16h sync timeout,
2h/owner gives ample headroom while preventing any single owner from
monopolising the run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:57:01 +10:00

1173 lines
46 KiB
Bash

#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════
# breakglass-sync.sh — APPEND-ONLY GitHub → Gitea mirror
# ═══════════════════════════════════════════════════════════
#
# DESIGN PRINCIPLE: This script ONLY ADDS data. It never
# deletes refs, never force-pushes, never prunes. If upstream
# is maliciously wiped, the worst that happens is the empty
# state gets added alongside all previous history — nothing
# is lost.
#
# Threat model:
# - Upstream maintainer force-pushes empty history
# - Upstream repo is deleted entirely
# - Upstream tags/branches are removed
# - GitHub account is suspended/banned
# - DMCA takedown removes repo
#
# In ALL these cases, previously-synced data is preserved.
#
# ═══════════════════════════════════════════════════════════
set -euo pipefail
# ── Prevent git from ever prompting for credentials ──────
# Without this, git/git-lfs will hang waiting for input when
# running under nohup/systemd with no terminal attached.
export GIT_TERMINAL_PROMPT=0
# Trust all repo paths regardless of UID — avoids "dubious ownership" errors
# when bare repos were created by a different user (e.g. root vs systemd unit user).
git config --global --add safe.directory '*' 2>/dev/null || true
# ── Load config ──────────────────────────────────────────
ENV_FILE="${BREAKGLASS_ENV:-/etc/breakglass/mirror.env}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "FATAL: config not found at $ENV_FILE" >&2
exit 1
fi
# shellcheck source=/dev/null
source "$ENV_FILE"
# ── Defaults ─────────────────────────────────────────────
MIRROR_ROOT="${MIRROR_ROOT:-/var/lib/breakglass/repos}"
RELEASE_ROOT="${RELEASE_ROOT:-/var/lib/breakglass/releases}"
LOG_DIR="${LOG_DIR:-/var/log/breakglass}"
AUDIT_DIR="${AUDIT_DIR:-/var/lib/breakglass/audit}"
FORCE_HTTP11="${FORCE_HTTP11:-true}"
NOTIFY_METHOD="${NOTIFY_METHOD:-none}"
# If upstream loses more than this % of refs, abort the push
# as a likely malicious wipe. 0 = disabled.
WIPE_THRESHOLD="${WIPE_THRESHOLD:-50}"
# How many releases to keep per repo (latest N)
RELEASE_KEEP="${RELEASE_KEEP:-3}"
# Enable wiki and release syncing
SYNC_WIKIS="${SYNC_WIKIS:-true}"
SYNC_RELEASES="${SYNC_RELEASES:-true}"
# Reverse sync (Gitea → GitHub)
REVERSE_SYNC_REPOS="${REVERSE_SYNC_REPOS:-}"
GITHUB_PUSH_TOKEN="${GITHUB_PUSH_TOKEN:-}"
GITHUB_PUSH_OWNER="${GITHUB_PUSH_OWNER:-}"
# Per-owner soft budget. Once exceeded, the script stops starting new repos
# for that owner and moves on to the next. The in-flight repo (if any)
# finishes naturally — we never kill mid-push. Set to 0 to disable.
# Default 7200s (2h). Originally added 2026-05-28 after signalapp was
# starved by openclaw repeatedly consuming the whole window.
OWNER_BUDGET_SEC="${OWNER_BUDGET_SEC:-7200}"
mkdir -p "$MIRROR_ROOT" "$RELEASE_ROOT" "$LOG_DIR" "$AUDIT_DIR"
TIMESTAMP=$(date -u +%Y%m%dT%H%M%SZ)
LOG_FILE="$LOG_DIR/sync-${TIMESTAMP}.log"
AUDIT_FILE="$AUDIT_DIR/audit-${TIMESTAMP}.log"
ERRORS=0
SYNCED=0
SKIPPED=0
PROTECTED=0
OWNERS_DEFERRED=0
# ── Helpers ──────────────────────────────────────────────
log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*" | tee -a "$LOG_FILE"; }
warn() { log "WARN: $*"; }
die() { log "FATAL: $*"; notify "Breakglass sync FAILED: $*"; exit 1; }
audit() {
# Append-only audit trail — one line per event
printf '%s %s %s\n' "$TIMESTAMP" "$(date -u +%H:%M:%S)" "$*" >> "$AUDIT_FILE"
}
retry() {
local max_attempts=$1; shift
local delay=2
for (( attempt=1; attempt<=max_attempts; attempt++ )); do
if "$@"; then return 0; fi
if (( attempt < max_attempts )); then
log " retry $attempt/$max_attempts — sleeping ${delay}s …"
sleep "$delay"
(( delay = delay * 2 > 30 ? 30 : delay * 2 ))
fi
done
return 1
}
# ── HTTP helpers ─────────────────────────────────────────
# GitHub API calls — may go through Cloudflare, so honour FORCE_HTTP11
gh_curl_opts=(-sfS --connect-timeout 15 --max-time 120)
[[ "$FORCE_HTTP11" == "true" ]] && gh_curl_opts+=(--http1.1)
# Gitea API calls — typically localhost, no need for --http1.1
# Use -sS (not -f) so we can inspect HTTP responses ourselves
gitea_curl_opts=(-sS --connect-timeout 15 --max-time 300)
gh_api() {
local path="$1"; shift
local -a headers=(-H "Accept: application/vnd.github+json")
[[ -n "${GITHUB_TOKEN:-}" ]] && headers+=(-H "Authorization: Bearer $GITHUB_TOKEN")
curl "${gh_curl_opts[@]}" "${headers[@]}" "https://api.github.com${path}" "$@"
}
gitea_api() {
local method="$1" path="$2"; shift 2
local -a args=(-X "$method" -H "Content-Type: application/json"
-H "Authorization: token $GITEA_TOKEN")
local response http_code
response=$(curl "${gitea_curl_opts[@]}" -w "\n%{http_code}" "${args[@]}" "${GITEA_URL}/api/v1${path}" "$@" 2>&1)
http_code=$(echo "$response" | tail -1)
response=$(echo "$response" | sed '$d')
# Return the response body on stdout
echo "$response"
# Return success for 2xx and 409 (conflict = already exists, which is fine)
case "$http_code" in
2[0-9][0-9]) return 0 ;;
409) return 0 ;;
*) return 1 ;;
esac
}
# ── YAML-lite parser ─────────────────────────────────────
parse_sources() {
local gh="" org="" inc="" exc="" in_include="" in_exclude=""
while IFS= read -r line; do
line="${line%%#*}"
[[ -z "${line// /}" ]] && continue
if [[ "$line" =~ ^[[:space:]]*-[[:space:]]*github:[[:space:]]*(.+) ]]; then
[[ -n "$gh" ]] && echo "OWNER $gh ${org:-$gh} ${inc:-*} ${exc:-}"
gh="${BASH_REMATCH[1]// /}"
org="" inc="" exc="" in_include="" in_exclude=""
elif [[ "$line" =~ ^[[:space:]]*gitea_org:[[:space:]]*(.+) ]]; then
org="${BASH_REMATCH[1]// /}"
elif [[ "$line" =~ ^[[:space:]]*-[[:space:]]*\"(.+)\" ]] && [[ -n "$in_include" ]]; then
inc="${inc:+$inc|}${BASH_REMATCH[1]}"
elif [[ "$line" =~ ^[[:space:]]*-[[:space:]]*\"(.+)\" ]] && [[ -n "$in_exclude" ]]; then
exc="${exc:+$exc|}${BASH_REMATCH[1]}"
fi
if [[ "$line" =~ ^[[:space:]]*include: ]]; then in_include=1; in_exclude=""; fi
if [[ "$line" =~ ^[[:space:]]*exclude: ]]; then in_exclude=1; in_include=""; fi
if [[ ! "$line" =~ ^[[:space:]]*- ]] && [[ ! "$line" =~ ^[[:space:]]*(include|exclude): ]]; then
in_include="" in_exclude=""
fi
done < "$SOURCES_FILE"
[[ -n "$gh" ]] && echo "OWNER $gh ${org:-$gh} ${inc:-*} ${exc:-}"
}
# ── GitHub pagination ────────────────────────────────────
gh_list_repos() {
local owner="$1"
local page=1 per_page=100
while true; do
local url="/orgs/${owner}/repos?per_page=${per_page}&page=${page}&type=public"
local body
if ! body=$(gh_api "$url" 2>/dev/null); then
url="/users/${owner}/repos?per_page=${per_page}&page=${page}&type=public"
body=$(gh_api "$url") || { warn "cannot list repos for $owner"; return 1; }
fi
local names
names=$(echo "$body" | grep -o '"full_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/.*"full_name"[[:space:]]*:[[:space:]]*"//;s/"//' \
| awk -F/ '{print $2}')
[[ -z "$names" ]] && break
echo "$names"
(( page++ ))
local count
count=$(echo "$names" | wc -l)
(( count < per_page )) && break
done
}
# ── Gitea org/repo ensure ────────────────────────────────
ensure_gitea_org() {
local org="$1"
local result
result=$(gitea_api GET "/orgs/${org}" 2>/dev/null) || true
if echo "$result" | grep -q '"id"'; then
# Org exists — sync avatar if we haven't already
sync_org_avatar "$org"
return 0
fi
log " creating Gitea org: $org"
result=$(gitea_api POST "/orgs" -d "{\"username\":\"${org}\",\"visibility\":\"public\"}" 2>/dev/null) || true
if echo "$result" | grep -q '"id"'; then
log " org $org ready"
sync_org_avatar "$org"
return 0
fi
warn "could not create org $org — will push under $GITEA_USER"
}
ensure_gitea_repo() {
local org="$1" repo="$2"
local result
result=$(gitea_api GET "/repos/${org}/${repo}" 2>/dev/null) || true
if echo "$result" | grep -q '"id"'; then
# Repo exists — sync avatar if we haven't already
sync_repo_avatar "$org" "$repo"
return 0
fi
log " creating Gitea repo: ${org}/${repo}"
result=$(gitea_api POST "/orgs/${org}/repos" \
-d "{\"name\":\"${repo}\",\"private\":false,\"description\":\"[BREAKGLASS] Append-only mirror of github.com/${org}/${repo}\"}" 2>/dev/null) || true
if echo "$result" | grep -q '"id"'; then
log " repo ${org}/${repo} ready"
sync_repo_avatar "$org" "$repo"
return 0
fi
warn "could not create repo ${org}/${repo}"
return 1
}
# ── Avatar/logo sync ────────────────────────────────────
sync_org_avatar() {
local org="$1"
local avatar_cache="${MIRROR_ROOT}/.avatars"
local marker="${avatar_cache}/${org}.org.synced"
mkdir -p "$avatar_cache"
# Only sync once per org (remove marker file to force re-sync)
[[ -f "$marker" ]] && return 0
log " syncing avatar for org: $org"
# Download from GitHub (works for both orgs and users)
local tmp_avatar="${avatar_cache}/${org}.png"
if curl -sS -L -o "$tmp_avatar" "https://github.com/${org}.png?size=256" 2>/dev/null; then
# Check we actually got an image (not an error page)
local file_size
file_size=$(stat -c %s "$tmp_avatar" 2>/dev/null || echo 0)
if (( file_size > 500 )); then
# Upload to Gitea via API — base64 encode the image
local b64_avatar
b64_avatar=$(base64 -w0 "$tmp_avatar" 2>/dev/null)
if [[ -n "$b64_avatar" ]]; then
local avatar_result
avatar_result=$(gitea_api POST "/orgs/${org}/avatar" \
-d "{\"image\":\"${b64_avatar}\"}" 2>/dev/null) || true
log " avatar set for org $org"
touch "$marker"
fi
else
warn "avatar download too small for $org (${file_size} bytes) — skipping"
fi
else
warn "could not download GitHub avatar for $org"
fi
rm -f "$tmp_avatar"
}
sync_repo_avatar() {
local org="$1" repo="$2"
local avatar_cache="${MIRROR_ROOT}/.avatars"
local marker="${avatar_cache}/${org}_${repo}.repo.synced"
mkdir -p "$avatar_cache"
# Only sync once per repo
[[ -f "$marker" ]] && return 0
log " syncing avatar for repo: ${org}/${repo}"
# Try to get the repo's owner avatar from GitHub API
local gh_repo_data
gh_repo_data=$(gh_api "/repos/${org}/${repo}" 2>/dev/null) || { touch "$marker"; return 0; }
# Extract owner avatar_url (repos inherit org avatar on GitHub)
local avatar_url
avatar_url=$(echo "$gh_repo_data" | grep -o '"avatar_url"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
| sed 's/"avatar_url"[[:space:]]*:[[:space:]]*"//;s/"//')
if [[ -z "$avatar_url" ]]; then
# No custom avatar — just mark as done (inherits org avatar in Gitea too)
touch "$marker"
return 0
fi
local tmp_avatar="${avatar_cache}/${org}_${repo}.png"
if curl -sS -L -o "$tmp_avatar" "${avatar_url}" 2>/dev/null; then
local file_size
file_size=$(stat -c %s "$tmp_avatar" 2>/dev/null || echo 0)
if (( file_size > 500 )); then
local b64_avatar
b64_avatar=$(base64 -w0 "$tmp_avatar" 2>/dev/null)
if [[ -n "$b64_avatar" ]]; then
local avatar_result
avatar_result=$(gitea_api POST "/repos/${org}/${repo}/avatar" \
-d "{\"image\":\"${b64_avatar}\"}" 2>/dev/null) || true
log " avatar set for ${org}/${repo}"
fi
fi
fi
touch "$marker"
rm -f "$tmp_avatar"
}
# ═══════════════════════════════════════════════════════════
# CORE: Per-repo append-only sync
# ═══════════════════════════════════════════════════════════
count_refs() {
# Count refs in a bare repo (heads + tags + notes)
local dir="$1"
git -C "$dir" for-each-ref --format='x' refs/heads refs/tags refs/notes 2>/dev/null | wc -l
}
snapshot_refs() {
# Save every current ref into refs/backup/<timestamp>/
# This is the append-only guarantee: old state is always preserved
local dir="$1" ts="$2"
local count=0
git -C "$dir" for-each-ref --format='%(refname) %(objectname)' \
refs/heads refs/tags refs/notes 2>/dev/null | \
while read -r refname sha; do
local backup_ref="refs/backup/${ts}/${refname#refs/}"
git -C "$dir" update-ref "$backup_ref" "$sha" 2>/dev/null || true
(( count++ )) || true
done
echo "$count"
}
detect_wipe() {
# Compare ref count before and after fetch.
# If upstream lost a large proportion of refs, this is suspicious.
local before="$1" after="$2" repo_name="$3"
if (( before == 0 )); then
# First sync — nothing to compare
return 0
fi
if (( after == 0 )); then
log " !! WIPE DETECTED: upstream has ZERO refs for $repo_name"
audit "WIPE_DETECTED repo=$repo_name before=$before after=0"
return 1
fi
if (( WIPE_THRESHOLD > 0 )); then
local lost=$(( before - after ))
if (( lost > 0 )); then
local pct=$(( lost * 100 / before ))
if (( pct >= WIPE_THRESHOLD )); then
log " !! SUSPICIOUS: upstream lost ${pct}% of refs ($before$after) for $repo_name"
audit "WIPE_SUSPECTED repo=$repo_name before=$before after=$after lost_pct=$pct"
return 1
fi
fi
fi
return 0
}
sync_repo() {
local gh_owner="$1" repo="$2" gitea_org="$3"
local bare_dir="${MIRROR_ROOT}/${gh_owner}/${repo}.git"
local gh_url="https://github.com/${gh_owner}/${repo}.git"
local gitea_url="${GITEA_URL}/${gitea_org}/${repo}.git"
log " syncing ${gh_owner}/${repo}${gitea_org}/${repo}"
audit "SYNC_START repo=${gh_owner}/${repo}"
# ── Ensure local bare clone exists ───────────────────
if [[ ! -d "$bare_dir" ]]; then
log " initial clone …"
if ! retry 3 git clone --bare "$gh_url" "$bare_dir" 2>>"$LOG_FILE"; then
warn "clone failed for ${gh_owner}/${repo}"
audit "CLONE_FAILED repo=${gh_owner}/${repo}"
return 1
fi
# Do NOT use --mirror flag: it enables pruning on fetch.
# We configure fetch refspecs manually below.
audit "CLONED repo=${gh_owner}/${repo}"
fi
cd "$bare_dir" || return 1
# ── Configure remotes (no --mirror, no --prune) ──────
git remote set-url origin "$gh_url" 2>/dev/null || git remote add origin "$gh_url"
# CRITICAL: Remove any prune or mirror config that might exist
git config --unset remote.origin.mirror 2>/dev/null || true
git config --unset remote.origin.prune 2>/dev/null || true
git config remote.origin.prune false
git config remote.origin.tagOpt --no-tags # we fetch tags explicitly
# Suppress LFS locking messages (we're a one-way mirror, no locking needed)
git config lfs.locksverify false
git config lfs.https://${GITEA_URL#*://}.locksverify false 2>/dev/null || true
# Set up gitea remote with embedded auth (handle both http and https)
local authed_url
if [[ "$GITEA_URL" == https://* ]]; then
authed_url="${GITEA_URL/https:\/\//https:\/\/${GITEA_USER}:${GITEA_TOKEN}@}/${gitea_org}/${repo}.git"
else
authed_url="${GITEA_URL/http:\/\//http:\/\/${GITEA_USER}:${GITEA_TOKEN}@}/${gitea_org}/${repo}.git"
fi
if git remote get-url gitea &>/dev/null; then
git remote set-url gitea "$authed_url"
else
git remote add gitea "$authed_url"
fi
git config remote.gitea.mirror false 2>/dev/null || true
git config remote.gitea.prune false
[[ "$FORCE_HTTP11" == "true" ]] && git config http.version HTTP/1.1
# ── Count refs BEFORE fetch ──────────────────────────
local refs_before
refs_before=$(count_refs "$bare_dir")
# ── Snapshot current state (THE SAFETY NET) ──────────
log " snapshotting refs → refs/backup/${TIMESTAMP}/"
snapshot_refs "$bare_dir" "$TIMESTAMP"
audit "SNAPSHOT repo=${gh_owner}/${repo} refs_before=$refs_before"
# ── Fetch from GitHub (ADDITIVE ONLY) ────────────────
# We do NOT use '+' force-update prefix on heads.
# Instead we fetch into a staging namespace first, then
# safely merge forward.
log " fetching from GitHub …"
# Fetch into staging area — does not touch our refs/heads
if ! retry 3 git fetch origin \
'+refs/heads/*:refs/upstream-staging/heads/*' \
'+refs/tags/*:refs/upstream-staging/tags/*' \
'+refs/notes/*:refs/upstream-staging/notes/*' 2>>"$LOG_FILE"; then
warn "fetch failed for ${gh_owner}/${repo} — upstream may be down"
audit "FETCH_FAILED repo=${gh_owner}/${repo}"
# This is OK — upstream might be deleted. Our local copy is safe.
# Still push what we have to Gitea.
push_to_gitea "$bare_dir" "$gitea_org" "$repo"
return 0
fi
# ── Count upstream refs and check for wipe ───────────
local refs_upstream
refs_upstream=$(git for-each-ref --format='x' refs/upstream-staging/heads refs/upstream-staging/tags 2>/dev/null | wc -l)
if ! detect_wipe "$refs_before" "$refs_upstream" "${gh_owner}/${repo}"; then
log " !! PROTECTION ACTIVATED: refusing to update local refs"
log " !! Previous state preserved in refs/backup/${TIMESTAMP}/"
log " !! Upstream staging refs kept for manual inspection"
audit "WIPE_BLOCKED repo=${gh_owner}/${repo} upstream_refs=$refs_upstream"
notify "BREAKGLASS ALERT: Possible wipe detected for ${gh_owner}/${repo} — upstream went from $refs_before to $refs_upstream refs. Sync blocked, previous state preserved." "urgent" "rotating_light,shield"
(( PROTECTED++ )) || true
# Still push existing state to Gitea (including the suspicious staging refs
# so you can inspect them)
push_to_gitea "$bare_dir" "$gitea_org" "$repo"
return 0
fi
# ── Safe-merge: update local refs from staging ───────
# For each upstream ref, fast-forward our local ref if possible.
# If upstream has force-pushed (not fast-forward), we keep BOTH:
# the old ref is already in refs/backup/, and we update the
# live ref to match upstream so the mirror stays current.
log " merging upstream state …"
git for-each-ref --format='%(refname) %(objectname)' refs/upstream-staging/ 2>/dev/null | \
while read -r staging_ref sha; do
# refs/upstream-staging/heads/main → refs/heads/main
local target_ref="${staging_ref/refs\/upstream-staging\//refs\/}"
local old_sha
old_sha=$(git rev-parse "$target_ref" 2>/dev/null || echo "")
if [[ "$old_sha" == "$sha" ]]; then
continue # no change
fi
if [[ -z "$old_sha" ]]; then
# New ref — just create it
git update-ref "$target_ref" "$sha"
audit "REF_ADDED repo=${gh_owner}/${repo} ref=$target_ref sha=$sha"
else
# Existing ref changed — update it (old state is in backup)
git update-ref "$target_ref" "$sha"
audit "REF_UPDATED repo=${gh_owner}/${repo} ref=$target_ref old=$old_sha new=$sha"
fi
done
# ── NOTE: We NEVER delete local refs that upstream removed ──
# If upstream deleted a branch, our copy keeps it. That's the point.
# ── LFS objects ──────────────────────────────────────
# Time-boxed: LFS is best-effort. We never let it block the whole sync.
LFS_TIMEOUT="${LFS_TIMEOUT:-600}" # 10 min default per repo
if command -v git-lfs &>/dev/null && git config --get-regexp 'lfs\.' &>/dev/null 2>&1; then
log " fetching LFS objects (timeout: ${LFS_TIMEOUT}s) …"
timeout "$LFS_TIMEOUT" git lfs fetch origin --all 2>>"$LOG_FILE" || warn "LFS fetch skipped/incomplete for ${gh_owner}/${repo}"
fi
# ── Wiki ─────────────────────────────────────────────
if [[ "$SYNC_WIKIS" == "true" ]]; then
sync_wiki "$gh_owner" "$repo" "$gitea_org"
fi
# ── Push to Gitea ───────────────────────────────────
push_to_gitea "$bare_dir" "$gitea_org" "$repo"
# ── Release assets ──────────────────────────────────
if [[ "$SYNC_RELEASES" == "true" ]]; then
sync_releases "$gh_owner" "$repo" "$gitea_org"
fi
local refs_after
refs_after=$(count_refs "$bare_dir")
log " ✓ done (refs: $refs_before$refs_after)"
audit "SYNC_OK repo=${gh_owner}/${repo} refs_before=$refs_before refs_after=$refs_after"
return 0
}
# ═══════════════════════════════════════════════════════════
# WIKI: Mirror the wiki repo (if it exists)
# ═══════════════════════════════════════════════════════════
sync_wiki() {
local gh_owner="$1" repo="$2" gitea_org="$3"
local wiki_url="https://github.com/${gh_owner}/${repo}.wiki.git"
local wiki_dir="${MIRROR_ROOT}/${gh_owner}/${repo}.wiki.git"
# Quick check: does the wiki exist? (HEAD request to avoid cloning empty wikis)
if ! curl -sfS --head --connect-timeout 10 "$wiki_url" &>/dev/null; then
return 0 # No wiki — skip silently
fi
log " syncing wiki …"
if [[ ! -d "$wiki_dir" ]]; then
if ! git clone --bare "$wiki_url" "$wiki_dir" 2>>"$LOG_FILE"; then
warn "wiki clone failed for ${gh_owner}/${repo} (may not exist)"
return 0
fi
audit "WIKI_CLONED repo=${gh_owner}/${repo}"
fi
# Fetch latest wiki content
cd "$wiki_dir" || return 0
git remote set-url origin "$wiki_url" 2>/dev/null || true
git config remote.origin.prune false
git fetch origin '+refs/heads/*:refs/heads/*' 2>>"$LOG_FILE" || {
warn "wiki fetch failed for ${gh_owner}/${repo}"
return 0
}
# Push wiki to Gitea (Gitea auto-creates wiki repos on first push)
local gitea_wiki_url
if [[ "$GITEA_URL" == https://* ]]; then
gitea_wiki_url="${GITEA_URL/https:\/\//https:\/\/${GITEA_USER}:${GITEA_TOKEN}@}/${gitea_org}/${repo}.wiki.git"
else
gitea_wiki_url="${GITEA_URL/http:\/\//http:\/\/${GITEA_USER}:${GITEA_TOKEN}@}/${gitea_org}/${repo}.wiki.git"
fi
if git remote get-url gitea &>/dev/null; then
git remote set-url gitea "$gitea_wiki_url"
else
git remote add gitea "$gitea_wiki_url"
fi
# Ensure credential helper is configured (uses same store as main repos)
git config credential.helper store
git push gitea '+refs/heads/*:refs/heads/*' 2>>"$LOG_FILE" || {
warn "wiki push failed for ${gitea_org}/${repo}"
return 0
}
log " wiki synced"
audit "WIKI_SYNCED repo=${gh_owner}/${repo}"
}
# ═══════════════════════════════════════════════════════════
# RELEASES: Download release assets from GitHub
# ═══════════════════════════════════════════════════════════
sync_releases() {
local gh_owner="$1" repo="$2" gitea_org="$3"
local release_dir="${RELEASE_ROOT}/${gh_owner}/${repo}"
mkdir -p "$release_dir"
# Fetch the latest N releases from GitHub API
local releases_json
releases_json=$(gh_api "/repos/${gh_owner}/${repo}/releases?per_page=${RELEASE_KEEP}" 2>/dev/null) || {
# No releases or API error — skip silently
return 0
}
# Check if there are any releases
local release_count
release_count=$(echo "$releases_json" | grep -c '"tag_name"' || true)
if (( release_count == 0 )); then
return 0
fi
log " syncing releases (latest ${RELEASE_KEEP}) …"
# Parse each release: tag_name and assets
local tag_names
tag_names=$(echo "$releases_json" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/"tag_name"[[:space:]]*:[[:space:]]*"//;s/"//')
while IFS= read -r tag; do
[[ -z "$tag" ]] && continue
local tag_dir="${release_dir}/${tag}"
local marker="${tag_dir}/.downloaded"
# Skip if we've already downloaded this release
if [[ -f "$marker" ]]; then
continue
fi
mkdir -p "$tag_dir"
log " release: $tag"
# Get assets for this specific release
local release_data
release_data=$(gh_api "/repos/${gh_owner}/${repo}/releases/tags/${tag}" 2>/dev/null) || continue
# Save release metadata (description, notes, etc.)
echo "$release_data" > "${tag_dir}/release.json"
# Extract asset download URLs and names
# Format: "browser_download_url": "https://..."
local asset_urls
asset_urls=$(echo "$release_data" | grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/"browser_download_url"[[:space:]]*:[[:space:]]*"//;s/"//')
local asset_names
asset_names=$(echo "$release_data" | grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/"name"[[:space:]]*:[[:space:]]*"//;s/"//')
# Download each asset
local asset_count=0
while IFS= read -r url; do
[[ -z "$url" ]] && continue
local filename
filename=$(basename "$url")
local dest="${tag_dir}/${filename}"
if [[ ! -f "$dest" ]]; then
if curl "${gh_curl_opts[@]}" -L -o "$dest" "$url" 2>>"$LOG_FILE"; then
(( asset_count++ )) || true
else
warn "failed to download release asset: $url"
rm -f "$dest"
fi
fi
done <<< "$asset_urls"
# Also download the source archives (always available)
for fmt in tar.gz zip; do
local src_url="https://github.com/${gh_owner}/${repo}/archive/refs/tags/${tag}.${fmt}"
local src_dest="${tag_dir}/${repo}-${tag}.${fmt}"
if [[ ! -f "$src_dest" ]]; then
curl "${gh_curl_opts[@]}" -L -o "$src_dest" "$src_url" 2>>"$LOG_FILE" || rm -f "$src_dest"
fi
done
touch "$marker"
log " $asset_count assets downloaded"
audit "RELEASE_SYNCED repo=${gh_owner}/${repo} tag=$tag assets=$asset_count"
done <<< "$tag_names"
# ── Upload releases to Gitea ──────────────────────────
# Create matching releases on Gitea and upload assets
while IFS= read -r tag; do
[[ -z "$tag" ]] && continue
local tag_dir="${release_dir}/${tag}"
local gitea_marker="${tag_dir}/.gitea_uploaded"
# Skip if already uploaded to Gitea
[[ -f "$gitea_marker" ]] && continue
[[ ! -f "${tag_dir}/release.json" ]] && continue
# Extract release name and body from saved metadata
local rel_name rel_body rel_prerelease
rel_name=$(grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' "${tag_dir}/release.json" | head -1 \
| sed 's/"name"[[:space:]]*:[[:space:]]*"//;s/"//')
rel_prerelease=$(grep -o '"prerelease"[[:space:]]*:[[:space:]]*[a-z]*' "${tag_dir}/release.json" | head -1 \
| sed 's/"prerelease"[[:space:]]*:[[:space:]]*//')
[[ -z "$rel_name" ]] && rel_name="$tag"
[[ -z "$rel_prerelease" ]] && rel_prerelease="false"
# Create the release on Gitea (idempotent — 409 if exists)
local create_result
create_result=$(gitea_api POST "/repos/${gitea_org}/${repo}/releases" \
-d "{\"tag_name\":\"${tag}\",\"name\":\"${rel_name}\",\"prerelease\":${rel_prerelease}}" 2>/dev/null) || true
# Get the release ID (from create or existing)
local release_id
release_id=$(echo "$create_result" | grep -o '"id"[[:space:]]*:[[:space:]]*[0-9]*' | head -1 \
| sed 's/"id"[[:space:]]*:[[:space:]]*//')
if [[ -z "$release_id" ]]; then
# Try to get existing release
local existing
existing=$(gitea_api GET "/repos/${gitea_org}/${repo}/releases/tags/${tag}" 2>/dev/null) || true
release_id=$(echo "$existing" | grep -o '"id"[[:space:]]*:[[:space:]]*[0-9]*' | head -1 \
| sed 's/"id"[[:space:]]*:[[:space:]]*//')
fi
if [[ -n "$release_id" ]]; then
# Upload assets to Gitea release
local uploaded=0
for asset_file in "${tag_dir}"/*; do
[[ -f "$asset_file" ]] || continue
local fname
fname=$(basename "$asset_file")
# Skip metadata and markers
[[ "$fname" == "release.json" || "$fname" == .* ]] && continue
# Upload via Gitea API (multipart form)
curl "${gitea_curl_opts[@]}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@${asset_file}" \
"${GITEA_URL}/api/v1/repos/${gitea_org}/${repo}/releases/${release_id}/assets?name=${fname}" \
&>>"$LOG_FILE" || true
(( uploaded++ )) || true
done
log " uploaded $uploaded assets to Gitea release $tag"
touch "$gitea_marker"
else
warn "could not create/find Gitea release for ${gitea_org}/${repo} tag $tag"
fi
done <<< "$tag_names"
}
push_to_gitea() {
local bare_dir="$1" gitea_org="$2" repo="$3"
ensure_gitea_repo "$gitea_org" "$repo" || return 1
log " pushing to Gitea …"
# Push all ref namespaces. We use '+' here because Gitea is OUR
# server — we trust ourselves. The append-only guarantee is in
# the local bare repo and the backup refs.
if ! retry 3 git -C "$bare_dir" push gitea \
'+refs/heads/*:refs/heads/*' \
'+refs/tags/*:refs/tags/*' \
'+refs/notes/*:refs/notes/*' \
'+refs/backup/*:refs/backup/*' 2>>"$LOG_FILE"; then
warn "push to Gitea failed for ${gitea_org}/${repo}"
audit "PUSH_FAILED repo=${gitea_org}/${repo}"
return 1
fi
# Push LFS to Gitea — time-boxed to avoid blocking on huge repos (e.g. buildroot)
# Override the LFS URL so git-lfs pushes to Gitea, not back to github.com
# (repos often have .lfsconfig or lfs.url pointing at GitHub's LFS endpoint)
LFS_TIMEOUT="${LFS_TIMEOUT:-600}"
if command -v git-lfs &>/dev/null && git config --get-regexp 'lfs\.' &>/dev/null 2>&1; then
log " pushing LFS to Gitea (timeout: ${LFS_TIMEOUT}s) …"
local gitea_lfs_url
gitea_lfs_url=$(git -C "$bare_dir" remote get-url gitea 2>/dev/null | sed 's/\.git$//')
timeout "$LFS_TIMEOUT" git -C "$bare_dir" \
-c "lfs.url=${gitea_lfs_url}.git/info/lfs" \
lfs push gitea --all 2>>"$LOG_FILE" || \
warn "LFS push skipped/incomplete for ${gitea_org}/${repo} (may have timed out)"
fi
# ── Sync default branch and description from GitHub ───
sync_repo_metadata "$gitea_org" "$repo"
audit "PUSHED repo=${gitea_org}/${repo}"
}
# ═══════════════════════════════════════════════════════════
# METADATA: Sync default branch, description, website from GitHub
# ═══════════════════════════════════════════════════════════
sync_repo_metadata() {
local gitea_org="$1" repo="$2"
local marker="${MIRROR_ROOT}/.avatars/${gitea_org}_${repo}.meta.synced"
# Only sync metadata once per repo (delete marker to force re-sync)
[[ -f "$marker" ]] && return 0
# Get GitHub repo info
local gh_data
gh_data=$(gh_api "/repos/${gitea_org}/${repo}" 2>/dev/null) || return 0
# Extract default branch
local default_branch
default_branch=$(echo "$gh_data" | grep -o '"default_branch"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/"default_branch"[[:space:]]*:[[:space:]]*"//;s/"//')
# Extract description
local description
description=$(echo "$gh_data" | grep -o '"description"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
| sed 's/"description"[[:space:]]*:[[:space:]]*"//;s/"//')
# Extract homepage
local homepage
homepage=$(echo "$gh_data" | grep -o '"homepage"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/"homepage"[[:space:]]*:[[:space:]]*"//;s/"//')
if [[ -n "$default_branch" ]]; then
# Build the update payload
local payload="{\"default_branch\":\"${default_branch}\""
if [[ -n "$description" ]]; then
# Escape special JSON chars in description
description=$(echo "$description" | sed 's/\\/\\\\/g;s/"/\\"/g')
payload+=",\"description\":\"[BREAKGLASS] ${description}\""
fi
if [[ -n "$homepage" ]]; then
payload+=",\"website\":\"${homepage}\""
fi
payload+="}"
gitea_api PATCH "/repos/${gitea_org}/${repo}" -d "$payload" &>/dev/null || true
log " metadata synced (default_branch: $default_branch)"
fi
touch "$marker"
}
# ═══════════════════════════════════════════════════════════
# REVERSE SYNC: Push Gitea repos → GitHub (public backup)
# ═══════════════════════════════════════════════════════════
reverse_sync_repo() {
local gitea_org="$1" repo="$2" gh_owner="$3" gh_repo="$4"
log " reverse-sync ${gitea_org}/${repo} → github:${gh_owner}/${gh_repo}"
audit "REVERSE_SYNC_START repo=${gitea_org}/${repo} target=${gh_owner}/${gh_repo}"
# ── Ensure GitHub repo exists (create if not) ──────────
local gh_result
gh_result=$(curl -sfS --connect-timeout 15 --max-time 30 \
-H "Authorization: Bearer ${GITHUB_PUSH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${gh_owner}/${gh_repo}" 2>/dev/null) || true
if ! echo "$gh_result" | grep -q '"id"'; then
log " creating GitHub repo: ${gh_owner}/${gh_repo}"
local create_payload="{\"name\":\"${gh_repo}\",\"private\":false,\"description\":\"Public backup from git.mineracks.com/${gitea_org}/${repo}\"}"
# Try org endpoint first, fall back to user endpoint
local create_result
create_result=$(curl -sfS --connect-timeout 15 --max-time 30 \
-X POST \
-H "Authorization: Bearer ${GITHUB_PUSH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
-d "$create_payload" \
"https://api.github.com/orgs/${gh_owner}/repos" 2>/dev/null) || true
if ! echo "$create_result" | grep -q '"id"'; then
# Try as user repo
create_result=$(curl -sfS --connect-timeout 15 --max-time 30 \
-X POST \
-H "Authorization: Bearer ${GITHUB_PUSH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
-d "$create_payload" \
"https://api.github.com/user/repos" 2>/dev/null) || true
if ! echo "$create_result" | grep -q '"id"'; then
warn "could not create GitHub repo ${gh_owner}/${gh_repo}"
audit "REVERSE_SYNC_FAILED repo=${gitea_org}/${repo} reason=cannot_create_github_repo"
return 1
fi
fi
log " GitHub repo created: ${gh_owner}/${gh_repo}"
fi
# ── Clone from Gitea (or use existing) ─────────────────
local work_dir="${MIRROR_ROOT}/.reverse-sync/${gitea_org}/${repo}.git"
# ── Build authed Gitea URL ──────────────────────────────
local authed_gitea
if [[ "${GITEA_URL}" == https://* ]]; then
authed_gitea="${GITEA_URL/https:\/\//https:\/\/${GITEA_USER}:${GITEA_TOKEN}@}/${gitea_org}/${repo}.git"
else
authed_gitea="${GITEA_URL/http:\/\//http:\/\/${GITEA_USER}:${GITEA_TOKEN}@}/${gitea_org}/${repo}.git"
fi
if [[ ! -d "$work_dir" ]]; then
log " cloning from Gitea …"
mkdir -p "$(dirname "$work_dir")"
if ! git clone --bare "$authed_gitea" "$work_dir" 2>>"$LOG_FILE"; then
warn "reverse-sync clone failed for ${gitea_org}/${repo}"
return 1
fi
fi
cd "$work_dir" || return 1
# ── Configure remotes ──────────────────────────────────
# Gitea as origin (source)
git remote set-url origin "$authed_gitea" 2>/dev/null || git remote add origin "$authed_gitea"
# GitHub as push target
local authed_github="https://${GITHUB_PUSH_TOKEN}@github.com/${gh_owner}/${gh_repo}.git"
if git remote get-url github &>/dev/null; then
git remote set-url github "$authed_github"
else
git remote add github "$authed_github"
fi
# ── Fetch latest from Gitea ────────────────────────────
log " fetching from Gitea …"
if ! git fetch origin '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' 2>>"$LOG_FILE"; then
warn "reverse-sync fetch from Gitea failed for ${gitea_org}/${repo}"
return 1
fi
# ── Push to GitHub ─────────────────────────────────────
# Only push heads and tags (not backup refs — those stay private)
log " pushing to GitHub …"
if ! retry 3 git push github \
'+refs/heads/*:refs/heads/*' \
'+refs/tags/*:refs/tags/*' 2>>"$LOG_FILE"; then
warn "reverse-sync push to GitHub failed for ${gh_owner}/${gh_repo}"
audit "REVERSE_SYNC_FAILED repo=${gitea_org}/${repo} reason=push_failed"
return 1
fi
log " ✓ reverse-synced to github.com/${gh_owner}/${gh_repo}"
audit "REVERSE_SYNC_OK repo=${gitea_org}/${repo} target=${gh_owner}/${gh_repo}"
return 0
}
run_reverse_sync() {
local repos="${REVERSE_SYNC_REPOS:-}"
[[ -z "$repos" ]] && return 0
local gh_push_owner="${GITHUB_PUSH_OWNER:-}"
if [[ -z "${GITHUB_PUSH_TOKEN:-}" ]]; then
warn "REVERSE_SYNC_REPOS set but GITHUB_PUSH_TOKEN is empty — skipping reverse sync"
return 0
fi
log ""
log "═══ Reverse sync (Gitea → GitHub) ═══"
local reverse_ok=0 reverse_fail=0
for entry in $repos; do
local gitea_side gh_side
if [[ "$entry" == *:* ]]; then
# Explicit mapping: gitea_org/repo:github_owner/repo
gitea_side="${entry%%:*}"
gh_side="${entry##*:}"
else
# Short form: gitea_org/repo → same on GitHub
gitea_side="$entry"
gh_side="${gh_push_owner:+${gh_push_owner}/}${entry##*/}"
# If no GITHUB_PUSH_OWNER, use the gitea org
[[ "$gh_side" == /* ]] && gh_side="${entry}"
fi
local g_org="${gitea_side%%/*}"
local g_repo="${gitea_side##*/}"
local gh_owner="${gh_side%%/*}"
local gh_repo="${gh_side##*/}"
if reverse_sync_repo "$g_org" "$g_repo" "$gh_owner" "$gh_repo"; then
(( reverse_ok++ )) || true
else
(( reverse_fail++ )) || true
fi
done
log "═══ Reverse sync done: ${reverse_ok} ok, ${reverse_fail} failed ═══"
if (( reverse_fail > 0 )); then
notify "Reverse sync: ${reverse_ok} ok, ${reverse_fail} failed — check $LOG_FILE" "high" "warning"
elif (( reverse_ok > 0 )); then
notify "Reverse sync: ${reverse_ok} repos pushed to GitHub" "low" "arrow_right"
fi
}
# ── Notifications ────────────────────────────────────────
notify() {
local message="$1"
local priority="${2:-default}" # ntfy priority: min/low/default/high/urgent
local tags="${3:-}" # ntfy tags (emoji shortcodes)
case "${NOTIFY_METHOD}" in
ntfy)
local -a ntfy_args=(-sfS -d "$message")
[[ -n "$priority" ]] && ntfy_args+=(-H "Priority: $priority")
[[ -n "$tags" ]] && ntfy_args+=(-H "Tags: $tags")
curl "${ntfy_args[@]}" "${NTFY_SERVER:-https://ntfy.sh}/${NTFY_TOPIC:-breakglass}" &>/dev/null || true
;;
email)
echo "$message" | mail -s "Breakglass Mirror Alert" "${NOTIFY_EMAIL:-}" 2>/dev/null || true
;;
telegram)
curl -sfS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN:-}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID:-}" -d "text=${message}" &>/dev/null || true
;;
*) ;;
esac
}
# ── Glob matching ────────────────────────────────────────
matches_glob() {
local name="$1" pattern="$2"
[[ "$pattern" == "*" ]] && return 0
local IFS='|'
for glob in $pattern; do
# shellcheck disable=SC2053
[[ "$name" == $glob ]] && return 0
done
return 1
}
# ═══════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════
log "═══ Breakglass APPEND-ONLY sync started at $TIMESTAMP ═══"
audit "SESSION_START timestamp=$TIMESTAMP config=$ENV_FILE"
log "Config: $ENV_FILE"
log "Sources: $SOURCES_FILE"
log ""
# ── Mode: reverse-only skips the GitHub→Gitea pull ───────
REVERSE_SYNC_ONLY="${REVERSE_SYNC_ONLY:-}"
# ── Mode: ONLY_REPO=owner/repo narrows sync to a single repo ──
ONLY_REPO="${ONLY_REPO:-}"
ONLY_OWNER=""
ONLY_NAME=""
if [[ -n "$ONLY_REPO" ]]; then
if [[ "$ONLY_REPO" != */* ]]; then
die "ONLY_REPO must be in form owner/repo (got: $ONLY_REPO)"
fi
ONLY_OWNER="${ONLY_REPO%%/*}"
ONLY_NAME="${ONLY_REPO##*/}"
log "── ONLY_REPO filter active: ${ONLY_OWNER}/${ONLY_NAME} ──"
fi
if [[ -z "$REVERSE_SYNC_ONLY" ]]; then
declare -a OWNERS
if [[ -n "$ONLY_REPO" ]]; then
# Try to find a matching owner entry in sources.yml; if missing,
# synthesize one so ONLY_REPO works even for unconfigured owners.
if [[ -f "$SOURCES_FILE" ]]; then
mapfile -t OWNERS < <(parse_sources)
fi
matched=""
for entry in "${OWNERS[@]:-}"; do
read -r _ gh_owner _rest <<< "$entry"
if [[ "$gh_owner" == "$ONLY_OWNER" ]]; then matched="$entry"; break; fi
done
if [[ -z "$matched" ]]; then
synth_org="${GITEA_ORG_OVERRIDE:-$ONLY_OWNER}"
log "── ONLY_REPO owner '$ONLY_OWNER' not in sources.yml — synthesizing entry (gitea org: $synth_org) ──"
OWNERS=("- $ONLY_OWNER $synth_org * ")
else
OWNERS=("$matched")
fi
else
[[ -f "$SOURCES_FILE" ]] || die "sources file not found: $SOURCES_FILE"
mapfile -t OWNERS < <(parse_sources)
[[ ${#OWNERS[@]} -eq 0 ]] && die "no owners found in $SOURCES_FILE"
fi
for entry in "${OWNERS[@]}"; do
read -r _ gh_owner gitea_org include_glob exclude_glob <<< "$entry"
if [[ -n "$ONLY_REPO" && "$gh_owner" != "$ONLY_OWNER" ]]; then
continue
fi
log "── Owner: $gh_owner → gitea:$gitea_org ──"
ensure_gitea_org "$gitea_org"
if [[ -n "$ONLY_REPO" ]]; then
repos="$ONLY_NAME"
else
repos=$(gh_list_repos "$gh_owner") || { (( ERRORS++ )) || true; continue; }
fi
owner_start=$(date +%s)
while IFS= read -r repo; do
[[ -z "$repo" ]] && continue
# Per-owner budget: stop starting new repos once exceeded.
# The in-flight repo finishes naturally; we just don't begin another.
# ONLY_REPO mode bypasses the budget (single-repo runs are intentional).
if [[ -z "$ONLY_REPO" ]] && (( OWNER_BUDGET_SEC > 0 )); then
owner_elapsed=$(( $(date +%s) - owner_start ))
if (( owner_elapsed > OWNER_BUDGET_SEC )); then
warn "owner $gh_owner exceeded budget (${owner_elapsed}s > ${OWNER_BUDGET_SEC}s) — deferring remaining repos to next run"
audit "OWNER_BUDGET_EXCEEDED owner=$gh_owner elapsed=${owner_elapsed}s budget=${OWNER_BUDGET_SEC}s"
(( OWNERS_DEFERRED++ )) || true
break
fi
fi
if [[ -z "$ONLY_REPO" ]]; then
if ! matches_glob "$repo" "${include_glob:-*}"; then
(( SKIPPED++ )) || true; continue
fi
if [[ -n "$exclude_glob" ]] && matches_glob "$repo" "$exclude_glob"; then
(( SKIPPED++ )) || true; continue
fi
fi
if sync_repo "$gh_owner" "$repo" "$gitea_org"; then
(( SYNCED++ )) || true
else
(( ERRORS++ )) || true
fi
done <<< "$repos"
done
else
log "── REVERSE_SYNC_ONLY mode — skipping GitHub → Gitea pull ──"
fi
# ── Reverse sync (Gitea → GitHub) ────────────────────────
if [[ -n "$ONLY_REPO" && -z "$REVERSE_SYNC_ONLY" ]]; then
log "── ONLY_REPO active — skipping reverse sync ──"
else
run_reverse_sync
fi
# ── Summary ──────────────────────────────────────────────
DURATION=$(( $(date +%s) - $(date -d "$TIMESTAMP" +%s 2>/dev/null || date -u -d "${TIMESTAMP:0:8} ${TIMESTAMP:9:2}:${TIMESTAMP:11:2}:${TIMESTAMP:13:2}" +%s 2>/dev/null || echo 0) ))
DURATION_MIN=$(( DURATION / 60 ))
SUMMARY="Breakglass sync: ${SYNCED} synced, ${SKIPPED} skipped, ${PROTECTED} wipe-protected, ${ERRORS} errors, ${OWNERS_DEFERRED} owners deferred (${DURATION_MIN}m)"
log ""
log "═══ $SUMMARY ═══"
audit "SESSION_END $SUMMARY"
if (( PROTECTED > 0 )); then
notify "🛡️ $SUMMARY — WIPE PROTECTION ACTIVATED — check $LOG_FILE" "urgent" "rotating_light,shield"
elif (( ERRORS > 0 )); then
notify "⚠️ $SUMMARY — check $LOG_FILE" "high" "warning"
else
notify "$SUMMARY" "low" "white_check_mark"
fi
# ── Log rotation: keep 90 days (audit logs kept forever) ─
find "$LOG_DIR" -name 'sync-*.log' -mtime +90 -delete 2>/dev/null || true
# ── Generate SHA256 of audit file for tamper evidence ────
if [[ -f "$AUDIT_FILE" ]]; then
sha256sum "$AUDIT_FILE" >> "$AUDIT_DIR/checksums.log"
fi
exit $(( ERRORS > 0 ? 1 : 0 ))