Add Drone Discord changelog pipeline and test notification
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-10-17 17:07:01 +02:00
parent 96f471ef4a
commit 8f2e1f210d
7 changed files with 405 additions and 4 deletions
+1
View File
@@ -8,6 +8,7 @@ from prefab_identity import check_prefabs
ROOT=Path(__file__).resolve().parents[1]
BASE='58D0FB3206B6F859'
def check():
subprocess.run([sys.executable,'-m','unittest','discover','-s',str(ROOT/'tests'),'-p','test_discord_changelog_ci.py'],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_terrain_controller_init.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_terrain_chat_retention.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_empty_server_restart.py')],check=True)
+136
View File
@@ -0,0 +1,136 @@
#!/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.
message = '**' + latest[0] + '**\n\n' + 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())