git-tag-release 0.4.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. git_tag_release-0.4.8/LICENSE.txt +21 -0
  2. git_tag_release-0.4.8/Makefile +17 -0
  3. git_tag_release-0.4.8/PKG-INFO +13 -0
  4. git_tag_release-0.4.8/README.md +75 -0
  5. git_tag_release-0.4.8/prelude.mk +29 -0
  6. git_tag_release-0.4.8/pyproject.toml +33 -0
  7. git_tag_release-0.4.8/setup.cfg +4 -0
  8. git_tag_release-0.4.8/src/git_tag_release.egg-info/PKG-INFO +13 -0
  9. git_tag_release-0.4.8/src/git_tag_release.egg-info/SOURCES.txt +30 -0
  10. git_tag_release-0.4.8/src/git_tag_release.egg-info/dependency_links.txt +1 -0
  11. git_tag_release-0.4.8/src/git_tag_release.egg-info/entry_points.txt +2 -0
  12. git_tag_release-0.4.8/src/git_tag_release.egg-info/scm_file_list.json +27 -0
  13. git_tag_release-0.4.8/src/git_tag_release.egg-info/scm_version.json +8 -0
  14. git_tag_release-0.4.8/src/git_tag_release.egg-info/top_level.txt +1 -0
  15. git_tag_release-0.4.8/src/release.py +424 -0
  16. git_tag_release-0.4.8/test/lib/log.sh +139 -0
  17. git_tag_release-0.4.8/test/lib/test.sh +198 -0
  18. git_tag_release-0.4.8/test/main.sh +77 -0
  19. git_tag_release-0.4.8/test/tst-basic-major.sh +6 -0
  20. git_tag_release-0.4.8/test/tst-basic-minor.sh +6 -0
  21. git_tag_release-0.4.8/test/tst-basic-patch.sh +6 -0
  22. git_tag_release-0.4.8/test/tst-custom-message.sh +17 -0
  23. git_tag_release-0.4.8/test/tst-lightweight-tags.sh +12 -0
  24. git_tag_release-0.4.8/test/tst-no-tags-major.sh +6 -0
  25. git_tag_release-0.4.8/test/tst-no-tags-patch.sh +6 -0
  26. git_tag_release-0.4.8/test/tst-prefix-custom.sh +6 -0
  27. git_tag_release-0.4.8/test/tst-prefix-empty.sh +6 -0
  28. git_tag_release-0.4.8/test/tst-retag-lightweight.sh +21 -0
  29. git_tag_release-0.4.8/test/tst-retag.sh +20 -0
  30. git_tag_release-0.4.8/test/tst-strict-no.sh +6 -0
  31. git_tag_release-0.4.8/test/tst-strict-yes-prefix.sh +8 -0
  32. git_tag_release-0.4.8/test/tst-strict-yes.sh +7 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Egor Tensin <egor@tensin.name>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,17 @@
1
+ include prelude.mk
2
+
3
+ PKG_NAME ?= tag-release
4
+ DESTDIR ?=
5
+
6
+ $(eval $(call noexpand,PKG_NAME))
7
+ $(eval $(call noexpand,DESTDIR))
8
+
9
+ .PHONY: all
10
+ all:
11
+
12
+ .PHONY: install
13
+ install:
14
+ install -D -m 0644 -t '$(call escape,$(DESTDIR))/usr/share/$(call escape,$(PKG_NAME))/' LICENSE.txt
15
+ install -D -m 0644 -t '$(call escape,$(DESTDIR))/usr/share/doc/$(call escape,$(PKG_NAME))/' README.md
16
+
17
+ install -D -m 0755 -T src/release.py '$(call escape,$(DESTDIR))/usr/bin/$(call escape,$(PKG_NAME))'
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-tag-release
3
+ Version: 0.4.8
4
+ Summary: Automate creation of semantic versioning tags
5
+ Author-email: Egor Tensin <egor@tensin.name>
6
+ License-Expression: MIT
7
+ Project-URL: homepage, https://github.com/egor-tensin/tag-release
8
+ Project-URL: issues, https://github.com/egor-tensin/tag-release/issues
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Requires-Python: >=3.8
12
+ License-File: LICENSE.txt
13
+ Dynamic: license-file
@@ -0,0 +1,75 @@
1
+ tag-release
2
+ ===========
3
+
4
+ [![CI](https://github.com/egor-tensin/tag-release/actions/workflows/ci.yml/badge.svg)](https://github.com/egor-tensin/tag-release/actions/workflows/ci.yml)
5
+ [![Packages (Debian)](https://github.com/egor-tensin/tag-release/actions/workflows/debian.yml/badge.svg)](https://github.com/egor-tensin/tag-release/actions/workflows/debian.yml)
6
+ [![Publish (Launchpad)](https://github.com/egor-tensin/tag-release/actions/workflows/ppa.yml/badge.svg)](https://github.com/egor-tensin/tag-release/actions/workflows/ppa.yml)
7
+
8
+ I'm tired of looking up the correct tag name to use, hence this script.
9
+ It follows the semantic versioning rules.
10
+ Should work with any semi-recent Python version.
11
+
12
+ Usage
13
+ -----
14
+
15
+ * View usage info:
16
+
17
+ ./src/release.py -h
18
+
19
+ * Make a new patch version release in the current repo.
20
+ Tries to parse the latest tag (say, v2.1.3) and will add a new tag (in this
21
+ case, v2.1.4).
22
+
23
+ ../path/to/tag-release/src/release.py patch
24
+
25
+ * Make a new minor version release in the current repo (v2.1.3 -> v2.2.0):
26
+
27
+ ../path/to/tag-release/src/release.py minor
28
+
29
+ * Similar for major versions (v2.1.3 -> v3.0.0):
30
+
31
+ ../path/to/tag-release/src/release.py major
32
+
33
+ * Accepts path to repository as an optional argument:
34
+
35
+ ./src/release.py patch path/to/your/repo
36
+
37
+ * You can customize the version prefix ("v" by default).
38
+ It will search for the latest tag with the given prefix then.
39
+ For example, this command, given that tag "2.1.3" exists, will create tag
40
+ "2.1.4":
41
+
42
+ ../path/to/tag-release/src/release.py -p '' patch
43
+
44
+ * A prefix can be anything, basically; this will create tag "release/v2.2.0"
45
+ if the latest tag is "release/v2.1.3":
46
+
47
+ ../path/to/tag-release/src/release.py -p 'release/v' minor
48
+
49
+ * If no tags were found, the implicit version 0.0.0 is assumed.
50
+ In a repository with no matching tags, this will create tag "v1.0.0":
51
+
52
+ ../path/to/tag-release/src/release.py major
53
+
54
+ * Customize the tag message:
55
+
56
+ ../path/to/tag-release/src/release.py -m 'Release: version {}' patch
57
+
58
+ * You can "retag" parent versions.
59
+ For example, given that the latest tag is "v1.1.3", this will create/update
60
+ tags "v1" & "v1.1" to point to the new tag "v1.1.4":
61
+
62
+ ../path/to/tag-release/src/release.py -r patch
63
+
64
+ * Create lightweight tags, if you wish (using annotated tags, which is the
65
+ default, is almost always the preferred option):
66
+
67
+ ../path/to/tag-release/src/release.py -l patch
68
+
69
+ License
70
+ -------
71
+
72
+ Distributed under the MIT License.
73
+ See [LICENSE.txt] for details.
74
+
75
+ [LICENSE.txt]: LICENSE.txt
@@ -0,0 +1,29 @@
1
+ # Please see https://tensin.name/blog/notes/makefile.html
2
+
3
+ MAKEFLAGS += --no-builtin-rules --no-builtin-variables --warn-undefined-variables
4
+ unexport MAKEFLAGS
5
+ .DEFAULT_GOAL := all
6
+ .DELETE_ON_ERROR:
7
+ .SUFFIXES:
8
+ SHELL := bash
9
+ .SHELLFLAGS := -eu -o pipefail -c
10
+
11
+ escape = $(subst ','\'',$(1))
12
+
13
+ define noexpand
14
+ ifeq ($$(origin $(1)),environment)
15
+ $(1) := $$(value $(1))
16
+ endif
17
+ ifeq ($$(origin $(1)),environment override)
18
+ $(1) := $$(value $(1))
19
+ endif
20
+ ifeq ($$(origin $(1)),command line)
21
+ override $(1) := $$(value $(1))
22
+ endif
23
+ endef
24
+
25
+ # A helper target to force execution of pattern rules (works like .PHONY for
26
+ # them): https://www.gnu.org/software/make/manual/html_node/Phony-Targets.html
27
+ #
28
+ .PHONY: DO
29
+ DO:
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ # The SPDX license identifiers are available from 77.0.3:
3
+ # https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license-and-license-files
4
+ requires = ["setuptools >= 77.0.3", "setuptools-scm"]
5
+ build-backend = "setuptools.build_meta"
6
+
7
+ [project]
8
+ name = "git-tag-release"
9
+ description = "Automate creation of semantic versioning tags"
10
+ license = "MIT"
11
+ license-files = ["LICENSE.txt"]
12
+ dynamic = ["version"]
13
+ authors = [{name = "Egor Tensin", email = "egor@tensin.name"}]
14
+ requires-python = ">=3.8"
15
+ # ^^^ For shlex.join, at least?
16
+
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ ]
21
+
22
+ [project.urls]
23
+ homepage = "https://github.com/egor-tensin/tag-release"
24
+ issues = "https://github.com/egor-tensin/tag-release/issues"
25
+
26
+ [project.scripts]
27
+ tag-release = "release:main"
28
+
29
+ [tool.setuptools]
30
+ package-dir = {"" = "src"}
31
+ py-modules = ["release"]
32
+
33
+ [tool.setuptools_scm]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-tag-release
3
+ Version: 0.4.8
4
+ Summary: Automate creation of semantic versioning tags
5
+ Author-email: Egor Tensin <egor@tensin.name>
6
+ License-Expression: MIT
7
+ Project-URL: homepage, https://github.com/egor-tensin/tag-release
8
+ Project-URL: issues, https://github.com/egor-tensin/tag-release/issues
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Requires-Python: >=3.8
12
+ License-File: LICENSE.txt
13
+ Dynamic: license-file
@@ -0,0 +1,30 @@
1
+ LICENSE.txt
2
+ Makefile
3
+ README.md
4
+ prelude.mk
5
+ pyproject.toml
6
+ src/release.py
7
+ src/git_tag_release.egg-info/PKG-INFO
8
+ src/git_tag_release.egg-info/SOURCES.txt
9
+ src/git_tag_release.egg-info/dependency_links.txt
10
+ src/git_tag_release.egg-info/entry_points.txt
11
+ src/git_tag_release.egg-info/scm_file_list.json
12
+ src/git_tag_release.egg-info/scm_version.json
13
+ src/git_tag_release.egg-info/top_level.txt
14
+ test/main.sh
15
+ test/tst-basic-major.sh
16
+ test/tst-basic-minor.sh
17
+ test/tst-basic-patch.sh
18
+ test/tst-custom-message.sh
19
+ test/tst-lightweight-tags.sh
20
+ test/tst-no-tags-major.sh
21
+ test/tst-no-tags-patch.sh
22
+ test/tst-prefix-custom.sh
23
+ test/tst-prefix-empty.sh
24
+ test/tst-retag-lightweight.sh
25
+ test/tst-retag.sh
26
+ test/tst-strict-no.sh
27
+ test/tst-strict-yes-prefix.sh
28
+ test/tst-strict-yes.sh
29
+ test/lib/log.sh
30
+ test/lib/test.sh
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tag-release = release:main
@@ -0,0 +1,27 @@
1
+ {
2
+ "files": [
3
+ "LICENSE.txt",
4
+ "Makefile",
5
+ "README.md",
6
+ "prelude.mk",
7
+ "pyproject.toml",
8
+ "src/release.py",
9
+ "test/lib/log.sh",
10
+ "test/lib/test.sh",
11
+ "test/main.sh",
12
+ "test/tst-basic-major.sh",
13
+ "test/tst-basic-minor.sh",
14
+ "test/tst-basic-patch.sh",
15
+ "test/tst-custom-message.sh",
16
+ "test/tst-lightweight-tags.sh",
17
+ "test/tst-no-tags-major.sh",
18
+ "test/tst-no-tags-patch.sh",
19
+ "test/tst-prefix-custom.sh",
20
+ "test/tst-prefix-empty.sh",
21
+ "test/tst-retag-lightweight.sh",
22
+ "test/tst-retag.sh",
23
+ "test/tst-strict-no.sh",
24
+ "test/tst-strict-yes-prefix.sh",
25
+ "test/tst-strict-yes.sh"
26
+ ]
27
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.4.8",
3
+ "distance": 0,
4
+ "node": "g0f3a28d95c826c0c5a10512c79b281f840161d57",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-09-30"
8
+ }
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Copyright (c) 2026 Egor Tensin <egor@tensin.name>
4
+ # This file is part of the "tag-release" project.
5
+ # For details, see https://github.com/egor-tensin/tag-release
6
+ # Distributed under the MIT License.
7
+
8
+ R"""
9
+ This script automates the creation of git tags in accordance with the semantic
10
+ versioning policy. It follows the convention of tags being in the MAJOR.MINOR.PATCH
11
+ format (with an optional prefix - "v" by default). You can then use it to bump
12
+ either of the major/minor/patch version numbers, and it will take care of
13
+ creating the proper tag for you.
14
+ """
15
+
16
+ import argparse
17
+ from contextlib import contextmanager
18
+ from dataclasses import dataclass
19
+ from enum import Enum
20
+ import logging
21
+ import os
22
+ import re
23
+ import shlex
24
+ import subprocess
25
+ import sys
26
+
27
+
28
+ @contextmanager
29
+ def setup_logging(verbose=False):
30
+ level_names = {
31
+ logging.DEBUG: "DBG",
32
+ logging.INFO: "INFO",
33
+ logging.WARNING: "WARN",
34
+ logging.ERROR: "ERR",
35
+ logging.CRITICAL: "CRIT",
36
+ }
37
+ for lvl, name in level_names.items():
38
+ logging.addLevelName(lvl, name)
39
+
40
+ logging.basicConfig(
41
+ datefmt="%Y-%m-%d %H:%M:%S%z",
42
+ format="%(asctime)s | %(levelname)4s | %(message)s",
43
+ level=logging.DEBUG if verbose else logging.INFO,
44
+ stream=sys.stdout,
45
+ )
46
+ try:
47
+ yield
48
+ except Exception as e:
49
+ logging.exception(e)
50
+ sys.exit(1)
51
+
52
+
53
+ def _run_log_output(level, output):
54
+ if not output:
55
+ logging.log(level, "... No output")
56
+ return
57
+ logging.log(level, "Output (%d characters):", len(output))
58
+ for line in output.splitlines():
59
+ logging.log(level, " %s", line)
60
+
61
+
62
+ def run(*args, **kwargs):
63
+ stdout = subprocess.PIPE
64
+ stderr = subprocess.STDOUT
65
+
66
+ logging.info("Running: %s", shlex.join(args))
67
+ try:
68
+ result = subprocess.run(
69
+ args, check=True, stdout=stdout, stderr=stderr, encoding="utf-8", **kwargs
70
+ )
71
+ except subprocess.CalledProcessError as e:
72
+ logging.error("... Returned exit code %d", e.returncode)
73
+ _run_log_output(logging.ERROR, e.output)
74
+ raise
75
+
76
+ _run_log_output(logging.DEBUG, result.stdout)
77
+ return result.stdout
78
+
79
+
80
+ def go_to_repo(repo_dir=None):
81
+ if repo_dir is not None:
82
+ os.chdir(repo_dir)
83
+ try:
84
+ run(*["git", "rev-parse", "--is-inside-work-tree"])
85
+ except subprocess.CalledProcessError as e:
86
+ logging.error("%s doesn't seem to be a git working directory", os.getcwd())
87
+ raise
88
+
89
+
90
+ class ReleaseScope(Enum):
91
+ MAJOR = "major"
92
+ MINOR = "minor"
93
+ PATCH = "patch"
94
+
95
+ def __str__(self):
96
+ return self.value
97
+
98
+ def next_version(self, version):
99
+ version = version.extend(len(ReleaseScope))
100
+ version = version.upgrade(self._index)
101
+ return version
102
+
103
+ @property
104
+ def _index(self):
105
+ if self is ReleaseScope.MAJOR:
106
+ return 0
107
+ if self is ReleaseScope.MINOR:
108
+ return 1
109
+ if self is ReleaseScope.PATCH:
110
+ return 2
111
+ raise NotImplementedError(f"Unknown release scope: {self}")
112
+
113
+
114
+ class Version:
115
+ def __init__(self, nums):
116
+ nums = list(nums)
117
+ if len(nums) > len(ReleaseScope):
118
+ raise ValueError(f"Too many version components: {nums}")
119
+
120
+ while nums and nums[-1] is None:
121
+ nums.pop()
122
+ if not nums:
123
+ raise ValueError("Must provide at least the major version number")
124
+ if any((n is None for n in nums)):
125
+ raise ValueError(f"Some version components are invalid: {nums}")
126
+
127
+ self._nums = tuple(nums)
128
+
129
+ @staticmethod
130
+ def parse(src, strict=False):
131
+ invalid_msg = f"Invalid version: {src}"
132
+
133
+ match = re.fullmatch(r"(\d+)(?:\.(\d+)(?:\.(\d+))?)?", src)
134
+ if not match:
135
+ if strict:
136
+ raise ValueError(invalid_msg)
137
+ logging.warning("%s", invalid_msg)
138
+ return None
139
+ assert len(match.groups()) == 3
140
+
141
+ nums = [int(n) if n is not None else None for n in match.groups()]
142
+ return Version(nums)
143
+
144
+ def __str__(self):
145
+ return ".".join(map(str, self._nums))
146
+
147
+ def __eq__(self, other):
148
+ return self._nums == other._nums
149
+
150
+ def __lt__(self, other):
151
+ # Attempting to sort the way `sort -V` does it (so v1 < v1.0 for example).
152
+ for a, b in zip(self._nums, other._nums):
153
+ if a > b:
154
+ return False
155
+ if a < b:
156
+ return True
157
+ return len(self._nums) < len(other._nums)
158
+
159
+ def __hash__(self):
160
+ return hash(self._nums)
161
+
162
+ def extend(self, new_len):
163
+ if new_len < 1 or new_len > len(ReleaseScope):
164
+ raise ValueError(f"Invalid version length {new_len}")
165
+ if len(self._nums) >= new_len:
166
+ return Version(self._nums)
167
+ nums = list(self._nums)
168
+ nums.extend([0 for i in range(new_len - len(self._nums))])
169
+ return Version(nums)
170
+
171
+ def upgrade(self, idx):
172
+ assert idx < len(ReleaseScope)
173
+ if idx < 0 or len(self._nums) < idx + 1:
174
+ raise ValueError(
175
+ f"Can't increment version number at index {idx} for version {self}"
176
+ )
177
+ nums = list(self._nums[:idx])
178
+ nums.append(self._nums[idx] + 1)
179
+ nums.extend([0 for i in range(len(self._nums) - idx - 1)])
180
+ return Version(nums)
181
+
182
+ @property
183
+ def has_parent(self):
184
+ return len(self._nums) > 1
185
+
186
+ def get_parent(self):
187
+ return Version(self._nums[:-1])
188
+
189
+
190
+ @dataclass
191
+ class Tag:
192
+ name: str
193
+ version: Version
194
+ lightweight: bool
195
+
196
+ def git_cmd_create(self, message=None, target=None):
197
+ cmd = ["git", "tag"]
198
+ if self.lightweight:
199
+ return cmd + [self.name]
200
+ if message is None:
201
+ raise ValueError("Must provide a tag message for annotated tags")
202
+ cmd += ["-a", "-m", message, self.name]
203
+ if target is not None:
204
+ cmd.append(target.name + "^{}")
205
+ # ^^^ Avoid tags-to-tags, which is somehow bad, idk.
206
+ return cmd
207
+
208
+ def git_cmd_update(self, target):
209
+ cmd = ["git", "tag"]
210
+ if self.lightweight:
211
+ return cmd + ["-f", self.name, target.name]
212
+ cmd += ["-a", "-f", self.name]
213
+ cmd.append(target.name + "^{}")
214
+ # ^^^ Avoid tags-to-tags, which is somehow bad, idk.
215
+ return cmd
216
+
217
+
218
+ class TagManager:
219
+ DEFAULT_PREFIX = "v"
220
+ DEFAULT_VERSION = Version((0 for _ in range(len(ReleaseScope))))
221
+ DEFAULT_MESSAGE_FMT = "{}"
222
+
223
+ def __init__(self, prefix=None, strict=False, lightweight=False, message_fmt=None):
224
+ if prefix is None:
225
+ prefix = TagManager.DEFAULT_PREFIX
226
+ self._prefix = prefix
227
+ self._strict = strict
228
+ self._lightweight = lightweight
229
+ if message_fmt is None:
230
+ message_fmt = TagManager.DEFAULT_MESSAGE_FMT
231
+ self._message_fmt = message_fmt
232
+
233
+ tags = self._git_query_tags()
234
+ tags = self._git_filter_tags(tags)
235
+ tags = self._git_parse_tags(tags)
236
+ tags = list(tags)
237
+ if not tags:
238
+ version = TagManager.DEFAULT_VERSION
239
+ tags = [Tag(self._format_tag_name(version), version, lightweight)]
240
+
241
+ self._tag_lst = sorted(tags, key=lambda tag: tag.version)
242
+ self._tag_map = {tag.version: tag for tag in tags}
243
+
244
+ def _format_tag_name(self, version):
245
+ return f"{self._prefix}{version}"
246
+
247
+ @staticmethod
248
+ def _git_query_tags():
249
+ cmd = [
250
+ "git",
251
+ "for-each-ref",
252
+ "--format=%(refname:short) %(objecttype)",
253
+ "refs/tags/",
254
+ ]
255
+
256
+ output = run(*cmd)
257
+ lines = output.splitlines()
258
+
259
+ for line in lines:
260
+ line = line.split()
261
+ assert len(line) == 2
262
+ yield line
263
+
264
+ def _git_filter_tags(self, tags):
265
+ for refname, objecttype in tags:
266
+ if not refname.startswith(self._prefix):
267
+ msg = f"Unexpected tag name: {refname}"
268
+ if self._strict:
269
+ raise RuntimeError(msg)
270
+ logging.warning("%s", msg)
271
+ continue
272
+ yield refname.removeprefix(self._prefix), objecttype
273
+
274
+ @staticmethod
275
+ def _is_tag_lightweight(objecttype):
276
+ if objecttype == "commit":
277
+ return True
278
+ if objecttype == "tag":
279
+ return False
280
+ raise ValueError(f"Unexpected %(objecttype) value: {objecttype}")
281
+
282
+ def _git_parse_tags(self, tags):
283
+ for refname, objecttype in tags:
284
+ version = Version.parse(refname, strict=self._strict)
285
+ if version is None:
286
+ continue
287
+ lightweight = self._is_tag_lightweight(objecttype)
288
+ yield Tag(self._format_tag_name(version), version, lightweight)
289
+
290
+ def __len__(self):
291
+ return len(self._tag_lst)
292
+
293
+ @property
294
+ def latest(self):
295
+ if not self:
296
+ raise RuntimeError("No tags, can't get the latest")
297
+ return self._tag_lst[-1]
298
+
299
+ def create(self, tag, target=None):
300
+ if tag.version in self._tag_map:
301
+ raise RuntimeError(f"Tag {tag.name} already exists")
302
+
303
+ cmd = tag.git_cmd_create(
304
+ message=self._message_fmt.format(tag.name), target=target
305
+ )
306
+ run(*cmd)
307
+
308
+ self._tag_lst.append(tag)
309
+ self._tag_lst.sort(key=lambda tag: tag.version)
310
+ self._tag_map[tag.version] = tag
311
+
312
+ def update(self, parent, child):
313
+ if parent.version not in self._tag_map:
314
+ raise RuntimeError(f"Tag {parent.name} doesn't exist")
315
+ if child.version not in self._tag_map:
316
+ raise RuntimeError(f"Tag {child.name} doesn't exist")
317
+
318
+ cmd = parent.git_cmd_update(child)
319
+ env = os.environ.copy()
320
+ env["GIT_EDITOR"] = "true"
321
+ run(*cmd, env=env)
322
+
323
+ def release_next(self, scope):
324
+ version = scope.next_version(self.latest.version)
325
+ tag = Tag(f"{self._prefix}{version}", version, self._lightweight)
326
+ self.create(tag)
327
+ return tag
328
+
329
+ def retag_parents(self, child):
330
+ while child.version.has_parent:
331
+ parent_version = child.version.get_parent()
332
+ if parent_version in self._tag_map:
333
+ parent = self._tag_map[parent_version]
334
+ self.update(parent, child)
335
+ child = parent
336
+ continue
337
+ parent = Tag(
338
+ self._format_tag_name(parent_version),
339
+ parent_version,
340
+ self._lightweight,
341
+ )
342
+ self.create(parent, target=child)
343
+ child = parent
344
+
345
+
346
+ def parse_args(argv=None):
347
+ if argv is None:
348
+ argv = sys.argv[1:]
349
+
350
+ epilog = R"""
351
+ The tag message (--message) can include Python's str.format() placeholders.
352
+ It will be format()ted with a single argument: the full tag name (for example,
353
+ v1.2.3).
354
+ """
355
+
356
+ parser = argparse.ArgumentParser(description=__doc__, epilog=epilog)
357
+
358
+ parser.add_argument("-v", "--verbose", action="store_true", help="verbose output")
359
+ parser.add_argument(
360
+ "-p",
361
+ "--prefix",
362
+ default=TagManager.DEFAULT_PREFIX,
363
+ metavar="STR",
364
+ help=f"""tag prefix ("{TagManager.DEFAULT_PREFIX}" by default)""",
365
+ )
366
+ parser.add_argument(
367
+ "-s",
368
+ "--strict",
369
+ action="store_true",
370
+ help="error out on discovering malformed tags",
371
+ )
372
+ parser.add_argument(
373
+ "-l",
374
+ "--lightweight",
375
+ action="store_true",
376
+ help="create lightweight tags (annotated by default)",
377
+ )
378
+ parser.add_argument(
379
+ "-m",
380
+ "--message",
381
+ metavar="FMT",
382
+ dest="message_fmt",
383
+ default=TagManager.DEFAULT_MESSAGE_FMT,
384
+ help="tag message format string",
385
+ )
386
+ parser.add_argument(
387
+ "-r",
388
+ "--retag",
389
+ action="store_true",
390
+ help="update parent version tags (i.e. for tag v2.1.1, update tags v2 & v2.1 to point to it)",
391
+ )
392
+ parser.add_argument(
393
+ "release_scope",
394
+ choices=ReleaseScope,
395
+ type=ReleaseScope,
396
+ help="release scope",
397
+ )
398
+ parser.add_argument(
399
+ "repo_dir",
400
+ metavar="DIR",
401
+ nargs="?",
402
+ help="path to repository (working directory by default)",
403
+ )
404
+
405
+ return parser.parse_args(argv)
406
+
407
+
408
+ def main(argv=None):
409
+ args = parse_args(argv)
410
+ with setup_logging(args.verbose):
411
+ go_to_repo(args.repo_dir)
412
+ tags = TagManager(
413
+ prefix=args.prefix,
414
+ strict=args.strict,
415
+ lightweight=args.lightweight,
416
+ message_fmt=args.message_fmt,
417
+ )
418
+ new = tags.release_next(args.release_scope)
419
+ if args.retag:
420
+ tags.retag_parents(new)
421
+
422
+
423
+ if __name__ == "__main__":
424
+ main()
@@ -0,0 +1,139 @@
1
+ # Copyright (c) 2026 Egor Tensin <egor@tensin.name>
2
+ # This file is part of the "tag-release" project.
3
+ # For details, see https://github.com/egor-tensin/tag-release
4
+ # Distributed under the MIT License.
5
+
6
+ log() {
7
+ local msg
8
+ for msg; do
9
+ echo -e "$test_file | $msg" >&2
10
+ done
11
+ }
12
+
13
+ log_run() {
14
+ if [ "$#" -lt 1 ]; then
15
+ log "usage: ${FUNCNAME[0]} ARGV0 [ARG...]"
16
+ return 1
17
+ fi
18
+
19
+ local msg='Running:'
20
+ msg="$msg$( printf -- ' %q' "$@" )"
21
+ log "$msg"
22
+ }
23
+
24
+ fail() {
25
+ local msg
26
+ for msg; do
27
+ log "FAIL: $msg"
28
+ done
29
+ }
30
+
31
+ fail_details() {
32
+ local msg
33
+ for msg; do
34
+ fail " $msg"
35
+ done
36
+ }
37
+
38
+ log_numof_tests_ok=0
39
+ log_numof_tests_failed=0
40
+ declare -a log_tests_failed=()
41
+ declare -A log_tests_should_fail=()
42
+
43
+ log_test_start() {
44
+ if [ "$#" -ne 1 ]; then
45
+ echo "usage: ${FUNCNAME[0]} TEST_FILE" >&2
46
+ return 1
47
+ fi
48
+
49
+ local test_file="$1"
50
+
51
+ if [ -n "${test_should_fail:-}" ]; then
52
+ log_tests_should_fail["$test_file"]=1
53
+ fi
54
+
55
+ cat <<EOF >&2
56
+
57
+ ======================================================================
58
+ TEST: $test_file
59
+ ======================================================================
60
+ EOF
61
+
62
+ log "Should fail: ${test_should_fail:-No}"
63
+ }
64
+
65
+ log_test_ok() {
66
+ if [ "$#" -ne 1 ]; then
67
+ echo "usage: ${FUNCNAME[0]} TEST_FILE" >&2
68
+ return 1
69
+ fi
70
+
71
+ log_numof_tests_ok=$((log_numof_tests_ok + 1))
72
+
73
+ cat <<EOF >&2
74
+ ----------------------------------------------------------------------
75
+ OK: $1
76
+ ----------------------------------------------------------------------
77
+ EOF
78
+ }
79
+
80
+ log_test_fail() {
81
+ if [ "$#" -ne 1 ]; then
82
+ echo "usage: ${FUNCNAME[0]} TEST_FILE" >&2
83
+ return 1
84
+ fi
85
+
86
+ log_numof_tests_failed=$((log_numof_tests_failed + 1))
87
+ log_tests_failed+=("$1")
88
+
89
+ cat <<EOF >&2
90
+ ----------------------------------------------------------------------
91
+ FAIL: $1
92
+ ----------------------------------------------------------------------
93
+ EOF
94
+ }
95
+
96
+ log_test_finish() {
97
+ if [ "$#" -ne 2 ]; then
98
+ echo "usage: ${FUNCNAME[0]} TEST_FILE EXIT_CODE" >&2
99
+ return 1
100
+ fi
101
+
102
+ local test_file="$1"
103
+ local ret="$2"
104
+ local test_should_fail
105
+ test_should_fail="${log_tests_should_fail["$test_file"]:+x}"
106
+
107
+ if [ "$ret" -eq 0 ] || [ -n "$test_should_fail" ]; then
108
+ log_test_ok "$test_file"
109
+ else
110
+ log_test_fail "$test_file"
111
+ fi
112
+ }
113
+
114
+ log_tests_summary() {
115
+ cat <<EOF >&2
116
+
117
+ ======================================================================
118
+ SUMMARY
119
+ ======================================================================
120
+ OK: $log_numof_tests_ok
121
+ FAIL: $log_numof_tests_failed
122
+ EOF
123
+
124
+ local test_file
125
+ for test_file in ${log_tests_failed[@]+"${log_tests_failed[@]}"}; do
126
+ cat <<EOF >&2
127
+ FAIL: $test_file
128
+ EOF
129
+ done
130
+
131
+ cat <<EOF >&2
132
+ ----------------------------------------------------------------------
133
+ EOF
134
+
135
+ local ret=0
136
+ [ "$log_numof_tests_failed" -gt 0 ] && ret=1
137
+
138
+ return "$ret"
139
+ }
@@ -0,0 +1,198 @@
1
+ # Copyright (c) 2026 Egor Tensin <egor@tensin.name>
2
+ # This file is part of the "tag-release" project.
3
+ # For details, see https://github.com/egor-tensin/tag-release
4
+ # Distributed under the MIT License.
5
+
6
+ test_should_fail=
7
+ test_root_dir=
8
+
9
+ test_repo_workdir=
10
+
11
+ test_setup() {
12
+ test_root_dir="$( mktemp -d )"
13
+
14
+ test_repo_workdir="$test_root_dir/workdir"
15
+ mkdir -- "$test_repo_workdir"
16
+
17
+ log "Root directory: $test_root_dir"
18
+ log "Working directory: $test_repo_workdir"
19
+
20
+ git -C "$test_repo_workdir" init -q
21
+ git -C "$test_repo_workdir" config user.name 'Test user'
22
+ git -C "$test_repo_workdir" config user.email 'test@example.com'
23
+ }
24
+
25
+ test_cleanup_default() {
26
+ if [ -n "$test_root_dir" ]; then
27
+ log "Removing test's root directory: $test_root_dir"
28
+ rm -rf -- "$test_root_dir"
29
+ fi
30
+ }
31
+
32
+ test_make_commit() {
33
+ local file
34
+ file="$( mktemp "--tmpdir=$test_repo_workdir" )"
35
+
36
+ log "Commiting file $file in $test_repo_workdir..."
37
+ touch -- "$file"
38
+ git -C "$test_repo_workdir" add "$file"
39
+ git -C "$test_repo_workdir" commit -q -m "$file"
40
+ }
41
+
42
+ _validate_tag_kind() {
43
+ if [ "$#" -ne 1 ]; then
44
+ log "usage: ${FUNCNAME[0]} {lightweight|annotated}"
45
+ return 1
46
+ fi
47
+
48
+ case "$1" in
49
+ lightweight|annotated)
50
+ echo "$1"
51
+ ;;
52
+ *)
53
+ log "${FUNCNAME[1]}: invalid tag type: $kind"
54
+ return 1
55
+ ;;
56
+ esac
57
+ }
58
+
59
+ test_get_tags() {
60
+ if [ "$#" -gt 1 ]; then
61
+ log "usage: ${FUNCNAME[0]} [{lightweight|annotated}]"
62
+ return 1
63
+ fi
64
+
65
+ local kind=
66
+ [ "$#" -gt 0 ] && kind="$( _validate_tag_kind "$1" )"
67
+
68
+ log "Reading tags in $test_repo_workdir..."
69
+
70
+ local objecttype
71
+ local refname
72
+
73
+ git -C "$test_repo_workdir" for-each-ref refs/tags/ \
74
+ '--format=%(objecttype)%0a%(refname:short)' |
75
+ while IFS= read -r objecttype; do
76
+ IFS= read -r refname
77
+
78
+ case "$kind-$objecttype" in
79
+ lightweight-commit|annotated-tag|-*)
80
+ echo "$refname"
81
+ ;;
82
+ esac
83
+ done | sort -V
84
+ }
85
+
86
+ test_get_tag_message() {
87
+ if [ "$#" -ne 1 ]; then
88
+ log "usage: ${FUNCNAME[0]} TAG"
89
+ return 1
90
+ fi
91
+
92
+ local tag="$1"
93
+
94
+ git -C "$test_repo_workdir" for-each-ref "refs/tags/$tag" '--format=%(contents)'
95
+ }
96
+
97
+ test_get_tag_commit() {
98
+ if [ "$#" -ne 1 ]; then
99
+ log "usage: ${FUNCNAME[0]} TAG"
100
+ return 1
101
+ fi
102
+
103
+ local tag="$1"
104
+
105
+ git -C "$test_repo_workdir" rev-list -n 1 "$tag" --
106
+ }
107
+
108
+ test_create_tags() {
109
+ local tag
110
+ for tag; do
111
+ log "Creating simple tag: $tag"
112
+ touch -- "$test_repo_workdir/$tag"
113
+ git -C "$test_repo_workdir" add "$test_repo_workdir/$tag"
114
+ git -C "$test_repo_workdir" commit -q -m "$tag"
115
+ git -C "$test_repo_workdir" tag -a -m "$tag" "$tag"
116
+ done
117
+ }
118
+
119
+ test_validate_tags() {
120
+ if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then
121
+ log "usage: ${FUNCNAME[0]} REPO_DIR EXPECTED_TAGS [{lightweight,annotated}]"
122
+ return 1
123
+ fi
124
+
125
+ local expected="$1"
126
+
127
+ local kind=
128
+ [ "$#" -gt 1 ] && kind="$( _validate_tag_kind "$2" )"
129
+
130
+ local actual
131
+ actual="$( test_get_tags $kind | paste -s -d ',' )"
132
+
133
+ log "Validating tags in $test_repo_workdir..."
134
+
135
+ [ "$actual" == "$expected" ] && return 0
136
+
137
+ fail "Unexpected tags"
138
+ fail_details "Expected tags: $expected"
139
+ fail_details "Actual tags: $actual"
140
+ return 1
141
+ }
142
+
143
+ test_validate_tag_message() {
144
+ if [ "$#" -ne 2 ]; then
145
+ log "usage: ${FUNCNAME[0]} TAG EXPECTED_MSG"
146
+ return 1
147
+ fi
148
+
149
+ local tag="$1"
150
+ local expected="$2"
151
+
152
+ log "Validating tag message for tag: $tag"
153
+
154
+ local actual
155
+ actual="$( test_get_tag_message "$tag" )"
156
+
157
+ [ "$actual" == "$expected" ] && return 0
158
+
159
+ fail "Unexpected message for tag $tag:"
160
+ fail_details "Expected: $expected"
161
+ fail_details "Actual: $actual"
162
+ return 1
163
+ }
164
+
165
+ test_validate_tags_same_target() {
166
+ if [ "$#" -lt 2 ]; then
167
+ log "usage: ${FUNCNAME[0]} TAG1 TAG2 [TAG...]"
168
+ return 1
169
+ fi
170
+
171
+ local tgt=
172
+
173
+ local tag
174
+ for tag; do
175
+ log "Validating tag target for tag: $tag"
176
+
177
+ local output
178
+ output="$( test_get_tag_commit "$tag" )"
179
+
180
+ if [ -z "$tgt" ]; then
181
+ tgt="$output"
182
+ continue
183
+ fi
184
+
185
+ if [ "$tgt" != "$output" ]; then
186
+ fail "Tag '$tag' doesn't point to the expected revision"
187
+ fail_details "Expected: $tgt"
188
+ fail_details "Actual: $output"
189
+ return 1
190
+ fi
191
+ done
192
+ }
193
+
194
+ test_run_release_script() {
195
+ local cmd=("$script_dir/../src/release.py" --verbose "$@" "$test_repo_workdir")
196
+ log_run "${cmd[@]}"
197
+ "${cmd[@]}"
198
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Copyright (c) 2026 Egor Tensin <egor@tensin.name>
4
+ # This file is part of the "tag-release" project.
5
+ # For details, see https://github.com/egor-tensin/tag-release
6
+ # Distributed under the MIT License.
7
+
8
+ set -o errexit -o nounset -o pipefail
9
+ shopt -s inherit_errexit lastpipe
10
+
11
+ script_dir="$( dirname -- "${BASH_SOURCE[0]}" )"
12
+ script_dir="$( cd -- "$script_dir" && pwd )"
13
+ readonly script_dir
14
+ script_name="$( basename -- "${BASH_SOURCE[0]}" )"
15
+ readonly script_name
16
+
17
+ source "$script_dir/lib/log.sh"
18
+
19
+ run_test_file() {
20
+ local test_file
21
+ for test_file; do
22
+ source "$script_dir/lib/test.sh"
23
+ source "$script_dir/$test_file"
24
+
25
+ log_test_start "$test_file"
26
+
27
+ set +e
28
+ (
29
+ set -o errexit -o nounset -o pipefail
30
+ shopt -s inherit_errexit lastpipe
31
+
32
+ if [ "$( type -t test_cleanup )" == function ]; then
33
+ trap test_cleanup EXIT
34
+ else
35
+ trap test_cleanup_default EXIT
36
+ fi
37
+
38
+ test_run
39
+ )
40
+ local ret="$?"
41
+ set -e
42
+
43
+ log_test_finish "$test_file" "$ret"
44
+ done
45
+ }
46
+
47
+ main() {
48
+ if [ "$#" -gt 1 ]; then
49
+ echo "usage: $script_name [PATTERN]" >&2
50
+ return 1
51
+ fi
52
+
53
+ local -a test_files=()
54
+ local test_file
55
+
56
+ local pattern='*'
57
+ [ "$#" -gt 0 ] && pattern="$1"
58
+
59
+ find "$script_dir" \
60
+ -mindepth 1 \
61
+ -maxdepth 1 \
62
+ -type f \
63
+ -regex '.*/tst-.*\.sh$' \
64
+ -regextype posix-basic \
65
+ -name "$pattern" \
66
+ -printf '%P\0' \
67
+ | sort -z \
68
+ | \
69
+ while IFS= read -d '' -r test_file; do
70
+ test_files+=("$test_file")
71
+ done
72
+
73
+ run_test_file ${test_files[@]+"${test_files[@]}"}
74
+ log_tests_summary
75
+ }
76
+
77
+ main "$@"
@@ -0,0 +1,6 @@
1
+ test_run() {
2
+ test_setup
3
+ test_create_tags v1
4
+ test_run_release_script major
5
+ test_validate_tags v1,v2.0.0
6
+ }
@@ -0,0 +1,6 @@
1
+ test_run() {
2
+ test_setup
3
+ test_create_tags v1
4
+ test_run_release_script minor
5
+ test_validate_tags v1,v1.1.0
6
+ }
@@ -0,0 +1,6 @@
1
+ test_run() {
2
+ test_setup
3
+ test_create_tags v1
4
+ test_run_release_script patch
5
+ test_validate_tags v1,v1.0.1
6
+ }
@@ -0,0 +1,17 @@
1
+ test_run() {
2
+ test_setup
3
+ test_create_tags v1.0.0
4
+
5
+ test_run_release_script -l -m "shouldn't be visible" major
6
+ test_run_release_script -m "minor release" minor
7
+ test_run_release_script -p 'debian/' --message "Debian release {}" patch
8
+ test_run_release_script -m $'foo\n\nbar' patch
9
+
10
+ test_validate_tags debian/0.0.1,v1.0.0,v2.0.0,v2.1.0,v2.1.1
11
+ test_validate_tags debian/0.0.1,v1.0.0,v2.1.0,v2.1.1 annotated
12
+ test_validate_tags v2.0.0 lightweight
13
+
14
+ test_validate_tag_message v2.1.0 'minor release'
15
+ test_validate_tag_message debian/0.0.1 'Debian release debian/0.0.1'
16
+ test_validate_tag_message v2.1.1 $'foo\n\nbar'
17
+ }
@@ -0,0 +1,12 @@
1
+ test_run() {
2
+ test_setup
3
+ test_create_tags v1.0.0
4
+
5
+ test_run_release_script --lightweight major
6
+ test_run_release_script minor
7
+ test_run_release_script --l patch
8
+
9
+ test_validate_tags v1.0.0,v2.0.0,v2.1.0,v2.1.1
10
+ test_validate_tags v1.0.0,v2.1.0 annotated
11
+ test_validate_tags v2.0.0,v2.1.1 lightweight
12
+ }
@@ -0,0 +1,6 @@
1
+ test_run() {
2
+ test_setup
3
+ test_make_commit
4
+ test_run_release_script major
5
+ test_validate_tags v1.0.0
6
+ }
@@ -0,0 +1,6 @@
1
+ test_run() {
2
+ test_setup
3
+ test_make_commit
4
+ test_run_release_script patch
5
+ test_validate_tags v0.0.1
6
+ }
@@ -0,0 +1,6 @@
1
+ test_run() {
2
+ test_setup
3
+ test_create_tags V1 V2 V2.1
4
+ test_run_release_script -p V major
5
+ test_validate_tags V1,V2,V2.1,V3.0.0
6
+ }
@@ -0,0 +1,6 @@
1
+ test_run() {
2
+ test_setup
3
+ test_create_tags 1 2 2.1
4
+ test_run_release_script -p '' major
5
+ test_validate_tags 1,2,2.1,3.0.0
6
+ }
@@ -0,0 +1,21 @@
1
+ test_run() {
2
+ test_setup
3
+
4
+ test_create_tags v1
5
+ test_make_commit
6
+ test_run_release_script minor
7
+ test_make_commit
8
+ test_run_release_script patch
9
+
10
+ test_validate_tags v1,v1.1.0,v1.1.1 annotated
11
+
12
+ test_make_commit
13
+ test_run_release_script -l -r patch
14
+
15
+ test_validate_tags v1,v1.1,v1.1.0,v1.1.1,v1.1.2
16
+ test_validate_tags v1,v1.1.0,v1.1.1 annotated
17
+ test_validate_tags v1.1,v1.1.2 lightweight
18
+
19
+ test_validate_tags_same_target v1 v1.1 v1.1.2
20
+ test_validate_tag_message v1 v1
21
+ }
@@ -0,0 +1,20 @@
1
+ test_run() {
2
+ test_setup
3
+
4
+ test_create_tags v1
5
+ test_make_commit
6
+ test_run_release_script minor
7
+ test_make_commit
8
+ test_run_release_script patch
9
+
10
+ test_validate_tags v1,v1.1.0,v1.1.1 annotated
11
+
12
+ test_make_commit
13
+ test_run_release_script -r patch
14
+
15
+ test_validate_tags v1,v1.1,v1.1.0,v1.1.1,v1.1.2 annotated
16
+
17
+ test_validate_tags_same_target v1 v1.1 v1.1.2
18
+ test_validate_tag_message v1 v1
19
+ test_validate_tag_message v1.1 v1.1
20
+ }
@@ -0,0 +1,6 @@
1
+ test_run() {
2
+ test_setup
3
+ test_create_tags v1 test_tag v2
4
+ test_run_release_script patch
5
+ test_validate_tags test_tag,v1,v2,v2.0.1
6
+ }
@@ -0,0 +1,8 @@
1
+ test_should_fail=Yes
2
+
3
+ test_run() {
4
+ test_setup
5
+ test_create_tags v1 vinvalid v2
6
+ test_run_release_script --strict patch
7
+ test_validate_tags vinvalid,v1,v2,v2.0.1
8
+ }
@@ -0,0 +1,7 @@
1
+ test_should_fail=Yes
2
+
3
+ test_run() {
4
+ test_setup
5
+ test_create_tags v1 test_tag v2
6
+ test_run_release_script --strict patch
7
+ }