[Feature] [25.12.5] OTA: make the update check direction-aware and fail-closed
Summary
ota check decides whether an update exists by testing whether the running build's RELEASE-REVISION string appears in the first line of the channel's version.latest. Anything that is not a substring match is reported to the user as "Found new firmware", and that same first line is what ota download fetches and what sysupgrade then flashes. There is no comparison of direction (newer vs. older) anywhere along that path, and no "cannot determine" state.
Within the design's own assumption — one directory per device family, always holding exactly the newest image for that family, whose filename embeds the running build's version string — the behaviour is self-consistent. What this request asks for is (a) a directional comparison and (b) a fail-closed unknown state, so that the property the check verifies matches the promise the UI makes.
Environment
- Branch:
istoreos-25.12. All paths below are repo-relative. - Reproduction device family: x86_64 (
x86_64channel; thex86_64_efichannel is in the same situation, serving its own-efiimage of the same build).
Current behaviour (with evidence)
1. The identity string — package/diy/luci-app-ota/root/bin/ota:125-127:
local release=`grep 'DISTRIB_RELEASE=' /etc/openwrt_release | sed -E "s/.*'(.+)'.*/\1/"`
local revision=`grep 'DISTRIB_REVISION=' /etc/openwrt_release | sed -E "s/.*'(.+)'.*/\1/" | cut -d'-' -f1`
local current="$release-$revision"DISTRIB_RELEASE / DISTRIB_REVISION are %V / %R (package/base-files/files/etc/openwrt_release:2-3), i.e. VERSION_NUMBER and REVISION (include/version.mk:97,103).
2. The entire decision — ota:131-134:
if head -1 /var/run/ota/ota.latest | grep -Fq "$current"; then
latest=1
current=""
fiA substring hit means "already newest" (rc 1). Any miss — older remote build, unrelated release line, renamed file, a revision string that simply never appears in filenames — leaves latest=0, which ota:151 documents as # 0: found newer fw.
3. Nothing downstream re-examines the version. ota:180 extracts the filename from that same first line, ota:183 downloads it, and luasrc/controller/admin/ota.lua:82 hands it to sysupgrade. The only condition that can suppress the offer is an empty changelog (ota:144 returns 1 when ota.log has zero lines), which is orthogonal to versions; the sysupgrade -T gate at ota.lua:60,70 checks image compatibility, not version.
4. UI copy vs. the property actually established. luasrc/view/admin_system/ota.htm:49 reads "Found new firmware", :104 "The latest firmware", :160 "Already the latest firmware". The user is told new; the code has only established different.
5. The channel has no per-release component. target/linux/x86/64/base-files/lib/upgrade/ota.sh:2-15 picks between https://fw0.koolcenter.com/iStoreOS/x86_64_efi (gpt) and https://fw0.koolcenter.com/iStoreOS/x86_64 (dos) by blkid -o value -s PTTYPE alone; the other targets' ota.sh follow the same one-directory-per-family shape. Every release of the family reads the same version.latest, so what gets offered is whatever the directory holds at that moment.
6. Observed on an x86_64 VM at the time of writing. The first line of .../iStoreOS/x86_64/version.latest names istoreos-24.10.8-2026073111-x86-64-squashfs-combined.img.gz; the OTA page on a machine that is not 24.10.8 presents it as new firmware and enables Step 2/3 Download — that state is d.code == 0, i.e. ota check rc 0 (ota.htm:145-152, ota.lua:88-103). The x86_64_efi channel currently advertises the same build in its own artifact, istoreos-24.10.8-2026073111-x86-64-squashfs-combined-efi.img.gz.
7. The structured version string already exists on the channel, and the same check already downloads it. version.index holds one RELEASE-REVISION entry per release — exactly the form ota:127 builds for the running system (.../iStoreOS/x86_64/version.index currently reads 24.10.8-2026073111). prepare_check fetches it at ota:135, four lines after the decision has already been made, and compares its entries to $current for exact equality at ota:137; the loop at ota:136-143 treats the file as newest-first. So the decision is taken on the weakest signal available (a substring of a filename) while a directly comparable, structured string is downloaded moments later and used only to trim the changelog.
Side note on why official builds normally match. include/toplevel.mk:14 takes REVISION from scripts/getver.sh. On official CI builds try_version (getver.sh:8-12) reads TOPDIR/version, a build-date code such as 2026073111, which is a substring of the image filename — that is precisely what makes the grep -Fq come out right. A plain git checkout instead falls through to try_git and yields rXXXXX+N-hash (getver.sh:46), which ota:126 truncates at the first - to rXXXXX+N, a form that cannot appear in a published image filename, so such a build reports "new firmware available" on every single check regardless of what the channel serves.
Why this matters for the 25.12 release path
The mechanism is fine as long as the channel directory is always one step ahead of every device reading it. The 25.12 rollout makes that guarantee harder to hold:
- During any window in which the shared
x86_64/x86_64_efidirectory still advertises a 24.10.x image — mirror lag, a staged switch of directory contents, a rollback of the published image — an official 25.12 install computescurrent="25.12.x-<buildcode>", fails the substring test against the 24.10.8 filename, and is shown "Found new firmware". - The offered artifact is then a genuinely older release, and the three-step UI (Check → Download → Flash) carries it all the way to
sysupgrade. For the user this is a whole-disk downgrade presented as an upgrade, on the exact release where a downgrade is most disruptive.
The same window also covers the reverse case that will arrive later: once the directory moves to 25.12, 24.10 devices are silently pushed across a major release boundary with no min_from-style gate, because the check cannot express "different, but not an upgrade path I should take".
Changing the comparison logic now, before 25.12 is broadly deployed, has essentially no compatibility cost: the client-side rule is strictly narrower than today's (every case it would newly refuse is a case today's code cannot justify anyway), and it requires no change to what the server publishes.
Suggested direction
Offered as directions rather than a design; the maintainers are better placed to pick the shape.
1. Structured, three-state comparison in the client (the part that needs no server change).
Take the remote version from the first line of version.index — already a RELEASE-REVISION string in the same form as $current, and already downloaded by the same check (see evidence 7) — parse both sides into RELEASE + BUILDCODE, compare RELEASE segment-wise as numbers first, then BUILDCODE numerically, and return one of three results:
- remote strictly greater → offer the update (today's rc 0),
- remote equal or lower → "already the latest" (today's rc 1),
- either side unparseable, or
version.indexmissing/empty → a distinct unknown state: show the running build as a custom/unsupported build and do not expose the download step.
Sourcing the remote version from version.index rather than from the filename also removes the filename-parsing dependency entirely. The third branch is the fail-closed part, and it removes the "every check says an update is available" behaviour on self-built images without special-casing them.
2. Optional: machine-readable channel metadata.
The artifact and its checksum are still taken from a human-readable Markdown line, parsed with sed -E 's/.*\((.+)\).*/\1/' (ota:180; ota:170 applies a similar anchored form for display). A small version.json alongside the existing files (version, build, url, sha256, and optionally min_from) would let the client read declared fields instead of inferring artifact and version from a filename, and would give the publisher a place to state an explicit lower bound for direct upgrades. The existing version.latest and version.index can stay for backwards compatibility.
3. Optional: make the channel's intent explicit rather than implicit.
Either per-release directories (e.g. .../x86_64/25.12/) selected by the running release, or an explicit upgrade hint in the metadata, so that "which build should this device be offered" is a published decision rather than a side effect of what the shared directory currently contains. This also removes the coupling that makes any directory-content switch a live event for every device in the family at once.
Directions 1 and 2/3 are independent: 1 alone already turns the failure mode from "flash an older image" into "decline and say why".
Happy to provide more detail — full ota check output, the raw version.latest / version.index first lines from both channels, or a test on any other device family — if that would help.
Source: istoreos/istoreos