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:...@@ -622,11 +622,11 @@ jobs:
622 if: needs.preflight.outputs.is_prerelease == 'true'622 if: needs.preflight.outputs.is_prerelease == 'true'
623 continue-on-error: true623 continue-on-error: true
624 env:624 env:
625 DISCORD_RELEASE_NIGHTLY_ROLE_ID: ${{ vars.DISCORD_RELEASE_NIGHTLY_ROLE_ID }}625 DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_NIGHTLY_ROLE_ID }}
626 DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}626 DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
627 run: |627 run: |
628 node scripts/notify-discord-release.ts prerelease \628 node scripts/notify-discord-release.ts prerelease \
629 --role-id "$DISCORD_RELEASE_NIGHTLY_ROLE_ID" \629 --role-id "$DISCORD_MENTION_ROLE_ID" \
630 --release-name "${{ needs.preflight.outputs.release_name }}" \630 --release-name "${{ needs.preflight.outputs.release_name }}" \
631 --version "${{ needs.preflight.outputs.version }}" \631 --version "${{ needs.preflight.outputs.version }}" \
632 --tag "${{ needs.preflight.outputs.tag }}" \632 --tag "${{ needs.preflight.outputs.tag }}" \
...@@ -636,11 +636,11 @@ jobs:...@@ -636,11 +636,11 @@ jobs:
636 if: needs.preflight.outputs.make_latest == 'true'636 if: needs.preflight.outputs.make_latest == 'true'
637 continue-on-error: true637 continue-on-error: true
638 env:638 env:
639 DISCORD_RELEASE_LATEST_ROLE_ID: ${{ vars.DISCORD_RELEASE_LATEST_ROLE_ID }}639 DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_LATEST_ROLE_ID }}
640 DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}640 DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
641 run: |641 run: |
642 node scripts/notify-discord-release.ts latest \642 node scripts/notify-discord-release.ts latest \
643 --role-id "$DISCORD_RELEASE_LATEST_ROLE_ID" \643 --role-id "$DISCORD_MENTION_ROLE_ID" \
644 --release-name "${{ needs.preflight.outputs.release_name }}" \644 --release-name "${{ needs.preflight.outputs.release_name }}" \
645 --version "${{ needs.preflight.outputs.version }}" \645 --version "${{ needs.preflight.outputs.version }}" \
646 --tag "${{ needs.preflight.outputs.tag }}" \646 --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", () => {...@@ -10,7 +10,9 @@ it("builds a prerelease Discord announcement for nightly subscribers", () => {
10 releaseName: "T3 Code Nightly 1.2.4-nightly.20260501.17 (abcdef123456)",10 releaseName: "T3 Code Nightly 1.2.4-nightly.20260501.17 (abcdef123456)",
11 version: "1.2.4-nightly.20260501.17",11 version: "1.2.4-nightly.20260501.17",
12 tag: "v1.2.4-nightly.20260501.17",12 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 ),
14 timestamp: "2026-05-01T01:41:00.000Z",16 timestamp: "2026-05-01T01:41:00.000Z",
15 }),17 }),
16 {18 {
...@@ -52,7 +54,7 @@ it("builds a latest Discord announcement for stable subscribers", () => {...@@ -52,7 +54,7 @@ it("builds a latest Discord announcement for stable subscribers", () => {
52 releaseName: "T3 Code v1.2.3",54 releaseName: "T3 Code v1.2.3",
53 version: "1.2.3",55 version: "1.2.3",
54 tag: "v1.2.3",56 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"),
56 timestamp: "2026-05-01T01:41:00.000Z",58 timestamp: "2026-05-01T01:41:00.000Z",
57 }),59 }),
58 {60 {
scripts/notify-discord-release.ts+82-19
...@@ -2,9 +2,14 @@...@@ -2,9 +2,14 @@
22
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { Config, Data, Effect, Layer, Schema } from "effect";5import { Config, Data, Effect, Layer, Logger, Schema } from "effect";
6import { Argument, Command, Flag } from "effect/unstable/cli";6import { 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
9export type DiscordReleaseTarget = "prerelease" | "latest";14export type DiscordReleaseTarget = "prerelease" | "latest";
1015
...@@ -14,7 +19,7 @@ interface DiscordReleaseAnnouncementOptions {...@@ -14,7 +19,7 @@ interface DiscordReleaseAnnouncementOptions {
14 readonly releaseName: string;19 readonly releaseName: string;
15 readonly version: string;20 readonly version: string;
16 readonly tag: string;21 readonly tag: string;
17 readonly releaseUrl: string;22 readonly releaseUrl: URL;
18 readonly timestamp: string;23 readonly timestamp: string;
19}24}
2025
...@@ -39,8 +44,7 @@ interface DiscordWebhookPayload {...@@ -39,8 +44,7 @@ interface DiscordWebhookPayload {
3944
40const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const;45const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const;
41const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/));46const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/));
42const WebUrlSchema = Schema.String.check(Schema.isPattern(/^https?:\/\/\S+$/));47const DiscordWebhookUrl = Config.url("DISCORD_WEBHOOK_URL");
43const DiscordWebhookUrl = Config.nonEmptyString("DISCORD_WEBHOOK_URL");
4448
45class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{49class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{
46 readonly message: string;50 readonly message: string;
...@@ -57,6 +61,23 @@ const targetColors = {...@@ -57,6 +61,23 @@ const targetColors = {
57 latest: 0x2ecc71,61 latest: 0x2ecc71,
58} as const satisfies Record<DiscordReleaseTarget, number>;62} 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
60export const buildDiscordReleaseAnnouncement = (81export const buildDiscordReleaseAnnouncement = (
61 options: DiscordReleaseAnnouncementOptions,82 options: DiscordReleaseAnnouncementOptions,
62): DiscordWebhookPayload => ({83): DiscordWebhookPayload => ({
...@@ -67,7 +88,7 @@ export const buildDiscordReleaseAnnouncement = (...@@ -67,7 +88,7 @@ export const buildDiscordReleaseAnnouncement = (
67 embeds: [88 embeds: [
68 {89 {
69 title: options.releaseName,90 title: options.releaseName,
70 url: options.releaseUrl,91 url: options.releaseUrl.href,
71 description:92 description:
72 options.target === "prerelease"93 options.target === "prerelease"
73 ? "A new T3 Code prerelease is available for nightly testers."94 ? "A new T3 Code prerelease is available for nightly testers."
...@@ -91,7 +112,7 @@ export const buildDiscordReleaseAnnouncement = (...@@ -91,7 +112,7 @@ export const buildDiscordReleaseAnnouncement = (
91});112});
92113
93const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (114const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
94 webhookUrl: string,115 webhookUrl: URL,
95 payload: DiscordWebhookPayload,116 payload: DiscordWebhookPayload,
96) {117) {
97 const httpClient = (yield* HttpClient.HttpClient).pipe(118 const httpClient = (yield* HttpClient.HttpClient).pipe(
...@@ -99,10 +120,16 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (...@@ -99,10 +120,16 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
99 retryOn: "errors-and-responses",120 retryOn: "errors-and-responses",
100 times: 3,121 times: 3,
101 }),122 }),
102 HttpClient.filterStatusOk,
103 );123 );
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(
106 HttpClientRequest.bodyJson(payload),133 HttpClientRequest.bodyJson(payload),
107 Effect.flatMap(httpClient.execute),134 Effect.flatMap(httpClient.execute),
108 Effect.mapError(135 Effect.mapError(
...@@ -113,9 +140,24 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (...@@ -113,9 +140,24 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
113 }),140 }),
114 ),141 ),
115 );142 );
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
120export const notifyDiscordReleaseCommand = Command.make(162export const notifyDiscordReleaseCommand = Command.make(
121 "notify-discord-release",163 "notify-discord-release",
...@@ -140,31 +182,52 @@ export const notifyDiscordReleaseCommand = Command.make(...@@ -140,31 +182,52 @@ export const notifyDiscordReleaseCommand = Command.make(
140 Flag.withDescription("Git tag for the release."),182 Flag.withDescription("Git tag for the release."),
141 ),183 ),
142 releaseUrl: Flag.string("release-url").pipe(184 releaseUrl: Flag.string("release-url").pipe(
143 Flag.withSchema(WebUrlSchema),185 Flag.withSchema(Schema.URLFromString),
144 Flag.withDescription("Public GitHub release URL."),186 Flag.withDescription("Public GitHub release URL."),
145 ),187 ),
146 },188 },
147 ({ target, roleId, releaseName, version, tag, releaseUrl }) =>189 ({ target, roleId, releaseName, version, tag, releaseUrl }) =>
148 Effect.gen(function* () {190 Effect.gen(function* () {
149 const webhookUrl = yield* DiscordWebhookUrl;191 yield* Effect.logInfo("discord release announcement starting").pipe(
150 yield* postDiscordWebhook(192 Effect.annotateLogs({
151 webhookUrl,
152 buildDiscordReleaseAnnouncement({
153 target,193 target,
154 roleId,194 roleIdProvided: roleId.length > 0,
195 roleIdLength: roleId.length,
155 releaseName,196 releaseName,
156 version,197 version,
157 tag,198 tag,
158 releaseUrl,199 releaseUrl,
159 timestamp: new Date().toISOString(),
160 }),200 }),
161 );201 );
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");
162 }),219 }),
163).pipe(Command.withDescription("Post a T3 Code release announcement to Discord."));220).pipe(Command.withDescription("Post a T3 Code release announcement to Discord."));
164221
165if (import.meta.main) {222if (import.meta.main) {
166 Command.run(notifyDiscordReleaseCommand, { version: "0.0.0" }).pipe(223 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 ),
168 NodeRuntime.runMain,231 NodeRuntime.runMain,
169 );232 );
170}233}