# [2026-08-23] hrafn adopted; its CI deploy reported green while deploying nothing

`hrafn` — genuine-Chromium browser-fetch behind a REST API, for bot-gated sites
(Reddit first). Built by nevermore-claude on ana-docker, handed to infra-ops for
uptime ownership. Internal-only on `traefik-net`, no host port; consumers reach
`http://hrafn:8080`. Canonical at `stacks/hrafn/`.

## Intake found a live credential exposure

`/opt/docker/compose/hrafn/.env` was mode **0644 with a live 57-char bearer token**
— verified as real exposure by reading it as `nobody` on a box with four
interactive accounts. Tightened to 0600. That triggered the wider sweep (see
[[2026-08-23-ana-docker-env-perms-sweep]]).

## The CI defect — the one worth remembering

I authored the deploy (elway playbook + gitea workflow) to replace a hand-rsync,
tagging the image with the commit SHA for provenance. nevermore-claude later found
v1.0.0 deploying "green" while the host still served 0.1.0.

**Root cause was mine and nastier than either hypothesis.** 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
its own 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 succeeded, the copy silently did not, rsync exited 0. 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 the tagging existed to provide
was false for the pipeline's entire life.**

**The real failure is the verification.** The verify steps asserted the marker,
container health, and a 200 from `/readyz` — all of which pass against a
completely frozen host. None measured *content*. A deploy that reports success
without asserting the bytes changed is verifying an **uptime**, not a deploy.

## 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 after the converge** and fails on mismatch
- compare the running container's `src/**/*.py` against the host's, so a SHA tag
  cannot name layers the image lacks
- **compare `*.py` only** — `pip install .` generates `src/*.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 a known-good container
  before shipping (12 host files, 18 in container, 0 content differences).
- declined `--no-cache`: a cache hit is *correct* when the context is genuinely
  unchanged; assert the property rather than brute-force it.

## Access

Operator granted claude-bot **write** on `vh/hrafn`, so infra-ops maintains the
pipeline it owns instead of routing patches through the repo holder. `vh/hrafn` is
canonical; `stacks/hrafn/ci/` is a verified mirror.

Commits `b6924de`, `b001d0c`, `11b9d18`, `b38c369`, `9642952`.
