1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as Config from "effect/Config";
6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Logger from "effect/Logger";
11import * as Option from "effect/Option";
12import * as Redacted from "effect/Redacted";
13import * as Schedule from "effect/Schedule";
14import * as Schema from "effect/Schema";
15import { Argument, Command, Flag } from "effect/cli";
16import {
17 FetchHttpClient,
18 HttpClient,
19 HttpClientError,
20 HttpClientRequest,
21 HttpClientResponse,
22} from "effect/http";
23
24export type DiscordReleaseTarget = "prerelease" | "latest";
25
26export interface DiscordReleaseAnnouncementOptions {
27 readonly target: DiscordReleaseTarget;
28 readonly roleId: string;
29 readonly releaseName: string;
30 readonly version: string;
31 readonly tag: string;
32 readonly releaseUrl: URL;
33 readonly timestamp: string;
34 readonly releaseNotes?: string;
35}
36
37interface DiscordWebhookPayload {
38 readonly content: string;
39 readonly allowed_mentions: {
40 readonly parse: ReadonlyArray<never>;
41 readonly roles: ReadonlyArray<string>;
42 };
43 readonly embeds: ReadonlyArray<{
44 readonly title: string;
45 readonly url: string;
46 readonly description: string;
47 readonly color: number;
48 readonly footer: { readonly text: string };
49 readonly timestamp: string;
50 }>;
51}
52
53const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const;
54const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/));
55const DiscordWebhookUrl = Config.Redacted("DISCORD_WEBHOOK_URL").pipe(
56 Effect.flatMap((value) =>
57 Effect.try({
58 try: () => new URL(Redacted.value(value)),
59 catch: () => new DiscordReleaseWebhookConfigurationError({}),
60 }),
61 ),
62);
63
64export class DiscordReleaseWebhookConfigurationError extends Schema.TaggedError<DiscordReleaseWebhookConfigurationError>()(
65 "DiscordReleaseWebhookConfigurationError",
66 {},
67) {
68 override get message(): string {
69 return "DISCORD_WEBHOOK_URL must be a valid URL.";
70 }
71}
72
73const discordReleaseErrorContext = {
74 target: Schema.Literals(["prerelease", "latest"]),
75 releaseName: Schema.String,
76 version: Schema.String,
77 tag: Schema.String,
78 releaseUrl: Schema.String,
79 webhookOrigin: Schema.String,
80 webhookPathnameSegmentCount: Schema.Number,
81 contentLength: Schema.Number,
82 embedCount: Schema.Number,
83 allowedRoleMentionCount: Schema.Number,
84 hasRoleMentionSyntax: Schema.Boolean,
85};
86
87export class DiscordReleaseWebhookRequestError extends Schema.TaggedError<DiscordReleaseWebhookRequestError>()(
88 "DiscordReleaseWebhookRequestError",
89 {
90 ...discordReleaseErrorContext,
91 reason: Schema.String,
92 },
93) {
94 override get message(): string {
95 return `Failed to post Discord ${this.target} release announcement for "${this.tag}" to ${this.webhookOrigin}.`;
96 }
97}
98
99export class DiscordReleaseWebhookResponseError extends Schema.TaggedError<DiscordReleaseWebhookResponseError>()(
100 "DiscordReleaseWebhookResponseError",
101 {
102 ...discordReleaseErrorContext,
103 status: Schema.Number,
104 },
105) {
106 override get message(): string {
107 return `Discord ${this.target} release webhook for "${this.tag}" returned status ${this.status}.`;
108 }
109}
110
111const targetColors = {
112 prerelease: 0x5865f2,
113 latest: 0x2ecc71,
114} as const satisfies Record<DiscordReleaseTarget, number>;
115
116function describeWebhookUrl(webhookUrl: URL) {
117 return {
118 configured: true,
119 origin: webhookUrl.origin,
120 pathnameSegmentCount: webhookUrl.pathname.split("/").filter(Boolean).length,
121 } as const;
122}
123
124function summarizePayload(payload: DiscordWebhookPayload) {
125 return {
126 contentLength: payload.content.length,
127 embedCount: payload.embeds.length,
128 allowedRoleMentionCount: payload.allowed_mentions.roles.length,
129 hasRoleMentionSyntax: payload.content.includes("<@&"),
130 } as const;
131}
132
133const suppressMentions = (text: string) =>
134 text.replace(/@(everyone|here)\b|<@[!&]?\d+>/g, (mention) => mention.replace("@", "@\u200b"));
135
136const escapeLinkLabel = (text: string) => text.replace(/[\\[\]]/g, "\\$&");
137
138/** GitHub links `(#123)` on the release page; Discord needs the link written out. */
139function linkPullRequestNumbers(notes: string, releaseUrl: URL) {
140 const repositoryUrl = releaseUrl.href.split("/releases/")[0];
141 return notes.replace(/#(\d+)\b/g, `[#$1](${repositoryUrl}/pull/$1)`);
142}
143
144function formatReleaseNotes(notes: string) {
145 return notes
146 .replace(
147 /^([*-] )(.+?)(?: by @([\w-]+(?:\[bot\])?))? in (https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+)[ \t]*\r?$/gm,
148 (
149 _entry,
150 bullet: string,
151 title: string,
152 author: string | undefined,
153 pullRequestUrl: string,
154 ) => {
155 const change = `${bullet}[${escapeLinkLabel(title)}](${pullRequestUrl})`;
156 if (!author) return change;
157 const profile = author.endsWith("[bot]") ? `apps/${author.slice(0, -5)}` : author;
158 return `${change} by [@${escapeLinkLabel(author)}](https://github.com/${profile})`;
159 },
160 )
161 .replace(
162 /^(?:\*\*)?Full Changelog(?:\*\*)?: (https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/compare\/\S+)[ \t]*\r?$/gm,
163 "[Full Changelog]($1)",
164 );
165}
166
167function splitDescription(text: string, limit: number) {
168 const chunks: string[] = [];
169 while (text.length > limit) {
170 const newline = text.lastIndexOf("\n", limit - 1);
171 const space = text.lastIndexOf(" ", limit - 1);
172 let end = newline > 0 ? newline + 1 : space > 0 ? space + 1 : limit;
173 const lastCodeUnit = text.charCodeAt(end - 1);
174 if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) end -= 1;
175 chunks.push(text.slice(0, end));
176 text = text.slice(end);
177 }
178 chunks.push(text);
179 return chunks;
180}
181
182export const buildDiscordReleaseAnnouncement = (
183 options: DiscordReleaseAnnouncementOptions,
184): ReadonlyArray<DiscordWebhookPayload> => {
185 const releaseName = suppressMentions(options.releaseName);
186 const version = suppressMentions(options.version);
187 const footerText = `v${version.replace(/^v/, "")}`;
188 const content = `-# <@&${options.roleId}>`;
189 if (!/^\d+$/.test(options.roleId)) throw new RangeError("Invalid Discord release role ID.");
190 if (releaseName.length > 256)
191 throw new RangeError("Discord release title exceeds 256 characters.");
192 if (content.length > 2000)
193 throw new RangeError("Discord release content exceeds 2000 characters.");
194 if (footerText.length > 2048) {
195 throw new RangeError("Discord release footer exceeds 2048 characters.");
196 }
197
198 const intro =
199 options.target === "prerelease"
200 ? "A new T3 Code prerelease is available for nightly testers."
201 : `[View full release notes on GitHub](${options.releaseUrl.href})`;
202 // Generated summaries fold the full list under <details>, which Discord cannot render.
203 const [summary = "", folded] = (options.releaseNotes ?? "").split(/^<details>/m);
204 const notes =
205 options.target === "prerelease"
206 ? suppressMentions(
207 folded === undefined
208 ? formatReleaseNotes(summary.trim())
209 : `${linkPullRequestNumbers(summary.trim(), options.releaseUrl)}\n\n[Full changelog](${options.releaseUrl.href})`,
210 )
211 : "";
212 const description = notes ? `${intro}\n\n${notes}` : intro;
213 // Separate embeds avoid URL deduplication; reserve title and footer space within Discord's 6000-character limit.
214 const limit = Math.min(4096, 6000 - 256 - footerText.length);
215 const descriptions = splitDescription(description, limit);
216
217 return descriptions.map((description, index) => ({
218 content: index === 0 ? content : "",
219 allowed_mentions: {
220 parse: [],
221 roles: index === 0 ? [options.roleId] : [],
222 },
223 embeds: [
224 {
225 title:
226 index === 0 ? releaseName : `Changelog continued (${index + 1}/${descriptions.length})`,
227 url: options.releaseUrl.href,
228 description,
229 color: targetColors[options.target],
230 footer: { text: footerText },
231 timestamp: options.timestamp,
232 },
233 ],
234 }));
235};
236
237export const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
238 webhookUrl: URL,
239 payload: DiscordWebhookPayload,
240 announcement: DiscordReleaseAnnouncementOptions,
241) {
242 const requestUrl = new URL(webhookUrl);
243 requestUrl.searchParams.set("wait", "true");
244 const httpClient = (yield* HttpClient.HttpClient).pipe(
245 HttpClient.retryTransient({
246 retryOn: "errors-and-responses",
247 times: 3,
248 schedule: Schedule.recurs(3).pipe(
249 Schedule.addDelay(
250 (
251 metadata: Schedule.Metadata<
252 number,
253 HttpClientResponse.HttpClientResponse | HttpClientError.HttpClientError
254 >,
255 ) => {
256 const response = !HttpClientError.isHttpClientError(metadata.input)
257 ? metadata.input
258 : metadata.input.reason._tag === "StatusCodeError"
259 ? metadata.input.reason.response
260 : undefined;
261 const retryAfterSeconds = Number(response?.headers["retry-after"]);
262 return Effect.succeed(
263 Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0
264 ? retryAfterSeconds * 1000
265 : 1000,
266 );
267 },
268 ),
269 ),
270 }),
271 );
272
273 yield* Effect.logInfo("discord webhook request dispatching").pipe(
274 Effect.annotateLogs({
275 ...describeWebhookUrl(webhookUrl),
276 ...summarizePayload(payload),
277 }),
278 );
279
280 const errorContext = {
281 target: announcement.target,
282 releaseName: announcement.releaseName,
283 version: announcement.version,
284 tag: announcement.tag,
285 releaseUrl: announcement.releaseUrl.href,
286 webhookOrigin: webhookUrl.origin,
287 webhookPathnameSegmentCount: webhookUrl.pathname.split("/").filter(Boolean).length,
288 ...summarizePayload(payload),
289 } as const;
290
291 const response = yield* HttpClientRequest.post(requestUrl).pipe(
292 HttpClientRequest.bodyJson(payload),
293 Effect.flatMap(httpClient.execute),
294 Effect.timeout("1 minute"),
295 Effect.mapError(
296 (error) =>
297 new DiscordReleaseWebhookRequestError({
298 ...errorContext,
299 // HTTP errors retain the request URL, including the webhook token.
300 reason: error._tag === "HttpClientError" ? error.reason._tag : error._tag,
301 }),
302 ),
303 );
304
305 yield* Effect.logInfo("discord webhook response received").pipe(
306 Effect.annotateLogs({
307 status: response.status,
308 ok: response.status >= 200 && response.status < 300,
309 }),
310 );
311
312 yield* HttpClientResponse.filterStatusOk(response).pipe(
313 Effect.mapError(
314 () =>
315 new DiscordReleaseWebhookResponseError({
316 ...errorContext,
317 status: response.status,
318 }),
319 ),
320 );
321});
322
323export const notifyDiscordReleaseCommand = Command.make(
324 "notify-discord-release",
325 {
326 target: Argument.Literals("target", DISCORD_RELEASE_TARGETS).pipe(
327 Argument.withDescription("Discord announcement target: prerelease or latest."),
328 ),
329 roleId: Flag.String("role-id").pipe(
330 Flag.withSchema(DiscordRoleIdSchema),
331 Flag.withDescription("Discord role ID to mention in the release announcement."),
332 ),
333 releaseName: Flag.String("release-name").pipe(
334 Flag.withSchema(Schema.NonEmptyString),
335 Flag.withDescription("Human-readable release name."),
336 ),
337 releaseVersion: Flag.String("release-version").pipe(
338 Flag.withSchema(Schema.NonEmptyString),
339 Flag.withDescription("Release version."),
340 ),
341 tag: Flag.String("tag").pipe(
342 Flag.withSchema(Schema.NonEmptyString),
343 Flag.withDescription("Git tag for the release."),
344 ),
345 releaseUrl: Flag.String("release-url").pipe(
346 Flag.withSchema(Schema.URLFromString),
347 Flag.withDescription("Public GitHub release URL."),
348 ),
349 releaseNotesFile: Flag.String("release-notes-file").pipe(
350 Flag.optional,
351 Flag.withDescription("File containing the published GitHub release notes."),
352 ),
353 },
354 ({ target, roleId, releaseName, releaseVersion, tag, releaseUrl, releaseNotesFile }) =>
355 Effect.gen(function* () {
356 yield* Effect.logInfo("discord release announcement starting").pipe(
357 Effect.annotateLogs({
358 target,
359 roleIdProvided: roleId.length > 0,
360 roleIdLength: roleId.length,
361 releaseName,
362 version: releaseVersion,
363 tag,
364 releaseUrl,
365 }),
366 );
367
368 const webhookUrl = yield* DiscordWebhookUrl;
369 const fs = yield* FileSystem.FileSystem;
370 const releaseNotes =
371 target === "prerelease" && Option.isSome(releaseNotesFile)
372 ? yield* fs.readFileString(releaseNotesFile.value)
373 : "";
374 const timestamp = DateTime.formatIso(yield* DateTime.now);
375 const announcement = {
376 target,
377 roleId,
378 releaseName,
379 version: releaseVersion,
380 tag,
381 releaseUrl,
382 timestamp,
383 releaseNotes,
384 } satisfies DiscordReleaseAnnouncementOptions;
385 const payloads = yield* Effect.sync(() => buildDiscordReleaseAnnouncement(announcement));
386
387 yield* Effect.logInfo("discord release announcement payloads built").pipe(
388 Effect.annotateLogs({ messageCount: payloads.length }),
389 );
390 for (const payload of payloads) {
391 yield* postDiscordWebhook(webhookUrl, payload, announcement);
392 }
393 yield* Effect.logInfo("discord release announcement completed");
394 }),
395).pipe(Command.withDescription("Post a T3 Code release announcement to Discord."));
396
397if (import.meta.main) {
398 Command.run(notifyDiscordReleaseCommand, { version: "0.0.0" }).pipe(
399 Effect.provide(
400 Layer.mergeAll(
401 Logger.layer([Logger.consolePretty()]),
402 NodeServices.layer,
403 FetchHttpClient.layer,
404 ),
405 ),
406 NodeRuntime.runMain,
407 );
408}