From 8f2e1f210d6d2056fe852e65b583d47b0179c074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BD=C3=A1=C4=8Dek?= Date: Sat, 17 Oct 2026 17:07:01 +0200 Subject: [PATCH] Add Drone Discord changelog pipeline and test notification --- .drone.yml | 28 ++++++ AGENTS.md | 22 ++++- CHANGELOG.md | 9 ++ docs/discord-changelog-ci.md | 70 ++++++++++++++ tests/test_discord_changelog_ci.py | 143 +++++++++++++++++++++++++++++ tools/check.py | 1 + tools/discord_changelog_ci.py | 136 +++++++++++++++++++++++++++ 7 files changed, 405 insertions(+), 4 deletions(-) create mode 100644 .drone.yml create mode 100644 CHANGELOG.md create mode 100644 docs/discord-changelog-ci.md create mode 100644 tests/test_discord_changelog_ci.py create mode 100644 tools/discord_changelog_ci.py diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..ae24d04 --- /dev/null +++ b/.drone.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index e8a482a..9f9b903 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8d9a6c5 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/docs/discord-changelog-ci.md b/docs/discord-changelog-ci.md new file mode 100644 index 0000000..9136675 --- /dev/null +++ b/docs/discord-changelog-ci.md @@ -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). diff --git a/tests/test_discord_changelog_ci.py b/tests/test_discord_changelog_ci.py new file mode 100644 index 0000000..a155f96 --- /dev/null +++ b/tests/test_discord_changelog_ci.py @@ -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() diff --git a/tools/check.py b/tools/check.py index c3bce23..4d61f1e 100644 --- a/tools/check.py +++ b/tools/check.py @@ -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) diff --git a/tools/discord_changelog_ci.py b/tools/discord_changelog_ci.py new file mode 100644 index 0000000..d4867ec --- /dev/null +++ b/tools/discord_changelog_ci.py @@ -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())