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.
- git_tag_release-0.4.8/LICENSE.txt +21 -0
- git_tag_release-0.4.8/Makefile +17 -0
- git_tag_release-0.4.8/PKG-INFO +13 -0
- git_tag_release-0.4.8/README.md +75 -0
- git_tag_release-0.4.8/prelude.mk +29 -0
- git_tag_release-0.4.8/pyproject.toml +33 -0
- git_tag_release-0.4.8/setup.cfg +4 -0
- git_tag_release-0.4.8/src/git_tag_release.egg-info/PKG-INFO +13 -0
- git_tag_release-0.4.8/src/git_tag_release.egg-info/SOURCES.txt +30 -0
- git_tag_release-0.4.8/src/git_tag_release.egg-info/dependency_links.txt +1 -0
- git_tag_release-0.4.8/src/git_tag_release.egg-info/entry_points.txt +2 -0
- git_tag_release-0.4.8/src/git_tag_release.egg-info/scm_file_list.json +27 -0
- git_tag_release-0.4.8/src/git_tag_release.egg-info/scm_version.json +8 -0
- git_tag_release-0.4.8/src/git_tag_release.egg-info/top_level.txt +1 -0
- git_tag_release-0.4.8/src/release.py +424 -0
- git_tag_release-0.4.8/test/lib/log.sh +139 -0
- git_tag_release-0.4.8/test/lib/test.sh +198 -0
- git_tag_release-0.4.8/test/main.sh +77 -0
- git_tag_release-0.4.8/test/tst-basic-major.sh +6 -0
- git_tag_release-0.4.8/test/tst-basic-minor.sh +6 -0
- git_tag_release-0.4.8/test/tst-basic-patch.sh +6 -0
- git_tag_release-0.4.8/test/tst-custom-message.sh +17 -0
- git_tag_release-0.4.8/test/tst-lightweight-tags.sh +12 -0
- git_tag_release-0.4.8/test/tst-no-tags-major.sh +6 -0
- git_tag_release-0.4.8/test/tst-no-tags-patch.sh +6 -0
- git_tag_release-0.4.8/test/tst-prefix-custom.sh +6 -0
- git_tag_release-0.4.8/test/tst-prefix-empty.sh +6 -0
- git_tag_release-0.4.8/test/tst-retag-lightweight.sh +21 -0
- git_tag_release-0.4.8/test/tst-retag.sh +20 -0
- git_tag_release-0.4.8/test/tst-strict-no.sh +6 -0
- git_tag_release-0.4.8/test/tst-strict-yes-prefix.sh +8 -0
- 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
|
+
[](https://github.com/egor-tensin/tag-release/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/egor-tensin/tag-release/actions/workflows/debian.yml)
|
|
6
|
+
[](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,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 @@
|
|
|
1
|
+
|
|
@@ -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 @@
|
|
|
1
|
+
release
|
|
@@ -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,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,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
|
+
}
|