forked from AmbitionEng/django-pghistory
-
Notifications
You must be signed in to change notification settings - Fork 0
/
devops.py
205 lines (148 loc) · 6.56 KB
/
devops.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
#!/usr/bin/env python3
"""
Devops functions for this package. Includes functions for automated
package deployment, changelog generation, and changelog checking.
This script is generated by the template at
https://github.com/Opus10/python-library-template
Do not change this script! Any fixes or updates to this script should be made
to https://github.com/Opus10/python-library-template
"""
import os
import subprocess
import sys
import tempfile
from typing import IO, Any, Final, Literal, Tuple, TypeAlias, Union
from packaging import version
CIRCLECI_ENV_VAR: Final = "CIRCLECI"
File: TypeAlias = Union[IO[Any], int, None]
class Error(Exception):
"""Base exception for this script"""
class NotOnCircleCIError(Error):
"""Thrown when not running on CircleCI"""
def _check_git_version() -> None:
"""Verify git version"""
git_version = _shell_stdout("git --version | rev | cut -f 1 -d' ' | rev")
if version.parse(git_version) < version.parse("2.22.0"):
raise RuntimeError(f"Must have git version >= 2.22.0 (version = {git_version})")
def _shell(
cmd: str,
check: bool = True,
stdin: File = None,
stdout: File = None,
stderr: File = None,
): # pragma: no cover
"""Runs a subprocess shell with check=True by default"""
return subprocess.run(cmd, shell=True, check=check, stdin=stdin, stdout=stdout, stderr=stderr)
def _shell_stdout(cmd: str, check: bool = True) -> str:
"""Runs a shell command and returns stdout"""
ret = _shell(cmd, stdout=subprocess.PIPE, check=check)
return ret.stdout.decode("utf-8").strip() if ret.stdout else ""
def _configure_git() -> None:
"""Configure git name/email and verify git version"""
_check_git_version()
_shell('git config --local user.email "[email protected]"')
_shell('git config --local user.name "Opus 10 Devops"')
_shell("git config push.default current")
def _find_latest_tag() -> str:
return _shell_stdout("git describe --tags --abbrev=0", check=False)
def _find_sem_ver_update() -> Literal["major", "minor", "patch"]:
"""
Find the semantic version string based on the commit log.
Defaults to returning "patch"
"""
sem_ver = "patch"
latest_tag = _find_latest_tag()
log_section = f"{latest_tag}..HEAD" if latest_tag else ""
cmd = (
f"git log {log_section} --pretty='%(trailers:key=type,valueonly)'"
" | grep -q {sem_ver_type}"
)
change_types_found = {
change_type: _shell(cmd.format(sem_ver_type=change_type), check=False).returncode == 0
for change_type in ["bug", "feature", "api-break"]
}
if change_types_found["api-break"]:
sem_ver = "major"
elif change_types_found["bug"] or change_types_found["feature"]:
sem_ver = "minor"
return sem_ver
def _update_package_version() -> Tuple[str, str]:
"""Apply semantic versioning to package based on git commit messages"""
# Obtain the current version
old_version = _shell_stdout("poetry version | rev | cut -f 1 -d' ' | rev")
if old_version == "0.0.0":
old_version = ""
latest_tag = _find_latest_tag()
if old_version and version.parse(old_version) != version.parse(latest_tag):
raise RuntimeError(
f'The latest tag "{latest_tag}" and the current version'
f' "{old_version}" do not match.'
)
# Find out the sem-ver tag to apply
sem_ver = _find_sem_ver_update()
_shell(f"poetry version {sem_ver}")
# Get the new version
new_version = _shell_stdout("poetry version | rev | cut -f 1 -d' ' | rev")
if new_version == old_version:
raise RuntimeError(f'Version update could not be applied (version = "{old_version}")')
return old_version, new_version
def _generate_changelog_and_tag(old_version: str, new_version: str) -> None:
"""Generates a change log using git-tidy and tags repo"""
# Tag the version temporarily so that changelog generation
# renders properly
_shell(f'git tag -f -a {new_version} -m "Version {new_version}"')
# Generate the full changelog and copy it to docs/release_notes.md
_shell("git tidy-log > CHANGELOG.md")
_shell("cp CHANGELOG.md docs/release_notes.md")
# Generate a requirements.txt for readthedocs.org
_shell("poetry export --with dev --without-hashes -f requirements.txt > docs/requirements.txt")
_shell('echo "." >> docs/requirements.txt')
# Add all updated files
_shell("git add pyproject.toml CHANGELOG.md docs/release_notes.md docs/requirements.txt")
# Use [skip ci] to ensure CircleCI doesnt recursively deploy
_shell(
'git commit --no-verify -m "Release version'
f' {new_version} [skip ci]" -m "Type: trivial"'
)
# Create release notes just for this release so that we can use them in
# the commit message
with tempfile.NamedTemporaryFile() as commit_msg_file:
_shell(f'echo "{new_version}\n" > {commit_msg_file.name}')
tidy_log_args = f"^{old_version} HEAD" if old_version else "HEAD"
_shell(f"git tidy-log {tidy_log_args} >> {commit_msg_file.name}")
# Update the tag so that it includes the latest release messages and
# the automated commit
_shell(f"git tag -d {new_version}")
_shell(f"git tag -f -a {new_version} -F {commit_msg_file.name} --cleanup=whitespace")
def _publish_to_pypi() -> None:
"""
Uses poetry to publish to pypi
"""
if "PYPI_USERNAME" not in os.environ or "PYPI_PASSWORD" not in os.environ:
raise RuntimeError("Must set PYPI_USERNAME and PYPI_PASSWORD env vars")
_shell("poetry config http-basic.pypi ${PYPI_USERNAME} ${PYPI_PASSWORD}")
_shell("poetry build")
_shell("poetry publish -vvv -n", stdout=subprocess.PIPE)
def _build_and_push_distribution() -> None:
"""
Builds and pushes distribution to PyPI, along with pushing the
tags back to the repo
"""
_publish_to_pypi()
# Push the code changes after succcessful pypi deploy
_shell("git push --follow-tags")
def deploy() -> None:
"""Deploys the package and uploads documentation."""
# Ensure proper environment
if not os.environ.get(CIRCLECI_ENV_VAR): # pragma: no cover
raise NotOnCircleCIError("Must be on CircleCI to run this script")
_configure_git()
old_version, new_version = _update_package_version()
_generate_changelog_and_tag(old_version, new_version)
_build_and_push_distribution()
print(f"Deployment complete. Latest version is {new_version}")
if __name__ == "__main__":
if sys.argv[-1] == "deploy":
deploy()
else:
raise RuntimeError(f'Invalid subcommand "{sys.argv[-1]}"')