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
+28
View File
@@ -0,0 +1,28 @@
kind: pipeline
type: docker
name: discord-changelog
clone:
depth: 0
steps:
- name: test-changelog
image: python:3.13-alpine
commands:
- apk add --no-cache git
- python -m unittest discover -s tests -p test_discord_changelog_ci.py
- name: notify-discord
image: python:3.13-alpine
environment:
DISCORD_WEBHOOK_URL:
from_secret: discord_webhook_url
commands:
- apk add --no-cache git
- python tools/discord_changelog_ci.py
trigger:
branch:
- main
event:
- push
+18 -4
View File
@@ -1,7 +1,20 @@
# Push changelogs
The user authorized Discord changelogs after new changes are successfully pushed.
After each successful push to `origin/main` from this checkout:
Maintain a concise, player-facing newest entry in `CHANGELOG.md` for each update.
The `.drone.yml` pipeline can deliver it automatically for pushes to `main` from
any checkout. See `docs/discord-changelog-ci.md` for activation and secret setup.
On 2026-10-17 the user confirmed Drone is prepared with `discord_webhook_url`
and requested a test push. Drone now owns delivery; do not run the manual helper
for that push or subsequent pushes unless the user explicitly requests a fallback.
Once Drone activation and its `discord_webhook_url` secret are confirmed, let
Drone deliver the changelog and do not also run the manual helper. Check its
delivery result when available; do not claim delivery from Git push success alone.
Until Drone activation is confirmed, after each successful push to `origin/main`
from this checkout:
1. Prepare a concise, player-facing changelog covering the commits in that push.
Include only the changelog: describe fixes/features accurately, without server,
@@ -18,6 +31,7 @@ Never print it, commit it, or put it into changelogs or command-line arguments.
The helper verifies the remote commit, disables mentions, and records delivery in
`.local/discord-changelog-state.json`. Do not backfill old commits unless requested.
This is an assistant workflow, not a server-side Git hook. Notifications for
pushes from other checkouts require a Discord push webhook in Gitea repository
settings; that server-side automation has not been configured.
The manual helper is a fallback assistant workflow, not a Git hook. The Drone
pipeline replaces it after activation; do not configure a second Discord push
webhook alongside it. Inspect Discord before retrying an uncertain CI delivery
or rebuilding the same push, since builds do not share delivery state.
+9
View File
@@ -0,0 +1,9 @@
# Changelog
## 2026-10-17 — Discord changelog test
- Test notification for automatic mission changelogs.
## 2026-10-17 — Changelog notifications
- Added automatic Discord changelogs for new mission updates.
+70
View File
@@ -0,0 +1,70 @@
# Discord changelogs through Drone
`.drone.yml` runs a Docker pipeline for pushes to `main`. It tests the notifier,
then reads `CHANGELOG.md` from the pushed commit. Discord receives only the newest
entry's heading and body, with mentions disabled and no build, Git or hosting
status added.
## One-time setup
1. Connect Drone to Gitea and activate this repository in Drone. A Docker runner
must be available. Keep the configuration filename at `.drone.yml`.
2. In this repository's **Drone settings → Secrets**, add `discord_webhook_url`.
Use the existing webhook URL stored locally in `.local/discord-webhook.url`.
Do not put the URL in Git or enable the secret for pull requests.
3. Push these files to `main`, then verify the `notify-discord` step and its
corresponding Discord message. Creating the YAML alone does not activate Drone
or install its Gitea webhook.
4. Once Drone delivery is enabled, stop the manual post-push notifier. Both
delivery paths running for the same push would produce duplicate messages.
Drone's repository activation manages the Gitea event webhook. This notification
pipeline does not compile, publish or update the mission.
## Updating the changelog
Prepend a new level-two heading and player-facing bullets for each update:
```markdown
## 2026-10-18 — Checkpoint improvements
- Improved checkpoint cover and approach barriers.
- Added a small rest tent and inspection table.
```
Keep headings unique, newest first, with no empty placeholder section at the top.
Use a distinct title for multiple updates on the same date. Entries are plain
Markdown prose/lists; level-three headings are supported. Do not use level-two
headings inside code examples within an entry. Keep the formatted newest entry
within Discord's 2000-character limit; oversized entries fail without truncation.
The dated files in `publishing/` remain separate Workshop release notes.
The notifier compares `DRONE_COMMIT_BEFORE` with `DRONE_COMMIT_SHA`, covering the
whole push, even when a documentation commit follows the changelog commit.
Unchanged newest entries, historical edits and removing an entry to expose an
older one do not post. Editing the current newest entry posts its revised text.
If a push adds several entries, only the newest is posted. Combine their notes
in one top entry when all changes should appear in the announcement.
The first push introducing `CHANGELOG.md` posts its newest entry. It does not
backfill older release notes. Missing Git history fails instead of guessing a
previous revision; an exact-SHA fetch is attempted for a missing before commit.
## Verification and delivery failures
Run `python3 -m unittest discover -s tests -p test_discord_changelog_ci.py` locally.
The tests use temporary Git repositories and mocked HTTP; they send no messages.
`python3 tools/discord_changelog_ci.py --dry-run` inside a Drone checkout with its
normal commit environment prints the selected message without using the secret.
Delivery uses `wait=true` and requires Discord's message confirmation. Errors
fail the step without printing the webhook URL. There are no automatic HTTP
retries. Inspect Discord before manually rebuilding a delivery step: a timed-out
request may already have arrived. Drone workspaces are ephemeral, so rerunning
the same push is not deduplicated across builds; exactly-once delivery is not
claimed.
References: [Drone push triggers](https://docs.drone.io/pipeline/docker/syntax/trigger/),
[repository secrets](https://docs.drone.io/secret/repository/),
[push before commit](https://docs.drone.io/pipeline/environment/reference/drone-commit-before/),
[Discord webhooks](https://docs.discord.com/developers/resources/webhook#execute-webhook).
+143
View File
@@ -0,0 +1,143 @@
"""Exercise changelog selection against real push ranges and mock Discord delivery."""
import contextlib
import importlib.util
import io
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
import urllib.error
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('discord_changelog_ci', ROOT / 'tools/discord_changelog_ci.py')
ci = importlib.util.module_from_spec(spec)
spec.loader.exec_module(ci)
OLD = '# Changelog\n\n## Older update\n\n- Older fix.\n'
NEW = '# Changelog\n\n## Latest update\n\n- New fix.\n\n### Gameplay\n\n- New feature.\n\n## Older update\n\n- Older fix.\n'
URL = 'https://discord.com/api/webhooks/123/test-token'
class SelectionTests(unittest.TestCase):
def test_only_latest_section(self):
message = ci.latest_changes(OLD, NEW)
self.assertIn('New feature.', message)
self.assertIn('### Gameplay', message)
self.assertNotIn('Older fix.', message)
self.assertEqual(message, ci.latest_changes('', NEW.replace('\n', '\r\n')))
def test_skip_unchanged_or_historical_edits(self):
self.assertIsNone(ci.latest_changes(NEW, NEW))
self.assertIsNone(ci.latest_changes(NEW, NEW.replace('Older fix.', 'Corrected old note.')))
self.assertIsNone(ci.latest_changes(NEW, OLD))
def test_edit_current_entry(self):
self.assertIn('Corrected feature.', ci.latest_changes(NEW, NEW.replace('New feature.', 'Corrected feature.')))
def test_invalid_entries(self):
for text in ('', '# Changelog', '## Empty\n', '## A\nOne\n## A\nTwo', '## Huge\n' + 'x' * 2000,
'## Emoji\n' + '\U0001f680' * 1000):
with self.subTest(text=text[:25]), self.assertRaises(ci.ChangelogError):
ci.latest_changes('', text)
def test_exact_message_limit(self):
text = '## A\n' + 'x' * 1993
self.assertEqual(len(ci.latest_changes('', text)), 2000)
class PushTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.repo = Path(self.temp.name)
self.git('init', '-q')
self.git('config', 'user.name', 'Changelog test')
self.git('config', 'user.email', 'test@example.invalid')
self.first = self.save('README.txt', 'Initial checkout')
def git(self, *args):
return subprocess.check_output(['git', *args], cwd=self.repo, text=True, stderr=subprocess.PIPE).strip()
def save(self, name, text):
(self.repo / name).write_text(text)
self.git('add', name)
self.git('commit', '-qm', 'Fixture update')
return self.git('rev-parse', 'HEAD')
def env(self, before, after):
return dict(DRONE_BUILD_EVENT='push', DRONE_COMMIT_BRANCH='main',
DRONE_COMMIT_BEFORE=before, DRONE_COMMIT_SHA=after)
def test_first_changelog_and_new_branch(self):
after = self.save('CHANGELOG.md', NEW)
self.assertEqual(ci.prepare(self.repo, self.env(self.first, after)), ci.latest_changes('', NEW))
self.assertEqual(ci.prepare(self.repo, self.env('0' * 40, after)), ci.latest_changes('', NEW))
def test_whole_push_not_only_last_commit(self):
before = self.save('CHANGELOG.md', OLD)
self.save('CHANGELOG.md', NEW)
after = self.save('README.txt', 'Documentation follows changelog in same push')
self.assertEqual(ci.prepare(self.repo, self.env(before, after)), ci.latest_changes(OLD, NEW))
next_push = self.save('README.txt', 'Unrelated next push')
self.assertIsNone(ci.prepare(self.repo, self.env(after, next_push)))
def test_checkout_mismatch_and_missing_history(self):
after = self.save('CHANGELOG.md', NEW)
for before, tip in ((self.first, self.first), ('f' * 40, after), ('', after), ('--help', after)):
with self.subTest(before=before, tip=tip), self.assertRaises(ci.ChangelogError):
ci.prepare(self.repo, self.env(before, tip))
def test_other_events_skip_before_reading_git_or_secret(self):
for event, branch in (('pull_request', 'main'), ('tag', 'main'), ('push', 'feature')):
self.assertIsNone(ci.prepare(Path('/does-not-exist'), dict(DRONE_BUILD_EVENT=event, DRONE_COMMIT_BRANCH=branch)))
class DeliveryTests(unittest.TestCase):
@patch.object(ci.urllib.request, 'build_opener')
def test_confirmed_payload_mentions_disabled(self, opener):
opener.return_value.open.return_value.__enter__.return_value = io.BytesIO(b'{"id":"456"}')
ci.send('- Fixed @everyone notification.', URL)
request = opener.return_value.open.call_args.args[0]
self.assertEqual(request.full_url, URL + '?wait=true')
self.assertEqual(json.loads(request.data), {
'content': '- Fixed @everyone notification.', 'allowed_mentions': {'parse': []}})
self.assertEqual(opener.return_value.open.call_args.kwargs['timeout'], 30)
@patch.object(ci.urllib.request, 'build_opener')
def test_failures_never_retry_or_leak_url(self, opener):
errors = [urllib.error.HTTPError(URL, 429, 'limited', {}, None),
urllib.error.URLError(URL), TimeoutError(URL)]
for error in errors:
opener.return_value.open.reset_mock()
opener.return_value.open.side_effect = error
with self.assertRaises(ci.ChangelogError) as raised:
ci.send('- Fix.', URL)
self.assertNotIn('test-token', str(raised.exception))
self.assertEqual(opener.return_value.open.call_count, 1)
@patch.object(ci.urllib.request, 'build_opener')
def test_no_confirmation_is_failure(self, opener):
for response in (b'{}', b'[]', b'not json'):
opener.return_value.open.return_value.__enter__.return_value = io.BytesIO(response)
with self.assertRaises(ci.ChangelogError):
ci.send('- Fix.', URL)
def test_redirects_and_invalid_destinations(self):
self.assertIsNone(ci.NoRedirect().redirect_request(None, None, 302, '', {}, 'https://example.invalid'))
for url in ('', URL.replace('https:', 'http:'), URL.replace('discord.com', 'example.invalid'),
URL + '?wait=false', URL + '#fragment'):
with self.subTest(url=url), self.assertRaises(ci.ChangelogError):
ci.send('- Fix.', url)
@patch.object(ci, 'send')
@patch.object(ci, 'prepare', return_value='- Preview only.')
def test_dry_run_does_not_send(self, prepare, send):
with contextlib.redirect_stdout(io.StringIO()) as output:
self.assertEqual(ci.main(['--dry-run']), 0)
self.assertEqual(output.getvalue().strip(), '- Preview only.')
send.assert_not_called()
if __name__ == '__main__':
unittest.main()
+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())