fix: sort repositories in v3 lock file hash to match Java resolver order (#1557)
diff --git a/MODULE.bazel b/MODULE.bazel
index 57eb663..9122aaf 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -478,6 +478,27 @@
     lock_file = "//tests/custom_maven_install:maven_install.json",
 )
 dev_maven.install(
+    name = "multi_repo_hash",
+    artifacts = ["junit:junit:4.13.2"],
+    fail_if_repin_required = True,
+    lock_file = "//tests/custom_maven_install:multi_repo_hash_install.json",
+    repositories = [
+        "https://repo1.maven.org/maven2/",
+        "https://repo.maven.apache.org/maven2/",
+    ],
+    resolver = "maven",
+)
+dev_maven.install(
+    name = "legacy_multi_repo_hash",
+    artifacts = ["junit:junit:4.13.2"],
+    fail_if_repin_required = True,
+    lock_file = "//tests/custom_maven_install:legacy_multi_repo_hash_install.json",
+    repositories = [
+        "https://repo1.maven.org/maven2/",
+        "https://repo.maven.apache.org/maven2/",
+    ],
+)
+dev_maven.install(
     # This name matches the one in `tests/integration/bzlmod_lock_files`
     name = "multiple_lock_files",
     artifacts = ["org.zeromq:jeromq:0.5.4"],
@@ -1098,6 +1119,10 @@
     "unpinned_v1_lock_file_format",
     "v1_lock_file_format",
     "version_interval_testing",
+    "multi_repo_hash",
+    "unpinned_multi_repo_hash",
+    "legacy_multi_repo_hash",
+    "unpinned_legacy_multi_repo_hash",
 )
 
 http_file(
diff --git a/private/rules/coursier.bzl b/private/rules/coursier.bzl
index 78d623b..f1be5a6 100644
--- a/private/rules/coursier.bzl
+++ b/private/rules/coursier.bzl
@@ -684,7 +684,17 @@
     elif importer.compute_lock_file_hash(maven_install_json_content) != dep_tree_signature:
         # Then, validate that the signature provided matches the contents of the dependency_tree.
         # This is to stop users from manually modifying maven_install.json.
-        if _get_fail_if_repin_required(repository_ctx):
+        legacy_hash_fn = getattr(importer, "compute_lock_file_hash_legacy", None)
+        is_legacy_hash = legacy_hash_fn != None and legacy_hash_fn(maven_install_json_content) == dep_tree_signature
+        if is_legacy_hash:
+            # The lock file was written by an older version of rules_jvm_external that used
+            # repository insertion order when computing hashes. The file is valid but needs
+            # to be regenerated to use the current hash algorithm.
+            print(
+                "NOTE: %s_install.json was generated by an older version of rules_jvm_external " % user_provided_name +
+                "and needs to be regenerated. To update, run 'bazel run %s'." % pin_target,
+            )
+        elif _get_fail_if_repin_required(repository_ctx):
             computed_hash = importer.compute_lock_file_hash(maven_install_json_content)
             to_print = importer.print_friendly_hash_difference(dep_tree_signature, computed_hash)
             fail(
diff --git a/private/rules/v3_lock_file.bzl b/private/rules/v3_lock_file.bzl
index 43b4369..7ec4123 100644
--- a/private/rules/v3_lock_file.bzl
+++ b/private/rules/v3_lock_file.bzl
@@ -131,7 +131,7 @@
 
     return final_hashes
 
-def _compute_lock_file_hash_v3(lock_file_contents):
+def _compute_lock_file_hash_v3_impl(lock_file_contents, repo_keys):
     all_infos = dict()
 
     for dep, dep_info in lock_file_contents["artifacts"].items():
@@ -149,8 +149,8 @@
             type_info["sha"] = sha
             all_infos[dep + suffix] = type_info
 
-    for repo, artifacts in lock_file_contents["repositories"].items():
-        for artifact in artifacts:
+    for repo in repo_keys:
+        for artifact in lock_file_contents["repositories"][repo]:
             all_infos[artifact]["repository"] = repo
 
     for dep, dep_info in lock_file_contents["dependencies"].items():
@@ -158,6 +158,26 @@
 
     return _compute_final_hash(all_infos)
 
+def _compute_lock_file_hash_v3(lock_file_contents):
+    # Sort repositories to match the order used by the Java resolver binary (AbstractMain.calculateArtifactHash
+    # uses sortMapRecursively which sorts alphabetically). Without sorting, artifacts present in
+    # multiple repositories get different "repository" values depending on iteration order, causing
+    # hash mismatches between the stored value (written by Java) and the value computed here.
+    return _compute_lock_file_hash_v3_impl(
+        lock_file_contents,
+        sorted(lock_file_contents["repositories"].keys()),
+    )
+
+def _compute_lock_file_hash_v3_legacy(lock_file_contents):
+    # Computes the hash using repository insertion order. Used to detect lock
+    # files written by older versions of rules_jvm_external (before the fix
+    # that aligned Starlark hash order with the Java resolver). If the current
+    # hash does not match but this one does, the file needs to be repinned.
+    return _compute_lock_file_hash_v3_impl(
+        lock_file_contents,
+        lock_file_contents["repositories"].keys(),
+    )
+
 def _to_m2_path(unpacked):
     path = "{group}/{artifact}/{version}/{artifact}-{version}".format(
         artifact = unpacked["artifact"],
@@ -329,6 +349,7 @@
     get_lock_file_hash = _get_lock_file_hash,
     print_friendly_hash_difference = _print_friendly_hash_difference_v3,
     compute_lock_file_hash = _compute_lock_file_hash_v3,
+    compute_lock_file_hash_legacy = _compute_lock_file_hash_v3_legacy,
     get_artifacts = _get_artifacts,
     get_netrc_entries = _get_netrc_entries,
     render_lock_file = _render_lock_file,
diff --git a/tests/bazel_run_tests.sh b/tests/bazel_run_tests.sh
index dc13e73..c6bc81e 100755
--- a/tests/bazel_run_tests.sh
+++ b/tests/bazel_run_tests.sh
@@ -369,6 +369,19 @@
   bazel build @from_files//:all
 }
 
+function test_hash_verification_stable_for_artifact_in_multiple_repos() {
+  force_bzlmod_lock_file_to_be_regenerated
+
+  bazel build @multi_repo_hash//:junit_junit >> "$TEST_LOG" 2>&1
+}
+
+function test_legacy_multi_repo_hash_accepted_by_fallback() {
+  force_bzlmod_lock_file_to_be_regenerated
+
+  bazel build @legacy_multi_repo_hash//:junit_junit >> "$TEST_LOG" 2>&1
+  expect_log "NOTE: legacy_multi_repo_hash_install.json was generated by an older version of rules_jvm_external"
+}
+
 TESTS=(
   "test_coursier_resolution_with_boms"
   "test_maven_resolution"
@@ -396,6 +409,8 @@
   # "test_gradle_metadata_is_resolved_correctly_for_aar_artifact"
   "test_gradle_metadata_is_resolved_correctly_for_jvm_artifact"
   "test_gradle_versions_catalog"
+  "test_hash_verification_stable_for_artifact_in_multiple_repos"
+  "test_legacy_multi_repo_hash_accepted_by_fallback"
 )
 
 function run_tests() {
diff --git a/tests/custom_maven_install/legacy_multi_repo_hash_install.json b/tests/custom_maven_install/legacy_multi_repo_hash_install.json
new file mode 100644
index 0000000..86b8a29
--- /dev/null
+++ b/tests/custom_maven_install/legacy_multi_repo_hash_install.json
@@ -0,0 +1,83 @@
+{
+  "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
+  "__INPUT_ARTIFACTS_HASH": {
+    "junit:junit": -744267592,
+    "repositories": 1879491525
+  },
+  "__RESOLVED_ARTIFACTS_HASH": {
+    "junit:junit": 712711302,
+    "org.hamcrest:hamcrest-core": 866476646
+  },
+  "artifacts": {
+    "junit:junit": {
+      "shasums": {
+        "jar": "8e495b634469d64fb8acfa3495a065cbacc8a0fff55ce1e31007be4c16dc57d3"
+      },
+      "version": "4.13.2"
+    },
+    "org.hamcrest:hamcrest-core": {
+      "shasums": {
+        "jar": "66fdef91e9739348df7a096aa384a5685f4e875584cce89386a7a47251c4d8e9"
+      },
+      "version": "1.3"
+    }
+  },
+  "dependencies": {
+    "junit:junit": [
+      "org.hamcrest:hamcrest-core"
+    ]
+  },
+  "packages": {
+    "junit:junit": [
+      "junit.extensions",
+      "junit.framework",
+      "junit.runner",
+      "junit.textui",
+      "org.junit",
+      "org.junit.experimental",
+      "org.junit.experimental.categories",
+      "org.junit.experimental.max",
+      "org.junit.experimental.results",
+      "org.junit.experimental.runners",
+      "org.junit.experimental.theories",
+      "org.junit.experimental.theories.internal",
+      "org.junit.experimental.theories.suppliers",
+      "org.junit.function",
+      "org.junit.internal",
+      "org.junit.internal.builders",
+      "org.junit.internal.management",
+      "org.junit.internal.matchers",
+      "org.junit.internal.requests",
+      "org.junit.internal.runners",
+      "org.junit.internal.runners.model",
+      "org.junit.internal.runners.rules",
+      "org.junit.internal.runners.statements",
+      "org.junit.matchers",
+      "org.junit.rules",
+      "org.junit.runner",
+      "org.junit.runner.manipulation",
+      "org.junit.runner.notification",
+      "org.junit.runners",
+      "org.junit.runners.model",
+      "org.junit.runners.parameterized",
+      "org.junit.validator"
+    ],
+    "org.hamcrest:hamcrest-core": [
+      "org.hamcrest",
+      "org.hamcrest.core",
+      "org.hamcrest.internal"
+    ]
+  },
+  "repositories": {
+    "https://repo1.maven.org/maven2/": [
+      "junit:junit",
+      "org.hamcrest:hamcrest-core"
+    ],
+    "https://repo.maven.apache.org/maven2/": [
+      "junit:junit",
+      "org.hamcrest:hamcrest-core"
+    ]
+  },
+  "services": {},
+  "version": "3"
+}
diff --git a/tests/custom_maven_install/multi_repo_hash_install.json b/tests/custom_maven_install/multi_repo_hash_install.json
new file mode 100644
index 0000000..cd2dabf
--- /dev/null
+++ b/tests/custom_maven_install/multi_repo_hash_install.json
@@ -0,0 +1,84 @@
+{
+  "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
+  "__INPUT_ARTIFACTS_HASH": {
+    "junit:junit": -744267592,
+    "repositories": 1879491525
+  },
+  "__RESOLVED_ARTIFACTS_HASH": {
+    "junit:junit": -1256429642,
+    "org.hamcrest:hamcrest-core": 649657847
+  },
+  "artifacts": {
+    "junit:junit": {
+      "shasums": {
+        "jar": "8e495b634469d64fb8acfa3495a065cbacc8a0fff55ce1e31007be4c16dc57d3"
+      },
+      "version": "4.13.2"
+    },
+    "org.hamcrest:hamcrest-core": {
+      "shasums": {
+        "jar": "66fdef91e9739348df7a096aa384a5685f4e875584cce89386a7a47251c4d8e9"
+      },
+      "version": "1.3"
+    }
+  },
+  "dependencies": {
+    "junit:junit": [
+      "org.hamcrest:hamcrest-core"
+    ]
+  },
+  "packages": {
+    "junit:junit": [
+      "junit.extensions",
+      "junit.framework",
+      "junit.runner",
+      "junit.textui",
+      "org.junit",
+      "org.junit.experimental",
+      "org.junit.experimental.categories",
+      "org.junit.experimental.max",
+      "org.junit.experimental.results",
+      "org.junit.experimental.runners",
+      "org.junit.experimental.theories",
+      "org.junit.experimental.theories.internal",
+      "org.junit.experimental.theories.suppliers",
+      "org.junit.function",
+      "org.junit.internal",
+      "org.junit.internal.builders",
+      "org.junit.internal.management",
+      "org.junit.internal.matchers",
+      "org.junit.internal.requests",
+      "org.junit.internal.runners",
+      "org.junit.internal.runners.model",
+      "org.junit.internal.runners.rules",
+      "org.junit.internal.runners.statements",
+      "org.junit.matchers",
+      "org.junit.rules",
+      "org.junit.runner",
+      "org.junit.runner.manipulation",
+      "org.junit.runner.notification",
+      "org.junit.runners",
+      "org.junit.runners.model",
+      "org.junit.runners.parameterized",
+      "org.junit.validator"
+    ],
+    "org.hamcrest:hamcrest-core": [
+      "org.hamcrest",
+      "org.hamcrest.core",
+      "org.hamcrest.internal"
+    ]
+  },
+  "repositories": {
+    "https://repo1.maven.org/maven2/": [
+      "junit:junit",
+      "org.hamcrest:hamcrest-core"
+    ],
+    "https://repo.maven.apache.org/maven2/": [
+      "junit:junit",
+      "org.hamcrest:hamcrest-core"
+    ]
+  },
+  "services": {},
+  "skipped": [],
+  "version": "3"
+}