Skip to main content

lib/mix/tasks/verify.release_publish.ex

# credo:disable-for-this-file
defmodule Mix.Tasks.Verify.ReleasePublish do
  @moduledoc false

  use Mix.Task

  @shortdoc "Verifies published rulestead artifacts from fresh consumer fixtures"
  @switches []
  @default_fixture_module Rulestead.Test.ReleasePublishFixture

  @impl Mix.Task
  def run(args) do
    {_opts, argv} = parse_args!(args)

    version =
      case argv do
        [value] ->
          validate_version!(value)

        _other ->
          Mix.raise(
            "expected a published version argument, e.g. mix verify.release_publish 0.1.0"
          )
      end

    tmp_dir = tmp_dir()

    try do
      plan = plan(version, tmp_dir: tmp_dir)

      case verify(plan) do
        {:ok, _report} ->
          Mix.shell().info("published release verified for #{version}")

        {:error, reason} ->
          Mix.raise(format_error(reason))
      end
    after
      File.rm_rf!(tmp_dir)
    end
  end

  def validate_version!(version) when is_binary(version) do
    trimmed = String.trim(version)

    cond do
      trimmed == "" ->
        Mix.raise("expected a published version argument")

      String.contains?(trimmed, "path:") or String.contains?(trimmed, "/") ->
        Mix.raise("expected a published version, got local path input: #{trimmed}")

      not Regex.match?(~r/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z\.-]+)?$/, trimmed) ->
        Mix.raise("expected a published version, got: #{trimmed}")

      true ->
        trimmed
    end
  end

  def plan(version, opts \\ []) do
    fixture_module = Keyword.get(opts, :fixture_module, @default_fixture_module)
    tmp_dir = Keyword.get(opts, :tmp_dir, tmp_dir())
    fixture_opts = Keyword.drop(opts, [:fixture_module, :tmp_dir])

    %{
      version: validate_version!(version),
      tmp_dir: tmp_dir,
      hexdocs_url: hexdocs_url(version),
      consumers: [
        fixture_module.setup_core_consumer!(tmp_dir, version, fixture_opts),
        fixture_module.setup_admin_consumer!(tmp_dir, version, fixture_opts)
      ]
    }
  end

  def verify(plan, opts \\ []) do
    http_get = Keyword.get(opts, :http_get, &default_http_get/1)
    command_runner = Keyword.get(opts, :command_runner, &default_command_runner/3)

    with :ok <- validate_consumers(plan.consumers),
         {:ok, docs_status} <- check_hexdocs(plan.hexdocs_url, http_get),
         {:ok, consumers} <- run_consumer_checks(plan.consumers, command_runner) do
      {:ok, %{hexdocs: docs_status, consumers: consumers}}
    end
  end

  def hexdocs_url(version), do: "https://hexdocs.pm/rulestead/#{version}"

  defp parse_args!(args) do
    {opts, argv, invalid} = OptionParser.parse(args, strict: @switches)

    case invalid do
      [] -> {opts, argv}
      [{flag, _value} | _rest] -> Mix.raise("unknown option: #{flag}")
    end
  end

  defp validate_consumers(consumers) do
    consumers
    |> Enum.find_value(:ok, fn consumer ->
      consumer.deps
      |> Enum.find_value(fn dep ->
        cond do
          Map.has_key?(dep, :path) -> {:error, {:local_path_dependency, dep.app}}
          not is_binary(dep[:requirement]) -> {:error, {:missing_version_requirement, dep.app}}
          true -> nil
        end
      end)
    end)
  end

  defp check_hexdocs(url, http_get) do
    case http_get.(url) do
      {:ok, 200} -> {:ok, {:ok, 200}}
      {:ok, status} -> {:error, {:hexdocs_unreachable, url, status}}
      {:error, reason} -> {:error, {:hexdocs_request_failed, url, reason}}
    end
  end

  defp run_consumer_checks(consumers, command_runner) do
    consumers
    |> Enum.reduce_while({:ok, []}, fn consumer, {:ok, reports} ->
      case run_consumer_check(consumer, command_runner) do
        {:ok, report} -> {:cont, {:ok, reports ++ [report]}}
        {:error, reason} -> {:halt, {:error, reason}}
      end
    end)
  end

  defp run_consumer_check(consumer, command_runner) do
    consumer.checks
    |> Enum.reduce_while({:ok, []}, fn check, {:ok, completed} ->
      case command_runner.(check.cmd, check.args, cd: consumer.app_dir, stderr_to_stdout: true) do
        {output, 0} ->
          {:cont, {:ok, completed ++ [%{cmd: check.cmd, args: check.args, output: output}]}}

        {output, status} ->
          {:halt,
           {:error,
            {:consumer_command_failed, consumer.name, check.cmd, check.args, status, output}}}
      end
    end)
    |> case do
      {:ok, completed} -> {:ok, %{name: consumer.name, checks: completed}}
      error -> error
    end
  end

  defp default_http_get(url) do
    :inets.start()
    :ssl.start()

    case :httpc.request(:get, {String.to_charlist(url), []}, [], [{:body_format, :binary}]) do
      {:ok, {{_http_version, status, _reason}, _headers, _body}} -> {:ok, status}
      {:error, reason} -> {:error, reason}
    end
  end

  defp default_command_runner(cmd, args, opts) do
    System.cmd(cmd, args, opts)
  end

  defp format_error({:local_path_dependency, app}),
    do: "published release verification rejected local path dependency fallback for #{app}"

  defp format_error({:missing_version_requirement, app}),
    do: "published release verification requires an explicit package version for #{app}"

  defp format_error({:hexdocs_unreachable, url, status}),
    do: "versioned HexDocs URL did not return 200: #{url} (status #{status})"

  defp format_error({:hexdocs_request_failed, url, reason}),
    do: "HexDocs reachability check failed for #{url}: #{inspect(reason)}"

  defp format_error({:consumer_command_failed, name, cmd, args, status, output}) do
    "consumer #{name} command failed with exit #{status}: #{cmd} #{Enum.join(args, " ")}\n#{output}"
  end

  defp tmp_dir do
    path =
      Path.join(
        System.tmp_dir!(),
        "rulestead-release-publish-#{System.unique_integer([:positive])}"
      )

    File.mkdir_p!(path)
    path
  end
end