-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathrelease.py
More file actions
257 lines (207 loc) · 9.22 KB
/
Copy pathrelease.py
File metadata and controls
257 lines (207 loc) · 9.22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Check the repository over and tag a new dvcurator release.
Releases are driven by tags: pushing a "v*" tag triggers
.github/workflows/build.yml, which rewrites dvcurator/version.py from the tag,
builds the Windows executable with pyinstaller, and opens a DRAFT release with
the .exe attached.
This script checks the repository over, tags it, and pushes, which is what
starts the build. It confirms before pushing, since that is the point of no
return.
python release.py v1.1.3
python release.py 1.1.3 --skip-tests
python release.py v1.1.3 --yes # don't ask before pushing
python release.py v1.1.3 --no-push # tag only, push it yourself
"""
import argparse
import os
import re
import subprocess
import sys
RELEASES = "https://github.com/QualitativeDataRepository/dvcurator-python/releases"
ACTIONS = "https://github.com/QualitativeDataRepository/dvcurator-python/actions"
def fail(message):
"""Print an error and stop, leaving the repository as we found it"""
print("\nERROR: " + message)
sys.exit(1)
def step(message):
print("\n==> " + message)
def run_git(*args):
"""Run a git command, returning the finished process"""
return subprocess.run(("git",) + args, capture_output=True, text=True)
def git(*args):
"""Run a git command that is expected to succeed, returning its output"""
result = run_git(*args)
if (result.returncode != 0):
fail("git " + " ".join(args) + " failed:\n" + result.stderr.strip())
return result.stdout.strip()
def check_version(version):
"""
Normalise a version and check it looks like a release tag
:param version: Version as typed on the command line, i.e. "1.1.3"
:type version: String
:return: The version with a leading "v"
:rtype: String
"""
if not version.startswith("v"):
version = "v" + version
# build.yml triggers on "v*", and version.py is derived with --match "v[0-9]*"
if not re.match(r"^v[0-9]+(\.[0-9]+)*$", version):
fail("'" + version + "' is not a release version. Expected something like v1.1.3.")
return version
def check_working_tree():
"""Refuse to tag a tree that doesn't match the commit we're tagging"""
step("Checking the working tree")
# Only tracked changes matter: those are the difference between the working
# tree and the commit we are about to tag
modified = git("status", "--porcelain", "--untracked-files=no")
if modified:
for line in modified.splitlines():
print(" " + line)
fail("Working tree has uncommitted changes. Commit or stash before releasing.")
# Untracked files aren't in the commit, so they don't block the release.
# Worth pointing out though, in case one was meant to be part of it
untracked = git("ls-files", "--others", "--exclude-standard").splitlines()
if untracked:
print("WARNING: untracked files, not part of this release:")
for line in untracked[:10]:
print(" " + line)
if (len(untracked) > 10):
# An un-ignored directory can run to thousands of files
print(" ... and " + str(len(untracked) - 10) + " more")
branch = git("rev-parse", "--abbrev-ref", "HEAD")
if (branch != "master"):
fail("On branch '" + branch + "'. Releases are tagged on master.")
print("Clean, on master.")
def check_tag_unused(version):
"""Refuse to reuse a version, locally or on origin"""
step("Checking " + version + " is unused")
if (run_git("rev-parse", "-q", "--verify", "refs/tags/" + version).returncode == 0):
fail("Tag " + version + " already exists locally. "
"Delete it with 'git tag -d " + version + "' if it was a mistake.")
if git("ls-remote", "--tags", "origin", "refs/tags/" + version):
fail("Tag " + version + " already exists on origin. Pick a new version.")
print(version + " is free.")
def check_origin():
"""
Compare the local branch with origin
:return: How many commits master is ahead of origin
:rtype: int
"""
step("Comparing with origin/master")
git("fetch", "origin", "master", "--quiet")
behind = int(git("rev-list", "--count", "HEAD..origin/master"))
if behind:
fail("Local master is " + str(behind) + " commit(s) behind origin/master. "
"Pull before releasing.")
# Unpushed commits are fine to tag, but master has to be pushed first or the
# release gets built from commits that aren't on the branch yet
ahead = int(git("rev-list", "--count", "origin/master..HEAD"))
if ahead:
print("WARNING: " + str(ahead) + " commit(s) not yet on origin/master. "
"Push master before the tag.")
else:
print("Up to date with origin/master.")
return ahead
def run_tests():
"""Run the test suite with the interpreter running this script"""
step("Running the test suite")
probe = subprocess.run([sys.executable, "-c", "import pikepdf, requests, docx2pdf"],
capture_output=True, text=True)
if (probe.returncode != 0):
message = ("Test dependencies are missing from " + sys.executable + ":\n"
" " + probe.stderr.strip().splitlines()[-1] + "\n\n"
"Install them with:\n"
" " + sys.executable + " -m pip install -r requirements.txt")
venv = os.path.join(".venv", "Scripts" if os.name == "nt" else "bin", "python")
if os.path.exists(venv + (".exe" if os.name == "nt" else "")):
message += "\n\nOr run this script with the venv you already have:\n " + venv + " release.py ..."
fail(message)
if (subprocess.run([sys.executable, "-m", "unittest", "test"]).returncode != 0):
fail("Tests failed. Fix them, or re-run with --skip-tests if you know better.")
print("Tests passed.")
def confirm_push(version, ahead):
"""
Last check before the irreversible part
:param version: Tag about to be pushed
:type version: String
:param ahead: How many commits master is ahead of origin
:type ahead: int
:return: Whether to go ahead
:rtype: boolean
"""
print("\nAbout to push:")
if ahead:
print(" master (" + str(ahead) + " commit(s))")
print(" " + version + " -- this starts the build")
try:
answer = input("\nPush now? [y/N] ").strip().lower()
except EOFError:
# No one there to ask, so don't assume yes
return False
return answer in ("y", "yes")
def push(version, ahead):
"""
Push master if it's ahead, then the tag. Pushing the tag starts the build.
:param version: Tag to push
:type version: String
:param ahead: How many commits master is ahead of origin
:type ahead: int
"""
step("Pushing")
# master first, or the release gets built from commits that aren't on it
if ahead:
result = run_git("push", "origin", "master")
if (result.returncode != 0):
fail("Couldn't push master:\n" + result.stderr.strip() + "\n\n"
"Nothing else was pushed. " + version + " is still only local -- "
"remove it with 'git tag -d " + version + "' if you need to start over.")
print("Pushed master.")
result = run_git("push", "origin", version)
if (result.returncode != 0):
fail("Couldn't push " + version + ":\n" + result.stderr.strip() + "\n\n"
"The tag is still local. Remove it with 'git tag -d " + version + "'.")
print("Pushed " + version + ".")
def main():
parser = argparse.ArgumentParser(
description="Check the repository over and tag a new dvcurator release.")
parser.add_argument("version", help="version to tag, e.g. v1.1.3")
parser.add_argument("--skip-tests", action="store_true",
help="don't run the test suite first")
parser.add_argument("--yes", "-y", action="store_true",
help="don't ask for confirmation before pushing")
parser.add_argument("--no-push", action="store_true",
help="create the tag but leave pushing to you")
args = parser.parse_args()
# Always work from the repository, not wherever the shell happens to be
os.chdir(os.path.dirname(os.path.abspath(__file__)))
version = check_version(args.version)
step("Releasing " + version)
check_working_tree()
check_tag_unused(version)
ahead = check_origin()
if args.skip_tests:
step("Skipping tests (--skip-tests)")
else:
run_tests()
step("Tagging")
git("tag", "-a", version, "-m", "dvcurator " + version)
print("Created " + version + " at " + git("rev-parse", "--short", "HEAD"))
if (args.no_push or not (args.yes or confirm_push(version, ahead))):
print("\nNothing pushed. To do it yourself:")
if ahead:
print(" git push origin master")
print(" git push origin " + version)
print("\nOr drop the tag again with: git tag -d " + version)
return
push(version, ahead)
print("\nThe build is running:")
print(" " + ACTIONS)
print("\nWhen it finishes it leaves a DRAFT release:")
print(" " + RELEASES)
print("\nPublish the draft. 'SU Lab Install.ps1' resolves /releases/latest, which")
print("ignores drafts, so an unpublished release quietly keeps handing out the")
print("previous version.")
if __name__ == "__main__":
main()