Skip to content

Commit 07d6e0d

Browse files
authored
Merge branch 'master' into bdu/revert-test-exception-synthetic-skip
2 parents bc8f0c9 + df1784e commit 07d6e0d

32 files changed

Lines changed: 3660 additions & 82 deletions

.github/scripts/dependency_age.py

Lines changed: 227 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66
import re
77
import sys
8+
import urllib.error
89
import urllib.parse
910
import urllib.request
1011
from dataclasses import dataclass
@@ -18,7 +19,6 @@
1819
DEFAULT_MIN_AGE_HOURS = 48
1920

2021

21-
2222
@dataclass(frozen=True)
2323
class 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
3132
def 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
250262
def _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:
272284
def 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+
339556
def 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

Comments
 (0)