Skip to content

Commit 492f88b

Browse files
committed
Merge remote-tracking branch 'origin/master'
2 parents e12e786 + 5d25773 commit 492f88b

5 files changed

Lines changed: 477 additions & 37 deletions

File tree

.github/workflows/ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,19 @@ jobs:
8282
cargo install cargo-machete --locked
8383
cargo machete
8484
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+
8598
build:
8699
name: Build & Test (${{ matrix.os }})
87100
runs-on: ${{ matrix.os }}
Lines changed: 23 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,36 @@
11
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.
47
release:
58
types: [published]
9+
workflow_dispatch:
10+
inputs:
11+
tag:
12+
description: Existing public release tag to announce
13+
required: true
14+
type: string
15+
16+
concurrency:
17+
group: discord-release-${{ inputs.tag || github.event.release.tag_name }}
18+
cancel-in-progress: false
619

720
permissions:
8-
contents: read
21+
contents: write
922

1023
jobs:
1124
announce:
1225
runs-on: ubuntu-latest
1326
steps:
14-
- name: Post release notes to Discord
15-
env:
16-
WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
17-
TAG: ${{ github.event.release.tag_name }}
18-
NAME: ${{ github.event.release.name }}
19-
BODY: ${{ github.event.release.body }}
20-
URL: ${{ github.event.release.html_url }}
21-
run: |
22-
python3 - <<'EOF'
23-
import json, os, re, urllib.request
24-
25-
tag = os.environ["TAG"]
26-
name = os.environ.get("NAME") or tag
27-
body = os.environ.get("BODY") or ""
28-
url = os.environ["URL"]
27+
- uses: actions/checkout@v4
28+
with:
29+
ref: ${{ github.event.repository.default_branch }}
2930

30-
# Strip platform-availability boilerplate and collapse blank lines
31-
body = re.sub(r"<!-- jcode-platform-availability:start -->.*", "", body, flags=re.S)
32-
body = re.sub(r"\n{3,}", "\n\n", body).strip()
33-
34-
title = f"## {tag}" + (f" \u2014 {name}" if name != tag else "")
35-
msg = f"{title}\n{body}"
36-
suffix = f"\n\u2026 (full notes: <{url}>)"
37-
if len(msg) > 1995:
38-
msg = msg[:1995 - len(suffix)] + suffix
39-
40-
req = urllib.request.Request(
41-
os.environ["WEBHOOK_URL"],
42-
data=json.dumps({"content": msg}).encode(),
43-
headers={
44-
"Content-Type": "application/json",
45-
"User-Agent": "jcode-release-bot/1.0",
46-
},
47-
)
48-
urllib.request.urlopen(req)
49-
print(f"Posted {tag} to Discord")
50-
EOF
31+
- name: Post release notes to Discord if not already announced
32+
env:
33+
GH_TOKEN: ${{ github.token }}
34+
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
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: 20 additions & 0 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,6 +662,17 @@ jobs:
659662
echo "Release ${GITHUB_REF_NAME} is already public; leaving it public."
660663
fi
661664
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
671+
env:
672+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
673+
GH_TOKEN: ${{ github.token }}
674+
run: gh workflow run discord-release.yml --ref "$DEFAULT_BRANCH" -f "tag=${GITHUB_REF_NAME}"
675+
662676
- name: Update Homebrew formula
663677
env:
664678
HOMEBREW_DEPLOY_KEY: ${{ secrets.HOMEBREW_DEPLOY_KEY }}
@@ -857,3 +871,9 @@ jobs:
857871
--reason completed
858872
gh issue edit "$issue" --remove-label "$label" || true
859873
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: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
#!/usr/bin/env python3
2+
"""Post one GitHub release announcement to a Discord webhook.
3+
4+
The release workflow publishes releases with GitHub's built-in GITHUB_TOKEN.
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.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
import json
14+
import os
15+
import re
16+
import sys
17+
import urllib.error
18+
import urllib.parse
19+
import urllib.request
20+
from typing import Any
21+
22+
23+
DISCORD_LIMIT = 2_000
24+
MARKER_PREFIX = "jcode-discord-announced"
25+
PLATFORM_BLOCK = re.compile(
26+
r"\n?<!-- jcode-platform-availability:start -->.*?"
27+
r"<!-- jcode-platform-availability:end -->\n?",
28+
flags=re.DOTALL,
29+
)
30+
31+
32+
def announcement_marker(tag: str) -> str:
33+
return f"<!-- {MARKER_PREFIX}:{tag} -->"
34+
35+
36+
def already_announced(body: str, tag: str) -> bool:
37+
return announcement_marker(tag) in body
38+
39+
40+
def release_notes_for_discord(body: str) -> str:
41+
body = PLATFORM_BLOCK.sub("\n", body)
42+
return re.sub(r"\n{3,}", "\n\n", body).strip()
43+
44+
45+
def format_message(*, tag: str, name: str, body: str, url: str) -> str:
46+
title = f"## {tag}" + (f" — {name}" if name and name != tag else "")
47+
notes = release_notes_for_discord(body)
48+
message = title if not notes else f"{title}\n{notes}"
49+
suffix = f"\n… (full notes: <{url}>)"
50+
if len(message) > DISCORD_LIMIT:
51+
message = message[: DISCORD_LIMIT - len(suffix)] + suffix
52+
return message
53+
54+
55+
def github_request(
56+
url: str,
57+
*,
58+
token: str,
59+
method: str = "GET",
60+
payload: dict[str, Any] | None = None,
61+
) -> dict[str, Any]:
62+
data = None if payload is None else json.dumps(payload).encode("utf-8")
63+
request = urllib.request.Request(
64+
url,
65+
data=data,
66+
method=method,
67+
headers={
68+
"Accept": "application/vnd.github+json",
69+
"Authorization": f"Bearer {token}",
70+
"Content-Type": "application/json",
71+
"User-Agent": "jcode-release-bot/2.0",
72+
"X-GitHub-Api-Version": "2022-11-28",
73+
},
74+
)
75+
with urllib.request.urlopen(request, timeout=30) as response:
76+
return json.load(response)
77+
78+
79+
def fetch_release(*, repository: str, tag: str, token: str) -> dict[str, Any]:
80+
quoted_tag = urllib.parse.quote(tag, safe="")
81+
return github_request(
82+
f"https://api.github.com/repos/{repository}/releases/tags/{quoted_tag}",
83+
token=token,
84+
)
85+
86+
87+
def discord_webhook_url(webhook_url: str) -> str:
88+
parts = urllib.parse.urlsplit(webhook_url)
89+
query = urllib.parse.parse_qsl(parts.query, keep_blank_values=True)
90+
if not any(key == "wait" for key, _ in query):
91+
query.append(("wait", "true"))
92+
return urllib.parse.urlunsplit(
93+
(
94+
parts.scheme,
95+
parts.netloc,
96+
parts.path,
97+
urllib.parse.urlencode(query),
98+
parts.fragment,
99+
)
100+
)
101+
102+
103+
def post_to_discord(*, webhook_url: str, content: str) -> dict[str, Any]:
104+
request = urllib.request.Request(
105+
discord_webhook_url(webhook_url),
106+
data=json.dumps(
107+
{
108+
"content": content,
109+
# Release notes are generated from commit subjects. Never let
110+
# them turn @everyone or user-looking text into real mentions.
111+
"allowed_mentions": {"parse": []},
112+
}
113+
).encode("utf-8"),
114+
headers={
115+
"Content-Type": "application/json",
116+
"User-Agent": "jcode-release-bot/2.0",
117+
},
118+
)
119+
with urllib.request.urlopen(request, timeout=30) as response:
120+
result = json.load(response)
121+
if not result.get("id"):
122+
raise RuntimeError("Discord accepted the webhook but returned no message id")
123+
return result
124+
125+
126+
def mark_release_announced(
127+
*, repository: str, release: dict[str, Any], tag: str, token: str
128+
) -> None:
129+
body = release.get("body") or ""
130+
marker = announcement_marker(tag)
131+
if marker in body:
132+
return
133+
updated_body = f"{body.rstrip()}\n\n{marker}\n"
134+
github_request(
135+
f"https://api.github.com/repos/{repository}/releases/{release['id']}",
136+
token=token,
137+
method="PATCH",
138+
payload={"body": updated_body},
139+
)
140+
141+
142+
def parse_args() -> argparse.Namespace:
143+
parser = argparse.ArgumentParser(description=__doc__)
144+
parser.add_argument("--tag", default=os.environ.get("GITHUB_REF_NAME"))
145+
parser.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY"))
146+
return parser.parse_args()
147+
148+
149+
def required(value: str | None, description: str) -> str:
150+
if value:
151+
return value
152+
raise SystemExit(f"Missing {description}")
153+
154+
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+
194+
def main() -> int:
195+
args = parse_args()
196+
tag = required(args.tag, "release tag (--tag or GITHUB_REF_NAME)")
197+
repository = required(
198+
args.repository, "GitHub repository (--repository or GITHUB_REPOSITORY)"
199+
)
200+
token = required(
201+
os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN"), "GH_TOKEN"
202+
)
203+
webhook_url = required(
204+
os.environ.get("DISCORD_RELEASE_WEBHOOK"), "DISCORD_RELEASE_WEBHOOK"
205+
)
206+
207+
try:
208+
announce_release(
209+
repository=repository,
210+
tag=tag,
211+
token=token,
212+
webhook_url=webhook_url,
213+
)
214+
return 0
215+
except urllib.error.HTTPError as error:
216+
detail = error.read().decode("utf-8", errors="replace")
217+
print(f"HTTP {error.code} while announcing {tag}: {detail}", file=sys.stderr)
218+
return 1
219+
except (RuntimeError, urllib.error.URLError) as error:
220+
print(f"Could not announce {tag}: {error}", file=sys.stderr)
221+
return 1
222+
223+
224+
if __name__ == "__main__":
225+
raise SystemExit(main())

0 commit comments

Comments
 (0)