Skip to content

Commit f9d3adc

Browse files
authored
Get latest dependency that fits 48h cooldown period (#11460)
Get latest dependency that fits 48h cooldown period Include GAV in cache Clean Co-authored-by: sarah.chen <sarah.chen@datadoghq.com>
1 parent bf1e444 commit f9d3adc

1 file changed

Lines changed: 116 additions & 21 deletions

File tree

.github/scripts/dependency_age.py

Lines changed: 116 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import urllib.error
99
import urllib.parse
1010
import urllib.request
11+
import xml.etree.ElementTree as ET
1112
from email.utils import parsedate_to_datetime
1213
from dataclasses import dataclass
1314
from datetime import datetime, timedelta, timezone
@@ -372,63 +373,103 @@ def validate_lockfiles(args: argparse.Namespace) -> int:
372373
emit_outputs({"cutoff_at": format_datetime(cutoff), "reverted_files": 0}, args.github_output)
373374
return 0
374375

376+
baseline_lockfiles = collect_lockfiles(baseline_dir)
377+
375378
changed_by_file: dict[str, list[str]] = {}
376379
for relative_path, gav in changed:
377380
changed_by_file.setdefault(relative_path, []).append(gav)
378381

379382
timestamp_cache: dict[str, tuple[datetime | None, str | None]] = {}
380-
too_new = "too_new"
381-
unverified = "unverified"
383+
replacements_by_file: dict[str, dict[str, str]] = {}
382384
violations_by_file: dict[str, list[tuple[str, str]]] = {}
383385
for relative_path, gavs in sorted(changed_by_file.items()):
386+
baseline_coords = baseline_lockfiles.get(relative_path, set())
384387
for gav in gavs:
385388
if gav not in timestamp_cache:
386389
timestamp_cache[gav] = resolve_gav_timestamp(gav=gav, metadata=metadata, repo_urls=repo_urls)
387390
published_at, reason = timestamp_cache[gav]
388391
if published_at is None:
389-
violations_by_file.setdefault(relative_path, []).append((gav, unverified))
392+
violations_by_file.setdefault(relative_path, []).append((gav, "unverified"))
390393
elif published_at > cutoff:
391-
violations_by_file.setdefault(relative_path, []).append((gav, too_new))
394+
group_id, artifact_id, version = gav.split(":", 2)
395+
baseline_version = next((c[len(f"{group_id}:{artifact_id}:"):] for c in baseline_coords if c.startswith(f"{group_id}:{artifact_id}:")), None)
396+
eligible = find_eligible_version(
397+
group_id=group_id, artifact_id=artifact_id,
398+
too_new_version=version, baseline_version=baseline_version,
399+
cutoff=cutoff, repo_urls=repo_urls,
400+
)
401+
if eligible:
402+
replacement_gav = f"{group_id}:{artifact_id}:{eligible[0]}"
403+
replacements_by_file.setdefault(relative_path, {})[gav] = replacement_gav
404+
print(f"Latest version {gav} did not meet 48h cooldown requirement, updating to {replacement_gav} instead.")
405+
else:
406+
violations_by_file.setdefault(relative_path, []).append((gav, "too_new"))
392407
else:
393408
print(f"Verified {gav} (published {format_datetime(published_at)}, cutoff {format_datetime(cutoff)})")
394409

410+
if replacements_by_file:
411+
apply_lockfile_replacements(replacements_by_file=replacements_by_file, current_dir=current_dir)
412+
395413
if violations_by_file:
396414
revert_lockfiles_to_baseline(violations_by_file=violations_by_file, baseline_dir=baseline_dir, current_dir=current_dir)
397415
for relative_path, entries in sorted(violations_by_file.items()):
398416
for gav, kind in entries:
399-
print(f"::warning file={relative_path}::{gav}: {'Cannot verify age' if kind == unverified else 'Too new'}. Reverted lockfile to baseline.")
417+
print(f"::warning file={relative_path}::{gav}: {'Cannot verify age' if kind == 'unverified' else 'Too new'}. Reverted lockfile to baseline.")
400418

401419
reverted_files = len(violations_by_file)
402-
summary = build_validation_summary(violations_by_file=violations_by_file, min_age_hours=args.min_age_hours)
420+
summary = build_validation_summary(violations_by_file=violations_by_file, replacements_by_file=replacements_by_file, min_age_hours=args.min_age_hours)
403421
emit_outputs({"cutoff_at": format_datetime(cutoff), "reverted_files": reverted_files, "summary": summary}, args.github_output)
404422
print(f"Validated {len(changed)} changed coordinate(s) across {len(changed_by_file)} lockfile(s). {reverted_files} lockfile(s) reverted.")
405423
return 0
406424

407425

408-
# build summary of reverted dependencies for PR descriptions
409-
def build_validation_summary(*, violations_by_file: dict[str, list[tuple[str, str]]], min_age_hours: int) -> str:
410-
if not violations_by_file:
426+
# build summary of reverted/downgraded dependencies for PR descriptions
427+
def build_validation_summary(
428+
*,
429+
violations_by_file: dict[str, list[tuple[str, str]]],
430+
replacements_by_file: dict[str, dict[str, str]],
431+
min_age_hours: int,
432+
) -> str:
433+
if not violations_by_file and not replacements_by_file:
411434
return ""
412-
summary_messages = {
413-
"too_new": f"Did not meet {min_age_hours}h dependency age requirement",
414-
"unverified": "Cannot verify age in Maven Central",
415-
}
416-
lines = [
417-
f"## Dependency age policy",
418-
f"",
419-
f"The following dependencies were reverted:",
420-
f"",
421-
]
422-
# deduplicate
435+
lines = [f"## Dependency age policy", ""]
423436
seen: set[str] = set()
437+
for replacements in replacements_by_file.values():
438+
for old_gav, new_gav in replacements.items():
439+
if old_gav not in seen:
440+
seen.add(old_gav)
441+
lines.append(f"- `{old_gav}` -> `{new_gav}` (downgraded to meet {min_age_hours}h cooldown)")
424442
for entries in violations_by_file.values():
425443
for gav, kind in entries:
426444
if gav not in seen:
427445
seen.add(gav)
428-
lines.append(f"- `{gav}` — {summary_messages[kind]}")
446+
reason = "Cannot verify age in Maven Central" if kind == "unverified" else f"Did not meet {min_age_hours}h dependency age requirement"
447+
lines.append(f"- `{gav}` — {reason} (reverted)")
429448
return "\n".join(lines)
430449

431450

451+
# replace specific coordinates in lockfiles (for version downgrades)
452+
def apply_lockfile_replacements(
453+
*,
454+
replacements_by_file: dict[str, dict[str, str]],
455+
current_dir: Path,
456+
) -> None:
457+
for relative_path, replacements in sorted(replacements_by_file.items()):
458+
lockfile_path = current_dir / relative_path
459+
lines = lockfile_path.read_text(encoding="utf-8").splitlines(keepends=True)
460+
new_lines = []
461+
for line in lines:
462+
stripped = line.strip()
463+
if stripped and not stripped.startswith("#"):
464+
coordinate = stripped.split("=", 1)[0]
465+
if coordinate in replacements:
466+
configs = stripped.split("=", 1)[1] if "=" in stripped else ""
467+
new_coord = replacements[coordinate]
468+
line = f"{new_coord}={configs}\n"
469+
new_lines.append(line)
470+
lockfile_path.write_text("".join(new_lines), encoding="utf-8")
471+
472+
432473
# restore each violating lockfile to its baseline copy to keep the file consistent
433474
def revert_lockfiles_to_baseline(
434475
*,
@@ -493,6 +534,60 @@ def _head_pom_timestamp(pom_url: str) -> datetime | None:
493534
return None
494535

495536

537+
# fetch the list of available versions for a group:artifact from maven-metadata.xml
538+
# tries each repo URL in order; returns versions sorted newest-first using _version_sort_key
539+
def fetch_available_versions(group_id: str, artifact_id: str, repo_urls: list[str]) -> list[str]:
540+
group_path = group_id.replace(".", "/")
541+
metadata_path = f"{group_path}/{artifact_id}/maven-metadata.xml"
542+
for repo_url in repo_urls:
543+
url = f"{repo_url}/{metadata_path}"
544+
try:
545+
with urllib.request.urlopen(url, timeout=30) as response:
546+
tree = ET.parse(response)
547+
versions = [v.text for v in tree.findall(".//version") if v.text]
548+
if versions:
549+
versions.sort(key=_version_sort_key, reverse=True)
550+
return versions
551+
except (urllib.error.URLError, ET.ParseError, TimeoutError, OSError):
552+
continue
553+
return []
554+
555+
556+
# for a too-new coordinate, walk backward through available versions to find the newest one
557+
# that meets the age cutoff and is newer than the baseline version
558+
def find_eligible_version(
559+
*,
560+
group_id: str,
561+
artifact_id: str,
562+
too_new_version: str,
563+
baseline_version: str | None,
564+
cutoff: datetime,
565+
repo_urls: list[str],
566+
) -> tuple[str, datetime] | None:
567+
versions = fetch_available_versions(group_id, artifact_id, repo_urls)
568+
too_new_key = _version_sort_key(too_new_version)
569+
too_new_is_ga = too_new_key[1] # True if no prerelease segments
570+
baseline_key = _version_sort_key(baseline_version) if baseline_version else None
571+
group_path = group_id.replace(".", "/")
572+
573+
for version in versions:
574+
key = _version_sort_key(version)
575+
if key >= too_new_key:
576+
continue # skip the too-new version and anything newer
577+
if baseline_key is not None and key <= baseline_key:
578+
break # no point checking versions older than or equal to baseline
579+
if too_new_is_ga and not key[1]:
580+
continue # don't downgrade a GA release to a pre-release
581+
pom_path = f"{group_path}/{artifact_id}/{version}/{artifact_id}-{version}.pom"
582+
for repo_url in repo_urls:
583+
published_at = _head_pom_timestamp(f"{repo_url}/{pom_path}")
584+
if published_at is not None:
585+
if published_at <= cutoff:
586+
return version, published_at
587+
break # version found but too new, try the next older one
588+
return None
589+
590+
496591
# load optional metadata overrides from a JSON file (group:artifact:version -> timestamp)
497592
def load_metadata_overrides(path: str | None) -> dict[str, Any]:
498593
if not path:

0 commit comments

Comments
 (0)