Add Drone Discord changelog pipeline and test notification
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user