Last active
July 6, 2025 17:09
-
-
Save drindt/47f97fe6a9d2ed49346bb0e3b25c878b to your computer and use it in GitHub Desktop.
A tool for fixing dependency verification errors.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| # ====================================================================================== | |
| # Gradle Guardian - A tool for fixing dependency verification errors. | |
| # https://gemini.google.com/app/98400daeaa43b060 | |
| # | |
| # Author: Gemini, based on ideas and feedback from the user `drindt`. | |
| # Version: 0.13.0 | |
| # | |
| # SCRIPT WORKFLOW & USAGE: | |
| # | |
| # This script automates the process of fixing the two most common Gradle dependency | |
| # verification errors: missing checksums and untrusted GPG keys. | |
| # | |
| # It can be operated in two modes: | |
| # | |
| # 1. One-Shot Mode (Default): | |
| # $ ./gradle_guardian.py | |
| # - In this mode, NO Gradle build is executed automatically. | |
| # - The script finds the most recent `dependency-verification-report.html`, | |
| # parses it, and directly corrects the `verification-metadata.xml` file. | |
| # - After running, you must manually trigger your build again (e.g., in your IDE | |
| # or on the command line) to see if the issues are resolved. | |
| # | |
| # 2. Automated Loop Mode (Optional): | |
| # $ ./gradle_guardian.py --task check | |
| # - By providing a Gradle task with the `--task` argument, you enable the | |
| # automated build-and-fix loop. | |
| # - WHY A LOOP IS NEEDED: Fixing one dependency error (e.g., trusting a key for | |
| # a library) can cause Gradle to resolve its dependencies, which in turn might | |
| # reveal *new* verification errors from these "transitive" dependencies. The | |
| # automated loop handles this by repeatedly running the build and applying | |
| # fixes until all related errors are resolved. | |
| # - The loop aborts on success or if it detects it's stuck on the same error. | |
| # - Recommended tasks: `check` (faster) or `assembleDebug` (very reliable). | |
| # | |
| # ====================================================================================== | |
| import hashlib | |
| import os | |
| import re | |
| import glob | |
| import subprocess | |
| import argparse | |
| from bs4 import BeautifulSoup | |
| import xml.etree.ElementTree as ET | |
| from xml.dom import minidom | |
| # --- Configuration --- | |
| GRADLE_DIR = "gradle" | |
| VERIFICATION_XML_PATH = os.path.join(GRADLE_DIR, "verification-metadata.xml") | |
| # ANSI color codes for terminal output | |
| RED = "\033[91m" | |
| GREEN = "\033[92m" | |
| YELLOW = "\033[93m" | |
| RESET = "\033[0m" | |
| # --- End of Configuration --- | |
| def get_user_identity(): | |
| """ | |
| Tries to get the git username. If not found, falls back to the system user. | |
| """ | |
| try: | |
| result = subprocess.run(["git", "config", "user.name"], capture_output=True, text=True, check=True) | |
| username = result.stdout.strip() | |
| if username: | |
| return username | |
| except (subprocess.CalledProcessError, FileNotFoundError): | |
| pass | |
| system_user = os.environ.get("USER") | |
| if system_user: | |
| return system_user | |
| return "fixer tool" | |
| def find_latest_report_file(search_dir="."): | |
| """ | |
| Recursively searches the 'build' directory to find the most recent | |
| 'dependency-verification-report.html' file. | |
| """ | |
| print(f"\n--- Searching for the latest report in '{os.path.join(search_dir, 'build')}' directory ---") | |
| search_pattern = os.path.join(search_dir, "build", "**", "dependency-verification-report.html") | |
| report_files = glob.glob(search_pattern, recursive=True) | |
| if not report_files: | |
| print(f"{RED}ERROR: No 'dependency-verification-report.html' found in the 'build' directory.{RESET}") | |
| return None | |
| latest_file = max(report_files, key=os.path.getmtime) | |
| print(f" [Found] Using latest report: '{latest_file}'") | |
| return latest_file | |
| def parse_html_report(report_path): | |
| """ | |
| Parses the HTML report for missing checksums and untrusted keys. | |
| """ | |
| print(f"\n--- Parsing HTML report ---") | |
| if not os.path.exists(report_path): | |
| print(f"{RED}ERROR: Report file not found at '{report_path}'{RESET}") | |
| return {} | |
| with open(report_path, "r", encoding="utf-8") as f: | |
| soup = BeautifulSoup(f, "html.parser") | |
| results = {"missing_checksums": [], "untrusted_keys": []} | |
| unique_checksum_artifacts = set() | |
| unique_key_artifacts = set() | |
| all_rows = soup.find_all("tr") | |
| for tr in all_rows: | |
| cells = tr.find_all("td") | |
| if len(cells) < 3: | |
| continue | |
| module_string = cells[0].get_text(strip=True) | |
| problem_cell = cells[2] | |
| # Check for missing checksums | |
| checksum_span = problem_cell.find( | |
| "span", | |
| string=re.compile(r"^\s*Checksums are missing from verification metadata\s*$"), | |
| ) | |
| if checksum_span: | |
| artifact_link = cells[1].find("a") | |
| if module_string and artifact_link and artifact_link.has_attr("href"): | |
| file_path = artifact_link["href"].replace("file:", "") | |
| artifact_name = artifact_link.get_text(strip=True) | |
| if (module_string, file_path) not in unique_checksum_artifacts: | |
| results["missing_checksums"].append( | |
| { | |
| "module": module_string, | |
| "file_path": file_path, | |
| "artifact_name": artifact_name, | |
| } | |
| ) | |
| unique_checksum_artifacts.add((module_string, file_path)) | |
| # Check for untrusted keys | |
| key_span = problem_cell.find("span", style="font-weight:bold; color: #c59434") | |
| if key_span and "key is not in your trusted key list" in key_span.get_text(): | |
| key_id_tag = key_span.find("b") | |
| if module_string and key_id_tag: | |
| key_id = key_id_tag.get_text(strip=True).split(" ")[0] | |
| if (module_string, key_id) not in unique_key_artifacts: | |
| results["untrusted_keys"].append({"module": module_string, "key_id": key_id}) | |
| unique_key_artifacts.add((module_string, key_id)) | |
| if not results["missing_checksums"] and not results["untrusted_keys"]: | |
| print(" No fixable problems found in the report.") | |
| else: | |
| print(f" [Found] {len(results['missing_checksums'])} artifacts with missing checksums.") | |
| print(f" [Found] {len(results['untrusted_keys'])} artifacts with untrusted keys.") | |
| return results | |
| def calculate_checksums(file_path): | |
| """ | |
| Calculates the SHA-256 and SHA-512 checksums for a given file. | |
| """ | |
| expanded_path = os.path.expanduser(file_path) | |
| if not os.path.exists(expanded_path): | |
| print(f" {YELLOW}WARNING: File not found at '{expanded_path}'. Skipping checksum calculation.{RESET}") | |
| return None | |
| sha256, sha512 = hashlib.sha256(), hashlib.sha512() | |
| try: | |
| with open(expanded_path, "rb") as f: | |
| while True: | |
| data = f.read(65536) | |
| if not data: | |
| break | |
| sha256.update(data) | |
| sha512.update(data) | |
| return {"sha256": sha256.hexdigest(), "sha512": sha512.hexdigest()} | |
| except IOError as e: | |
| print(f" {RED}ERROR reading file '{expanded_path}': {e}{RESET}") | |
| return None | |
| def update_verification_xml(xml_path, problems): | |
| """ | |
| Updates the verification-metadata.xml file and overwrites it directly. | |
| """ | |
| print(f"\n--- Updating '{xml_path}' ---") | |
| if not os.path.exists(xml_path): | |
| print(f"{RED}ERROR: XML file not found at '{xml_path}'{RESET}") | |
| return False | |
| with open(xml_path, "r", encoding="utf-8") as f: | |
| original_content = f.read() | |
| xml_start_index = original_content.find("<verification-metadata") | |
| if xml_start_index == -1: | |
| print(f"{RED}ERROR: Could not find the <verification-metadata> root element.{RESET}") | |
| return False | |
| header = original_content[:xml_start_index] | |
| xml_body = original_content[xml_start_index:] | |
| namespace = "https://schema.gradle.org/dependency-verification" | |
| ET.register_namespace("", namespace) | |
| root = ET.fromstring(xml_body) | |
| user = get_user_identity() | |
| origin_text = f"Checksum created by tool from {user}" | |
| # Handle missing checksums | |
| if problems.get("missing_checksums"): | |
| components_node = root.find(f"{{{namespace}}}components") | |
| if components_node is None: | |
| return False | |
| for dep in problems["missing_checksums"]: | |
| group, name, version = dep["module"].split(":") | |
| artifact_name = dep["artifact_name"] | |
| checksums = calculate_checksums(dep["file_path"]) | |
| if not checksums: | |
| continue | |
| comp_xpath = f".//{{{namespace}}}component[@group='{group}'][@name='{name}'][@version='{version}']" | |
| component_node = components_node.find(comp_xpath) | |
| if component_node is None: | |
| component_node = ET.SubElement( | |
| components_node, | |
| "component", | |
| {"group": group, "name": name, "version": version}, | |
| ) | |
| art_xpath = f".//{{{namespace}}}artifact[@name='{artifact_name}']" | |
| artifact_node = component_node.find(art_xpath) | |
| if artifact_node is None: | |
| artifact_node = ET.SubElement(component_node, "artifact", {"name": artifact_name}) | |
| else: | |
| for sha_tag in list(artifact_node): | |
| artifact_node.remove(sha_tag) | |
| ET.SubElement( | |
| artifact_node, | |
| "sha256", | |
| {"value": checksums["sha256"], "origin": origin_text}, | |
| ) | |
| ET.SubElement( | |
| artifact_node, | |
| "sha512", | |
| {"value": checksums["sha512"], "origin": origin_text}, | |
| ) | |
| # Handle untrusted keys | |
| if problems.get("untrusted_keys"): | |
| trusted_keys_node = root.find(f".//{{{namespace}}}trusted-keys") | |
| if trusted_keys_node is None: | |
| return False | |
| for dep in problems["untrusted_keys"]: | |
| group, name, version = dep["module"].split(":") | |
| key_id = dep["key_id"] | |
| key_xpath = ( | |
| f".//{{{namespace}}}trusted-key[@id='{key_id}'][@group='{group}'][@name='{name}'][@version='{version}']" | |
| ) | |
| if trusted_keys_node.find(key_xpath) is None: | |
| print(f" Adding trusted key: ID {key_id} for {dep['module']}") | |
| ET.SubElement( | |
| trusted_keys_node, | |
| "trusted-key", | |
| {"id": key_id, "group": group, "name": name, "version": version}, | |
| ) | |
| xml_str = ET.tostring(root, encoding="unicode") | |
| reparsed = minidom.parseString(xml_str) | |
| pretty_xml_str = reparsed.toprettyxml(indent=" ", newl="\n")[reparsed.toprettyxml().find("?>") + 2 :].strip() | |
| cleaned_xml_lines = [line for line in pretty_xml_str.split("\n") if line.strip()] | |
| cleaned_xml_str = "\n".join(cleaned_xml_lines) | |
| with open(xml_path, "w", encoding="utf-8") as f: | |
| f.write(header) | |
| f.write(cleaned_xml_str) | |
| print(f" [SUCCESS] The file '{xml_path}' has been updated.") | |
| return True | |
| def run_gradle_build(task): | |
| """ | |
| Executes a Gradle build task that triggers dependency verification. | |
| """ | |
| command = ["./gradlew", task, "--continue"] | |
| print("\n" + "=" * 60) | |
| print(f"=== Starting Gradle build: {' '.join(command)} ===") | |
| print("=" * 60) | |
| try: | |
| process = subprocess.Popen( | |
| command, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.STDOUT, | |
| text=True, | |
| bufsize=1, | |
| encoding="utf-8", | |
| ) | |
| for line in iter(process.stdout.readline, ""): | |
| print(line, end="") | |
| process.wait() | |
| print("=" * 60) | |
| print(f"=== Gradle task finished with exit code: {process.returncode} ===") | |
| print("=" * 60) | |
| return process.returncode | |
| except FileNotFoundError: | |
| print(f"{RED}ERROR: './gradlew' not found. Please run this script from your project's root directory.{RESET}") | |
| return -1 | |
| def canonicalize_problems(problems_dict): | |
| """Converts the problems dictionary into a canonical, hashable form for comparison.""" | |
| if not problems_dict: | |
| return None | |
| checksums = frozenset(frozenset(d.items()) for d in problems_dict.get("missing_checksums", [])) | |
| keys = frozenset(frozenset(d.items()) for d in problems_dict.get("untrusted_keys", [])) | |
| return (checksums, keys) | |
| def main(): | |
| """ | |
| Main function to orchestrate the entire build-and-fix loop. | |
| """ | |
| parser = argparse.ArgumentParser( | |
| description="A tool to automatically fix Gradle dependency verification errors.", | |
| formatter_class=argparse.RawTextHelpFormatter, | |
| epilog="Example for one-shot fix (default): ./gradle_guardian.py\n" | |
| "Example for automated loop: ./gradle_guardian.py --task check", | |
| ) | |
| parser.add_argument( | |
| "--task", | |
| default=None, | |
| help='(Optional) The Gradle task to run in an automated loop (e.g., "check", "assembleDebug").\n' | |
| "If this is not provided, the script runs in one-shot mode, fixing the\n" | |
| "latest report without running Gradle itself.", | |
| ) | |
| args = parser.parse_args() | |
| print("=================================================") | |
| print(f"=== {GREEN}Gradle Guardian{RESET} ===") | |
| print("=================================================") | |
| if args.task: | |
| # Automated Loop Mode | |
| print(f"=== Automated Mode active with task: {GREEN}{args.task}{RESET} ===") | |
| last_problems = None | |
| build_succeeded = False | |
| made_changes = False | |
| attempt = 1 | |
| max_retries = 10 # Safety break | |
| while attempt <= max_retries: | |
| print(f"\n{YELLOW}--- Build Attempt #{attempt} ---{RESET}") | |
| exit_code = run_gradle_build(args.task) | |
| if exit_code == 0: | |
| print(f"\n{GREEN}SUCCESS: Gradle build completed successfully!{RESET}") | |
| build_succeeded = True | |
| break | |
| print(f"\n{YELLOW}Build failed. Attempting to fix verification metadata...{RESET}") | |
| report_html_path = find_latest_report_file() | |
| if not report_html_path: | |
| print( | |
| f"{RED}Could not find a verification report. The build might have failed for a different reason. Exiting.{RESET}" | |
| ) | |
| break | |
| current_problems = parse_html_report(report_html_path) | |
| canonical_current_problems = canonicalize_problems(current_problems) | |
| if not (current_problems.get("missing_checksums") or current_problems.get("untrusted_keys")): | |
| print( | |
| f"{RED}Build failed, but no fixable problems were found in the report. Cannot fix automatically. Exiting.{RESET}" | |
| ) | |
| break | |
| if canonical_current_problems == last_problems: | |
| print( | |
| f"{RED}Build is still failing with the same verification errors. The script cannot make further progress. Exiting.{RESET}" | |
| ) | |
| break | |
| try: | |
| with open(VERIFICATION_XML_PATH, "r", encoding="utf-8") as f: | |
| content_before = f.read() | |
| except FileNotFoundError: | |
| content_before = "" | |
| if update_verification_xml(VERIFICATION_XML_PATH, current_problems): | |
| with open(VERIFICATION_XML_PATH, "r", encoding="utf-8") as f: | |
| content_after = f.read() | |
| if content_before != content_after: | |
| made_changes = True | |
| else: | |
| break | |
| last_problems = canonical_current_problems | |
| attempt += 1 | |
| if not build_succeeded: | |
| print( | |
| f"\n{RED}ERROR: Build did not succeed after {attempt-1} attempts. Please check the Gradle output for persistent errors.{RESET}" | |
| ) | |
| if build_succeeded and made_changes: | |
| print(f"{RED}\nIMPORTANT: Because fixing dependencies can reveal new transitive dependencies,") | |
| print(f"you may need to run the build and this tool again if new errors appear after committing.{RESET}") | |
| else: | |
| # One-Shot Mode (Default) | |
| print(f"=== One-Shot Mode (no task specified) ===") | |
| report_html_path = find_latest_report_file() | |
| if not report_html_path: | |
| print("\nNo report found. Nothing to do.") | |
| else: | |
| problems = parse_html_report(report_html_path) | |
| if problems.get("missing_checksums") or problems.get("untrusted_keys"): | |
| update_verification_xml(VERIFICATION_XML_PATH, problems) | |
| print(f"\n{GREEN}Metadata file updated. Please run your build process again to verify.{RESET}") | |
| else: | |
| print("\nNo fixable problems found in the latest report.") | |
| print("\n=================================================") | |
| print("=== Tool execution finished ===") | |
| print("=================================================") | |
| if __name__ == "__main__": | |
| main() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
If you know the name of the task that Gradle calls in IntelliJ to synchronize the project, please let me know. Thanks!