55import os
66import re
77import sys
8+ import urllib .error
89import urllib .parse
910import urllib .request
1011from dataclasses import dataclass
1819DEFAULT_MIN_AGE_HOURS = 48
1920
2021
21-
2222@dataclass (frozen = True )
2323class Candidate :
2424 version : str
@@ -28,6 +28,7 @@ class Candidate:
2828# Entry point for GitHub Actions workflows
2929# select-gradle: get newest Gradle release that is at least MIN_DEPENDENCY_AGE_HOURS hours old
3030# select-maven: get newest Maven artifact release that is at least MIN_DEPENDENCY_AGE_HOURS hours old
31+ # validate-lockfiles: check that each new coordinate in the Gradle lockfiles is at least MIN_DEPENDENCY_AGE_HOURS hours old
3132def parse_args () -> argparse .Namespace :
3233 parser = argparse .ArgumentParser (description = "Dependency age helpers for GitHub workflows." )
3334 subparsers = parser .add_subparsers (dest = "command" , required = True )
@@ -50,6 +51,15 @@ def parse_args() -> argparse.Namespace:
5051 help = "Case-insensitive regex fragment used to exclude prerelease versions." ,
5152 )
5253
54+ validate = subparsers .add_parser ("validate-lockfiles" , help = "Validate age of new coordinates in Gradle lockfiles." )
55+ validate .add_argument ("--baseline-dir" , required = True )
56+ validate .add_argument ("--current-dir" , default = "." )
57+ validate .add_argument ("--metadata-file" , help = "JSON file mapping group:artifact:version to a timestamp override." )
58+ validate .add_argument ("--search-url" , default = MAVEN_SEARCH_URL )
59+ validate .add_argument ("--min-age-hours" , type = int , default = default_min_age_hours ())
60+ validate .add_argument ("--now" )
61+ validate .add_argument ("--github-output" , default = None )
62+
5363 return parser .parse_args ()
5464
5565
@@ -98,7 +108,7 @@ def parse_datetime(value: Any) -> datetime:
98108 except ValueError :
99109 pass
100110
101- # ISO 8601: normalise Z and +HHMM → +HH:MM for fromisoformat
111+ # ISO 8601: normalise Z and +HHMM -> +HH:MM for fromisoformat
102112 text = re .sub (r"([+-])(\d{2})(\d{2})$" , r"\1\2:\3" , text .replace ("Z" , "+00:00" ))
103113 return datetime .fromisoformat (text ).astimezone (timezone .utc )
104114
@@ -120,8 +130,12 @@ def emit_outputs(outputs: dict[str, Any], github_output: str | None) -> None:
120130 print (line )
121131 if github_output :
122132 with open (github_output , "a" , encoding = "utf-8" ) as handle :
123- for line in lines :
124- handle .write (f"{ line } \n " )
133+ for key , value in outputs .items ():
134+ text = "" if value is None else str (value )
135+ if "\n " in text :
136+ handle .write (f"{ key } <<__EOF__\n { text } \n __EOF__\n " )
137+ else :
138+ handle .write (f"{ key } ={ text } \n " )
125139
126140
127141# load JSON from file or URL
@@ -156,7 +170,6 @@ def select_gradle_release(args: argparse.Namespace) -> int:
156170
157171 return emit_selection_result (
158172 label = "Gradle" ,
159- cutoff = cutoff ,
160173 github_output = args .github_output ,
161174 candidates = candidates ,
162175 not_found_reason = (
@@ -189,7 +202,6 @@ def select_maven_release(args: argparse.Namespace) -> int:
189202
190203 return emit_selection_result (
191204 label = f"{ args .group_id } :{ args .artifact_id } " ,
192- cutoff = cutoff ,
193205 github_output = args .github_output ,
194206 candidates = candidates ,
195207 not_found_reason = (
@@ -246,7 +258,7 @@ def load_maven_documents(
246258 return docs
247259
248260
249- # parse a version string into a tuple of ints for numeric comparison (e.g. "3.9.11" → (3, 9, 11))
261+ # parse a version string into a sortable tuple for comparison; numeric segments sort before non-numeric
250262def _version_sort_key (version : str ) -> tuple :
251263 segments = []
252264 for segment in re .split (r"([.\-])" , version ):
@@ -272,7 +284,6 @@ def _version_sort_key(version: str) -> tuple:
272284def emit_selection_result (
273285 * ,
274286 label : str ,
275- cutoff : datetime ,
276287 github_output : str | None ,
277288 candidates : list [Candidate ],
278289 not_found_reason : str ,
@@ -336,12 +347,220 @@ def emit_selection_result(
336347 return 0
337348
338349
350+ # check that every new coordinate in the Gradle lockfiles is at least min_age_hours old
351+ def validate_lockfiles (args : argparse .Namespace ) -> int :
352+ cutoff = now_utc (args .now ) - timedelta (hours = args .min_age_hours )
353+ baseline_dir = Path (args .baseline_dir )
354+ current_dir = Path (args .current_dir )
355+ metadata = load_metadata_overrides (args .metadata_file )
356+
357+ # Guard against a silent snapshot failure: if baseline is empty but current has lockfiles,
358+ # every coordinate would appear "new" and the age check would be meaningless
359+ baseline_has_lockfiles = baseline_dir .exists () and any (baseline_dir .rglob ("gradle.lockfile" ))
360+ current_has_lockfiles = any (current_dir .rglob ("gradle.lockfile" ))
361+ if not baseline_has_lockfiles and current_has_lockfiles :
362+ print ("::error::Baseline has no lockfiles but current directory does — the snapshot step may have failed." )
363+ emit_outputs ({"cutoff_at" : format_datetime (cutoff ), "reverted_files" : 0 }, args .github_output )
364+ return 1
365+
366+ changed = changed_lockfile_coordinates (baseline_dir = baseline_dir , current_dir = current_dir )
367+ if not changed :
368+ print ("No dependency version changes detected across Gradle lockfiles." )
369+ emit_outputs ({"cutoff_at" : format_datetime (cutoff ), "reverted_files" : 0 }, args .github_output )
370+ return 0
371+
372+ changed_by_file : dict [str , list [str ]] = {}
373+ for relative_path , gav in changed :
374+ changed_by_file .setdefault (relative_path , []).append (gav )
375+
376+ timestamp_cache : dict [str , tuple [datetime | None , str | None ]] = {}
377+ too_new = "too_new"
378+ unverified = "unverified"
379+ violations_by_file : dict [str , list [tuple [str , str ]]] = {}
380+ for relative_path , gavs in sorted (changed_by_file .items ()):
381+ for gav in gavs :
382+ if gav not in timestamp_cache :
383+ timestamp_cache [gav ] = resolve_gav_timestamp (gav = gav , metadata = metadata , search_url = args .search_url )
384+ published_at , reason = timestamp_cache [gav ]
385+ if published_at is None :
386+ violations_by_file .setdefault (relative_path , []).append ((gav , unverified ))
387+ elif published_at > cutoff :
388+ violations_by_file .setdefault (relative_path , []).append ((gav , too_new ))
389+ else :
390+ print (f"Verified { gav } (published { format_datetime (published_at )} , cutoff { format_datetime (cutoff )} )" )
391+
392+ if violations_by_file :
393+ revert_lockfiles_to_baseline (violations_by_file = violations_by_file , baseline_dir = baseline_dir , current_dir = current_dir )
394+ for relative_path , entries in sorted (violations_by_file .items ()):
395+ for gav , kind in entries :
396+ print (f"::warning file={ relative_path } ::{ gav } : { 'Cannot verify age' if kind == unverified else 'Too new' } . Reverted lockfile to baseline." )
397+
398+ reverted_files = len (violations_by_file )
399+ summary = build_validation_summary (violations_by_file = violations_by_file , min_age_hours = args .min_age_hours )
400+ emit_outputs ({"cutoff_at" : format_datetime (cutoff ), "reverted_files" : reverted_files , "summary" : summary }, args .github_output )
401+ print (f"Validated { len (changed )} changed coordinate(s) across { len (changed_by_file )} lockfile(s). { reverted_files } lockfile(s) reverted." )
402+ return 0
403+
404+
405+ # build summary of reverted dependencies for PR descriptions
406+ def build_validation_summary (* , violations_by_file : dict [str , list [tuple [str , str ]]], min_age_hours : int ) -> str :
407+ if not violations_by_file :
408+ return ""
409+ summary_messages = {
410+ "too_new" : f"Did not meet { min_age_hours } h dependency age requirement" ,
411+ "unverified" : "Cannot verify age in Maven Central" ,
412+ }
413+ lines = [
414+ f"## Dependency age policy" ,
415+ f"" ,
416+ f"The following dependencies were reverted:" ,
417+ f"" ,
418+ ]
419+ # deduplicate
420+ seen : set [str ] = set ()
421+ for entries in violations_by_file .values ():
422+ for gav , kind in entries :
423+ if gav not in seen :
424+ seen .add (gav )
425+ lines .append (f"- `{ gav } ` — { summary_messages [kind ]} " )
426+ return "\n " .join (lines )
427+
428+
429+ # restore each violating lockfile to its baseline copy to keep the file consistent
430+ def revert_lockfiles_to_baseline (
431+ * ,
432+ violations_by_file : dict [str , list [tuple [str , str ]]],
433+ baseline_dir : Path ,
434+ current_dir : Path ,
435+ ) -> None :
436+ for relative_path in sorted (violations_by_file ):
437+ current_path = current_dir / relative_path
438+ baseline_path = baseline_dir / relative_path
439+ if baseline_path .exists ():
440+ current_path .write_text (baseline_path .read_text (encoding = "utf-8" ), encoding = "utf-8" )
441+ print (f"Reverted { relative_path } to baseline." )
442+ else :
443+ current_path .unlink (missing_ok = True )
444+ print (f"Removed new lockfile { relative_path } (no baseline copy to restore)." )
445+
446+
447+ # look up the publish timestamp for a group:artifact:version coordinate in Maven Central
448+ # returns (datetime, None) on success, (None, reason) when the timestamp cannot be determined
449+ # retries once on any error
450+ def resolve_gav_timestamp (
451+ * ,
452+ gav : str ,
453+ metadata : dict [str , Any ],
454+ search_url : str ,
455+ ) -> tuple [datetime | None , str | None ]:
456+ if gav in metadata :
457+ return parse_metadata_override (gav , metadata [gav ])
458+
459+ group_id , artifact_id , version = gav .split (":" , 2 )
460+ query = urllib .parse .urlencode ({
461+ "q" : f'g:"{ group_id } " AND a:"{ artifact_id } " AND v:"{ version } "' ,
462+ "core" : "gav" ,
463+ "rows" : 20 ,
464+ "wt" : "json" ,
465+ })
466+ url = f"{ search_url } ?{ query } "
467+ payload = None
468+ fetch_error = None
469+ for attempt in range (2 ):
470+ try :
471+ payload = load_json (None , url )
472+ fetch_error = None
473+ break
474+ except urllib .error .HTTPError as exc :
475+ fetch_error = f"Maven Central search failed (HTTP { exc .code } )."
476+ except (urllib .error .URLError , TimeoutError , OSError , ValueError ):
477+ fetch_error = "Maven Central search was unreachable."
478+
479+ if fetch_error :
480+ return None , fetch_error
481+
482+ for doc in payload .get ("response" , {}).get ("docs" , []):
483+ if doc .get ("v" ) != version :
484+ continue
485+ timestamp = doc .get ("timestamp" )
486+ if timestamp is None :
487+ return None , "Maven Central search result did not include a publish timestamp."
488+ try :
489+ return parse_datetime (timestamp ), None
490+ except (ValueError , TypeError ) as exc :
491+ return None , f"Maven Central returned an unparseable timestamp for { gav } : { exc } "
492+ return None , f"{ gav } was not found in Maven Central. Add an entry in --metadata-file to bypass."
493+
494+
495+ # load optional metadata overrides from a JSON file (group:artifact:version -> timestamp)
496+ def load_metadata_overrides (path : str | None ) -> dict [str , Any ]:
497+ if not path :
498+ return {}
499+ return load_json (path , None )
500+
501+
502+ # parse a single metadata override value: a timestamp string/number, or a dict with a timestamp key
503+ def parse_metadata_override (gav : str , override : Any ) -> tuple [datetime | None , str | None ]:
504+ if isinstance (override , dict ):
505+ for key in ("timestamp" , "published_at" , "timestamp_ms" ):
506+ if key in override :
507+ try :
508+ return parse_datetime (override [key ]), None
509+ except (ValueError , TypeError ) as exc :
510+ return None , f"Metadata override for { gav } has an invalid timestamp: { exc } "
511+ return None , f"Metadata override for { gav } is missing a timestamp key (expected: timestamp, published_at, or timestamp_ms)."
512+ if isinstance (override , (int , float , str )):
513+ try :
514+ return parse_datetime (override ), None
515+ except (ValueError , TypeError ) as exc :
516+ return None , f"Metadata override for { gav } has an invalid timestamp: { exc } "
517+ return None , f"Unsupported metadata override format for { gav } ."
518+
519+
520+ # diff baseline and current lockfile directories; return (relative_path, gav) for each added or changed coordinate
521+ def changed_lockfile_coordinates (* , baseline_dir : Path , current_dir : Path ) -> list [tuple [str , str ]]:
522+ changed : list [tuple [str , str ]] = []
523+ baseline_lockfiles = collect_lockfiles (baseline_dir )
524+ current_lockfiles = collect_lockfiles (current_dir )
525+ for relative_path in sorted (set (baseline_lockfiles ) | set (current_lockfiles )):
526+ before = baseline_lockfiles .get (relative_path , set ())
527+ after = current_lockfiles .get (relative_path , set ())
528+ for gav in sorted (after - before ):
529+ changed .append ((relative_path , gav ))
530+ return changed
531+
532+
533+ # recursively find all gradle.lockfile paths under root and parse them into sets of coordinates
534+ def collect_lockfiles (root : Path ) -> dict [str , set [str ]]:
535+ if not root .exists ():
536+ return {}
537+ return {
538+ str (path .relative_to (root )): parse_lockfile (path )
539+ for path in root .rglob ("gradle.lockfile" )
540+ }
541+
542+
543+ # parse a lockfile into a set of group:artifact:version coordinates (skipping comments and empty lines)
544+ def parse_lockfile (path : Path ) -> set [str ]:
545+ coordinates : set [str ] = set ()
546+ for line in path .read_text (encoding = "utf-8" ).splitlines ():
547+ line = line .strip ()
548+ if not line or line .startswith ("#" ):
549+ continue
550+ coordinate = line .split ("=" , 1 )[0 ]
551+ if coordinate .count (":" ) == 2 :
552+ coordinates .add (coordinate )
553+ return coordinates
554+
555+
339556def main () -> int :
340557 args = parse_args ()
341558 if args .command == "select-gradle" :
342559 return select_gradle_release (args )
343560 if args .command == "select-maven" :
344561 return select_maven_release (args )
562+ if args .command == "validate-lockfiles" :
563+ return validate_lockfiles (args )
345564 raise ValueError (f"Unsupported command: { args .command } " )
346565
347566
0 commit comments