mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-12 21:07:13 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b130680ca |
@@ -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/<id>/<token>` — 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/<id>` 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.
|
||||
@@ -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/<id>/<token>.')
|
||||
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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user