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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6330609a75
commit
5549736706
@@ -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})"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 ))"
|
||||||
Reference in New Issue
Block a user