#!/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/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 && 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())