├── config ├── dev.exs ├── prod.exs ├── test.exs └── config.exs ├── test ├── test_helper.exs ├── support │ └── test_shell.ex ├── mix_bump_test.exs ├── mix-bump │ └── git_test.exs └── mix │ └── tasks │ └── bump_test.exs ├── .formatter.exs ├── lib ├── mix-bump │ ├── command │ │ ├── adapter.ex │ │ └── shell.ex │ ├── command.ex │ └── git.ex ├── mix-bump.ex └── mix │ └── tasks │ └── bump.ex ├── .gitignore ├── README.md ├── LICENSE.md ├── mix.lock └── mix.exs /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["mix.exs", ".formatter.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :mix_bump, 4 | command_adapter: MixBump.Command.TestShell 5 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | config :mix_bump, 6 | command_adapter: MixBump.Command.Shell 7 | 8 | import_config "#{Mix.env()}.exs" 9 | -------------------------------------------------------------------------------- /lib/mix-bump/command/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule MixBump.Command.Adapter do 2 | @moduledoc """ 3 | A small adapter for the execution of shell commands. 4 | """ 5 | 6 | @callback execute(String.t()) :: :ok | :error 7 | 8 | @callback task(String.t()) :: any() 9 | end 10 | -------------------------------------------------------------------------------- /test/support/test_shell.ex: -------------------------------------------------------------------------------- 1 | defmodule MixBump.Command.TestShell do 2 | @moduledoc """ 3 | A `MixBump.Command` adapter that sends commands to stdout, enabling them to 4 | be captured in tests 5 | """ 6 | @behaviour MixBump.Command.Adapter 7 | 8 | @impl true 9 | def execute(message) do 10 | IO.puts(message) 11 | :ok 12 | end 13 | 14 | @impl true 15 | def task(message) do 16 | IO.puts(message) 17 | :ok 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/mix-bump/command/shell.ex: -------------------------------------------------------------------------------- 1 | defmodule MixBump.Command.Shell do 2 | @moduledoc """ 3 | A `MixBump.Command` adapter that sends commands to the shell. 4 | """ 5 | @behaviour MixBump.Command.Adapter 6 | 7 | @doc """ 8 | Executes the given command. 9 | """ 10 | @impl true 11 | def execute(command) do 12 | if Mix.Shell.IO.cmd(command) == 0, do: :ok, else: :error 13 | end 14 | 15 | @doc """ 16 | Runs a `task`. 17 | """ 18 | @impl true 19 | def task(task) do 20 | Mix.Task.run(task) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mix Bump 2 | 3 | [![Hex Version](http://img.shields.io/hexpm/v/mix_bump.svg)](https://hex.pm/packages/mix_bump) 4 | 5 | This is a simple mix task to version bump a mix project. 6 | 7 | ## Installation 8 | 9 | ``` 10 | mix archive.install hex mix_bump 11 | ``` 12 | 13 | ## Usage 14 | 15 | ``` 16 | mix bump [major | minor | patch | ] 17 | 18 | options: 19 | -m, --message 20 | Commit message 21 | -p, --publish Publish package to hex 22 | -t, --tag Specify a tag 23 | -a, --annotated Use annotated tags (Enabled by default --no-annotated to use simple tags) 24 | ``` 25 | -------------------------------------------------------------------------------- /test/mix_bump_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixBumpTest do 2 | use ExUnit.Case 3 | 4 | test "bump_version(major) resets minor and patch to 0" do 5 | {:ok, _file, version} = 6 | "version: \"0.1.2\"" 7 | |> MixBump.bump_version("major") 8 | 9 | assert version === "1.0.0" 10 | end 11 | 12 | test "bump_version(minor) resets patch to 0" do 13 | {:ok, _file, version} = 14 | "version: \"0.1.2\"" 15 | |> MixBump.bump_version("minor") 16 | 17 | assert version === "0.2.0" 18 | end 19 | 20 | test "bump_version(patch) does not affect major and minor" do 21 | {:ok, _file, version} = 22 | "version: \"0.1.2\"" 23 | |> MixBump.bump_version("patch") 24 | 25 | assert version === "0.1.3" 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/mix-bump/git_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixBump.GitTest do 2 | use ExUnit.Case 3 | import ExUnit.CaptureIO 4 | alias MixBump.Git 5 | 6 | test "Git.commit creates a commit with only the mix.exs file, using the provided message" do 7 | assert capture_io(fn -> 8 | Git.commit("IT IS DONE!") 9 | end) =~ ~s(git commit -o mix.exs -m 'IT IS DONE!' -q) 10 | end 11 | 12 | test "Git.tag will create annotated tags when annotated tagging is enabled" do 13 | assert capture_io(fn -> 14 | Git.tag("v1.0.0", %{message: "first release", annotated: true}) 15 | end) =~ ~s(git tag -a 'v1.0.0' -m 'first release') 16 | end 17 | 18 | test "Git.tag will create simple tags when annotated tagging is disabled" do 19 | assert capture_io(fn -> 20 | Git.tag("v1.0.0", %{message: "first release", annotated: false}) 21 | end) =~ ~s(git tag 'v1.0.0') 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/mix-bump/command.ex: -------------------------------------------------------------------------------- 1 | defmodule MixBump.Command do 2 | @adapter Application.get_env(:mix_bump, :command_adapter, MixBump.Command.Shell) 3 | 4 | defdelegate execute(message), to: @adapter 5 | 6 | defdelegate task(message), to: @adapter 7 | 8 | def write(message), do: IO.write(message) 9 | 10 | def puts(message), do: IO.puts(message) 11 | 12 | def error(message) do 13 | Mix.Shell.IO.error(message) 14 | System.halt(1) 15 | end 16 | 17 | def callback(:ok), do: [:green, " \u2713"] |> IO.ANSI.format() |> IO.puts() 18 | 19 | def callback(:error) do 20 | [:red, " \u2717"] |> IO.ANSI.format() |> IO.puts() 21 | System.halt(1) 22 | end 23 | 24 | def rainbow(text) do 25 | colors = [:black, :red, :green, :yellow, :blue, :magenta, :cyan, :white] 26 | 27 | text 28 | |> String.split("") 29 | |> Enum.map(fn char -> [Enum.random(colors), char] end) 30 | |> IO.ANSI.format() 31 | |> IO.puts() 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/mix-bump/git.ex: -------------------------------------------------------------------------------- 1 | defmodule MixBump.Git do 2 | alias MixBump.Command 3 | 4 | @doc """ 5 | Commits the modified mix file, with a custom message. You can specify this 6 | message with `--message` or `-m` when running the CLI. Default message is 7 | `Bump version to NEW_VERSION`. 8 | """ 9 | @spec commit(String.t()) :: :ok | :error 10 | def commit(message) do 11 | Command.execute("git commit -o mix.exs -m '#{message}' -q") 12 | end 13 | 14 | @doc """ 15 | Tags the release. In the CLI, the flags `--annotated` and `--no-annotated` 16 | determines if tagging will be simple, or annotated. Defaults to annotated. 17 | """ 18 | @type tag_options :: map 19 | @type tag_name :: String.t() 20 | 21 | @spec tag(tag_name, tag_options) :: :ok | :error 22 | def tag(name, options \\ %{}) 23 | 24 | def tag(name, %{message: message, annotated: true}) do 25 | Command.execute("git tag -a '#{name}' -m '#{message}'") 26 | end 27 | 28 | def tag(name, _options) do 29 | Command.execute("git tag '#{name}'") 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 iLeeXu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, 3 | "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"}, 4 | "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, 5 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, 6 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, 7 | } 8 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Bump.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :mix_bump, 7 | version: "0.1.0", 8 | elixir: "~> 1.10", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | docs: docs(), 13 | description: description(), 14 | package: package() 15 | ] 16 | end 17 | 18 | # Run "mix help compile.app" to learn about applications. 19 | def application do 20 | [ 21 | extra_applications: [:logger] 22 | ] 23 | end 24 | 25 | # Run "mix help deps" to learn about dependencies. 26 | defp deps do 27 | [ 28 | {:ex_doc, "~> 0.18", only: :dev} 29 | ] 30 | end 31 | 32 | defp docs do 33 | [extras: ["README.md"]] 34 | end 35 | 36 | defp description do 37 | "This is a simple mix task to version bump a mix project." 38 | end 39 | 40 | defp package do 41 | [ 42 | files: ["lib", "mix.exs", "README.md", "LICENSE.md"], 43 | maintainers: ["Milo Lee"], 44 | licenses: ["MIT"], 45 | links: %{Github: "https://github.com/oo6/mix-bump"} 46 | ] 47 | end 48 | 49 | # Specifies which paths to compile per environment. 50 | defp elixirc_paths(:test), do: ["lib", "test/support"] 51 | defp elixirc_paths(_), do: ["lib"] 52 | end 53 | -------------------------------------------------------------------------------- /test/mix/tasks/bump_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.BumpTest do 2 | use ExUnit.Case 3 | import ExUnit.CaptureIO 4 | alias Mix.Tasks.Bump 5 | 6 | @version Mix.Project.config()[:version] 7 | 8 | # capturing IO, rather than running the command. 9 | # Commands that shell out end in a new line, so we can check the command end 10 | # by including it. 11 | 12 | test "bump with annotated tags by default" do 13 | assert capture_io(fn -> 14 | Bump.run([@version]) 15 | end) =~ ~s(git tag -a 'v#{@version}' -m 'Bump version to #{@version}'\n) 16 | end 17 | 18 | test "bump uses simple tags when specified" do 19 | assert capture_io(fn -> 20 | Bump.run([@version, "--no-annotated"]) 21 | end) =~ ~s(git tag 'v#{@version}'\n) 22 | end 23 | 24 | test "bump uses a custom tag value when specified" do 25 | assert capture_io(fn -> 26 | Bump.run([@version, "--no-annotated", "--tag", "custom"]) 27 | end) =~ ~s(git tag 'custom'\n) 28 | end 29 | 30 | test "bump uses a custom commit message when specified" do 31 | assert capture_io(fn -> 32 | Bump.run([@version, "--message", "custom"]) 33 | end) =~ ~s(git commit -o mix.exs -m 'custom' -q\n) 34 | end 35 | 36 | test "bump commits, then tags" do 37 | assert capture_io(fn -> 38 | Bump.run([@version]) 39 | end) =~ ~s(git commit -o mix.exs -m 'Bump version to #{@version}' -q\ngit tag) 40 | end 41 | 42 | test "bump publishes when specified" do 43 | assert capture_io(fn -> 44 | Bump.run([@version, "--publish"]) 45 | end) =~ ~s(hex.publish) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/mix-bump.ex: -------------------------------------------------------------------------------- 1 | defmodule MixBump do 2 | alias MixBump.Command 3 | 4 | @version_regex ~r/version:\s*"(\d+).(\d+).(\d+)"/ 5 | 6 | def load_mix_file() do 7 | if File.exists?("mix.exs") do 8 | file = File.read!("mix.exs") 9 | {:ok, file, get_version!(file)} 10 | else 11 | Command.error("No mix.exs file found") 12 | end 13 | end 14 | 15 | def bump_version(file, "major") do 16 | file = 17 | Regex.replace(@version_regex, file, fn _, major, _minor, _patch -> 18 | "version: \"#{String.to_integer(major) + 1}.0.0\"" 19 | end) 20 | 21 | {:ok, file, get_version!(file)} 22 | end 23 | 24 | def bump_version(file, "minor") do 25 | file = 26 | Regex.replace(@version_regex, file, fn _, major, minor, _patch -> 27 | "version: \"#{major}.#{String.to_integer(minor) + 1}.0\"" 28 | end) 29 | 30 | {:ok, file, get_version!(file)} 31 | end 32 | 33 | def bump_version(file, "patch") do 34 | file = 35 | Regex.replace(@version_regex, file, fn _, major, minor, patch -> 36 | "version: \"#{major}.#{minor}.#{String.to_integer(patch) + 1}\"" 37 | end) 38 | 39 | {:ok, file, get_version!(file)} 40 | end 41 | 42 | def bump_version(file, version) do 43 | replacement = "version: \"#{version}\"" 44 | 45 | if Regex.match?(@version_regex, replacement) do 46 | {:ok, Regex.replace(@version_regex, file, replacement), version} 47 | else 48 | Command.error("Invalid version: #{version}") 49 | end 50 | end 51 | 52 | def save_mix_file!(file), do: File.write!("mix.exs", file) 53 | 54 | def update_version(new_version) do 55 | GenServer.call( 56 | Mix.ProjectStack, 57 | {:update_stack, 58 | fn [%{config: config}] = stack -> 59 | config = Keyword.put(config, :version, new_version) 60 | {:ok, [%{hd(stack) | config: config}]} 61 | end} 62 | ) 63 | end 64 | 65 | defp get_version!(file) do 66 | case Regex.run(@version_regex, file) do 67 | nil -> 68 | Command.error("No version config found") 69 | 70 | result -> 71 | result |> tl |> Enum.join(".") 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/mix/tasks/bump.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Bump do 2 | use Mix.Task 3 | 4 | @moduledoc """ 5 | Prints Bump tasks and their information. 6 | mix bump [major | minor | patch | ] 7 | 8 | options 9 | -m, --message 10 | Commit message 11 | -p, --publish Publish package to hex 12 | -t, --tag Specify a tag 13 | -a, --annotated Use annotated tags (Enabled by default, use --no-annotated to use simple tags) 14 | """ 15 | 16 | alias MixBump 17 | alias MixBump.{Command, Git} 18 | 19 | @parse_opts [ 20 | switches: [message: :string, publish: :boolean, tag: :string, annotated: :boolean], 21 | aliases: [m: :message, p: :publish, t: :tag, a: :annotated] 22 | ] 23 | 24 | def run(args) do 25 | {options, args, _} = OptionParser.parse(args, @parse_opts) 26 | process_args(args, options) 27 | end 28 | 29 | defp process_args([], _options) do 30 | Command.puts("This is a simple mix task to version bump a mix project.") 31 | end 32 | 33 | defp process_args(args, _options) when length(args) > 1 do 34 | Command.error("usage: mix bump [major | minor | patch | ]") 35 | end 36 | 37 | defp process_args([version], options) do 38 | with {:ok, file, _old_version} <- MixBump.load_mix_file(), 39 | {:ok, file, new_version} <- MixBump.bump_version(file, version) do 40 | MixBump.save_mix_file!(file) 41 | process_options(options, new_version) 42 | end 43 | end 44 | 45 | defp process_options(options, new_version) do 46 | Command.write("Preparing and taging a new version") 47 | 48 | message = Keyword.get(options, :message, "Bump version to #{new_version}") 49 | tag_name = if name = Keyword.get(options, :tag), do: name, else: "v#{new_version}" 50 | annotated = Keyword.get(options, :annotated, true) 51 | 52 | with :ok <- Git.commit(message), 53 | :ok <- Git.tag(tag_name, %{message: message, annotated: annotated}) do 54 | Command.callback(:ok) 55 | else 56 | _ -> Command.callback(:error) 57 | end 58 | 59 | if Keyword.get(options, :publish) do 60 | MixBump.update_version(new_version) 61 | Command.task("hex.publish") && Command.rainbow("Congrats on publishing a new package!") 62 | else 63 | Command.rainbow("Bump version to #{new_version}!") 64 | end 65 | end 66 | end 67 | --------------------------------------------------------------------------------