From 355ced273ea4a89ad9255edd44194eed787e1150 Mon Sep 17 00:00:00 2001 From: Sarah Chen Date: Wed, 6 May 2026 14:22:39 -0400 Subject: [PATCH 1/5] Implement 48h cooldown for gradle dependencies --- .github/scripts/dependency_age.py | 172 +++++++++++++++++- .github/scripts/tests/test_dependency_age.py | 134 +++++++++++++- .../workflows/update-gradle-dependencies.yaml | 15 ++ 3 files changed, 317 insertions(+), 4 deletions(-) diff --git a/.github/scripts/dependency_age.py b/.github/scripts/dependency_age.py index cfc8e707a63..ad0da39a049 100644 --- a/.github/scripts/dependency_age.py +++ b/.github/scripts/dependency_age.py @@ -5,6 +5,7 @@ import os import re import sys +import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass @@ -18,7 +19,6 @@ DEFAULT_MIN_AGE_HOURS = 48 - @dataclass(frozen=True) class Candidate: version: str @@ -28,6 +28,7 @@ class Candidate: # Entry point for GitHub Actions workflows # select-gradle: get newest Gradle release that is at least MIN_DEPENDENCY_AGE_HOURS hours old # select-maven: get newest Maven artifact release that is at least MIN_DEPENDENCY_AGE_HOURS hours old +# validate-lockfiles: check that each new coordinate in the Gradle lockfiles is at least MIN_DEPENDENCY_AGE_HOURS hours old def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Dependency age helpers for GitHub workflows.") subparsers = parser.add_subparsers(dest="command", required=True) @@ -50,6 +51,15 @@ def parse_args() -> argparse.Namespace: help="Case-insensitive regex fragment used to exclude prerelease versions.", ) + validate = subparsers.add_parser("validate-lockfiles", help="Validate age of new coordinates in Gradle lockfiles.") + validate.add_argument("--baseline-dir", required=True) + validate.add_argument("--current-dir", default=".") + validate.add_argument("--metadata-file", help="JSON file mapping group:artifact:version to a timestamp override.") + validate.add_argument("--search-url", default=MAVEN_SEARCH_URL) + validate.add_argument("--min-age-hours", type=int, default=default_min_age_hours()) + validate.add_argument("--now") + validate.add_argument("--github-output", default=None) + return parser.parse_args() @@ -97,7 +107,7 @@ def parse_datetime(value: Any) -> datetime: except ValueError: pass - # ISO 8601: normalise Z and +HHMM → +HH:MM for fromisoformat + # ISO 8601: normalise Z and +HHMM -> +HH:MM for fromisoformat text = re.sub(r"([+-])(\d{2})(\d{2})$", r"\1\2:\3", text.replace("Z", "+00:00")) return datetime.fromisoformat(text).astimezone(timezone.utc) @@ -238,7 +248,7 @@ def load_maven_documents( return docs -# parse a version string into a tuple of ints for numeric comparison (e.g. "3.9.11" → (3, 9, 11)) +# parse a version string into a tuple of ints for numeric comparison (e.g. "3.9.11" -> (3, 9, 11)) def _version_sort_key(version: str) -> tuple: parts = [] for segment in re.split(r"([.\-])", version): @@ -293,12 +303,168 @@ def emit_selection_result( return 0 +# check that every new coordinate in the Gradle lockfiles is at least min_age_hours old +def validate_lockfiles(args: argparse.Namespace) -> int: + cutoff = now_utc(args.now) - timedelta(hours=args.min_age_hours) + baseline_dir = Path(args.baseline_dir) + current_dir = Path(args.current_dir) + metadata = load_metadata_overrides(args.metadata_file) + + changed = changed_lockfile_coordinates(baseline_dir=baseline_dir, current_dir=current_dir) + if not changed: + print("No dependency version changes detected across Gradle lockfiles.") + emit_outputs({"cutoff_at": format_datetime(cutoff), "reverted_files": 0}, args.github_output) + return 0 + + changed_by_file: dict[str, list[str]] = {} + for relative_path, gav in changed: + changed_by_file.setdefault(relative_path, []).append(gav) + + timestamp_cache: dict[str, tuple[datetime | None, str | None]] = {} + violations_by_file: dict[str, list[tuple[str, str]]] = {} + for relative_path, gavs in sorted(changed_by_file.items()): + for gav in gavs: + if gav not in timestamp_cache: + timestamp_cache[gav] = resolve_gav_timestamp(gav=gav, metadata=metadata, search_url=args.search_url) + published_at, reason = timestamp_cache[gav] + if published_at is None: + print(f"::warning file={relative_path}::{gav}: {reason} Skipping age check.") + continue + if published_at > cutoff: + violations_by_file.setdefault(relative_path, []).append( + (gav, f"Published at {format_datetime(published_at)}, cutoff {format_datetime(cutoff)}.") + ) + else: + print(f"Verified {gav} (published {format_datetime(published_at)}, cutoff {format_datetime(cutoff)})") + + if violations_by_file: + revert_lockfiles_to_baseline(violations_by_file=violations_by_file, baseline_dir=baseline_dir, current_dir=current_dir) + for relative_path, entries in sorted(violations_by_file.items()): + for gav, message in entries: + print(f"::warning file={relative_path}::{gav}: {message} Reverted lockfile to baseline.") + + reverted_files = len(violations_by_file) + emit_outputs({"cutoff_at": format_datetime(cutoff), "reverted_files": reverted_files}, args.github_output) + print(f"Validated {len(changed)} changed coordinate(s) across {len(changed_by_file)} lockfile(s). {reverted_files} lockfile(s) reverted.") + return 0 + + +# restore each violating lockfile to its baseline copy to keep the file consistent +def revert_lockfiles_to_baseline( + *, + violations_by_file: dict[str, list[tuple[str, str]]], + baseline_dir: Path, + current_dir: Path, +) -> None: + for relative_path in sorted(violations_by_file): + current_path = current_dir / relative_path + baseline_path = baseline_dir / relative_path + if baseline_path.exists(): + current_path.write_text(baseline_path.read_text(encoding="utf-8"), encoding="utf-8") + print(f"Reverted {relative_path} to baseline.") + else: + current_path.unlink(missing_ok=True) + print(f"Removed new lockfile {relative_path} (no baseline copy to restore).") + + +# look up the publish timestamp for a group:artifact:version coordinate in Maven Central +# returns (datetime, None) on success, (None, reason) when the timestamp cannot be determined +def resolve_gav_timestamp( + *, + gav: str, + metadata: dict[str, Any], + search_url: str, +) -> tuple[datetime | None, str | None]: + if gav in metadata: + return parse_metadata_override(gav, metadata[gav]) + + group_id, artifact_id, version = gav.split(":", 2) + query = urllib.parse.urlencode({ + "q": f'g:"{group_id}" AND a:"{artifact_id}" AND v:"{version}"', + "core": "gav", + "rows": 20, + "wt": "json", + }) + try: + payload = load_json(None, f"{search_url}?{query}") + docs = payload.get("response", {}).get("docs", []) + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, ValueError): + return None, "Maven Central search was unreachable." + for doc in docs: + if doc.get("v") != version: + continue + timestamp = doc.get("timestamp") + if timestamp is None: + return None, "Maven Central search result did not include a publish timestamp." + return parse_datetime(timestamp), None + return None, f"No metadata found in Maven Central for {gav}." + + +# load optional metadata overrides from a JSON file (group:artifact:version -> timestamp or skip reason) +def load_metadata_overrides(path: str | None) -> dict[str, Any]: + if not path: + return {} + return load_json(path, None) + + +# parse a single metadata override value: a timestamp string/number, or a dict with "reason" to skip +def parse_metadata_override(gav: str, override: Any) -> tuple[datetime | None, str | None]: + if isinstance(override, dict): + if "reason" in override: + return None, str(override["reason"]) + for key in ("timestamp", "published_at", "timestamp_ms"): + if key in override: + return parse_datetime(override[key]), None + return None, f"Metadata override for {gav} is missing a timestamp." + if isinstance(override, (int, float, str)): + return parse_datetime(override), None + return None, f"Unsupported metadata override format for {gav}." + + +# diff baseline and current lockfile directories; return (relative_path, gav) for each new coordinate +def changed_lockfile_coordinates(*, baseline_dir: Path, current_dir: Path) -> list[tuple[str, str]]: + changed: list[tuple[str, str]] = [] + baseline_lockfiles = collect_lockfiles(baseline_dir) + current_lockfiles = collect_lockfiles(current_dir) + for relative_path in sorted(set(baseline_lockfiles) | set(current_lockfiles)): + before = baseline_lockfiles.get(relative_path, set()) + after = current_lockfiles.get(relative_path, set()) + for gav in sorted(after - before): + changed.append((relative_path, gav)) + return changed + + +# recursively find all gradle.lockfile paths under root and parse them into sets of coordinates +def collect_lockfiles(root: Path) -> dict[str, set[str]]: + if not root.exists(): + return {} + return { + str(path.relative_to(root)): parse_lockfile(path) + for path in root.rglob("gradle.lockfile") + } + + +# parse a lockfile into a set of group:artifact:version coordinates (skipping comments and empty lines) +def parse_lockfile(path: Path) -> set[str]: + coordinates: set[str] = set() + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + coordinate = line.split("=", 1)[0] + if coordinate.count(":") == 2: + coordinates.add(coordinate) + return coordinates + + def main() -> int: args = parse_args() if args.command == "select-gradle": return select_gradle_release(args) if args.command == "select-maven": return select_maven_release(args) + if args.command == "validate-lockfiles": + return validate_lockfiles(args) raise ValueError(f"Unsupported command: {args.command}") diff --git a/.github/scripts/tests/test_dependency_age.py b/.github/scripts/tests/test_dependency_age.py index 4ee3a8d0f50..93ab781b386 100644 --- a/.github/scripts/tests/test_dependency_age.py +++ b/.github/scripts/tests/test_dependency_age.py @@ -1,6 +1,9 @@ +import json import os import re +import shutil import subprocess +import tempfile import unittest from pathlib import Path @@ -10,7 +13,7 @@ FIXTURES = Path(__file__).resolve().parent / "fixtures" NOW = "2026-04-24T12:00:00Z" OUTPUT_PATTERN = re.compile( - r"^(cutoff_at|found|version|published_at|reason)=(.*)$" + r"^(cutoff_at|found|version|published_at|reason|reverted_files)=(.*)$" ) @@ -124,5 +127,134 @@ def test_exact_48_hour_boundary_is_accepted(self) -> None: self.assertEqual(outputs["published_at"], "2026-04-22T12:00:00Z") + def run_validate_lockfiles( + self, + *, + baseline: dict[str, str], + current: dict[str, str], + metadata: dict, + now: str = NOW, + ) -> tuple[subprocess.CompletedProcess[str], Path]: + """ + Run validate-lockfiles with in-memory lockfile content. + baseline/current map relative paths to file text. + All coordinates must be covered by metadata — any uncovered coordinate + hits the (unreachable) search URL and is warned+skipped. + """ + tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, tmp, True) + baseline_dir = tmp / "before" + current_dir = tmp / "after" + metadata_file = tmp / "metadata.json" + + for rel_path, content in baseline.items(): + p = baseline_dir / rel_path + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content, encoding="utf-8") + + for rel_path, content in current.items(): + p = current_dir / rel_path + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content, encoding="utf-8") + + metadata_file.write_text(json.dumps(metadata), encoding="utf-8") + + result = self.run_script( + "validate-lockfiles", + "--baseline-dir", str(baseline_dir), + "--current-dir", str(current_dir), + "--metadata-file", str(metadata_file), + "--search-url", (tmp / "no-network").as_uri(), + "--now", now, + ) + return result, current_dir + + def test_validates_changed_lockfiles_when_all_updates_are_old_enough(self) -> None: + baseline_content = "# lockfile\ncom.example:lib-a:1.0.0=runtimeClasspath\ncom.example:lib-b:1.0.0=runtimeClasspath\n" + current_content = "# lockfile\ncom.example:lib-a:1.1.0=runtimeClasspath\ncom.example:lib-b:1.1.0=runtimeClasspath\n" + metadata = { + "com.example:lib-a:1.1.0": "2026-04-20T12:00:00Z", + "com.example:lib-b:1.1.0": "2026-04-20T11:00:00Z", + } + + result, current_dir = self.run_validate_lockfiles( + baseline={"module/gradle.lockfile": baseline_content}, + current={"module/gradle.lockfile": current_content}, + metadata=metadata, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + outputs = self.parse_outputs(result.stdout) + self.assertEqual(outputs["reverted_files"], "0") + self.assertEqual((current_dir / "module/gradle.lockfile").read_text(encoding="utf-8"), current_content) + + def test_reverts_lockfile_when_any_changed_dependency_is_too_new(self) -> None: + baseline_content = "# lockfile\ncom.example:lib-a:1.0.0=runtimeClasspath\ncom.example:lib-b:1.0.0=runtimeClasspath\n" + current_content = "# lockfile\ncom.example:lib-a:1.1.0=runtimeClasspath\ncom.example:lib-b:2.0.0=runtimeClasspath\n" + metadata = { + "com.example:lib-a:1.1.0": "2026-04-20T12:00:00Z", # old enough + "com.example:lib-b:2.0.0": "2026-04-24T11:00:00Z", # too new + } + + result, current_dir = self.run_validate_lockfiles( + baseline={"module/gradle.lockfile": baseline_content}, + current={"module/gradle.lockfile": current_content}, + metadata=metadata, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + outputs = self.parse_outputs(result.stdout) + self.assertEqual(outputs["reverted_files"], "1") + self.assertEqual((current_dir / "module/gradle.lockfile").read_text(encoding="utf-8"), baseline_content) + + def test_reverts_lockfile_when_one_of_multiple_coexisting_versions_is_too_new(self) -> None: + baseline_content = "# lockfile\ncom.typesafe:config:1.3.1=compileClasspath\ncom.typesafe:config:1.4.4=runtimeClasspath\n" + current_content = "# lockfile\ncom.typesafe:config:1.3.1=compileClasspath\ncom.typesafe:config:1.5.0=runtimeClasspath\n" + metadata = { + "com.typesafe:config:1.5.0": "2026-04-24T11:00:00Z", # too new + } + + result, current_dir = self.run_validate_lockfiles( + baseline={"module/gradle.lockfile": baseline_content}, + current={"module/gradle.lockfile": current_content}, + metadata=metadata, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((current_dir / "module/gradle.lockfile").read_text(encoding="utf-8"), baseline_content) + + def test_removes_brand_new_lockfile_with_too_new_dependency(self) -> None: + current_content = "# lockfile\ncom.example:brand-new:1.0.0=runtimeClasspath\n" + metadata = { + "com.example:brand-new:1.0.0": "2026-04-24T11:00:00Z", # too new + } + + result, current_dir = self.run_validate_lockfiles( + baseline={}, + current={"module/gradle.lockfile": current_content}, + metadata=metadata, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse((current_dir / "module/gradle.lockfile").exists()) + + def test_warns_and_skips_coordinate_when_metadata_lookup_fails(self) -> None: + # coordinate not in metadata -> hits unreachable search URL -> warns and skips (does not revert) + baseline_content = "# lockfile\ncom.example:lib:1.0.0=runtimeClasspath\n" + current_content = "# lockfile\ncom.example:lib:1.1.0=runtimeClasspath\n" + + result, current_dir = self.run_validate_lockfiles( + baseline={"module/gradle.lockfile": baseline_content}, + current={"module/gradle.lockfile": current_content}, + metadata={}, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + outputs = self.parse_outputs(result.stdout) + self.assertEqual(outputs["reverted_files"], "0") + self.assertIn("::warning", result.stdout) + self.assertEqual((current_dir / "module/gradle.lockfile").read_text(encoding="utf-8"), current_content) + + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/update-gradle-dependencies.yaml b/.github/workflows/update-gradle-dependencies.yaml index 001c5b38d4e..a62af69cce1 100644 --- a/.github/workflows/update-gradle-dependencies.yaml +++ b/.github/workflows/update-gradle-dependencies.yaml @@ -8,6 +8,8 @@ jobs: update-gradle-dependencies: runs-on: ubuntu-latest name: Update Gradle dependencies + env: + MIN_DEPENDENCY_AGE_HOURS: 48 permissions: contents: read id-token: write # Required for OIDC token federation @@ -41,6 +43,11 @@ jobs: echo "core_branch=ci/update-gradle-dependencies-${DATE}" >> $GITHUB_OUTPUT echo "instrumentation_branch=ci/update-gradle-dependencies-instrumentation-${DATE}" >> $GITHUB_OUTPUT + - name: Snapshot current Gradle lock files + run: | + mkdir -p /tmp/gradle-lockfiles-before + find . -name 'gradle.lockfile' -exec cp --parents {} /tmp/gradle-lockfiles-before/ \; + - name: Update Gradle dependencies env: ORG_GRADLE_PROJECT_akkaRepositoryToken: ${{ secrets.AKKA_REPO_TOKEN }} @@ -49,6 +56,14 @@ jobs: GRADLE_OPTS="-Dorg.gradle.jvmargs='-Xms2G -Xmx3G'" \ ./gradlew resolveAndLockAll --write-locks --parallel --stacktrace --no-daemon --max-workers=4 + - name: Validate changed lock files meet dependency age policy + run: | + python3 .github/scripts/dependency_age.py validate-lockfiles \ + --baseline-dir /tmp/gradle-lockfiles-before \ + --current-dir . \ + --min-age-hours "${MIN_DEPENDENCY_AGE_HOURS}" \ + --github-output "$GITHUB_OUTPUT" + - name: Save instrumentation lock files run: | mkdir -p /tmp/instrumentation-lockfiles From 012c4d9be6e4fa75ba2f3ac1c4f04b3e1f53342f Mon Sep 17 00:00:00 2001 From: Sarah Chen Date: Thu, 7 May 2026 11:25:07 -0400 Subject: [PATCH 2/5] Adjust fail-closed behavior for unverified dependencies and add tests --- .github/scripts/dependency_age.py | 75 ++++++++++++++----- .github/scripts/tests/test_dependency_age.py | 71 +++++++++++++++--- .../workflows/update-gradle-dependencies.yaml | 3 +- 3 files changed, 118 insertions(+), 31 deletions(-) diff --git a/.github/scripts/dependency_age.py b/.github/scripts/dependency_age.py index ad0da39a049..fc122bc07c4 100644 --- a/.github/scripts/dependency_age.py +++ b/.github/scripts/dependency_age.py @@ -248,7 +248,7 @@ def load_maven_documents( return docs -# parse a version string into a tuple of ints for numeric comparison (e.g. "3.9.11" -> (3, 9, 11)) +# parse a version string into a sortable tuple for comparison; numeric segments sort before non-numeric def _version_sort_key(version: str) -> tuple: parts = [] for segment in re.split(r"([.\-])", version): @@ -310,6 +310,15 @@ def validate_lockfiles(args: argparse.Namespace) -> int: current_dir = Path(args.current_dir) metadata = load_metadata_overrides(args.metadata_file) + # Guard against a silent snapshot failure: if baseline is empty but current has lockfiles, + # every coordinate would appear "new" and the age check would be meaningless + baseline_has_lockfiles = baseline_dir.exists() and any(baseline_dir.rglob("gradle.lockfile")) + current_has_lockfiles = any(current_dir.rglob("gradle.lockfile")) + if not baseline_has_lockfiles and current_has_lockfiles: + print("::error::Baseline has no lockfiles but current directory does — the snapshot step may have failed.") + emit_outputs({"cutoff_at": format_datetime(cutoff), "reverted_files": 0}, args.github_output) + return 1 + changed = changed_lockfile_coordinates(baseline_dir=baseline_dir, current_dir=current_dir) if not changed: print("No dependency version changes detected across Gradle lockfiles.") @@ -328,9 +337,11 @@ def validate_lockfiles(args: argparse.Namespace) -> int: timestamp_cache[gav] = resolve_gav_timestamp(gav=gav, metadata=metadata, search_url=args.search_url) published_at, reason = timestamp_cache[gav] if published_at is None: - print(f"::warning file={relative_path}::{gav}: {reason} Skipping age check.") - continue - if published_at > cutoff: + # Cannot verify age — treat as a violation + violations_by_file.setdefault(relative_path, []).append( + (gav, f"Cannot verify age: {reason}") + ) + elif published_at > cutoff: violations_by_file.setdefault(relative_path, []).append( (gav, f"Published at {format_datetime(published_at)}, cutoff {format_datetime(cutoff)}.") ) @@ -369,6 +380,7 @@ def revert_lockfiles_to_baseline( # look up the publish timestamp for a group:artifact:version coordinate in Maven Central # returns (datetime, None) on success, (None, reason) when the timestamp cannot be determined +# retries once on transient 5xx / network errors; 4xx fails immediately (permanent client error) def resolve_gav_timestamp( *, gav: str, @@ -385,43 +397,66 @@ def resolve_gav_timestamp( "rows": 20, "wt": "json", }) - try: - payload = load_json(None, f"{search_url}?{query}") - docs = payload.get("response", {}).get("docs", []) - except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, ValueError): - return None, "Maven Central search was unreachable." - for doc in docs: + url = f"{search_url}?{query}" + payload = None + fetch_error = None + for attempt in range(2): + try: + payload = load_json(None, url) + fetch_error = None + break + except urllib.error.HTTPError as exc: + if exc.code < 500: + return None, ( + f"Maven Central returned HTTP {exc.code} for {gav}. " + "Add an entry in --metadata-file to bypass." + ) + fetch_error = f"Maven Central search failed (HTTP {exc.code})." + except (urllib.error.URLError, TimeoutError, OSError, ValueError): + fetch_error = "Maven Central search was unreachable." + + if fetch_error: + return None, fetch_error + + for doc in payload.get("response", {}).get("docs", []): if doc.get("v") != version: continue timestamp = doc.get("timestamp") if timestamp is None: return None, "Maven Central search result did not include a publish timestamp." - return parse_datetime(timestamp), None - return None, f"No metadata found in Maven Central for {gav}." + try: + return parse_datetime(timestamp), None + except (ValueError, TypeError) as exc: + return None, f"Maven Central returned an unparseable timestamp for {gav}: {exc}" + return None, f"{gav} was not found in Maven Central. Add an entry in --metadata-file to bypass." -# load optional metadata overrides from a JSON file (group:artifact:version -> timestamp or skip reason) +# load optional metadata overrides from a JSON file (group:artifact:version -> timestamp) def load_metadata_overrides(path: str | None) -> dict[str, Any]: if not path: return {} return load_json(path, None) -# parse a single metadata override value: a timestamp string/number, or a dict with "reason" to skip +# parse a single metadata override value: a timestamp string/number, or a dict with a timestamp key def parse_metadata_override(gav: str, override: Any) -> tuple[datetime | None, str | None]: if isinstance(override, dict): - if "reason" in override: - return None, str(override["reason"]) for key in ("timestamp", "published_at", "timestamp_ms"): if key in override: - return parse_datetime(override[key]), None - return None, f"Metadata override for {gav} is missing a timestamp." + try: + return parse_datetime(override[key]), None + except (ValueError, TypeError) as exc: + return None, f"Metadata override for {gav} has an invalid timestamp: {exc}" + return None, f"Metadata override for {gav} is missing a timestamp key (expected: timestamp, published_at, or timestamp_ms)." if isinstance(override, (int, float, str)): - return parse_datetime(override), None + try: + return parse_datetime(override), None + except (ValueError, TypeError) as exc: + return None, f"Metadata override for {gav} has an invalid timestamp: {exc}" return None, f"Unsupported metadata override format for {gav}." -# diff baseline and current lockfile directories; return (relative_path, gav) for each new coordinate +# diff baseline and current lockfile directories; return (relative_path, gav) for each added or changed coordinate def changed_lockfile_coordinates(*, baseline_dir: Path, current_dir: Path) -> list[tuple[str, str]]: changed: list[tuple[str, str]] = [] baseline_lockfiles = collect_lockfiles(baseline_dir) diff --git a/.github/scripts/tests/test_dependency_age.py b/.github/scripts/tests/test_dependency_age.py index 93ab781b386..489d169160f 100644 --- a/.github/scripts/tests/test_dependency_age.py +++ b/.github/scripts/tests/test_dependency_age.py @@ -138,8 +138,8 @@ def run_validate_lockfiles( """ Run validate-lockfiles with in-memory lockfile content. baseline/current map relative paths to file text. - All coordinates must be covered by metadata — any uncovered coordinate - hits the (unreachable) search URL and is warned+skipped. + Any uncovered coordinate hits the (unreachable) search URL and is + treated as a violation (fail-closed), causing the lockfile to be reverted. """ tmp = Path(tempfile.mkdtemp()) self.addCleanup(shutil.rmtree, tmp, True) @@ -224,22 +224,29 @@ def test_reverts_lockfile_when_one_of_multiple_coexisting_versions_is_too_new(se self.assertEqual((current_dir / "module/gradle.lockfile").read_text(encoding="utf-8"), baseline_content) def test_removes_brand_new_lockfile_with_too_new_dependency(self) -> None: - current_content = "# lockfile\ncom.example:brand-new:1.0.0=runtimeClasspath\n" + # A brand-new module has no baseline counterpart — the lockfile should be removed. + # So include an unchanged pre-existing lockfile in both baseline and current to satisfy + # the precondition check that confirms the snapshot step ran successfully. + existing_content = "# lockfile\ncom.existing:lib:1.0.0=runtimeClasspath\n" + new_content = "# lockfile\ncom.example:brand-new:1.0.0=runtimeClasspath\n" metadata = { "com.example:brand-new:1.0.0": "2026-04-24T11:00:00Z", # too new } result, current_dir = self.run_validate_lockfiles( - baseline={}, - current={"module/gradle.lockfile": current_content}, + baseline={"existing/gradle.lockfile": existing_content}, + current={ + "existing/gradle.lockfile": existing_content, + "new-module/gradle.lockfile": new_content, + }, metadata=metadata, ) self.assertEqual(result.returncode, 0, result.stderr) - self.assertFalse((current_dir / "module/gradle.lockfile").exists()) + self.assertFalse((current_dir / "new-module/gradle.lockfile").exists()) - def test_warns_and_skips_coordinate_when_metadata_lookup_fails(self) -> None: - # coordinate not in metadata -> hits unreachable search URL -> warns and skips (does not revert) + def test_reverts_lockfile_when_metadata_lookup_fails(self) -> None: + # coordinate not in metadata -> hits unreachable search URL -> treated as violation (fail-closed) baseline_content = "# lockfile\ncom.example:lib:1.0.0=runtimeClasspath\n" current_content = "# lockfile\ncom.example:lib:1.1.0=runtimeClasspath\n" @@ -251,9 +258,53 @@ def test_warns_and_skips_coordinate_when_metadata_lookup_fails(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) outputs = self.parse_outputs(result.stdout) - self.assertEqual(outputs["reverted_files"], "0") + self.assertEqual(outputs["reverted_files"], "1") self.assertIn("::warning", result.stdout) - self.assertEqual((current_dir / "module/gradle.lockfile").read_text(encoding="utf-8"), current_content) + self.assertEqual((current_dir / "module/gradle.lockfile").read_text(encoding="utf-8"), baseline_content) + + def test_fails_when_baseline_has_no_lockfiles_but_current_does(self) -> None: + # empty baseline with lockfiles in current suggests the snapshot step failed + current_content = "# lockfile\ncom.example:lib:1.0.0=runtimeClasspath\n" + + result, _ = self.run_validate_lockfiles( + baseline={}, + current={"module/gradle.lockfile": current_content}, + metadata={}, + ) + + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("::error::Baseline has no lockfiles", result.stdout) + + def test_exits_cleanly_when_lockfiles_are_identical(self) -> None: + # no changes between baseline and current -> exit 0 with reverted_files=0 + content = "# lockfile\ncom.example:lib:1.0.0=runtimeClasspath\n" + + result, _ = self.run_validate_lockfiles( + baseline={"module/gradle.lockfile": content}, + current={"module/gradle.lockfile": content}, + metadata={}, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + outputs = self.parse_outputs(result.stdout) + self.assertEqual(outputs["reverted_files"], "0") + self.assertIn("No dependency version changes", result.stdout) + + def test_reverts_lockfile_when_metadata_override_has_invalid_timestamp(self) -> None: + # malformed timestamp in metadata override -> cannot verify age -> fail-closed revert + baseline_content = "# lockfile\ncom.example:lib:1.0.0=runtimeClasspath\n" + current_content = "# lockfile\ncom.example:lib:1.1.0=runtimeClasspath\n" + + result, current_dir = self.run_validate_lockfiles( + baseline={"module/gradle.lockfile": baseline_content}, + current={"module/gradle.lockfile": current_content}, + metadata={"com.example:lib:1.1.0": "not-a-valid-date"}, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + outputs = self.parse_outputs(result.stdout) + self.assertEqual(outputs["reverted_files"], "1") + self.assertEqual((current_dir / "module/gradle.lockfile").read_text(encoding="utf-8"), baseline_content) if __name__ == "__main__": diff --git a/.github/workflows/update-gradle-dependencies.yaml b/.github/workflows/update-gradle-dependencies.yaml index a62af69cce1..e6902db8337 100644 --- a/.github/workflows/update-gradle-dependencies.yaml +++ b/.github/workflows/update-gradle-dependencies.yaml @@ -46,7 +46,7 @@ jobs: - name: Snapshot current Gradle lock files run: | mkdir -p /tmp/gradle-lockfiles-before - find . -name 'gradle.lockfile' -exec cp --parents {} /tmp/gradle-lockfiles-before/ \; + find . -name 'gradle.lockfile' -print0 | xargs -0 -r cp --parents -t /tmp/gradle-lockfiles-before/ - name: Update Gradle dependencies env: @@ -57,6 +57,7 @@ jobs: ./gradlew resolveAndLockAll --write-locks --parallel --stacktrace --no-daemon --max-workers=4 - name: Validate changed lock files meet dependency age policy + id: validate-lockfiles run: | python3 .github/scripts/dependency_age.py validate-lockfiles \ --baseline-dir /tmp/gradle-lockfiles-before \ From 8fffa6531a141640d36ae02bedc574040652ce37 Mon Sep 17 00:00:00 2001 From: Sarah Chen Date: Fri, 8 May 2026 16:27:47 -0400 Subject: [PATCH 3/5] Clean up --- .github/scripts/dependency_age.py | 10 +--- .github/scripts/tests/test_dependency_age.py | 54 ------------------- .../workflows/update-gradle-dependencies.yaml | 1 + 3 files changed, 2 insertions(+), 63 deletions(-) diff --git a/.github/scripts/dependency_age.py b/.github/scripts/dependency_age.py index 02ca03f15e2..2891cd555a3 100644 --- a/.github/scripts/dependency_age.py +++ b/.github/scripts/dependency_age.py @@ -166,7 +166,6 @@ def select_gradle_release(args: argparse.Namespace) -> int: return emit_selection_result( label="Gradle", - cutoff=cutoff, github_output=args.github_output, candidates=candidates, not_found_reason=( @@ -199,7 +198,6 @@ def select_maven_release(args: argparse.Namespace) -> int: return emit_selection_result( label=f"{args.group_id}:{args.artifact_id}", - cutoff=cutoff, github_output=args.github_output, candidates=candidates, not_found_reason=( @@ -282,7 +280,6 @@ def _version_sort_key(version: str) -> tuple: def emit_selection_result( *, label: str, - cutoff: datetime, github_output: str | None, candidates: list[Candidate], not_found_reason: str, @@ -423,7 +420,7 @@ def revert_lockfiles_to_baseline( # look up the publish timestamp for a group:artifact:version coordinate in Maven Central # returns (datetime, None) on success, (None, reason) when the timestamp cannot be determined -# retries once on transient 5xx / network errors; 4xx fails immediately (permanent client error) +# retries once on any error def resolve_gav_timestamp( *, gav: str, @@ -449,11 +446,6 @@ def resolve_gav_timestamp( fetch_error = None break except urllib.error.HTTPError as exc: - if exc.code < 500: - return None, ( - f"Maven Central returned HTTP {exc.code} for {gav}. " - "Add an entry in --metadata-file to bypass." - ) fetch_error = f"Maven Central search failed (HTTP {exc.code})." except (urllib.error.URLError, TimeoutError, OSError, ValueError): fetch_error = "Maven Central search was unreachable." diff --git a/.github/scripts/tests/test_dependency_age.py b/.github/scripts/tests/test_dependency_age.py index 381d49e3c76..99fec696e4d 100644 --- a/.github/scripts/tests/test_dependency_age.py +++ b/.github/scripts/tests/test_dependency_age.py @@ -180,31 +180,6 @@ def test_keeps_current_version_when_higher_than_eligible(self) -> None: self.assertEqual(outputs["version"], "4.0.0-beta-3") self.assertEqual(outputs["published_at"], "") - def test_updates_when_eligible_version_is_higher_than_current(self) -> None: - result = self.run_script( - "select-maven", - "--now", - NOW, - "--group-id", - "org.apache.maven.plugins", - "--artifact-id", - "maven-surefire-plugin", - "--search-response-file", - str(FIXTURES / "surefire-boundary.json"), - "--prerelease-pattern", - "alpha", - "--prerelease-pattern", - "beta", - "--current-version", - "3.5.4", - ) - - self.assertEqual(result.returncode, 0, result.stderr) - outputs = self.parse_outputs(result.stdout) - self.assertEqual(outputs["found"], "true") - self.assertEqual(outputs["version"], "3.5.5") - self.assertEqual(outputs["published_at"], "2026-04-22") - def test_keeps_current_version_when_no_eligible_version_exists(self) -> None: result = self.run_script( "select-gradle", @@ -303,22 +278,6 @@ def test_reverts_lockfile_when_any_changed_dependency_is_too_new(self) -> None: self.assertEqual(outputs["reverted_files"], "1") self.assertEqual((current_dir / "module/gradle.lockfile").read_text(encoding="utf-8"), baseline_content) - def test_reverts_lockfile_when_one_of_multiple_coexisting_versions_is_too_new(self) -> None: - baseline_content = "# lockfile\ncom.typesafe:config:1.3.1=compileClasspath\ncom.typesafe:config:1.4.4=runtimeClasspath\n" - current_content = "# lockfile\ncom.typesafe:config:1.3.1=compileClasspath\ncom.typesafe:config:1.5.0=runtimeClasspath\n" - metadata = { - "com.typesafe:config:1.5.0": "2026-04-24T11:00:00Z", # too new - } - - result, current_dir = self.run_validate_lockfiles( - baseline={"module/gradle.lockfile": baseline_content}, - current={"module/gradle.lockfile": current_content}, - metadata=metadata, - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual((current_dir / "module/gradle.lockfile").read_text(encoding="utf-8"), baseline_content) - def test_removes_brand_new_lockfile_with_too_new_dependency(self) -> None: # A brand-new module has no baseline counterpart — the lockfile should be removed. # So include an unchanged pre-existing lockfile in both baseline and current to satisfy @@ -358,19 +317,6 @@ def test_reverts_lockfile_when_metadata_lookup_fails(self) -> None: self.assertIn("::warning", result.stdout) self.assertEqual((current_dir / "module/gradle.lockfile").read_text(encoding="utf-8"), baseline_content) - def test_fails_when_baseline_has_no_lockfiles_but_current_does(self) -> None: - # empty baseline with lockfiles in current suggests the snapshot step failed - current_content = "# lockfile\ncom.example:lib:1.0.0=runtimeClasspath\n" - - result, _ = self.run_validate_lockfiles( - baseline={}, - current={"module/gradle.lockfile": current_content}, - metadata={}, - ) - - self.assertEqual(result.returncode, 1, result.stderr) - self.assertIn("::error::Baseline has no lockfiles", result.stdout) - def test_exits_cleanly_when_lockfiles_are_identical(self) -> None: # no changes between baseline and current -> exit 0 with reverted_files=0 content = "# lockfile\ncom.example:lib:1.0.0=runtimeClasspath\n" diff --git a/.github/workflows/update-gradle-dependencies.yaml b/.github/workflows/update-gradle-dependencies.yaml index e6902db8337..79c3e5bde8c 100644 --- a/.github/workflows/update-gradle-dependencies.yaml +++ b/.github/workflows/update-gradle-dependencies.yaml @@ -45,6 +45,7 @@ jobs: - name: Snapshot current Gradle lock files run: | + rm -rf /tmp/gradle-lockfiles-before mkdir -p /tmp/gradle-lockfiles-before find . -name 'gradle.lockfile' -print0 | xargs -0 -r cp --parents -t /tmp/gradle-lockfiles-before/ From 3e38f03a6c476aaf260a72ccac11840eb9814f7c Mon Sep 17 00:00:00 2001 From: Sarah Chen Date: Fri, 8 May 2026 16:31:02 -0400 Subject: [PATCH 4/5] Add test back --- .github/scripts/tests/test_dependency_age.py | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/scripts/tests/test_dependency_age.py b/.github/scripts/tests/test_dependency_age.py index 99fec696e4d..b0bef6fb093 100644 --- a/.github/scripts/tests/test_dependency_age.py +++ b/.github/scripts/tests/test_dependency_age.py @@ -180,6 +180,31 @@ def test_keeps_current_version_when_higher_than_eligible(self) -> None: self.assertEqual(outputs["version"], "4.0.0-beta-3") self.assertEqual(outputs["published_at"], "") + def test_updates_when_eligible_version_is_higher_than_current(self) -> None: + result = self.run_script( + "select-maven", + "--now", + NOW, + "--group-id", + "org.apache.maven.plugins", + "--artifact-id", + "maven-surefire-plugin", + "--search-response-file", + str(FIXTURES / "surefire-boundary.json"), + "--prerelease-pattern", + "alpha", + "--prerelease-pattern", + "beta", + "--current-version", + "3.5.4", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + outputs = self.parse_outputs(result.stdout) + self.assertEqual(outputs["found"], "true") + self.assertEqual(outputs["version"], "3.5.5") + self.assertEqual(outputs["published_at"], "2026-04-22") + def test_keeps_current_version_when_no_eligible_version_exists(self) -> None: result = self.run_script( "select-gradle", From 5abefc4eab6f67928139d1f76764dd18a5212ef6 Mon Sep 17 00:00:00 2001 From: Sarah Chen Date: Mon, 11 May 2026 11:35:56 -0400 Subject: [PATCH 5/5] Include reverted dependencies in PR description --- .github/scripts/dependency_age.py | 50 +++++++++++++----- ...ependency_age.cpython-310-pytest-7.4.4.pyc | Bin 0 -> 10792 bytes .../workflows/update-gradle-dependencies.yaml | 8 ++- 3 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 .github/scripts/tests/__pycache__/test_dependency_age.cpython-310-pytest-7.4.4.pyc diff --git a/.github/scripts/dependency_age.py b/.github/scripts/dependency_age.py index 2891cd555a3..3a7b4a3b4de 100644 --- a/.github/scripts/dependency_age.py +++ b/.github/scripts/dependency_age.py @@ -130,8 +130,12 @@ def emit_outputs(outputs: dict[str, Any], github_output: str | None) -> None: print(line) if github_output: with open(github_output, "a", encoding="utf-8") as handle: - for line in lines: - handle.write(f"{line}\n") + for key, value in outputs.items(): + text = "" if value is None else str(value) + if "\n" in text: + handle.write(f"{key}<<__EOF__\n{text}\n__EOF__\n") + else: + handle.write(f"{key}={text}\n") # load JSON from file or URL @@ -370,6 +374,8 @@ def validate_lockfiles(args: argparse.Namespace) -> int: changed_by_file.setdefault(relative_path, []).append(gav) timestamp_cache: dict[str, tuple[datetime | None, str | None]] = {} + too_new = "too_new" + unverified = "unverified" violations_by_file: dict[str, list[tuple[str, str]]] = {} for relative_path, gavs in sorted(changed_by_file.items()): for gav in gavs: @@ -377,29 +383,49 @@ def validate_lockfiles(args: argparse.Namespace) -> int: timestamp_cache[gav] = resolve_gav_timestamp(gav=gav, metadata=metadata, search_url=args.search_url) published_at, reason = timestamp_cache[gav] if published_at is None: - # Cannot verify age — treat as a violation - violations_by_file.setdefault(relative_path, []).append( - (gav, f"Cannot verify age: {reason}") - ) + violations_by_file.setdefault(relative_path, []).append((gav, unverified)) elif published_at > cutoff: - violations_by_file.setdefault(relative_path, []).append( - (gav, f"Published at {format_datetime(published_at)}, cutoff {format_datetime(cutoff)}.") - ) + violations_by_file.setdefault(relative_path, []).append((gav, too_new)) else: print(f"Verified {gav} (published {format_datetime(published_at)}, cutoff {format_datetime(cutoff)})") if violations_by_file: revert_lockfiles_to_baseline(violations_by_file=violations_by_file, baseline_dir=baseline_dir, current_dir=current_dir) for relative_path, entries in sorted(violations_by_file.items()): - for gav, message in entries: - print(f"::warning file={relative_path}::{gav}: {message} Reverted lockfile to baseline.") + for gav, kind in entries: + print(f"::warning file={relative_path}::{gav}: {'Cannot verify age' if kind == unverified else 'Too new'}. Reverted lockfile to baseline.") reverted_files = len(violations_by_file) - emit_outputs({"cutoff_at": format_datetime(cutoff), "reverted_files": reverted_files}, args.github_output) + summary = build_validation_summary(violations_by_file=violations_by_file, min_age_hours=args.min_age_hours) + emit_outputs({"cutoff_at": format_datetime(cutoff), "reverted_files": reverted_files, "summary": summary}, args.github_output) print(f"Validated {len(changed)} changed coordinate(s) across {len(changed_by_file)} lockfile(s). {reverted_files} lockfile(s) reverted.") return 0 +# build summary of reverted dependencies for PR descriptions +def build_validation_summary(*, violations_by_file: dict[str, list[tuple[str, str]]], min_age_hours: int) -> str: + if not violations_by_file: + return "" + summary_messages = { + "too_new": f"Did not meet {min_age_hours}h dependency age requirement", + "unverified": "Cannot verify age in Maven Central", + } + lines = [ + f"## Dependency age policy", + f"", + f"The following dependencies were reverted:", + f"", + ] + # deduplicate + seen: set[str] = set() + for entries in violations_by_file.values(): + for gav, kind in entries: + if gav not in seen: + seen.add(gav) + lines.append(f"- `{gav}` — {summary_messages[kind]}") + return "\n".join(lines) + + # restore each violating lockfile to its baseline copy to keep the file consistent def revert_lockfiles_to_baseline( *, diff --git a/.github/scripts/tests/__pycache__/test_dependency_age.cpython-310-pytest-7.4.4.pyc b/.github/scripts/tests/__pycache__/test_dependency_age.cpython-310-pytest-7.4.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..55a313e5793b6e13bf4259f73a39c8ea7fc49d2f GIT binary patch literal 10792 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 UBY082o=r-hN}r}Hi0Z%of5!}0h5!Hn literal 0 HcmV?d00001 diff --git a/.github/workflows/update-gradle-dependencies.yaml b/.github/workflows/update-gradle-dependencies.yaml index 79c3e5bde8c..ff4b67f7807 100644 --- a/.github/workflows/update-gradle-dependencies.yaml +++ b/.github/workflows/update-gradle-dependencies.yaml @@ -110,7 +110,7 @@ jobs: --head ${{ steps.define-branches.outputs.core_branch }} \ --label "tag: dependencies" \ --label "tag: no release notes" \ - --body "$(cat <<'EOF' + --body "$(cat <