Project: publish-pipeline — automated docs release
Status: live (2026-10) — end-to-end run verified. Replaces the manual release runbook with a dispatched Gitea Actions workflow plus a host-side git relay. The human gate is unchanged: operator review of the sanitized diff, then dispatch.
Related: dev-vm, infra-wiki, wiki, gitea, diag-pipe (same trust boundary, shared redaction state).
Summary
A docs release used to be a ten-step manual runbook across three
machines. It is now: Devin merges to the devvm mirrors and tags a
publish epoch; the operator reviews the diff and dispatches
publish.yml with that epoch; a dedicated runner LXC does the rest —
un-redact into gitea, rebaseline the mirror, rebuild + deploy both
wikis, and stamp a new
epoch on the way out. A watcher on the host relays refs between the
isolated devvm and the runner over git transports only.
The epoch tag is both the input barrier (“don’t start until this exact cross-repo state arrived”) and the completion signal (“the new tag on every devvm mirror = the run landed”). A missing output epoch is the failure indication — no separate health check needed.
Motivation
The manual flow was ~10 ordered steps on the operator machine (sync-in, sync-out, wiki build+deploy×2, backups, state persistence, per-machine clones and vault refresh). Complex, correct, and begging for automation — every step was mechanical except the diff review. The constraint that shaped the design: devvm must not gain connectivity to anything, so the runner can’t fetch the mirrors directly and the host can’t be mounted into the runner.
Architecture
The relay (host-ferry)
devvm mirrors ──ssh fetch──▶ /srv/mirrors/*.git ──ssh push──▶ runner /srv/mirrors
devvm mirrors ◀──ssh push─── /srv/mirrors/*.git ◀──ssh fetch── runner /srv/mirrors
runner ──https+PAT──▶ gitea (vnet0) runner ──ssh──▶ wiki (vnet0)
host-ferry.sh runs on the Proxmox host as a systemd watcher
(watch mode — ls-remote polls both endpoints every ~5s, relays
only on real ref changes). Both transports are host-initiated —
the host reaches devvm (devin alias) and the runner (ci alias)
because guest→host rules only drop guest-initiated traffic; host
OUTPUT into either network is fine. No firewall exceptions, no shared
filesystem, no bind mounts.
Per pass, per repo:
fetch --prune devvm— the store’srefs/{heads,tags}track devvm (canonical; devvm is authoritative).fetch runner— the runner’s refs land inrefs/tracking/runner/*(a parallel namespace; runner state never shares canonical refs).- Deliver differing tracked refs to devvm in one
git push --atomic— heads fast-forward-only; if any tracked head is behind or diverged the whole repo’s delivery is skipped (a stale run’s baseline/epoch tags must never land pointing at orphaned commits). New branches always deliver. fetch --prune devvmagain, then converge the runner to canonical — force heads+tags, delete runner refs canonical lacks. Between runs the runner’s copies are pure slave replicas.
Git transport everywhere means every observable state is a consistent
one — that’s why this beat a shared volume (filesystem copies can tear
a live packfile; fetch/push are transactional per ref).
The publish-lock ref
A whole run is a transaction boundary git can’t express (many ops
across many repos), so the run marks it with a ref:
refs/ferry/publish-lock on the runner’s copy of infra-docs, set
after the epoch wait and deleted on exit. While present the ferry
skips all runner-side ops; devvm↔store traffic continues. A stale ref
self-heals — the next run sets and clears it.
Publish epochs
ci/epoch.sh hashes the refs/heads/main sha of every repo in
EPOCH_REPOS into one value and tags each repo’s main with
publish-epoch-<hash> (annotated; the manifest is the tag message).
- Input: Devin tags post-merge; dispatch passes the hash as
expect_epoch. The run polls every 5s until the tag exists on all epoch repos on the runner’s mirrors — dispatch is fully timing-independent. - Output: on a clean run only, the runner recomputes the epoch over its mutated heads and re-tags; the tags ride the ferry back after the lock clears.
- Check:
ci/epoch.sh status ~/mirrors <repos>on devvm — all repos agreeing on one epoch = last run completed and durable.
Runner container
LXC 116 gitea-runner (10.0.1.36) — unprivileged Debian, needs
features: nesting=1 for the containerized Quartz build/wrangler
deploy. Runs gitea-runner daemon under systemd (registered label
gitea-runner:host, host executor — no podman-in-podman). Root is
fine inside an unprivileged CT; the gitea runner and the git-transport
user share /srv/mirrors on the same identity.
Trust model
Same boundary as the docs mirror and diag-pipe: the runner never reaches devvm or the host — it talks only to gitea, the wiki deploy target, and public egress (image pulls, Pages upload). The ferry is the choke point: a compromised runner can only write its own repo copies; the only thing crossing is refs the ferry chose to relay, and heads can never be force-moved backward on devvm.
The human gate stays upstream: branch review, then dispatch is the approval click. Automation covers transport, bookkeeping, redaction — same division as diag-pipe.
Failure semantics
- Runner crashes mid-run → lock ref may linger (harmless), output
epoch never stamps →
epoch.sh statusshows the old epoch → fix and redispatch. - devvm mirror moved during a run (operational violation — devvm’s mirrors are read-only for Devin while a run is in flight) → the ferry’s divergence abort drops that repo’s output → output epoch never completes → manual state repair.
- Wiki build/deploy fails → phase returns nonzero, epoch not
stamped,
wiki-deployed-shanot recorded — rerun is not skipped next time.
Shakedown notes (things that actually broke)
- LXC needs
features: nesting=1in116.conffor docker-in-LXC —pct setwrites it but the container needs a real stop/start. - A gitea bot user with “Disable Sign In” set can’t auth git over https at all — PAT included.
GITEA_*is a reserved prefix for actions variables and secrets — henceVAR_GITEA_*vars andBOT_TOKEN/API_TOKEN.git fetchauto-follows tags intorefs/tags/*regardless of refspec destination —remote.*.tagopt=--no-tagson the runner remote is load-bearing, or runner tags leak into canonical.git clone --mirrorleavesremote.*.mirror=truebehind — a normal push under it becomes a mirror push and can revert the remote.ensure_remotesstrips it every pass.- Phase functions invoked as
phase || run_failed=1run with errexit suppressed — internal failures mustreturn 1explicitly or a failed deploy still stamps the epoch. - The runner caches
STATE_DIRbetween runs; a clone made while the state repo was empty has no branches — fetch-before-checkout heals it. - A wrong-but-writable
WIKI_PATHdeploys green:rsync --deletecreates the target, exits 0,wiki-deployed-sharecords a deploy the webroot never saw.ci.example.envnow ships the real webroot, but verifyci.env’sWIKI_PATHmatches the wiki Caddyfile root — and after any such fix,force_wiki=trueto override the falsely recorded sha.