Skip to content

Commit c6b3a57

Browse files
committed
ci: Add 5-day grace period
Signed-off-by: Cagri Yonca <cagri@ibm.com>
1 parent 3dab401 commit c6b3a57

2 files changed

Lines changed: 203 additions & 4 deletions

File tree

.circleci/config.yml

Lines changed: 55 additions & 4 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 scripts/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,12 +180,14 @@ 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+
py312cassandra:
165189
docker:
166-
- image: public.ecr.aws/docker/library/python:3.9
190+
- image: public.ecr.aws/docker/library/python:3.12
167191
- image: public.ecr.aws/docker/library/cassandra:3.11.16-jammy
168192
environment:
169193
MAX_HEAP_SIZE: 2048m
@@ -178,6 +202,8 @@ jobs:
178202
- run-tests-with-coverage-report:
179203
cassandra: "true"
180204
tests: "tests/clients/test_cassandra-driver.py"
205+
- capture-installed-versions:
206+
label: "cassandra"
181207
- store-pytest-results
182208
- store-coverage-report
183209

@@ -194,6 +220,8 @@ jobs:
194220
- run-tests-with-coverage-report:
195221
gevent: "true"
196222
tests: "tests/frameworks/test_gevent.py"
223+
- capture-installed-versions:
224+
label: "gevent"
197225
- store-pytest-results
198226
- store-coverage-report
199227

@@ -209,6 +237,8 @@ jobs:
209237
requirements: "tests/requirements-aws.txt"
210238
- run-tests-with-coverage-report:
211239
tests: "tests_aws"
240+
- capture-installed-versions:
241+
label: "aws"
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,7 +341,7 @@ workflows:
293341
matrix:
294342
parameters:
295343
py-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
296-
- py39cassandra
344+
- py312cassandra
297345
- py39gevent
298346
- py312aws
299347
- py313kafka
@@ -304,8 +352,11 @@ workflows:
304352
- final_job:
305353
requires:
306354
- python3x
307-
- py39cassandra
355+
- py312cassandra
308356
- py39gevent
309357
- py312aws
310358
- py313kafka
311359
- autowrapt
360+
- update-currency-versions:
361+
requires:
362+
- final_job

scripts/pin_safe_versions.py

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

0 commit comments

Comments
 (0)