Skip to content

Commit 5d25773

Browse files
committed
fix(release): harden Discord announcement delivery
1 parent 6e06aaa commit 5d25773

5 files changed

Lines changed: 168 additions & 39 deletions

File tree

.github/workflows/ci.yml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,25 @@ jobs:
7676
shell: bash
7777
run: python3 scripts/check_wildcard_reexport_budget.py
7878

79-
- name: Test Discord release announcements
80-
run: python3 -m unittest -v scripts/test_post_discord_release.py
81-
8279
- name: Enforce no unused dependencies
8380
shell: bash
8481
run: |
8582
cargo install cargo-machete --locked
8683
cargo machete
8784
85+
release-automation:
86+
name: Release Automation
87+
runs-on: ubuntu-latest
88+
timeout-minutes: 5
89+
steps:
90+
- uses: actions/checkout@v4
91+
92+
- name: Test Discord release announcements
93+
run: python3 -m unittest -v scripts/test_post_discord_release.py
94+
95+
- name: Compile release automation scripts
96+
run: python3 -m py_compile scripts/post_discord_release.py scripts/test_post_discord_release.py
97+
8898
build:
8999
name: Build & Test (${{ matrix.os }})
90100
runs-on: ${{ matrix.os }}
Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
1-
name: Backfill Discord release announcement
1+
name: Announce release on Discord
22

33
on:
4+
# Covers releases published outside the tag-driven release workflow. Events
5+
# created by GITHUB_TOKEN are suppressed, so release.yml also dispatches this
6+
# workflow explicitly after it publishes a release.
7+
release:
8+
types: [published]
49
workflow_dispatch:
510
inputs:
611
tag:
712
description: Existing public release tag to announce
813
required: true
914
type: string
1015

16+
concurrency:
17+
group: discord-release-${{ inputs.tag || github.event.release.tag_name }}
18+
cancel-in-progress: false
19+
1120
permissions:
1221
contents: write
1322

@@ -16,9 +25,12 @@ jobs:
1625
runs-on: ubuntu-latest
1726
steps:
1827
- uses: actions/checkout@v4
28+
with:
29+
ref: ${{ github.event.repository.default_branch }}
1930

2031
- name: Post release notes to Discord if not already announced
2132
env:
2233
GH_TOKEN: ${{ github.token }}
2334
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
24-
run: python3 scripts/post_discord_release.py --tag "${{ inputs.tag }}"
35+
TAG: ${{ inputs.tag || github.event.release.tag_name }}
36+
run: python3 scripts/post_discord_release.py --tag "$TAG"

.github/workflows/release.yml

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,9 @@ jobs:
486486
if: ${{ always() && needs.create-release.result == 'success' }}
487487
runs-on: ubuntu-latest
488488
timeout-minutes: 15
489+
permissions:
490+
actions: write
491+
contents: write
489492
steps:
490493
- uses: actions/checkout@v4
491494
with:
@@ -659,13 +662,16 @@ jobs:
659662
echo "Release ${GITHUB_REF_NAME} is already public; leaving it public."
660663
fi
661664
662-
# Do this in the publishing workflow itself. Releases created with
663-
# GITHUB_TOKEN do not trigger a separate `release: published` workflow.
664-
- name: Announce release on Discord
665+
# Releases created with GITHUB_TOKEN do not trigger `release: published`,
666+
# but workflow_dispatch is allowed. Queue the dedicated, per-tag
667+
# announcement workflow without letting Discord block package publishing.
668+
- name: Queue Discord release announcement
669+
id: discord_announcement
670+
continue-on-error: true
665671
env:
672+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
666673
GH_TOKEN: ${{ github.token }}
667-
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
668-
run: python3 scripts/post_discord_release.py --tag "${GITHUB_REF_NAME}"
674+
run: gh workflow run discord-release.yml --ref "$DEFAULT_BRANCH" -f "tag=${GITHUB_REF_NAME}"
669675

670676
- name: Update Homebrew formula
671677
env:
@@ -865,3 +871,9 @@ jobs:
865871
--reason completed
866872
gh issue edit "$issue" --remove-label "$label" || true
867873
done
874+
875+
- name: Report Discord announcement queue failure
876+
if: always() && steps.discord_announcement.outcome == 'failure'
877+
run: |
878+
echo "The release was published, but its Discord announcement could not be queued." >&2
879+
exit 1

scripts/post_discord_release.py

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22
"""Post one GitHub release announcement to a Discord webhook.
33
44
The release workflow publishes releases with GitHub's built-in GITHUB_TOKEN.
5-
GitHub deliberately does not emit a second workflow run for most events created
6-
by that token, so a separate ``release: published`` workflow is not reliable.
7-
This script is called directly by the release workflow and by a manual backfill
8-
workflow.
5+
GitHub deliberately suppresses most follow-up events created by that token, so
6+
the publisher explicitly dispatches the same workflow used for external release
7+
events and manual backfills.
98
"""
109

1110
from __future__ import annotations
@@ -129,6 +128,8 @@ def mark_release_announced(
129128
) -> None:
130129
body = release.get("body") or ""
131130
marker = announcement_marker(tag)
131+
if marker in body:
132+
return
132133
updated_body = f"{body.rstrip()}\n\n{marker}\n"
133134
github_request(
134135
f"https://api.github.com/repos/{repository}/releases/{release['id']}",
@@ -151,6 +152,45 @@ def required(value: str | None, description: str) -> str:
151152
raise SystemExit(f"Missing {description}")
152153

153154

155+
def announce_release(
156+
*, repository: str, tag: str, token: str, webhook_url: str
157+
) -> str | None:
158+
release = fetch_release(repository=repository, tag=tag, token=token)
159+
if release.get("draft"):
160+
raise RuntimeError(f"Refusing to announce draft release {tag}")
161+
162+
body = release.get("body") or ""
163+
if already_announced(body, tag):
164+
print(f"Discord announcement for {tag} is already recorded; skipping")
165+
return None
166+
167+
content = format_message(
168+
tag=tag,
169+
name=release.get("name") or tag,
170+
body=body,
171+
url=release["html_url"],
172+
)
173+
message = post_to_discord(webhook_url=webhook_url, content=content)
174+
message_id = str(message["id"])
175+
print(f"Posted {tag} to Discord as message {message_id}")
176+
177+
# Re-fetch immediately before patching so an edit made while the Discord
178+
# request was in flight is not overwritten with the initially fetched body.
179+
latest_release = fetch_release(repository=repository, tag=tag, token=token)
180+
try:
181+
mark_release_announced(
182+
repository=repository, release=latest_release, tag=tag, token=token
183+
)
184+
except Exception as error: # noqa: BLE001 - best-effort after public side effect
185+
# A marker write failure must not turn a successful public post into a
186+
# failed workflow that an operator is likely to rerun and duplicate.
187+
print(
188+
f"::warning::Discord post succeeded, but the release marker failed: {error}",
189+
file=sys.stderr,
190+
)
191+
return message_id
192+
193+
154194
def main() -> int:
155195
args = parse_args()
156196
tag = required(args.tag, "release tag (--tag or GITHUB_REF_NAME)")
@@ -165,38 +205,20 @@ def main() -> int:
165205
)
166206

167207
try:
168-
release = fetch_release(repository=repository, tag=tag, token=token)
169-
body = release.get("body") or ""
170-
if already_announced(body, tag):
171-
print(f"Discord announcement for {tag} is already recorded; skipping")
172-
return 0
173-
174-
content = format_message(
208+
announce_release(
209+
repository=repository,
175210
tag=tag,
176-
name=release.get("name") or tag,
177-
body=body,
178-
url=release["html_url"],
211+
token=token,
212+
webhook_url=webhook_url,
179213
)
180-
message = post_to_discord(webhook_url=webhook_url, content=content)
181-
print(f"Posted {tag} to Discord as message {message['id']}")
182-
183-
# The marker makes workflow reruns and manual backfills idempotent. A
184-
# marker write failure must not turn a successful public post into a
185-
# failed workflow that an operator is likely to rerun and duplicate.
186-
try:
187-
mark_release_announced(
188-
repository=repository, release=release, tag=tag, token=token
189-
)
190-
except Exception as error: # noqa: BLE001 - best-effort after public side effect
191-
print(
192-
f"::warning::Discord post succeeded, but the release marker failed: {error}",
193-
file=sys.stderr,
194-
)
195214
return 0
196215
except urllib.error.HTTPError as error:
197216
detail = error.read().decode("utf-8", errors="replace")
198217
print(f"HTTP {error.code} while announcing {tag}: {detail}", file=sys.stderr)
199218
return 1
219+
except (RuntimeError, urllib.error.URLError) as error:
220+
print(f"Could not announce {tag}: {error}", file=sys.stderr)
221+
return 1
200222

201223

202224
if __name__ == "__main__":

scripts/test_post_discord_release.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import threading
1010
import unittest
1111
import urllib.error
12+
from unittest import mock
1213

1314

1415
SCRIPT = Path(__file__).with_name("post_discord_release.py")
@@ -118,6 +119,78 @@ def test_rejected_webhook_raises_http_error(self) -> None:
118119
thread.join()
119120
server.server_close()
120121

122+
def test_announce_skips_an_existing_marker(self) -> None:
123+
release = {
124+
"id": 52,
125+
"name": "v0.52.0",
126+
"body": "notes\n<!-- jcode-discord-announced:v0.52.0 -->",
127+
"html_url": "https://example.test/v0.52.0",
128+
"draft": False,
129+
}
130+
with (
131+
mock.patch.object(MODULE, "fetch_release", return_value=release),
132+
mock.patch.object(MODULE, "post_to_discord") as post,
133+
):
134+
result = MODULE.announce_release(
135+
repository="owner/repo",
136+
tag="v0.52.0",
137+
token="token",
138+
webhook_url="https://discord.test/webhook",
139+
)
140+
self.assertIsNone(result)
141+
post.assert_not_called()
142+
143+
def test_announce_rejects_draft_release(self) -> None:
144+
release = {
145+
"id": 53,
146+
"name": "v0.53.0",
147+
"body": "notes",
148+
"html_url": "https://example.test/v0.53.0",
149+
"draft": True,
150+
}
151+
with (
152+
mock.patch.object(MODULE, "fetch_release", return_value=release),
153+
mock.patch.object(MODULE, "post_to_discord") as post,
154+
self.assertRaisesRegex(RuntimeError, "draft release"),
155+
):
156+
MODULE.announce_release(
157+
repository="owner/repo",
158+
tag="v0.53.0",
159+
token="token",
160+
webhook_url="https://discord.test/webhook",
161+
)
162+
post.assert_not_called()
163+
164+
def test_announce_refetches_before_writing_marker(self) -> None:
165+
initial = {
166+
"id": 54,
167+
"name": "v0.54.0",
168+
"body": "initial notes",
169+
"html_url": "https://example.test/v0.54.0",
170+
"draft": False,
171+
}
172+
latest = {**initial, "body": "notes edited during post"}
173+
with (
174+
mock.patch.object(MODULE, "fetch_release", side_effect=[initial, latest]),
175+
mock.patch.object(
176+
MODULE, "post_to_discord", return_value={"id": "message-54"}
177+
),
178+
mock.patch.object(MODULE, "mark_release_announced") as mark,
179+
):
180+
result = MODULE.announce_release(
181+
repository="owner/repo",
182+
tag="v0.54.0",
183+
token="token",
184+
webhook_url="https://discord.test/webhook",
185+
)
186+
self.assertEqual(result, "message-54")
187+
mark.assert_called_once_with(
188+
repository="owner/repo",
189+
release=latest,
190+
tag="v0.54.0",
191+
token="token",
192+
)
193+
121194

122195
if __name__ == "__main__":
123196
unittest.main()

0 commit comments

Comments
 (0)