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
@@ -293,12 +303,168 @@ def emit_selection_result(
293303 return 0
294304
295305
306+ # check that every new coordinate in the Gradle lockfiles is at least min_age_hours old
307+ def validate_lockfiles (args : argparse .Namespace ) -> int :
308+ cutoff = now_utc (args .now ) - timedelta (hours = args .min_age_hours )
309+ baseline_dir = Path (args .baseline_dir )
310+ current_dir = Path (args .current_dir )
311+ metadata = load_metadata_overrides (args .metadata_file )
312+
313+ changed = changed_lockfile_coordinates (baseline_dir = baseline_dir , current_dir = current_dir )
314+ if not changed :
315+ print ("No dependency version changes detected across Gradle lockfiles." )
316+ emit_outputs ({"cutoff_at" : format_datetime (cutoff ), "reverted_files" : 0 }, args .github_output )
317+ return 0
318+
319+ changed_by_file : dict [str , list [str ]] = {}
320+ for relative_path , gav in changed :
321+ changed_by_file .setdefault (relative_path , []).append (gav )
322+
323+ timestamp_cache : dict [str , tuple [datetime | None , str | None ]] = {}
324+ violations_by_file : dict [str , list [tuple [str , str ]]] = {}
325+ for relative_path , gavs in sorted (changed_by_file .items ()):
326+ for gav in gavs :
327+ if gav not in timestamp_cache :
328+ timestamp_cache [gav ] = resolve_gav_timestamp (gav = gav , metadata = metadata , search_url = args .search_url )
329+ published_at , reason = timestamp_cache [gav ]
330+ if published_at is None :
331+ print (f"::warning file={ relative_path } ::{ gav } : { reason } Skipping age check." )
332+ continue
333+ if published_at > cutoff :
334+ violations_by_file .setdefault (relative_path , []).append (
335+ (gav , f"Published at { format_datetime (published_at )} , cutoff { format_datetime (cutoff )} ." )
336+ )
337+ else :
338+ print (f"Verified { gav } (published { format_datetime (published_at )} , cutoff { format_datetime (cutoff )} )" )
339+
340+ if violations_by_file :
341+ revert_lockfiles_to_baseline (violations_by_file = violations_by_file , baseline_dir = baseline_dir , current_dir = current_dir )
342+ for relative_path , entries in sorted (violations_by_file .items ()):
343+ for gav , message in entries :
344+ print (f"::warning file={ relative_path } ::{ gav } : { message } Reverted lockfile to baseline." )
345+
346+ reverted_files = len (violations_by_file )
347+ emit_outputs ({"cutoff_at" : format_datetime (cutoff ), "reverted_files" : reverted_files }, args .github_output )
348+ print (f"Validated { len (changed )} changed coordinate(s) across { len (changed_by_file )} lockfile(s). { reverted_files } lockfile(s) reverted." )
349+ return 0
350+
351+
352+ # restore each violating lockfile to its baseline copy to keep the file consistent
353+ def revert_lockfiles_to_baseline (
354+ * ,
355+ violations_by_file : dict [str , list [tuple [str , str ]]],
356+ baseline_dir : Path ,
357+ current_dir : Path ,
358+ ) -> None :
359+ for relative_path in sorted (violations_by_file ):
360+ current_path = current_dir / relative_path
361+ baseline_path = baseline_dir / relative_path
362+ if baseline_path .exists ():
363+ current_path .write_text (baseline_path .read_text (encoding = "utf-8" ), encoding = "utf-8" )
364+ print (f"Reverted { relative_path } to baseline." )
365+ else :
366+ current_path .unlink (missing_ok = True )
367+ print (f"Removed new lockfile { relative_path } (no baseline copy to restore)." )
368+
369+
370+ # look up the publish timestamp for a group:artifact:version coordinate in Maven Central
371+ # returns (datetime, None) on success, (None, reason) when the timestamp cannot be determined
372+ def resolve_gav_timestamp (
373+ * ,
374+ gav : str ,
375+ metadata : dict [str , Any ],
376+ search_url : str ,
377+ ) -> tuple [datetime | None , str | None ]:
378+ if gav in metadata :
379+ return parse_metadata_override (gav , metadata [gav ])
380+
381+ group_id , artifact_id , version = gav .split (":" , 2 )
382+ query = urllib .parse .urlencode ({
383+ "q" : f'g:"{ group_id } " AND a:"{ artifact_id } " AND v:"{ version } "' ,
384+ "core" : "gav" ,
385+ "rows" : 20 ,
386+ "wt" : "json" ,
387+ })
388+ try :
389+ payload = load_json (None , f"{ search_url } ?{ query } " )
390+ docs = payload .get ("response" , {}).get ("docs" , [])
391+ except (urllib .error .HTTPError , urllib .error .URLError , TimeoutError , ValueError ):
392+ return None , "Maven Central search was unreachable."
393+ for doc in docs :
394+ if doc .get ("v" ) != version :
395+ continue
396+ timestamp = doc .get ("timestamp" )
397+ if timestamp is None :
398+ return None , "Maven Central search result did not include a publish timestamp."
399+ return parse_datetime (timestamp ), None
400+ return None , f"No metadata found in Maven Central for { gav } ."
401+
402+
403+ # load optional metadata overrides from a JSON file (group:artifact:version -> timestamp or skip reason)
404+ def load_metadata_overrides (path : str | None ) -> dict [str , Any ]:
405+ if not path :
406+ return {}
407+ return load_json (path , None )
408+
409+
410+ # parse a single metadata override value: a timestamp string/number, or a dict with "reason" to skip
411+ def parse_metadata_override (gav : str , override : Any ) -> tuple [datetime | None , str | None ]:
412+ if isinstance (override , dict ):
413+ if "reason" in override :
414+ return None , str (override ["reason" ])
415+ for key in ("timestamp" , "published_at" , "timestamp_ms" ):
416+ if key in override :
417+ return parse_datetime (override [key ]), None
418+ return None , f"Metadata override for { gav } is missing a timestamp."
419+ if isinstance (override , (int , float , str )):
420+ return parse_datetime (override ), None
421+ return None , f"Unsupported metadata override format for { gav } ."
422+
423+
424+ # diff baseline and current lockfile directories; return (relative_path, gav) for each new coordinate
425+ def changed_lockfile_coordinates (* , baseline_dir : Path , current_dir : Path ) -> list [tuple [str , str ]]:
426+ changed : list [tuple [str , str ]] = []
427+ baseline_lockfiles = collect_lockfiles (baseline_dir )
428+ current_lockfiles = collect_lockfiles (current_dir )
429+ for relative_path in sorted (set (baseline_lockfiles ) | set (current_lockfiles )):
430+ before = baseline_lockfiles .get (relative_path , set ())
431+ after = current_lockfiles .get (relative_path , set ())
432+ for gav in sorted (after - before ):
433+ changed .append ((relative_path , gav ))
434+ return changed
435+
436+
437+ # recursively find all gradle.lockfile paths under root and parse them into sets of coordinates
438+ def collect_lockfiles (root : Path ) -> dict [str , set [str ]]:
439+ if not root .exists ():
440+ return {}
441+ return {
442+ str (path .relative_to (root )): parse_lockfile (path )
443+ for path in root .rglob ("gradle.lockfile" )
444+ }
445+
446+
447+ # parse a lockfile into a set of group:artifact:version coordinates (skipping comments and empty lines)
448+ def parse_lockfile (path : Path ) -> set [str ]:
449+ coordinates : set [str ] = set ()
450+ for line in path .read_text (encoding = "utf-8" ).splitlines ():
451+ line = line .strip ()
452+ if not line or line .startswith ("#" ):
453+ continue
454+ coordinate = line .split ("=" , 1 )[0 ]
455+ if coordinate .count (":" ) == 2 :
456+ coordinates .add (coordinate )
457+ return coordinates
458+
459+
296460def main () -> int :
297461 args = parse_args ()
298462 if args .command == "select-gradle" :
299463 return select_gradle_release (args )
300464 if args .command == "select-maven" :
301465 return select_maven_release (args )
466+ if args .command == "validate-lockfiles" :
467+ return validate_lockfiles (args )
302468 raise ValueError (f"Unsupported command: { args .command } " )
303469
304470
0 commit comments