5.3 KiB
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<version>/ (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/<customer> |
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<semver> 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.9created 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<semver>, 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.