Files
felhom-agent/scripts/check-release-complete.py
T
admin 53d047a6c1
gates / gates (push) Successful in 17s
Two guards, one number: bound the published check to the retention it must live with
Gates only. No release, no version bump, no binary published; the agent stays
v0.128.0 at 28ba8593b8 and nothing on a customer's machine changes.

THE COUPLING DEFECT. The registry stopped serving 0.120.0 and older while
check-published-versions.py demanded every tag still be downloadable. Both rules
are sensible and together they are impossible, so CI went red at a commit whose
own run had been GREEN the day before -- and would have gone red again at the
next publish when 0.121.0 was evicted. scripts/retention-policy.json is now THE
number and both readers take it from there.

WHAT CI NO LONGER COVERS, and it prints this on every run rather than leaving it
to be discovered: a released version older than the retention window is no longer
asserted downloadable. Its git tag and its config tree ARE still asserted -- only
the binary's presence is dropped. A missing policy file is INCONCLUSIVE (exit 2),
never silently unbounded.

THE NUMBER IS NOT A LOCATED RULING and the file says so in its own header. Ten is
what the registry demonstrably holds; no register row records a prune, R-210 says
"Nothing was deleted; this is a list, not an action" and concerns local Docker
images, and container packages hold 19 each. The principled bound is the hub's
vouched min_agent floor -- nothing can install below it -- and that is the
recorded follow-up.

check-release-complete.py is the tag half as a machine. release-agent.sh already
warned that "a released version without a git tag 404s a box mid-install, as
root" and the step was still missed, so this is a gate and not a reminder. Legs
1-2 need no network and run in --fast, so the pre-push hook is the earliest
catch. Red-proved by repointing the CHANGELOG head at an unreleased v0.129.0:
both legs convicted and each named its fix command.

Three controls run: green at 10 naming what it dropped; widened to 11 the evicted
version re-enters and convicts; policy removed gives INCONCLUSIVE naming the path.
2026-08-09 19:05:05 +02:00

125 lines
5.5 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""check-release-complete.py — the version at the head of CHANGELOG.md is a COMPLETE release.
THE DEFECT THIS IS A MACHINE FOR (2026-08-08/09, R-273). Agent v0.128.0 was built, tested,
CHANGELOG'd and published to the package registry — and its git tag was never pushed. The hub then
vouched it, and because felhom-host-install.sh fetches an agent's config files from
`raw/tag/v<version>/configs/`, EVERY fresh install and every reinstall died at step 5 of 8, as root,
on a virgin machine, for the better part of a day.
`scripts/release-agent.sh` already warns about exactly this, in as many words:
"a released version without a git tag 404s a box mid-install, as root"
The warning was there, it was correct, and the step was still missed. **So the fix is a machine and
not a reminder** — that is the whole point of this file.
WHAT IT ASSERTS, for the newest `## vX.Y.Z` in CHANGELOG.md:
1. a git tag `vX.Y.Z` EXISTS, and
2. it points at a commit that is an ANCESTOR OF (or equal to) the tip it was released from — a tag
parked on an unrelated commit is not a release, and
3. the generic package for X.Y.Z is DOWNLOADABLE.
(3) needs the network. (1) and (2) do not, and they are the half that actually failed — so this gate
is useful offline and says so rather than going quiet.
EXIT CODES, matching this repo's other gates: 0 clean, 1 convicted, 2 inconclusive. An unreachable
registry is INCONCLUSIVE for leg 3 only; legs 1 and 2 still run and can still convict.
"""
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GITEA_BASE = os.environ.get("GITEA_BASE", "https://gitea.dooplex.hu").rstrip("/")
OWNER, PKG = "admin", "felhom-agent"
HEAD_RE = re.compile(r"^##\s+v?(\d+\.\d+\.\d+)\b", re.M)
def git(*args):
return subprocess.run(("git",) + args, cwd=ROOT, capture_output=True, text=True)
def head_version():
ch = os.path.join(ROOT, "CHANGELOG.md")
if not os.path.exists(ch):
return None
m = HEAD_RE.search(open(ch, encoding="utf-8").read())
return m.group(1) if m else None
def main():
print("check-release-complete — the newest CHANGELOG version is a complete release")
v = head_version()
if not v:
print(" no '## vX.Y.Z' heading in CHANGELOG.md — nothing to check, and nothing proven")
return 0
tag = "v" + v
print(" newest CHANGELOG version: %s" % tag)
problems, inconclusive = [], []
# ---- leg 1 + 2: the tag, and where it points. Offline-capable. -----------------------------
r = git("rev-parse", "-q", "--verify", "refs/tags/%s^{commit}" % tag)
if r.returncode != 0:
# A shallow CI clone has no tags of its own; ask the remote before convicting, so this
# gate does not fire on a clone shape rather than on a real defect.
ls = git("ls-remote", "--tags", "origin", "refs/tags/%s" % tag)
if ls.returncode != 0:
inconclusive.append("cannot reach origin to look for tag %s: %s"
% (tag, ls.stderr.strip()[:120]))
elif not ls.stdout.strip():
problems.append(
"TAG %s DOES NOT EXIST. The installer fetches this version's configs from\n"
" %s/%s/felhom-agent/raw/tag/%s/configs/ — without the tag every install\n"
" 404s mid-run, as root. Fix: git tag -a %s <released-commit> && git push origin %s"
% (tag, GITEA_BASE, OWNER, tag, tag, tag))
else:
print(" ok tag %s exists on origin (not in this shallow clone)" % tag)
else:
sha = r.stdout.strip()
anc = git("merge-base", "--is-ancestor", sha, "HEAD")
if anc.returncode == 0:
print(" ok tag %s -> %s, an ancestor of HEAD" % (tag, sha[:10]))
else:
problems.append("tag %s points at %s, which is NOT an ancestor of HEAD — a tag parked "
"on an unrelated commit is not a release" % (tag, sha[:10]))
# ---- leg 3: the package. Needs the network. ------------------------------------------------
url = "%s/api/packages/%s/generic/%s/%s/%s" % (GITEA_BASE, OWNER, PKG, v, PKG)
req = urllib.request.Request(url, method="HEAD")
try:
with urllib.request.urlopen(req, timeout=25) as resp:
if resp.status == 200:
print(" ok package %s is downloadable" % v)
else:
problems.append("package %s returned HTTP %s at %s" % (v, resp.status, url))
except urllib.error.HTTPError as e:
if e.code == 404:
problems.append("PACKAGE %s IS NOT PUBLISHED (HTTP 404 at %s).\n"
" Fix: bash scripts/release-agent.sh %s" % (v, url, v))
else:
inconclusive.append("registry returned HTTP %s for %s" % (e.code, v))
except Exception as e:
inconclusive.append("registry unreachable (%s) — leg 3 not checked; legs 1-2 still ran" % e)
if problems:
print("\ncheck-release-complete: INCOMPLETE RELEASE")
for p in problems:
print(" - " + p)
return 1
if inconclusive:
print("\ncheck-release-complete: INCONCLUSIVE — an undetermined result is never a pass")
for i in inconclusive:
print(" - " + i)
return 2
print("\ncheck-release-complete: %s is tagged, placed and published." % tag)
return 0
if __name__ == "__main__":
sys.exit(main())