86 lines
4.6 KiB
Python
86 lines
4.6 KiB
Python
"""Publish files as a release of the current tag on Gitea / Forgejo (CI; both have the same release API).
|
|
|
|
usage: python3 tools/ci_release.py [--notes NOTES.md] TAG FILE...
|
|
Creates the release for TAG (a prerelease if TAG contains '-', e.g. v1.2.0-rc1) unless it exists, then
|
|
attaches each FILE, replacing an asset of the same name (re-runs). The release notes are NOTES.md (if given;
|
|
{tag} and {repo_url} are filled in), then the commit built and each FILE's SHA-256. Environment: RELEASE_TOKEN (a token
|
|
allowed to write releases, i.e. with the write:repository scope; never printed), RELEASE_TOKEN_FALLBACK
|
|
(optional, e.g. the workflow's own token: used when RELEASE_TOKEN is refused with 401/403), GITHUB_SERVER_URL
|
|
and GITHUB_REPOSITORY (set by the runner), GITHUB_SHA (optional, named in the release notes).
|
|
"""
|
|
import hashlib, json, os, sys, urllib.error, urllib.parse, urllib.request, uuid
|
|
|
|
TOKENS = [(n, os.environ.get(n)) for n in ('RELEASE_TOKEN', 'RELEASE_TOKEN_FALLBACK') if os.environ.get(n)]
|
|
|
|
|
|
def api(method, path, body=None, ctype=None):
|
|
url = os.environ['GITHUB_SERVER_URL'].rstrip('/') + '/api/v1/repos/' + os.environ['GITHUB_REPOSITORY'] + path
|
|
if body is not None and ctype is None:
|
|
body, ctype = json.dumps(body).encode(), 'application/json'
|
|
while True:
|
|
headers = {'Authorization': 'token ' + TOKENS[0][1], 'Accept': 'application/json'}
|
|
if ctype:
|
|
headers['Content-Type'] = ctype
|
|
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=300) as r:
|
|
data = r.read()
|
|
return r.status, (json.loads(data) if data.strip() else None)
|
|
except urllib.error.HTTPError as e:
|
|
msg = e.read().decode('utf-8', 'replace')[:500]
|
|
if e.code in (401, 403) and len(TOKENS) > 1:
|
|
print('%s refused (HTTP %s: %s) - using %s' % (TOKENS[0][0], e.code, msg.strip(), TOKENS[1][0]))
|
|
TOKENS.pop(0)
|
|
continue
|
|
return e.code, msg
|
|
|
|
|
|
def main():
|
|
a = sys.argv[1:]
|
|
intro = ''
|
|
if '--notes' in a:
|
|
i = a.index('--notes')
|
|
intro = open(a[i + 1], encoding='utf-8').read().strip()
|
|
del a[i:i + 2]
|
|
if len(a) < 2:
|
|
sys.exit(__doc__)
|
|
tag, files = a[0], a[1:]
|
|
url = os.environ.get('GITHUB_SERVER_URL', '').rstrip('/') + '/' + os.environ.get('GITHUB_REPOSITORY', '')
|
|
intro = intro.replace('{tag}', tag).replace('{repo_url}', url)
|
|
if not TOKENS:
|
|
sys.exit('RELEASE_TOKEN is not set')
|
|
status, rel = api('GET', '/releases/tags/' + urllib.parse.quote(tag))
|
|
if status == 404:
|
|
notes = ([intro, ''] if intro else []) + ['Built by CI from %s.' % os.environ.get('GITHUB_SHA', tag), '']
|
|
for f in files:
|
|
if not f.endswith('.sha256'):
|
|
notes.append('SHA-256 of %s: `%s`' % (os.path.basename(f), hashlib.sha256(open(f, 'rb').read()).hexdigest()))
|
|
status, rel = api('POST', '/releases', {'tag_name': tag, 'name': tag, 'body': '\n'.join(notes),
|
|
'draft': False, 'prerelease': '-' in tag})
|
|
if status not in (200, 201):
|
|
sys.exit('creating release %s failed: HTTP %s %s' % (tag, status, rel))
|
|
print('created release', tag, rel.get('html_url', ''))
|
|
elif status != 200:
|
|
sys.exit('looking up release %s failed: HTTP %s %s' % (tag, status, rel))
|
|
else:
|
|
print('release', tag, 'exists:', rel.get('html_url', ''))
|
|
for f in files:
|
|
name = os.path.basename(f)
|
|
for old in rel.get('assets') or []:
|
|
if old['name'] == name:
|
|
status, msg = api('DELETE', '/releases/%d/assets/%d' % (rel['id'], old['id']))
|
|
if status not in (200, 204):
|
|
sys.exit('removing old %s failed: HTTP %s %s' % (name, status, msg))
|
|
boundary = uuid.uuid4().hex
|
|
body = (('--%s\r\nContent-Disposition: form-data; name="attachment"; filename="%s"\r\n'
|
|
'Content-Type: application/octet-stream\r\n\r\n' % (boundary, name)).encode()
|
|
+ open(f, 'rb').read() + ('\r\n--%s--\r\n' % boundary).encode())
|
|
status, msg = api('POST', '/releases/%d/assets?name=%s' % (rel['id'], urllib.parse.quote(name)), body,
|
|
'multipart/form-data; boundary=' + boundary)
|
|
if status not in (200, 201):
|
|
sys.exit('uploading %s failed: HTTP %s %s' % (name, status, msg))
|
|
print('attached', name, msg.get('browser_download_url', '') if isinstance(msg, dict) else '')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|