Skip to content

Commit 4efc512

Browse files
committed
Implement 48h cooldown for gradle dependencies
1 parent 6aa6068 commit 4efc512

3 files changed

Lines changed: 315 additions & 2 deletions

File tree

.github/scripts/dependency_age.py

Lines changed: 167 additions & 1 deletion
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

@@ -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+
296460
def 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

.github/scripts/tests/test_dependency_age.py

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import json
12
import os
23
import re
4+
import shutil
35
import subprocess
6+
import tempfile
47
import unittest
58
from pathlib import Path
69

@@ -10,7 +13,7 @@
1013
FIXTURES = Path(__file__).resolve().parent / "fixtures"
1114
NOW = "2026-04-24T12:00:00Z"
1215
OUTPUT_PATTERN = re.compile(
13-
r"^(cutoff_at|found|version|published_at|reason)=(.*)$"
16+
r"^(cutoff_at|found|version|published_at|reason|reverted_files)=(.*)$"
1417
)
1518

1619

@@ -124,5 +127,134 @@ def test_exact_48_hour_boundary_is_accepted(self) -> None:
124127
self.assertEqual(outputs["published_at"], "2026-04-22T12:00:00Z")
125128

126129

130+
def run_validate_lockfiles(
131+
self,
132+
*,
133+
baseline: dict[str, str],
134+
current: dict[str, str],
135+
metadata: dict,
136+
now: str = NOW,
137+
) -> tuple[subprocess.CompletedProcess[str], Path]:
138+
"""
139+
Run validate-lockfiles with in-memory lockfile content.
140+
baseline/current map relative paths to file text.
141+
All coordinates must be covered by metadata — any uncovered coordinate
142+
hits the (unreachable) search URL and is warned+skipped.
143+
"""
144+
tmp = Path(tempfile.mkdtemp())
145+
self.addCleanup(shutil.rmtree, tmp, True)
146+
baseline_dir = tmp / "before"
147+
current_dir = tmp / "after"
148+
metadata_file = tmp / "metadata.json"
149+
150+
for rel_path, content in baseline.items():
151+
p = baseline_dir / rel_path
152+
p.parent.mkdir(parents=True, exist_ok=True)
153+
p.write_text(content, encoding="utf-8")
154+
155+
for rel_path, content in current.items():
156+
p = current_dir / rel_path
157+
p.parent.mkdir(parents=True, exist_ok=True)
158+
p.write_text(content, encoding="utf-8")
159+
160+
metadata_file.write_text(json.dumps(metadata), encoding="utf-8")
161+
162+
result = self.run_script(
163+
"validate-lockfiles",
164+
"--baseline-dir", str(baseline_dir),
165+
"--current-dir", str(current_dir),
166+
"--metadata-file", str(metadata_file),
167+
"--search-url", (tmp / "no-network").as_uri(),
168+
"--now", now,
169+
)
170+
return result, current_dir
171+
172+
def test_validates_changed_lockfiles_when_all_updates_are_old_enough(self) -> None:
173+
baseline_content = "# lockfile\ncom.example:lib-a:1.0.0=runtimeClasspath\ncom.example:lib-b:1.0.0=runtimeClasspath\n"
174+
current_content = "# lockfile\ncom.example:lib-a:1.1.0=runtimeClasspath\ncom.example:lib-b:1.1.0=runtimeClasspath\n"
175+
metadata = {
176+
"com.example:lib-a:1.1.0": "2026-04-20T12:00:00Z",
177+
"com.example:lib-b:1.1.0": "2026-04-20T11:00:00Z",
178+
}
179+
180+
result, current_dir = self.run_validate_lockfiles(
181+
baseline={"module/gradle.lockfile": baseline_content},
182+
current={"module/gradle.lockfile": current_content},
183+
metadata=metadata,
184+
)
185+
186+
self.assertEqual(result.returncode, 0, result.stderr)
187+
outputs = self.parse_outputs(result.stdout)
188+
self.assertEqual(outputs["reverted_files"], "0")
189+
self.assertEqual((current_dir / "module/gradle.lockfile").read_text(encoding="utf-8"), current_content)
190+
191+
def test_reverts_lockfile_when_any_changed_dependency_is_too_new(self) -> None:
192+
baseline_content = "# lockfile\ncom.example:lib-a:1.0.0=runtimeClasspath\ncom.example:lib-b:1.0.0=runtimeClasspath\n"
193+
current_content = "# lockfile\ncom.example:lib-a:1.1.0=runtimeClasspath\ncom.example:lib-b:2.0.0=runtimeClasspath\n"
194+
metadata = {
195+
"com.example:lib-a:1.1.0": "2026-04-20T12:00:00Z", # old enough
196+
"com.example:lib-b:2.0.0": "2026-04-24T11:00:00Z", # too new
197+
}
198+
199+
result, current_dir = self.run_validate_lockfiles(
200+
baseline={"module/gradle.lockfile": baseline_content},
201+
current={"module/gradle.lockfile": current_content},
202+
metadata=metadata,
203+
)
204+
205+
self.assertEqual(result.returncode, 0, result.stderr)
206+
outputs = self.parse_outputs(result.stdout)
207+
self.assertEqual(outputs["reverted_files"], "1")
208+
self.assertEqual((current_dir / "module/gradle.lockfile").read_text(encoding="utf-8"), baseline_content)
209+
210+
def test_reverts_lockfile_when_one_of_multiple_coexisting_versions_is_too_new(self) -> None:
211+
baseline_content = "# lockfile\ncom.typesafe:config:1.3.1=compileClasspath\ncom.typesafe:config:1.4.4=runtimeClasspath\n"
212+
current_content = "# lockfile\ncom.typesafe:config:1.3.1=compileClasspath\ncom.typesafe:config:1.5.0=runtimeClasspath\n"
213+
metadata = {
214+
"com.typesafe:config:1.5.0": "2026-04-24T11:00:00Z", # too new
215+
}
216+
217+
result, current_dir = self.run_validate_lockfiles(
218+
baseline={"module/gradle.lockfile": baseline_content},
219+
current={"module/gradle.lockfile": current_content},
220+
metadata=metadata,
221+
)
222+
223+
self.assertEqual(result.returncode, 0, result.stderr)
224+
self.assertEqual((current_dir / "module/gradle.lockfile").read_text(encoding="utf-8"), baseline_content)
225+
226+
def test_removes_brand_new_lockfile_with_too_new_dependency(self) -> None:
227+
current_content = "# lockfile\ncom.example:brand-new:1.0.0=runtimeClasspath\n"
228+
metadata = {
229+
"com.example:brand-new:1.0.0": "2026-04-24T11:00:00Z", # too new
230+
}
231+
232+
result, current_dir = self.run_validate_lockfiles(
233+
baseline={},
234+
current={"module/gradle.lockfile": current_content},
235+
metadata=metadata,
236+
)
237+
238+
self.assertEqual(result.returncode, 0, result.stderr)
239+
self.assertFalse((current_dir / "module/gradle.lockfile").exists())
240+
241+
def test_warns_and_skips_coordinate_when_metadata_lookup_fails(self) -> None:
242+
# coordinate not in metadata → hits unreachable search URL → warns and skips (does not revert)
243+
baseline_content = "# lockfile\ncom.example:lib:1.0.0=runtimeClasspath\n"
244+
current_content = "# lockfile\ncom.example:lib:1.1.0=runtimeClasspath\n"
245+
246+
result, current_dir = self.run_validate_lockfiles(
247+
baseline={"module/gradle.lockfile": baseline_content},
248+
current={"module/gradle.lockfile": current_content},
249+
metadata={},
250+
)
251+
252+
self.assertEqual(result.returncode, 0, result.stderr)
253+
outputs = self.parse_outputs(result.stdout)
254+
self.assertEqual(outputs["reverted_files"], "0")
255+
self.assertIn("::warning", result.stdout)
256+
self.assertEqual((current_dir / "module/gradle.lockfile").read_text(encoding="utf-8"), current_content)
257+
258+
127259
if __name__ == "__main__":
128260
unittest.main()

.github/workflows/update-gradle-dependencies.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ jobs:
88
update-gradle-dependencies:
99
runs-on: ubuntu-latest
1010
name: Update Gradle dependencies
11+
env:
12+
MIN_DEPENDENCY_AGE_HOURS: 48
1113
permissions:
1214
contents: read
1315
id-token: write # Required for OIDC token federation
@@ -41,6 +43,11 @@ jobs:
4143
echo "core_branch=ci/update-gradle-dependencies-${DATE}" >> $GITHUB_OUTPUT
4244
echo "instrumentation_branch=ci/update-gradle-dependencies-instrumentation-${DATE}" >> $GITHUB_OUTPUT
4345
46+
- name: Snapshot current Gradle lock files
47+
run: |
48+
mkdir -p /tmp/gradle-lockfiles-before
49+
find . -name 'gradle.lockfile' -exec cp --parents {} /tmp/gradle-lockfiles-before/ \;
50+
4451
- name: Update Gradle dependencies
4552
env:
4653
ORG_GRADLE_PROJECT_akkaRepositoryToken: ${{ secrets.AKKA_REPO_TOKEN }}
@@ -49,6 +56,14 @@ jobs:
4956
GRADLE_OPTS="-Dorg.gradle.jvmargs='-Xms2G -Xmx3G'" \
5057
./gradlew resolveAndLockAll --write-locks --parallel --stacktrace --no-daemon --max-workers=4
5158
59+
- name: Validate changed lock files meet dependency age policy
60+
run: |
61+
python3 .github/scripts/dependency_age.py validate-lockfiles \
62+
--baseline-dir /tmp/gradle-lockfiles-before \
63+
--current-dir . \
64+
--min-age-hours "${MIN_DEPENDENCY_AGE_HOURS}" \
65+
--github-output "$GITHUB_OUTPUT"
66+
5267
- name: Save instrumentation lock files
5368
run: |
5469
mkdir -p /tmp/instrumentation-lockfiles

0 commit comments

Comments
 (0)