Skip to content

Commit e0099f2

Browse files
committed
Catch stale entries in the Java SDK dependency trust list
Signed-off-by: PoAn Yang <payang@apache.org>
1 parent 4277ca8 commit e0099f2

7 files changed

Lines changed: 411 additions & 40 deletions

File tree

‎.pre-commit-config.yaml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,25 @@ repos:
279279
(?x)
280280
^java-sdk/gradle\.properties$|
281281
^java-sdk/sdk/schema/schema\.json$
282+
- id: regenerate-java-sdk-verification-metadata
283+
name: Regenerate the Java SDK dependency verification metadata
284+
description: "Drop trusted checksums that no Java SDK build task resolves any more"
285+
entry: ./scripts/ci/prek/regenerate_java_sdk_verification_metadata.py
286+
language: python
287+
pass_filenames: false
288+
# example/ and scala_spark_example/ are separate builds this metadata does
289+
# not cover, so their build files are excluded.
290+
files: >
291+
(?x)
292+
^java-sdk/build\.gradle\.kts$|
293+
^java-sdk/buildSrc/.*$|
294+
^java-sdk/gradle\.properties$|
295+
^java-sdk/gradle/libs\.versions\.toml$|
296+
^java-sdk/gradle/verification-metadata\.xml$|
297+
^java-sdk/gradle/wrapper/gradle-wrapper\.properties$|
298+
^java-sdk/settings\.gradle\.kts$|
299+
^scripts/ci/prek/regenerate_java_sdk_verification_metadata\.py$|
300+
^java-sdk/(?!example/|scala_spark_example/)[^/]+/(?:build\.gradle\.kts|gradle\.properties)$
282301
- id: update-java-sdk-readme-matrix
283302
name: Update the Java SDK compatibility matrix in java-sdk/README.md and Dokka module doc
284303
entry: ./scripts/ci/prek/update_java_sdk_readme_matrix.py

‎dev/breeze/doc/ci/04_selective_checks.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -519,9 +519,11 @@ when some files are not changed. Those are the rules implemented:
519519
type errors (see #68919)
520520
* if no `All Python files` changed - `flynt` check is skipped
521521
* if no `Helm files` changed - `lint-helm-chart` check is skipped
522-
* if no `Java SDK files` changed - `ktlint` check is skipped (it runs the java-sdk Gradle
523-
wrapper, which downloads the Gradle distribution, so we avoid that download on PRs that do
524-
not touch `java-sdk/`)
522+
* if no `Java SDK files` changed - `ktlint` and
523+
`regenerate-java-sdk-verification-metadata` checks are skipped (both run the java-sdk
524+
Gradle wrapper, which downloads the Gradle distribution, and the latter additionally
525+
resolves the whole Java SDK dependency graph from Maven Central, so we avoid those
526+
downloads on PRs that do not touch `java-sdk/`)
525527
* if no `TS SDK files` (`ts-sdk/`) changed - `check-ts-sdk-supervisor-schema` check is
526528
skipped (it regenerates and diffs the generated ts-sdk file; a change to the supervisor
527529
wire schema alone deliberately does not trigger it - regenerating the ts-sdk types is

‎dev/breeze/src/airflow_breeze/utils/selective_checks.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,6 +1729,10 @@ def skip_prek_hooks(self) -> str:
17291729
# on a cold cache. Skip it when no java-sdk files changed so unrelated PRs do not
17301730
# depend on that (intermittently failing) download.
17311731
prek_hooks_to_skip.add("ktlint")
1732+
# Rewriting the verification metadata resolves the entire Java SDK dependency graph
1733+
# from Maven Central. Skip it when no java-sdk files changed so unrelated PRs do not
1734+
# depend on that resolution.
1735+
prek_hooks_to_skip.add("regenerate-java-sdk-verification-metadata")
17321736
if not self._matching_files(FileGroupForCi.TS_SDK_FILES, CI_FILE_GROUP_MATCHES):
17331737
# This hook regenerates ts-sdk/src/generated/supervisor.ts from the wire schema and
17341738
# diffs it. Schema-only changes deliberately do not trigger it: regenerating the

‎dev/breeze/tests/test_selective_checks.py‎

Lines changed: 34 additions & 30 deletions
Large diffs are not rendered by default.

‎java-sdk/README.md‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,19 @@ development tools may have further requirements (see the toolchain in
4444

4545
Repositories are centralized in `settings.gradle.kts`, and dynamic and changing versions are rejected for project dependency configurations (not for plugin markers or detached configurations — pin those by hand). `buildSrc/` declares its own repositories, but its dependencies *are* covered by this metadata.
4646

47-
To update a dependency or plugin, regenerate from a trusted network. The task list must cover everything CI runs, since only what the invoked tasks resolve gets recorded:
47+
To update a dependency or plugin, regenerate the file from a trusted network. Run the command from the repository root:
4848

4949
```bash
50-
./gradlew --write-verification-metadata sha256 --refresh-dependencies \
51-
build \
52-
:sdk:dokkaGeneratePublicationHtml :sdk:dokkaGeneratePublicationJavadoc \
53-
sourceTarball checksumSourceTarball \
54-
publishToMavenLocal -PskipSigning=true
50+
prek run regenerate-java-sdk-verification-metadata --all-files
5551
```
5652

57-
Without `-PskipSigning=true` the signing tasks fail and Gradle still writes metadata from the partial run. Regeneration only appends, so delete superseded entries by hand after a version bump.
53+
The hook runs on its own whenever a change moves the resolved dependency set, and it keeps failing until you stage the rewritten file too. It is a plain script, so you can also run it directly:
54+
55+
```bash
56+
uv run scripts/ci/prek/regenerate_java_sdk_verification_metadata.py
57+
```
58+
59+
Gradle only appends to the file, so the script empties the component list before regenerating. Otherwise every version bump leaves its superseded entries behind, and they stay trusted. The script also owns the task list, which has to cover everything CI builds, because Gradle records only what the invoked tasks resolve.
5860

5961
Review every entry in the diff. Generating the file records what the repositories served at that moment; it does not make those bytes trustworthy. Cross-check new coordinates and checksums against the dependency's official release information, and never bypass a failure with lenient or disabled verification.
6062

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
#!/usr/bin/env python3
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
"""
19+
Rewrite java-sdk/gradle/verification-metadata.xml from an empty component list.
20+
21+
Gradle only ever appends to that file, so a version bump leaves the superseded
22+
entries behind and they stay trusted for good. Starting from an empty list drops
23+
them, which is what turns a stale checksum into a visible diff.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import pathlib
29+
import subprocess
30+
import sys
31+
import time
32+
from collections.abc import Callable
33+
34+
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
35+
JAVA_SDK = REPO_ROOT / "java-sdk"
36+
METADATA = JAVA_SDK / "gradle" / "verification-metadata.xml"
37+
38+
GRADLE_TASKS = [
39+
"build",
40+
":sdk:dokkaGeneratePublicationHtml",
41+
":sdk:dokkaGeneratePublicationJavadoc",
42+
"sourceTarball",
43+
"checksumSourceTarball",
44+
"publishToMavenLocal",
45+
]
46+
47+
MAX_ATTEMPTS = 3
48+
RETRY_DELAY_SECONDS = 30
49+
50+
LICENSE_HEADER = """<!--
51+
Licensed to the Apache Software Foundation (ASF) under one
52+
or more contributor license agreements. See the NOTICE file
53+
distributed with this work for additional information
54+
regarding copyright ownership. The ASF licenses this file
55+
to you under the Apache License, Version 2.0 (the
56+
"License"); you may not use this file except in compliance
57+
with the License. You may obtain a copy of the License at
58+
59+
http://www.apache.org/licenses/LICENSE-2.0
60+
61+
Unless required by applicable law or agreed to in writing,
62+
software distributed under the License is distributed on an
63+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
64+
KIND, either express or implied. See the License for the
65+
specific language governing permissions and limitations
66+
under the License.
67+
-->"""
68+
69+
ADVISORY = """
70+
The trust list changed. Entries that disappeared are checksums nothing resolves
71+
any more - usually versions superseded by a dependency bump. They stayed
72+
trusted, so a future direct or transitive dependency could have pulled that
73+
exact version back in with nobody reviewing its checksum.
74+
75+
Review every entry in the diff before staging it: generating the file records
76+
what the repositories served, it does not make those bytes trustworthy.
77+
"""
78+
79+
80+
class RegenerationFailedError(RuntimeError):
81+
"""Gradle could not regenerate the metadata within the attempt budget."""
82+
83+
84+
def build_empty_metadata(committed: str) -> str:
85+
"""Return the committed metadata with its component list emptied."""
86+
kept: list[str] = []
87+
for line in committed.splitlines():
88+
if "<components>" in line:
89+
kept += [" <components/>", "</verification-metadata>"]
90+
break
91+
kept.append(line)
92+
return "\n".join(kept) + "\n"
93+
94+
95+
def insert_license_header(metadata: str) -> str:
96+
"""Put the ASF header back after the XML declaration, unless Gradle kept one."""
97+
if "Licensed to the Apache Software Foundation" in metadata:
98+
return metadata
99+
declaration, *rest = metadata.splitlines()
100+
return "\n".join([declaration, LICENSE_HEADER, *rest]) + "\n"
101+
102+
103+
def run_gradle() -> bool:
104+
"""Both properties are needed for the run to reach the end: signing has no key
105+
here, and sourceTarball has no default ref."""
106+
result = subprocess.run(
107+
[
108+
"./gradlew",
109+
"--no-daemon",
110+
"--write-verification-metadata",
111+
"sha256",
112+
"--refresh-dependencies",
113+
*GRADLE_TASKS,
114+
"-PskipSigning=true",
115+
"-PgitRef=HEAD",
116+
],
117+
cwd=JAVA_SDK,
118+
check=False,
119+
)
120+
return result.returncode == 0
121+
122+
123+
def regenerate(
124+
metadata: pathlib.Path,
125+
gradle: Callable[[], bool],
126+
sleep: Callable[[float], None] = time.sleep,
127+
) -> None:
128+
"""Rewrite the metadata in place, putting the committed file back if the run never succeeds."""
129+
committed = metadata.read_text()
130+
restore = True
131+
try:
132+
for attempt in range(1, MAX_ATTEMPTS + 1):
133+
print(f"==> Regenerating verification metadata (attempt {attempt}/{MAX_ATTEMPTS})", flush=True)
134+
metadata.write_text(build_empty_metadata(committed))
135+
if gradle():
136+
metadata.write_text(insert_license_header(metadata.read_text()))
137+
restore = False
138+
return
139+
if attempt < MAX_ATTEMPTS:
140+
print(f"Regeneration failed, retrying in {RETRY_DELAY_SECONDS}s", file=sys.stderr, flush=True)
141+
sleep(RETRY_DELAY_SECONDS)
142+
raise RegenerationFailedError(f"Regeneration failed after {MAX_ATTEMPTS} attempts")
143+
finally:
144+
if restore:
145+
metadata.write_text(committed)
146+
147+
148+
def metadata_changed(metadata: pathlib.Path) -> bool:
149+
result = subprocess.run(["git", "diff", "--quiet", "--", str(metadata)], cwd=REPO_ROOT, check=False)
150+
return result.returncode != 0
151+
152+
153+
def main() -> int:
154+
try:
155+
regenerate(METADATA, run_gradle)
156+
except RegenerationFailedError as error:
157+
print(f"ERROR: {error}", file=sys.stderr)
158+
return 1
159+
if metadata_changed(METADATA):
160+
print(ADVISORY, file=sys.stderr)
161+
return 0
162+
163+
164+
if __name__ == "__main__":
165+
sys.exit(main())

0 commit comments

Comments
 (0)