Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions lib/hex/remote_converger.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
114 changes: 103 additions & 11 deletions lib/mix/tasks/hex.outdated.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
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". 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
is `2.8`, then `--within-requirements` will exit with non-zero exit code (1).
Expand Down Expand Up @@ -134,7 +140,7 @@
end

defp display_table(
[{_package, _dep_only, current, latest, requirements, outdated?}],
[{_package, _dep_only, current, latest, requirements, outdated?, cooldown}],
[_app],
_opts
) do
Expand All @@ -158,6 +164,8 @@

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
Expand All @@ -171,16 +179,53 @@
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)
Hex.Shell.info(["\n", base_message, diff_message, diff_command_message])
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?, 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)
Expand Down Expand Up @@ -236,7 +281,7 @@
"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
Expand All @@ -256,14 +301,21 @@
# 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)
end
end

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} ->
Expand All @@ -276,9 +328,17 @@
requirements = deps_requirements ++ lock_requirements
dep_only = get_dep_only(deps, name)

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

[
{Atom.to_string(name), dep_only, lock_version, latest_version, requirements,
outdated?}
outdated?, cooldown}
]

_ ->
Expand All @@ -287,6 +347,25 @@
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 != []
Expand All @@ -306,17 +385,19 @@
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,
Expand All @@ -326,7 +407,7 @@
]
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
Expand Down Expand Up @@ -369,7 +450,8 @@
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
Expand All @@ -394,7 +476,7 @@
|> 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,
Expand All @@ -408,13 +490,23 @@
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

defp encode_json!(term) do
if Code.ensure_loaded?(:json) do
term |> :json.encode() |> IO.iodata_to_binary()

Check warning on line 509 in lib/mix/tasks/hex.outdated.ex

View workflow job for this annotation

GitHub Actions / Test (25.3, 1.14.5)

:json.encode/1 is undefined (module :json is not available or is yet to be defined)

Check warning on line 509 in lib/mix/tasks/hex.outdated.ex

View workflow job for this annotation

GitHub Actions / Test (25.3, 1.15.7)

:json.encode/1 is undefined (module :json is not available or is yet to be defined)

Check warning on line 509 in lib/mix/tasks/hex.outdated.ex

View workflow job for this annotation

GitHub Actions / Test (24.3, 1.12.3)

:json.encode/1 is undefined (module :json is not available or is yet to be defined)

Check warning on line 509 in lib/mix/tasks/hex.outdated.ex

View workflow job for this annotation

GitHub Actions / Test (25.3, 1.13.4)

:json.encode/1 is undefined (module :json is not available or is yet to be defined)

Check warning on line 509 in lib/mix/tasks/hex.outdated.ex

View workflow job for this annotation

GitHub Actions / Test (25.3, 1.15.7)

:json.encode/1 is undefined (module :json is not available or is yet to be defined)

Check warning on line 509 in lib/mix/tasks/hex.outdated.ex

View workflow job for this annotation

GitHub Actions / Test (25.3, 1.13.4)

:json.encode/1 is undefined (module :json is not available or is yet to be defined)

Check warning on line 509 in lib/mix/tasks/hex.outdated.ex

View workflow job for this annotation

GitHub Actions / Test (25.3, 1.14.5)

:json.encode/1 is undefined (module :json is not available or is yet to be defined)

Check warning on line 509 in lib/mix/tasks/hex.outdated.ex

View workflow job for this annotation

GitHub Actions / Test (24.3, 1.12.3)

:json.encode/1 is undefined (module :json is not available or is yet to be defined)
else
Mix.raise(":json module is not available, upgrade OTP to use this feature")
end
Expand Down
34 changes: 34 additions & 0 deletions test/hex/remote_converger_cooldown_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading