Releases: VERSION + automatic patch tags + promote; updater installs newer versions (node-26#49)
Tag release / tag (push) Successful in 8s
CI / lint (push) Successful in 8s
CI / test-op25 (push) Successful in 16s
CI / test (push) Successful in 49s
Build edge-node / build (push) Successful in 1m2s

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 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-11-09 00:34:50 -05:00
co-authored by Claude Opus 5.5
parent 6330609a75
commit 5549736706
8 changed files with 234 additions and 9 deletions
+39
View File
@@ -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})"
+31
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
1.1
+3
View File
@@ -6,6 +6,9 @@ class Settings(BaseSettings):
# Node identity # Node identity
node_id: str node_id: str
node_name: str = "Unnamed Node" 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_lat: float = 0.0
node_lon: float = 0.0 node_lon: float = 0.0
@@ -378,6 +378,7 @@ class MQTTManager:
"enforce_override_timeout": config.enforce_override_timeout, "enforce_override_timeout": config.enforce_override_timeout,
"secondary_sdr_mode": config.secondary_sdr_mode, "secondary_sdr_mode": config.secondary_sdr_mode,
"secondary_sdr_priority": config.secondary_sdr_priority, "secondary_sdr_priority": config.secondary_sdr_priority,
"version": settings.drb_version,
} }
payload["sdr_pins"] = config.sdr_pins payload["sdr_pins"] = config.sdr_pins
# Conventional channels (node-26#24): name/freq/NAC and whether the # Conventional channels (node-26#24): name/freq/NAC and whether the
+77 -1
View File
@@ -44,6 +44,11 @@ case "$1" in
*:docker-compose.yml) cat "$STUB_REPO/docker-compose.yml" ;; *:docker-compose.yml) cat "$STUB_REPO/docker-compose.yml" ;;
esac ;; esac ;;
checkout) echo "${@: -1}" > "$STUB/head" ;; 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 ;; *) exit 0 ;;
esac esac
''' '''
@@ -121,7 +126,7 @@ def test_no_request_means_checkin_only(env):
calls = _calls(env) calls = _calls(env)
assert "update-check" in calls assert "update-check" in calls
assert "docker" not 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): 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 (env["stub"] / "head").read_text().strip() == NEW
assert _pulled(_calls(env)) assert _pulled(_calls(env))
assert "DRB_TAG_" not in (env["inst"] / ".env").read_text() 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"
+62 -8
View File
@@ -1,7 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# DRB node host updater (node-26#15). Runs as root from systemd: # 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 # drb-update.path -> fires when the edge-node container drops
# <install>/configs/update-requested (MQTT node_update) # <install>/configs/update-requested (MQTT node_update)
# Both start drb-update.service, which runs this script. install-updater.sh # 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. # very file mid-run cannot change what is executing.
set -euo pipefail 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() { main() {
local CONF="${DRB_UPDATER_CONF:-/etc/drb/updater.conf}" local CONF="${DRB_UPDATER_CONF:-/etc/drb/updater.conf}"
local STATE_DIR="${DRB_UPDATER_STATE:-/var/lib/drb-update}" local STATE_DIR="${DRB_UPDATER_STATE:-/var/lib/drb-update}"
@@ -118,6 +157,22 @@ main() {
new_request="$requested" new_request="$requested"
reason="${reason:-C2 request $requested}" reason="${reason:-C2 request $requested}"
fi 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; } [ -n "$reason" ] || { log "no update requested"; return 0; }
# ── Update ───────────────────────────────────────────────────────────── # ── Update ─────────────────────────────────────────────────────────────
@@ -145,13 +200,7 @@ do_update() {
git -c protocol.allow=never -c protocol.https.allow=always fetch --force --tags --prune origin || return 1 git -c protocol.allow=never -c protocol.https.allow=always fetch --force --tags --prune origin || return 1
local target local target
if git show-ref --verify -q "refs/remotes/origin/$REF"; then target="$(resolve_target "$REF")" || { err "ref '$REF' not found on origin"; return 1; }
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
local old new local old new
old="$(git rev-parse HEAD)"; new="$(git rev-parse "$target^{commit}")" 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; } drb_pin_image_tags "$INSTALL_DIR/.env" "$INSTALL_DIR" "$new" || { err "could not write image pins to .env"; return 1; }
fi 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 if [ "$BUILD" = 1 ]; then
docker compose build || return 1 # tags the local builds with the same pins docker compose build || return 1 # tags the local builds with the same pins
fi fi
+20
View File
@@ -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 ))"