Files
EscapistsReintroduced/tools/discord_changelog_ci.py
T

145 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())