# REPORT — releasing publishes, and an unreleasable version fails CI (R-115, R-183) **Date:** 2026-08-03 · **Repo:** `felhom-agent` · **NO VERSION BUMP** — the agent stays **v0.120.0**, no Go code changed, nothing was built or deployed. ## What changed | File | | |---|---| | `scripts/release-agent.sh` | **new** — THE release path: build → tag → publish → verify by independent download | | `scripts/check-published-versions.py` | **new** — the R-115 gate | | `scripts/agent_gates.py` | registers the gate as **not `--fast`** (it needs network) | | `.gitea/workflows/gates.yml` | CI now runs the **full** gate set, not `--fast` | | `CLAUDE.md` | the raw `go build` line is replaced by the release script; a **Vouch** row replaces the old Publish row | ## Why Publishing was a step someone had to remember and was **forgotten three times in five days** — R-111's seventeen stranded releases, 0.114.0, and 0.120.0, which sat deployed on both demo hosts and undownloadable, so a documented-path reinstall would have silently downgraded them to the pre-merge agent **while reporting success**. R-111's own closing line named this leg and closed SHIPPED without it; it recurred the same afternoon. A note is not a mechanism. The script also **tags**, because `felhom-host-install.sh` now fetches the agent's sixteen config files from `raw/tag/v/` (R-183). A released version with no tag 404s a box mid-install, as root, on a virgin machine. Tag and package are two halves of one release. It **verifies by downloading what it just published** and comparing the sha to what it built. The publish step's own success is a report on its own write; a fetch returning the right bytes is a different claim, and it is the one that matters. It **does not vouch** — that points machines at a version and stays the operator's act. ## The gate's invariant — not the one specified, and the reason was measured The task's §8.4 asked for *"the version the hub tells machines to install must be downloadable"*. **CI cannot see that**, measured rather than assumed (P-C): | Endpoint | Anonymous | |---|---| | Gitea package **download** | **200** (and **404** for a fake version — it discriminates) | | Gitea **tags** api | **200** | | Gitea package **listing** api | **401** — token required | | Hub `/api/v1/artifacts/` | **401** — per-customer passphrase required | So a credential-free gate can ask *"is this version installable"* but not *"which version is vouched"*. Adding an operator credential to CI to close that is the operator's call, not a gate author's. The implemented invariant — **every `v` tag must have a downloadable package and a tag tree that serves the agent's configs** — needs no credential and **catches all three recorded instances**, because the release script creates the tag and publishes in one act. **What it does not catch, stated rather than assumed away:** the hub vouching a version that was never released at all. Nothing here can see that; it belongs at vouch time in the hub. → **R-184**. ## Proof | Check | Result | |---|---| | `go build ./... && go vet ./...` | OK | | `go test ./...` | **29 packages ok, rc=0** (read separately from any commit) | | `agent_gates.py --fast` | `published` correctly **SKIPPED** — the pre-push hook must not fail because Gitea blinked | | `agent_gates.py` (full) | `reuse-refs` OK, `published` OK | | release script: re-release guard | `ERROR: tag v0.120.0 already exists — releasing over it would make one version name two binaries`, rc=1 | | release script: clean-tree guard | `ERROR: working tree is dirty — commit and push first`, rc=1 | ### Red-proof F — both directions - **A tagged-but-unpublished version** (`v9.9.9` created for the purpose): gate **rc=1**, `binary NOT downloadable (HTTP 404 …)`. This is the R-115 shape exactly. - **The gate deregistered from the entry point**, same bad state: `agent_gates.py` → **rc=0, "all agent gates OK"**. Restored → **rc=1, CONVICTED: published**. The guard is what catches it, not something else. ### Scenario F measured on REAL CI, not inferred Runs **69** and **70** are on the **same commit** `0db7766`: | run | state of the repo | CI | |---|---|---| | 69 | no `v9.9.9` | **success** | | 70 | `v9.9.9` tagged, not published | **failure** | Same code, same workflow, one variable — so the gate demonstrably RUNS in CI and fails for exactly the R-115 condition. This also retrospectively explains runs 67/68, which were red in the window when `v9.9.9` first existed. **One deliberate CI failure e-mail reached the operator — that was this proof, not an incident.** I could not read CI's own step log to attribute those runs directly: the Gitea jobs endpoint requires an API token, and the only credential available on this host (`~/.docker/config.json`) is a registry password, which the API rejects. The controlled before/after above replaced that log rather than an assumption standing in for it. `v9.9.9` was deleted afterwards; `git ls-remote --tags` shows only `v0.120.0`. ## Tag convention `v`, at the commit the binary was built from. `v0.120.0` was created retroactively at `cd6e267` — the commit that produced the published binary (sha `a7763d31b55b5ce7…`). `configs/` is byte-identical between that commit and `main`, so nothing about the sixteen fetched files depends on the choice; `cd6e267` is tagged because it is the honest one.