Skip to content

Commit 2ae45bc

Browse files
committed
scripts: add script to create releases
The current release process is not documented in any way. As a result, it's not obvious how releases should be done at all, like e.g. which locations need adjusting. To fix this, let's introduce a new script that shall from now on be used to do all releases. As input it gets the tree that shall be released, the repository in which to do the release, credentials to authenticate against GitHub and the new version. E.g. executing the following will create a new release v0.32: $ ./script/release.py 0.32.0 --user pks-t --password **** While the password may currently be your usual GitLab password, it's recommended to use a personal access token intead. The script will then perform the following steps: 1. Verify that "include/git2/version.h" matches the new version. 2. Verify that "docs/changelog.md" has a section for that new version. 3. Extract the changelog entries for the current release from "docs/changelog.md". 4. Generate two archives in "tar.gz" and "zip" format via "git archive" from the tree passed by the user. If no tree was passed, we will use "HEAD". 5. Create the GitHub release using the extracted changelog entries as well as tag and name information derived from the version passed by the used. 6. Upload both code archives to that release. This should cover all steps required for a new release and thus ensures that nothing is missing that shouldn't be.
1 parent 1e556ee commit 2ae45bc

File tree

1 file changed

+171
-0
lines changed

1 file changed

+171
-0
lines changed

script/release.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
#!/usr/bin/env python
2+
3+
from collections import namedtuple
4+
5+
import argparse
6+
import base64
7+
import copy
8+
import json
9+
import subprocess
10+
import sys
11+
import urllib.parse
12+
import urllib.request
13+
import urllib.error
14+
15+
class Error(Exception):
16+
pass
17+
18+
class Version(object):
19+
def __init__(self, version):
20+
versions = version.split(sep='.')
21+
if len(versions) < 2 or len(versions) > 3:
22+
raise Error("Invalid version string '{}'".format(version))
23+
self.major = int(versions[0])
24+
self.minor = int(versions[1])
25+
self.revision = int(versions[2]) if len(versions) == 3 else 0
26+
27+
def __str__(self):
28+
return '{}.{}.{}'.format(self.major, self.minor, self.revision)
29+
30+
def __eq__(self, other):
31+
return self.major == other.major and self.minor == other.minor and self.revision == other.revision
32+
33+
def verify_version(version):
34+
expected = {
35+
'VERSION': [ '"{}"'.format(version), None ],
36+
'VER_MAJOR': [ str(version.major), None ],
37+
'VER_MINOR': [ str(version.minor), None ],
38+
'VER_REVISION': [ str(version.revision), None ],
39+
'VER_PATCH': [ '0', None ],
40+
'SOVERSION': [ '"{}.{}"'.format(version.major, version.minor), None ],
41+
}
42+
43+
with open('include/git2/version.h') as f:
44+
lines = f.readlines()
45+
46+
for key in expected.keys():
47+
define = '#define LIBGIT2_{} '.format(key)
48+
for line in lines:
49+
if line.startswith(define):
50+
expected[key][1] = line[len(define):].strip()
51+
break
52+
else:
53+
raise Error("version.h: missing define for '{}'".format(key))
54+
55+
for k, v in expected.items():
56+
if v[0] != v[1]:
57+
raise Error("version.h: define '{}' does not match (got '{}', expected '{}')".format(k, v[0], v[1]))
58+
59+
def generate_relnotes(tree, version):
60+
with open('docs/changelog.md') as f:
61+
lines = f.readlines()
62+
63+
if not lines[0].startswith('v'):
64+
raise Error("changelog.md: missing section for v{}".format(version))
65+
try:
66+
v = Version(lines[0][1:].strip())
67+
except:
68+
raise Error("changelog.md: invalid version string {}".format(lines[0].strip()))
69+
if v != version:
70+
raise Error("changelog.md: changelog version doesn't match (got {}, expected {})".format(v, version))
71+
if not lines[1].startswith('----'):
72+
raise Error("changelog.md: missing version header")
73+
if lines[2] != '\n':
74+
raise Error("changelog.md: missing newline after version header")
75+
76+
for i, line in enumerate(lines[3:]):
77+
if not line.startswith('v'):
78+
continue
79+
try:
80+
Version(line[1:].strip())
81+
break
82+
except:
83+
continue
84+
else:
85+
raise Error("changelog.md: cannot find section header of preceding release")
86+
87+
return ''.join(lines[3:i + 3]).strip()
88+
89+
def git(*args):
90+
process = subprocess.run([ 'git', *args ], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
91+
if process.returncode != 0:
92+
raise Error('Failed executing git {}: {}'.format(' '.join(args), process.stderr.decode()))
93+
return process.stdout
94+
95+
def post(url, data, contenttype, user, password):
96+
request = urllib.request.Request(url, data=data)
97+
request.add_header('Accept', 'application/json')
98+
request.add_header('Content-Type', contenttype)
99+
request.add_header('Content-Length', len(data))
100+
request.add_header('Authorization', 'Basic ' + base64.b64encode('{}:{}'.format(user, password).encode()).decode())
101+
102+
try:
103+
response = urllib.request.urlopen(request)
104+
if response.getcode() != 201:
105+
raise Error("POST to '{}' failed: {}".format(url, response.reason))
106+
except urllib.error.URLError as e:
107+
raise Error("POST to '{}' failed: {}".format(url, e))
108+
data = json.load(response)
109+
110+
return data
111+
112+
def generate_asset(version, tree, archive_format):
113+
Asset = namedtuple('Asset', ['name', 'label', 'mimetype', 'data'])
114+
mimetype = 'application/{}'.format('gzip' if archive_format == 'tar.gz' else 'zip')
115+
return Asset(
116+
"libgit2-{}.{}".format(version, archive_format), "Release sources: libgit2-{}.{}".format(version, archive_format), mimetype,
117+
git('archive', '--format', archive_format, '--prefix', 'libgit2-{}/'.format(version), tree)
118+
)
119+
120+
def release(args):
121+
params = {
122+
"tag_name": 'v' + str(args.version),
123+
"name": 'libgit2 v' + str(args.version),
124+
"target_commitish": git('rev-parse', args.tree).decode().strip(),
125+
"body": generate_relnotes(args.tree, args.version),
126+
}
127+
assets = [
128+
generate_asset(args.version, args.tree, 'tar.gz'),
129+
generate_asset(args.version, args.tree, 'zip'),
130+
]
131+
132+
if args.dryrun:
133+
for k, v in params.items():
134+
print('{}: {}'.format(k, v))
135+
for asset in assets:
136+
print('asset: name={}, label={}, mimetype={}, bytes={}'.format(asset.name, asset.label, asset.mimetype, len(asset.data)))
137+
return
138+
139+
try:
140+
url = 'https://api.github.com/repos/{}/releases'.format(args.repository)
141+
response = post(url, json.dumps(params).encode(), 'application/json', args.user, args.password)
142+
except Error as e:
143+
raise Error('Could not create release: ' + str(e))
144+
145+
for asset in assets:
146+
try:
147+
url = list(urllib.parse.urlparse(response['upload_url'].split('{?')[0]))
148+
url[4] = urllib.parse.urlencode({ 'name': asset.name, 'label': asset.label })
149+
post(urllib.parse.urlunparse(url), asset.data, asset.mimetype, args.user, args.password)
150+
except Error as e:
151+
raise Error('Could not upload asset: ' + str(e))
152+
153+
def main():
154+
parser = argparse.ArgumentParser(description='Create a libgit2 release')
155+
parser.add_argument('--tree', default='HEAD', help='tree to create release for (default: HEAD)')
156+
parser.add_argument('--dryrun', action='store_true', help='generate release, but do not post it')
157+
parser.add_argument('--repository', default='libgit2/libgit2', help='GitHub repository to create repository in')
158+
parser.add_argument('--user', help='user to authenitcate as')
159+
parser.add_argument('--password', help='password to authenticate with')
160+
parser.add_argument('version', type=Version, help='version of the new release')
161+
args = parser.parse_args()
162+
163+
verify_version(args.version)
164+
release(args)
165+
166+
if __name__ == '__main__':
167+
try:
168+
main()
169+
except Error as e:
170+
print(e)
171+
sys.exit(1)

0 commit comments

Comments
 (0)