-
Notifications
You must be signed in to change notification settings - Fork 19
/
git_ops.release.ex
364 lines (288 loc) · 11.1 KB
/
git_ops.release.ex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
defmodule Mix.Tasks.GitOps.Release do
use Mix.Task
@shortdoc "Parses the commit log and writes any updates to the changelog"
@moduledoc """
Updates project changelog, and any other configured release capabilities.
mix git_ops.release
Logs an error for any commits that were not parseable.
In the case that the prior version was a pre-release and this one is not,
the version is only updated via removing the pre-release identifier.
For more information on semantic versioning, including pre release and build identifiers,
see the specification here: https://semver.org/
## Switches:
* `--initial` - Creates the first changelog, and sets the version to whatever the
configured mix project's version is.
* `--pre-release` - Sets this release to be a pre release, using the configured
string as the pre release identifier. This is a manual process, and results in
an otherwise unchanged version. (Does not change the minor version).
The version number will only change if a *higher* version number bump is required
than what was originally changed in the creation of the RC. For instance, if patch
was changed when creating the pre-release, and no fixes or features were added when
requesting a new pre-release, then the version will not change. However, if the last
pre-release had only a patch version bump, but a major change has since been added,
the version will be changed accordingly.
* `--rc` - Overrides the presence of `--pre-release`, and manages an incrementing
identifier as the prerelease. This will look like `1.0.0-rc0` `1.0.0-rc1` and so
forth. See the `--pre-release` flag for information on when the version will change
for a pre-release. In the case that the version must change, the counter for
the release candidate counter will be reset as well.
* `--build` - Sets the release build metadata. Build information has no semantic
meaning to the version itself, and so is simply attached to the end and is to
be used to describe the build conditions for that release. You might build the
same version many times, and this can be used to denote that in whatever way
you choose.
* `--force-patch` - In cases where this task is run, but the version should not
change, this option will force the patch number to be incremented.
* `--no-major` - Forces major version changes to instead only result in minor version
changes. This would be a common option for libraries that are still in 0.x.x phases
where 1.0.0 should only happen at some specified milestones. After that, it is important
to *not* resist a 2.x.x change just because it doesn't seem like it deserves it.
Semantic versioning uses this major version change to communicate, and it should not be
reserved.
* `--dry-run` - Allow users to run release process and view changes without committing and tagging
* `--yes` - Don't prompt for confirmation, just perform release. Useful for your CI run.
* `--override` - Provide an explicit version override
"""
alias GitOps.Changelog
alias GitOps.Commit
alias GitOps.Config
alias GitOps.Git
alias GitOps.VersionReplace
@doc false
def run(args) do
opts = get_opts(args)
Config.mix_project_check(opts)
mix_project_module = Config.mix_project()
mix_project = mix_project_module.project()
changelog_file = Config.changelog_file()
changelog_path = Path.expand(changelog_file)
current_version = String.trim(mix_project[:version])
repo_path = Config.repository_path()
repo = Git.init!(repo_path)
if opts[:initial] do
Changelog.initialize(changelog_path, opts)
end
tags = Git.tags(repo)
prefix = Config.prefix()
config_types = Config.types()
allowed_tags = Config.allowed_tags()
allow_untagged? = Config.allow_untagged?()
from_rc? = Version.parse!(current_version).pre != []
{commit_messages_for_version, commit_messages_for_changelog} =
get_commit_messages(repo, prefix, tags, from_rc?, opts)
log_for_version? = !opts[:initial]
commits_for_version =
parse_commits(
commit_messages_for_version,
config_types,
allowed_tags,
allow_untagged?,
log_for_version?
)
commits_for_changelog =
parse_commits(
commit_messages_for_changelog,
config_types,
allowed_tags,
allow_untagged?,
false
)
prefixed_new_version =
if opts[:initial] do
prefix <> mix_project[:version]
else
GitOps.Version.determine_new_version(
current_version,
prefix,
commits_for_version,
GitOps.Version.last_valid_non_rc_version(tags, prefix),
opts
)
end
new_version =
if prefix != "" do
String.trim_leading(prefixed_new_version, prefix)
else
prefixed_new_version
end
changelog_changes =
Changelog.write(
changelog_path,
commits_for_changelog,
current_version,
prefixed_new_version,
opts
)
create_and_display_changes(current_version, new_version, changelog_changes, opts)
cond do
opts[:dry_run] ->
:ok
opts[:yes] ->
tag(repo, changelog_path, prefixed_new_version, changelog_changes)
:ok
true ->
confirm_and_tag(repo, changelog_path, prefixed_new_version, changelog_changes)
:ok
end
end
defp get_commit_messages(repo, prefix, tags, _from_rc?, opts) do
if opts[:initial] do
commits = Git.get_initial_commits!(repo)
{commits, commits}
else
tag =
if opts[:rc] do
GitOps.Version.last_valid_version(tags, prefix)
else
GitOps.Version.last_valid_non_rc_version(tags, prefix)
end
commits_for_version = Git.commit_messages_since_tag(repo, tag)
last_version_after = GitOps.Version.last_version_greater_than(tags, tag, prefix)
if last_version_after && !opts[:rc] do
commit_messages_for_changelog = Git.commit_messages_since_tag(repo, last_version_after)
{commits_for_version, commit_messages_for_changelog}
else
{commits_for_version, commits_for_version}
end
end
end
defp create_and_display_changes(current_version, new_version, changelog_changes, opts) do
changelog_file = Config.changelog_file()
mix_project_module = Config.mix_project()
readme = Config.manage_readme_version()
Mix.shell().info("Your new version is: #{new_version}\n")
mix_project_changes =
if Config.manage_mix_version?() do
VersionReplace.update_mix_project(
mix_project_module,
current_version,
new_version,
opts
)
end
readme_changes =
readme
|> List.wrap()
|> Enum.reject(&(&1 == false))
|> Enum.map(fn readme ->
{readme, VersionReplace.update_readme(readme, current_version, new_version, opts)}
end)
if opts[:dry_run] do
"Below are the contents of files that will change.\n"
|> append_changes_to_message(changelog_file, changelog_changes)
|> add_readme_changes(readme_changes)
|> append_changes_to_message(mix_project_module, mix_project_changes)
|> Mix.shell().info()
end
end
defp add_readme_changes(message, readme_changes) do
Enum.reduce(readme_changes, message, fn {file, changes}, message ->
append_changes_to_message(message, file, changes)
end)
end
defp tag(repo, changelog_path, new_version, new_message) do
Git.add!(repo, [changelog_path])
Git.commit!(repo, ["-am", "chore: release version #{new_version}"])
new_message =
new_message
|> String.replace(~r/^#+/m, "")
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n")
Git.tag!(repo, ["-a", new_version, "-m", "release #{new_version}\n\n" <> new_message])
Mix.shell().info("Don't forget to push with tags:\n\n git push --follow-tags")
end
defp confirm_and_tag(repo, changelog_path, new_version, new_message) do
message = """
Shall we commit and tag?
Instructions will be printed for committing and tagging if you choose no.
"""
if Mix.shell().yes?(message) do
tag(repo, changelog_path, new_version, new_message)
else
Mix.shell().info("""
If you want to do it on your own, make sure you tag the release with:
If you want to include your release notes in the tag message, use
git commit -am "chore: release version #{new_version}"
git tag -a #{new_version}
And replace the contents with your release notes (make sure to escape any # with \#)
Otherwise, use:
git commit -am "chore: release version #{new_version}"
git tag -a #{new_version} -m "release #{new_version}"
git push --follow-tags
""")
end
end
defp parse_commits(messages, config_types, allowed_tags, allow_untagged?, log?) do
Enum.flat_map(messages, &parse_commit(&1, config_types, allowed_tags, allow_untagged?, log?))
end
defp parse_commit(text, config_types, allowed_tags, allow_untagged?, log?) do
case Commit.parse(text) do
{:ok, commits} ->
commits
|> commits_with_allowed_tags(allowed_tags, allow_untagged?)
|> commits_with_type(config_types, text, log?)
_ ->
error_if_log("Unparseable commit: #{text}", log?)
[]
end
end
defp commits_with_allowed_tags(commits, :any, _), do: commits
defp commits_with_allowed_tags(commits, allowed_tags, allow_untagged?) do
case Enum.find(commits, fn %{type: type} -> type == "TAGS" end) do
nil ->
if allow_untagged?, do: commits, else: []
commit ->
tags = commit.message |> String.split(",", trim: true) |> Enum.map(&String.trim/1)
if Enum.any?(tags, fn tag -> tag in allowed_tags end) do
commits
else
[]
end
end
end
defp commits_with_type(commits, config_types, text, log?) do
Enum.flat_map(commits, fn commit ->
if Map.has_key?(config_types, String.downcase(commit.type)) do
[commit]
else
error_if_log("Commit with unknown type in: #{text}", log?)
[]
end
end)
end
defp append_changes_to_message(message, _, {:error, :bad_replace}), do: message
defp append_changes_to_message(message, file, changes) do
message <> "----- BEGIN #{file} -----\n\n#{changes}\n----- END #{file} -----\n\n"
end
defp error_if_log(error, _log? = true), do: Mix.shell().error(error)
defp error_if_log(_, _), do: :ok
defp get_opts(args) do
{opts, _} =
OptionParser.parse!(args,
strict: [
build: :string,
force_patch: :boolean,
initial: :boolean,
no_major: :boolean,
pre_release: :string,
rc: :boolean,
dry_run: :boolean,
yes: :boolean,
override: :string
],
aliases: [
i: :initial,
p: :pre_release,
b: :build,
f: :force_patch,
n: :no_major,
d: :dry_run,
y: :yes,
o: :override
]
)
opts
end
end