From 5549736706df0e2f492bd06c2e88445cba367c05 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 9 Nov 2026 00:34:50 -0500 Subject: [PATCH] Releases: VERSION + automatic patch tags + promote; updater installs newer versions (node-26#49) VERSION holds MAJOR.MINOR (1.1), edited by hand. Every push to main is tagged vMAJOR.MINOR.PATCH by the "Tag release" workflow, with PATCH counted as an integer by scripts/next-version.sh. "Promote release" moves the major tag (v1) to a tested release and refuses to move it backwards. drb-update.sh: a newer release on the node's channel is now a reason to update by itself. REF=latest follows the highest vX.Y.Z tag (test nodes); REF=v1 follows the promoted tag (customer nodes). Versions compare per field as integers (1.10.0 > 1.9.3, never string or float order). It never downgrades, and an untagged target still needs a request. After an update it writes DRB_VERSION to .env; the edge node reports it in checkin. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/promote.yml | 39 +++++++++++ .gitea/workflows/version-tag.yml | 31 +++++++++ VERSION | 1 + drb-edge-node/app/config.py | 3 + drb-edge-node/app/internal/mqtt_manager.py | 1 + drb-edge-node/tests/test_host_updater.py | 78 +++++++++++++++++++++- scripts/drb-update.sh | 70 ++++++++++++++++--- scripts/next-version.sh | 20 ++++++ 8 files changed, 234 insertions(+), 9 deletions(-) create mode 100644 .gitea/workflows/promote.yml create mode 100644 .gitea/workflows/version-tag.yml create mode 100644 VERSION create mode 100644 scripts/next-version.sh diff --git a/.gitea/workflows/promote.yml b/.gitea/workflows/promote.yml new file mode 100644 index 0000000..4633d5b --- /dev/null +++ b/.gitea/workflows/promote.yml @@ -0,0 +1,39 @@ +name: Promote release + +# node-26#49: move the major tag (v1) to a tested release. Customer nodes +# (updater REF=v1) install it on their next timer run, because its version is +# numerically higher than what they run. Never moves it backwards. + +on: + workflow_dispatch: + inputs: + version: + description: "Release to promote, e.g. 1.1.3 (must already be tagged v1.1.3)" + required: true + +jobs: + promote: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Move the major tag + env: + VERSION: ${{ inputs.version }} + run: | + [[ "$VERSION" =~ ^([0-9]+)\.[0-9]+\.[0-9]+$ ]] || { echo "version must be MAJOR.MINOR.PATCH, got '$VERSION'"; exit 1; } + MAJOR="${BASH_REMATCH[1]}" + git fetch --force --tags origin + git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null || { echo "tag v$VERSION does not exist"; exit 1; } + # Refuse to go backwards: compare with the release v$MAJOR points at now. + CUR="$(git tag --points-at "v$MAJOR" 2>/dev/null | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1 || true)" + if [ -n "$CUR" ]; then + HIGHEST="$(printf '%s\n%s\n' "$CUR" "$VERSION" | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)" + [ "$HIGHEST" = "$VERSION" ] || { echo "v$MAJOR is at $CUR, newer than $VERSION — refusing to roll back here"; exit 1; } + fi + git tag -f "v$MAJOR" "v$VERSION^{commit}" + git push --force origin "refs/tags/v$MAJOR" + echo "v$MAJOR -> $VERSION (was ${CUR:-untagged})" diff --git a/.gitea/workflows/version-tag.yml b/.gitea/workflows/version-tag.yml new file mode 100644 index 0000000..5cae703 --- /dev/null +++ b/.gitea/workflows/version-tag.yml @@ -0,0 +1,31 @@ +name: Tag release + +# node-26#49: every push to main is a release, vMAJOR.MINOR.PATCH (see +# scripts/next-version.sh). Test nodes (updater REF=latest) install the +# highest tag on their next timer run; customer nodes (REF=v1) wait for the +# Promote workflow. + +on: + push: + branches: [main] + +concurrency: + group: version-tag + cancel-in-progress: false + +jobs: + tag: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Tag this commit + run: | + git fetch --force --tags origin + TAG="$(bash scripts/next-version.sh)" + if [ -z "$TAG" ]; then echo "already tagged"; exit 0; fi + git tag "$TAG" + git push origin "refs/tags/$TAG" + echo "released $TAG" diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..9459d4b --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.1 diff --git a/drb-edge-node/app/config.py b/drb-edge-node/app/config.py index 1db01b5..e9b7db0 100644 --- a/drb-edge-node/app/config.py +++ b/drb-edge-node/app/config.py @@ -6,6 +6,9 @@ class Settings(BaseSettings): # Node identity node_id: str node_name: str = "Unnamed Node" + # The release installed on this node (node-26#49). The host updater writes + # DRB_VERSION to .env after each update; "untagged" before the first one. + drb_version: str = "untagged" node_lat: float = 0.0 node_lon: float = 0.0 diff --git a/drb-edge-node/app/internal/mqtt_manager.py b/drb-edge-node/app/internal/mqtt_manager.py index 576e993..7799114 100644 --- a/drb-edge-node/app/internal/mqtt_manager.py +++ b/drb-edge-node/app/internal/mqtt_manager.py @@ -378,6 +378,7 @@ class MQTTManager: "enforce_override_timeout": config.enforce_override_timeout, "secondary_sdr_mode": config.secondary_sdr_mode, "secondary_sdr_priority": config.secondary_sdr_priority, + "version": settings.drb_version, } payload["sdr_pins"] = config.sdr_pins # Conventional channels (node-26#24): name/freq/NAC and whether the diff --git a/drb-edge-node/tests/test_host_updater.py b/drb-edge-node/tests/test_host_updater.py index ddb33f2..4c00cf4 100644 --- a/drb-edge-node/tests/test_host_updater.py +++ b/drb-edge-node/tests/test_host_updater.py @@ -44,6 +44,11 @@ case "$1" in *:docker-compose.yml) cat "$STUB_REPO/docker-compose.yml" ;; esac ;; checkout) echo "${@: -1}" > "$STUB/head" ;; + tag) + if [ "$2" = --points-at ]; then + if [ "$3" = HEAD ]; then cat "$STUB/tags_head" 2>/dev/null; else cat "$STUB/tags_new" 2>/dev/null; fi + else cat "$STUB/tags_all" 2>/dev/null; fi + exit 0 ;; *) exit 0 ;; esac ''' @@ -121,7 +126,7 @@ def test_no_request_means_checkin_only(env): calls = _calls(env) assert "update-check" in calls assert "docker" not in calls - assert "fetch" not in calls + assert "checkout" not in calls def test_c2_request_updates_once_and_reports(env): @@ -248,3 +253,74 @@ def test_target_without_pinning_still_updates(env): assert (env["stub"] / "head").read_text().strip() == NEW assert _pulled(_calls(env)) assert "DRB_TAG_" not in (env["inst"] / ".env").read_text() + + +# --------------------------------------------------------------------------- +# Releases (node-26#49): a newer version on the node's channel is a reason to +# update by itself, and versions compare per field as integers. +# --------------------------------------------------------------------------- + +def _channel(env, ref, all_tags, target_tags, head_tags): + env["conf"].write_text(env["conf"].read_text().replace("REF=v1", f"REF={ref}")) + (env["stub"] / "tags_all").write_text("\n".join(all_tags) + "\n") + (env["stub"] / "tags_new").write_text("\n".join(target_tags) + "\n") + (env["stub"] / "tags_head").write_text("\n".join(head_tags) + "\n") + + +def test_latest_channel_installs_a_newer_release_unasked(env): + # 1.1.10 is newer than 1.1.9: compared as integers, not strings or floats. + _channel(env, "latest", ["v1", "v1.1.9", "v1.1.10", "v1.1.2"], ["v1.1.10"], ["v1.1.9"]) + r = _run(env) + assert r.returncode == 0, r.stderr + r.stdout + assert "release 1.1.10 on latest (installed 1.1.9)" in r.stdout + assert (env["stub"] / "head").read_text().strip() == NEW + assert "DRB_VERSION=1.1.10" in (env["inst"] / ".env").read_text() + + +def test_never_downgrades(env): + _channel(env, "latest", ["v1.1.9", "v1.2.0"], ["v1.1.9"], ["v1.2.0"]) + r = _run(env) + assert r.returncode == 0, r.stderr + assert "docker" not in _calls(env) + + +def test_same_release_is_not_reinstalled(env): + _channel(env, "v1", ["v1", "v1.1.3"], ["v1", "v1.1.3"], ["v1", "v1.1.3"]) + assert _run(env).returncode == 0 + assert "docker" not in _calls(env) + + +def test_promoted_major_tag_installs_on_stable_nodes(env): + _channel(env, "v1", ["v1", "v1.1.0", "v1.1.4"], ["v1", "v1.1.4"], ["v1.1.0"]) + r = _run(env) + assert r.returncode == 0, r.stderr + r.stdout + assert "release 1.1.4 on v1" in r.stdout + assert (env["stub"] / "head").read_text().strip() == NEW + + +def test_untagged_target_still_needs_a_request(env): + """v1 before its first promotion points at a commit with no release tag.""" + _channel(env, "v1", ["v1"], ["v1"], []) + assert _run(env).returncode == 0 + assert "docker" not in _calls(env) + + +def test_next_version_counts_patches_as_integers(tmp_path): + script = REPO_ROOT / "scripts" / "next-version.sh" + g = ["git", "-c", "user.email=t@t", "-c", "user.name=t", "-C", str(tmp_path)] + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + (tmp_path / "VERSION").write_text("1.1\n") + subprocess.run(g + ["commit", "-q", "--allow-empty", "-m", "a"], check=True) + + def nxt(): + return subprocess.run(["bash", str(script)], cwd=tmp_path, capture_output=True, text=True, + check=True).stdout.strip() + + assert nxt() == "v1.1.0" + for t in ("v1.1.0", "v1.1.9", "v1.0.30"): + subprocess.run(g + ["tag", t], check=True) + assert nxt() == "", "a commit that already has a release gets no second one" + subprocess.run(g + ["commit", "-q", "--allow-empty", "-m", "b"], check=True) + assert nxt() == "v1.1.10" + (tmp_path / "VERSION").write_text("1.2\n") + assert nxt() == "v1.2.0" diff --git a/scripts/drb-update.sh b/scripts/drb-update.sh index 6ee7e01..aa56a49 100644 --- a/scripts/drb-update.sh +++ b/scripts/drb-update.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash # # DRB node host updater (node-26#15). Runs as root from systemd: -# drb-update.timer -> every ~15 min: HTTPS check-in with C2; update if asked +# drb-update.timer -> every ~15 min: HTTPS check-in with C2; update if asked, +# or if the configured channel has a newer release +# (node-26#49: REF=latest follows the highest vX.Y.Z +# tag, REF=v1 follows the promoted major tag) # drb-update.path -> fires when the edge-node container drops # /configs/update-requested (MQTT node_update) # Both start drb-update.service, which runs this script. install-updater.sh @@ -26,6 +29,42 @@ # very file mid-run cannot change what is executing. set -euo pipefail +# ── Release versions (node-26#49) ───────────────────────────────────────── +# Versions are MAJOR.MINOR.PATCH and every comparison is per field, as +# integers: 1.10.0 > 1.9.3. Never string or float order ("1.10" < "1.9" as a +# float, "1.10.0" < "1.9.3" as a string). +semver_sort() { sort -t. -k1,1n -k2,2n -k3,3n; } + +semver_gt() { # semver_gt A B -> true when A is newer than B + local a b i + IFS=. read -r -a a <<< "$1"; IFS=. read -r -a b <<< "$2" + for i in 0 1 2; do + (( 10#${a[i]:-0} > 10#${b[i]:-0} )) && return 0 + (( 10#${a[i]:-0} < 10#${b[i]:-0} )) && return 1 + done + return 1 +} + +version_at() { # highest X.Y.Z release tagged on a commit, or nothing + git tag --points-at "$1" 2>/dev/null | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + | sed 's/^v//' | semver_sort | tail -1 || true +} + +resolve_target() { # REF -> a ref name git can resolve, or fail + local ref="$1" top + if [ "$ref" = latest ]; then + top="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sed 's/^v//' | semver_sort | tail -1 || true)" + [ -n "$top" ] || return 1 + echo "refs/tags/v$top" + elif git show-ref --verify -q "refs/remotes/origin/$ref"; then + echo "refs/remotes/origin/$ref" + elif git show-ref --verify -q "refs/tags/$ref"; then + echo "refs/tags/$ref" + else + return 1 + fi +} + main() { local CONF="${DRB_UPDATER_CONF:-/etc/drb/updater.conf}" local STATE_DIR="${DRB_UPDATER_STATE:-/var/lib/drb-update}" @@ -118,6 +157,22 @@ main() { new_request="$requested" reason="${reason:-C2 request $requested}" fi + # A newer release on this node's channel is a reason on its own (node-26#49). + # Only ever forward, and only between tagged releases: an untagged target + # (a branch REF, or v1 before its first promotion) still needs a request. + if [ -z "$reason" ]; then + local origin_url target tv iv + origin_url="$(git remote get-url origin 2>/dev/null || true)" + if [ "$origin_url" = "$REPO_URL" ] \ + && git -c protocol.allow=never -c protocol.https.allow=always fetch -q --force --tags --prune origin \ + && target="$(resolve_target "$REF")"; then + tv="$(version_at "$(git rev-parse "$target^{commit}")")" + iv="$(version_at HEAD)" + if [ -n "$tv" ] && { [ -z "$iv" ] || semver_gt "$tv" "$iv"; }; then + reason="release $tv on $REF (installed ${iv:-untagged})" + fi + fi + fi [ -n "$reason" ] || { log "no update requested"; return 0; } # ── Update ───────────────────────────────────────────────────────────── @@ -145,13 +200,7 @@ do_update() { git -c protocol.allow=never -c protocol.https.allow=always fetch --force --tags --prune origin || return 1 local target - if git show-ref --verify -q "refs/remotes/origin/$REF"; then - target="refs/remotes/origin/$REF" - elif git show-ref --verify -q "refs/tags/$REF"; then - target="refs/tags/$REF" - else - err "ref '$REF' not found on origin"; return 1 - fi + target="$(resolve_target "$REF")" || { err "ref '$REF' not found on origin"; return 1; } local old new old="$(git rev-parse HEAD)"; new="$(git rev-parse "$target^{commit}")" @@ -206,6 +255,11 @@ do_update() { drb_pin_image_tags "$INSTALL_DIR/.env" "$INSTALL_DIR" "$new" || { err "could not write image pins to .env"; return 1; } fi + # The release this node now runs, for its checkin (node-26#49). + local ver; ver="$(version_at "$new")" + sed -i '/^DRB_VERSION=/d' "$INSTALL_DIR/.env" + printf 'DRB_VERSION=%s\n' "${ver:-untagged}" >> "$INSTALL_DIR/.env" + if [ "$BUILD" = 1 ]; then docker compose build || return 1 # tags the local builds with the same pins fi diff --git a/scripts/next-version.sh b/scripts/next-version.sh new file mode 100644 index 0000000..429f15f --- /dev/null +++ b/scripts/next-version.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# node-26#49 — the release tag this commit should get, or nothing if it has one. +# +# VERSION holds MAJOR.MINOR and is edited by hand. PATCH is automatic: one +# more than the highest existing vMAJOR.MINOR.PATCH tag, compared as an +# integer (so .10 follows .9), starting at 0 for a new MAJOR.MINOR. +# +# Usage: scripts/next-version.sh (from the repo root, tags fetched) +set -euo pipefail + +base="$(tr -d ' \r\n' < VERSION)" +[[ "$base" =~ ^[0-9]+\.[0-9]+$ ]] || { echo "VERSION must be MAJOR.MINOR, got '$base'" >&2; exit 1; } +pattern="^v${base//./\\.}\\.[0-9]+$" + +if git tag --points-at HEAD | grep -qE "$pattern"; then + exit 0 # this commit is already released +fi + +last="$(git tag -l "v${base}.*" | grep -E "$pattern" | sed "s/^v${base}\.//" | sort -n | tail -1 || true)" +echo "v${base}.$(( ${last:--1} + 1 ))"