mirror of
https://github.com/alexbelgium/hassio-addons.git
synced 2026-08-23 21:33:32 +02:00
* feat(addons_updater): write Home Assistant compliant addon versions The updater copied the raw upstream tag into config.yaml. Home Assistant orders addon versions with awesomeversion and hides the update when it can compare both versions and the new one is not strictly newer, so tags such as 1.2.3-2, 1.2.3+4 or 1.2.3-2026-08-01 silently stopped the update from being offered, and tags such as version-bf9e0b4f or ubuntu-2026-06-01 cannot be ordered at all. The addon version is now derived from the upstream tag by ha_version.py, using the same library Home Assistant uses: a sortable and newer tag is kept as it is, 1.2.3-4 and 1.2.3+4 become 1.2.3.4, otherwise the release number inside the tag, an incremented addon number or the date is used. updater.json keeps the raw upstream tag, so the same upstream release is never published twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(addons_updater): address review comments - calendar versions carrying a counter now advance to the current date instead of only incrementing the counter - --selftest runs against a fixed date, so it keeps passing after today - config.json is written from a validated jq result, as updater.json is - README states the raw tag is added to the changelog only when it differs from the addon version - docstring, comment and changelog formatting Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(addons_updater): turn pre-release markers into a version section "5.0.0b5" is published as "5.0.0.5" so the beta number keeps ordering the addon instead of relying on how awesomeversion reads the marker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(addons_updater): keep every number of an unorderable tag "v26.2-ls256" is published as "v26.2.256", "nightly-2.6.1.5509-ls8" as "2.6.1.5509.8" and "4.16-r0-ls94" as "4.16.0.94", so the build number keeps ordering the addon instead of being dropped. Words holding no number, architectures and commit hashes are left out, and a section ending on a year is counted up rather than incremented. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style(addons_updater): keep the helper docstrings on one line Codacy runs pydocstyle with D213, which the multi-line summary added with the numbers rule trips. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(addons_updater): leave out more arch words and unreal dates "i686" and friends were read as the number 686, and "2026.02.31" was taken for a calendar version. Both now fall back to the plain number rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
335 lines
13 KiB
Python
335 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Compute a Home Assistant compliant addon version."""
|
|
|
|
# Home Assistant orders addon versions with awesomeversion. Its update
|
|
# entity hides the update whenever the two versions *can* be compared and
|
|
# the new one is not strictly newer, and it cannot order a version at all
|
|
# when the string follows no known scheme. Whatever lands in the addon
|
|
# config.yaml must therefore be recognisable and strictly greater than the
|
|
# version users already have installed.
|
|
#
|
|
# Upstream tags do not always cooperate:
|
|
#
|
|
# * "1.2.3-4" is a semver pre-release, i.e. older than "1.2.3"
|
|
# * "1.2.3+4" only differs by build metadata, which semver ignores
|
|
# * "version-bf9e0b4f" or "ubuntu-2026-06-01" follow no scheme at all
|
|
#
|
|
# This helper therefore decides what to write in config.yaml, while the raw
|
|
# upstream tag stays in updater.json: the next run keeps comparing upstream
|
|
# with upstream, so a single upstream release never triggers two addon
|
|
# updates.
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from collections.abc import Iterator
|
|
from datetime import date
|
|
|
|
from awesomeversion import (
|
|
AwesomeVersion,
|
|
AwesomeVersionCompareException,
|
|
AwesomeVersionStrategy,
|
|
)
|
|
|
|
# "5.0.0b5" -> "5.0.0.5": a pre-release marker is not a version section,
|
|
# so it is turned into one rather than left for Home Assistant to guess.
|
|
MARKER = re.compile(r"^(v?\d+(?:\.\d+)*)(?:alpha|beta|rc|a|b)(\d+)(?=$|-)")
|
|
# "1.2.3-4" -> "1.2.3.4": a numeric suffix behind a dash is a semver
|
|
# pre-release and sorts before the version it is meant to supersede.
|
|
PRERELEASE = re.compile(r"^(v?\d+(?:\.\d+)*)-(\d+(?:\.\d+)*)$")
|
|
# A version made of numbers only, e.g. "1.37" or "v2026.07.10.2".
|
|
DOTTED_NUMBER = re.compile(r"^(?P<prefix>v?)(?P<number>\d+(?:\.\d+)*)$")
|
|
# A number carrying a name, e.g. "ls256", "r0" or the bare "2026".
|
|
NAMED_NUMBER = re.compile(r"^[A-Za-z]*(\d+(?:\.\d+)*)$")
|
|
# Words holding a number that says nothing about the release.
|
|
NOT_A_NUMBER = frozenset(
|
|
(
|
|
"aarch64",
|
|
"amd64",
|
|
"arm64",
|
|
"armhf",
|
|
"armv6",
|
|
"armv7",
|
|
"i386",
|
|
"i486",
|
|
"i586",
|
|
"i686",
|
|
"mips64",
|
|
"ppc64",
|
|
"riscv64",
|
|
"win32",
|
|
"win64",
|
|
"x64",
|
|
"x86",
|
|
)
|
|
)
|
|
|
|
# Upper bound for the ".1", ".2", ... local rebuild counters.
|
|
MAX_COUNTER = 100
|
|
|
|
|
|
def normalise(version: str) -> str:
|
|
"""Rewrite the parts of a tag Home Assistant would sort wrongly."""
|
|
version = version.strip()
|
|
# Build metadata is ignored by semver precedence, a section is not.
|
|
version = version.replace("+", ".")
|
|
# In both, group 1 is the release and group 2 the number to keep.
|
|
version = MARKER.sub(r"\1.\2", version)
|
|
return PRERELEASE.sub(r"\1.\2", version)
|
|
|
|
|
|
def is_sortable(version: str) -> bool:
|
|
"""Return True when awesomeversion recognises the version scheme."""
|
|
if not version:
|
|
return False
|
|
return AwesomeVersion(version).strategy != AwesomeVersionStrategy.UNKNOWN
|
|
|
|
|
|
def is_newer(candidate: str, current: str) -> bool:
|
|
"""Return True when Home Assistant would offer the candidate."""
|
|
if not candidate or candidate == current:
|
|
return False
|
|
if not current:
|
|
return True
|
|
try:
|
|
return AwesomeVersion(candidate) > AwesomeVersion(current)
|
|
except AwesomeVersionCompareException:
|
|
# Home Assistant shows the update when it cannot compare, and it
|
|
# is the only way out of a version following no known scheme.
|
|
return True
|
|
|
|
|
|
def is_acceptable(candidate: str, current: str) -> bool:
|
|
"""Return True for a version safe to write in the addon config."""
|
|
return is_sortable(candidate) and is_newer(candidate, current)
|
|
|
|
|
|
def is_year(section: str) -> bool:
|
|
"""Return True for a section that can only be a year."""
|
|
return len(section) == 4 and 2000 <= int(section) <= 2999
|
|
|
|
|
|
def is_date_like(number: str) -> bool:
|
|
"""Return True for a real "YYYY.MM.DD", with or without a counter."""
|
|
parts = number.split(".")
|
|
if len(parts) < 3 or not is_year(parts[0]):
|
|
return False
|
|
try:
|
|
date(*(int(part) for part in parts[:3]))
|
|
except ValueError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def increment(number: str) -> str:
|
|
"""Increment the last section, keeping any zero padding."""
|
|
parts = number.split(".")
|
|
parts[-1] = str(int(parts[-1]) + 1).zfill(len(parts[-1]))
|
|
return ".".join(parts)
|
|
|
|
|
|
def counters(base: str) -> Iterator[str]:
|
|
"""Yield "<base>.1", "<base>.2", ... local rebuild counters."""
|
|
for counter in range(1, MAX_COUNTER):
|
|
yield f"{base}.{counter}"
|
|
|
|
|
|
def skeleton(version: str) -> str:
|
|
"""Return every number of a tag, in order, as dotted sections."""
|
|
# "v26.2-ls256" -> "v26.2.256", "nightly-2.6.1.5509-ls8" ->
|
|
# "2.6.1.5509.8", "4.16-r0-ls94" -> "4.16.0.94". Words carrying no
|
|
# number and anything else, a commit hash in particular, are dropped.
|
|
numbers = []
|
|
for word in re.split(r"[-/]", normalise(version)):
|
|
if word.lower() in NOT_A_NUMBER:
|
|
continue
|
|
named = NAMED_NUMBER.match(word)
|
|
if named:
|
|
numbers.append(named.group(1))
|
|
if not numbers:
|
|
return ""
|
|
prefix = "v" if version.startswith("v") else ""
|
|
return prefix + ".".join(numbers)
|
|
|
|
|
|
def upstream_candidates(upstream: str) -> Iterator[str]:
|
|
"""Yield the upstream tag, then the numbers hidden in it."""
|
|
yield normalise(upstream)
|
|
# "v26.3-ls256" -> "v26.3.256": the numbers of a tag Home Assistant
|
|
# cannot sort still order the addon better than one of our making.
|
|
yield skeleton(upstream)
|
|
|
|
|
|
def local_candidates(current: str, today: date, release: str) -> Iterator[str]:
|
|
"""Yield sortable versions derived from the current addon version."""
|
|
calver = today.strftime("%Y.%m.%d")
|
|
normalised = normalise(current)
|
|
dotted = DOTTED_NUMBER.match(normalised)
|
|
|
|
# An upstream that rebuilds the release the current version was built
|
|
# from, such as "v26.3-ls257" after "v26.3-ls256", gets a local
|
|
# counter ("26.3.1") instead of a release it never published.
|
|
if release and normalised.startswith(release):
|
|
yield from counters(release)
|
|
|
|
if dotted and is_date_like(dotted.group("number")):
|
|
# Calendar versioned addon: move to today, then count up when
|
|
# several upstream releases land on the same day.
|
|
yield calver
|
|
if dotted.group("number").count(".") > 2:
|
|
yield dotted.group("prefix") + increment(dotted.group("number"))
|
|
yield from counters(normalised)
|
|
elif dotted:
|
|
# "1.37" -> "1.38": the number already in use simply moves on,
|
|
# unless it ends on a year, which belongs to a date the addon
|
|
# does not choose.
|
|
if not is_year(dotted.group("number").split(".")[-1]):
|
|
yield dotted.group("prefix") + increment(dotted.group("number"))
|
|
yield from counters(normalised)
|
|
else:
|
|
# Nothing sortable to build on, e.g. "version-bf9e0b4f": keep the
|
|
# numbers when the version has some, else switch to calendar
|
|
# versioning, which is ordered and never runs out of numbers.
|
|
numbers = skeleton(normalised)
|
|
if numbers:
|
|
yield numbers
|
|
yield from counters(numbers)
|
|
yield calver
|
|
yield from counters(calver)
|
|
|
|
|
|
def resolve(current: str, upstream: str, today: date) -> str:
|
|
"""Return the version to write in the addon configuration."""
|
|
for candidate in upstream_candidates(upstream):
|
|
if is_acceptable(candidate, current):
|
|
return candidate
|
|
for candidate in local_candidates(current, today, skeleton(upstream)):
|
|
if is_acceptable(candidate, current):
|
|
return candidate
|
|
return ""
|
|
|
|
|
|
# Date the expectations below are written against.
|
|
SELFTEST_DATE = date(2026, 8, 1)
|
|
|
|
SELFTESTS = (
|
|
# (current, upstream, expected)
|
|
# Sortable upstream releases are used as they are.
|
|
("3.0.3", "3.0.4", "3.0.4"),
|
|
("v3.21.0", "v3.22.0", "v3.22.0"),
|
|
("2026.02.28", "2026.03.01", "2026.03.01"),
|
|
("1.43.1.10611", "1.43.2.10650", "1.43.2.10650"),
|
|
# Tags Home Assistant compares as older than what is installed.
|
|
("1.2.3", "1.2.3-2", "1.2.3.2"),
|
|
("1.2.3.2", "1.2.3-3", "1.2.3.3"),
|
|
("1.2.3", "1.2.3+4", "1.2.3.4"),
|
|
("1.2.3.4", "1.2.3+5", "1.2.3.5"),
|
|
("1.2.4", "1.2.3", "1.2.5"),
|
|
# Pre-release markers become a section of their own, so that the
|
|
# number they carry keeps ordering the addon versions.
|
|
("5.0.0b5-3", "5.0.0b5", "5.0.0.5"),
|
|
("5.0.0.5", "5.0.0b6", "5.0.0.6"),
|
|
("5.0.0.6", "5.0.0", "5.0.0.7"),
|
|
("1.2.3", "1.2.4rc2", "1.2.4.2"),
|
|
("1.2.3", "2.0.0beta1", "2.0.0.1"),
|
|
("1.2.3", "1.2.4a1-2", "1.2.4.1.2"),
|
|
# Tags Home Assistant cannot order keep every number they carry.
|
|
("v26.2-ls255", "v26.3-ls256", "v26.3.256"),
|
|
("v26.3.256", "v26.3-ls257", "v26.3.257"),
|
|
("v1.67.0.8", "nightly-2.6.1.5509-ls8", "2.6.1.5509.8"),
|
|
("4.16.0.93", "4.16-r0-ls94", "4.16.0.94"),
|
|
("1.43.3.10828.315", "1.43.3.10828-00f62d37d-ls316", "1.43.3.10828.316"),
|
|
("2026.06.01", "ubuntu-2026-07-01", "2026.07.01"),
|
|
("20260729.1", "nightly-20260801", "20260801"),
|
|
("20260801", "nightly-20260801-2", "20260801.2"),
|
|
# A word holding a number that is not part of the release is left out.
|
|
("5.3.2025.11.08", "5.3-amd64-2025-11-09", "5.3.2025.11.09"),
|
|
("1.2.3", "nightly-1.2.4-i686", "1.2.4"),
|
|
("1.2.3", "nightly-1.2.4-armv6", "1.2.4"),
|
|
# A date that does not exist is a number like any other.
|
|
("2026.02.31", "version-1a2b3c4d", "2026.02.32"),
|
|
("2026.02.29", "version-1a2b3c4d", "2026.02.30"),
|
|
("2028.02.29", "version-1a2b3c4d", "2028.02.29.1"),
|
|
# Dockerhub tags dated by the updater itself.
|
|
("1.2.3.2026.07.25", "1.2.3-2026-08-01", "1.2.3.2026.08.01"),
|
|
# The same, dated the other way round: the date cannot order the
|
|
# addon, so a local counter does.
|
|
("1.2.3.25.07.2026", "1.2.3-01-08-2026", "1.2.3.25.07.2026.1"),
|
|
# Tags holding no number at all: the addon number moves on...
|
|
("1.37", "ubunturesolute-version-8208e985", "1.38"),
|
|
("1.4", "sha-2b71a1c", "1.5"),
|
|
("2025.12-6", "alpine-sts", "2026.08.01"),
|
|
("version-bf9e0b4f", "version-1a2b3c4d", "2026.08.01"),
|
|
# ... and calendar versions count up on the same day.
|
|
("2026.08.01", "version-1a2b3c4d", "2026.08.01.1"),
|
|
("2026.08.01.1", "version-2b3c4d5e", "2026.08.01.2"),
|
|
("2026.07.30.2", "version-3c4d5e6f", "2026.08.01"),
|
|
# A version dated in the future is never downgraded.
|
|
("2026.09.15", "version-4d5e6f70", "2026.09.15.1"),
|
|
# Switching between schemes, in both directions.
|
|
("version-bf9e0b4f", "3.0.4", "3.0.4"),
|
|
("3.0.4", "version-bf9e0b4f", "3.0.5"),
|
|
# Tags holding characters that are special to sed.
|
|
("1.2.3", "1.2.4+build[1]", "1.2.4"),
|
|
("1.2.3", "release/2.0", "2.0"),
|
|
("2.0", "release/2.0.1", "2.0.1"),
|
|
("26.3", "v26.2-ls260", "26.4"),
|
|
# No current version to build on.
|
|
("", "3.0.4", "3.0.4"),
|
|
("", "1.2.3-2", "1.2.3.2"),
|
|
("", "version-bf9e0b4f", "2026.08.01"),
|
|
)
|
|
|
|
|
|
def selftest(today: date) -> int:
|
|
"""Check the rules above against known addon version histories."""
|
|
failures = 0
|
|
for current, upstream, expected in SELFTESTS:
|
|
result = resolve(current, upstream, today)
|
|
if result != expected:
|
|
failures += 1
|
|
print(
|
|
f"FAIL {current!r} + {upstream!r} -> "
|
|
f"{result!r}, expected {expected!r}",
|
|
file=sys.stderr,
|
|
)
|
|
print(f"{len(SELFTESTS) - failures}/{len(SELFTESTS)} checks passed")
|
|
return 1 if failures else 0
|
|
|
|
|
|
def main() -> int:
|
|
"""Parse the arguments and print the resulting version."""
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--current", default="", help="version in use")
|
|
parser.add_argument("--upstream", default="", help="upstream tag")
|
|
parser.add_argument("--today", default="", help="YYYY-MM-DD override")
|
|
parser.add_argument(
|
|
"--selftest", action="store_true", help="run the built-in checks"
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.selftest:
|
|
# The expectations above are written against a fixed date, so the
|
|
# checks keep passing whenever they are run.
|
|
if args.today:
|
|
return selftest(date.fromisoformat(args.today))
|
|
return selftest(SELFTEST_DATE)
|
|
|
|
today = date.fromisoformat(args.today) if args.today else date.today()
|
|
|
|
if not args.upstream:
|
|
parser.error("--upstream is required")
|
|
|
|
version = resolve(args.current.strip(), args.upstream.strip(), today)
|
|
if not version:
|
|
print("no Home Assistant compliant version found", file=sys.stderr)
|
|
return 1
|
|
print(version)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|