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
133// Keep release text inert, including occurrences of the intentionally pinged role.
134const suppressMentions = (text: string) =>
135 text.replace(/@(everyone|here)\b|<@[!&]?\d+>/g, (mention) => mention.replace("@", "@\u200b"));
136
137const escapeLinkLabel = (text: string) => text.replace(/[\\[\]]/g, "\\$&");
138
139// Compact GitHub's generated entries and comparison link, preserving custom
140// notes. Contributor links are GitHub profiles, not pings.
141function formatReleaseNotes(notes: string) {
142 return notes
143 .replace(
144 /^([*-] )(.+?)(?: by @([\w-]+(?:\[bot\])?))? in (https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+)[ \t]*\r?$/gm,
145 (
146 _entry,
147 bullet: string,
148 title: string,
149 author: string | undefined,
150 pullRequestUrl: string,
151 ) => {
152 const change = `${bullet}[${escapeLinkLabel(title)}](${pullRequestUrl})`;
153 if (!author) return change;
154 const profile = author.endsWith("[bot]") ? `apps/${author.slice(0, -5)}` : author;
155 return `${change} by [@${escapeLinkLabel(author)}](https://github.com/${profile})`;
156 },
157 )
158 .replace(
159 /^(?:\*\*)?Full Changelog(?:\*\*)?: (https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/compare\/\S+)[ \t]*\r?$/gm,
160 "[Full Changelog]($1)",
161 );
162}
163
164// Preserve every character and prefer whole lines, then words. A single oversized
165// line still needs splitting, but never between the halves of a Unicode character.
166function splitDescription(text: string, limit: number) {
167 const chunks: string[] = [];
168 while (text.length > limit) {
169 const newline = text.lastIndexOf("\n", limit - 1);
170 const space = text.lastIndexOf(" ", limit - 1);
171 let end = newline > 0 ? newline + 1 : space > 0 ? space + 1 : limit;
172 const lastCodeUnit = text.charCodeAt(end - 1);
173 if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) end -= 1;
174 chunks.push(text.slice(0, end));
175 text = text.slice(end);
176 }
177 chunks.push(text);
178 return chunks;
179}
180
181export const buildDiscordReleaseAnnouncement = (
182 options: DiscordReleaseAnnouncementOptions,
183): ReadonlyArray<DiscordWebhookPayload> => {
184 const releaseName = suppressMentions(options.releaseName);
185 const version = suppressMentions(options.version);
186 const footerText = `v${version.replace(/^v/, "")}`;
187 const content = `-# <@&${options.roleId}>`;
188 if (!/^\d+$/.test(options.roleId)) throw new RangeError("Invalid Discord release role ID.");
189 if (releaseName.length > 256)
190 throw new RangeError("Discord release title exceeds 256 characters.");
191 if (content.length > 2000)
192 throw new RangeError("Discord release content exceeds 2000 characters.");
193 if (footerText.length > 2048) {
194 throw new RangeError("Discord release footer exceeds 2048 characters.");
195 }
196
197 const intro =
198 options.target === "prerelease"
199 ? "A new T3 Code prerelease is available for nightly testers."
200 : `[View full release notes on GitHub](${options.releaseUrl.href})`;
201 const notes =
202 options.target === "prerelease"
203 ? suppressMentions(formatReleaseNotes(options.releaseNotes?.trim() ?? ""))
204 : "";
205 const description = notes ? `${intro}\n\n${notes}` : intro;
206 // One embed per message avoids Discord's URL deduplication. Reserve a full
207 // title and the version footer within the 6000-character aggregate limit.
208 const limit = Math.min(4096, 6000 - 256 - footerText.length);
209 const descriptions = splitDescription(description, limit);
210
211 return descriptions.map((description, index) => ({
212 content: index === 0 ? content : "",
213 allowed_mentions: {
214 parse: [],
215 roles: index === 0 ? [options.roleId] : [],
216 },
217 embeds: [
218 {
219 title:
220 index === 0 ? releaseName : `Changelog continued (${index + 1}/${descriptions.length})`,
221 url: options.releaseUrl.href,
222 description,
223 color: targetColors[options.target],
224 footer: { text: footerText },
225 timestamp: options.timestamp,
226 },
227 ],
228 }));
229};
230
231export const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
232 webhookUrl: URL,
233 payload: DiscordWebhookPayload,
234 announcement: DiscordReleaseAnnouncementOptions,
235) {
236 const requestUrl = new URL(webhookUrl);
237 requestUrl.searchParams.set("wait", "true");
238 const httpClient = (yield* HttpClient.HttpClient).pipe(
239 HttpClient.retryTransient({
240 retryOn: "errors-and-responses",
241 times: 3,
242 schedule: Schedule.recurs(3).pipe(
243 Schedule.addDelay(
244 (
245 metadata: Schedule.Metadata<
246 number,
247 HttpClientResponse.HttpClientResponse | HttpClientError.HttpClientError
248 >,
249 ) => {
250 const response = !HttpClientError.isHttpClientError(metadata.input)
251 ? metadata.input
252 : metadata.input.reason._tag === "StatusCodeError"
253 ? metadata.input.reason.response
254 : undefined;
255 const retryAfterSeconds = Number(response?.headers["retry-after"]);
256 return Effect.succeed(
257 Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0
258 ? retryAfterSeconds * 1000
259 : 1000,
260 );
261 },
262 ),
263 ),
264 }),
265 );
266
267 yield* Effect.logInfo("discord webhook request dispatching").pipe(
268 Effect.annotateLogs({
269 ...describeWebhookUrl(webhookUrl),
270 ...summarizePayload(payload),
271 }),
272 );
273
274 const errorContext = {
275 target: announcement.target,
276 releaseName: announcement.releaseName,
277 version: announcement.version,
278 tag: announcement.tag,
279 releaseUrl: announcement.releaseUrl.href,
280 webhookOrigin: webhookUrl.origin,
281 webhookPathnameSegmentCount: webhookUrl.pathname.split("/").filter(Boolean).length,
282 ...summarizePayload(payload),
283 } as const;
284
285 const response = yield* HttpClientRequest.post(requestUrl).pipe(
286 HttpClientRequest.bodyJson(payload),
287 Effect.flatMap(httpClient.execute),
288 // Bound requests and retry waits together without shortening Retry-After.
289 Effect.timeout("1 minute"),
290 Effect.mapError(
291 (error) =>
292 new DiscordReleaseWebhookRequestError({
293 ...errorContext,
294 // HTTP errors retain the request URL, including the webhook token.
295 reason: error._tag === "HttpClientError" ? error.reason._tag : error._tag,
296 }),
297 ),
298 );
299
300 yield* Effect.logInfo("discord webhook response received").pipe(
301 Effect.annotateLogs({
302 status: response.status,
303 ok: response.status >= 200 && response.status < 300,
304 }),
305 );
306
307 yield* HttpClientResponse.filterStatusOk(response).pipe(
308 Effect.mapError(
309 () =>
310 new DiscordReleaseWebhookResponseError({
311 ...errorContext,
312 status: response.status,
313 }),
314 ),
315 );
316});
317
318export const notifyDiscordReleaseCommand = Command.make(
319 "notify-discord-release",
320 {
321 target: Argument.Literals("target", DISCORD_RELEASE_TARGETS).pipe(
322 Argument.withDescription("Discord announcement target: prerelease or latest."),
323 ),
324 roleId: Flag.String("role-id").pipe(
325 Flag.withSchema(DiscordRoleIdSchema),
326 Flag.withDescription("Discord role ID to mention in the release announcement."),
327 ),
328 releaseName: Flag.String("release-name").pipe(
329 Flag.withSchema(Schema.NonEmptyString),
330 Flag.withDescription("Human-readable release name."),
331 ),
332 releaseVersion: Flag.String("release-version").pipe(
333 Flag.withSchema(Schema.NonEmptyString),
334 Flag.withDescription("Release version."),
335 ),
336 tag: Flag.String("tag").pipe(
337 Flag.withSchema(Schema.NonEmptyString),
338 Flag.withDescription("Git tag for the release."),
339 ),
340 releaseUrl: Flag.String("release-url").pipe(
341 Flag.withSchema(Schema.URLFromString),
342 Flag.withDescription("Public GitHub release URL."),
343 ),
344 releaseNotesFile: Flag.String("release-notes-file").pipe(
345 Flag.optional,
346 Flag.withDescription("File containing the published GitHub release notes."),
347 ),
348 },
349 ({ target, roleId, releaseName, releaseVersion, tag, releaseUrl, releaseNotesFile }) =>
350 Effect.gen(function* () {
351 yield* Effect.logInfo("discord release announcement starting").pipe(
352 Effect.annotateLogs({
353 target,
354 roleIdProvided: roleId.length > 0,
355 roleIdLength: roleId.length,
356 releaseName,
357 version: releaseVersion,
358 tag,
359 releaseUrl,
360 }),
361 );
362
363 const webhookUrl = yield* DiscordWebhookUrl;
364 const fs = yield* FileSystem.FileSystem;
365 const releaseNotes =
366 target === "prerelease" && Option.isSome(releaseNotesFile)
367 ? yield* fs.readFileString(releaseNotesFile.value)
368 : "";
369 const timestamp = DateTime.formatIso(yield* DateTime.now);
370 const announcement = {
371 target,
372 roleId,
373 releaseName,
374 version: releaseVersion,
375 tag,
376 releaseUrl,
377 timestamp,
378 releaseNotes,
379 } satisfies DiscordReleaseAnnouncementOptions;
380 const payloads = yield* Effect.sync(() => buildDiscordReleaseAnnouncement(announcement));
381
382 yield* Effect.logInfo("discord release announcement payloads built").pipe(
383 Effect.annotateLogs({ messageCount: payloads.length }),
384 );
385 for (const payload of payloads) {
386 yield* postDiscordWebhook(webhookUrl, payload, announcement);
387 }
388 yield* Effect.logInfo("discord release announcement completed");
389 }),
390).pipe(Command.withDescription("Post a T3 Code release announcement to Discord."));
391
392if (import.meta.main) {
393 Command.run(notifyDiscordReleaseCommand, { version: "0.0.0" }).pipe(
394 Effect.provide(
395 Layer.mergeAll(
396 Logger.layer([Logger.consolePretty()]),
397 NodeServices.layer,
398 FetchHttpClient.layer,
399 ),
400 ),
401 NodeRuntime.runMain,
402 );
403}