#!/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())