Skip to content

Commit a7e97f8

Browse files
committed
promotion only runs forward
1 parent 9e1f3cd commit a7e97f8

2 files changed

Lines changed: 49 additions & 9 deletions

File tree

‎src/release/promote.py‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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

115121
def 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)

‎tests/test_promote.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,28 @@ def test_flip_skips_an_already_official_release(config):
175175
assert gh.updated_releases == []
176176

177177

178+
def test_flip_refuses_to_promote_behind_the_official_release(config):
179+
# v7.0.4 official, so promoting v7.0.3 would drag Latest backwards.
180+
gh = FakeGitHub(official("v7.0.4"))
181+
with pytest.raises(promote.PromotionError, match="behind the current official"):
182+
promote.flip(gh, config, "v7.0.3", dry_run=False)
183+
assert gh.updated_releases == []
184+
185+
186+
def test_flip_refuses_before_a_dry_run_too(config):
187+
gh = FakeGitHub(official("v7.0.4"))
188+
with pytest.raises(promote.PromotionError):
189+
promote.flip(gh, config, "v7.0.3", dry_run=True)
190+
191+
192+
def test_re_promoting_the_current_official_stays_a_no_op(config):
193+
# The resume path: bump_downstream failed, so the same version is dispatched
194+
# again. The guard must not turn that into a hard failure.
195+
gh = FakeGitHub(official("v7.0.5"))
196+
promote.flip(gh, config, "v7.0.5", dry_run=False)
197+
assert gh.updated_releases == []
198+
199+
178200
def test_bump_downstream_rewrites_all_three_files(config):
179201
gh = FakeGitHub()
180202
promote.bump_downstream(gh, config, "v7.0.6", dry_run=False)

0 commit comments

Comments
 (0)