diff --git a/.github/discord-release/README.md b/.github/discord-release/README.md new file mode 100644 index 000000000..21deee164 --- /dev/null +++ b/.github/discord-release/README.md @@ -0,0 +1,111 @@ +# Discord release announcements + +This independent workflow announces new stable LangBot releases in the channel +selected by a dedicated Discord incoming webhook. It does not change the existing +release/build workflows, edit releases, run a persistent service, poll, or backfill. +Announcements run on publication, independently of artifact builds finishing. + +## Setup and read-only validation + +1. In the intended community **announcement channel**, create a dedicated incoming + webhook (Channel Settings → Integrations → Webhooks). Copy its URL; do not reuse + a webhook belonging to another automation. +2. In `langbot-app/LangBot` → Settings → Secrets and variables → Actions, create the + **repository secret** `DISCORD_RELEASE_WEBHOOK_URL`. Its value must be exactly + `https://discord.com/api/webhooks//` — no query, trailing slash, + API-version segment, or alternate domain. Treat the entire URL as a password. +3. Once this workflow is on `master`, open Actions → **Discord Release Announcement** + → Run workflow, choosing `master`. Alternatively: + + ```sh + gh workflow run discord-release.yml --repo langbot-app/LangBot --ref master + ``` + +4. Inspect **Validate webhook (GET only, no message)**. It checks webhook type `1` + and reports `guild_id` and `channel_id`; compare both with the intended server + and channel using Discord Developer Mode → Copy ID. The secret determines the + destination; no channel ID is guessed or overridden. The URL/token is never + logged. Dispatch cannot send a test message or announce an old release, even + when run again. Missing/invalid secrets fail validation clearly; offline tests + do not need secrets. + +GET validation confirms the webhook's identity, not delivery or notification +permissions. Verify those on the first genuine release. `mention_everyone=true` +confirms Discord parsed the mention; it cannot prove every member received a push +notification (member/server notification settings still apply). + +## Activation and message + +The workflow and `.github/discord-release/` helper **must be in the commit targeted +by each new release tag**. Merging to `master` does not enable announcements for +old tags whose commits lack these files. Manual dispatch becomes available when +the workflow is on the default branch. Only publish release tags from trusted, +reviewed commits: release workflows execute that tag's code with the secret. + +Only `release` events with action `published`, `draft=false`, and +`prerelease=false` can send. Drafts and prereleases are skipped; release edits do +not trigger announcements. The helper requires the repository to be exactly +`langbot-app/LangBot`, a stable `vX.Y.Z` tag (ASCII digits, at most 64 characters), +and its exact canonical GitHub release URL. Other naming schemes fail closed. + +Example message (the version and URL come from the validated event file): + +```text +@everyone LangBot v4.10.11 is now available! +Release notes: https://github.com/langbot-app/LangBot/releases/tag/v4.10.11 +``` + +The release title/body is never copied. There is one literal `@everyone`, explicit +`allowed_mentions.parse=["everyone"]`, empty user/role allowlists, and no reply +mention. TTS and notification-suppressing flags are disabled. Requests use HTTPS +only to `discord.com`, an explicit User-Agent, and no redirects or automatic +retries. After a webhook identity GET, one `POST ?wait=true` obtains a message ID; +an exact `/messages/` GET verifies its ID, webhook/channel, content, +`mention_everyone=true`, and empty user/role mention arrays before success. + +## Repeat guard and manual recovery + +Production sending requires **`GITHUB_RUN_ATTEMPT == "1"`**. Any Actions rerun +(including “Re-run failed jobs”) refuses to POST and requires manual reconciliation, +even if the first attempt failed before sending. Read-only dispatch may be rerun. + +This is a practical repeat guard, **not durable exactly-once delivery**. It cannot +prevent duplicates from a separate new run/event (for example deleting/recreating +a release), separate automation, or manual posting. It stores no durable dedupe +state and never modifies the release to mark delivery. + +If a POST times out, returns an error, or readback fails, the message may already +exist. The workflow fails rather than blindly sending again. A returned message ID +is included in the safe error when available. A runner termination can also leave +an ambiguous send without that log line. + +1. Inspect the announcement channel and the failed run logs. Locate the canonical + release link and, if available, the returned message ID. A failed verification + does **not** mean the message was absent. +2. If present, reconcile the existing message/mention problem manually; do not + rerun, create another release event, or send a duplicate ping. +3. If an operator has positively confirmed no message exists, fix the secret or + permission issue and use read-only dispatch to validate configuration. A + maintainer may then post the announcement manually once in Discord and record + the message link in the incident/run notes. Do not override the attempt guard + or delete/recreate a release to force recovery. +4. If absence cannot be established, pause and reconcile rather than resending. + +To stop future sends, disable **Discord Release Announcement** in Actions. Rotate +or delete the dedicated Discord webhook if the URL is exposed, and update the +secret before validation. No rollback of release artifacts is involved. + +## Local checks + +Requires Python 3.11+ and the standard library only: + +```sh +python3 -m unittest discover -s .github/discord-release -p 'test_*.py' -v +python3 -m py_compile .github/discord-release/announce.py .github/discord-release/test_announce.py +``` + +Tests exercise policy, CLI/event-file handling, mention payloads, hostile inputs, +HTTP failures, exact message readback, and refusal to retry. Only the HTTPS +transport is mocked for Discord tests; no live Discord requests or messages are +made. Changes to this directory or its workflow run the offline tests on push and +pull request; tests also gate release sending and read-only dispatch validation. diff --git a/.github/discord-release/announce.py b/.github/discord-release/announce.py new file mode 100644 index 000000000..c8548d380 --- /dev/null +++ b/.github/discord-release/announce.py @@ -0,0 +1,162 @@ +"""Announce only first-attempt stable releases; dispatch is read-only validation.""" + +import http.client +import json +import os +from pathlib import Path +import re +import sys + +REPOSITORY = 'langbot-app/LangBot' +RELEASE_PREFIX = f'https://github.com/{REPOSITORY}/releases/tag/' +RECONCILE = ( + 'Do not resend or bypass the run-attempt guard; manual reconciliation is required. ' + 'Inspect the announcement channel and workflow logs before any manual recovery ' + '(see .github/discord-release/README.md).' +) + + +class AnnouncementError(Exception): + """A safe, operator-facing error containing no webhook URL or response body.""" + + +def release_payload(event, attempt): + """Return a bounded, mention-safe payload, or None for draft/preview releases.""" + if not isinstance(event, dict) or event.get('action') != 'published': + raise AnnouncementError('Only release.published events are accepted.') + repository = event.get('repository') + if not isinstance(repository, dict) or repository.get('full_name') != REPOSITORY: + raise AnnouncementError('Unexpected release repository.') + release = event.get('release') + if not isinstance(release, dict) or any(type(release.get(key)) is not bool for key in ('draft', 'prerelease')): + raise AnnouncementError('Invalid release flags.') + if release['draft'] or release['prerelease']: + return None + if attempt != '1': + raise AnnouncementError(f'Release reruns or missing run attempts are refused. {RECONCILE}') + tag = release.get('tag_name') + if not isinstance(tag, str) or len(tag) > 64 or not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', tag): + raise AnnouncementError('Expected a stable release tag in vX.Y.Z format (at most 64 characters).') + url = RELEASE_PREFIX + tag + if release.get('html_url') != url: + raise AnnouncementError('Release URL must be the canonical LangBot release URL matching its tag.') + return { + 'content': f'@everyone LangBot {tag} is now available!\nRelease notes: {url}', + 'allowed_mentions': {'parse': ['everyone'], 'users': [], 'roles': [], 'replied_user': False}, + 'tts': False, + 'flags': 0, + } + + +def is_snowflake(value): + return isinstance(value, str) and re.fullmatch(r'[0-9]{1,20}', value) is not None + + +class DiscordWebhook: + def __init__(self, url): + if not url: + raise AnnouncementError('DISCORD_RELEASE_WEBHOOK_URL is missing. Set the repository Actions secret.') + match = re.fullmatch(r'https://discord\.com(/api/webhooks/([0-9]{1,20})/[A-Za-z0-9_-]+)', url) + if not match: + raise AnnouncementError('Invalid webhook URL; expected https://discord.com/api/webhooks//.') + self.path, self.id = match.groups() + + def _request(self, method, suffix='', payload=None): + # Direct HTTPS, default certificate verification, no proxies or redirect/retry machinery. + connection = http.client.HTTPSConnection('discord.com', timeout=20) + try: + body = json.dumps(payload).encode('utf-8') if payload is not None else None + connection.request( + method, + self.path + suffix, + body=body, + headers={'Content-Type': 'application/json', 'User-Agent': 'LangBot-Release-Announcements/1.0'}, + ) + response = connection.getresponse() + if response.status != 200: + raise AnnouncementError(f'Discord {method} returned HTTP {response.status}; no retry was attempted.') + raw = response.read(1_048_577) + if len(raw) > 1_048_576: + raise AnnouncementError('Discord response exceeded the size limit.') + return json.loads(raw) + except (OSError, http.client.HTTPException, ValueError, UnicodeError): + # Exceptions and bodies can contain the token; never print them or chain them. + raise AnnouncementError( + f'Discord {method} failed or returned invalid JSON; no retry was attempted.' + ) from None + finally: + connection.close() + + def validate(self): + """GET only: verify an incoming webhook and return safe identifying fields.""" + webhook = self._request('GET') + if ( + not isinstance(webhook, dict) + or type(webhook.get('type')) is not int + or webhook['type'] != 1 + or webhook.get('id') != self.id + or not is_snowflake(webhook.get('guild_id')) + or not is_snowflake(webhook.get('channel_id')) + ): + raise AnnouncementError('Expected an incoming (type 1) webhook with matching ID and guild/channel IDs.') + return {key: webhook[key] for key in ('id', 'type', 'guild_id', 'channel_id')} + + def send(self, payload): + """One POST, followed by exact message GET; never automatically retry a send.""" + webhook = self.validate() + message_id = None + try: + sent = self._request('POST', '?wait=true', payload) + if not isinstance(sent, dict) or not is_snowflake(sent.get('id')): + raise AnnouncementError('Discord did not return a valid message ID.') + message_id = sent['id'] + saved = self._request('GET', f'/messages/{message_id}') + if ( + not isinstance(saved, dict) + or saved.get('id') != message_id + or saved.get('webhook_id') != self.id + or saved.get('channel_id') != webhook['channel_id'] + or saved.get('content') != payload['content'] + or saved.get('mention_everyone') is not True + or saved.get('mentions') != [] + or saved.get('mention_roles') != [] + ): + raise AnnouncementError('Discord message readback did not match content, identity, or mentions.') + except AnnouncementError as error: + reference = f' Returned message ID: {message_id}.' if message_id else '' + raise AnnouncementError(f'Delivery not confirmed. {error}{reference} {RECONCILE}') from None + return message_id + + +def main(env=None): + env = os.environ if env is None else env + try: + if env.get('GITHUB_REPOSITORY') != REPOSITORY: + raise AnnouncementError('This workflow is restricted to langbot-app/LangBot.') + name = env.get('GITHUB_EVENT_NAME') + if name == 'workflow_dispatch': + webhook = DiscordWebhook(env.get('DISCORD_RELEASE_WEBHOOK_URL')).validate() + print( + f'Validated incoming webhook: guild_id={webhook["guild_id"]} channel_id={webhook["channel_id"]}. No message sent.' + ) + return 0 + if name != 'release': + raise AnnouncementError('Only release and workflow_dispatch events are accepted by this helper.') + try: + event = json.loads(Path(env.get('GITHUB_EVENT_PATH', '')).read_text(encoding='utf-8')) + except (OSError, ValueError, UnicodeError): + raise AnnouncementError('Cannot read a valid JSON release event from GITHUB_EVENT_PATH.') from None + payload = release_payload(event, env.get('GITHUB_RUN_ATTEMPT')) + if payload is None: + print('Skipped draft or prerelease; no message sent.') + return 0 + message_id = DiscordWebhook(env.get('DISCORD_RELEASE_WEBHOOK_URL')).send(payload) + print(f'Announcement verified by exact message readback: message_id={message_id}.') + return 0 + except AnnouncementError as error: + print(f'Error: {error}', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/discord-release/test_announce.py b/.github/discord-release/test_announce.py new file mode 100644 index 000000000..f77bae8b0 --- /dev/null +++ b/.github/discord-release/test_announce.py @@ -0,0 +1,427 @@ +"""Offline contract tests; no Discord credentials or network required.""" + +import contextlib +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +try: + import announce +except ModuleNotFoundError: + announce = None + +WEBHOOK = 'https://discord.com/api/webhooks/123456789012345678/fixture_token-ONLY' +WEBHOOK_ID = '123456789012345678' +GUILD_ID = '234567890123456789' +CHANNEL_ID = '345678901234567890' +MESSAGE_ID = '456789012345678901' +REPO = 'langbot-app/LangBot' +URL = f'https://github.com/{REPO}/releases/tag/v4.10.11' +CONTENT = f'@everyone LangBot v4.10.11 is now available!\nRelease notes: {URL}' + + +def event(): + return { + 'action': 'published', + 'repository': {'full_name': REPO}, + 'release': { + 'draft': False, + 'prerelease': False, + 'tag_name': 'v4.10.11', + 'html_url': URL, + 'name': 'Hostile @everyone <@123> $(touch /tmp/unsafe)', + 'body': '@everyone @here <@123> <@&456> `hostile`', + }, + } + + +def metadata(): + return {'id': WEBHOOK_ID, 'type': 1, 'guild_id': GUILD_ID, 'channel_id': CHANNEL_ID} + + +def message(): + return { + 'id': MESSAGE_ID, + 'webhook_id': WEBHOOK_ID, + 'channel_id': CHANNEL_ID, + 'content': CONTENT, + 'mention_everyone': True, + 'mentions': [], + 'mention_roles': [], + } + + +class BaseTest(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(announce, 'The release announcement helper must exist') + + +class PolicyTests(BaseTest): + def test_payload_has_one_literal_everyone_and_no_untrusted_body(self): + payload = announce.release_payload(event(), '1') + self.assertEqual(payload['content'], CONTENT) + self.assertEqual(json.dumps(payload).count('@everyone'), 1) + self.assertEqual( + payload['allowed_mentions'], + { + 'parse': ['everyone'], + 'users': [], + 'roles': [], + 'replied_user': False, + }, + ) + self.assertIs(payload['tts'], False) + self.assertEqual(payload['flags'], 0) + + def test_drafts_and_prereleases_are_skipped(self): + for flag in ('draft', 'prerelease'): + with self.subTest(flag=flag): + value = event() + value['release'][flag] = True + self.assertIsNone(announce.release_payload(value, '1')) + + def test_only_published_action_is_accepted(self): + for action in ('edited', 'created', 'released', 'deleted', '', None): + with self.subTest(action=action): + value = event() + value['action'] = action + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + + def test_reruns_and_missing_attempt_refuse_manual_reconciliation(self): + for attempt in ('2', '3', '', None, '01', '0', '1\n'): + with self.subTest(attempt=attempt): + with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'): + announce.release_payload(event(), attempt) + + def test_repository_must_match_exactly(self): + for repo in ('evil/LangBot', 'langbot-app/langbot', None): + value = event() + value['repository']['full_name'] = repo + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + + def test_hostile_and_noncanonical_tags_are_rejected(self): + for tag in ( + 'v1.2.3 @everyone', + 'v1.2.3\n', + 'v1.2.3/../../x', + 'v1.2.3?x=y', + '$(id)', + 'v1.2.3-rc.1', + 'v1.2.3', + 'v1.2.3%0a', + '<@123>', + 'v1.2.' + '3' * 100, + '', + None, + 123, + ): + with self.subTest(tag=tag): + value = event() + value['release']['tag_name'] = tag + value['release']['html_url'] = f'https://github.com/{REPO}/releases/tag/{tag}' + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + + def test_release_url_must_be_canonical_and_match_tag(self): + for url in ( + 'https://evil.example/tag/v4.10.11', + URL + '?x=y', + URL + '#anchor', + URL + '/', + URL.replace('v4.10.11', 'v4.10.12'), + URL.replace('github.com', 'github.com@evil.example'), + URL.replace('https:', 'http:'), + URL + '\n', + None, + ): + with self.subTest(url=url): + value = event() + value['release']['html_url'] = url + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + + def test_malformed_events_fail_closed(self): + for value in (None, [], {}, {'release': []}, {'repository': None}): + with self.subTest(value=value): + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + for flag in ('draft', 'prerelease'): + for bad in (None, 'false', 0, 1): + value = event() + value['release'][flag] = bad + with self.assertRaises(announce.AnnouncementError): + announce.release_payload(value, '1') + + +class DiscordTests(BaseTest): + def setUp(self): + super().setUp() + self.patch = patch('announce.http.client.HTTPSConnection') + self.connection_class = self.patch.start() + self.addCleanup(self.patch.stop) + self.connection = self.connection_class.return_value + + def respond(self, *values): + responses = [] + for value in values: + response = MagicMock() + response.status = 200 + response.read.return_value = json.dumps(value).encode() + responses.append(response) + self.connection.getresponse.side_effect = responses + + def methods(self): + return [call.args[0] for call in self.connection.request.call_args_list] + + def test_webhook_validation_is_get_only_and_reports_ids(self): + self.respond(metadata()) + result = announce.DiscordWebhook(WEBHOOK).validate() + self.assertEqual(result, metadata()) + self.assertEqual(self.methods(), ['GET']) + self.assertEqual( + self.connection.request.call_args.args[:2], ('GET', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY') + ) + self.connection_class.assert_called_with('discord.com', timeout=20) + self.connection.close.assert_called_once() + + def test_invalid_webhook_urls_are_rejected_before_network(self): + for url in ( + '', + None, + WEBHOOK + '/', + WEBHOOK + '?wait=true', + WEBHOOK + '#x', + WEBHOOK + '\n', + ' ' + WEBHOOK, + WEBHOOK.replace('https:', 'http:'), + WEBHOOK.replace('discord.com', 'discord.com.evil.example'), + WEBHOOK.replace('discord.com', 'discord.com@evil.example'), + WEBHOOK.replace('discord.com', 'discord.com:443'), + WEBHOOK.replace('/api/', '/api/v10/'), + WEBHOOK.replace(WEBHOOK_ID, 'abc'), + WEBHOOK + '/../../x', + WEBHOOK.replace('fixture_token-ONLY', 'a%2Fb'), + ): + with self.subTest(url=url): + with self.assertRaises(announce.AnnouncementError): + announce.DiscordWebhook(url) + self.connection_class.assert_not_called() + + def test_webhook_metadata_requires_incoming_type_and_ids(self): + invalid = [ + None, + [], + {}, + dict(metadata(), type=2), + dict(metadata(), type=True), + dict(metadata(), id='999'), + dict(metadata(), channel_id=None), + dict(metadata(), guild_id='::error::hostile'), + ] + for value in invalid: + with self.subTest(value=value): + self.respond(value) + with self.assertRaises(announce.AnnouncementError): + announce.DiscordWebhook(WEBHOOK).validate() + self.assertNotIn('POST', self.methods()) + + def test_send_waits_and_reads_back_exact_returned_message(self): + self.respond(metadata(), message(), message()) + result = announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + self.assertEqual(result, MESSAGE_ID) + self.assertEqual(self.methods(), ['GET', 'POST', 'GET']) + calls = self.connection.request.call_args_list + self.assertEqual(calls[1].args[:2], ('POST', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY?wait=true')) + self.assertEqual(json.loads(calls[1].kwargs['body']), announce.release_payload(event(), '1')) + self.assertEqual( + calls[2].args[:2], ('GET', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY/messages/{MESSAGE_ID}') + ) + + def test_readback_must_match_content_mentions_and_identity(self): + for field, bad in ( + ('content', 'wrong'), + ('mention_everyone', False), + ('mention_everyone', 1), + ('mentions', [{'id': '123'}]), + ('mention_roles', ['123']), + ('id', '999'), + ('channel_id', '999'), + ('webhook_id', '999'), + ): + with self.subTest(field=field, bad=bad): + self.connection.reset_mock() + self.respond(metadata(), message(), dict(message(), **{field: bad})) + with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'): + announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + self.assertEqual(self.methods().count('POST'), 1) + + def test_missing_readback_fields_fail_closed(self): + for field in message(): + value = message() + del value[field] + self.respond(metadata(), message(), value) + with self.assertRaises(announce.AnnouncementError): + announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + + def test_unsafe_post_message_id_never_becomes_get_path(self): + for value in (None, {}, dict(message(), id='../evil'), dict(message(), id='123?x=y')): + self.connection.reset_mock() + self.respond(metadata(), value) + with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'): + announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + self.assertEqual(self.methods(), ['GET', 'POST']) + + def test_post_failure_never_retries_and_never_logs_secret(self): + for status in (301, 302, 307, 308, 400, 401, 403, 429, 500, 204): + with self.subTest(status=status): + self.connection.reset_mock() + self.respond(metadata(), message()) + responses = list(self.connection.getresponse.side_effect) + responses[1].status = status + self.connection.getresponse.side_effect = responses + with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation') as caught: + announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + self.assertNotIn('fixture_token', str(caught.exception)) + self.assertEqual(self.methods(), ['GET', 'POST']) + + def test_ambiguous_timeout_never_retries_or_echoes_exception(self): + self.respond(metadata()) + first = next(self.connection.getresponse.side_effect) + self.connection.getresponse.side_effect = [first, TimeoutError(WEBHOOK)] + with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation') as caught: + announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1')) + self.assertNotIn('fixture_token', str(caught.exception)) + self.assertEqual(self.methods(), ['GET', 'POST']) + + def test_malformed_json_response_is_sanitized(self): + self.respond(metadata()) + response = next(self.connection.getresponse.side_effect) + response.read.return_value = WEBHOOK.encode() + self.connection.getresponse.side_effect = [response] + with self.assertRaises(announce.AnnouncementError) as caught: + announce.DiscordWebhook(WEBHOOK).validate() + self.assertNotIn('fixture_token', str(caught.exception)) + + def test_get_redirect_is_not_followed(self): + self.respond(metadata()) + response = next(self.connection.getresponse.side_effect) + response.status = 302 + response.getheader.return_value = 'https://evil.example/' + self.connection.getresponse.side_effect = [response] + with self.assertRaises(announce.AnnouncementError): + announce.DiscordWebhook(WEBHOOK).validate() + self.assertEqual(self.methods(), ['GET']) + + +class EntrypointTests(BaseTest): + def run_main(self, data=None, **overrides): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'event.json' + path.write_text(json.dumps(event() if data is None else data)) + env = { + 'GITHUB_EVENT_NAME': 'release', + 'GITHUB_EVENT_PATH': str(path), + 'GITHUB_REPOSITORY': REPO, + 'GITHUB_RUN_ATTEMPT': '1', + 'DISCORD_RELEASE_WEBHOOK_URL': WEBHOOK, + } + env.update(overrides) + output = io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): + result = announce.main(env) + return result, output.getvalue() + + def test_dispatch_only_validates_even_if_event_contains_release(self): + with patch('announce.DiscordWebhook') as client: + client.return_value.validate.return_value = metadata() + result, output = self.run_main(GITHUB_EVENT_NAME='workflow_dispatch') + self.assertEqual(result, 0) + client.return_value.validate.assert_called_once() + client.return_value.send.assert_not_called() + self.assertIn(GUILD_ID, output) + self.assertIn(CHANNEL_ID, output) + self.assertNotIn('fixture_token', output) + + def test_production_release_sends_once(self): + with patch('announce.DiscordWebhook') as client: + client.return_value.send.return_value = MESSAGE_ID + result, output = self.run_main() + self.assertEqual(result, 0) + client.return_value.send.assert_called_once_with(announce.release_payload(event(), '1')) + self.assertIn(MESSAGE_ID, output) + + def test_skipped_releases_need_no_secret_or_network(self): + for flag in ('draft', 'prerelease'): + value = event() + value['release'][flag] = True + with patch('announce.DiscordWebhook') as client: + result, _ = self.run_main(value, DISCORD_RELEASE_WEBHOOK_URL='') + self.assertEqual(result, 0) + client.assert_not_called() + + def test_rerun_never_constructs_client(self): + with patch('announce.DiscordWebhook') as client: + result, output = self.run_main(GITHUB_RUN_ATTEMPT='2') + self.assertEqual(result, 1) + self.assertIn('manual reconciliation', output) + client.assert_not_called() + + def test_unexpected_event_or_repository_cannot_send(self): + for overrides in ( + {'GITHUB_EVENT_NAME': 'push'}, + {'GITHUB_EVENT_NAME': 'pull_request'}, + {'GITHUB_REPOSITORY': 'evil/LangBot'}, + ): + with patch('announce.DiscordWebhook') as client: + result, _ = self.run_main(**overrides) + self.assertEqual(result, 1) + client.assert_not_called() + + def test_missing_secret_fails_clearly_for_send_and_validation(self): + for name in ('release', 'workflow_dispatch'): + result, output = self.run_main(GITHUB_EVENT_NAME=name, DISCORD_RELEASE_WEBHOOK_URL='') + self.assertEqual(result, 1) + self.assertIn('DISCORD_RELEASE_WEBHOOK_URL is missing', output) + + def test_cli_reads_event_file_and_redacts_invalid_input(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'event.json' + value = event() + value['release']['tag_name'] = '::error::hostile @everyone' + path.write_text(json.dumps(value)) + env = dict( + os.environ, + GITHUB_EVENT_NAME='release', + GITHUB_EVENT_PATH=str(path), + GITHUB_REPOSITORY=REPO, + GITHUB_RUN_ATTEMPT='1', + DISCORD_RELEASE_WEBHOOK_URL=WEBHOOK, + ) + result = subprocess.run( + [sys.executable, str(Path(__file__).with_name('announce.py'))], + env=env, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 1) + self.assertNotIn('hostile', result.stderr) + self.assertNotIn('fixture_token', result.stderr) + self.assertNotIn('Traceback', result.stderr) + + def test_unreadable_event_fails_safely(self): + result, output = self.run_main(GITHUB_EVENT_PATH='/nonexistent/event.json') + self.assertEqual(result, 1) + self.assertNotIn('Traceback', output) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/discord-release.yml b/.github/workflows/discord-release.yml new file mode 100644 index 000000000..23e5d8033 --- /dev/null +++ b/.github/workflows/discord-release.yml @@ -0,0 +1,64 @@ +name: Discord Release Announcement + +on: + release: + types: [published] + workflow_dispatch: + push: + paths: + - '.github/workflows/discord-release.yml' + - '.github/discord-release/**' + pull_request: + paths: + - '.github/workflows/discord-release.yml' + - '.github/discord-release/**' + +permissions: + contents: read + +jobs: + tests: + name: Offline announcement tests + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Test helper without secrets or network + run: python3 -m unittest discover -s .github/discord-release -p 'test_*.py' -v + + validate: + name: Validate webhook (GET only, no message) + if: github.repository == 'langbot-app/LangBot' && github.event_name == 'workflow_dispatch' + needs: tests + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Validate incoming webhook and report guild/channel IDs + env: + DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} + run: python3 .github/discord-release/announce.py + + announce: + name: Announce published stable release + if: >- + github.repository == 'langbot-app/LangBot' && + github.event_name == 'release' && github.event.action == 'published' && + github.event.release.draft == false && github.event.release.prerelease == false + needs: tests + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + # The helper refuses GITHUB_RUN_ATTEMPT != 1 with recovery guidance. + # Never interpolate release data into a shell command. + - name: Send once and verify the exact Discord message + env: + DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} + run: python3 .github/discord-release/announce.py