145 lines
5.9 KiB
Python
145 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
||
"""Send the newest changed CHANGELOG.md entry for a Drone main-branch push."""
|
||
import argparse
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
CHANGELOG = 'CHANGELOG.md'
|
||
|
||
|
||
class ChangelogError(Exception):
|
||
pass
|
||
|
||
|
||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||
return None
|
||
|
||
|
||
def sections(text):
|
||
"""Entries start at level-two headings; level-three headings stay inside."""
|
||
headings = list(re.finditer(r'^## ([^\r\n]+)\r?$', text, re.MULTILINE))
|
||
result = []
|
||
for index, heading in enumerate(headings):
|
||
end = headings[index + 1].start() if index + 1 < len(headings) else len(text)
|
||
title = heading[1].strip()
|
||
content = text[heading.end():end].strip().replace('\r\n', '\n')
|
||
if title in [item[0] for item in result]:
|
||
raise ChangelogError('Changelog entry headings must be unique.')
|
||
result.append((title, content))
|
||
return result
|
||
|
||
|
||
def latest_changes(before, after):
|
||
old, new = sections(before), sections(after)
|
||
if not new or not new[0][1]:
|
||
raise ChangelogError('CHANGELOG.md needs a nonempty newest ## entry.')
|
||
latest = new[0]
|
||
if old and (latest == old[0] or latest[0] in [title for title, _ in old[1:]]):
|
||
return None # Unchanged newest entry, or removal/reordering of old entries.
|
||
version_note = ''
|
||
preamble = after.split('\n## ', 1)[0]
|
||
versions = re.findall(r'^Latest published Workshop version: \*\*(\d+\.\d+\.\d+)\*\*\r?$',
|
||
preamble, re.MULTILINE)
|
||
if 'Latest published Workshop version:' in preamble:
|
||
if len(versions) != 1 or any(int(part) > 32000 for part in versions[0].split('.')):
|
||
raise ChangelogError('Invalid Workshop version record in CHANGELOG.md.')
|
||
version_note = 'Last published Workshop version: **' + versions[0] + '**\n\n'
|
||
message = '**' + latest[0] + '**\n\n' + version_note + latest[1]
|
||
if len(message.encode('utf-16-le')) // 2 > 2000:
|
||
raise ChangelogError('Newest changelog entry exceeds Discord’s 2000-character limit.')
|
||
return message
|
||
|
||
|
||
def git(repo, *args):
|
||
result = subprocess.run(['git', *args], cwd=repo, text=True,
|
||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||
if result.returncode:
|
||
raise ChangelogError('Required Git history is unavailable; no changelog sent.')
|
||
return result.stdout
|
||
|
||
|
||
def commit(value):
|
||
if not re.fullmatch(r'[0-9a-fA-F]{40}|[0-9a-fA-F]{64}', value or ''):
|
||
raise ChangelogError('Drone must provide complete before/after commit IDs.')
|
||
return value.lower()
|
||
|
||
|
||
def at_revision(repo, sha):
|
||
git(repo, 'cat-file', '-e', sha + '^{commit}')
|
||
if not git(repo, 'ls-tree', '--name-only', sha, '--', CHANGELOG).strip():
|
||
return ''
|
||
return git(repo, 'show', sha + ':' + CHANGELOG)
|
||
|
||
|
||
def prepare(repo, env):
|
||
if env.get('DRONE_BUILD_EVENT') != 'push' or env.get('DRONE_COMMIT_BRANCH') != 'main':
|
||
return None
|
||
before = commit(env.get('DRONE_COMMIT_BEFORE'))
|
||
after = commit(env.get('DRONE_COMMIT_SHA'))
|
||
if git(repo, 'rev-parse', 'HEAD').strip() != after:
|
||
raise ChangelogError('Checkout does not match the pushed commit; no changelog sent.')
|
||
previous = ''
|
||
if set(before) != {'0'}:
|
||
try:
|
||
git(repo, 'cat-file', '-e', before + '^{commit}')
|
||
except ChangelogError:
|
||
# A force-push may leave the before commit outside the cloned history.
|
||
git(repo, 'fetch', '--no-tags', 'origin', before)
|
||
previous = at_revision(repo, before)
|
||
return latest_changes(previous, at_revision(repo, after))
|
||
|
||
|
||
def send(message, webhook):
|
||
parsed = urllib.parse.urlsplit(webhook)
|
||
if (parsed.scheme != 'https' or parsed.netloc != 'discord.com'
|
||
or not re.fullmatch(r'/api/webhooks/[0-9]+/[A-Za-z0-9_-]+', parsed.path)
|
||
or parsed.query or parsed.fragment):
|
||
raise ChangelogError('Set the Drone secret discord_webhook_url to a Discord webhook URL.')
|
||
payload = json.dumps({'content': message, 'allowed_mentions': {'parse': []}}).encode()
|
||
request = urllib.request.Request(webhook + '?wait=true', data=payload, headers={
|
||
'Content-Type': 'application/json', 'User-Agent': 'EscapistsReintroduced-Changelog/1.0'})
|
||
# Never retry automatically: a lost response may still mean Discord accepted it.
|
||
try:
|
||
with urllib.request.build_opener(NoRedirect()).open(request, timeout=30) as response:
|
||
result = json.load(response)
|
||
if not isinstance(result, dict) or not result.get('id'):
|
||
raise ValueError('Missing confirmation')
|
||
except urllib.error.HTTPError as error:
|
||
raise ChangelogError('Discord returned HTTP ' + str(error.code)
|
||
+ '; inspect delivery before rerunning.') from None
|
||
except (urllib.error.URLError, OSError, ValueError):
|
||
raise ChangelogError('Discord delivery could not be confirmed; inspect Discord before rerunning.') from None
|
||
|
||
|
||
def main(argv=None):
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument('--dry-run', action='store_true', help='Print the selected entry without sending it')
|
||
args = parser.parse_args(argv)
|
||
try:
|
||
message = prepare(ROOT, os.environ)
|
||
if message is None:
|
||
print('No new changelog entry to send.')
|
||
elif args.dry_run:
|
||
print(message)
|
||
else:
|
||
send(message, os.environ.get('DISCORD_WEBHOOK_URL', '').strip())
|
||
print('Discord changelog delivered.')
|
||
except (ChangelogError, ValueError) as error:
|
||
# URL parsing errors may contain secrets; only our own messages are safe.
|
||
print(str(error) if isinstance(error, ChangelogError) else 'Invalid webhook configuration.', file=sys.stderr)
|
||
return 1
|
||
return 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|