Root cause of nevermore-claude's report that v1.0.0 deployed green while the host kept serving 0.1.0. The staging dir was $compose_dir/.stage -- INSIDE the rsync target. So `rsync -a --delete $compose_dir/.stage/ $compose_dir/` deleted .stage from the destination (absent from the source listing) DURING the transfer, destroying the source mid-copy. Reproduced exactly: before: app.py="OLD" leftover.txt .stage/app.py="NEW" after: app.py="OLD" leftover.txt GONE, .stage GONE Deletion worked; the copy silently did not. So the directory looked converged while host source stayed frozen at the first manual rsync, and because the build's COPY inputs never changed, Docker full-cache-hit and every SHA tag aliased one image. The provenance guarantee was false. Nothing caught it because the verify steps asserted the marker, health, and a 200 from /readyz -- all of which pass on a frozen host. None measured content. Fixes: - stage at /tmp/hrafn-deploy-stage, outside the target - CI computes context_sha256 over the shipped file list; the playbook recomputes it on the host post-converge and fails on mismatch - compare the running container's src/**/*.py against the host's, catching a SHA tag naming layers the image does not contain - checkout clean:true + assert HEAD == GITHUB_SHA so a reused runner workspace fails the job rather than shipping a stale tree Declined --no-cache: a cache hit is correct when the context is genuinely unchanged, and the new assertions prove the property directly rather than brute-forcing it. The container-vs-host check compares only *.py -- `pip install .` generates src/hrafn.egg-info/* inside the image and __pycache__ appears at runtime, so a naive `find src -type f` compare false-fails on every healthy deploy. Verified against the live container before shipping (12 host files, 18 in container, 0 content differences).
hrafn CI deploy — authored here, lands in vh/hrafn
These two files replace hrafn's hand-rsync deploy. They are authored and
version-controlled here because infra-ops owns hrafn's uptime, but they
belong in the vh/hrafn repo — infra-ops has no write access to it
(claude-bot is not a collaborator), so they are handed to the repo holder
rather than committed directly.
| file here | destination in vh/hrafn |
|---|---|
playbooks-deploy.yaml |
playbooks/deploy.yaml |
gitea-workflows-deploy.yaml |
.gitea/workflows/deploy.yaml |
Keep this copy in sync if the deployed version changes, or delete it once
infra-ops has write access to vh/hrafn and the repo copy is canonical.
What the change buys
The pre-CI shape was: rsync a working tree into /opt/docker/compose/hrafn/,
then docker compose build && up. Two problems, both fixed here.
- No provenance. The image was always
local/hrafn:v1, so nothing on the box could answer "what commit is running". The image is now tagged with the commit SHA, and/opt/docker/compose/hrafn/.deployedrecords the SHA and timestamp. Rollback becomes a retag. - The whole repo lived in the compose directory —
tests/,docs/,ROADMAP.md,persistent-memory.md,CLAUDE.md. Only the build context ships now (Dockerfile,compose.yaml,pyproject.toml,README.md,src/).
Two design calls worth knowing
- Tarball, not per-file upload steps. nevermore's playbook enumerates
every source file as its own
upload:step. That is explicit, but it fails open: addsrc/hrafn/newthing.py, forget the matching step, and the deploy silently ships without it. hrafn's build context travels as one archive so it cannot go partial. .envis never deployed. It is host-owned,0600, and holds the bearer token. The playbook refuses to run if it is missing or not0600— a guard added because the file arrived at0644on handoff.- The deploy converges, it does not accrete. The first version unpacked
the tarball in place, which overwrote tracked files but never removed
anything — so leftovers from the pre-CI hand-rsync (
tests/,docs/,ROADMAP.md,persistent-memory.md,CLAUDE.md,LICENSE) survived the first CI deploy and had to be cleaned off the host by hand. Now the tarball unpacks to a staging dir andrsync --deleteconverges the compose directory onto it, so a stray file cannot outlive the next deploy. The consequence: the tar list in the workflow is authoritative — anything omitted from it is deleted from the host, except.envand.deployed.
The frozen-source defect (fixed 2026-08-22)
The converge fix above had a defect that made every deploy a no-op for source content, while still reporting success. Worth reading before touching this playbook.
The bug: the staging directory was $compose_dir/.stage — inside the
rsync target. rsync -a --delete $compose_dir/.stage/ $compose_dir/ then
deleted .stage from the destination (it is not in the source listing)
during the transfer, destroying the source mid-copy. Reproduced exactly:
before: app.py="OLD" leftover.txt .stage/app.py="NEW"
after: app.py="OLD" (leftover.txt GONE, .stage GONE)
Note which half worked. Deletion succeeded, so the directory looked
converged; the copy silently did not happen. The host source sat frozen at
the first manual rsync while .deployed and the image tag advanced with
every commit — and because the build's COPY inputs never changed, Docker
full-cache-hit and every SHA tag aliased one image. The provenance the SHA
tagging exists to provide was false the whole time.
Why nothing caught it: the verify steps asserted the marker, container
health, and a 200 from /readyz. All three pass on a frozen host. None of
them measured content. A deploy that reports success without asserting the
bytes changed is verifying an uptime, not a deploy.
The fixes:
- Stage outside the target (
/tmp/hrafn-deploy-stage). - CI computes a
context_sha256over the shipped file list; the playbook recomputes it on the host after the converge and fails if they differ. End-to-end from the CI checkout to the host filesystem. - Compare the running container's
src/**/*.pyagainst the host's, catching a SHA tag that names layers the image does not contain. clean: trueon the checkout plus an explicitHEAD == GITHUB_SHAassertion, so a reused runner workspace fails the job instead of shipping a stale tree.
No --no-cache. A cache hit is correct when the build context is
genuinely unchanged, and rebuilding a Chromium base image every deploy to
paper over a bug is the wrong trade. The content assertions prove the
property directly instead of brute-forcing it.
Gotcha in the check itself: compare only *.py. pip install .
generates src/hrafn.egg-info/* inside the image (6 files the host lacks),
and __pycache__ appears at runtime, so a naive find src -type f compare
fails on every healthy deploy. Verified against a known-good container
before shipping: 12 host files, 18 in the container, 0 content differences.
Validation
The playbook parses and interpolates clean under elway's own parser:
scripts/elway ana-docker --playbook stacks/hrafn/ci/playbooks-deploy.yaml \
--var hrafn_sha=abc123def456 --dry-run
That dry-run is worth running after any edit — it caught a real bug during
authoring, where a comment containing a literal {{ ... }} identifier was
picked up by elway's variable substitution and failed the run.
No new Actions secrets are required: DEPLOY_SSH_KEY and MGMT_REPO_TOKEN
already exist at user scope on vh from the nevermore/task-board wiring.