Compare commits

...

1 Commits

Author SHA1 Message Date
Hyu 9b130680ca ci(discord): announce published stable releases via webhook (#2539)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-13 00:39:21 +08:00
4 changed files with 764 additions and 0 deletions
+111
View File
@@ -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.
+162
View File
@@ -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())
+427
View File
@@ -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',
'v.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()
+64
View File
@@ -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