authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:09:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:09:34-07:00
logf1052dd8b1040b22ad5a2e21282ee9980f98dbc5
treeb0d6bacb74956c3f0b89bafdc7b3aae4d28787b8
parente85144a58ce53d30d4d8595788896b37f5b966b8
signature Signed by PGP key B5690EEEBB952194

[codex] Structure Discord release webhook failures (#3285)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 191 insertions(+), 51 deletions(-)

scripts/notify-discord-release.test.ts+123-37
...@@ -1,6 +1,25 @@...@@ -1,6 +1,25 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
25
3import { buildDiscordReleaseAnnouncement } from "./notify-discord-release.ts";6import {
7 buildDiscordReleaseAnnouncement,
8 isDiscordReleaseAnnouncementError,
9 postDiscordWebhook,
10} from "./notify-discord-release.ts";
11
12const latestAnnouncement = {
13 target: "latest",
14 roleId: "222222222222222222",
15 releaseName: "T3 Code v1.2.3",
16 version: "1.2.3",
17 tag: "v1.2.3",
18 releaseUrl: new URL("https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3"),
19 timestamp: "2026-05-01T01:41:00.000Z",
20} as const;
21
22const webhookUrl = new URL("https://discord.com/api/webhooks/123456/secret-token");
423
5it("builds a prerelease Discord announcement for nightly subscribers", () => {24it("builds a prerelease Discord announcement for nightly subscribers", () => {
6 assert.deepStrictEqual(25 assert.deepStrictEqual(
...@@ -47,42 +66,109 @@ it("builds a prerelease Discord announcement for nightly subscribers", () => {...@@ -47,42 +66,109 @@ it("builds a prerelease Discord announcement for nightly subscribers", () => {
47});66});
4867
49it("builds a latest Discord announcement for stable subscribers", () => {68it("builds a latest Discord announcement for stable subscribers", () => {
50 assert.deepStrictEqual(69 assert.deepStrictEqual(buildDiscordReleaseAnnouncement(latestAnnouncement), {
51 buildDiscordReleaseAnnouncement({70 content: "<@&222222222222222222> Latest published: T3 Code v1.2.3",
52 target: "latest",71 allowed_mentions: {
53 roleId: "222222222222222222",72 roles: ["222222222222222222"],
54 releaseName: "T3 Code v1.2.3",
55 version: "1.2.3",
56 tag: "v1.2.3",
57 releaseUrl: new URL("https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3"),
58 timestamp: "2026-05-01T01:41:00.000Z",
59 }),
60 {
61 content: "<@&222222222222222222> Latest published: T3 Code v1.2.3",
62 allowed_mentions: {
63 roles: ["222222222222222222"],
64 },
65 embeds: [
66 {
67 title: "T3 Code v1.2.3",
68 url: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3",
69 description: "A new T3 Code latest release is available.",
70 color: 0x2ecc71,
71 fields: [
72 {
73 name: "Version",
74 value: "1.2.3",
75 inline: true,
76 },
77 {
78 name: "Tag",
79 value: "v1.2.3",
80 inline: true,
81 },
82 ],
83 timestamp: "2026-05-01T01:41:00.000Z",
84 },
85 ],
86 },73 },
74 embeds: [
75 {
76 title: "T3 Code v1.2.3",
77 url: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3",
78 description: "A new T3 Code latest release is available.",
79 color: 0x2ecc71,
80 fields: [
81 {
82 name: "Version",
83 value: "1.2.3",
84 inline: true,
85 },
86 {
87 name: "Tag",
88 value: "v1.2.3",
89 inline: true,
90 },
91 ],
92 timestamp: "2026-05-01T01:41:00.000Z",
93 },
94 ],
95 });
96});
97
98it.effect("preserves webhook request context and the full client cause chain", () => {
99 const payload = buildDiscordReleaseAnnouncement(latestAnnouncement);
100 const requestCause = new Error("request encoder unavailable");
101 let clientError: HttpClientError.HttpClientError | undefined;
102 const httpClientLayer = Layer.succeed(
103 HttpClient.HttpClient,
104 HttpClient.make((request) => {
105 clientError = new HttpClientError.HttpClientError({
106 reason: new HttpClientError.EncodeError({
107 request,
108 cause: requestCause,
109 }),
110 });
111 return Effect.fail(clientError);
112 }),
87 );113 );
114
115 return Effect.gen(function* () {
116 const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe(
117 Effect.provide(httpClientLayer),
118 Effect.flip,
119 );
120
121 if (error._tag !== "DiscordReleaseWebhookRequestError") {
122 assert.fail(`Unexpected error: ${error._tag}`);
123 }
124 assert.equal(error.target, "latest");
125 assert.equal(error.releaseName, latestAnnouncement.releaseName);
126 assert.equal(error.version, latestAnnouncement.version);
127 assert.equal(error.tag, latestAnnouncement.tag);
128 assert.equal(error.releaseUrl, latestAnnouncement.releaseUrl.href);
129 assert.equal(error.webhookOrigin, webhookUrl.origin);
130 assert.equal(error.webhookPathnameSegmentCount, 4);
131 assert.equal(error.contentLength, payload.content.length);
132 assert.equal(error.embedCount, 1);
133 assert.equal(error.allowedRoleMentionCount, 1);
134 assert.equal(error.hasRoleMentionSyntax, true);
135 assert.equal(error.cause, clientError);
136 assert.equal((error.cause as HttpClientError.HttpClientError).cause, requestCause);
137 assert.ok(!error.message.includes(requestCause.message));
138 assert.equal(isDiscordReleaseAnnouncementError(error), true);
139 });
140});
141
142it.effect("preserves a non-success response error with structured status context", () => {
143 const payload = buildDiscordReleaseAnnouncement(latestAnnouncement);
144 const httpClientLayer = Layer.succeed(
145 HttpClient.HttpClient,
146 HttpClient.make((request) =>
147 Effect.succeed(
148 HttpClientResponse.fromWeb(request, new Response("invalid webhook", { status: 400 })),
149 ),
150 ),
151 );
152
153 return Effect.gen(function* () {
154 const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe(
155 Effect.provide(httpClientLayer),
156 Effect.flip,
157 );
158
159 if (error._tag !== "DiscordReleaseWebhookResponseError") {
160 assert.fail(`Unexpected error: ${error._tag}`);
161 }
162 assert.equal(error.target, "latest");
163 assert.equal(error.tag, latestAnnouncement.tag);
164 assert.equal(error.webhookOrigin, webhookUrl.origin);
165 assert.equal(error.webhookPathnameSegmentCount, 4);
166 assert.equal(error.status, 400);
167 if (!HttpClientError.isHttpClientError(error.cause)) {
168 assert.fail("Expected HttpClientError cause");
169 }
170 assert.equal(error.cause.reason._tag, "StatusCodeError");
171 assert.ok(!error.message.includes(error.cause.message));
172 assert.equal(isDiscordReleaseAnnouncementError(error), true);
173 });
88});174});
scripts/notify-discord-release.ts+68-14
...@@ -3,7 +3,6 @@...@@ -3,7 +3,6 @@
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 * as Config from "effect/Config";5import * as Config from "effect/Config";
6import * as Data from "effect/Data";
7import * as DateTime from "effect/DateTime";6import * as DateTime from "effect/DateTime";
8import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
...@@ -19,7 +18,7 @@ import {...@@ -19,7 +18,7 @@ import {
1918
20export type DiscordReleaseTarget = "prerelease" | "latest";19export type DiscordReleaseTarget = "prerelease" | "latest";
2120
22interface DiscordReleaseAnnouncementOptions {21export interface DiscordReleaseAnnouncementOptions {
23 readonly target: DiscordReleaseTarget;22 readonly target: DiscordReleaseTarget;
24 readonly roleId: string;23 readonly roleId: string;
25 readonly releaseName: string;24 readonly releaseName: string;
...@@ -52,10 +51,51 @@ const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const;...@@ -52,10 +51,51 @@ const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const;
52const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/));51const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/));
53const DiscordWebhookUrl = Config.url("DISCORD_WEBHOOK_URL");52const DiscordWebhookUrl = Config.url("DISCORD_WEBHOOK_URL");
5453
55class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{54const discordReleaseErrorContext = {
56 readonly message: string;55 target: Schema.Literals(["prerelease", "latest"]),
57 readonly cause?: unknown;56 releaseName: Schema.String,
58}> {}57 version: Schema.String,
58 tag: Schema.String,
59 releaseUrl: Schema.String,
60 webhookOrigin: Schema.String,
61 webhookPathnameSegmentCount: Schema.Number,
62 contentLength: Schema.Number,
63 embedCount: Schema.Number,
64 allowedRoleMentionCount: Schema.Number,
65 hasRoleMentionSyntax: Schema.Boolean,
66};
67
68export class DiscordReleaseWebhookRequestError extends Schema.TaggedErrorClass<DiscordReleaseWebhookRequestError>()(
69 "DiscordReleaseWebhookRequestError",
70 {
71 ...discordReleaseErrorContext,
72 cause: Schema.Defect(),
73 },
74) {
75 override get message(): string {
76 return `Failed to post Discord ${this.target} release announcement for "${this.tag}" to ${this.webhookOrigin}.`;
77 }
78}
79
80export class DiscordReleaseWebhookResponseError extends Schema.TaggedErrorClass<DiscordReleaseWebhookResponseError>()(
81 "DiscordReleaseWebhookResponseError",
82 {
83 ...discordReleaseErrorContext,
84 status: Schema.Number,
85 cause: Schema.Defect(),
86 },
87) {
88 override get message(): string {
89 return `Discord ${this.target} release webhook for "${this.tag}" returned status ${this.status}.`;
90 }
91}
92
93export const DiscordReleaseAnnouncementError = Schema.Union([
94 DiscordReleaseWebhookRequestError,
95 DiscordReleaseWebhookResponseError,
96]);
97export type DiscordReleaseAnnouncementError = typeof DiscordReleaseAnnouncementError.Type;
98export const isDiscordReleaseAnnouncementError = Schema.is(DiscordReleaseAnnouncementError);
5999
60const targetLabels = {100const targetLabels = {
61 prerelease: "Prerelease",101 prerelease: "Prerelease",
...@@ -117,9 +157,10 @@ export const buildDiscordReleaseAnnouncement = (...@@ -117,9 +157,10 @@ export const buildDiscordReleaseAnnouncement = (
117 ],157 ],
118});158});
119159
120const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (160export const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
121 webhookUrl: URL,161 webhookUrl: URL,
122 payload: DiscordWebhookPayload,162 payload: DiscordWebhookPayload,
163 announcement: DiscordReleaseAnnouncementOptions,
123) {164) {
124 const httpClient = (yield* HttpClient.HttpClient).pipe(165 const httpClient = (yield* HttpClient.HttpClient).pipe(
125 HttpClient.retryTransient({166 HttpClient.retryTransient({
...@@ -135,13 +176,24 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (...@@ -135,13 +176,24 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
135 }),176 }),
136 );177 );
137178
179 const errorContext = {
180 target: announcement.target,
181 releaseName: announcement.releaseName,
182 version: announcement.version,
183 tag: announcement.tag,
184 releaseUrl: announcement.releaseUrl.href,
185 webhookOrigin: webhookUrl.origin,
186 webhookPathnameSegmentCount: webhookUrl.pathname.split("/").filter(Boolean).length,
187 ...summarizePayload(payload),
188 } as const;
189
138 const response = yield* HttpClientRequest.post(webhookUrl).pipe(190 const response = yield* HttpClientRequest.post(webhookUrl).pipe(
139 HttpClientRequest.bodyJson(payload),191 HttpClientRequest.bodyJson(payload),
140 Effect.flatMap(httpClient.execute),192 Effect.flatMap(httpClient.execute),
141 Effect.mapError(193 Effect.mapError(
142 (cause) =>194 (cause) =>
143 new DiscordReleaseAnnouncementError({195 new DiscordReleaseWebhookRequestError({
144 message: "Failed to post Discord release announcement.",196 ...errorContext,
145 cause,197 cause,
146 }),198 }),
147 ),199 ),
...@@ -157,8 +209,9 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (...@@ -157,8 +209,9 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
157 yield* HttpClientResponse.filterStatusOk(response).pipe(209 yield* HttpClientResponse.filterStatusOk(response).pipe(
158 Effect.mapError(210 Effect.mapError(
159 (cause) =>211 (cause) =>
160 new DiscordReleaseAnnouncementError({212 new DiscordReleaseWebhookResponseError({
161 message: `Discord webhook returned status ${response.status}.`,213 ...errorContext,
214 status: response.status,
162 cause,215 cause,
163 }),216 }),
164 ),217 ),
...@@ -208,7 +261,7 @@ export const notifyDiscordReleaseCommand = Command.make(...@@ -208,7 +261,7 @@ export const notifyDiscordReleaseCommand = Command.make(
208261
209 const webhookUrl = yield* DiscordWebhookUrl;262 const webhookUrl = yield* DiscordWebhookUrl;
210 const timestamp = DateTime.formatIso(yield* DateTime.now);263 const timestamp = DateTime.formatIso(yield* DateTime.now);
211 const payload = buildDiscordReleaseAnnouncement({264 const announcement = {
212 target,265 target,
213 roleId,266 roleId,
214 releaseName,267 releaseName,
...@@ -216,12 +269,13 @@ export const notifyDiscordReleaseCommand = Command.make(...@@ -216,12 +269,13 @@ export const notifyDiscordReleaseCommand = Command.make(
216 tag,269 tag,
217 releaseUrl,270 releaseUrl,
218 timestamp,271 timestamp,
219 });272 } satisfies DiscordReleaseAnnouncementOptions;
273 const payload = buildDiscordReleaseAnnouncement(announcement);
220274
221 yield* Effect.logInfo("discord release announcement payload built").pipe(275 yield* Effect.logInfo("discord release announcement payload built").pipe(
222 Effect.annotateLogs(summarizePayload(payload)),276 Effect.annotateLogs(summarizePayload(payload)),
223 );277 );
224 yield* postDiscordWebhook(webhookUrl, payload);278 yield* postDiscordWebhook(webhookUrl, payload, announcement);
225 yield* Effect.logInfo("discord release announcement completed");279 yield* Effect.logInfo("discord release announcement completed");
226 }),280 }),
227).pipe(Command.withDescription("Post a T3 Code release announcement to Discord."));281).pipe(Command.withDescription("Post a T3 Code release announcement to Discord."));