Skip to content

Commit cbf0d8b

Browse files
authored
Upgrade smoke test dependencies only if >=48h old (#11215)
Iteration 1 of 48h dependency cooldown Iteration 2 with no fail fast for lockfiles Scope to smoke tests only Merge branch 'master' into sarahchen6/implement-48h-cooldown Add edit and tests for RC and milestone filtering Merge branch 'master' into sarahchen6/implement-48h-cooldown Co-authored-by: sarah.chen <sarah.chen@datadoghq.com>
1 parent 2e6ce6c commit cbf0d8b

8 files changed

Lines changed: 590 additions & 51 deletions

.github/scripts/dependency_age.py

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import json
5+
import os
6+
import re
7+
import sys
8+
import urllib.parse
9+
import urllib.request
10+
from dataclasses import dataclass
11+
from datetime import datetime, timedelta, timezone
12+
from pathlib import Path
13+
from typing import Any
14+
15+
16+
GRADLE_VERSIONS_URL = "https://services.gradle.org/versions/all"
17+
MAVEN_SEARCH_URL = "https://search.maven.org/solrsearch/select"
18+
DEFAULT_MIN_AGE_HOURS = 48
19+
20+
21+
22+
@dataclass(frozen=True)
23+
class Candidate:
24+
version: str
25+
published_at: datetime
26+
27+
28+
# Entry point for GitHub Actions workflows
29+
# select-gradle: get newest Gradle release that is at least MIN_DEPENDENCY_AGE_HOURS hours old
30+
# select-maven: get newest Maven artifact release that is at least MIN_DEPENDENCY_AGE_HOURS hours old
31+
def parse_args() -> argparse.Namespace:
32+
parser = argparse.ArgumentParser(description="Dependency age helpers for GitHub workflows.")
33+
subparsers = parser.add_subparsers(dest="command", required=True)
34+
35+
gradle = subparsers.add_parser("select-gradle", help="Select the newest eligible Gradle release.")
36+
add_common_selection_args(gradle)
37+
gradle.add_argument("--versions-url", default=GRADLE_VERSIONS_URL)
38+
gradle.add_argument("--versions-file")
39+
40+
maven = subparsers.add_parser("select-maven", help="Select the newest eligible Maven artifact release.")
41+
add_common_selection_args(maven)
42+
maven.add_argument("--group-id", required=True)
43+
maven.add_argument("--artifact-id", required=True)
44+
maven.add_argument("--search-url", default=MAVEN_SEARCH_URL)
45+
maven.add_argument("--search-response-file")
46+
maven.add_argument(
47+
"--prerelease-pattern",
48+
action="append",
49+
default=[],
50+
help="Case-insensitive regex fragment used to exclude prerelease versions.",
51+
)
52+
53+
return parser.parse_args()
54+
55+
56+
# add shared args used by select-gradle and select-maven
57+
def add_common_selection_args(parser: argparse.ArgumentParser) -> None:
58+
parser.add_argument("--min-age-hours", type=int, default=default_min_age_hours())
59+
parser.add_argument("--now")
60+
parser.add_argument("--github-output", default=None)
61+
62+
63+
# get MIN_DEPENDENCY_AGE_HOURS from environment variable; default is 48 hours
64+
def default_min_age_hours() -> int:
65+
try:
66+
return int(os.environ.get("MIN_DEPENDENCY_AGE_HOURS", DEFAULT_MIN_AGE_HOURS))
67+
except ValueError:
68+
return DEFAULT_MIN_AGE_HOURS
69+
70+
71+
# return input as a datetime object; default to current UTC time
72+
def now_utc(raw: str | None) -> datetime:
73+
if raw:
74+
return parse_datetime(raw)
75+
return datetime.now(timezone.utc)
76+
77+
78+
# now_utc helper to parse input as a datetime object; used for Gradle and Maven timestamps
79+
def parse_datetime(value: Any) -> datetime:
80+
if isinstance(value, datetime):
81+
return value.astimezone(timezone.utc)
82+
if isinstance(value, (int, float)):
83+
timestamp = float(value)
84+
if timestamp > 10_000_000_000:
85+
timestamp /= 1000.0
86+
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
87+
if value is None:
88+
raise ValueError("timestamp is required")
89+
90+
text = str(value).strip()
91+
if not text:
92+
raise ValueError("timestamp is empty")
93+
94+
# Gradle buildTime compact format: 20260423130000+0000
95+
try:
96+
return datetime.strptime(text, "%Y%m%d%H%M%S%z").astimezone(timezone.utc)
97+
except ValueError:
98+
pass
99+
100+
# ISO 8601: normalise Z and +HHMM → +HH:MM for fromisoformat
101+
text = re.sub(r"([+-])(\d{2})(\d{2})$", r"\1\2:\3", text.replace("Z", "+00:00"))
102+
return datetime.fromisoformat(text).astimezone(timezone.utc)
103+
104+
105+
# normalize datetime to YYYY-MM-DDTHH:MM:SSZ for GitHub Actions outputs
106+
def format_datetime(value: datetime) -> str:
107+
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
108+
109+
110+
# emit key=value lines to stdout and GitHub Actions output file
111+
def emit_outputs(outputs: dict[str, Any], github_output: str | None) -> None:
112+
lines = [f"{key}={'' if value is None else value}" for key, value in outputs.items()]
113+
for line in lines:
114+
print(line)
115+
if github_output:
116+
with open(github_output, "a", encoding="utf-8") as handle:
117+
for line in lines:
118+
handle.write(f"{line}\n")
119+
120+
121+
# load JSON from file or URL
122+
def load_json(file_path: str | None, url: str | None) -> Any:
123+
if file_path:
124+
text = Path(file_path).read_text(encoding="utf-8")
125+
text = re.sub(r"(?<!:)//[^\n]*", "", text) # strip // line comments, preserve ://
126+
return json.loads(text)
127+
if not url:
128+
raise ValueError("either file_path or url is required")
129+
with urllib.request.urlopen(url, timeout=30) as response:
130+
return json.load(response)
131+
132+
133+
# select latest Gradle release that is at least MIN_DEPENDENCY_AGE_HOURS hours old
134+
def select_gradle_release(args: argparse.Namespace) -> int:
135+
cutoff = now_utc(args.now) - timedelta(hours=args.min_age_hours)
136+
payload = load_json(args.versions_file, args.versions_url)
137+
candidates: list[Candidate] = []
138+
for entry in payload:
139+
version = entry.get("version")
140+
build_time = entry.get("buildTime")
141+
if not version or not build_time:
142+
continue
143+
if any(bool(entry.get(flag)) for flag in ("snapshot", "nightly", "releaseNightly", "broken", "activeRc")):
144+
continue
145+
if entry.get("rcFor") or entry.get("milestoneFor"):
146+
continue
147+
published_at = parse_datetime(build_time)
148+
if published_at <= cutoff:
149+
candidates.append(Candidate(version=version, published_at=published_at))
150+
151+
return emit_selection_result(
152+
label="Gradle",
153+
cutoff=cutoff,
154+
github_output=args.github_output,
155+
candidates=candidates,
156+
not_found_reason=(
157+
f"No eligible stable Gradle release is at least {args.min_age_hours} hours old."
158+
),
159+
)
160+
161+
162+
# select latest Maven artifact release that is at least MIN_DEPENDENCY_AGE_HOURS hours old
163+
def select_maven_release(args: argparse.Namespace) -> int:
164+
cutoff = now_utc(args.now) - timedelta(hours=args.min_age_hours)
165+
pattern = combine_patterns(args.prerelease_pattern)
166+
candidates: list[Candidate] = []
167+
for document in load_maven_documents(
168+
group_id=args.group_id,
169+
artifact_id=args.artifact_id,
170+
search_url=args.search_url,
171+
response_file=args.search_response_file,
172+
):
173+
version = document.get("v")
174+
timestamp = document.get("timestamp")
175+
if not version or timestamp is None:
176+
continue
177+
if pattern and pattern.search(version):
178+
continue
179+
published_at = parse_datetime(timestamp)
180+
if published_at <= cutoff:
181+
candidates.append(Candidate(version=version, published_at=published_at))
182+
183+
return emit_selection_result(
184+
label=f"{args.group_id}:{args.artifact_id}",
185+
cutoff=cutoff,
186+
github_output=args.github_output,
187+
candidates=candidates,
188+
not_found_reason=(
189+
f"No eligible stable release found for {args.group_id}:{args.artifact_id} "
190+
f"that is at least {args.min_age_hours} hours old."
191+
),
192+
)
193+
194+
195+
# combine prerelease patterns into a single regex pattern
196+
def combine_patterns(patterns: list[str]) -> re.Pattern[str] | None:
197+
non_empty = [pattern for pattern in patterns if pattern]
198+
if not non_empty:
199+
return None
200+
return re.compile("|".join(f"(?:{pattern})" for pattern in non_empty), re.IGNORECASE)
201+
202+
203+
# load all Maven Central versions for given group:artifact
204+
def load_maven_documents(
205+
*,
206+
group_id: str,
207+
artifact_id: str,
208+
search_url: str,
209+
response_file: str | None,
210+
) -> list[dict[str, Any]]:
211+
if response_file:
212+
payload = load_json(response_file, None)
213+
return list(payload.get("response", {}).get("docs", []))
214+
215+
docs: list[dict[str, Any]] = []
216+
start = 0
217+
rows = 200
218+
total = None
219+
while total is None or start < total:
220+
query = urllib.parse.urlencode(
221+
{
222+
"q": f'g:"{group_id}" AND a:"{artifact_id}"',
223+
"core": "gav",
224+
"rows": rows,
225+
"start": start,
226+
"wt": "json",
227+
"sort": "timestamp desc",
228+
}
229+
)
230+
payload = load_json(None, f"{search_url}?{query}")
231+
response = payload.get("response", {})
232+
total = int(response.get("numFound", 0))
233+
batch = list(response.get("docs", []))
234+
docs.extend(batch)
235+
if not batch:
236+
break
237+
start += len(batch)
238+
return docs
239+
240+
241+
# parse a version string into a tuple of ints for numeric comparison (e.g. "3.9.11" → (3, 9, 11))
242+
def _version_sort_key(version: str) -> tuple:
243+
parts = []
244+
for segment in re.split(r"([.\-])", version):
245+
if segment in {"", ".", "-"}:
246+
continue
247+
try:
248+
parts.append((0, int(segment)))
249+
except ValueError:
250+
parts.append((1, segment))
251+
return tuple(parts)
252+
253+
254+
# emit selection result to stdout and GitHub Actions output file for select-gradle and select-maven
255+
def emit_selection_result(
256+
*,
257+
label: str,
258+
cutoff: datetime,
259+
github_output: str | None,
260+
candidates: list[Candidate],
261+
not_found_reason: str,
262+
) -> int:
263+
selected = max(candidates, key=lambda candidate: _version_sort_key(candidate.version), default=None)
264+
outputs: dict[str, Any] = {
265+
"cutoff_at": format_datetime(cutoff),
266+
}
267+
if not selected:
268+
outputs.update(
269+
{
270+
"found": "false",
271+
"version": "",
272+
"published_at": "",
273+
"reason": not_found_reason,
274+
}
275+
)
276+
emit_outputs(outputs, github_output)
277+
print(f"::error::{not_found_reason}")
278+
return 1
279+
280+
outputs.update(
281+
{
282+
"found": "true",
283+
"version": selected.version,
284+
"published_at": format_datetime(selected.published_at),
285+
"reason": "",
286+
}
287+
)
288+
emit_outputs(outputs, github_output)
289+
print(
290+
f"Selected latest eligible stable version for {label}: "
291+
f"{selected.version} (published {format_datetime(selected.published_at)}, cutoff {format_datetime(cutoff)})"
292+
)
293+
return 0
294+
295+
296+
def main() -> int:
297+
args = parse_args()
298+
if args.command == "select-gradle":
299+
return select_gradle_release(args)
300+
if args.command == "select-maven":
301+
return select_maven_release(args)
302+
raise ValueError(f"Unsupported command: {args.command}")
303+
304+
305+
if __name__ == "__main__":
306+
sys.exit(main())
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
[
2+
{
3+
"version": "9.5-rc-1",
4+
"buildTime": "20260420120000+0000",
5+
"snapshot": false,
6+
"nightly": false,
7+
"releaseNightly": false,
8+
"broken": false,
9+
"activeRc": true
10+
},
11+
{
12+
"version": "9.4.2",
13+
"buildTime": "20260423130000+0000",
14+
"snapshot": false,
15+
"nightly": false,
16+
"releaseNightly": false,
17+
"broken": false,
18+
"activeRc": false
19+
},
20+
{
21+
"version": "9.4.1",
22+
"buildTime": "20260422110000+0000",
23+
"snapshot": false,
24+
"nightly": false,
25+
"releaseNightly": false,
26+
"broken": false,
27+
"activeRc": false
28+
}
29+
]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[
2+
{
3+
"version": "9.5-rc-1",
4+
"buildTime": "20260420120000+0000",
5+
"snapshot": false,
6+
"nightly": false,
7+
"releaseNightly": false,
8+
"broken": false,
9+
"activeRc": true
10+
},
11+
{
12+
"version": "9.4.2",
13+
"buildTime": "20260423130000+0000",
14+
"snapshot": false,
15+
"nightly": false,
16+
"releaseNightly": false,
17+
"broken": false,
18+
"activeRc": false
19+
}
20+
]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[
2+
{
3+
"version": "9.5.0-rc-4",
4+
"buildTime": "20260420120000+0000",
5+
"snapshot": false,
6+
"nightly": false,
7+
"releaseNightly": false,
8+
"broken": false,
9+
"activeRc": false,
10+
"rcFor": "9.5.0",
11+
"milestoneFor": ""
12+
},
13+
{
14+
"version": "9.6.0-milestone-1",
15+
"buildTime": "20260419120000+0000",
16+
"snapshot": false,
17+
"nightly": false,
18+
"releaseNightly": false,
19+
"broken": false,
20+
"activeRc": false,
21+
"rcFor": "",
22+
"milestoneFor": "9.6.0"
23+
},
24+
{
25+
"version": "9.4.1",
26+
"buildTime": "20260422110000+0000",
27+
"snapshot": false,
28+
"nightly": false,
29+
"releaseNightly": false,
30+
"broken": false,
31+
"activeRc": false,
32+
"rcFor": "",
33+
"milestoneFor": ""
34+
}
35+
]

0 commit comments

Comments
 (0)