From f04d163bea10c6a3d4641fc6f76189d334f7768b Mon Sep 17 00:00:00 2001 From: Elektr0Vodka <211697683+Elektr0Vodka@users.noreply.github.com> Date: Mon, 14 Sep 2026 04:05:58 +0200 Subject: [PATCH 1/9] feat(ota): channel-base resolver + arg parser with native tests (cherry picked from commit 52a4ccc39093891b64e3752316fe7c191fd9b9ca) --- platformio.ini | 18 ++++++- src/helpers/OtaChannel.h | 62 ++++++++++++++++++++++ test/test_ota_channel/test_ota_channel.cpp | 34 ++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 src/helpers/OtaChannel.h create mode 100644 test/test_ota_channel/test_ota_channel.cpp diff --git a/platformio.ini b/platformio.ini index 6c012f1e5..d47869081 100644 --- a/platformio.ini +++ b/platformio.ini @@ -177,7 +177,7 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = test_kiss_modem, test_ota_channel build_src_filter = -<*> +<../src/Utils.cpp> @@ -203,3 +203,19 @@ build_src_filter = lib_deps = google/googletest @ 1.17.0 bblanchon/ArduinoJson @ 7.4.3 + +[env:native_ota_channel] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I test/mocks + -I src + -DOTA_MANIFEST_BASE="\"https://stable.example/mqtt/v\"" + -DOTA_MANIFEST_BASE_STABLE="\"https://stable.example/mqtt/v\"" + -DOTA_MANIFEST_BASE_DEV="\"https://dev.example/mqtt/dev/v\"" +test_build_src = yes +test_filter = test_ota_channel +build_src_filter = + -<*> +lib_deps = + google/googletest @ 1.17.0 diff --git a/src/helpers/OtaChannel.h b/src/helpers/OtaChannel.h new file mode 100644 index 000000000..d589854bc --- /dev/null +++ b/src/helpers/OtaChannel.h @@ -0,0 +1,62 @@ +#pragma once +#include +#include + +// OTA release-channel selector, persisted in NodePrefs::ota_channel. +enum OtaChannel : uint8_t { + OTA_CH_NATIVE = 0, // follow the channel this build was made for + OTA_CH_STABLE = 1, + OTA_CH_DEV = 2, +}; + +// Resolve the effective manifest base URL for a channel selector. +// build.sh injects the three bases as compile-time macros: +// OTA_MANIFEST_BASE = this build's native channel (defined on every OTA build) +// OTA_MANIFEST_BASE_STABLE = stable channel +// OTA_MANIFEST_BASE_DEV = dev channel +// stable/dev fall back to the native base when their macro is undefined (legacy/local +// builds that only define OTA_MANIFEST_BASE), so this never returns nullptr on an +// OTA-capable build. On a non-OTA build it returns nullptr. +static inline const char* ota_resolve_base(uint8_t channel) { +#if defined(OTA_MANIFEST_BASE) + switch (channel) { + case OTA_CH_STABLE: +#if defined(OTA_MANIFEST_BASE_STABLE) + return OTA_MANIFEST_BASE_STABLE; +#else + return OTA_MANIFEST_BASE; +#endif + case OTA_CH_DEV: +#if defined(OTA_MANIFEST_BASE_DEV) + return OTA_MANIFEST_BASE_DEV; +#else + return OTA_MANIFEST_BASE; +#endif + case OTA_CH_NATIVE: + default: + return OTA_MANIFEST_BASE; + } +#else + (void)channel; + return nullptr; +#endif +} + +// Human label for a selector (for the `ota branch` report). +static inline const char* ota_channel_name(uint8_t channel) { + switch (channel) { + case OTA_CH_STABLE: return "stable"; + case OTA_CH_DEV: return "dev"; + default: return "native"; + } +} + +// Parse an `ota branch` argument. Returns true and sets *out on a known keyword +// (stable|dev|default; "default" -> native); returns false and leaves *out untouched +// otherwise. +static inline bool ota_parse_channel(const char* arg, uint8_t* out) { + if (strcmp(arg, "stable") == 0) { *out = OTA_CH_STABLE; return true; } + if (strcmp(arg, "dev") == 0) { *out = OTA_CH_DEV; return true; } + if (strcmp(arg, "default") == 0) { *out = OTA_CH_NATIVE; return true; } + return false; +} diff --git a/test/test_ota_channel/test_ota_channel.cpp b/test/test_ota_channel/test_ota_channel.cpp new file mode 100644 index 000000000..699ffb70c --- /dev/null +++ b/test/test_ota_channel/test_ota_channel.cpp @@ -0,0 +1,34 @@ +#include +#include "helpers/OtaChannel.h" + +// The three base URLs are provided as -D macros by the test env (see platformio.ini). +TEST(OtaChannel, ResolvesNativeToBaseMacro) { + EXPECT_STREQ(ota_resolve_base(OTA_CH_NATIVE), "https://stable.example/mqtt/v"); +} +TEST(OtaChannel, ResolvesStable) { + EXPECT_STREQ(ota_resolve_base(OTA_CH_STABLE), "https://stable.example/mqtt/v"); +} +TEST(OtaChannel, ResolvesDev) { + EXPECT_STREQ(ota_resolve_base(OTA_CH_DEV), "https://dev.example/mqtt/dev/v"); +} +TEST(OtaChannel, ParseKnownKeywords) { + uint8_t ch = 99; + EXPECT_TRUE(ota_parse_channel("stable", &ch)); EXPECT_EQ(ch, OTA_CH_STABLE); + EXPECT_TRUE(ota_parse_channel("dev", &ch)); EXPECT_EQ(ch, OTA_CH_DEV); + EXPECT_TRUE(ota_parse_channel("default", &ch)); EXPECT_EQ(ch, OTA_CH_NATIVE); +} +TEST(OtaChannel, ParseRejectsUnknownAndLeavesOutputUntouched) { + uint8_t ch = 7; + EXPECT_FALSE(ota_parse_channel("beta", &ch)); + EXPECT_EQ(ch, 7); +} +TEST(OtaChannel, NameLabels) { + EXPECT_STREQ(ota_channel_name(OTA_CH_NATIVE), "native"); + EXPECT_STREQ(ota_channel_name(OTA_CH_STABLE), "stable"); + EXPECT_STREQ(ota_channel_name(OTA_CH_DEV), "dev"); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 3910489414f00d16f377c5051cae47051f2e1194 Mon Sep 17 00:00:00 2001 From: Elektr0Vodka <211697683+Elektr0Vodka@users.noreply.github.com> Date: Mon, 14 Sep 2026 04:07:12 +0200 Subject: [PATCH 2/9] feat(ota): persist ota_channel selector in NodePrefs (cherry picked from commit db1db5b57ecce408c7441cb6aa70f29608b09603) --- src/helpers/CommonCLI.h | 2 ++ .../test_config_serializer.cpp | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index e0a0a71b8..db77ac8db 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -70,6 +70,7 @@ public: uint8_t path_hash_mode = 0; // which path mode to use when sending uint8_t loop_detect = 0; uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean) + uint8_t ota_channel = 0; // OTA release channel selector: 0=native, 1=stable, 2=dev uint8_t extra_sf[4]; // NOTE: observer settings (MQTT/WiFi/timezone/SNMP/alert) are not in NodePrefs. @@ -182,6 +183,7 @@ protected: def("lat", node_lat); def("lon", node_lon); def("disc_mod", discovery_mod_timestamp); // gates 'since'-filtered DISCOVER replies + def("ota_ch", ota_channel); // OTA release channel: 0=native, 1=stable, 2=dev def("radio", radio); def("bridge", bridge); def("gps", gps); diff --git a/test/test_config_serializer/test_config_serializer.cpp b/test/test_config_serializer/test_config_serializer.cpp index 27c3c8119..1fbe1c8ad 100644 --- a/test/test_config_serializer/test_config_serializer.cpp +++ b/test/test_config_serializer/test_config_serializer.cpp @@ -214,6 +214,35 @@ TEST(NodePrefs, FemGainSettingsRoundTrip) { EXPECT_EQ(1, loaded.radio_fem_txgain); } +TEST(NodePrefs, OtaChannelDefaultsToNative) { + NodePrefs prefs; + EXPECT_EQ(0, prefs.ota_channel); // 0 == native +} + +TEST(NodePrefs, OtaChannelRoundTrip) { + NodePrefs saved; + saved.ota_channel = 2; // dev + + MockPrintStream output; + ASSERT_TRUE(saved.saveSerial(output)); + std::string serialised(reinterpret_cast(output.getBytes()), output.getLength()); + EXPECT_NE(std::string::npos, serialised.find("ota_ch:2")); + + MockInputStream input(serialised.c_str()); + NodePrefs loaded; + loaded.ota_channel = 1; // start different + ASSERT_TRUE(loaded.loadSerial(input)) << serialised; + EXPECT_EQ(2, loaded.ota_channel); +} + +TEST(NodePrefs, OtaChannelMissingKeyKeepsDefault) { + // A /prefs.json written before this field existed has no ota_ch key. + MockInputStream input("{name:\"n\"}"); + NodePrefs loaded; // ota_channel default 0 (native) + ASSERT_TRUE(loaded.loadSerial(input)); + EXPECT_EQ(0, loaded.ota_channel); +} + // ── main ─────────────────────────────────────────────────────── From 942a19eb63947ec71a7f1769ad74374ae3e2e939 Mon Sep 17 00:00:00 2001 From: Elektr0Vodka <211697683+Elektr0Vodka@users.noreply.github.com> Date: Mon, 14 Sep 2026 04:15:20 +0200 Subject: [PATCH 3/9] feat(ota): pass runtime manifest base through otaFromManifest (cherry picked from commit 0bee84c0546780b2e7147148c32087e92d894ac0) --- examples/simple_repeater/MyMesh.cpp | 3 ++- examples/simple_room_server/MyMesh.cpp | 3 ++- src/MeshCore.h | 2 +- src/helpers/CommonCLI_Observer.cpp | 5 +++-- src/helpers/ESP32Board.cpp | 19 ++++++++++--------- src/helpers/ESP32Board.h | 4 ++-- 6 files changed, 20 insertions(+), 16 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 7e209cfb2..b37896576 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -2,6 +2,7 @@ #include #include // for qsort() #include +#include #if defined(WITH_MQTT_NEIGHBORS) #include // kSyncedClockEpoch #endif @@ -1740,7 +1741,7 @@ void MyMesh::loop() { Serial.println("OTA: aborted, MQTT stop did not complete cleanly - resuming bridge"); otaAlert("OTA aborted: MQTT stop unclean, bridge resumed"); setBridgeState(true); - } else if (!_cli.getBoard()->otaFromManifest(getFirmwareVer(), false, ota_reply)) { + } else if (!_cli.getBoard()->otaFromManifest(ota_resolve_base(_prefs.ota_channel), getFirmwareVer(), false, ota_reply)) { Serial.print("OTA: aborted, resuming bridge - "); Serial.println(ota_reply); char ota_alert_msg[160]; snprintf(ota_alert_msg, sizeof(ota_alert_msg), "OTA aborted: %s", ota_reply); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 9fe5a9c8a..2e97d4917 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,6 +1,7 @@ #include "MyMesh.h" #include #include +#include #if defined(WITH_MQTT_NEIGHBORS) #include // kSyncedClockEpoch #endif @@ -1616,7 +1617,7 @@ void MyMesh::loop() { } char ota_reply[160]; - if (may_flash && !_cli.getBoard()->otaFromManifest(getFirmwareVer(), false, ota_reply)) { + if (may_flash && !_cli.getBoard()->otaFromManifest(ota_resolve_base(_prefs.ota_channel), getFirmwareVer(), false, ota_reply)) { Serial.print("OTA: aborted - "); Serial.println(ota_reply); may_flash = false; } diff --git a/src/MeshCore.h b/src/MeshCore.h index 15ccdc156..32d669739 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -67,7 +67,7 @@ public: // Pull-based OTA: fetch the firmware build for this variant from a baked-in manifest and flash it. // current_ver is the running firmware version string (used to skip if already up to date); when // dry_run is true the build is only reported, not flashed. Observer (ESP32+WiFi) builds only. - virtual bool otaFromManifest(const char* current_ver, bool dry_run, char reply[]) { return false; } + virtual bool otaFromManifest(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) { return false; } // LoRa front-end-module LNA (RX gain) control. Only FEM-equipped boards override // these; others report they can't control it. Driven by NodePrefs.radio_fem_rxgain. diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index c1b7e74a1..c6d44f49c 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -12,6 +12,7 @@ #include #include "CommonCLI.h" +#include "OtaChannel.h" #include "TxtDataHelpers.h" #include "AlertReporter.h" // for alertReporterBannedChannelMatch[Hex]() #include "MQTTObserverValidation.h" // pure input validators (host-testable) @@ -1088,7 +1089,7 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, // MQTT bridge UP: the slim per-variant manifest is tiny, so the fetch only // costs a single TLS handshake (no large JSON doc) — which fits alongside // the live MQTT sessions even on no-PSRAM boards. No bridge bounce needed. - _board->otaFromManifest(_callbacks->getFirmwareVer(), true, reply); + _board->otaFromManifest(ota_resolve_base(_prefs->ota_channel), _callbacks->getFirmwareVer(), true, reply); } else { // `ota update`: cheap pre-check first (plain HTTP, bridge stays up). Only // schedule the real update — which tears the bridge down, flashes, and @@ -1096,7 +1097,7 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, // returns true iff so; otherwise it leaves the explanation (up to date / // cable flash / error) in reply, which we send without disturbing the // bridge or misleading the user with a "Beginning update..." that no-ops. - if (_board->otaFromManifest(_callbacks->getFirmwareVer(), true, reply)) { + if (_board->otaFromManifest(ota_resolve_base(_prefs->ota_channel), _callbacks->getFirmwareVer(), true, reply)) { // reply now holds "update available: -> (N behind|new base)", // where is "vX.Y.Z.B (hash)". Pull out for a friendlier // start message. The "-> " ... trailing " (" framing is produced by diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 120203ab0..36db7fa91 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -162,6 +162,7 @@ static void ota_partitionSignature(char* out, size_t out_sz) { // which stays valid because that function blocks until the worker signals done. struct OtaTaskArgs { ESP32Board* self; + const char* manifest_base; const char* current_ver; bool dry_run; char* reply; @@ -171,18 +172,18 @@ struct OtaTaskArgs { static void ota_task_entry(void* param) { OtaTaskArgs* a = static_cast(param); - a->result = a->self->otaFromManifestImpl(a->current_ver, a->dry_run, a->reply); + a->result = a->self->otaFromManifestImpl(a->manifest_base, a->current_ver, a->dry_run, a->reply); a->done = true; // on a successful `ota update` we reboot before reaching here vTaskDelete(nullptr); } -bool ESP32Board::otaFromManifest(const char* current_ver, bool dry_run, char reply[]) { +bool ESP32Board::otaFromManifest(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) { // The TLS handshake (cert-bundle verify) + JSON parse / HTTPUpdate use far more // stack than the ~8 KB loop task offers — especially when reached via the deep // mesh-receive call chain (it overflows the loopTask canary). Run the work in a // dedicated 24 KB-stack task and block here until it finishes. The big stack is // freed when the task exits; on a successful update the chip reboots inside it. - OtaTaskArgs args = { this, current_ver, dry_run, reply, false, false }; + OtaTaskArgs args = { this, manifest_base, current_ver, dry_run, reply, false, false }; TaskHandle_t handle = nullptr; BaseType_t ok = xTaskCreatePinnedToCore(ota_task_entry, "ota", 24576, &args, 5, &handle, 1); if (ok != pdPASS) { @@ -195,7 +196,7 @@ bool ESP32Board::otaFromManifest(const char* current_ver, bool dry_run, char rep return args.result; } -bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char reply[]) { +bool ESP32Board::otaFromManifestImpl(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) { #if !defined(OTA_MANIFEST_BASE) || !defined(OTA_VARIANT) strcpy(reply, "ERR: OTA not configured (build via build.sh)"); return false; @@ -230,10 +231,10 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char // and the handshake + the bridge both fail). This only reads version info; the // firmware download below (ota update) is always TLS-verified. Requires the // manifest host to serve /v over HTTP (no forced HTTPS redirect). - if (strncmp(OTA_MANIFEST_BASE, "https://", 8) == 0) { - snprintf(murl, sizeof(murl), "http://%s/%s.json", OTA_MANIFEST_BASE + 8, OTA_VARIANT); + if (strncmp(manifest_base, "https://", 8) == 0) { + snprintf(murl, sizeof(murl), "http://%s/%s.json", manifest_base + 8, OTA_VARIANT); } else { - snprintf(murl, sizeof(murl), "%s/%s.json", OTA_MANIFEST_BASE, OTA_VARIANT); + snprintf(murl, sizeof(murl), "%s/%s.json", manifest_base, OTA_VARIANT); } if (!http.begin(murl)) { strcpy(reply, "ERR: manifest connect failed"); @@ -248,7 +249,7 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char mclient.setCACertBundle(rootca_crt_bundle_start); #endif mclient.setTimeout(15000); - snprintf(murl, sizeof(murl), "%s/%s.json", OTA_MANIFEST_BASE, OTA_VARIANT); + snprintf(murl, sizeof(murl), "%s/%s.json", manifest_base, OTA_VARIANT); if (!http.begin(mclient, murl)) { strcpy(reply, "ERR: manifest connect failed"); return false; @@ -417,7 +418,7 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char #endif // OTA_MANIFEST_BASE && OTA_VARIANT } #else -bool ESP32Board::otaFromManifest(const char* current_ver, bool dry_run, char reply[]) { +bool ESP32Board::otaFromManifest(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) { strcpy(reply, "ERR: not supported"); return false; } diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 3ecf6735e..af8197194 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -155,11 +155,11 @@ public: } bool startOTAUpdate(const char* id, char reply[], bool force_ap = false) override; - bool otaFromManifest(const char* current_ver, bool dry_run, char reply[]) override; + bool otaFromManifest(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) override; // Heavy body (TLS + JSON / HTTPUpdate). Runs in a dedicated large-stack task // spawned by otaFromManifest() — public only so that task entry point can call // it; not meant to be invoked directly. - bool otaFromManifestImpl(const char* current_ver, bool dry_run, char reply[]); + bool otaFromManifestImpl(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]); void setInhibitSleep(bool inhibit) { inhibit_sleep = inhibit; From ea541e182a20ee89c977f02e375e0ba77d2caae0 Mon Sep 17 00:00:00 2001 From: Elektr0Vodka <211697683+Elektr0Vodka@users.noreply.github.com> Date: Mon, 14 Sep 2026 04:29:28 +0200 Subject: [PATCH 4/9] feat(ota): add 'ota branch [stable|dev|default]' command (cherry picked from commit 240804999edb284fd276489e6898ccad8a19a8e7) --- src/helpers/CommonCLI_Observer.cpp | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index c6d44f49c..e4d71182d 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -1130,6 +1130,35 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, } #else strcpy(reply, "ERR: online OTA not supported on this build"); +#endif + return true; + } else if (memcmp(command, "ota branch", 10) == 0) { + // Switch (or report) the OTA release channel this device pulls from. The + // selection is persisted (NodePrefs::ota_channel) and resolved to a baked-in + // base URL by ota_resolve_base(); it changes only WHERE updates are fetched, + // never the running image's reported version. Reachable from any admin path, + // same as `ota update`. +#if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_BASE) + const char* arg = command + 10; + while (*arg == ' ') arg++; + if (*arg == 0) { + snprintf(reply, 160, "channel: %s (%s), base %s", + ota_channel_name(_prefs->ota_channel), + _prefs->ota_channel == OTA_CH_NATIVE ? "native" : "override", + ota_resolve_base(_prefs->ota_channel)); + } else { + uint8_t ch; + if (!ota_parse_channel(arg, &ch)) { + strcpy(reply, "ERR: usage ota branch [stable|dev|default]"); + } else { + _prefs->ota_channel = ch; + savePrefs(); + snprintf(reply, 160, "channel set to %s, base %s", + ota_channel_name(ch), ota_resolve_base(ch)); + } + } +#else + strcpy(reply, "ERR: online OTA not supported on this build"); #endif return true; } else if (memcmp(command, "start webconfig", 15) == 0 && (command[15] == 0 || command[15] == ' ')) { From 763ba835c18379784ce37f1dfba9d3941b384310 Mon Sep 17 00:00:00 2001 From: Elektr0Vodka <211697683+Elektr0Vodka@users.noreply.github.com> Date: Mon, 14 Sep 2026 04:29:28 +0200 Subject: [PATCH 5/9] build(ota): bake stable + dev manifest bases into observer builds (cherry picked from commit 29011959170db757ec5364de88f6beeaae82b995) --- build.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/build.sh b/build.sh index 5d6ae4439..a6ad41c63 100755 --- a/build.sh +++ b/build.sh @@ -203,10 +203,18 @@ build_firmware() { # the .ini declarations were removed rather than overridden. OTA_MANIFEST_BASE_URL="${OTA_MANIFEST_BASE_URL:-https://observer.gessaman.com/v}" + # Both named channel bases are baked into EVERY observer build so `ota branch` + # can re-point a device at either channel at runtime. OTA_MANIFEST_BASE above stays + # the build's NATIVE channel (= stable base for stable builds, dev base for dev + # builds), so `ota branch default` resolves correctly. These two must match the + # paths DutchMeshCore-OTA serves (feat/dev-stable-ota-channels): /mqtt/v + /mqtt/dev/v. + OTA_MANIFEST_BASE_STABLE_URL="${OTA_MANIFEST_BASE_STABLE_URL:-https://ota.dutchmeshcore.nl/mqtt/v}" + OTA_MANIFEST_BASE_DEV_URL="${OTA_MANIFEST_BASE_DEV_URL:-https://ota.dutchmeshcore.nl/mqtt/dev/v}" + # add firmware version info to end of existing platformio build flags in environment vars. # OTA_VARIANT is the env name ($1) — it selects this build's slim per-variant manifest # (/.json) that the observer pull-OTA fetches. - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${EMBEDDED_VERSION_STRING}\"' -DOTA_VARIANT='\"$1\"' -DOTA_MANIFEST_BASE='\"${OTA_MANIFEST_BASE_URL}\"'" + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${EMBEDDED_VERSION_STRING}\"' -DOTA_VARIANT='\"$1\"' -DOTA_MANIFEST_BASE='\"${OTA_MANIFEST_BASE_URL}\"' -DOTA_MANIFEST_BASE_STABLE='\"${OTA_MANIFEST_BASE_STABLE_URL}\"' -DOTA_MANIFEST_BASE_DEV='\"${OTA_MANIFEST_BASE_DEV_URL}\"'" # disable debug flags if requested disable_debug_flags From 2633b4267cdabfc351ce8b3aa9c59ab020497cec Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 3 Oct 2026 15:17:46 -0700 Subject: [PATCH 6/9] fix(ota): compare by commit hash when targeting another channel Build counters are per channel, so a cross-channel build number is not comparable: a switch to a channel with a lower counter reported up to date and never happened. Off the native channel, update unless the hash matches. --- src/helpers/ESP32Board.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 36db7fa91..206a3a7c1 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -334,10 +334,14 @@ bool ESP32Board::otaFromManifestImpl(const char* manifest_base, const char* curr bool same_base = (own_base[0] && avail_base[0] && strcmp(own_base, avail_base) == 0); bool have_builds = (own_build >= 0 && avail_build >= 0); bool diff_base = (own_base[0] && avail_base[0] && !same_base); + // Build counters are per channel, so build numbers only compare within the native channel. + bool cross_channel = (strcmp(manifest_base, OTA_MANIFEST_BASE) != 0); int behind = 0; bool up_to_date; - if (same_base && have_builds) { + if (cross_channel) { + up_to_date = hash_equal; + } else if (same_base && have_builds) { behind = avail_build - own_build; up_to_date = (behind <= 0); } else if (diff_base) { @@ -362,6 +366,8 @@ bool ESP32Board::otaFromManifestImpl(const char* manifest_base, const char* curr if (dry_run) { if (up_to_date) { snprintf(reply, 160, "up to date: %s", avail_disp); + } else if (cross_channel) { + snprintf(reply, 160, "update available: %s -> %s (channel switch)%s", own_disp, avail_disp, pc_note); } else if (same_base && have_builds) { snprintf(reply, 160, "update available: %s -> %s (%d behind)%s", own_disp, avail_disp, behind, pc_note); } else if (diff_base) { From 4afb3f7e3f0a03b58b770a6403eba5a25bbf6f87 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 3 Oct 2026 15:17:46 -0700 Subject: [PATCH 7/9] build(ota): point the stable/dev channel bases at observer.gessaman.com --- build.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/build.sh b/build.sh index a6ad41c63..1d6783fb3 100755 --- a/build.sh +++ b/build.sh @@ -206,10 +206,10 @@ build_firmware() { # Both named channel bases are baked into EVERY observer build so `ota branch` # can re-point a device at either channel at runtime. OTA_MANIFEST_BASE above stays # the build's NATIVE channel (= stable base for stable builds, dev base for dev - # builds), so `ota branch default` resolves correctly. These two must match the - # paths DutchMeshCore-OTA serves (feat/dev-stable-ota-channels): /mqtt/v + /mqtt/dev/v. - OTA_MANIFEST_BASE_STABLE_URL="${OTA_MANIFEST_BASE_STABLE_URL:-https://ota.dutchmeshcore.nl/mqtt/v}" - OTA_MANIFEST_BASE_DEV_URL="${OTA_MANIFEST_BASE_DEV_URL:-https://ota.dutchmeshcore.nl/mqtt/dev/v}" + # builds), so `ota branch default` resolves correctly. These must match the + # production (/v) and beta (/beta/v) manifest paths. + OTA_MANIFEST_BASE_STABLE_URL="${OTA_MANIFEST_BASE_STABLE_URL:-https://observer.gessaman.com/v}" + OTA_MANIFEST_BASE_DEV_URL="${OTA_MANIFEST_BASE_DEV_URL:-https://observer.gessaman.com/beta/v}" # add firmware version info to end of existing platformio build flags in environment vars. # OTA_VARIANT is the env name ($1) — it selects this build's slim per-variant manifest From 9c65e198f1ab1b44f608fdb5df25956eb513e9f4 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 3 Oct 2026 15:19:06 -0700 Subject: [PATCH 8/9] feat(ota): prod/beta channel names for ota branch, with stable/dev aliases Report the selected channel and the build's own channel, tag each baked-in base for CI, and list ota branch in the CLI docs and portal autocomplete. --- docs/cli_commands.md | 6 ++ src/helpers/CommonCLI_Observer.cpp | 11 ++-- src/helpers/OtaChannel.h | 68 +++++++++++++--------- test/test_ota_channel/test_ota_channel.cpp | 22 +++++-- webui/index.html | 2 + 5 files changed, 72 insertions(+), 37 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 057fc0e2a..5267f5306 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -88,6 +88,12 @@ This document provides an overview of CLI commands that can be sent to MeshCore - `start ota` — serves the ElegantOTA web upload page on the station IP if joined to a Wi-Fi network, otherwise raises the `MeshCore-OTA` Wi-Fi hotspot. - `start ota ap` — always raises the `MeshCore-OTA` Wi-Fi hotspot, even when joined to a network. Use this when the network applies client isolation and the station IP isn't reachable. +### Switch the OTA release channel (observer builds) + +- `ota branch` — shows the selected channel, the channel this build was made for, and the manifest base `ota check`/`ota update` will use. +- `ota branch prod` (alias `stable`) / `ota branch beta` (alias `dev`) — pull future `ota update`s from that channel. The selection is saved; run `ota update` to switch. +- `ota branch default` — follow the channel this build was made for. + --- ### Erase/Factory Reset diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index e4d71182d..5ef7287a6 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -1132,7 +1132,7 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, strcpy(reply, "ERR: online OTA not supported on this build"); #endif return true; - } else if (memcmp(command, "ota branch", 10) == 0) { + } else if (memcmp(command, "ota branch", 10) == 0 && (command[10] == 0 || command[10] == ' ')) { // Switch (or report) the OTA release channel this device pulls from. The // selection is persisted (NodePrefs::ota_channel) and resolved to a baked-in // base URL by ota_resolve_base(); it changes only WHERE updates are fetched, @@ -1142,18 +1142,17 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, const char* arg = command + 10; while (*arg == ' ') arg++; if (*arg == 0) { - snprintf(reply, 160, "channel: %s (%s), base %s", - ota_channel_name(_prefs->ota_channel), - _prefs->ota_channel == OTA_CH_NATIVE ? "native" : "override", + snprintf(reply, 160, "channel: %s (build: %s), base %s", + ota_channel_name(_prefs->ota_channel), ota_native_channel_name(), ota_resolve_base(_prefs->ota_channel)); } else { uint8_t ch; if (!ota_parse_channel(arg, &ch)) { - strcpy(reply, "ERR: usage ota branch [stable|dev|default]"); + strcpy(reply, "ERR: usage ota branch [prod|beta|default] (aliases: stable, dev)"); } else { _prefs->ota_channel = ch; savePrefs(); - snprintf(reply, 160, "channel set to %s, base %s", + snprintf(reply, 160, "channel set to %s, base %s; run ota update to switch", ota_channel_name(ch), ota_resolve_base(ch)); } } diff --git a/src/helpers/OtaChannel.h b/src/helpers/OtaChannel.h index d589854bc..2918d83d4 100644 --- a/src/helpers/OtaChannel.h +++ b/src/helpers/OtaChannel.h @@ -5,36 +5,44 @@ // OTA release-channel selector, persisted in NodePrefs::ota_channel. enum OtaChannel : uint8_t { OTA_CH_NATIVE = 0, // follow the channel this build was made for - OTA_CH_STABLE = 1, - OTA_CH_DEV = 2, + OTA_CH_STABLE = 1, // production + OTA_CH_DEV = 2, // beta }; // Resolve the effective manifest base URL for a channel selector. // build.sh injects the three bases as compile-time macros: // OTA_MANIFEST_BASE = this build's native channel (defined on every OTA build) -// OTA_MANIFEST_BASE_STABLE = stable channel -// OTA_MANIFEST_BASE_DEV = dev channel +// OTA_MANIFEST_BASE_STABLE = stable (production) channel +// OTA_MANIFEST_BASE_DEV = dev (beta) channel // stable/dev fall back to the native base when their macro is undefined (legacy/local // builds that only define OTA_MANIFEST_BASE), so this never returns nullptr on an // OTA-capable build. On a non-OTA build it returns nullptr. +// +// Each base is stored behind an "ota-base-:" tag so CI can read a binary's +// channels back with `strings` (scripts/verify_ota_channel.sh). Returning a pointer +// into the tagged array keeps the tag referenced, so the linker cannot drop it. +#if defined(OTA_MANIFEST_BASE) +#define OTA_BASE_TAG_NATIVE "ota-base-native:" +#define OTA_BASE_TAG_STABLE "ota-base-stable:" +#define OTA_BASE_TAG_DEV "ota-base-dev:" +#if !defined(OTA_MANIFEST_BASE_STABLE) +#define OTA_MANIFEST_BASE_STABLE OTA_MANIFEST_BASE +#endif +#if !defined(OTA_MANIFEST_BASE_DEV) +#define OTA_MANIFEST_BASE_DEV OTA_MANIFEST_BASE +#endif +static const char ota_tagged_native[] = OTA_BASE_TAG_NATIVE OTA_MANIFEST_BASE; +static const char ota_tagged_stable[] = OTA_BASE_TAG_STABLE OTA_MANIFEST_BASE_STABLE; +static const char ota_tagged_dev[] = OTA_BASE_TAG_DEV OTA_MANIFEST_BASE_DEV; +#endif + static inline const char* ota_resolve_base(uint8_t channel) { #if defined(OTA_MANIFEST_BASE) switch (channel) { - case OTA_CH_STABLE: -#if defined(OTA_MANIFEST_BASE_STABLE) - return OTA_MANIFEST_BASE_STABLE; -#else - return OTA_MANIFEST_BASE; -#endif - case OTA_CH_DEV: -#if defined(OTA_MANIFEST_BASE_DEV) - return OTA_MANIFEST_BASE_DEV; -#else - return OTA_MANIFEST_BASE; -#endif + case OTA_CH_STABLE: return ota_tagged_stable + sizeof(OTA_BASE_TAG_STABLE) - 1; + case OTA_CH_DEV: return ota_tagged_dev + sizeof(OTA_BASE_TAG_DEV) - 1; case OTA_CH_NATIVE: - default: - return OTA_MANIFEST_BASE; + default: return ota_tagged_native + sizeof(OTA_BASE_TAG_NATIVE) - 1; } #else (void)channel; @@ -45,18 +53,26 @@ static inline const char* ota_resolve_base(uint8_t channel) { // Human label for a selector (for the `ota branch` report). static inline const char* ota_channel_name(uint8_t channel) { switch (channel) { - case OTA_CH_STABLE: return "stable"; - case OTA_CH_DEV: return "dev"; - default: return "native"; + case OTA_CH_STABLE: return "prod"; + case OTA_CH_DEV: return "beta"; + default: return "default"; } } +// Label for the channel this build was made for, by matching its native base. +static inline const char* ota_native_channel_name() { + const char* native = ota_resolve_base(OTA_CH_NATIVE); + if (native == nullptr) return "none"; + if (strcmp(native, ota_resolve_base(OTA_CH_STABLE)) == 0) return "prod"; + if (strcmp(native, ota_resolve_base(OTA_CH_DEV)) == 0) return "beta"; + return "custom"; +} + // Parse an `ota branch` argument. Returns true and sets *out on a known keyword -// (stable|dev|default; "default" -> native); returns false and leaves *out untouched -// otherwise. +// (prod|stable, beta|dev, default); returns false and leaves *out untouched otherwise. static inline bool ota_parse_channel(const char* arg, uint8_t* out) { - if (strcmp(arg, "stable") == 0) { *out = OTA_CH_STABLE; return true; } - if (strcmp(arg, "dev") == 0) { *out = OTA_CH_DEV; return true; } - if (strcmp(arg, "default") == 0) { *out = OTA_CH_NATIVE; return true; } + if (strcmp(arg, "prod") == 0 || strcmp(arg, "stable") == 0) { *out = OTA_CH_STABLE; return true; } + if (strcmp(arg, "beta") == 0 || strcmp(arg, "dev") == 0) { *out = OTA_CH_DEV; return true; } + if (strcmp(arg, "default") == 0) { *out = OTA_CH_NATIVE; return true; } return false; } diff --git a/test/test_ota_channel/test_ota_channel.cpp b/test/test_ota_channel/test_ota_channel.cpp index 699ffb70c..d2a4e482c 100644 --- a/test/test_ota_channel/test_ota_channel.cpp +++ b/test/test_ota_channel/test_ota_channel.cpp @@ -11,21 +11,33 @@ TEST(OtaChannel, ResolvesStable) { TEST(OtaChannel, ResolvesDev) { EXPECT_STREQ(ota_resolve_base(OTA_CH_DEV), "https://dev.example/mqtt/dev/v"); } +TEST(OtaChannel, BasesAreStoredBehindCiTags) { + EXPECT_STREQ(ota_tagged_native, "ota-base-native:https://stable.example/mqtt/v"); + EXPECT_STREQ(ota_tagged_stable, "ota-base-stable:https://stable.example/mqtt/v"); + EXPECT_STREQ(ota_tagged_dev, "ota-base-dev:https://dev.example/mqtt/dev/v"); +} TEST(OtaChannel, ParseKnownKeywords) { uint8_t ch = 99; + EXPECT_TRUE(ota_parse_channel("prod", &ch)); EXPECT_EQ(ch, OTA_CH_STABLE); EXPECT_TRUE(ota_parse_channel("stable", &ch)); EXPECT_EQ(ch, OTA_CH_STABLE); - EXPECT_TRUE(ota_parse_channel("dev", &ch)); EXPECT_EQ(ch, OTA_CH_DEV); + EXPECT_TRUE(ota_parse_channel("beta", &ch)); EXPECT_EQ(ch, OTA_CH_DEV); EXPECT_TRUE(ota_parse_channel("default", &ch)); EXPECT_EQ(ch, OTA_CH_NATIVE); + EXPECT_TRUE(ota_parse_channel("dev", &ch)); EXPECT_EQ(ch, OTA_CH_DEV); } TEST(OtaChannel, ParseRejectsUnknownAndLeavesOutputUntouched) { uint8_t ch = 7; - EXPECT_FALSE(ota_parse_channel("beta", &ch)); + EXPECT_FALSE(ota_parse_channel("production", &ch)); + EXPECT_FALSE(ota_parse_channel("Beta", &ch)); + EXPECT_FALSE(ota_parse_channel("", &ch)); EXPECT_EQ(ch, 7); } TEST(OtaChannel, NameLabels) { - EXPECT_STREQ(ota_channel_name(OTA_CH_NATIVE), "native"); - EXPECT_STREQ(ota_channel_name(OTA_CH_STABLE), "stable"); - EXPECT_STREQ(ota_channel_name(OTA_CH_DEV), "dev"); + EXPECT_STREQ(ota_channel_name(OTA_CH_NATIVE), "default"); + EXPECT_STREQ(ota_channel_name(OTA_CH_STABLE), "prod"); + EXPECT_STREQ(ota_channel_name(OTA_CH_DEV), "beta"); +} +TEST(OtaChannel, NativeChannelNameMatchesBase) { + EXPECT_STREQ(ota_native_channel_name(), "prod"); } int main(int argc, char** argv) { diff --git a/webui/index.html b/webui/index.html index ab7b43d6a..30cf4ceaf 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1495,6 +1495,8 @@ var CLI_VERBS=[ ["alert test","Send a test alert on the configured channel"], ["ota check","Check for a newer build (does not flash)"], ["ota update","Download and flash the newer build, then reboot"], + ["ota branch","Show the OTA release channel"], + ["ota branch ","Set the OTA release channel {prod|beta|default}"], // `start ota` is absent: it binds port 80, which this portal is already using. ["start webconfig","Start this portal on the LAN"], ["start webconfig ap","Start this portal on its own setup AP"], From 65d80bc908a721e76ffdd60499c305aa47205328 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 3 Oct 2026 15:24:11 -0700 Subject: [PATCH 9/9] ci(ota): verify each published binary's native channel by tag Every observer build now carries both channel URLs, so checking for a URL's presence or absence can no longer tell prod from beta (and the old checks would reject every build). Read the ota-base-* tags from every .bin in out/ instead of a single ELF, with URLs shared by build.sh and both workflows. --- .../build-observer-firmwares-beta.yml | 21 +-- .../workflows/build-observer-firmwares.yml | 15 +++ scripts/verify_ota_channel.py | 125 ++++++++++++++++++ 3 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 scripts/verify_ota_channel.py diff --git a/.github/workflows/build-observer-firmwares-beta.yml b/.github/workflows/build-observer-firmwares-beta.yml index 8618da4b9..0fa2c9fd5 100644 --- a/.github/workflows/build-observer-firmwares-beta.yml +++ b/.github/workflows/build-observer-firmwares-beta.yml @@ -67,6 +67,10 @@ env: # The channel itself. Firmware fetches /.json, # so this URL is what keeps beta nodes on beta. OTA_MANIFEST_BASE_URL: https://observer.gessaman.com/beta/v + # Both channel bases are baked into every build so `ota branch prod|beta` can + # move a node between channels. Identical in both observer workflows. + OTA_MANIFEST_BASE_STABLE_URL: https://observer.gessaman.com/v + OTA_MANIFEST_BASE_DEV_URL: https://observer.gessaman.com/beta/v # Marks the embedded version, e.g. v1.16.0.3-observer-beta-dev-abc1234, so `ver` # (and the MQTT firmware_version / SNMP) identify BOTH the channel and its # provenance: this channel is built from the upstream-dev-merged line, so "dev" @@ -168,20 +172,9 @@ jobs: run: /usr/bin/env bash build.sh build-firmware ${{ matrix.shard.envs }} - name: Verify beta channel is baked in - shell: bash - run: | - # Fail fast rather than publish firmware that would OTA itself onto the - # production channel. Checks one built binary actually carries the beta - # manifest URL and does NOT carry the production one. - BIN=$(find .pio/build -name firmware.elf | head -1) - if [ -z "$BIN" ]; then echo "no ELF found to verify" >&2; exit 1; fi - if ! strings "$BIN" | grep -qF "$OTA_MANIFEST_BASE_URL"; then - echo "ERROR: beta manifest base missing from $BIN" >&2; exit 1 - fi - if strings "$BIN" | grep -qE 'https://observer\.gessaman\.com/v"?$'; then - echo "ERROR: production manifest base present in a beta build" >&2; exit 1 - fi - echo "OK: $BIN carries $OTA_MANIFEST_BASE_URL" + # Fail fast rather than publish firmware that would OTA itself onto the + # production channel: checks every .bin the shard publishes. + run: python3 scripts/verify_ota_channel.py --expect beta - name: Upload Shard Artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/build-observer-firmwares.yml b/.github/workflows/build-observer-firmwares.yml index 13d9cda0f..9aebe03fd 100644 --- a/.github/workflows/build-observer-firmwares.yml +++ b/.github/workflows/build-observer-firmwares.yml @@ -48,6 +48,16 @@ env: # repo). Baked into the slim OTA manifests' file URLs; must stay consistent # with config.json's staticPath. STATIC_PATH: https://observer-fw.gessaman.com + # Where the firmware fetches .json from — this URL *is* the release + # channel. Stated explicitly (it equals build.sh's default) rather than left + # unset, so "Verify production channel is baked in" below can assert against the + # same value the build was handed instead of a second hardcoded copy. Must match + # build.sh's OTA_MANIFEST_BASE_URL default and the Pages path serving flasher/v. + OTA_MANIFEST_BASE_URL: https://observer.gessaman.com/v + # Both channel bases are baked into every build so `ota branch prod|beta` can + # move a node between channels. Identical in both observer workflows. + OTA_MANIFEST_BASE_STABLE_URL: https://observer.gessaman.com/v + OTA_MANIFEST_BASE_DEV_URL: https://observer.gessaman.com/beta/v jobs: @@ -137,6 +147,11 @@ jobs: FIRMWARE_BUILD_NUMBER: ${{ needs.enumerate.outputs.build_number }} run: /usr/bin/env bash build.sh build-firmware ${{ matrix.shard.envs }} + - name: Verify production channel is baked in + # Fail fast rather than publish firmware that would OTA itself onto the + # wrong channel: checks every .bin the shard publishes. + run: python3 scripts/verify_ota_channel.py --expect prod + - name: Upload Shard Artifact uses: actions/upload-artifact@v4 with: diff --git a/scripts/verify_ota_channel.py b/scripts/verify_ota_channel.py new file mode 100644 index 000000000..15d91c26f --- /dev/null +++ b/scripts/verify_ota_channel.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Verify every built observer binary is baked for the expected OTA channel. + +Observer builds carry three manifest bases (src/helpers/OtaChannel.h), each stored +behind a tag so it can be read back from the binary: + + ota-base-native: the channel the build OTAs from by default + ota-base-stable: production, the target of `ota branch prod` + ota-base-dev: beta, the target of `ota branch beta` + +Every build contains both channel URLs, so checking for the presence or absence +of a URL can no longer tell production from beta. This reads the tags instead and +fails unless, in every .bin in out/ (the files that get published), each tag appears with exactly one value, the stable +and dev tags match the workflow's URLs, and the native tag is the URL of the +channel named by --expect. + + python3 scripts/verify_ota_channel.py --expect prod + python3 scripts/verify_ota_channel.py --self-test + +URLs default to OTA_MANIFEST_BASE_URL, OTA_MANIFEST_BASE_STABLE_URL and +OTA_MANIFEST_BASE_DEV_URL from the environment. Stdlib only. +""" +import argparse +import os +import re +import sys + +TAG_RE = re.compile(rb"ota-base-(native|stable|dev):([\x21-\x7e]*)\x00") + + +def read_tags(data): + tags = {"native": set(), "stable": set(), "dev": set()} + for m in TAG_RE.finditer(data): + tags[m.group(1).decode()].add(m.group(2).decode()) + return tags + + +def check(data, expect, native_url, stable_url, dev_url): + """Return a list of problems with one binary's tags (empty when it passes).""" + problems = [] + if stable_url == dev_url: + problems.append("stable and dev URLs are identical (%s)" % stable_url) + expected_native = stable_url if expect == "prod" else dev_url + if native_url != expected_native: + problems.append("OTA_MANIFEST_BASE_URL %s is not the %s URL %s" + % (native_url, expect, expected_native)) + tags = read_tags(data) + for name, want in (("native", expected_native), ("stable", stable_url), ("dev", dev_url)): + found = sorted(tags[name]) + if found != [want]: + problems.append("ota-base-%s: expected [%s], found %s" % (name, want, found or "nothing")) + return problems + + +def find_bins(root): + for name in os.listdir(root): + if name.endswith(".bin"): + yield os.path.join(root, name) + + +def self_test(): + P, B = "https://h/v", "https://h/beta/v" + + def blob(native, stable=P, dev=B): + return b"\x00junk\x00ota-base-native:%s\x00ota-base-stable:%s\x00ota-base-dev:%s\x00" % ( + native.encode(), stable.encode(), dev.encode()) + + cases = [ + ("prod build passes", blob(P), "prod", P, True), + ("beta build passes", blob(B), "beta", B, True), + ("beta build fails prod check", blob(B), "prod", P, False), + ("prod build fails beta check", blob(P), "beta", B, False), + ("missing tags fail", b"\x00https://h/v\x00https://h/beta/v\x00", "prod", P, False), + ("wrong dev URL fails", blob(P, dev="https://other/v"), "prod", P, False), + ("conflicting native tags fail", blob(P) + blob(B), "prod", P, False), + ("workflow URL off-channel fails", blob(P), "prod", B, False), + ] + failed = 0 + for name, data, expect, native, ok in cases: + got = not check(data, expect, native, P, B) + if got != ok: + failed += 1 + print("FAIL: %s" % name) + print("self-test: %d/%d passed" % (len(cases) - failed, len(cases))) + return 1 if failed else 0 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--expect", choices=("prod", "beta")) + ap.add_argument("--bin-dir", default="out") + ap.add_argument("--native-url", default=os.environ.get("OTA_MANIFEST_BASE_URL")) + ap.add_argument("--stable-url", default=os.environ.get("OTA_MANIFEST_BASE_STABLE_URL")) + ap.add_argument("--dev-url", default=os.environ.get("OTA_MANIFEST_BASE_DEV_URL")) + ap.add_argument("--self-test", action="store_true") + args = ap.parse_args() + + if args.self_test: + return self_test() + if not args.expect: + ap.error("--expect is required") + for opt in ("native_url", "stable_url", "dev_url"): + if not getattr(args, opt): + ap.error("--%s (or its environment variable) is required" % opt.replace("_", "-")) + + bins = sorted(find_bins(args.bin_dir)) if os.path.isdir(args.bin_dir) else [] + if not bins: + print("ERROR: no .bin files in %s" % args.bin_dir, file=sys.stderr) + return 1 + bad = 0 + for path in bins: + with open(path, "rb") as f: + problems = check(f.read(), args.expect, args.native_url, args.stable_url, args.dev_url) + for p in problems: + print("ERROR: %s: %s" % (path, p), file=sys.stderr) + bad += bool(problems) + if bad: + print("ERROR: %d of %d builds are not baked for %s" % (bad, len(bins), args.expect), file=sys.stderr) + return 1 + print("OK: %d builds default to %s (%s) and carry both channels" % (len(bins), args.expect, args.native_url)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())