From d57cda8c35b07610cc410792999c5bf2f12980c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 28 May 2026 00:50:51 +0200 Subject: [PATCH 1/4] Annotate cooldown-held versions in mix hex.outdated Show "(cooldown)" alongside the status when the latest version of a dependency is within the configured cooldown window, with a "Versions in cooldown" legend below the table listing eligibility dates. The single-package output gets a similar line, and the JSON output gains a `cooldown` field exposing published_at and eligible_on. --- lib/mix/tasks/hex.outdated.ex | 102 ++++++++++++-- test/mix/tasks/hex.outdated_test.exs | 202 ++++++++++++++++++++++++++- 2 files changed, 292 insertions(+), 12 deletions(-) diff --git a/lib/mix/tasks/hex.outdated.ex b/lib/mix/tasks/hex.outdated.ex index 00736a12..a6586c24 100644 --- a/lib/mix/tasks/hex.outdated.ex +++ b/lib/mix/tasks/hex.outdated.ex @@ -18,6 +18,12 @@ defmodule Mix.Tasks.Hex.Outdated do as specified in your `mix.exs` file, with the `--within-requirements` command line option, so it only exits with non-zero exit code if the update is possible. + If a `:cooldown` is configured (see `mix hex.config`) and the latest version of a + dependency falls within the cooldown window, the row is annotated with `(cooldown)` + and the version is listed under "Versions in cooldown". The exit code still treats + such dependencies as outdated; cooldown only affects what `mix deps.update` would + resolve to. + For example, if your version requirement is "~> 2.0" but the latest version is `3.0`, with `--within-requirements` it will exit successfully, but if the latest version is `2.8`, then `--within-requirements` will exit with non-zero exit code (1). @@ -134,7 +140,7 @@ defmodule Mix.Tasks.Hex.Outdated do end defp display_table( - [{_package, _dep_only, current, latest, requirements, outdated?}], + [{_package, _dep_only, current, latest, requirements, outdated?, cooldown}], [_app], _opts ) do @@ -158,6 +164,8 @@ defmodule Mix.Tasks.Hex.Outdated do message = "Up-to-date indicates if the requirement matches the latest version." Hex.Shell.info(["\n", message]) + + maybe_print_single_cooldown(latest, cooldown) end defp display_table(versions, _args, opts) do @@ -171,6 +179,8 @@ defmodule Mix.Tasks.Hex.Outdated do header = ["Dependency", "Only", "Current", "Latest", "Status"] Mix.Tasks.Hex.print_table(header, values) + maybe_print_cooldown_legend(versions) + base_message = "Run `mix hex.outdated APP` to see requirements for a specific dependency." diff_message = maybe_diff_message(diff_links) diff_command_message = maybe_diff_command_message(diff_links) @@ -178,9 +188,42 @@ defmodule Mix.Tasks.Hex.Outdated do end end + defp maybe_print_single_cooldown(_latest, nil), do: :ok + + defp maybe_print_single_cooldown(latest, %{eligible_on: eligible_on}) do + days = Date.diff(eligible_on, Date.utc_today()) + Hex.Shell.info("\nVersion #{latest} is in cooldown — eligible #{eligible_on} (#{days} days)") + end + + defp maybe_print_cooldown_legend(versions) do + entries = + Enum.flat_map(versions, fn + {package, _, _, latest, _, _, %{eligible_on: eligible_on}} -> + [{package, latest, eligible_on}] + + _ -> + [] + end) + + case entries do + [] -> + :ok + + entries -> + today = Date.utc_today() + Hex.Shell.info("") + Hex.Shell.info("Versions in cooldown:") + + Enum.each(entries, fn {package, version, eligible_on} -> + days = Date.diff(eligible_on, today) + Hex.Shell.info(" #{package} #{version} — eligible #{eligible_on} (#{days} days)") + end) + end + end + defp set_exit_code(versions, args, opts) do outdated_versions = - Enum.filter(versions, fn {_p, _o, _l, _la, _r, outdated?} -> outdated? end) + Enum.filter(versions, fn {_p, _o, _l, _la, _r, outdated?, _c} -> outdated? end) if outdated_versions != [] and exit_with_error?(outdated_versions, args, opts) do Mix.Tasks.Hex.set_exit_code(1) @@ -236,7 +279,7 @@ defmodule Mix.Tasks.Hex.Outdated do "Update possible" => 3 } - Enum.sort_by(values, fn [_package, _dep_only, _lock, _latest, [_color, status]] -> + Enum.sort_by(values, fn [_package, _dep_only, _lock, _latest, [_color, status | _]] -> Map.fetch!(status_order, status) end) end @@ -256,7 +299,7 @@ defmodule Mix.Tasks.Hex.Outdated do # deps can have multiple `only` values, so we separate by `,` only_values = String.split(only_value, ",", trim: true) - Enum.filter(versions, fn {_package, dep_only, _lock, _latest, _reqs, _outdated?} -> + Enum.filter(versions, fn {_package, dep_only, _lock, _latest, _reqs, _outdated?, _c} -> dep_only_parts = String.split(dep_only, ",") Enum.any?(dep_only_parts, &(&1 in only_values)) end) @@ -264,6 +307,8 @@ defmodule Mix.Tasks.Hex.Outdated do end defp get_versions(dep_names, deps, lock, pre?) do + cutoff = Hex.Cooldown.build_cutoff() + Enum.flat_map(dep_names, fn name -> case Hex.Utils.lock(lock[name]) do %{repo: repo, name: package, version: lock_version} -> @@ -276,9 +321,12 @@ defmodule Mix.Tasks.Hex.Outdated do requirements = deps_requirements ++ lock_requirements dep_only = get_dep_only(deps, name) + cooldown = + if outdated?, do: cooldown_info(repo, package, latest_version, cutoff), else: nil + [ {Atom.to_string(name), dep_only, lock_version, latest_version, requirements, - outdated?} + outdated?, cooldown} ] _ -> @@ -287,6 +335,25 @@ defmodule Mix.Tasks.Hex.Outdated do end) end + defp cooldown_info(_repo, _package, _version, :disabled), do: nil + + defp cooldown_info(repo, package, version, cutoff) do + if Hex.Cooldown.repo_excluded?(repo) do + nil + else + published_at = Registry.published_at(repo, package, version) + + if Hex.Cooldown.eligible?(published_at, cutoff) do + nil + else + %{ + published_at: published_at, + eligible_on: Hex.Cooldown.eligible_on(published_at, cutoff) + } + end + end + end + defp latest_version(repo, package, default, pre?) do {:ok, default} = Version.parse(default) pre? = pre? || default.pre != [] @@ -306,17 +373,19 @@ defmodule Mix.Tasks.Hex.Outdated do List.last(versions) end - defp format_all_row({package, dep_only, lock, latest, requirements, outdated?}) do + defp format_all_row({package, dep_only, lock, latest, requirements, outdated?, cooldown}) do latest_color = if outdated?, do: :red, else: :green req_matches? = req_matches?(requirements, latest) - status = + base_status = case {outdated?, req_matches?} do {true, true} -> [:yellow, "Update possible"] {true, false} -> [:red, "Update not possible"] {false, _} -> [:green, "Up-to-date"] end + status = if cooldown, do: base_status ++ [" (cooldown)"], else: base_status + [ [:bright, package], dep_only, @@ -326,7 +395,7 @@ defmodule Mix.Tasks.Hex.Outdated do ] end - defp build_diff_link({package, _dep_only, lock, latest, requirements, outdated?}) do + defp build_diff_link({package, _dep_only, lock, latest, requirements, outdated?, _cooldown}) do req_matches? = req_matches?(requirements, latest) case {outdated?, req_matches?} do @@ -369,7 +438,8 @@ defmodule Mix.Tasks.Hex.Outdated do end defp any_possible_to_update?(outdated_versions) do - Enum.any?(outdated_versions, fn {_package, _dep_only, _lock, latest, requirements, _outdated?} -> + Enum.any?(outdated_versions, fn {_package, _dep_only, _lock, latest, requirements, _outdated?, + _cooldown} -> req_matches?(requirements, latest) end) end @@ -394,7 +464,7 @@ defmodule Mix.Tasks.Hex.Outdated do |> Enum.join(",") end - defp cast_version_map({package, dep_only, lock, latest, requirements, outdated?}) do + defp cast_version_map({package, dep_only, lock, latest, requirements, outdated?, cooldown}) do %{ package: package, only: dep_only, @@ -408,7 +478,17 @@ defmodule Mix.Tasks.Hex.Outdated do up_to_date: version_match?(latest, req_version) } end), - outdated: outdated? + outdated: outdated?, + cooldown: cast_cooldown_map(cooldown) + } + end + + defp cast_cooldown_map(nil), do: nil + + defp cast_cooldown_map(%{published_at: published_at, eligible_on: eligible_on}) do + %{ + published_at: published_at |> DateTime.from_unix!() |> DateTime.to_iso8601(), + eligible_on: Date.to_iso8601(eligible_on) } end diff --git a/test/mix/tasks/hex.outdated_test.exs b/test/mix/tasks/hex.outdated_test.exs index 2a4d302e..fe1aa2d5 100644 --- a/test/mix/tasks/hex.outdated_test.exs +++ b/test/mix/tasks/hex.outdated_test.exs @@ -608,7 +608,8 @@ defmodule Mix.Tasks.Hex.OutdatedTest do %{source: "ecto", requirement: "~> 0.0.1", up_to_date: false}, %{source: "postgrex", requirement: "0.0.1", up_to_date: false} ], - outdated: true + outdated: true, + cooldown: nil } ] |> :json.encode() @@ -895,4 +896,203 @@ defmodule Mix.Tasks.Hex.OutdatedTest do refute combined_output =~ "ex_doc" end) end + + describe "cooldown" do + defp inject_published_at(repo, package, version, published_at) do + :sys.replace_state(Hex.Registry.Server, fn state -> + :ets.insert(state.ets, {{:published_at, repo, package, version}, published_at}) + state + end) + end + + defp clear_all_published_at do + :sys.replace_state(Hex.Registry.Server, fn state -> + :ets.match_delete(state.ets, {{:published_at, :_, :_, :_}, :_}) + state + end) + end + + defp collect_shell_lines do + for {:mix_shell, :info, [line]} <- flush(), is_binary(line), do: line + end + + test "annotates row when latest version is within the cooldown window" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1} + + lines = collect_shell_lines() + + assert Enum.any?(lines, &(&1 =~ "foo" and &1 =~ "Update possible" and &1 =~ "(cooldown)")) + assert Enum.any?(lines, &(&1 == "Versions in cooldown:")) + assert Enum.any?(lines, &(&1 =~ "foo 0.1.1" and &1 =~ "eligible")) + end) + end + + test "does not annotate when latest version is older than the cooldown window" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 30 * 86_400) + Hex.State.put(:cooldown, "7d") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1} + + lines = collect_shell_lines() + refute Enum.any?(lines, &(&1 =~ "(cooldown)")) + refute Enum.any?(lines, &(&1 == "Versions in cooldown:")) + end) + end + + test "does not annotate when cooldown is disabled" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "0d") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1} + + lines = collect_shell_lines() + refute Enum.any?(lines, &(&1 =~ "(cooldown)")) + refute Enum.any?(lines, &(&1 == "Versions in cooldown:")) + end) + end + + test "treats missing published_at as eligible" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + Hex.State.put(:cooldown, "1mo") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1} + + lines = collect_shell_lines() + refute Enum.any?(lines, &(&1 =~ "(cooldown)")) + refute Enum.any?(lines, &(&1 == "Versions in cooldown:")) + end) + end + + test "does not annotate up-to-date dependencies even when fresh" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "bar", "0.1.0", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1} + + lines = collect_shell_lines() + refute Enum.any?(lines, &(&1 =~ "bar" and &1 =~ "(cooldown)")) + end) + end + + test "annotates single-package output when latest is in cooldown" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + catch_throw(Mix.Task.run("hex.outdated", ["foo"])) + + lines = collect_shell_lines() + assert Enum.any?(lines, &(&1 =~ "Version 0.1.1 is in cooldown" and &1 =~ "eligible")) + end) + end + + test "--sort status orders cooldown-annotated rows by their base status" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all", "--sort", "status"])) == + {:exit_code, 1} + + assert extract_statuses(flush()) == [ + "Up-to-date", + "Update not possible", + "Update possible" + ] + end) + end + + @tag :requires_json + test "JSON output exposes published_at and eligible_on when latest is in cooldown" do + Mix.Project.push(OutdatedApp.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{ex_doc: {:hex, :ex_doc, "0.0.1"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "ex_doc", "0.1.0", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + catch_throw(Mix.Task.run("hex.outdated", ["ex_doc", "--json"])) + + [json] = collect_shell_lines() + [decoded] = :json.decode(json) + assert %{"published_at" => _, "eligible_on" => _} = decoded["cooldown"] + end) + end + end end From be0efb41c5c8630185c0da3fab941a5c1b75c40c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 28 May 2026 09:49:49 +0200 Subject: [PATCH 2/4] Exclude cooldown-held updates from hex.outdated exit code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dependency whose latest version is filtered by cooldown should not cause `mix hex.outdated` to exit non-zero — `mix deps.update` would not pick that version up anyway, so it isn't actionable until the cooldown window passes. --- lib/mix/tasks/hex.outdated.ex | 10 ++++--- test/mix/tasks/hex.outdated_test.exs | 44 ++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/lib/mix/tasks/hex.outdated.ex b/lib/mix/tasks/hex.outdated.ex index a6586c24..0747a7e4 100644 --- a/lib/mix/tasks/hex.outdated.ex +++ b/lib/mix/tasks/hex.outdated.ex @@ -20,9 +20,9 @@ defmodule Mix.Tasks.Hex.Outdated do If a `:cooldown` is configured (see `mix hex.config`) and the latest version of a dependency falls within the cooldown window, the row is annotated with `(cooldown)` - and the version is listed under "Versions in cooldown". The exit code still treats - such dependencies as outdated; cooldown only affects what `mix deps.update` would - resolve to. + and the version is listed under "Versions in cooldown". Cooldown-held updates do + not contribute to the non-zero exit code — `mix deps.update` would not pick them + up until they age out of the window. For example, if your version requirement is "~> 2.0" but the latest version is `3.0`, with `--within-requirements` it will exit successfully, but if the latest version @@ -223,7 +223,9 @@ defmodule Mix.Tasks.Hex.Outdated do defp set_exit_code(versions, args, opts) do outdated_versions = - Enum.filter(versions, fn {_p, _o, _l, _la, _r, outdated?, _c} -> outdated? end) + Enum.filter(versions, fn {_p, _o, _l, _la, _r, outdated?, cooldown} -> + outdated? and is_nil(cooldown) + end) if outdated_versions != [] and exit_with_error?(outdated_versions, args, opts) do Mix.Tasks.Hex.set_exit_code(1) diff --git a/test/mix/tasks/hex.outdated_test.exs b/test/mix/tasks/hex.outdated_test.exs index fe1aa2d5..edf90e9f 100644 --- a/test/mix/tasks/hex.outdated_test.exs +++ b/test/mix/tasks/hex.outdated_test.exs @@ -1026,7 +1026,7 @@ defmodule Mix.Tasks.Hex.OutdatedTest do end) end - test "annotates single-package output when latest is in cooldown" do + test "single-package mode annotates output and does not exit with error when latest is in cooldown" do Mix.Project.push(OutdatedDeps.MixProject) in_tmp(fn -> @@ -1040,13 +1040,51 @@ defmodule Mix.Tasks.Hex.OutdatedTest do inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) Hex.State.put(:cooldown, "7d") - catch_throw(Mix.Task.run("hex.outdated", ["foo"])) + assert Mix.Task.run("hex.outdated", ["foo"]) == nil lines = collect_shell_lines() assert Enum.any?(lines, &(&1 =~ "Version 0.1.1 is in cooldown" and &1 =~ "eligible")) end) end + test "does not exit with error when every outdated dep is cooldown-held" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + now = System.system_time(:second) + clear_all_published_at() + inject_published_at("hexpm", "foo", "0.1.1", now - 86_400) + inject_published_at("hexpm", "ex_doc", "0.1.0", now - 86_400) + Hex.State.put(:cooldown, "7d") + + assert Mix.Task.run("hex.outdated", ["--all"]) == nil + end) + end + + test "still exits with error when at least one outdated dep is not cooldown-held" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1} + end) + end + test "--sort status orders cooldown-annotated rows by their base status" do Mix.Project.push(OutdatedDeps.MixProject) @@ -1087,7 +1125,7 @@ defmodule Mix.Tasks.Hex.OutdatedTest do inject_published_at("hexpm", "ex_doc", "0.1.0", System.system_time(:second) - 86_400) Hex.State.put(:cooldown, "7d") - catch_throw(Mix.Task.run("hex.outdated", ["ex_doc", "--json"])) + Mix.Task.run("hex.outdated", ["ex_doc", "--json"]) [json] = collect_shell_lines() [decoded] = :json.decode(json) From 48a407ee987dcff9f985cb8ded7281092aae97bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 28 May 2026 11:50:51 +0200 Subject: [PATCH 3/4] Extend cooldown bypass to lock-children of unsafe packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A package whose locked version has been retired or has a security advisory was already bypassed from cooldown filtering, but escaping typically requires resolving newer transitive dependencies — and those were still being held back. Lift cooldown for the lock-children too, mirroring the way mix deps.update unlocks the parent and its children together. mix hex.outdated now consults the same bypass when deciding whether to annotate a row with (cooldown). --- lib/hex/remote_converger.ex | 38 +++++++++++++++---- lib/mix/tasks/hex.outdated.ex | 11 +++++- test/hex/remote_converger_cooldown_test.exs | 34 +++++++++++++++++ test/mix/tasks/hex.outdated_test.exs | 41 +++++++++++++++++++++ 4 files changed, 115 insertions(+), 9 deletions(-) diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index 258fbc46..d588eea6 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -143,7 +143,7 @@ defmodule Hex.RemoteConverger do def build_cooldown_bypass(_old_lock, _locked, :disabled), do: MapSet.new() def build_cooldown_bypass(old_lock, locked, _cutoff) do - # The bypass set has two sources: + # The bypass set has three sources: # # 1. Packages in `locked` (the post-`prepare_locked/3` set): the lockfile # entry survived requirement checking and dep unlocking, so this @@ -156,15 +156,37 @@ defmodule Hex.RemoteConverger do # to escape that version; cooldown must not block the escape. Walks # `old_lock` rather than `locked` because `mix deps.update foo` to # escape an unsafe foo removes foo from `locked`. + # + # 3. The currently-locked children of any package in (2): escaping an + # unsafe parent typically requires re-resolving its dependency + # subtree, and the new parent version usually wants newer versions of + # those children. Mirroring `mix deps.update`'s behavior of unlocking + # the children alongside the explicit target, we lift cooldown on the + # same set. lock_satisfied = for %{name: name} <- locked, into: MapSet.new(), do: name + MapSet.union(lock_satisfied, unsafe_lock_bypass(old_lock)) + end + + @doc """ + Set of package names whose cooldown should be lifted because the lock + currently points at an unsafe version of that package, plus the names + of that package's lock-children. + """ + def unsafe_lock_bypass(lock) do + Enum.reduce(lock, MapSet.new(), fn {_app, lock_entry}, acc -> + case Hex.Utils.lock(lock_entry) do + %{repo: repo, name: name, version: version, deps: deps} -> + if locked_version_unsafe?(repo || "hexpm", name, to_string(version)) do + children = for {_app, _req, opts} <- deps || [], do: opts[:hex] + acc |> MapSet.put(name) |> MapSet.union(MapSet.new(children)) + else + acc + end - unsafe = - for %{repo: repo, name: name, version: version} <- Hex.Mix.from_lock(old_lock), - locked_version_unsafe?(repo || "hexpm", name, to_string(version)), - into: MapSet.new(), - do: name - - MapSet.union(lock_satisfied, unsafe) + nil -> + acc + end + end) end defp locked_version_unsafe?(repo, name, version) do diff --git a/lib/mix/tasks/hex.outdated.ex b/lib/mix/tasks/hex.outdated.ex index 0747a7e4..da3bb047 100644 --- a/lib/mix/tasks/hex.outdated.ex +++ b/lib/mix/tasks/hex.outdated.ex @@ -311,6 +311,11 @@ defmodule Mix.Tasks.Hex.Outdated do defp get_versions(dep_names, deps, lock, pre?) do cutoff = Hex.Cooldown.build_cutoff() + bypass = + if cutoff == :disabled, + do: MapSet.new(), + else: Hex.RemoteConverger.unsafe_lock_bypass(lock) + Enum.flat_map(dep_names, fn name -> case Hex.Utils.lock(lock[name]) do %{repo: repo, name: package, version: lock_version} -> @@ -324,7 +329,11 @@ defmodule Mix.Tasks.Hex.Outdated do dep_only = get_dep_only(deps, name) cooldown = - if outdated?, do: cooldown_info(repo, package, latest_version, cutoff), else: nil + cond do + not outdated? -> nil + MapSet.member?(bypass, package) -> nil + true -> cooldown_info(repo, package, latest_version, cutoff) + end [ {Atom.to_string(name), dep_only, lock_version, latest_version, requirements, diff --git a/test/hex/remote_converger_cooldown_test.exs b/test/hex/remote_converger_cooldown_test.exs index 9cac26d4..958bb635 100644 --- a/test/hex/remote_converger_cooldown_test.exs +++ b/test/hex/remote_converger_cooldown_test.exs @@ -43,6 +43,15 @@ defmodule Hex.RemoteConvergerCooldownTest do {:hex, String.to_atom(name), version, "checksum", [:mix], [], repo, "outer_checksum"} end + defp lock_tuple_with_deps(name, version, child_names, repo \\ "hexpm") do + deps = + Enum.map(child_names, fn child -> + {String.to_atom(child), "~> 0.0.0", [hex: child, repo: "hexpm", optional: false]} + end) + + {:hex, String.to_atom(name), version, "checksum", [:mix], deps, repo, "outer_checksum"} + end + defp locked_request(name, version, repo \\ "hexpm") do %{repo: repo, name: name, app: name, version: version} end @@ -90,6 +99,31 @@ defmodule Hex.RemoteConvergerCooldownTest do old_lock = %{advised_dep: lock_tuple("advised_dep", "2.0.0")} assert MapSet.new() == RemoteConverger.build_cooldown_bypass(old_lock, [], @cutoff) end + + test "lock-children of unsafe parent are also bypassed" do + # Escaping an unsafe parent typically requires re-resolving its + # dependency subtree, and the new parent version usually wants newer + # versions of those children. Mirror `mix deps.update`'s behavior of + # unlocking the children alongside the explicit target. + old_lock = %{ + retired_dep: lock_tuple_with_deps("retired_dep", "1.0.0", ["good", "advised_dep"]), + good: lock_tuple("good", "1.0.0") + } + + bypass = RemoteConverger.build_cooldown_bypass(old_lock, [], @cutoff) + + assert MapSet.member?(bypass, "retired_dep") + assert MapSet.member?(bypass, "good") + assert MapSet.member?(bypass, "advised_dep") + end + + test "children of safe parents are not bypassed" do + old_lock = %{ + good: lock_tuple_with_deps("good", "1.0.0", ["advised_dep"]) + } + + assert MapSet.new() == RemoteConverger.build_cooldown_bypass(old_lock, [], @cutoff) + end end describe "build_cooldown_bypass/3 — lock-satisfied bypass (spec C)" do diff --git a/test/mix/tasks/hex.outdated_test.exs b/test/mix/tasks/hex.outdated_test.exs index edf90e9f..feae519f 100644 --- a/test/mix/tasks/hex.outdated_test.exs +++ b/test/mix/tasks/hex.outdated_test.exs @@ -88,6 +88,19 @@ defmodule Mix.Tasks.Hex.OutdatedTest do end end + defmodule UnsafeLockedDeps.MixProject do + def project do + [ + app: :outdated_app, + version: "0.0.1", + deps: [ + {:tired, ">= 0.0.0"}, + {:foo, ">= 0.0.0"} + ] + ] + end + end + defmodule OutdatedDepsWithTypes.MixProject do def project do [ @@ -1110,6 +1123,34 @@ defmodule Mix.Tasks.Hex.OutdatedTest do end) end + test "does not annotate when locked version is retired (unsafe-lock bypass)" do + Mix.Project.push(UnsafeLockedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + # tired 0.1.0 is retired in setup_hexpm.exs; foo 0.1.0 is not. + Mix.Dep.Lock.write(%{ + tired: {:hex, :tired, "0.1.0"}, + foo: {:hex, :foo, "0.1.0"} + }) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "tired", "0.2.0", System.system_time(:second) - 86_400) + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1} + + lines = collect_shell_lines() + + refute Enum.any?(lines, &(&1 =~ "tired" and &1 =~ "(cooldown)")) + assert Enum.any?(lines, &(&1 =~ "foo" and &1 =~ "(cooldown)")) + end) + end + @tag :requires_json test "JSON output exposes published_at and eligible_on when latest is in cooldown" do Mix.Project.push(OutdatedApp.MixProject) From c2d9d7d1ff4d53da73f237a1b922083b0731010a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 28 May 2026 11:57:44 +0200 Subject: [PATCH 4/4] Suppress hex.outdated cooldown annotation when update is not possible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the latest version of a dependency doesn't match any of the requirements on it, the user can't take that upgrade regardless of cooldown — the (cooldown) tag was misleading. Strip the cooldown attachment for those rows so the status is just "Update not possible" and the version doesn't appear in the cooldown legend. --- lib/mix/tasks/hex.outdated.ex | 1 + test/mix/tasks/hex.outdated_test.exs | 55 +++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/lib/mix/tasks/hex.outdated.ex b/lib/mix/tasks/hex.outdated.ex index da3bb047..62e95b7a 100644 --- a/lib/mix/tasks/hex.outdated.ex +++ b/lib/mix/tasks/hex.outdated.ex @@ -331,6 +331,7 @@ defmodule Mix.Tasks.Hex.Outdated do cooldown = cond do not outdated? -> nil + not req_matches?(requirements, latest_version) -> nil MapSet.member?(bypass, package) -> nil true -> cooldown_info(repo, package, latest_version, cutoff) end diff --git a/test/mix/tasks/hex.outdated_test.exs b/test/mix/tasks/hex.outdated_test.exs index feae519f..05b97b57 100644 --- a/test/mix/tasks/hex.outdated_test.exs +++ b/test/mix/tasks/hex.outdated_test.exs @@ -88,6 +88,16 @@ defmodule Mix.Tasks.Hex.OutdatedTest do end end + defmodule SingleDep.MixProject do + def project do + [ + app: :outdated_app, + version: "0.0.1", + deps: [{:foo, ">= 0.0.0"}] + ] + end + end + defmodule UnsafeLockedDeps.MixProject do def project do [ @@ -1061,22 +1071,20 @@ defmodule Mix.Tasks.Hex.OutdatedTest do end test "does not exit with error when every outdated dep is cooldown-held" do - Mix.Project.push(OutdatedDeps.MixProject) + Mix.Project.push(SingleDep.MixProject) in_tmp(fn -> set_home_tmp() - Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + Mix.Dep.Lock.write(%{foo: {:hex, :foo, "0.1.0"}}) Mix.Task.run("deps.get") flush() - now = System.system_time(:second) clear_all_published_at() - inject_published_at("hexpm", "foo", "0.1.1", now - 86_400) - inject_published_at("hexpm", "ex_doc", "0.1.0", now - 86_400) + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) Hex.State.put(:cooldown, "7d") - assert Mix.Task.run("hex.outdated", ["--all"]) == nil + assert Mix.Task.run("hex.outdated") == nil end) end @@ -1151,22 +1159,49 @@ defmodule Mix.Tasks.Hex.OutdatedTest do end) end + test "does not annotate when the update is not possible due to requirements" do + Mix.Project.push(OutdatedDeps.MixProject) + + in_tmp(fn -> + set_home_tmp() + # ex_doc's locked version is 0.0.1; OutdatedDeps requires ~> 0.0.1, + # which doesn't match the latest 0.1.0. Even with cooldown set on + # the latest, the user can't take the upgrade — don't annotate. + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) + + Mix.Task.run("deps.get") + flush() + + clear_all_published_at() + inject_published_at("hexpm", "ex_doc", "0.1.0", System.system_time(:second) - 86_400) + Hex.State.put(:cooldown, "7d") + + assert catch_throw(Mix.Task.run("hex.outdated", ["--all"])) == {:exit_code, 1} + + lines = collect_shell_lines() + + assert Enum.any?(lines, &(&1 =~ "ex_doc" and &1 =~ "Update not possible")) + refute Enum.any?(lines, &(&1 =~ "ex_doc" and &1 =~ "(cooldown)")) + refute Enum.any?(lines, &(&1 =~ "ex_doc 0.1.0" and &1 =~ "eligible")) + end) + end + @tag :requires_json test "JSON output exposes published_at and eligible_on when latest is in cooldown" do - Mix.Project.push(OutdatedApp.MixProject) + Mix.Project.push(OutdatedDeps.MixProject) in_tmp(fn -> set_home_tmp() - Mix.Dep.Lock.write(%{ex_doc: {:hex, :ex_doc, "0.0.1"}}) + Mix.Dep.Lock.write(%{bar: {:hex, :bar, "0.1.0"}, foo: {:hex, :foo, "0.1.0"}}) Mix.Task.run("deps.get") flush() clear_all_published_at() - inject_published_at("hexpm", "ex_doc", "0.1.0", System.system_time(:second) - 86_400) + inject_published_at("hexpm", "foo", "0.1.1", System.system_time(:second) - 86_400) Hex.State.put(:cooldown, "7d") - Mix.Task.run("hex.outdated", ["ex_doc", "--json"]) + Mix.Task.run("hex.outdated", ["foo", "--json"]) [json] = collect_shell_lines() [decoded] = :json.decode(json)