Files
esh-pfi-infrastructure/stacks/hrafn/ci
vh b38c369313 fix(hrafn-ci): staging dir inside the rsync target froze host source silently
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).
2026-08-22 21:57:16 -07:00
..

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.

  1. 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/.deployed records the SHA and timestamp. Rollback becomes a retag.
  2. The whole repo lived in the compose directorytests/, 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: add src/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.
  • .env is never deployed. It is host-owned, 0600, and holds the bearer token. The playbook refuses to run if it is missing or not 0600 — a guard added because the file arrived at 0644 on 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 and rsync --delete converges 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 .env and .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/.stageinside 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_sha256 over 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/**/*.py against the host's, catching a SHA tag that names layers the image does not contain.
  • clean: true on the checkout plus an explicit HEAD == GITHUB_SHA assertion, 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.