Add Drone Discord changelog pipeline and test notification
This commit is contained in:
+28
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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()
|
||||
@@ -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