diff --git a/lith/mirror/README.md b/lith/mirror/README.md new file mode 100644 index 000000000..d6434bd07 --- /dev/null +++ b/lith/mirror/README.md @@ -0,0 +1,101 @@ +# lith mirror — knot ↔ GitHub bidirectional sync + +Keeps `main` in lockstep between the two remotes for +`aesthetic.computer/core`: + +- **knot** (`knot.aesthetic.computer:aesthetic.computer/core`, Tangled) — + what `lith` deploy pulls from. +- **GitHub** (`whistlegraph/aesthetic-computer`) — what `session-server` + deploy pulls from, plus Claude/tools usage. + +Runs as a systemd timer on `lith.aesthetic.computer`, every 60 seconds. +Idempotent: exits 0 when the tips match, pushes the ahead side to the +behind side otherwise, and exits 2 with a warning when the tips truly +diverged (requires manual merge). + +## Files in this directory + +- [`mirror.sh`](./mirror.sh) — the bidirectional sync script. +- [`ac-mirror.service`](./ac-mirror.service) — systemd oneshot unit. +- [`ac-mirror.timer`](./ac-mirror.timer) — every-60s trigger. + +## First-time setup + +On the lith host: + +```sh +# 1. Bare clone (fetched over anon HTTPS; push goes via SSH keys below). +mkdir -p /opt/ac-mirror +git clone --bare https://knot.aesthetic.computer/aesthetic.computer/core \ + /opt/ac-mirror/core +cd /opt/ac-mirror/core +git remote rename origin knot +git remote set-url --push knot git@knot.aesthetic.computer:aesthetic.computer/core +git remote add github https://github.com/whistlegraph/aesthetic-computer.git +git remote set-url --push github git@github.com:whistlegraph/aesthetic-computer.git + +# 2. SSH keys (ed25519). +# /root/.ssh/knot_push ← copy of the vault's home/.ssh/tangled +# (the key registered on @jeffrey's +# Tangled account, allowed to push). +# /root/.ssh/github_mirror ← fresh ed25519 keypair generated on lith; +# public half registered as a repo deploy key +# with write access on +# whistlegraph/aesthetic-computer, +# encrypted private copy in +# aesthetic-computer-vault/lith/mirror/. +# Both files must be mode 600. + +# 3. Pin host keys to avoid interactive prompts. +ssh-keyscan -t ed25519,rsa knot.aesthetic.computer >> /root/.ssh/known_hosts +ssh-keyscan -t ed25519,rsa github.com >> /root/.ssh/known_hosts +sort -u /root/.ssh/known_hosts -o /root/.ssh/known_hosts + +# 4. Install the script + units and enable the timer. +install -m 755 mirror.sh /opt/ac-mirror/mirror.sh +install -m 644 ac-mirror.service /etc/systemd/system/ac-mirror.service +install -m 644 ac-mirror.timer /etc/systemd/system/ac-mirror.timer +systemctl daemon-reload +systemctl enable --now ac-mirror.timer +``` + +## Observe / debug + +```sh +systemctl list-timers ac-mirror.timer +journalctl -u ac-mirror -n 50 +# In sync = no output per run. A sync push logs one line: +# 2026-04-20T22:31:27+00:00 → knot behind; pushing to knot. +``` + +## Manual force + +```sh +# Run immediately (the timer fires hourly-ish otherwise on boot). +systemctl start ac-mirror.service +``` + +## Divergent heads + +If both sides received independent commits (true fork), the script exits +`2` and logs: + +``` +⚠️ divergent heads: knot= github= — skipping (manual resolution required) +``` + +Resolve by pulling both locally, merging with `git merge`, and pushing +the merge commit. The mirror will then see both sides equal the merge +tip and go back to green. + +## Why this instead of GitHub Actions? + +A `.github/workflows/mirror-to-knot.yml` was tried first but: + +1. GitHub Actions is billing-locked on the repo at the moment — + the workflow never fires. +2. Tangled knot *also* reads `.github/workflows/*.yml` as pipelines, + and sent failure emails about them. + +The systemd timer on lith is free, runs even when GitHub is unavailable, +and keeps all credentials on a host we already control. diff --git a/lith/mirror/ac-mirror.service b/lith/mirror/ac-mirror.service new file mode 100644 index 000000000..958732c88 --- /dev/null +++ b/lith/mirror/ac-mirror.service @@ -0,0 +1,13 @@ +[Unit] +Description=Mirror main between knot and GitHub for aesthetic-computer/core +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/opt/ac-mirror/mirror.sh +# Don't flood logs with normal exits (code 0 = in sync, 1 = error, 2 = divergent). +SuccessExitStatus=0 + +[Install] +WantedBy=multi-user.target diff --git a/lith/mirror/ac-mirror.timer b/lith/mirror/ac-mirror.timer new file mode 100644 index 000000000..5d91b9378 --- /dev/null +++ b/lith/mirror/ac-mirror.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run ac-mirror every 60 seconds + +[Timer] +OnBootSec=60 +OnUnitActiveSec=60 +AccuracySec=10 +Unit=ac-mirror.service + +[Install] +WantedBy=timers.target diff --git a/lith/mirror/mirror.sh b/lith/mirror/mirror.sh new file mode 100755 index 000000000..f8b37ab60 --- /dev/null +++ b/lith/mirror/mirror.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# ac-mirror.sh — bidirectional knot ↔ github mirror for the core repo. +# Runs via systemd timer every 60s. Idempotent, exits fast when in sync. +set -euo pipefail + +REPO=/opt/ac-mirror/core +KNOT_KEY=/root/.ssh/knot_push +GH_KEY=/root/.ssh/github_mirror +BRANCH=main + +cd "$REPO" + +export GIT_SSH_COMMAND="ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ + -i $KNOT_KEY -i $GH_KEY" + +# "+" prefix → allow non-fast-forward fetches. A mirror must always track +# wherever the remote actually is, even after force-pushes or rewinds. +git fetch --quiet knot "+$BRANCH":refs/remotes/knot/$BRANCH +git fetch --quiet github "+$BRANCH":refs/remotes/github/$BRANCH + +knot_head=$(git rev-parse "knot/$BRANCH") +gh_head=$(git rev-parse "github/$BRANCH") + +if [ "$knot_head" = "$gh_head" ]; then + exit 0 +fi + +if git merge-base --is-ancestor "$gh_head" "$knot_head"; then + echo "$(date -Iseconds) → github behind; pushing $knot_head to github." + git push --quiet github "$knot_head:refs/heads/$BRANCH" +elif git merge-base --is-ancestor "$knot_head" "$gh_head"; then + echo "$(date -Iseconds) → knot behind; pushing $gh_head to knot." + git push --quiet knot "$gh_head:refs/heads/$BRANCH" +else + echo "$(date -Iseconds) ⚠️ divergent heads: knot=$knot_head github=$gh_head — skipping (manual resolution required)" >&2 + exit 2 +fi