Warn when releases have not been updated for a long time (#521)

Raise an alert during the daily auto-update about stale releases, e.g.:

- non-EOL releases with latest releases not updated in a year,
- non-EOL releases with a release date older than a year.

The threshold is configurable.
This commit is contained in:
Marc Wrobel
2025-09-20 09:43:39 +02:00
committed by GitHub
parent 79bf3d737e
commit 9364fee53c
5 changed files with 73 additions and 15 deletions

View File

@@ -4,7 +4,7 @@ from common import dates, releasedata
"""Remove empty releases or releases which are released in the future.""" """Remove empty releases or releases which are released in the future."""
TODAY = dates.today() TODAY = dates.today_at_midnight()
frontmatter, _ = releasedata.parse_argv(ignore_auto_config=True) frontmatter, _ = releasedata.parse_argv(ignore_auto_config=True)
with releasedata.ProductData(frontmatter.name) as product_data: with releasedata.ProductData(frontmatter.name) as product_data:

View File

@@ -1,5 +1,5 @@
import calendar import calendar
from datetime import datetime, timezone import datetime
def parse_date(text: str, formats: list[str] = frozenset([ def parse_date(text: str, formats: list[str] = frozenset([
@@ -15,7 +15,7 @@ def parse_date(text: str, formats: list[str] = frozenset([
"%Y/%m/%d", # 2020/01/25 "%Y/%m/%d", # 2020/01/25
"%A %d %B %Y", # Wednesday 1 January 2020 "%A %d %B %Y", # Wednesday 1 January 2020
"%A %d %b %Y", # Wednesday 1 Jan 2020 "%A %d %b %Y", # Wednesday 1 Jan 2020
])) -> datetime: ])) -> datetime.datetime:
"""Parse a given text representing a date using a list of formats. """Parse a given text representing a date using a list of formats.
""" """
return parse_datetime(text, formats, to_utc=False) return parse_datetime(text, formats, to_utc=False)
@@ -28,7 +28,7 @@ def parse_month_year_date(text: str, formats: list[str] = frozenset([
"%Y/%m", # 2020/01 "%Y/%m", # 2020/01
"%m-%Y", # 01-2020 "%m-%Y", # 01-2020
"%m/%Y", # 01/2020 "%m/%Y", # 01/2020
])) -> datetime: ])) -> datetime.datetime:
"""Parse a given text representing a partial date using a list of formats, """Parse a given text representing a partial date using a list of formats,
adjusting it to the last day of the month. adjusting it to the last day of the month.
""" """
@@ -37,7 +37,7 @@ def parse_month_year_date(text: str, formats: list[str] = frozenset([
return date.replace(day=last_day) return date.replace(day=last_day)
def parse_date_or_month_year_date(text: str) -> datetime: def parse_date_or_month_year_date(text: str) -> datetime.datetime:
"""Parse a given text representing a date or a partial date using the default list of formats. """Parse a given text representing a date or a partial date using the default list of formats.
""" """
try: try:
@@ -60,7 +60,7 @@ def parse_datetime(text: str, formats: list[str] = frozenset([
"%a %b %d %H:%M:%S %z %Y", # Wed Jan 01 00:00:00 -0400 2020 "%a %b %d %H:%M:%S %z %Y", # Wed Jan 01 00:00:00 -0400 2020
"%b %d %Y %I:%M %p", # Jan 1 2020 0:00 pm "%b %d %Y %I:%M %p", # Jan 1 2020 0:00 pm
"%Y%m%d%H%M%S", # 20230501083234 "%Y%m%d%H%M%S", # 20230501083234
]), to_utc: bool = True) -> datetime: ]), to_utc: bool = True) -> datetime.datetime:
"""Parse a given text representing a datetime using a list of formats, """Parse a given text representing a datetime using a list of formats,
optionally converting it to UTC. optionally converting it to UTC.
""" """
@@ -78,12 +78,12 @@ def parse_datetime(text: str, formats: list[str] = frozenset([
) )
for fmt in formats: for fmt in formats:
try: try:
dt = datetime.strptime(text, fmt) # NOQA: DTZ007, timezone is handled below dt = datetime.datetime.strptime(text, fmt) # NOQA: DTZ007, timezone is handled below
if to_utc: if to_utc:
dt = dt.astimezone(timezone.utc) dt = dt.astimezone(datetime.timezone.utc)
elif dt.tzinfo is None: elif dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc) dt = dt.replace(tzinfo=datetime.timezone.utc)
return dt return dt
except ValueError: except ValueError:
@@ -93,11 +93,15 @@ def parse_datetime(text: str, formats: list[str] = frozenset([
raise ValueError(msg) raise ValueError(msg)
def date(year: int, month: int, day: int) -> datetime: def date(year: int, month: int, day: int) -> datetime.datetime:
"""Create a datetime object with the given year, month and day, at midnight.""" """Create a datetime object with the given year, month and day, at midnight."""
return datetime(year, month, day, tzinfo=timezone.utc) return datetime.datetime(year, month, day, tzinfo=datetime.timezone.utc)
def today() -> datetime: def today_at_midnight() -> datetime.datetime:
"""Create a datetime object with today's date, at midnight.""" """Create a datetime object with today's date, at midnight."""
return datetime.now(tz=timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) return datetime.datetime.now(tz=datetime.timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
def today() -> datetime.date:
"""Create a date object with today's date."""
return datetime.datetime.now(tz=datetime.timezone.utc).date()

View File

@@ -23,7 +23,7 @@ with ProductData(config.product) as product_data:
date_text = cells[date_index].get_text().strip() date_text = cells[date_index].get_text().strip()
date = dates.parse_date(date_text) date = dates.parse_date(date_text)
if date > dates.today(): if date > dates.today_at_midnight():
logging.info(f"Skipping future version {cells}") logging.info(f"Skipping future version {cells}")
continue continue

View File

@@ -11,7 +11,7 @@ This script works cumulatively: when a model is not listed anymore on https://se
it retains the date and use it as the model's EOL date. it retains the date and use it as the model's EOL date.
""" """
TODAY = dates.today() TODAY = dates.today_at_midnight()
frontmatter, config = parse_argv() frontmatter, config = parse_argv()
with ProductData(config.product) as product_data: with ProductData(config.product) as product_data:

View File

@@ -11,6 +11,7 @@ from ruamel.yaml import YAML, StringIO
from ruamel.yaml.representer import RoundTripRepresenter from ruamel.yaml.representer import RoundTripRepresenter
from ruamel.yaml.resolver import Resolver from ruamel.yaml.resolver import Resolver
from src.common.dates import today_at_midnight
from src.common.endoflife import list_products from src.common.endoflife import list_products
from src.common.gha import GitHubOutput from src.common.gha import GitHubOutput
from src.common.releasedata import DATA_DIR from src.common.releasedata import DATA_DIR
@@ -74,9 +75,18 @@ class ReleaseCycle:
self.data["latestReleaseDate"] = date self.data["latestReleaseDate"] = date
self.updated = True self.updated = True
def release_date(self) -> datetime.date | None:
return self.__as_date(self.data.get("releaseDate", None))
def eol(self) -> datetime.date | bool | None:
return self.__as_date(self.data.get("eol", None))
def latest(self) -> str | None: def latest(self) -> str | None:
return self.data.get("latest", None) return self.data.get("latest", None)
def latest_release_date(self) -> datetime.date | None:
return self.__as_date(self.data.get("latestReleaseDate", None))
def includes(self, version: str) -> bool: def includes(self, version: str) -> bool:
"""matches releases that are exact (such as 4.1 being the first release for the 4.1 release cycle) """matches releases that are exact (such as 4.1 being the first release for the 4.1 release cycle)
or releases that include a dot just after the release cycle (4.1.*) or releases that include a dot just after the release cycle (4.1.*)
@@ -93,6 +103,19 @@ class ReleaseCycle:
def __str__(self) -> str: def __str__(self) -> str:
return self.product + '#' + self.name return self.product + '#' + self.name
@staticmethod
def __as_date(o: str | bool | datetime.datetime | datetime.date | None) -> datetime.date | bool | None:
if isinstance(o, datetime.date):
return o
if isinstance(o, datetime.datetime):
return o.date()
if isinstance(o, str):
return datetime.date.fromisoformat(o)
if isinstance(o, bool):
return o
return None
class Product: class Product:
def __init__(self, name: str, product_dir: Path, versions_dir: Path) -> None: def __init__(self, name: str, product_dir: Path, versions_dir: Path) -> None:
@@ -196,6 +219,7 @@ def update_product(name: str, product_dir: Path, releases_dir: Path, output: Git
today = datetime.datetime.now(tz=datetime.timezone.utc).date() today = datetime.datetime.now(tz=datetime.timezone.utc).date()
__raise_alert_for_unmatched_versions(name, output, product, today, 30) __raise_alert_for_unmatched_versions(name, output, product, today, 30)
__raise_alert_for_unmatched_releases(name, output, product) __raise_alert_for_unmatched_releases(name, output, product)
__raise_alert_for_stale_releases(name, output, product)
def __raise_alert_for_unmatched_versions(name: str, output: GitHubOutput, product: Product, today: datetime.date, def __raise_alert_for_unmatched_versions(name: str, output: GitHubOutput, product: Product, today: datetime.date,
@@ -235,6 +259,36 @@ def __raise_alert_for_unmatched_releases(name: str, output: GitHubOutput, produc
__print_unmatched_releases_as_yaml(product) __print_unmatched_releases_as_yaml(product)
def __raise_alert_for_stale_releases(name: str, output: GitHubOutput, product: Product) -> None:
threshold = product.data.get("staleReleaseThresholdYears", 1) * 365
for release in product.releases:
logging.debug(f"checking staleness of {name}:{release.name}")
eol = release.eol()
if isinstance(eol, datetime.date):
continue # explicitly set, skip
if eol:
continue # explicitly EOL, skip
latest_release_date = release.latest_release_date()
if latest_release_date:
days_since_latest = (today_at_midnight().date() - latest_release_date).days
if days_since_latest > threshold:
message = f"{name}:{release.name} is not EOL and has not had a release in {days_since_latest} days"
logging.warning(message)
output.println(message)
continue
release_date = release.release_date()
days_since_release = (today_at_midnight().date() - release_date).days
if days_since_release > threshold:
message = f"{name}:{release.name} was released {days_since_release} days ago and has no EOL date"
logging.warning(message)
output.println(message)
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Update product releases.') parser = argparse.ArgumentParser(description='Update product releases.')
parser.add_argument('product', nargs='?', help='restrict update to the given product') parser.add_argument('product', nargs='?', help='restrict update to the given product')