From 2881621c0fbde360265ac8ce6be6fa191b4ab6d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 20 May 2026 20:26:28 +0200 Subject: [PATCH 1/2] Fix dep loader rejecting fetched deps with stale .app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a fetchable dependency is re-fetched at a new version, the source in `deps/` is replaced but the build artifacts under `_build//lib/` are not. The dep loader's `validate_app` reads the stale `_build/.../.app`, sees the previous version string, and either reports `:nomatchvsn` against the new requirement or, more subtly, returns `{:ok, old_vsn}` which then trips the converger's `req_mismatch` check and surfaces as `:divergedreq`. This used to be guarded by `recently_fetched?`, which short-circuited `validate_app` to `:compile` whenever a `.fetch` marker (later `compile.fetch` then `compile.elixir_scm`) indicated the build was behind the source. That guard was removed in "Store the lock in the manifest" in favor of `check_manifest` comparing the stored lock against the current lock and marking the dep as `:compile`. But `check_manifest` is only reached via `check_lock`, which is guarded by `available?(dep)` — and once `validate_app` has produced `{:ok, _}`, the converger can set `:divergedreq` first, which makes the dep diverged and `available?` false, so the manifest lock check never fires. Restore the short-circuit in `validate_app` using the new signal: if the SCM manifest's stored lock differs from `opts[:lock]` (or the manifest is missing), treat the dep as `:compile` and skip validating the stale `.app`. This preserves the architecture introduced by the lock-in-manifest commit while closing the regression. Reported via hexpm/hex#1166, where a workaround was added to delete the stale `.app` from Hex's SCM after each fetch. --- lib/mix/lib/mix/dep/loader.ex | 13 +++++++++++++ lib/mix/test/mix/tasks/deps.git_test.exs | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/lib/mix/lib/mix/dep/loader.ex b/lib/mix/lib/mix/dep/loader.ex index 7b576a52842..809f87f40be 100644 --- a/lib/mix/lib/mix/dep/loader.ex +++ b/lib/mix/lib/mix/dep/loader.ex @@ -396,6 +396,9 @@ defmodule Mix.Dep.Loader do not ok?(dep) -> dep + fetched_but_not_compiled?(dep) -> + %{dep | status: :compile} + opts_app == false -> dep @@ -410,6 +413,16 @@ defmodule Mix.Dep.Loader do end end + # The build's .app is stale after a fetch replaced the source; skip its + # vsn/req check until the SCM manifest's stored lock matches opts[:lock]. + defp fetched_but_not_compiled?(%Mix.Dep{scm: scm, opts: opts}) do + scm.fetchable?() and + case Mix.Dep.ElixirSCM.read(Path.join(opts[:build], ".mix")) do + {:ok, _, _, stored_lock} -> stored_lock != opts[:lock] + :error -> true + end + end + defp app_status(app_path, app, req) do case Mix.AppLoader.read_app(app, app_path) do {:ok, properties} -> diff --git a/lib/mix/test/mix/tasks/deps.git_test.exs b/lib/mix/test/mix/tasks/deps.git_test.exs index 985d0405bda..1a7ec961f42 100644 --- a/lib/mix/test/mix/tasks/deps.git_test.exs +++ b/lib/mix/test/mix/tasks/deps.git_test.exs @@ -275,6 +275,30 @@ defmodule Mix.Tasks.DepsGitTest do purge([GitRepo, GitRepo.MixProject]) end + test "marks fetchable dep for recompile when stored lock differs from current lock" do + Mix.Project.push(GitApp) + + in_fixture("no_mixfile", fn -> + Mix.Tasks.Deps.Get.run([]) + Mix.Tasks.Deps.Compile.run([]) + + manifest = "_build/dev/lib/git_repo/.mix/compile.elixir_scm" + {2, vsn, scm, _lock} = manifest |> File.read!() |> :erlang.binary_to_term() + File.write!(manifest, :erlang.term_to_binary({2, vsn, scm, :stale_lock})) + + Mix.Task.clear() + Mix.State.clear_cache() + purge([GitRepo, GitRepo.MixProject]) + + [git_repo_dep] = + Mix.Dep.load_and_cache() |> Enum.filter(&(&1.app == :git_repo)) + + assert git_repo_dep.status == :compile + end) + after + purge([GitRepo, GitRepo.MixProject]) + end + test "updates the repo when the lock updates" do Mix.Project.push(GitApp) [last, first | _] = get_git_repo_revs("git_repo") From e60256ed4594308ba620ae18b12d2921dfafa939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 20 May 2026 20:33:05 +0200 Subject: [PATCH 2/2] Add converger-level regression test for stale .app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the full chain seen in hexpm/hex#1166: a fetchable dep's stale .app is read by `validate_app`, produces an `{:ok, vsn}` that matches the top-level requirement, then a transitive parent with a stricter requirement causes the converger's `req_mismatch` to mark the dep as `:divergedreq` — even though the only thing actually wrong is that `_build` hasn't been recompiled yet. With the loader bypass, `validate_app` returns `:compile` for a fetchable dep whose SCM manifest's stored lock differs from `opts[:lock]`, so the dep never enters the converger with `{:ok, _}` and `req_mismatch` doesn't fire. The previous test exercised the loader short-circuit in isolation; this one exercises the same path through the full converge step and asserts `Mix.Dep.diverged?/1` returns false. --- lib/mix/test/mix/tasks/deps.git_test.exs | 76 ++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/lib/mix/test/mix/tasks/deps.git_test.exs b/lib/mix/test/mix/tasks/deps.git_test.exs index 1a7ec961f42..402d36a4e40 100644 --- a/lib/mix/test/mix/tasks/deps.git_test.exs +++ b/lib/mix/test/mix/tasks/deps.git_test.exs @@ -299,6 +299,82 @@ defmodule Mix.Tasks.DepsGitTest do purge([GitRepo, GitRepo.MixProject]) end + test "stale .app for a fetchable dep does not surface as :divergedreq via the converger" do + # Reproduces the convergence chain seen in hexpm/hex#1166: + # + # 1. validate_app reads `_build/.../git_repo.app` (stale vsn 0.1.0) + # against the top-level requirement "0.1.0" and returns + # {:ok, "0.1.0"}. + # 2. The converger then encounters git_repo a second time, this + # time as a child of `strict_parent`, whose requirement + # "~> 0.2.0" does not match the cached {:ok, "0.1.0"}. + # `req_mismatch` fires and the dep is marked :divergedreq. + # 3. `show_diverged!` raises before the lock-in-manifest check + # in `check_manifest` gets a chance to flag the build as stale. + # + # With the loader bypass, step 1 returns :compile instead of + # {:ok, _}, `req_mismatch` returns nil, and the dep ends up flagged + # for recompile rather than as a spurious requirement conflict. + in_fixture("no_mixfile", fn -> + File.mkdir_p!("strict_parent/lib") + File.write!("strict_parent/lib/strict_parent.ex", "defmodule StrictParent do\nend\n") + + write_strict_parent = fn req -> + File.write!("strict_parent/mix.exs", """ + defmodule StrictParent.MixProject do + use Mix.Project + def project do + [ + app: :strict_parent, + version: "0.1.0", + deps: [{:git_repo, #{inspect(req)}, git: #{inspect(fixture_path("git_repo"))}}] + ] + end + end + """) + end + + # Bootstrap with a matching requirement so deps.get + compile succeed + # and the SCM manifest records the current lock alongside vsn 0.1.0. + write_strict_parent.("0.1.0") + + Mix.ProjectStack.post_config( + deps: [ + {:git_repo, "0.1.0", git: fixture_path("git_repo")}, + {:strict_parent, path: "strict_parent"} + ] + ) + + Mix.Project.push(MixTest.Case.Sample) + + Mix.Tasks.Deps.Get.run([]) + Mix.Tasks.Deps.Compile.run([]) + + # Now simulate the post-fetch state: + # - The transitive parent has moved on to a stricter requirement + # that the build's stale .app vsn ("0.1.0") no longer satisfies. + # - The SCM manifest's stored lock no longer matches opts[:lock], + # signalling that _build is behind the fetched source. + write_strict_parent.("~> 0.2.0") + + manifest = "_build/dev/lib/git_repo/.mix/compile.elixir_scm" + {2, vsn, scm, _lock} = manifest |> File.read!() |> :erlang.binary_to_term() + File.write!(manifest, :erlang.term_to_binary({2, vsn, scm, :stale_lock})) + + Mix.Task.clear() + Mix.State.clear_cache() + purge([GitRepo, GitRepo.MixProject, StrictParent.MixProject]) + + [git_repo_dep] = + Mix.Dep.load_and_cache() |> Enum.filter(&(&1.app == :git_repo)) + + assert git_repo_dep.status == :compile + refute Mix.Dep.diverged?(git_repo_dep) + end) + after + purge([GitRepo, GitRepo.MixProject, StrictParent.MixProject]) + end + test "updates the repo when the lock updates" do Mix.Project.push(GitApp) [last, first | _] = get_git_repo_revs("git_repo")