Skip to content

Commit 2a213b2

Browse files
committed
fix: Modify pin_safe_versions script to install newer packages after grace period ended
Signed-off-by: Cagri Yonca <cagri@ibm.com>
1 parent c9639d7 commit 2a213b2

2 files changed

Lines changed: 236 additions & 18 deletions

File tree

.circleci/config.yml

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ commands:
4646
command: |
4747
. venv/bin/activate
4848
pip install -r <<parameters.requirements>>
49+
- run:
50+
name: Apply grace period to installed packages
51+
command: |
52+
. venv/bin/activate
53+
pip install --quiet requests packaging
54+
python .circleci/pin_safe_versions.py <<parameters.requirements>>
4955
5056
run-tests-with-coverage-report:
5157
parameters:
@@ -81,6 +87,22 @@ commands:
8187
paths:
8288
- coverage_results
8389

90+
capture-installed-versions:
91+
parameters:
92+
label:
93+
type: string
94+
steps:
95+
- run:
96+
name: Capture installed package versions
97+
when: on_success
98+
command: |
99+
. venv/bin/activate
100+
pip freeze > /tmp/installed_<<parameters.label>>.txt
101+
- persist_to_workspace:
102+
root: /tmp
103+
paths:
104+
- installed_<<parameters.label>>.txt
105+
84106
store-pytest-results:
85107
steps:
86108
- store_test_results:
@@ -158,57 +180,65 @@ jobs:
158180
- pip-install-deps
159181
- pip-install-tests-deps
160182
- run-tests-with-coverage-report
183+
- capture-installed-versions:
184+
label: "py<<parameters.py-version>>"
161185
- store-pytest-results
162186
- store-coverage-report
163187

164-
py39cassandra:
188+
py39gevent:
165189
docker:
166190
- image: public.ecr.aws/docker/library/python:3.9
167-
- image: public.ecr.aws/docker/library/cassandra:3.11.16-jammy
168-
environment:
169-
MAX_HEAP_SIZE: 2048m
170-
HEAP_NEWSIZE: 512m
171191
working_directory: ~/repo
172192
steps:
173193
- checkout
174194
- check-if-tests-needed
175195
- pip-install-deps
176196
- pip-install-tests-deps:
177-
requirements: "tests/requirements-cassandra.txt"
197+
requirements: "tests/requirements-gevent-starlette.txt"
178198
- run-tests-with-coverage-report:
179-
cassandra: "true"
180-
tests: "tests/clients/test_cassandra-driver.py"
199+
gevent: "true"
200+
tests: "tests/frameworks/test_gevent.py"
201+
- capture-installed-versions:
202+
label: "gevent"
181203
- store-pytest-results
182204
- store-coverage-report
183205

184-
py39gevent:
206+
py312aws:
185207
docker:
186-
- image: public.ecr.aws/docker/library/python:3.9
208+
- image: public.ecr.aws/docker/library/python:3.12
187209
working_directory: ~/repo
188210
steps:
189211
- checkout
190212
- check-if-tests-needed
191213
- pip-install-deps
192214
- pip-install-tests-deps:
193-
requirements: "tests/requirements-gevent-starlette.txt"
215+
requirements: "tests/requirements-aws.txt"
194216
- run-tests-with-coverage-report:
195-
gevent: "true"
196-
tests: "tests/frameworks/test_gevent.py"
217+
tests: "tests_aws"
218+
- capture-installed-versions:
219+
label: "aws"
197220
- store-pytest-results
198221
- store-coverage-report
199222

200-
py312aws:
223+
py312cassandra:
201224
docker:
202225
- image: public.ecr.aws/docker/library/python:3.12
226+
- image: public.ecr.aws/docker/library/cassandra:3.11.16-jammy
227+
environment:
228+
MAX_HEAP_SIZE: 2048m
229+
HEAP_NEWSIZE: 512m
203230
working_directory: ~/repo
204231
steps:
205232
- checkout
206233
- check-if-tests-needed
207234
- pip-install-deps
208235
- pip-install-tests-deps:
209-
requirements: "tests/requirements-aws.txt"
236+
requirements: "tests/requirements-cassandra.txt"
210237
- run-tests-with-coverage-report:
211-
tests: "tests_aws"
238+
cassandra: "true"
239+
tests: "tests/clients/test_cassandra-driver.py"
240+
- capture-installed-versions:
241+
label: "cassandra"
212242
- store-pytest-results
213243
- store-coverage-report
214244

@@ -253,6 +283,8 @@ jobs:
253283
- run-tests-with-coverage-report:
254284
kafka: "true"
255285
tests: "tests/clients/kafka/test*.py"
286+
- capture-installed-versions:
287+
label: "kafka"
256288
- store-pytest-results
257289
- store-coverage-report
258290

@@ -285,6 +317,22 @@ jobs:
285317
- check-if-tests-needed
286318
- run_sonarqube
287319

320+
update-currency-versions:
321+
docker:
322+
- image: public.ecr.aws/docker/library/alpine:latest
323+
steps:
324+
- attach_workspace:
325+
at: /tmp/workspace
326+
- run:
327+
name: Collect pip freeze files
328+
command: |
329+
mkdir -p /tmp/pip-freeze
330+
cp /tmp/workspace/installed_*.txt /tmp/pip-freeze/
331+
ls -la /tmp/pip-freeze/
332+
- store_artifacts:
333+
path: /tmp/pip-freeze
334+
destination: pip-freeze
335+
288336
workflows:
289337
tests:
290338
max_auto_reruns: 2
@@ -293,9 +341,9 @@ workflows:
293341
matrix:
294342
parameters:
295343
py-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
296-
- py39cassandra
297344
- py39gevent
298345
- py312aws
346+
- py312cassandra
299347
- py313kafka
300348
- autowrapt:
301349
matrix:
@@ -304,8 +352,20 @@ workflows:
304352
- final_job:
305353
requires:
306354
- python3x
307-
- py39cassandra
308355
- py39gevent
309356
- py312aws
357+
- py312cassandra
310358
- py313kafka
311359
- autowrapt
360+
- update-currency-versions:
361+
filters:
362+
branches:
363+
only:
364+
- main
365+
requires:
366+
- python3x
367+
- py39gevent
368+
- py312aws
369+
- py312cassandra
370+
- py313kafka
371+
- final_job

.circleci/pin_safe_versions.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#!/usr/bin/env python3
2+
# (c) Copyright IBM Corp. 2026
3+
4+
"""
5+
Downgrades any installed packages that were released within the 5-day grace
6+
period to their latest safe version. Run after pip install so that CI tests
7+
only exercise versions that have cleared the supply-chain safety window.
8+
9+
Usage:
10+
python scripts/pin_safe_versions.py [requirements_file]
11+
12+
If a requirements file is given, only the packages listed there are checked.
13+
Otherwise every installed package is checked (slow).
14+
"""
15+
from typing import Any, Union
16+
17+
18+
import re
19+
import subprocess
20+
import sys
21+
from datetime import datetime, timedelta
22+
23+
import requests
24+
from packaging.specifiers import SpecifierSet
25+
from packaging.version import Version
26+
27+
GRACE_PERIOD_DAYS = 5
28+
29+
30+
def _get_pypi_releases(package_name: str) -> list[Any]:
31+
try:
32+
r = requests.get(f"https://pypi.org/pypi/{package_name}/json", timeout=10)
33+
r.raise_for_status()
34+
data = r.json()
35+
except Exception:
36+
return []
37+
38+
current_python = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
39+
40+
result = []
41+
for ver, files in data["releases"].items():
42+
if not files or re.search(r"(a|b|rc|dev)\d*$", ver, re.I):
43+
continue
44+
try:
45+
Version(ver)
46+
except Exception:
47+
continue
48+
requires_python = next(
49+
(f["requires_python"] for f in files if f.get("requires_python")), None
50+
)
51+
if requires_python:
52+
try:
53+
if not SpecifierSet(requires_python).contains(current_python):
54+
continue
55+
except Exception:
56+
pass
57+
upload_time = files[-1].get("upload_time_iso_8601", "")
58+
match = re.search(r"([\d-]+)T", upload_time)
59+
if not match:
60+
continue
61+
date = datetime.strptime(match[1], "%Y-%m-%d").date()
62+
result.append((ver, date))
63+
result.sort(key=lambda x: (x[1], Version(x[0])), reverse=True)
64+
return result
65+
66+
67+
def _get_safe_version(releases: list[Any]) -> Union[tuple[Any, Any], tuple[None, None]]:
68+
today = datetime.today().date()
69+
grace_cutoff = today - timedelta(days=GRACE_PERIOD_DAYS)
70+
for i, (ver, date) in enumerate(releases):
71+
grace_end = date + timedelta(days=GRACE_PERIOD_DAYS)
72+
superseded = any(nd < grace_end for _, nd in releases[:i])
73+
if not superseded and date <= grace_cutoff:
74+
return ver, date
75+
return None, None
76+
77+
78+
def _installed_packages() -> dict[Any, Any]:
79+
result = subprocess.run(["pip", "freeze"], capture_output=True, text=True, check=True)
80+
packages = {}
81+
for line in result.stdout.strip().splitlines():
82+
if "==" in line:
83+
pkg, ver = line.split("==", 1)
84+
packages[pkg.lower()] = ver.strip()
85+
return packages
86+
87+
88+
def _parse_req_file(path: str) -> set[str]:
89+
names = set()
90+
try:
91+
with open(path) as f:
92+
for line in f:
93+
line = line.strip()
94+
if not line or line.startswith("#"):
95+
continue
96+
if line.startswith("-r "):
97+
# Recurse into included requirement files (same directory)
98+
import os
99+
included = os.path.join(os.path.dirname(path), line[3:].strip())
100+
names |= _parse_req_file(included)
101+
continue
102+
if line.startswith("-"):
103+
continue
104+
name = re.split(r"[><=!;[\s]", line)[0].strip().lower()
105+
if name:
106+
names.add(name)
107+
except FileNotFoundError:
108+
print(f"Warning: requirements file '{path}' not found.")
109+
return names
110+
111+
112+
def main() -> None:
113+
packages_to_check = None
114+
if len(sys.argv) > 1:
115+
packages_to_check = _parse_req_file(sys.argv[1])
116+
print(f"Checking {len(packages_to_check)} packages from {sys.argv[1]}")
117+
118+
installed = _installed_packages()
119+
today = datetime.today().date()
120+
grace_cutoff = today - timedelta(days=GRACE_PERIOD_DAYS)
121+
122+
to_pin = []
123+
for pkg, installed_ver in installed.items():
124+
if packages_to_check is not None and pkg not in packages_to_check:
125+
continue
126+
127+
releases = _get_pypi_releases(pkg)
128+
if not releases:
129+
continue
130+
131+
installed_date = next((d for v, d in releases if v == installed_ver), None)
132+
if installed_date is None or installed_date <= grace_cutoff:
133+
continue
134+
135+
safe_ver, safe_date = _get_safe_version(releases)
136+
if safe_ver is None:
137+
print(
138+
f"[grace-period] {pkg}=={installed_ver} (released {installed_date}) "
139+
f"is within grace period but no safe version exists — skipping"
140+
)
141+
continue
142+
143+
print(
144+
f"[grace-period] {pkg}: {installed_ver} (released {installed_date}) "
145+
f"→ pinning to {safe_ver} (released {safe_date})"
146+
)
147+
to_pin.append(f"{pkg}=={safe_ver}")
148+
149+
if to_pin:
150+
print(f"\nPinning {len(to_pin)} package(s) to grace-period-safe versions...")
151+
subprocess.run(["pip", "install"] + to_pin, check=True)
152+
print("Grace period enforcement complete.")
153+
else:
154+
print("All checked packages comply with the grace period.")
155+
156+
157+
if __name__ == "__main__":
158+
main()

0 commit comments

Comments
 (0)