Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add commitizen changelog generation and automatic release task #138

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
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
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ _Note that in case the push or publish step fail because of missing authenticati
or a failing network, the task must not be rerun. Instead run `git push` or
`mix hex.publish` to finish releasing the new version._

#### Fully automate releases

Expublish can fully automate the release process, inferring the version bump and generating the changelog entry
from commits implementing the [commitizen](#commitezen) specification.

```
mix expublish.release
```

## Installation

See the [Installation](./docs/INSTALLATION.md) page to learn how to set up Expublish.
Expand All @@ -74,14 +83,22 @@ See the [Cheatsheet](./docs/CHEATSHEET.md) page to get a quick overview on how t

See the [Version levels](./docs/VERSION_LEVELS.md) page to learn how Expublish increases version levels.

<span id="commitizen"></span>

## Commitizen

Expublish can automatically generate changelog entries from commits implementing the [commitizen]() specification.
All commits _prefixed_ by one of the following keywords will be included in the generated changelog entry, while all others
will be ommited: `fix`, `feat`, `BREAKING CHANGE`.

<span id="quick-reference"></span>

## Quick Reference

See the full [Reference](./docs/REFERENCE.md) page to learn about all valid `mix expublish`
task levels, options and defaults.

```bash
```
Usage: mix expublish.[level] [options]

level:
Expand All @@ -92,6 +109,7 @@ level:
rc - Publish release-candidate pre-release of next patch version
beta - Publish beta pre-release of next patch version
alpha - Publish alpha pre-release of next patch version
release - Publish a new version inferred by commits following the commitizen specification

options:
-d, --dry-run - Perform dry run (no writes, no commits)
Expand All @@ -101,6 +119,7 @@ options:
--disable-publish - Disable hex publish
--disable-push - Disable git push
--disable-test - Disable test run
--commitizen - Generate changelog from commits following the commitizen specification
--changelog-date-time - Use date-time instead of date in new changelog entry
--branch=string - Remote branch to push to, default: "master"
--remote=string - Remote name to push to, default: "origin"
Expand Down
8 changes: 7 additions & 1 deletion lib/expublish.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ defmodule Expublish do

require Logger

@doc """
Automatically release new version of current project.
"""
@spec release(Options.t()) :: :ok
def release(options \\ %Options{}), do: run(:release, %{options | commitizen: true})

@doc """
Publish major version of current project.
"""
Expand Down Expand Up @@ -55,7 +61,7 @@ defmodule Expublish do
@spec stable(Options.t()) :: :ok
def stable(options \\ %Options{}), do: run(:stable, options)

@type level() :: :major | :minor | :patch | :rc | :beta | :alpha | :stable
@type level() :: :release | :major | :minor | :patch | :rc | :beta | :alpha | :stable
@spec run(level(), Options.t()) :: :ok

defp run(level, options) do
Expand Down
36 changes: 35 additions & 1 deletion lib/expublish/changelog.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ defmodule Expublish.Changelog do
Validate changelog setup. Returns :ok or error message.
"""
@spec validate(Options.t()) :: :ok | String.t()
def validate(%Options{commitizen: true}) do
cond do
!File.exists?(@changelog_file) ->
"Missing file: #{@changelog_file}"

!String.contains?(File.read!(@changelog_file), @changelog_entries_marker) ->
"CHANGELOG.md is missing required placeholder."

true ->
:ok
end
end

def validate(_options) do
cond do
!File.exists?(@release_file) ->
Expand All @@ -35,7 +48,24 @@ defmodule Expublish.Changelog do
Generate new changelog entry from RELEASE.md contents.
"""
@spec write_entry!(Version.t(), Options.t()) :: Version.t()
def write_entry!(%Version{} = version, options \\ %Options{}) do
def write_entry!(version, options \\ %Options{})

def write_entry!(%Version{} = version, %Options{commitizen: true} = options) do
title = build_title(version, options)

text =
options
|> Expublish.Git.releasable_commits()
|> Expublish.Commitizen.run()
|> Map.get(:all)
|> Enum.map_join("\n", &"- #{&1}")

add_changelog_entry(title, text, options)

version
end

def write_entry!(%Version{} = version, options) do
title = build_title(version, options)
text = @release_file |> File.read!() |> String.trim()

Expand All @@ -54,6 +84,10 @@ defmodule Expublish.Changelog do
version
end

def remove_release_file!(%Version{} = version, %Options{commitizen: true}, _) do
version
end

def remove_release_file!(%Version{} = version, _options, file_path) do
File.rm!(file_path)
version
Expand Down
33 changes: 33 additions & 0 deletions lib/expublish/commitizen.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
defmodule Expublish.Commitizen do
@moduledoc """
Implements commitizen specification.

https://www.conventionalcommits.org/en/v1.0.0/#specification
"""

defstruct all: [], patch: [], feature: [], breaking: []

def run(commits) do
collect(commits)
end

defp collect(commits, acc \\ %__MODULE__{})

defp collect([], acc), do: acc

defp collect(["fix" <> _ = commit | rest], acc) do
collect(rest, %__MODULE__{acc | all: [commit | acc.all], patch: [commit | acc.patch]})
end

defp collect(["feat" <> _ = commit | rest], acc) do
collect(rest, %__MODULE__{acc | all: [commit | acc.all], feature: [commit | acc.feature]})
end

defp collect(["BREAKING CHANGE" <> _ = commit | rest], acc) do
Copy link

@marc0s marc0s Jul 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly the specification, BREAKING CHANGE: is not a valid type for a commit. That is, a commit subject cannot start with BREAKING CHANGE: <description>.

BREAKING CHANGE (or BREAKING-CHANGE) is a valid token to be found in the footer.

If we want to indicate a breaking change by looking only at the first line of the commit, we should look for a ! after the commit type (or scope), like in fix!: some fix that is also a breaking change or fix(parser)!: some fix to the parser that is a breaking change.

Unfortunately, this implies --oneline cannot be used https://github.com/tfiedlerdejanze/expublish/pull/138/files#diff-2c4e7ba552cf4684b4bc0dd08450ff59592e3e8b06e4675ef3aa3f580862f0bbR74 as we need the full commit message to look for a possible BREAKING CHANGE token.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A naïve attempt to be closer to the spec wrt breaking changes(while still using --oneline for fetching commits): marc0s@ecae0d0

collect(rest, %__MODULE__{acc | all: [commit | acc.all], breaking: [commit | acc.breaking]})
end

defp collect([_ | rest], acc) do
collect(rest, acc)
end
end
45 changes: 43 additions & 2 deletions lib/expublish/git.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,12 @@ defmodule Expublish.Git do
Logger.info("Pushing new package version with: \"git push #{remote} #{branch} --tags\".\n")

case Expublish.System.cmd("git", ["push", remote, branch, "--tags"]) do
{_, 0} -> :noop
_ -> Logger.error("Failed to push new version commit to git.")
{_, 0} ->
:noop

_ ->
Logger.error("Failed to push new version commit to git.")
exit({:shutdown, 1})
end

version
Expand All @@ -64,6 +68,43 @@ defmodule Expublish.Git do
version
end

def releasable_commits(_options) do
case Expublish.System.cmd("git", ["describe", "--tags", "--abbrev=0"], stderr_to_stdout: true) do
{tag, 0} ->
case Expublish.System.cmd("git", ["log", "#{String.trim(tag)}..HEAD", "--oneline"]) do
{"", 0} ->
Logger.error("No commits to release since last tag: #{String.trim(tag)}. Abort.")
exit({:shutdown, 1})

{commits, 0} ->
prepare(commits)
end

{_, _} ->
case Expublish.System.cmd("git", ["log", "--oneline"]) do
{"", 0} ->
Logger.error("No commits to release. Abort.")
exit({:shutdown, 1})

{commits, 0} ->
prepare(commits)
end
end
end

defp prepare(commits_string) do
commits_string
|> String.split("\n")
|> Enum.map(fn commit ->
commit
|> String.trim()
|> String.split(" ")
|> List.delete_at(0)
|> Enum.join(" ")
end)
|> Enum.reject(&(is_nil(&1) or &1 == ""))
end

defp add(command, %Options{dry_run: true}) do
Expublish.System.cmd("git", command ++ ["--dry-run"])
end
Expand Down
3 changes: 2 additions & 1 deletion lib/expublish/options.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ defmodule Expublish.Options do
as_major: false,
as_minor: false,
changelog_date_time: false,
commitizen: false,
disable_publish: false,
disable_push: false,
disable_test: false,
Expand Down Expand Up @@ -60,7 +61,7 @@ defmodule Expublish.Options do

Returns :ok or error message.
"""
@type level() :: :major | :minor | :patch | :rc | :beta | :alpha | :stable
@type level() :: :release | :major | :minor | :patch | :rc | :beta | :alpha | :stable
@spec validate(__MODULE__.t(), level()) :: :ok | String.t()
def validate(%__MODULE__{as_major: true}, level) when level in @invalid_as_option_levels do
"Invalid task invokation. Can not use --as-major for #{level} version increase."
Expand Down
27 changes: 26 additions & 1 deletion lib/expublish/semver.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ defmodule Expublish.Semver do
@beta "beta"
@rc "rc"

@type level() :: :major | :minor | :patch | :rc | :beta | :alpha | :stable
@type level() :: :release | :major | :minor | :patch | :rc | :beta | :alpha | :stable

@doc "Interfaces `Expublish.Semver` version increase functions."
@spec increase!(Version.t(), level(), Options.t()) :: Version.t()
Expand All @@ -24,6 +24,31 @@ defmodule Expublish.Semver do
def increase!(version, :beta, options), do: beta(version, options)
def increase!(version, :alpha, options), do: alpha(version, options)

def increase!(%Version{pre: []} = version, :release, options) do
options
|> Expublish.Git.releasable_commits()
|> Expublish.Commitizen.run()
|> case do
%{all: []} ->
Logger.error("Can not automatically release without commitizen commits. Abort.")
exit({:shutdown, 1})

%{breaking: [_ | _]} ->
major(version)

%{feature: [_ | _]} ->
minor(version)

_commitizen ->
patch(version)
end
end

def increase!(version, :release, _options) do
Logger.error("Can not automatically release from pre-release version #{version}. Abort.")
exit({:shutdown, 1})
end

@doc "Bump major version."
@spec major(Version.t()) :: Version.t()
def major(%Version{} = version) do
Expand Down
5 changes: 3 additions & 2 deletions lib/expublish/tests.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ defmodule Expublish.Tests do
@doc """
Run tests, stop task if they fail, skip if there are none.
"""
@type level() :: :major | :minor | :patch | :rc | :beta | :alpha | :stable
@spec validate(Options.t(), level()) :: :ok
@type level() :: :release | :major | :minor | :patch | :rc | :beta | :alpha | :stable
@spec validate(Options.t(), level()) :: :ok | String.t()
def validate(%Options{disable_test: true}, level) do
Logger.info("Skipping test run for #{to_string(level)} release.")

:ok
end

Expand Down
20 changes: 20 additions & 0 deletions lib/mix/tasks.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,26 @@ defmodule Mix.Tasks.Expublish do
Options.print_help()
end

defmodule Release do
@shortdoc "Publish a new version of current project."
@moduledoc """
Release and publish new version of current project.

The changelog entry and next version will be inferred
from commits following the commitizen specification.

OptionParser interface defined in `Expublish.Options.`
"""
use Mix.Task

@doc false
def run(args) do
args
|> Options.parse()
|> Expublish.release()
end
end

defmodule Major do
@shortdoc "Publish a major version of current project."
@moduledoc """
Expand Down
12 changes: 12 additions & 0 deletions test/lib/changelog_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ defmodule ChangelogTest do
end)
end

test "validate/1 with commitizen: true", %{options: options} do
assert :ok == Changelog.validate(%{options | commitizen: true})
end

test "remove_release_file!/3 deletes the file", %{version: version} do
File.write!(@rm_release_file, "generated in test")

Expand All @@ -40,6 +44,14 @@ defmodule ChangelogTest do
assert capture_log(fun) =~ "Skipping new entry"
end

test "write_entry!/1 with commitizen", %{options: options, version: version} do
fun = fn ->
Changelog.write_entry!(version, %{options | commitizen: true})
end

assert capture_log(fun) =~ "Skipping new entry"
end

test "build_title/3 runs without errors", %{version: version} do
assert Changelog.build_title(version) =~ ~r/#{version} - \d{4}-\d{2}-\d{2}/
end
Expand Down
14 changes: 14 additions & 0 deletions test/lib/commitizen_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
defmodule CommitizenTest do
use ExUnit.Case

test "groups commits" do
commits = [
"fix: i fix something",
"feat: i add a feature",
"BREAKING CHANGE: i break something",
"not noteworthy"
]

assert %{all: [_, _, _], patch: [_], feature: [_], breaking: [_]} = Expublish.Commitizen.run(commits)
end
end
12 changes: 12 additions & 0 deletions test/lib/expublish_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ defmodule ExpublishTest do
on_exit(fn -> File.rm!("expublish_major_test") end)
end

test "release/1 runs without errors", %{options: options} do
fun = fn ->
Expublish.release(options)
end

capture_log(fn ->
Project.get_version!()
|> Semver.increase!(:release, options)
|> assert_dry_run(fun)
end)
end

test "major/1 runs without errors", %{options: options} do
fun = fn ->
with_release_md(fn ->
Expand Down
Loading