1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Cause from "effect/Cause";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Logger from "effect/Logger";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import { Command } from "effect/cli";
12import { HttpClient, HttpClientError, HttpClientResponse, UrlParams } from "effect/http";
13import { ChildProcess, ChildProcessSpawner } from "effect/process";
14
15import {
16 buildDiscordReleaseAnnouncement,
17 notifyDiscordReleaseCommand,
18 postDiscordWebhook,
19} from "./notify-discord-release.ts";
20
21const latestAnnouncement = {
22 target: "latest",
23 roleId: "222222222222222222",
24 releaseName: "T3 Code v1.2.3",
25 version: "1.2.3",
26 tag: "v1.2.3",
27 releaseUrl: new URL("https://github.com/pingdotgg/t3code/releases/tag/v1.2.3"),
28 timestamp: "2026-05-01T01:41:00.000Z",
29} as const;
30const nightlyAnnouncement = { ...latestAnnouncement, target: "prerelease" } as const;
31const webhookUrl = new URL("https://discord.com/api/webhooks/123456/test-secret-token");
32const intro = "A new T3 Code prerelease is available for nightly testers.";
33const notes = `## What's Changed\n* Fix remote reconnection by @contributor in https://github.com/pingdotgg/t3code/pull/10\n* Improve thread search in https://github.com/pingdotgg/t3code/pull/11\n\n**Full Changelog**: https://github.com/pingdotgg/t3code/compare/v1.2.2...v1.2.3`;
34const formattedNotes = `## What's Changed\n* [Fix remote reconnection](https://github.com/pingdotgg/t3code/pull/10) by [@contributor](https://github.com/contributor)\n* [Improve thread search](https://github.com/pingdotgg/t3code/pull/11)\n\n[Full Changelog](https://github.com/pingdotgg/t3code/compare/v1.2.2...v1.2.3)`;
35const runCli = Command.runWith(notifyDiscordReleaseCommand, { version: "0.0.0" });
36const cliArgs = (target: "latest" | "prerelease") => [
37 target,
38 "--role-id",
39 latestAnnouncement.roleId,
40 "--release-name",
41 latestAnnouncement.releaseName,
42 "--release-version",
43 latestAnnouncement.version,
44 "--tag",
45 latestAnnouncement.tag,
46 "--release-url",
47 latestAnnouncement.releaseUrl.href,
48];
49const configLayer = ConfigProvider.layer(
50 ConfigProvider.fromEnv({ env: { DISCORD_WEBHOOK_URL: webhookUrl.href } }),
51);
52const PayloadSchema = Schema.fromJsonString(
53 Schema.Struct({
54 content: Schema.String,
55 allowed_mentions: Schema.Struct({
56 parse: Schema.Array(Schema.String),
57 roles: Schema.Array(Schema.String),
58 }),
59 embeds: Schema.Array(
60 Schema.Struct({ title: Schema.String, description: Schema.String, url: Schema.String }),
61 ),
62 }),
63);
64const decodePayload = Schema.decodeUnknownSync(PayloadSchema);
65const payloadDescription = (payloads: ReturnType<typeof buildDiscordReleaseAnnouncement>) =>
66 payloads.flatMap((payload) => payload.embeds.map((embed) => embed.description)).join("");
67const captureClient = (requests: string[], status = 204) =>
68 Layer.succeed(
69 HttpClient.HttpClient,
70 HttpClient.make((request) => {
71 assert.deepStrictEqual(UrlParams.getAll(request.urlParams, "wait"), ["true"]);
72 assert.equal(request.body._tag, "Uint8Array");
73 if (request.body._tag !== "Uint8Array") return Effect.die("Expected JSON request body");
74 requests.push(new TextDecoder().decode(request.body.body));
75 return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status })));
76 }),
77 );
78
79function assertDiscordLimits(payloads: ReturnType<typeof buildDiscordReleaseAnnouncement>) {
80 for (const payload of payloads) {
81 assert.ok(payload.content.length <= 2000);
82 assert.equal(payload.embeds.length, 1);
83 let total = 0;
84 for (const embed of payload.embeds) {
85 assert.ok(embed.title.length <= 256);
86 assert.ok(embed.description.length <= 4096);
87 assert.ok(embed.footer.text.length <= 2048);
88 total += embed.title.length + embed.description.length + embed.footer.text.length;
89 }
90 assert.ok(total <= 6000);
91 }
92}
93
94it("keeps stable announcements short even when a long changelog is provided", () => {
95 assert.deepStrictEqual(
96 buildDiscordReleaseAnnouncement({ ...latestAnnouncement, releaseNotes: notes.repeat(200) }),
97 [
98 {
99 content: "-# <@&222222222222222222>",
100 allowed_mentions: { parse: [], roles: [latestAnnouncement.roleId] },
101 embeds: [
102 {
103 title: latestAnnouncement.releaseName,
104 url: latestAnnouncement.releaseUrl.href,
105 description: `[View full release notes on GitHub](${latestAnnouncement.releaseUrl.href})`,
106 color: 0x2ecc71,
107 footer: { text: "v1.2.3" },
108 timestamp: latestAnnouncement.timestamp,
109 },
110 ],
111 },
112 ],
113 );
114});
115
116it("links contributor profiles and escapes brackets in change titles and bot handles", () => {
117 const releaseNotes =
118 "* Fix [web] links by @octo-user in https://github.com/pingdotgg/t3code/pull/12\n* Bump dependencies by @dependabot[bot] in https://github.com/pingdotgg/t3code/pull/13";
119 const payloads = buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes });
120 assert.equal(
121 payloadDescription(payloads),
122 `${intro}\n\n* [Fix \\[web\\] links](https://github.com/pingdotgg/t3code/pull/12) by [@octo-user](https://github.com/octo-user)\n* [Bump dependencies](https://github.com/pingdotgg/t3code/pull/13) by [@dependabot\\[bot\\]](https://github.com/apps/dependabot)`,
123 );
124});
125
126it("preserves custom notes and links outside generated GitHub pull request entries", () => {
127 const releaseNotes =
128 "## Migration\n* Written by @someone in the docs\n* Commit https://github.com/pingdotgg/t3code/commit/abc123\n[Guide](https://example.com/guide)";
129 assert.equal(
130 payloadDescription(buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes })),
131 `${intro}\n\n${releaseNotes}`,
132 );
133});
134
135it("posts a generated summary without its folded full list", () => {
136 const summary = "**v1.2.3** · 4 changes\n\n## Fixes\n- Stop works again (#1)";
137 const releaseNotes = `${summary}\n\n<details>\n<summary>Full changelog</summary>\n\n* fix: raw by @someone in https://github.com/pingdotgg/t3code/pull/1\n\n</details>\n`;
138 assert.equal(
139 payloadDescription(buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes })),
140 `${intro}\n\n**v1.2.3** · 4 changes\n\n## Fixes\n- Stop works again ([#1](https://github.com/pingdotgg/t3code/pull/1))\n\n[Full changelog](${nightlyAnnouncement.releaseUrl.href})`,
141 );
142});
143
144it("falls back when notes are missing, empty, or whitespace", () => {
145 const fallback = buildDiscordReleaseAnnouncement(nightlyAnnouncement);
146 assert.equal(payloadDescription(fallback), intro);
147 assert.deepStrictEqual(
148 buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes: "" }),
149 fallback,
150 );
151 assert.deepStrictEqual(
152 buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes: " \r\n\t" }),
153 fallback,
154 );
155});
156
157it("splits long notes at line boundaries without dropping changes or the comparison link", () => {
158 const longNotes =
159 Array.from(
160 { length: 150 },
161 (_, i) => `* Change ${i} in https://github.com/pingdotgg/t3code/pull/${i}\n`,
162 ).join("") + notes;
163 const payloads = buildDiscordReleaseAnnouncement({
164 ...nightlyAnnouncement,
165 releaseNotes: longNotes,
166 });
167 assert.ok(payloads.length > 2);
168 const formattedChanges = Array.from(
169 { length: 150 },
170 (_, i) => `* [Change ${i}](https://github.com/pingdotgg/t3code/pull/${i})\n`,
171 ).join("");
172 assert.equal(payloadDescription(payloads), `${intro}\n\n${formattedChanges}${formattedNotes}`);
173 assertDiscordLimits(payloads);
174 for (const [index, payload] of payloads.entries()) {
175 assert.deepStrictEqual(payload.allowed_mentions, {
176 parse: [],
177 roles: index === 0 ? [latestAnnouncement.roleId] : [],
178 });
179 if (index > 0) {
180 assert.equal(payload.content, "");
181 assert.equal(
182 payload.embeds[0]?.title,
183 `Changelog continued (${index + 1}/${payloads.length})`,
184 );
185 }
186 if (index < payloads.length - 1) assert.ok(payload.embeds[0]?.description.endsWith("\n"));
187 assert.equal(payload.embeds[0]?.url, nightlyAnnouncement.releaseUrl.href);
188 }
189});
190
191it("handles oversized lines, words, and Unicode without losing or breaking characters", () => {
192 const longNotes =
193 "a".repeat(9000) + "\n" + "🦋".repeat(4500) + "\n" + "word ".repeat(2000).trim();
194 const payloads = buildDiscordReleaseAnnouncement({
195 ...nightlyAnnouncement,
196 releaseNotes: longNotes,
197 });
198 assertDiscordLimits(payloads);
199 assert.equal(payloadDescription(payloads), `${intro}\n\n${longNotes}`);
200 for (const payload of payloads) assert.ok(payload.embeds[0]?.description.isWellFormed());
201});
202
203it("accepts exactly 4096 description characters and splits the next character", () => {
204 const releaseNotes = "x".repeat(4096 - intro.length - 2);
205 assert.equal(buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes }).length, 1);
206 const longer = buildDiscordReleaseAnnouncement({
207 ...nightlyAnnouncement,
208 releaseNotes: releaseNotes + "x",
209 });
210 assert.equal(longer.length, 2);
211 assert.equal(payloadDescription(longer), `${intro}\n\n${releaseNotes}x`);
212 assertDiscordLimits(longer);
213});
214
215it("suppresses everyone, here, user, and role mentions on every message", () => {
216 const mentions = `@everyone @here <@123> <@!123> <@&456> <@&${nightlyAnnouncement.roleId}>`;
217 const inert = mentions.replaceAll("@", "@\u200b");
218 const payloads = buildDiscordReleaseAnnouncement({
219 ...nightlyAnnouncement,
220 releaseName: mentions,
221 releaseNotes: `${mentions}\n`.repeat(100) + notes,
222 });
223 assert.ok(payloads.length > 1);
224 assert.equal(
225 payloadDescription(payloads),
226 `${intro}\n\n${`${inert}\n`.repeat(100)}${formattedNotes}`,
227 );
228 for (const [index, payload] of payloads.entries()) {
229 assert.deepStrictEqual(payload.allowed_mentions, {
230 parse: [],
231 roles: index === 0 ? [latestAnnouncement.roleId] : [],
232 });
233 if (index === 0) assert.equal(payload.embeds[0]?.title, inert);
234 assert.ok(!payload.content.includes("@everyone"));
235 assert.ok(!payload.content.includes("@here"));
236 assert.equal(payload.content.match(/<@&\d+>/g)?.length ?? 0, index === 0 ? 1 : 0);
237 }
238});
239
240it("rejects invalid metadata before sending", () => {
241 assert.throws(
242 () => buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseName: "x".repeat(257) }),
243 /title exceeds/,
244 );
245 assert.throws(
246 () => buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, version: "x".repeat(2048) }),
247 /footer exceeds/,
248 );
249 assert.throws(
250 () => buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, roleId: "123> @everyone" }),
251 /Invalid Discord release role/,
252 );
253});
254
255it.effect("retains safe request context without leaking the webhook URL or nested cause", () =>
256 Effect.gen(function* () {
257 const payload = buildDiscordReleaseAnnouncement(latestAnnouncement)[0]!;
258 const logs: string[] = [];
259 const client = Layer.succeed(
260 HttpClient.HttpClient,
261 HttpClient.make((request) =>
262 Effect.fail(
263 new HttpClientError.HttpClientError({
264 reason: new HttpClientError.EncodeError({
265 request,
266 cause: new Error(`Encoder failed for ${webhookUrl.href}`),
267 }),
268 }),
269 ),
270 ),
271 );
272 const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe(
273 Effect.tapCause(Effect.logError),
274 Effect.provide([
275 client,
276 Logger.layer([
277 Logger.make((options) => {
278 logs.push(Logger.formatJson.log(options));
279 }),
280 ]),
281 ]),
282 Effect.flip,
283 );
284 if (error._tag !== "DiscordReleaseWebhookRequestError")
285 assert.fail(`Unexpected error: ${error._tag}`);
286 assert.equal(error.reason, "EncodeError");
287 assert.equal(error.target, "latest");
288 assert.equal(error.tag, latestAnnouncement.tag);
289 assert.equal(error.webhookOrigin, webhookUrl.origin);
290 assert.equal(error.webhookPathnameSegmentCount, 4);
291 assert.equal(error.contentLength, payload.content.length);
292 assert.equal(error.allowedRoleMentionCount, 1);
293 assert.ok(!Cause.pretty(Cause.fail(error)).includes("test-secret-token"));
294 assert.ok(!logs.join("\n").includes("test-secret-token"));
295 }),
296);
297
298it.effect(
299 "retains response status without retaining the secret-bearing request or response body",
300 () =>
301 Effect.gen(function* () {
302 const client = Layer.succeed(
303 HttpClient.HttpClient,
304 HttpClient.make((request) =>
305 Effect.succeed(
306 HttpClientResponse.fromWeb(request, new Response(webhookUrl.href, { status: 400 })),
307 ),
308 ),
309 );
310 const error = yield* postDiscordWebhook(
311 webhookUrl,
312 buildDiscordReleaseAnnouncement(latestAnnouncement)[0]!,
313 latestAnnouncement,
314 ).pipe(Effect.provide(client), Effect.flip);
315 if (error._tag !== "DiscordReleaseWebhookResponseError")
316 assert.fail(`Unexpected error: ${error._tag}`);
317 assert.equal(error.status, 400);
318 assert.equal(error.tag, latestAnnouncement.tag);
319 assert.ok(!Cause.pretty(Cause.fail(error)).includes("test-secret-token"));
320 }),
321);
322
323function workflowRun(workflow: string, stepName: string) {
324 const step = workflow.split(` - name: ${stepName}\n`)[1]?.split("\n - name:")[0];
325 assert.ok(step, `Missing workflow step: ${stepName}`);
326 const run = step.split(" run: |\n")[1];
327 assert.ok(run, `Missing run block: ${stepName}`);
328 return run
329 .split("\n")
330 .map((line) => line.replace(/^ /, ""))
331 .join("\n");
332}
333
334it.layer(NodeServices.layer)("Discord release CLI and workflow", (it) => {
335 it.effect("passes the published nightly body from the workflow to ordered sends", () =>
336 Effect.gen(function* () {
337 const fs = yield* FileSystem.FileSystem;
338 const path = yield* Path.Path;
339 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
340 const temp = yield* fs.makeTempDirectoryScoped({ prefix: "discord release " });
341 const workflow = yield* fs.readFileString(
342 yield* path.fromFileUrl(new URL("../.github/workflows/release.yml", import.meta.url)),
343 );
344 const publishedNotes =
345 Array.from({ length: 50 }, () => notes).join("\n\n") +
346 '\n* Literal $(touch "$RUNNER_TEMP/executed") and `touch "$RUNNER_TEMP/executed"`';
347 const read = workflowRun(workflow, "Read published release notes");
348 const shellEnv = {
349 RUNNER_TEMP: temp,
350 RELEASE_TAG: nightlyAnnouncement.tag,
351 RELEASE_CHANNEL: "nightly",
352 FIXTURE_NOTES: publishedNotes,
353 };
354 const readStatus = yield* spawner.exitCode(
355 ChildProcess.make(
356 "bash",
357 [
358 "-e",
359 "-c",
360 `gh() { [ "$*" = 'release view v1.2.3 --json body --jq .body // ""' ] || return 1; printf '%s' "$FIXTURE_NOTES"; }\n${read}`,
361 ],
362 { env: shellEnv },
363 ),
364 );
365 assert.equal(readStatus, 0);
366 assert.equal(yield* fs.readFileString(`${temp}/discord-release-notes.md`), publishedNotes);
367 const replacements: Record<string, string> = {
368 "needs.preflight.outputs.release_name": latestAnnouncement.releaseName,
369 "needs.preflight.outputs.version": latestAnnouncement.version,
370 "needs.preflight.outputs.tag": latestAnnouncement.tag,
371 "github.repository": "pingdotgg/t3code",
372 };
373 const announce = workflowRun(workflow, "Announce prerelease on Discord").replace(
374 /\$\{\{ (.*?) \}\}/g,
375 (_, key: string) => {
376 assert.ok(replacements[key], `Unexpected expression: ${key}`);
377 return replacements[key]!;
378 },
379 );
380 const announceStatus = yield* spawner.exitCode(
381 ChildProcess.make(
382 "bash",
383 ["-e", "-c", `node() { printf '%s\\0' "$@" > "$RUNNER_TEMP/args"; }\n${announce}`],
384 { env: { RUNNER_TEMP: temp, DISCORD_MENTION_ROLE_ID: latestAnnouncement.roleId } },
385 ),
386 );
387 assert.equal(announceStatus, 0);
388 const args = (yield* fs.readFileString(`${temp}/args`)).split("\0").slice(1, -1);
389 const requests: string[] = [];
390 yield* runCli(args).pipe(Effect.provide([configLayer, captureClient(requests)]));
391 const payloads = requests.map((request) => decodePayload(request));
392 assert.ok(payloads.length > 2);
393 assert.equal(
394 payloads.flatMap((payload) => payload.embeds.map((embed) => embed.description)).join(""),
395 `${intro}\n\n${Array.from({ length: 50 }, () => formattedNotes).join("\n\n")}\n* Literal $(touch "$RUNNER_TEMP/executed") and \`touch "$RUNNER_TEMP/executed"\``,
396 );
397 for (const [index, payload] of payloads.entries())
398 assert.deepStrictEqual(payload.allowed_mentions, {
399 parse: [],
400 roles: index === 0 ? [latestAnnouncement.roleId] : [],
401 });
402 assert.equal(yield* fs.exists(`${temp}/executed`), false);
403 assert.equal(
404 yield* spawner.exitCode(
405 ChildProcess.make("bash", ["-e", "-c", `gh() { return 99; }\n${read}`], {
406 env: { ...shellEnv, RELEASE_CHANNEL: "stable" },
407 }),
408 ),
409 0,
410 );
411 assert.equal(yield* fs.readFileString(`${temp}/discord-release-notes.md`), "");
412 assert.equal(
413 yield* spawner.exitCode(
414 ChildProcess.make(
415 "bash",
416 ["-e", "-c", `gh() { printf '%s' 'partial changelog'; return 42; }\n${read}`],
417 { env: shellEnv },
418 ),
419 ),
420 0,
421 );
422 assert.equal(yield* fs.readFileString(`${temp}/discord-release-notes.md`), "");
423 const fallbackRequests: string[] = [];
424 yield* runCli(args).pipe(Effect.provide([configLayer, captureClient(fallbackRequests)]));
425 assert.equal(fallbackRequests.length, 1);
426 const fallback = decodePayload(fallbackRequests[0]!);
427 assert.equal(fallback.embeds[0]?.description, intro);
428 assert.equal(fallback.content, `-# <@&${latestAnnouncement.roleId}>`);
429 assert.deepStrictEqual(fallback.allowed_mentions, {
430 parse: [],
431 roles: [latestAnnouncement.roleId],
432 });
433 assert.ok(
434 workflow
435 .split(" - name: Announce prerelease on Discord\n")[1]
436 ?.startsWith(" if: needs.preflight.outputs.is_prerelease == 'true'\n"),
437 );
438 assert.ok(workflow.includes("GH_REPO: ${{ github.repository }}"));
439 assert.ok(workflow.includes("RELEASE_TAG: ${{ needs.preflight.outputs.tag }}"));
440 assert.ok(
441 !workflowRun(workflow, "Announce latest release on Discord").includes(
442 "--release-notes-file",
443 ),
444 );
445 assert.ok(
446 workflow
447 .slice(workflow.indexOf(" announce_discord:"))
448 .includes("needs.preflight.outputs.release_channel != 'preview'"),
449 );
450 }),
451 );
452
453 it.effect("fails before sending when the requested nightly notes file cannot be read", () =>
454 Effect.gen(function* () {
455 const requests: string[] = [];
456 const result = yield* runCli([
457 ...cliArgs("prerelease"),
458 "--release-notes-file",
459 "/missing-nightly-notes.md",
460 ]).pipe(Effect.provide([configLayer, captureClient(requests)]), Effect.result);
461 assert.equal(result._tag, "Failure");
462 assert.deepStrictEqual(requests, []);
463 }),
464 );
465
466 it.effect("stops on a failed send rather than posting later continuations", () =>
467 Effect.gen(function* () {
468 const fs = yield* FileSystem.FileSystem;
469 const temp = yield* fs.makeTempDirectoryScoped();
470 const file = `${temp}/notes.md`;
471 yield* fs.writeFileString(file, notes.repeat(100));
472 const requests: string[] = [];
473 const result = yield* runCli([...cliArgs("prerelease"), "--release-notes-file", file]).pipe(
474 Effect.provide([configLayer, captureClient(requests, 400)]),
475 Effect.result,
476 );
477 assert.equal(result._tag, "Failure");
478 assert.equal(requests.length, 1);
479 }),
480 );
481
482 it.effect("does not expose an invalid webhook secret in configuration errors", () =>
483 Effect.gen(function* () {
484 const requests: string[] = [];
485 const result = yield* runCli(cliArgs("latest")).pipe(
486 Effect.provide([
487 ConfigProvider.layer(
488 ConfigProvider.fromEnv({ env: { DISCORD_WEBHOOK_URL: "invalid-test-secret-token" } }),
489 ),
490 captureClient(requests),
491 ]),
492 Effect.result,
493 );
494 assert.equal(result._tag, "Failure");
495 if (result._tag === "Failure")
496 assert.ok(!Cause.pretty(Cause.fail(result.failure)).includes("test-secret-token"));
497 assert.deepStrictEqual(requests, []);
498 }),
499 );
500});