@@ -31,21 +31,27 @@ def _assignee() -> str:
3131 return os .environ .get ("RELEASE_ASSIGNEE" , "" ).strip ()
3232
3333
34- def due (gh : GitHub , config : dict , now : datetime | None = None ) -> list [dict ]:
35- """Pre-releases old enough to be considered, and still ahead of the official one."""
36- now = now or datetime .now (UTC )
37- cutoff = now - timedelta (days = config ["promotion" ]["age_days" ])
38- releases = gh .releases (config ["repos" ]["backend" ])
34+ def _official_floor (releases : list [dict ]) -> tuple [int , int , int ] | None :
35+ """Highest version already official. Promotion never goes at or below this.
3936
40- # Promotion only ever moves forward. Once a version is official, every
41- # pre-release behind it is dead -- nobody will go back and promote v7.0.3
42- # after v7.0.5 shipped -- so those must not keep showing up as outstanding.
37+ Promotion only ever moves forward: nobody will go back and promote v7.0.3
38+ once v7.0.5 has shipped, and doing so by accident moves the "Latest" badge
39+ backwards and downgrades the community artifacts.
40+ """
4341 official = [
4442 ver .parse (release ["tag_name" ]) for release in releases
4543 if not release ["draft" ] and not release ["prerelease" ]
4644 and ver .is_release_tag (release ["tag_name" ])
4745 ]
48- floor = max (official ) if official else None
46+ return max (official ) if official else None
47+
48+
49+ def due (gh : GitHub , config : dict , now : datetime | None = None ) -> list [dict ]:
50+ """Pre-releases old enough to be considered, and still ahead of the official one."""
51+ now = now or datetime .now (UTC )
52+ cutoff = now - timedelta (days = config ["promotion" ]["age_days" ])
53+ releases = gh .releases (config ["repos" ]["backend" ])
54+ floor = _official_floor (releases )
4955
5056 out = []
5157 for release in releases :
@@ -113,6 +119,18 @@ def scan(gh: GitHub, config: dict, dry_run: bool) -> dict | None:
113119
114120
115121def flip (gh : GitHub , config : dict , tag : str , dry_run : bool ) -> None :
122+ # Refuse before touching anything. `make_latest` would move the Latest badge
123+ # backwards in both repos, and flip() cannot undo that -- it skips releases
124+ # that are already official, so the pointer would have to be fixed by hand.
125+ # Strictly below, not at or below: re-dispatching the version that is already
126+ # official is how you resume after bump_downstream failed, and must stay a no-op.
127+ floor = _official_floor (gh .releases (config ["repos" ]["backend" ]))
128+ if floor is not None and ver .parse (tag ) < floor :
129+ raise PromotionError (
130+ f"{ tag } is behind the current official release { ver .format (floor )} ; "
131+ f"promoting it would move Latest backwards and downgrade the chart"
132+ )
133+
116134 for key in ("frontend" , "backend" ):
117135 repo = config ["repos" ][key ]
118136 release = gh .release_by_tag (repo , tag )
0 commit comments