Skip to content

Commit 521f7f0

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

2 files changed

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

0 commit comments

Comments
 (0)