conventional-release 0.1.0__py3-none-any.whl
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.
- conventional_release/__init__.py +8 -0
- conventional_release/__main__.py +3 -0
- conventional_release/cli.py +123 -0
- conventional_release/cliff.py +197 -0
- conventional_release/config.py +164 -0
- conventional_release/git.py +68 -0
- conventional_release/py.typed +0 -0
- conventional_release/release.py +229 -0
- conventional_release/semver.py +30 -0
- conventional_release/versionfiles.py +96 -0
- conventional_release-0.1.0.dist-info/METADATA +207 -0
- conventional_release-0.1.0.dist-info/RECORD +15 -0
- conventional_release-0.1.0.dist-info/WHEEL +4 -0
- conventional_release-0.1.0.dist-info/entry_points.txt +3 -0
- conventional_release-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""standard-version-style CHANGELOG and release PRs from Conventional Commits."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("conventional-release")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree that was never installed
|
|
8
|
+
__version__ = "0.0.0"
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Command line: `conventional-release <command>` (alias `crel`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from conventional_release import __version__, cliff, release
|
|
12
|
+
from conventional_release.config import Config, ConfigError, load
|
|
13
|
+
from conventional_release.git import GitError
|
|
14
|
+
from conventional_release.versionfiles import VersionFileError
|
|
15
|
+
|
|
16
|
+
EXPECTED = (ConfigError, GitError, VersionFileError, cliff.CliffError, release.ReleaseError)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parser() -> argparse.ArgumentParser:
|
|
20
|
+
p = argparse.ArgumentParser(
|
|
21
|
+
prog="conventional-release",
|
|
22
|
+
description="standard-version-style CHANGELOG and release PRs from Conventional Commits.",
|
|
23
|
+
)
|
|
24
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
25
|
+
p.add_argument(
|
|
26
|
+
"-C", dest="root", type=Path, default=Path.cwd(), help="run as if started in this directory"
|
|
27
|
+
)
|
|
28
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
29
|
+
|
|
30
|
+
r = sub.add_parser("release", help="branch, CHANGELOG, version bump, commit, push, open a PR")
|
|
31
|
+
r.add_argument(
|
|
32
|
+
"bump", nargs="?", help="major | minor | patch | X.Y.Z (default: inferred from the commits)"
|
|
33
|
+
)
|
|
34
|
+
r.add_argument(
|
|
35
|
+
"--dry-run",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="print the version and the changelog section, change nothing",
|
|
38
|
+
)
|
|
39
|
+
r.add_argument("--no-push", action="store_true", help="stop after the local commit")
|
|
40
|
+
r.add_argument("--no-pr", action="store_true", help="push the branch but open no PR")
|
|
41
|
+
|
|
42
|
+
sub.add_parser("current", help="print the current version")
|
|
43
|
+
n = sub.add_parser("next", help="print the version the next release would get")
|
|
44
|
+
n.add_argument("bump", nargs="?", help="major | minor | patch | X.Y.Z")
|
|
45
|
+
|
|
46
|
+
no = sub.add_parser("notes", help="print one version's CHANGELOG section (release notes)")
|
|
47
|
+
no.add_argument("version")
|
|
48
|
+
|
|
49
|
+
d = sub.add_parser("detect", help="CI: is HEAD a release commit that still needs its tag?")
|
|
50
|
+
d.add_argument(
|
|
51
|
+
"--github-output",
|
|
52
|
+
action="store_true",
|
|
53
|
+
help="also append released/version/tag to $GITHUB_OUTPUT",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
t = sub.add_parser("tag", help="CI: create the annotated tag for VERSION on HEAD")
|
|
57
|
+
t.add_argument("version")
|
|
58
|
+
t.add_argument("--push", action="store_true", help="push the tag to origin")
|
|
59
|
+
|
|
60
|
+
c = sub.add_parser("check-title", help="validate a PR title / commit subject")
|
|
61
|
+
c.add_argument("title")
|
|
62
|
+
return p
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
66
|
+
args = _parser().parse_args(argv)
|
|
67
|
+
try:
|
|
68
|
+
config = load(args.root.resolve())
|
|
69
|
+
return _run(args, config)
|
|
70
|
+
except EXPECTED as e:
|
|
71
|
+
print(f"error: {e}", file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _run(args: argparse.Namespace, config: Config) -> int:
|
|
76
|
+
if args.command == "current":
|
|
77
|
+
print(release.current_version(config) or "")
|
|
78
|
+
elif args.command == "next":
|
|
79
|
+
print(release.plan(config, args.bump)[1])
|
|
80
|
+
elif args.command == "notes":
|
|
81
|
+
print(release.notes(config, args.version), end="")
|
|
82
|
+
elif args.command == "check-title":
|
|
83
|
+
problems = release.check_title(config, args.title)
|
|
84
|
+
for problem in problems:
|
|
85
|
+
print(f"error: {problem}", file=sys.stderr)
|
|
86
|
+
return 1 if problems else 0
|
|
87
|
+
elif args.command == "detect":
|
|
88
|
+
result = release.detect(config)
|
|
89
|
+
print(result.outputs(), end="")
|
|
90
|
+
if result.reason:
|
|
91
|
+
print(result.reason, file=sys.stderr)
|
|
92
|
+
if args.github_output:
|
|
93
|
+
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
|
|
94
|
+
f.write(result.outputs())
|
|
95
|
+
elif args.command == "tag":
|
|
96
|
+
print(release.create_tag(config, args.version, push=args.push))
|
|
97
|
+
elif args.command == "release":
|
|
98
|
+
return _release(args, config)
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _release(args: argparse.Namespace, config: Config) -> int:
|
|
103
|
+
current, version = release.plan(config, args.bump)
|
|
104
|
+
print(f"release {current or '(none)'} -> {version}", file=sys.stderr)
|
|
105
|
+
if args.dry_run:
|
|
106
|
+
print(
|
|
107
|
+
f"(dry run — nothing written; branch would be {config.branch(version)})\n",
|
|
108
|
+
file=sys.stderr,
|
|
109
|
+
)
|
|
110
|
+
print(cliff.unreleased_section(config, version))
|
|
111
|
+
return 0
|
|
112
|
+
release.cut(config, args.bump, push=not args.no_push, open_pr=not args.no_pr)
|
|
113
|
+
if args.no_push:
|
|
114
|
+
print(
|
|
115
|
+
f"committed on {config.branch(version)}; push it and open a PR titled "
|
|
116
|
+
f'"chore(release): {version}"',
|
|
117
|
+
file=sys.stderr,
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
print(
|
|
121
|
+
"Squash-merge the PR with its title as-is; CI tags the merge commit.", file=sys.stderr
|
|
122
|
+
)
|
|
123
|
+
return 0
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Drive git-cliff with a generated config that renders standard-version's CHANGELOG shape:
|
|
2
|
+
|
|
3
|
+
## 1.2.0 (2026-09-25)
|
|
4
|
+
|
|
5
|
+
### ⚠ BREAKING CHANGES
|
|
6
|
+
|
|
7
|
+
* **api:** drop the v1 endpoints
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **cli:** add --json ([#42](https://github.com/o/r/issues/42)) ([abc1234](https://github.com/o/r/commit/abc1234...))
|
|
12
|
+
|
|
13
|
+
The PR number comes from squash-merging: GitHub appends " (#N)" to the PR title, the title
|
|
14
|
+
becomes the commit subject, and a preprocessor turns it into a link. A project with its own
|
|
15
|
+
cliff.toml sets `cliff-config` and this module passes it through untouched.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
from collections.abc import Iterator
|
|
27
|
+
from contextlib import contextmanager
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from conventional_release import git
|
|
31
|
+
from conventional_release.config import Config
|
|
32
|
+
|
|
33
|
+
HEADER = """\
|
|
34
|
+
# Changelog
|
|
35
|
+
|
|
36
|
+
All notable changes to this project will be documented in this file. Generated by
|
|
37
|
+
[conventional-release](https://github.com/MdaaaaO/conventional-release) from
|
|
38
|
+
[Conventional Commits](https://www.conventionalcommits.org).
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CliffError(Exception):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _q(value: str) -> str:
|
|
48
|
+
"""A TOML basic string. JSON's escaping is a subset of TOML's."""
|
|
49
|
+
return json.dumps(value, ensure_ascii=False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Tera template. The trailing backslashes are TOML line continuations inside the """ string —
|
|
53
|
+
# they are what keeps the output free of stray blank lines.
|
|
54
|
+
_BODY = r"""
|
|
55
|
+
{% if version %}\
|
|
56
|
+
## {{ version | trim_start_matches(pat=@@PREFIX@@) }} ({{ timestamp | date(format="%Y-%m-%d") }})
|
|
57
|
+
{% else %}\
|
|
58
|
+
## Unreleased
|
|
59
|
+
{% endif %}\
|
|
60
|
+
{% set breaking = commits | filter(attribute="breaking", value=true) %}\
|
|
61
|
+
{% if breaking | length > 0 %}
|
|
62
|
+
|
|
63
|
+
### ⚠ BREAKING CHANGES
|
|
64
|
+
|
|
65
|
+
{% for commit in breaking %}\
|
|
66
|
+
* {% if commit.scope %}**{{ commit.scope }}:** {% endif %}\
|
|
67
|
+
{% if commit.breaking_description %}{{ commit.breaking_description | trim }}\
|
|
68
|
+
{% else %}{{ commit.message | trim }}{% endif %}
|
|
69
|
+
{% endfor %}\
|
|
70
|
+
{% endif %}\
|
|
71
|
+
{% for group, commits in commits | group_by(attribute="group") %}
|
|
72
|
+
|
|
73
|
+
### {{ group | striptags | trim }}
|
|
74
|
+
|
|
75
|
+
{% for commit in commits %}\
|
|
76
|
+
* {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | trim }}@@LINK@@
|
|
77
|
+
{% endfor %}\
|
|
78
|
+
{% endfor %}
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
_LINK = (
|
|
82
|
+
r' ([{{ commit.id | truncate(length=7, end="") }}]'
|
|
83
|
+
r"(@@URL@@/commit/{{ commit.id }}))"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def render_config(config: Config, repo_url: str | None, *, with_header: bool) -> str:
|
|
88
|
+
body = _BODY.replace("@@PREFIX@@", _q(config.tag_prefix))
|
|
89
|
+
body = body.replace("@@LINK@@", _LINK.replace("@@URL@@", repo_url) if repo_url else "")
|
|
90
|
+
|
|
91
|
+
parsers = [' { message = "^chore\\\\(release\\\\)", skip = true },']
|
|
92
|
+
for n, t in enumerate(config.types):
|
|
93
|
+
pattern = _q(rf"^{re.escape(t.type)}(\(.*\))?!?:")
|
|
94
|
+
if t.hidden:
|
|
95
|
+
parsers.append(f" {{ message = {pattern}, skip = true }},")
|
|
96
|
+
else:
|
|
97
|
+
group = _q(f"<!-- {n:02d} -->{t.section}")
|
|
98
|
+
parsers.append(f" {{ message = {pattern}, group = {group} }},")
|
|
99
|
+
|
|
100
|
+
preprocessors = []
|
|
101
|
+
if repo_url:
|
|
102
|
+
# (?m): a squash commit's message is the subject *plus* the PR body, so "$" must mean
|
|
103
|
+
# end of line — without the flag nothing matches.
|
|
104
|
+
pattern = _q(r"(?m)\s\(#([0-9]+)\)$")
|
|
105
|
+
replace = _q(" ([#${1}](" + repo_url + "/issues/${1}))")
|
|
106
|
+
preprocessors.append(f" {{ pattern = {pattern}, replace = {replace} }},")
|
|
107
|
+
|
|
108
|
+
header = HEADER if with_header else ""
|
|
109
|
+
return f'''\
|
|
110
|
+
[changelog]
|
|
111
|
+
header = {_q(header)}
|
|
112
|
+
body = """{body}"""
|
|
113
|
+
trim = true
|
|
114
|
+
footer = ""
|
|
115
|
+
|
|
116
|
+
[git]
|
|
117
|
+
conventional_commits = true
|
|
118
|
+
filter_unconventional = true
|
|
119
|
+
split_commits = false
|
|
120
|
+
protect_breaking_commits = true
|
|
121
|
+
sort_commits = "oldest"
|
|
122
|
+
tag_pattern = {_q("^" + re.escape(config.tag_prefix) + "[0-9]")}
|
|
123
|
+
commit_preprocessors = [
|
|
124
|
+
{chr(10).join(preprocessors)}
|
|
125
|
+
]
|
|
126
|
+
commit_parsers = [
|
|
127
|
+
{chr(10).join(parsers)}
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
[bump]
|
|
131
|
+
features_always_bump_minor = true
|
|
132
|
+
breaking_always_bump_major = true
|
|
133
|
+
initial_tag = {_q(config.tag(config.initial_version))}
|
|
134
|
+
'''
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def binary() -> str:
|
|
138
|
+
"""git-cliff from this interpreter's environment (the `git-cliff` wheel), else PATH."""
|
|
139
|
+
sibling = Path(sys.executable).parent / "git-cliff"
|
|
140
|
+
if sibling.is_file():
|
|
141
|
+
return str(sibling)
|
|
142
|
+
found = shutil.which("git-cliff")
|
|
143
|
+
if found is None:
|
|
144
|
+
raise CliffError("git-cliff not found — install the git-cliff package")
|
|
145
|
+
return found
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def repo_url(config: Config) -> str | None:
|
|
149
|
+
if config.repo_url:
|
|
150
|
+
return config.repo_url.rstrip("/")
|
|
151
|
+
remote = git.remote_url(config.root)
|
|
152
|
+
return git.web_url(remote) if remote else None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@contextmanager
|
|
156
|
+
def _config_file(config: Config, *, with_header: bool) -> Iterator[Path]:
|
|
157
|
+
if config.cliff_config:
|
|
158
|
+
yield config.root / config.cliff_config
|
|
159
|
+
return
|
|
160
|
+
with tempfile.TemporaryDirectory(prefix="conventional-release-") as tmp:
|
|
161
|
+
path = Path(tmp) / "cliff.toml"
|
|
162
|
+
path.write_text(render_config(config, repo_url(config), with_header=with_header))
|
|
163
|
+
yield path
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def run(config: Config, *args: str, with_header: bool = False) -> str:
|
|
167
|
+
with _config_file(config, with_header=with_header) as cfg:
|
|
168
|
+
proc = subprocess.run(
|
|
169
|
+
[binary(), "--config", str(cfg), *args],
|
|
170
|
+
cwd=config.root,
|
|
171
|
+
capture_output=True,
|
|
172
|
+
text=True,
|
|
173
|
+
)
|
|
174
|
+
if proc.returncode != 0:
|
|
175
|
+
raise CliffError(f"git-cliff {' '.join(args)} failed:\n{proc.stderr.strip()}")
|
|
176
|
+
return proc.stdout
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def bumped_version(config: Config) -> str:
|
|
180
|
+
out = run(config, "--bumped-version").strip()
|
|
181
|
+
return out.removeprefix(config.tag_prefix)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def unreleased_section(config: Config, version: str) -> str:
|
|
185
|
+
"""The section the next release will add, heading included, no header."""
|
|
186
|
+
return run(config, "--unreleased", "--tag", config.tag(version), "--strip", "all").strip()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def regenerate(config: Config, version: str) -> None:
|
|
190
|
+
run(
|
|
191
|
+
config,
|
|
192
|
+
"--tag",
|
|
193
|
+
config.tag(version),
|
|
194
|
+
"--output",
|
|
195
|
+
str(config.changelog_path),
|
|
196
|
+
with_header=True,
|
|
197
|
+
)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Project configuration: `.conventional-release.toml`, or `[tool.conventional-release]` in
|
|
2
|
+
pyproject.toml. Every key is optional; the defaults reproduce standard-version's behaviour."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
CONFIG_FILE = ".conventional-release.toml"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigError(Exception):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class CommitType:
|
|
20
|
+
type: str
|
|
21
|
+
section: str
|
|
22
|
+
hidden: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# The Angular set, which Conventional Commits grew out of, in the order standard-version's
|
|
26
|
+
# `.versionrc` examples use. Every type gets a section by default; set `hidden = true` to drop one.
|
|
27
|
+
DEFAULT_TYPES: tuple[CommitType, ...] = (
|
|
28
|
+
CommitType("feat", "Features"),
|
|
29
|
+
CommitType("fix", "Bug Fixes"),
|
|
30
|
+
CommitType("docs", "Documentation"),
|
|
31
|
+
CommitType("style", "Styling"),
|
|
32
|
+
CommitType("refactor", "Refactors"),
|
|
33
|
+
CommitType("perf", "Performance"),
|
|
34
|
+
CommitType("test", "Tests"),
|
|
35
|
+
CommitType("build", "Build System"),
|
|
36
|
+
CommitType("ci", "CI"),
|
|
37
|
+
CommitType("chore", "Chore"),
|
|
38
|
+
CommitType("revert", "Reverts"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
VERSION_SOURCES = ("file", "tag")
|
|
42
|
+
CHANGELOG_MODES = ("prepend", "regenerate")
|
|
43
|
+
|
|
44
|
+
# Probed in this order when `version-files` is not set and `version-source = "file"`.
|
|
45
|
+
AUTODETECT_FILES = ("pyproject.toml", "package.json", "Cargo.toml", "VERSION", "version")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Config:
|
|
50
|
+
root: Path
|
|
51
|
+
version_source: str = "file"
|
|
52
|
+
version_files: tuple[str, ...] = ()
|
|
53
|
+
changelog: str = "CHANGELOG.md"
|
|
54
|
+
changelog_mode: str = "prepend"
|
|
55
|
+
tag_prefix: str = "v"
|
|
56
|
+
base_branch: str = "main"
|
|
57
|
+
branch_prefix: str = "release/"
|
|
58
|
+
initial_version: str = "0.1.0"
|
|
59
|
+
repo_url: str | None = None
|
|
60
|
+
cliff_config: str | None = None
|
|
61
|
+
subject_lowercase: bool = True
|
|
62
|
+
types: tuple[CommitType, ...] = field(default=DEFAULT_TYPES)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def changelog_path(self) -> Path:
|
|
66
|
+
return self.root / self.changelog
|
|
67
|
+
|
|
68
|
+
def tag(self, version: str) -> str:
|
|
69
|
+
return f"{self.tag_prefix}{version}"
|
|
70
|
+
|
|
71
|
+
def branch(self, version: str) -> str:
|
|
72
|
+
return f"{self.branch_prefix}{self.tag(version)}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_SCALAR_KEYS = {
|
|
76
|
+
"version-source": ("version_source", str),
|
|
77
|
+
"changelog": ("changelog", str),
|
|
78
|
+
"changelog-mode": ("changelog_mode", str),
|
|
79
|
+
"tag-prefix": ("tag_prefix", str),
|
|
80
|
+
"base-branch": ("base_branch", str),
|
|
81
|
+
"branch-prefix": ("branch_prefix", str),
|
|
82
|
+
"initial-version": ("initial_version", str),
|
|
83
|
+
"repo-url": ("repo_url", str),
|
|
84
|
+
"cliff-config": ("cliff_config", str),
|
|
85
|
+
"subject-lowercase": ("subject_lowercase", bool),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _raw(root: Path) -> tuple[dict[str, Any], str]:
|
|
90
|
+
dedicated = root / CONFIG_FILE
|
|
91
|
+
if dedicated.is_file():
|
|
92
|
+
with dedicated.open("rb") as f:
|
|
93
|
+
return tomllib.load(f), CONFIG_FILE
|
|
94
|
+
pyproject = root / "pyproject.toml"
|
|
95
|
+
if pyproject.is_file():
|
|
96
|
+
with pyproject.open("rb") as f:
|
|
97
|
+
data = tomllib.load(f)
|
|
98
|
+
section = data.get("tool", {}).get("conventional-release")
|
|
99
|
+
if section is not None:
|
|
100
|
+
return section, "pyproject.toml [tool.conventional-release]"
|
|
101
|
+
return {}, "defaults"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load(root: Path) -> Config:
|
|
105
|
+
raw, where = _raw(root)
|
|
106
|
+
kwargs: dict[str, Any] = {}
|
|
107
|
+
for key, value in raw.items():
|
|
108
|
+
if key in _SCALAR_KEYS:
|
|
109
|
+
name, kind = _SCALAR_KEYS[key]
|
|
110
|
+
if not isinstance(value, kind):
|
|
111
|
+
raise ConfigError(f"{where}: {key} must be a {kind.__name__}")
|
|
112
|
+
kwargs[name] = value
|
|
113
|
+
elif key == "version-files":
|
|
114
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
115
|
+
raise ConfigError(f"{where}: version-files must be a list of paths")
|
|
116
|
+
kwargs["version_files"] = tuple(value)
|
|
117
|
+
elif key == "types":
|
|
118
|
+
kwargs["types"] = _types(value, where)
|
|
119
|
+
else:
|
|
120
|
+
raise ConfigError(f"{where}: unknown key {key!r}")
|
|
121
|
+
|
|
122
|
+
config = Config(root=root, **kwargs)
|
|
123
|
+
if config.version_source not in VERSION_SOURCES:
|
|
124
|
+
raise ConfigError(f"{where}: version-source must be one of {VERSION_SOURCES}")
|
|
125
|
+
if config.changelog_mode not in CHANGELOG_MODES:
|
|
126
|
+
raise ConfigError(f"{where}: changelog-mode must be one of {CHANGELOG_MODES}")
|
|
127
|
+
return config
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _types(value: Any, where: str) -> tuple[CommitType, ...]:
|
|
131
|
+
if not isinstance(value, list) or not value:
|
|
132
|
+
raise ConfigError(f"{where}: types must be a non-empty array of tables")
|
|
133
|
+
out = []
|
|
134
|
+
for item in value:
|
|
135
|
+
if not isinstance(item, dict) or not isinstance(item.get("type"), str):
|
|
136
|
+
raise ConfigError(f"{where}: every types entry needs a string 'type'")
|
|
137
|
+
section = item.get("section", item["type"])
|
|
138
|
+
hidden = item.get("hidden", False)
|
|
139
|
+
if not isinstance(section, str) or not isinstance(hidden, bool):
|
|
140
|
+
raise ConfigError(f"{where}: types.{item['type']}: section is a string, hidden a bool")
|
|
141
|
+
out.append(CommitType(item["type"], section, hidden))
|
|
142
|
+
return tuple(out)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def version_files(config: Config) -> list[Path]:
|
|
146
|
+
"""The files that carry the version, auto-detected when not configured."""
|
|
147
|
+
if config.version_source == "tag":
|
|
148
|
+
return []
|
|
149
|
+
if config.version_files:
|
|
150
|
+
paths = [config.root / p for p in config.version_files]
|
|
151
|
+
missing = [str(p.relative_to(config.root)) for p in paths if not p.is_file()]
|
|
152
|
+
if missing:
|
|
153
|
+
raise ConfigError(f"version-files not found: {', '.join(missing)}")
|
|
154
|
+
return paths
|
|
155
|
+
from conventional_release import versionfiles
|
|
156
|
+
|
|
157
|
+
for name in AUTODETECT_FILES:
|
|
158
|
+
path = config.root / name
|
|
159
|
+
if path.is_file() and versionfiles.has_version(path):
|
|
160
|
+
return [path]
|
|
161
|
+
raise ConfigError(
|
|
162
|
+
"no version file found (looked for " + ", ".join(AUTODETECT_FILES) + "). "
|
|
163
|
+
'Set version-files, or version-source = "tag" if the git tag is the version.'
|
|
164
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Thin wrappers over the git CLI. Every call raises on a non-zero exit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GitError(Exception):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(root: Path, *args: str) -> str:
|
|
15
|
+
proc = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
|
|
16
|
+
if proc.returncode != 0:
|
|
17
|
+
raise GitError(f"git {' '.join(args)}: {proc.stderr.strip() or proc.stdout.strip()}")
|
|
18
|
+
return proc.stdout.strip()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def status_porcelain(root: Path) -> str:
|
|
22
|
+
return run(root, "status", "--porcelain")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def latest_tag(root: Path, prefix: str) -> str | None:
|
|
26
|
+
try:
|
|
27
|
+
return run(root, "describe", "--tags", "--abbrev=0", "--match", f"{prefix}[0-9]*")
|
|
28
|
+
except GitError:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def tag_exists(root: Path, tag: str) -> bool:
|
|
33
|
+
try:
|
|
34
|
+
run(root, "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}")
|
|
35
|
+
except GitError:
|
|
36
|
+
return False
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def branch_exists(root: Path, branch: str) -> bool:
|
|
41
|
+
try:
|
|
42
|
+
run(root, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}")
|
|
43
|
+
except GitError:
|
|
44
|
+
return False
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def head_subject(root: Path) -> str:
|
|
49
|
+
return run(root, "log", "-1", "--pretty=%s")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def remote_url(root: Path, remote: str = "origin") -> str | None:
|
|
53
|
+
try:
|
|
54
|
+
return run(root, "remote", "get-url", remote)
|
|
55
|
+
except GitError:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_SSH = re.compile(r"^(?:ssh://)?git@([^:/]+)[:/](.+?)(?:\.git)?/?$")
|
|
60
|
+
_HTTPS = re.compile(r"^https?://(?:[^@/]+@)?([^/]+)/(.+?)(?:\.git)?/?$")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def web_url(remote: str) -> str | None:
|
|
64
|
+
"""git@github.com:o/r.git, https://x@github.com/o/r → https://github.com/o/r"""
|
|
65
|
+
for pattern in (_SSH, _HTTPS):
|
|
66
|
+
if m := pattern.match(remote):
|
|
67
|
+
return f"https://{m.group(1)}/{m.group(2)}"
|
|
68
|
+
return None
|
|
File without changes
|