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 @@
11import { 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
524it("builds a prerelease Discord announcement for nightly subscribers", () => {
625 assert.deepStrictEqual(
......@@ -47,42 +66,109 @@ it("builds a prerelease Discord announcement for nightly subscribers", () => {
4766});
4867
4968it("builds a latest Discord announcement for stable subscribers", () => {
50 assert.deepStrictEqual(
51 buildDiscordReleaseAnnouncement({
52 target: "latest",
53 roleId: "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 ],
69 assert.deepStrictEqual(buildDiscordReleaseAnnouncement(latestAnnouncement), {
70 content: "<@&222222222222222222> Latest published: T3 Code v1.2.3",
71 allowed_mentions: {
72 roles: ["222222222222222222"],
8673 },
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 }),
87113 );
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 });
88174});
scripts/notify-discord-release.ts+68-14
......@@ -3,7 +3,6 @@
33import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
44import * as NodeServices from "@effect/platform-node/NodeServices";
55import * as Config from "effect/Config";
6import * as Data from "effect/Data";
76import * as DateTime from "effect/DateTime";
87import * as Effect from "effect/Effect";
98import * as Layer from "effect/Layer";
......@@ -19,7 +18,7 @@ import {
1918
2019export type DiscordReleaseTarget = "prerelease" | "latest";
2120
22interface DiscordReleaseAnnouncementOptions {
21export interface DiscordReleaseAnnouncementOptions {
2322 readonly target: DiscordReleaseTarget;
2423 readonly roleId: string;
2524 readonly releaseName: string;
......@@ -52,10 +51,51 @@ const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const;
5251const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/));
5352const DiscordWebhookUrl = Config.url("DISCORD_WEBHOOK_URL");
5453
55class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{
56 readonly message: string;
57 readonly cause?: unknown;
58}> {}
54const discordReleaseErrorContext = {
55 target: Schema.Literals(["prerelease", "latest"]),
56 releaseName: Schema.String,
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
60100const targetLabels = {
61101 prerelease: "Prerelease",
......@@ -117,9 +157,10 @@ export const buildDiscordReleaseAnnouncement = (
117157 ],
118158});
119159
120const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
160export const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
121161 webhookUrl: URL,
122162 payload: DiscordWebhookPayload,
163 announcement: DiscordReleaseAnnouncementOptions,
123164) {
124165 const httpClient = (yield* HttpClient.HttpClient).pipe(
125166 HttpClient.retryTransient({
......@@ -135,13 +176,24 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
135176 }),
136177 );
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
138190 const response = yield* HttpClientRequest.post(webhookUrl).pipe(
139191 HttpClientRequest.bodyJson(payload),
140192 Effect.flatMap(httpClient.execute),
141193 Effect.mapError(
142194 (cause) =>
143 new DiscordReleaseAnnouncementError({
144 message: "Failed to post Discord release announcement.",
195 new DiscordReleaseWebhookRequestError({
196 ...errorContext,
145197 cause,
146198 }),
147199 ),
......@@ -157,8 +209,9 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
157209 yield* HttpClientResponse.filterStatusOk(response).pipe(
158210 Effect.mapError(
159211 (cause) =>
160 new DiscordReleaseAnnouncementError({
161 message: `Discord webhook returned status ${response.status}.`,
212 new DiscordReleaseWebhookResponseError({
213 ...errorContext,
214 status: response.status,
162215 cause,
163216 }),
164217 ),
......@@ -208,7 +261,7 @@ export const notifyDiscordReleaseCommand = Command.make(
208261
209262 const webhookUrl = yield* DiscordWebhookUrl;
210263 const timestamp = DateTime.formatIso(yield* DateTime.now);
211 const payload = buildDiscordReleaseAnnouncement({
264 const announcement = {
212265 target,
213266 roleId,
214267 releaseName,
......@@ -216,12 +269,13 @@ export const notifyDiscordReleaseCommand = Command.make(
216269 tag,
217270 releaseUrl,
218271 timestamp,
219 });
272 } satisfies DiscordReleaseAnnouncementOptions;
273 const payload = buildDiscordReleaseAnnouncement(announcement);
220274
221275 yield* Effect.logInfo("discord release announcement payload built").pipe(
222276 Effect.annotateLogs(summarizePayload(payload)),
223277 );
224 yield* postDiscordWebhook(webhookUrl, payload);
278 yield* postDiscordWebhook(webhookUrl, payload, announcement);
225279 yield* Effect.logInfo("discord release announcement completed");
226280 }),
227281).pipe(Command.withDescription("Post a T3 Code release announcement to Discord."));