Skip to main content

lib/mix/tasks/release.publish.ex

defmodule Mix.Tasks.Release.Publish do
  use Mix.Task

  @shortdoc "Tag and publish current version to Hex"

  @moduledoc """
  Automates a library release for Hex.

  Steps:

  1. Ensure git worktree is clean.
  2. Run `mix precommit` (unless `--skip-precommit`).
  3. Read version from `mix.exs` and derive tag `v<version>`.
  4. Ensure tag does not already exist.
  5. Create annotated tag.
  6. Optionally push commit + tag.
  7. Optionally publish package/docs to Hex.

  ## Options

    * `--remote <name>` - git remote to push to (default: `origin`)
    * `--message <msg>` - tag annotation message (default: `Release v<version>`)
    * `--no-tag` - skip tag creation
    * `--no-push` - skip git push
    * `--no-publish` - skip `mix hex.publish`
    * `--skip-precommit` - skip `mix precommit`
    * `--yes` - pass `--yes` to `mix hex.publish`

  ## Examples

      mix release.publish --yes
      mix release.publish --no-publish
      mix release.publish --no-tag --no-push --yes
  """

  @switches [
    remote: :string,
    message: :string,
    no_tag: :boolean,
    no_push: :boolean,
    no_publish: :boolean,
    skip_precommit: :boolean,
    yes: :boolean
  ]

  @impl Mix.Task
  def run(args) do
    Mix.Task.run("app.start")
    {opts, _argv, invalid} = OptionParser.parse(args, strict: @switches)

    if invalid != [] do
      Mix.raise("invalid options: #{inspect(invalid)}")
    end

    remote = Keyword.get(opts, :remote, "origin")
    version = Mix.Project.config()[:version] || Mix.raise("missing project version")
    tag = "v#{version}"
    message = Keyword.get(opts, :message, "Release #{tag}")

    ensure_clean_git!()

    unless opts[:skip_precommit] do
      run_mix!(~w(precommit))
    end

    unless opts[:no_tag] do
      ensure_tag_missing!(tag)
      run_git!(~w(tag -a) ++ [tag, "-m", message])
    end

    unless opts[:no_push] do
      run_git!(~w(push) ++ [remote, "HEAD"])

      unless opts[:no_tag] do
        run_git!(~w(push) ++ [remote, tag])
      end
    end

    unless opts[:no_publish] do
      publish_args = if opts[:yes], do: ~w(hex.publish --yes), else: ~w(hex.publish)
      run_mix!(publish_args)
    end

    Mix.shell().info("release.publish complete for #{tag}")
  end

  defp ensure_clean_git! do
    {output, 0} = capture_cmd!("git", ["status", "--porcelain"])

    if String.trim(output) != "" do
      Mix.raise("git worktree not clean; commit or stash changes first")
    end
  end

  defp ensure_tag_missing!(tag) do
    {output, _code} = capture_cmd!("git", ["tag", "-l", tag])

    if String.trim(output) != "" do
      Mix.raise("tag already exists: #{tag}")
    end
  end

  defp run_git!(args), do: run_or_raise!("git", args)
  defp run_mix!(args), do: run_or_raise!("mix", args)

  defp run_or_raise!(cmd, args) do
    {_output, code} = stream_cmd!(cmd, args)

    if code != 0 do
      Mix.raise("command failed: #{Enum.join([cmd | args], " ")}")
    end
  end

  defp stream_cmd!(cmd, args) do
    Mix.shell().info("$ #{Enum.join([cmd | args], " ")}")
    System.cmd(cmd, args, into: IO.stream(:stdio, :line), stderr_to_stdout: true)
  end

  defp capture_cmd!(cmd, args) do
    Mix.shell().info("$ #{Enum.join([cmd | args], " ")}")
    System.cmd(cmd, args, stderr_to_stdout: true)
  end
end