claude-bot holds write on vh/hrafn as of 2026-08-23, so the canonical copy of the pipeline moved there and infra-ops maintains it directly instead of routing patches through the repo holder. The files here are a verified mirror (byte-identical to live at 22e0eb9d75a6). Live state: run 9922 green, and both new content assertions executed rather than merely existing -- verify 4/5 host tree matches shipped context (a99ce748a0c9...) verify 5/5 image source matches host source (06f209fd0641...) The CI-computed context hash matching on the host is the end-to-end proof that the converge lands what CI ships. Its absence is what let the frozen-source bug survive every green deploy. Also records why the HEAD == GITHUB_SHA assertion was added and then removed: it needed the git binary (run 9920, exit 127), and installing git flipped actions/checkout@v4 from its node implementation to the git binary, which died on a missing CA bundle (run 9921). A nice-to-have assertion changed the checkout code path and broke a working pipeline; it guarded a hypothesis that proved wrong, so it went rather than getting ca-certificates bolted on.
hrafn CI deploy — mirror of what runs in vh/hrafn
These two files are hrafn's deploy pipeline. The canonical copy now lives in
vh/hrafn — as of 2026-08-23 claude-bot holds write there, so infra-ops
maintains the pipeline it owns directly instead of routing patches through the
repo holder.
| file here | canonical location in vh/hrafn |
|---|---|
playbooks-deploy.yaml |
playbooks/deploy.yaml |
gitea-workflows-deploy.yaml |
.gitea/workflows/deploy.yaml |
This copy is a mirror kept for review and history, verified byte-identical
to the live files at commit 22e0eb9d75a6. If you change one, change both —
or check with:
curl -s "https://gitea.phasefinal.com/api/v1/repos/vh/hrafn/contents/playbooks/deploy.yaml?ref=main" \
-H "Authorization: token $(cat ~/.config/claude-bot/gitea-token-repo-create)" \
| python3 -c 'import json,sys,base64,hashlib; print(hashlib.sha256(base64.b64decode(json.load(sys.stdin)["content"])).hexdigest()[:12])'
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.
Two failed runs getting there (9920, 9921)
Worth reading before adding anything to the workflow's prerequisite step.
The frozen-source fix originally shipped with a HEAD == GITHUB_SHA
assertion. It failed twice, both times for reasons the change itself
introduced:
- run 9920 —
git: command not found(exit 127). act_runner's job image ships no git binary, andactions/checkout@v4does not need one. - run 9921 — adding
gitmade the checkout fail withserver certificate verification failed. CAfile: none. Installing git flips checkout@v4 from its node implementation — which every working run of this pipeline had used — to the git binary, and the image has no CA bundle.
So a nice-to-have assertion changed the checkout's code path and broke a
working pipeline. It was removed rather than patched with ca-certificates:
it guarded a hypothesis that turned out to be wrong (the frozen source was
a self-referential rsync in the playbook, not a stale checkout), and
clean: true plus the host-side content hash already cover the real risk.
Do not add git to the prerequisite step. Run 9922 is green with it out.
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.