authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-30 20:53:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-01 03:53:47+00:00
log17b43960e86335534c63fb2b93ea68300b3bcc62
treec13ff8a8ea3e5b137e4525edb85c980dc720f6c3
parente5c7ac5a8cccc7d7679a7a63504d4af9a2969edc
signature Signed by PGP key B5690EEEBB952194

Add structured Discord webhook logging for release notifications (#2431)

Co-authored-by: Julius Marminge <julius@macmini.local>

3 files changed, 90 insertions(+), 25 deletions(-)

.github/workflows/release.yml+4-4
......@@ -622,11 +622,11 @@ jobs:
622622 if: needs.preflight.outputs.is_prerelease == 'true'
623623 continue-on-error: true
624624 env:
625 DISCORD_RELEASE_NIGHTLY_ROLE_ID: ${{ vars.DISCORD_RELEASE_NIGHTLY_ROLE_ID }}
625 DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_NIGHTLY_ROLE_ID }}
626626 DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
627627 run: |
628628 node scripts/notify-discord-release.ts prerelease \
629 --role-id "$DISCORD_RELEASE_NIGHTLY_ROLE_ID" \
629 --role-id "$DISCORD_MENTION_ROLE_ID" \
630630 --release-name "${{ needs.preflight.outputs.release_name }}" \
631631 --version "${{ needs.preflight.outputs.version }}" \
632632 --tag "${{ needs.preflight.outputs.tag }}" \
......@@ -636,11 +636,11 @@ jobs:
636636 if: needs.preflight.outputs.make_latest == 'true'
637637 continue-on-error: true
638638 env:
639 DISCORD_RELEASE_LATEST_ROLE_ID: ${{ vars.DISCORD_RELEASE_LATEST_ROLE_ID }}
639 DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_LATEST_ROLE_ID }}
640640 DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
641641 run: |
642642 node scripts/notify-discord-release.ts latest \
643 --role-id "$DISCORD_RELEASE_LATEST_ROLE_ID" \
643 --role-id "$DISCORD_MENTION_ROLE_ID" \
644644 --release-name "${{ needs.preflight.outputs.release_name }}" \
645645 --version "${{ needs.preflight.outputs.version }}" \
646646 --tag "${{ needs.preflight.outputs.tag }}" \
scripts/notify-discord-release.test.ts+4-2
......@@ -10,7 +10,9 @@ it("builds a prerelease Discord announcement for nightly subscribers", () => {
1010 releaseName: "T3 Code Nightly 1.2.4-nightly.20260501.17 (abcdef123456)",
1111 version: "1.2.4-nightly.20260501.17",
1212 tag: "v1.2.4-nightly.20260501.17",
13 releaseUrl: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.4-nightly.20260501.17",
13 releaseUrl: new URL(
14 "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.4-nightly.20260501.17",
15 ),
1416 timestamp: "2026-05-01T01:41:00.000Z",
1517 }),
1618 {
......@@ -52,7 +54,7 @@ it("builds a latest Discord announcement for stable subscribers", () => {
5254 releaseName: "T3 Code v1.2.3",
5355 version: "1.2.3",
5456 tag: "v1.2.3",
55 releaseUrl: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3",
57 releaseUrl: new URL("https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3"),
5658 timestamp: "2026-05-01T01:41:00.000Z",
5759 }),
5860 {
scripts/notify-discord-release.ts+82-19
......@@ -2,9 +2,14 @@
22
33import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
44import * as NodeServices from "@effect/platform-node/NodeServices";
5import { Config, Data, Effect, Layer, Schema } from "effect";
5import { Config, Data, Effect, Layer, Logger, Schema } from "effect";
66import { Argument, Command, Flag } from "effect/unstable/cli";
7import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
7import {
8 FetchHttpClient,
9 HttpClient,
10 HttpClientRequest,
11 HttpClientResponse,
12} from "effect/unstable/http";
813
914export type DiscordReleaseTarget = "prerelease" | "latest";
1015
......@@ -14,7 +19,7 @@ interface DiscordReleaseAnnouncementOptions {
1419 readonly releaseName: string;
1520 readonly version: string;
1621 readonly tag: string;
17 readonly releaseUrl: string;
22 readonly releaseUrl: URL;
1823 readonly timestamp: string;
1924}
2025
......@@ -39,8 +44,7 @@ interface DiscordWebhookPayload {
3944
4045const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const;
4146const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/));
42const WebUrlSchema = Schema.String.check(Schema.isPattern(/^https?:\/\/\S+$/));
43const DiscordWebhookUrl = Config.nonEmptyString("DISCORD_WEBHOOK_URL");
47const DiscordWebhookUrl = Config.url("DISCORD_WEBHOOK_URL");
4448
4549class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{
4650 readonly message: string;
......@@ -57,6 +61,23 @@ const targetColors = {
5761 latest: 0x2ecc71,
5862} as const satisfies Record<DiscordReleaseTarget, number>;
5963
64function describeWebhookUrl(webhookUrl: URL) {
65 return {
66 configured: true,
67 origin: webhookUrl.origin,
68 pathnameSegmentCount: webhookUrl.pathname.split("/").filter(Boolean).length,
69 } as const;
70}
71
72function summarizePayload(payload: DiscordWebhookPayload) {
73 return {
74 contentLength: payload.content.length,
75 embedCount: payload.embeds.length,
76 allowedRoleMentionCount: payload.allowed_mentions.roles.length,
77 hasRoleMentionSyntax: payload.content.includes("<@&"),
78 } as const;
79}
80
6081export const buildDiscordReleaseAnnouncement = (
6182 options: DiscordReleaseAnnouncementOptions,
6283): DiscordWebhookPayload => ({
......@@ -67,7 +88,7 @@ export const buildDiscordReleaseAnnouncement = (
6788 embeds: [
6889 {
6990 title: options.releaseName,
70 url: options.releaseUrl,
91 url: options.releaseUrl.href,
7192 description:
7293 options.target === "prerelease"
7394 ? "A new T3 Code prerelease is available for nightly testers."
......@@ -91,7 +112,7 @@ export const buildDiscordReleaseAnnouncement = (
91112});
92113
93114const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
94 webhookUrl: string,
115 webhookUrl: URL,
95116 payload: DiscordWebhookPayload,
96117) {
97118 const httpClient = (yield* HttpClient.HttpClient).pipe(
......@@ -99,10 +120,16 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
99120 retryOn: "errors-and-responses",
100121 times: 3,
101122 }),
102 HttpClient.filterStatusOk,
103123 );
104124
105 yield* HttpClientRequest.post(webhookUrl).pipe(
125 yield* Effect.logInfo("discord webhook request dispatching").pipe(
126 Effect.annotateLogs({
127 ...describeWebhookUrl(webhookUrl),
128 ...summarizePayload(payload),
129 }),
130 );
131
132 const response = yield* HttpClientRequest.post(webhookUrl).pipe(
106133 HttpClientRequest.bodyJson(payload),
107134 Effect.flatMap(httpClient.execute),
108135 Effect.mapError(
......@@ -113,9 +140,24 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
113140 }),
114141 ),
115142 );
116});
117143
118const runtimeLayer = Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer);
144 yield* Effect.logInfo("discord webhook response received").pipe(
145 Effect.annotateLogs({
146 status: response.status,
147 ok: response.status >= 200 && response.status < 300,
148 }),
149 );
150
151 yield* HttpClientResponse.filterStatusOk(response).pipe(
152 Effect.mapError(
153 (cause) =>
154 new DiscordReleaseAnnouncementError({
155 message: `Discord webhook returned status ${response.status}.`,
156 cause,
157 }),
158 ),
159 );
160});
119161
120162export const notifyDiscordReleaseCommand = Command.make(
121163 "notify-discord-release",
......@@ -140,31 +182,52 @@ export const notifyDiscordReleaseCommand = Command.make(
140182 Flag.withDescription("Git tag for the release."),
141183 ),
142184 releaseUrl: Flag.string("release-url").pipe(
143 Flag.withSchema(WebUrlSchema),
185 Flag.withSchema(Schema.URLFromString),
144186 Flag.withDescription("Public GitHub release URL."),
145187 ),
146188 },
147189 ({ target, roleId, releaseName, version, tag, releaseUrl }) =>
148190 Effect.gen(function* () {
149 const webhookUrl = yield* DiscordWebhookUrl;
150 yield* postDiscordWebhook(
151 webhookUrl,
152 buildDiscordReleaseAnnouncement({
191 yield* Effect.logInfo("discord release announcement starting").pipe(
192 Effect.annotateLogs({
153193 target,
154 roleId,
194 roleIdProvided: roleId.length > 0,
195 roleIdLength: roleId.length,
155196 releaseName,
156197 version,
157198 tag,
158199 releaseUrl,
159 timestamp: new Date().toISOString(),
160200 }),
161201 );
202
203 const webhookUrl = yield* DiscordWebhookUrl;
204 const payload = buildDiscordReleaseAnnouncement({
205 target,
206 roleId,
207 releaseName,
208 version,
209 tag,
210 releaseUrl,
211 timestamp: new Date().toISOString(),
212 });
213
214 yield* Effect.logInfo("discord release announcement payload built").pipe(
215 Effect.annotateLogs(summarizePayload(payload)),
216 );
217 yield* postDiscordWebhook(webhookUrl, payload);
218 yield* Effect.logInfo("discord release announcement completed");
162219 }),
163220).pipe(Command.withDescription("Post a T3 Code release announcement to Discord."));
164221
165222if (import.meta.main) {
166223 Command.run(notifyDiscordReleaseCommand, { version: "0.0.0" }).pipe(
167 Effect.provide(runtimeLayer),
224 Effect.provide(
225 Layer.mergeAll(
226 Logger.layer([Logger.consolePretty()]),
227 NodeServices.layer,
228 FetchHttpClient.layer,
229 ),
230 ),
168231 NodeRuntime.runMain,
169232 );
170233}