|
8 | 8 | import urllib.error |
9 | 9 | import urllib.parse |
10 | 10 | import urllib.request |
| 11 | +import xml.etree.ElementTree as ET |
11 | 12 | from email.utils import parsedate_to_datetime |
12 | 13 | from dataclasses import dataclass |
13 | 14 | from datetime import datetime, timedelta, timezone |
@@ -372,63 +373,103 @@ def validate_lockfiles(args: argparse.Namespace) -> int: |
372 | 373 | emit_outputs({"cutoff_at": format_datetime(cutoff), "reverted_files": 0}, args.github_output) |
373 | 374 | return 0 |
374 | 375 |
|
| 376 | + baseline_lockfiles = collect_lockfiles(baseline_dir) |
| 377 | + |
375 | 378 | changed_by_file: dict[str, list[str]] = {} |
376 | 379 | for relative_path, gav in changed: |
377 | 380 | changed_by_file.setdefault(relative_path, []).append(gav) |
378 | 381 |
|
379 | 382 | 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]] = {} |
382 | 384 | violations_by_file: dict[str, list[tuple[str, str]]] = {} |
383 | 385 | for relative_path, gavs in sorted(changed_by_file.items()): |
| 386 | + baseline_coords = baseline_lockfiles.get(relative_path, set()) |
384 | 387 | for gav in gavs: |
385 | 388 | if gav not in timestamp_cache: |
386 | 389 | timestamp_cache[gav] = resolve_gav_timestamp(gav=gav, metadata=metadata, repo_urls=repo_urls) |
387 | 390 | published_at, reason = timestamp_cache[gav] |
388 | 391 | 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")) |
390 | 393 | 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")) |
392 | 407 | else: |
393 | 408 | print(f"Verified {gav} (published {format_datetime(published_at)}, cutoff {format_datetime(cutoff)})") |
394 | 409 |
|
| 410 | + if replacements_by_file: |
| 411 | + apply_lockfile_replacements(replacements_by_file=replacements_by_file, current_dir=current_dir) |
| 412 | + |
395 | 413 | if violations_by_file: |
396 | 414 | revert_lockfiles_to_baseline(violations_by_file=violations_by_file, baseline_dir=baseline_dir, current_dir=current_dir) |
397 | 415 | for relative_path, entries in sorted(violations_by_file.items()): |
398 | 416 | 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.") |
400 | 418 |
|
401 | 419 | 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) |
403 | 421 | emit_outputs({"cutoff_at": format_datetime(cutoff), "reverted_files": reverted_files, "summary": summary}, args.github_output) |
404 | 422 | print(f"Validated {len(changed)} changed coordinate(s) across {len(changed_by_file)} lockfile(s). {reverted_files} lockfile(s) reverted.") |
405 | 423 | return 0 |
406 | 424 |
|
407 | 425 |
|
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: |
411 | 434 | 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", ""] |
423 | 436 | 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)") |
424 | 442 | for entries in violations_by_file.values(): |
425 | 443 | for gav, kind in entries: |
426 | 444 | if gav not in seen: |
427 | 445 | 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)") |
429 | 448 | return "\n".join(lines) |
430 | 449 |
|
431 | 450 |
|
| 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 | + |
432 | 473 | # restore each violating lockfile to its baseline copy to keep the file consistent |
433 | 474 | def revert_lockfiles_to_baseline( |
434 | 475 | *, |
@@ -493,6 +534,60 @@ def _head_pom_timestamp(pom_url: str) -> datetime | None: |
493 | 534 | return None |
494 | 535 |
|
495 | 536 |
|
| 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 | + |
496 | 591 | # load optional metadata overrides from a JSON file (group:artifact:version -> timestamp) |
497 | 592 | def load_metadata_overrides(path: str | None) -> dict[str, Any]: |
498 | 593 | if not path: |
|
0 commit comments