#!/usr/bin/env bash # release-agent.sh — THE way to release a felhom-agent version. One act: build → tag → publish → # verify by independent download. # # WHY THIS EXISTS (R-115). Publishing used to be a step someone had to remember, and it was # forgotten THREE TIMES IN FIVE DAYS: # # * R-111 (2026-07-29) 17 releases v0.97.0-v0.113.0 built and never published, so a new customer # would have installed without the whole R-82 tiered-backup arc, F-CRIT-2 and F-REBOOT. # * 0.114.0 (same afternoon) built, deployed to felhom-pve, never published. # * 0.120.0 (2026-08-03) built, committed and deployed to BOTH demo hosts, never published. A # documented-path reinstall would have silently DOWNGRADED both boxes to the pre-merge # agent — and would have *succeeded* while doing it, because the current `step_grows` # sets SYSDATA_GROW=0 so the older agent's fatal mp1 resize never fires. # # R-111's own closing line said publishing should join the release train rather than stay a # remembered step. It closed SHIPPED without that leg, and the leg recurred the same afternoon — # which is the evidence that a note is not a mechanism. This file is the mechanism. The # documentation now points here instead of at a raw `go build` line, so there is ONE documented way # to release and it cannot complete without publishing. # # WHY IT TAGS (R-183). Since felhom-host-install.sh pins its sixteen agent-config fetches to # `raw/tag/v`, a released version without a git tag 404s a box mid-install, as root, on a # virgin machine. The tag and the package are two halves of one release and are created together. # # WHY IT DOES NOT VOUCH. Vouching is what points machines at a version, and it stays the operator's # deliberate act — the same prove-then-vouch principle that governed the golden two sessions ago. # This script prints the version and sha to vouch; a human decides when. # # Usage: # GITEA_USER=admin GITEA_TOKEN= ./scripts/release-agent.sh # # Env: GITEA_USER/GITEA_TOKEN (package write) — same credentials publish-agent.sh already takes. # GITEA_BASE / GITEA_OWNER override the defaults. # RELEASE_ALLOW_DIRTY=1 skips the clean-tree gate (for a rehearsal; never for a real release). set -euo pipefail GITEA_BASE="${GITEA_BASE:-https://gitea.dooplex.hu}" GITEA_OWNER="${GITEA_OWNER:-admin}" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" die() { echo "[release-agent] ERROR: $*" >&2; exit 1; } log() { echo "[release-agent] $*" >&2; } VERSION="${1:-}" [[ -n "$VERSION" ]] || die "version required (usage: GITEA_USER=.. GITEA_TOKEN=.. $0 )" [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "version must be bare semver X.Y.Z (got '$VERSION')" TAG="v$VERSION" cd "$REPO_ROOT" # ── 1. Clean-tree gate ────────────────────────────────────────────────────────────────────────── # An unpushed change does not exist. Releasing a dirty tree publishes a binary whose source nobody # else can obtain, and tags a commit that does not contain what was built. if [[ "${RELEASE_ALLOW_DIRTY:-0}" != "1" ]]; then [[ -z "$(git status --porcelain)" ]] || die "working tree is dirty — commit and push first" local_head="$(git rev-parse HEAD)" git fetch -q origin main [[ "$local_head" == "$(git rev-parse origin/main)" ]] \ || die "HEAD != origin/main — push first (an unpushed change does not exist)" fi # ── 2. Refuse to re-release a version that already exists ─────────────────────────────────────── # Silently overwriting a published artifact is how "the same version" comes to mean two different # binaries on two different boxes. if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then die "tag $TAG already exists — releasing over it would make one version name two binaries" fi existing="$(curl -fsS -o /dev/null -w '%{http_code}' \ "$GITEA_BASE/api/packages/$GITEA_OWNER/generic/felhom-agent/$VERSION/felhom-agent" 2>/dev/null || true)" [[ "$existing" != "200" ]] || die "version $VERSION is ALREADY PUBLISHED — bump the version instead" # ── 3. Build ──────────────────────────────────────────────────────────────────────────────────── BIN="$(mktemp -t felhom-agent-XXXXXX)" trap 'rm -f "$BIN"' EXIT log "building $VERSION …" # REPRODUCIBLE BY CONSTRUCTION (R-186). The sha printed below is the one the operator vouches, and # until now nobody could rebuild it to check: `go build` stamps a module version derived from VCS # state, so a build made BEFORE the tag exists and a rebuild made after it are different binaries. # Measured 2026-08-03 at this commit — same source, same toolchain, same ldflags: # # default flags, no tag yet .. 18f4a495… 14 085 464 B (mod v0.121.2-0.2026…-3d0a1d61) # default flags, tagged ...... 4a38f394… 14 085 440 B (mod v0.121.99) # -trimpath -buildvcs=false ... 7ffcdf1d… 14 064 574 B IDENTICAL both ways # # `-buildvcs=false` removes the stamp — nothing in this repo reads it (no `ReadBuildInfo` caller, # verified) and the version comes from the explicit ldflag below, which is where it belongs. # `-trimpath` removes absolute build paths, so a rebuild from a different checkout directory also # matches. Neither is a sequencing trick: the property no longer depends on WHEN the build happens. # # CGO is deliberately left at its default. publish-agent.sh's fallback build used to force # CGO_ENABLED=0 and therefore produced a DIFFERENT binary (13 990 236 B, 74 KB smaller) for the same # version — one version name, two binaries, by whichever entry point was used. Both now build the # same way; if that ever has to change, change it in BOTH or the guarantee is gone. go build -trimpath -buildvcs=false -ldflags "-X main.version=$VERSION" -o "$BIN" ./cmd/felhom-agent \ || die "go build failed" built_ver="$("$BIN" --version 2>/dev/null | awk '{print $2}')" [[ "$built_ver" == "$VERSION" ]] \ || die "the built binary reports '$built_ver', not '$VERSION' — the ldflag did not take" BUILT_SHA="$(sha256sum "$BIN" | awk '{print $1}')" log "built ok: sha256 $BUILT_SHA" # ── 4. Tag LOCALLY (the push comes after the publish — see step 6) ────────────────────────────── # # THE ORDER CHANGED, AND ONLY THE PUSH MOVED (R-188, 2026-08-03). # # It used to be tag → push tag → publish, and the reason written here was sound: a tag with no # package is caught by scripts/check-published-versions.py, a package with no tag is invisible to it, # because the Gitea package LISTING api needs a token the gate does not have. # # What that reasoning missed is that the tag PUSH is what wakes CI (`on: [push]`), so the gate ran in # the seconds between the tag becoming visible and the package existing — and correctly failed. Every # correct release had roughly a coin-flip chance of emailing the operator a failure for a release # that worked. Measured across two releases in one session: runs 12/13 (v0.121.0) and 17/18 # (v0.121.1), same sha each time, opposite results. R-168 made that mail the thing that cannot be # missed; a mail that is wrong half the time is one you stop reading, and then the real one goes too. # # So the tag is still created HERE, before anything is published — the build and the tag still # describe the same commit, and a failed publish leaves a purely local tag that never misled anyone. # It simply becomes VISIBLE (to CI, and to any installer fetching raw/tag/…) only once the package # is downloadable. The invariant the old order protected is not traded away: it is asserted directly # by the gate's new converse probe (a published version with no tag FAILS), so both directions are # now checked rather than one being arranged for. log "tagging $TAG at $(git rev-parse --short HEAD) …" git tag -a "$TAG" -m "agent $TAG Released by scripts/release-agent.sh. sha256 of the published binary: $BUILT_SHA felhom-host-install.sh fetches this version's config files from raw/tag/$TAG/configs/, so this tag is part of the released artifact, not a bookmark (R-183)." # ── 5. Publish (the existing script; deliberately not reimplemented) ──────────────────────────── log "publishing …" # Invoked through `bash` DELIBERATELY, not as an executable. On 2026-08-03 the first real release # through this script died here — `publish-agent.sh` has been mode 0644 since it was created on # 2026-06-28, because every earlier caller ran it as `bash scripts/publish-agent.sh`. So the one leg # R-115 exists to make unforgettable was, on its first use, unrunnable. The mode bit is restored in # the same commit; this line makes the release independent of it, because a file mode is exactly the # kind of thing that is lost again by a checkout, an archive, or a copy. if ! bash "$REPO_ROOT/scripts/publish-agent.sh" "$VERSION" "$BIN"; then # The tag is LOCAL-ONLY at this point, so a failed publish must not leave one behind: the next # attempt would die at step 2's "tag $TAG already exists" and read as "this version is already # released", which would be exactly backwards. Only remove it if nothing was in fact published — # if a package DOES exist, the tag is wanted and must be pushed, not deleted. now_published="$(curl -fsS -o /dev/null -w '%{http_code}' \ "$GITEA_BASE/api/packages/$GITEA_OWNER/generic/felhom-agent/$VERSION/felhom-agent" 2>/dev/null || true)" if [[ "$now_published" == "200" ]]; then log "publish reported failure but the package IS downloadable — keeping the local tag; push it with: git push origin $TAG" else git tag -d "$TAG" >/dev/null 2>&1 && log "removed the local-only tag $TAG so the release can be retried" fi die "publish failed" fi # ── 6. Push the tag, now that the package exists ──────────────────────────────────────────────── # This is the step that makes the release VISIBLE — to CI, and to every `raw/tag/v/` fetch # the installer makes. It runs last of the two so CI can never see a tag whose package is not there. # # If it fails, the release is HALF DONE and must be said so loudly: the package is published and the # tag exists only in this clone, which is precisely the orphan the gate's converse probe now catches. # The recovery is one line and it is printed rather than described. log "pushing $TAG …" if ! git push origin "$TAG"; then cat >&2 </dev/null || true)" [[ "$cfg_code" == "200" ]] \ || die "tag $TAG does not serve configs/felhom-agent.service (HTTP $cfg_code) — a box would 404 mid-install" cat <