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 Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Logger from "effect/Logger";
11import * as Path from "effect/Path";
12import * as Schema from "effect/Schema";
13import * as TestClock from "effect/testing/TestClock";
14import { Command } from "effect/cli";
15import { HttpClient, HttpClientError, HttpClientResponse, UrlParams } from "effect/http";
16import { ChildProcess, ChildProcessSpawner } from "effect/process";
17
18import {
19 buildDiscordReleaseAnnouncement,
20 notifyDiscordReleaseCommand,
21 postDiscordWebhook,
22} from "./notify-discord-release.ts";
23
24const latestAnnouncement = {
25 target: "latest",
26 roleId: "222222222222222222",
27 releaseName: "T3 Code v1.2.3",
28 version: "1.2.3",
29 tag: "v1.2.3",
30 releaseUrl: new URL("https://github.com/pingdotgg/t3code/releases/tag/v1.2.3"),
31 timestamp: "2026-05-01T01:41:00.000Z",
32} as const;
33const nightlyAnnouncement = { ...latestAnnouncement, target: "prerelease" } as const;
34// Deliberately fake. All HTTP requests below use an injected client.
35const webhookUrl = new URL("https://discord.com/api/webhooks/123456/test-secret-token");
36const intro = "A new T3 Code prerelease is available for nightly testers.";
37const 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`;
38const 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)`;
39const runCli = Command.runWith(notifyDiscordReleaseCommand, { version: "0.0.0" });
40const cliArgs = (target: "latest" | "prerelease") => [
41 target,
42 "--role-id",
43 latestAnnouncement.roleId,
44 "--release-name",
45 latestAnnouncement.releaseName,
46 "--release-version",
47 latestAnnouncement.version,
48 "--tag",
49 latestAnnouncement.tag,
50 "--release-url",
51 latestAnnouncement.releaseUrl.href,
52];
53const configLayer = ConfigProvider.layer(
54 ConfigProvider.fromEnv({ env: { DISCORD_WEBHOOK_URL: webhookUrl.href } }),
55);
56const PayloadSchema = Schema.fromJsonString(
57 Schema.Struct({
58 content: Schema.String,
59 allowed_mentions: Schema.Struct({
60 parse: Schema.Array(Schema.String),
61 roles: Schema.Array(Schema.String),
62 }),
63 embeds: Schema.Array(
64 Schema.Struct({ title: Schema.String, description: Schema.String, url: Schema.String }),
65 ),
66 }),
67);
68const decodePayload = Schema.decodeUnknownSync(PayloadSchema);
69const payloadDescription = (payloads: ReturnType<typeof buildDiscordReleaseAnnouncement>) =>
70 payloads.flatMap((payload) => payload.embeds.map((embed) => embed.description)).join("");
71const captureClient = (requests: string[], status = 204) =>
72 Layer.succeed(
73 HttpClient.HttpClient,
74 HttpClient.make((request) => {
75 assert.deepStrictEqual(UrlParams.getAll(request.urlParams, "wait"), ["true"]);
76 assert.equal(request.body._tag, "Uint8Array");
77 if (request.body._tag !== "Uint8Array") return Effect.die("Expected JSON request body");
78 requests.push(new TextDecoder().decode(request.body.body));
79 return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status })));
80 }),
81 );
82
83function assertDiscordLimits(payloads: ReturnType<typeof buildDiscordReleaseAnnouncement>) {
84 for (const payload of payloads) {
85 assert.ok(payload.content.length <= 2000);
86 assert.equal(payload.embeds.length, 1);
87 let total = 0;
88 for (const embed of payload.embeds) {
89 assert.ok(embed.title.length <= 256);
90 assert.ok(embed.description.length <= 4096);
91 assert.ok(embed.footer.text.length <= 2048);
92 total += embed.title.length + embed.description.length + embed.footer.text.length;
93 }
94 assert.ok(total <= 6000);
95 }
96}
97
98it("includes nightly changes in order with their links and existing metadata", () => {
99 const payloads = buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes: notes });
100 assert.deepStrictEqual(payloads, [
101 {
102 content: "-# <@&222222222222222222>",
103 allowed_mentions: { parse: [], roles: [latestAnnouncement.roleId] },
104 embeds: [
105 {
106 title: latestAnnouncement.releaseName,
107 url: latestAnnouncement.releaseUrl.href,
108 description: `${intro}\n\n${formattedNotes}`,
109 color: 0x5865f2,
110 footer: { text: "v1.2.3" },
111 timestamp: latestAnnouncement.timestamp,
112 },
113 ],
114 },
115 ]);
116 assertDiscordLimits(payloads);
117});
118
119it("keeps stable announcements short even when a long changelog is provided", () => {
120 assert.deepStrictEqual(
121 buildDiscordReleaseAnnouncement({ ...latestAnnouncement, releaseNotes: notes.repeat(200) }),
122 [
123 {
124 content: "-# <@&222222222222222222>",
125 allowed_mentions: { parse: [], roles: [latestAnnouncement.roleId] },
126 embeds: [
127 {
128 title: latestAnnouncement.releaseName,
129 url: latestAnnouncement.releaseUrl.href,
130 description: `[View full release notes on GitHub](${latestAnnouncement.releaseUrl.href})`,
131 color: 0x2ecc71,
132 footer: { text: "v1.2.3" },
133 timestamp: latestAnnouncement.timestamp,
134 },
135 ],
136 },
137 ],
138 );
139});
140
141it("links contributor profiles and escapes brackets in change titles and bot handles", () => {
142 const releaseNotes =
143 "* 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";
144 const payloads = buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes });
145 assert.equal(
146 payloadDescription(payloads),
147 `${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)`,
148 );
149});
150
151it("preserves custom notes and links outside generated GitHub pull request entries", () => {
152 const releaseNotes =
153 "## Migration\n* Written by @someone in the docs\n* Commit https://github.com/pingdotgg/t3code/commit/abc123\n[Guide](https://example.com/guide)";
154 assert.equal(
155 payloadDescription(buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes })),
156 `${intro}\n\n${releaseNotes}`,
157 );
158});
159
160it("links the Full Changelog label instead of displaying the comparison URL", () => {
161 const url = "https://github.com/pingdotgg/t3code/compare/previous...next";
162 const releaseNotes = `**Full Changelog**: ${url}\nFull Changelog: ${url}`;
163 const description = payloadDescription(
164 buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes }),
165 );
166 assert.equal(description, `${intro}\n\n[Full Changelog](${url})\n[Full Changelog](${url})`);
167});
168
169it("keeps inline change attribution whole at a message boundary", () => {
170 const title = "* [Fix reconnection](https://github.com/pingdotgg/t3code/pull/10)";
171 const contributor = "[@contributor](https://github.com/contributor)";
172 const filler = "x".repeat(4096 - intro.length - 2 - title.length - 2);
173 const releaseNotes = `${filler}\n* Fix reconnection by @contributor in https://github.com/pingdotgg/t3code/pull/10`;
174 const payloads = buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes });
175 assert.equal(payloads.length, 2);
176 assert.equal(payloads[1]?.embeds[0]?.description, `${title} by ${contributor}`);
177 assert.equal(payloadDescription(payloads), `${intro}\n\n${filler}\n${title} by ${contributor}`);
178 assertDiscordLimits(payloads);
179});
180
181it("falls back when notes are missing, empty, or whitespace", () => {
182 const fallback = buildDiscordReleaseAnnouncement(nightlyAnnouncement);
183 assert.equal(payloadDescription(fallback), intro);
184 assert.deepStrictEqual(
185 buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes: "" }),
186 fallback,
187 );
188 assert.deepStrictEqual(
189 buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes: " \r\n\t" }),
190 fallback,
191 );
192});
193
194it("splits long notes at line boundaries without dropping changes or the comparison link", () => {
195 const longNotes =
196 Array.from(
197 { length: 150 },
198 (_, i) => `* Change ${i} in https://github.com/pingdotgg/t3code/pull/${i}\n`,
199 ).join("") + notes;
200 const payloads = buildDiscordReleaseAnnouncement({
201 ...nightlyAnnouncement,
202 releaseNotes: longNotes,
203 });
204 assert.ok(payloads.length > 2);
205 const formattedChanges = Array.from(
206 { length: 150 },
207 (_, i) => `* [Change ${i}](https://github.com/pingdotgg/t3code/pull/${i})\n`,
208 ).join("");
209 assert.equal(payloadDescription(payloads), `${intro}\n\n${formattedChanges}${formattedNotes}`);
210 assertDiscordLimits(payloads);
211 for (const [index, payload] of payloads.entries()) {
212 assert.deepStrictEqual(payload.allowed_mentions, {
213 parse: [],
214 roles: index === 0 ? [latestAnnouncement.roleId] : [],
215 });
216 if (index > 0) {
217 assert.equal(payload.content, "");
218 assert.equal(
219 payload.embeds[0]?.title,
220 `Changelog continued (${index + 1}/${payloads.length})`,
221 );
222 }
223 if (index < payloads.length - 1) assert.ok(payload.embeds[0]?.description.endsWith("\n"));
224 assert.equal(payload.embeds[0]?.url, nightlyAnnouncement.releaseUrl.href);
225 }
226});
227
228it("honors the aggregate embed limit when metadata is also large", () => {
229 const releaseNotes = "a".repeat(16000);
230 const payloads = buildDiscordReleaseAnnouncement({
231 ...nightlyAnnouncement,
232 releaseName: "n".repeat(256),
233 version: "x".repeat(2047),
234 releaseNotes,
235 });
236 assertDiscordLimits(payloads);
237 assert.equal(payloadDescription(payloads), `${intro}\n\n${releaseNotes}`);
238});
239
240it("handles oversized lines, words, and Unicode without losing or breaking characters", () => {
241 const longNotes =
242 "a".repeat(9000) + "\n" + "🦋".repeat(4500) + "\n" + "word ".repeat(2000).trim();
243 const payloads = buildDiscordReleaseAnnouncement({
244 ...nightlyAnnouncement,
245 releaseNotes: longNotes,
246 });
247 assertDiscordLimits(payloads);
248 assert.equal(payloadDescription(payloads), `${intro}\n\n${longNotes}`);
249 for (const payload of payloads) assert.ok(payload.embeds[0]?.description.isWellFormed());
250});
251
252it("accepts exactly 4096 description characters and splits the next character", () => {
253 const releaseNotes = "x".repeat(4096 - intro.length - 2);
254 assert.equal(buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes }).length, 1);
255 const longer = buildDiscordReleaseAnnouncement({
256 ...nightlyAnnouncement,
257 releaseNotes: releaseNotes + "x",
258 });
259 assert.equal(longer.length, 2);
260 assert.equal(payloadDescription(longer), `${intro}\n\n${releaseNotes}x`);
261 assertDiscordLimits(longer);
262});
263
264it("suppresses everyone, here, user, and role mentions on every message", () => {
265 const mentions = `@everyone @here <@123> <@!123> <@&456> <@&${nightlyAnnouncement.roleId}>`;
266 const inert = mentions.replaceAll("@", "@\u200b");
267 const payloads = buildDiscordReleaseAnnouncement({
268 ...nightlyAnnouncement,
269 releaseName: mentions,
270 releaseNotes: `${mentions}\n`.repeat(100) + notes,
271 });
272 assert.ok(payloads.length > 1);
273 assert.equal(
274 payloadDescription(payloads),
275 `${intro}\n\n${`${inert}\n`.repeat(100)}${formattedNotes}`,
276 );
277 for (const [index, payload] of payloads.entries()) {
278 assert.deepStrictEqual(payload.allowed_mentions, {
279 parse: [],
280 roles: index === 0 ? [latestAnnouncement.roleId] : [],
281 });
282 if (index === 0) assert.equal(payload.embeds[0]?.title, inert);
283 assert.ok(!payload.content.includes("@everyone"));
284 assert.ok(!payload.content.includes("@here"));
285 assert.equal(payload.content.match(/<@&\d+>/g)?.length ?? 0, index === 0 ? 1 : 0);
286 }
287});
288
289it("rejects invalid metadata before sending", () => {
290 assert.throws(
291 () => buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseName: "x".repeat(257) }),
292 /title exceeds/,
293 );
294 assert.throws(
295 () => buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, version: "x".repeat(2048) }),
296 /footer exceeds/,
297 );
298 assert.throws(
299 () => buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, roleId: "123> @everyone" }),
300 /Invalid Discord release role/,
301 );
302});
303
304it.effect("retains safe request context without leaking the webhook URL or nested cause", () =>
305 Effect.gen(function* () {
306 const payload = buildDiscordReleaseAnnouncement(latestAnnouncement)[0]!;
307 const logs: string[] = [];
308 const client = Layer.succeed(
309 HttpClient.HttpClient,
310 HttpClient.make((request) =>
311 Effect.fail(
312 new HttpClientError.HttpClientError({
313 reason: new HttpClientError.EncodeError({
314 request,
315 cause: new Error(`Encoder failed for ${webhookUrl.href}`),
316 }),
317 }),
318 ),
319 ),
320 );
321 const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe(
322 Effect.tapCause(Effect.logError),
323 Effect.provide([
324 client,
325 Logger.layer([
326 Logger.make((options) => {
327 logs.push(Logger.formatJson.log(options));
328 }),
329 ]),
330 ]),
331 Effect.flip,
332 );
333 if (error._tag !== "DiscordReleaseWebhookRequestError")
334 assert.fail(`Unexpected error: ${error._tag}`);
335 assert.equal(error.reason, "EncodeError");
336 assert.equal(error.target, "latest");
337 assert.equal(error.tag, latestAnnouncement.tag);
338 assert.equal(error.webhookOrigin, webhookUrl.origin);
339 assert.equal(error.webhookPathnameSegmentCount, 4);
340 assert.equal(error.contentLength, payload.content.length);
341 assert.equal(error.allowedRoleMentionCount, 1);
342 assert.ok(!Cause.pretty(Cause.fail(error)).includes("test-secret-token"));
343 assert.ok(!logs.join("\n").includes("test-secret-token"));
344 }),
345);
346
347it.effect(
348 "retains response status without retaining the secret-bearing request or response body",
349 () =>
350 Effect.gen(function* () {
351 const client = Layer.succeed(
352 HttpClient.HttpClient,
353 HttpClient.make((request) =>
354 Effect.succeed(
355 HttpClientResponse.fromWeb(request, new Response(webhookUrl.href, { status: 400 })),
356 ),
357 ),
358 );
359 const error = yield* postDiscordWebhook(
360 webhookUrl,
361 buildDiscordReleaseAnnouncement(latestAnnouncement)[0]!,
362 latestAnnouncement,
363 ).pipe(Effect.provide(client), Effect.flip);
364 if (error._tag !== "DiscordReleaseWebhookResponseError")
365 assert.fail(`Unexpected error: ${error._tag}`);
366 assert.equal(error.status, 400);
367 assert.equal(error.tag, latestAnnouncement.tag);
368 assert.ok(!Cause.pretty(Cause.fail(error)).includes("test-secret-token"));
369 }),
370);
371
372it.effect("waits for Retry-After before retrying a rate-limited message", () =>
373 Effect.gen(function* () {
374 const limited = yield* Deferred.make<void>();
375 let attempts = 0;
376 const client = Layer.succeed(
377 HttpClient.HttpClient,
378 HttpClient.make((request) =>
379 Effect.gen(function* () {
380 attempts += 1;
381 if (attempts === 1) {
382 yield* Deferred.succeed(limited, undefined);
383 return HttpClientResponse.fromWeb(
384 request,
385 new Response(null, { status: 429, headers: { "retry-after": "2" } }),
386 );
387 }
388 return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }));
389 }),
390 ),
391 );
392 const fiber = yield* postDiscordWebhook(
393 webhookUrl,
394 buildDiscordReleaseAnnouncement(latestAnnouncement)[0]!,
395 latestAnnouncement,
396 ).pipe(Effect.provide(client), Effect.forkChild);
397 yield* Deferred.await(limited);
398 yield* TestClock.adjust("1 second");
399 assert.equal(attempts, 1);
400 yield* TestClock.adjust("1 second");
401 yield* Fiber.join(fiber);
402 assert.equal(attempts, 2);
403 }),
404);
405
406it.effect("bounds total retry time while preserving delays within the deadline", () =>
407 Effect.gen(function* () {
408 const limited = yield* Deferred.make<void>();
409 let attempts = 0;
410 const client = Layer.succeed(
411 HttpClient.HttpClient,
412 HttpClient.make((request) =>
413 Effect.gen(function* () {
414 attempts += 1;
415 yield* Deferred.succeed(limited, undefined);
416 return HttpClientResponse.fromWeb(
417 request,
418 new Response(null, { status: 429, headers: { "retry-after": "40" } }),
419 );
420 }),
421 ),
422 );
423 const fiber = yield* postDiscordWebhook(
424 webhookUrl,
425 buildDiscordReleaseAnnouncement(latestAnnouncement)[0]!,
426 latestAnnouncement,
427 ).pipe(Effect.provide(client), Effect.result, Effect.forkChild);
428 yield* Deferred.await(limited);
429 yield* TestClock.adjust("39 seconds");
430 assert.equal(attempts, 1);
431 yield* TestClock.adjust("1 second");
432 assert.equal(attempts, 2);
433 yield* TestClock.adjust("20 seconds");
434 const result = yield* Fiber.join(fiber);
435 assert.equal(attempts, 2);
436 assert.equal(result._tag, "Failure");
437 if (result._tag !== "Failure") assert.fail("Expected the retry deadline to expire");
438 assert.equal(result.failure._tag, "DiscordReleaseWebhookRequestError");
439 if (result.failure._tag !== "DiscordReleaseWebhookRequestError")
440 assert.fail(`Unexpected error: ${result.failure._tag}`);
441 assert.equal(result.failure.reason, "TimeoutError");
442 assert.ok(!Cause.pretty(Cause.fail(result.failure)).includes("test-secret-token"));
443 }),
444);
445
446// Run the checked-in shell commands with gh/node stubs, then send their captured
447// arguments through the real CLI parser and an injected HTTP client.
448function workflowRun(workflow: string, stepName: string) {
449 const step = workflow.split(` - name: ${stepName}\n`)[1]?.split("\n - name:")[0];
450 assert.ok(step, `Missing workflow step: ${stepName}`);
451 const run = step.split(" run: |\n")[1];
452 assert.ok(run, `Missing run block: ${stepName}`);
453 return run
454 .split("\n")
455 .map((line) => line.replace(/^ /, ""))
456 .join("\n");
457}
458
459it.layer(NodeServices.layer)("Discord release CLI and workflow", (it) => {
460 it.effect("passes the published nightly body from the workflow to ordered sends", () =>
461 Effect.gen(function* () {
462 const fs = yield* FileSystem.FileSystem;
463 const path = yield* Path.Path;
464 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
465 const temp = yield* fs.makeTempDirectoryScoped({ prefix: "discord release " });
466 const workflow = yield* fs.readFileString(
467 yield* path.fromFileUrl(new URL("../.github/workflows/release.yml", import.meta.url)),
468 );
469 const publishedNotes =
470 Array.from({ length: 50 }, () => notes).join("\n\n") +
471 '\n* Literal $(touch "$RUNNER_TEMP/executed") and `touch "$RUNNER_TEMP/executed"`';
472 const read = workflowRun(workflow, "Read published release notes");
473 const shellEnv = {
474 RUNNER_TEMP: temp,
475 RELEASE_TAG: nightlyAnnouncement.tag,
476 RELEASE_CHANNEL: "nightly",
477 FIXTURE_NOTES: publishedNotes,
478 };
479 const readStatus = yield* spawner.exitCode(
480 ChildProcess.make(
481 "bash",
482 [
483 "-e",
484 "-c",
485 `gh() { [ "$*" = 'release view v1.2.3 --json body --jq .body // ""' ] || return 1; printf '%s' "$FIXTURE_NOTES"; }\n${read}`,
486 ],
487 { env: shellEnv },
488 ),
489 );
490 assert.equal(readStatus, 0);
491 assert.equal(yield* fs.readFileString(`${temp}/discord-release-notes.md`), publishedNotes);
492 const replacements: Record<string, string> = {
493 "needs.preflight.outputs.release_name": latestAnnouncement.releaseName,
494 "needs.preflight.outputs.version": latestAnnouncement.version,
495 "needs.preflight.outputs.tag": latestAnnouncement.tag,
496 "github.repository": "pingdotgg/t3code",
497 };
498 const announce = workflowRun(workflow, "Announce prerelease on Discord").replace(
499 /\$\{\{ (.*?) \}\}/g,
500 (_, key: string) => {
501 assert.ok(replacements[key], `Unexpected expression: ${key}`);
502 return replacements[key]!;
503 },
504 );
505 const announceStatus = yield* spawner.exitCode(
506 ChildProcess.make(
507 "bash",
508 ["-e", "-c", `node() { printf '%s\\0' "$@" > "$RUNNER_TEMP/args"; }\n${announce}`],
509 { env: { RUNNER_TEMP: temp, DISCORD_MENTION_ROLE_ID: latestAnnouncement.roleId } },
510 ),
511 );
512 assert.equal(announceStatus, 0);
513 const args = (yield* fs.readFileString(`${temp}/args`)).split("\0").slice(1, -1);
514 const requests: string[] = [];
515 yield* runCli(args).pipe(Effect.provide([configLayer, captureClient(requests)]));
516 const payloads = requests.map((request) => decodePayload(request));
517 assert.ok(payloads.length > 2);
518 assert.equal(
519 payloads.flatMap((payload) => payload.embeds.map((embed) => embed.description)).join(""),
520 `${intro}\n\n${Array.from({ length: 50 }, () => formattedNotes).join("\n\n")}\n* Literal $(touch "$RUNNER_TEMP/executed") and \`touch "$RUNNER_TEMP/executed"\``,
521 );
522 for (const [index, payload] of payloads.entries())
523 assert.deepStrictEqual(payload.allowed_mentions, {
524 parse: [],
525 roles: index === 0 ? [latestAnnouncement.roleId] : [],
526 });
527 assert.equal(yield* fs.exists(`${temp}/executed`), false);
528 assert.equal(
529 yield* spawner.exitCode(
530 ChildProcess.make("bash", ["-e", "-c", `gh() { return 99; }\n${read}`], {
531 env: { ...shellEnv, RELEASE_CHANNEL: "stable" },
532 }),
533 ),
534 0,
535 );
536 assert.equal(yield* fs.readFileString(`${temp}/discord-release-notes.md`), "");
537 assert.equal(
538 yield* spawner.exitCode(
539 ChildProcess.make(
540 "bash",
541 ["-e", "-c", `gh() { printf '%s' 'partial changelog'; return 42; }\n${read}`],
542 { env: shellEnv },
543 ),
544 ),
545 0,
546 );
547 assert.equal(yield* fs.readFileString(`${temp}/discord-release-notes.md`), "");
548 const fallbackRequests: string[] = [];
549 yield* runCli(args).pipe(Effect.provide([configLayer, captureClient(fallbackRequests)]));
550 assert.equal(fallbackRequests.length, 1);
551 const fallback = decodePayload(fallbackRequests[0]!);
552 assert.equal(fallback.embeds[0]?.description, intro);
553 assert.equal(fallback.content, `-# <@&${latestAnnouncement.roleId}>`);
554 assert.deepStrictEqual(fallback.allowed_mentions, {
555 parse: [],
556 roles: [latestAnnouncement.roleId],
557 });
558 assert.ok(
559 workflow
560 .split(" - name: Announce prerelease on Discord\n")[1]
561 ?.startsWith(" if: needs.preflight.outputs.is_prerelease == 'true'\n"),
562 );
563 assert.ok(workflow.includes("GH_REPO: ${{ github.repository }}"));
564 assert.ok(workflow.includes("RELEASE_TAG: ${{ needs.preflight.outputs.tag }}"));
565 assert.ok(
566 !workflowRun(workflow, "Announce latest release on Discord").includes(
567 "--release-notes-file",
568 ),
569 );
570 assert.ok(
571 workflow
572 .slice(workflow.indexOf(" announce_discord:"))
573 .includes("needs.preflight.outputs.release_channel != 'preview'"),
574 );
575 }),
576 );
577
578 it.effect("uses the fallback when notes are omitted and ignores a notes file for latest", () =>
579 Effect.gen(function* () {
580 const requests: string[] = [];
581 yield* runCli(cliArgs("prerelease")).pipe(
582 Effect.provide([configLayer, captureClient(requests)]),
583 );
584 assert.equal(decodePayload(requests[0]!).embeds[0]?.description, intro);
585 yield* runCli([
586 ...cliArgs("latest"),
587 "--release-notes-file",
588 "/missing-stable-notes.md",
589 ]).pipe(Effect.provide([configLayer, captureClient(requests)]));
590 assert.equal(requests.length, 2);
591 assert.equal(
592 decodePayload(requests[1]!).embeds[0]?.description,
593 `[View full release notes on GitHub](${latestAnnouncement.releaseUrl.href})`,
594 );
595 }),
596 );
597
598 it.effect("fails before sending when the requested nightly notes file cannot be read", () =>
599 Effect.gen(function* () {
600 const requests: string[] = [];
601 const result = yield* runCli([
602 ...cliArgs("prerelease"),
603 "--release-notes-file",
604 "/missing-nightly-notes.md",
605 ]).pipe(Effect.provide([configLayer, captureClient(requests)]), Effect.result);
606 assert.equal(result._tag, "Failure");
607 assert.deepStrictEqual(requests, []);
608 }),
609 );
610
611 it.effect("stops on a failed send rather than posting later continuations", () =>
612 Effect.gen(function* () {
613 const fs = yield* FileSystem.FileSystem;
614 const temp = yield* fs.makeTempDirectoryScoped();
615 const file = `${temp}/notes.md`;
616 yield* fs.writeFileString(file, notes.repeat(100));
617 const requests: string[] = [];
618 const result = yield* runCli([...cliArgs("prerelease"), "--release-notes-file", file]).pipe(
619 Effect.provide([configLayer, captureClient(requests, 400)]),
620 Effect.result,
621 );
622 assert.equal(result._tag, "Failure");
623 assert.equal(requests.length, 1);
624 }),
625 );
626
627 it.effect("does not expose an invalid webhook secret in configuration errors", () =>
628 Effect.gen(function* () {
629 const requests: string[] = [];
630 const result = yield* runCli(cliArgs("latest")).pipe(
631 Effect.provide([
632 ConfigProvider.layer(
633 ConfigProvider.fromEnv({ env: { DISCORD_WEBHOOK_URL: "invalid-test-secret-token" } }),
634 ),
635 captureClient(requests),
636 ]),
637 Effect.result,
638 );
639 assert.equal(result._tag, "Failure");
640 if (result._tag === "Failure")
641 assert.ok(!Cause.pretty(Cause.fail(result.failure)).includes("test-secret-token"));
642 assert.deepStrictEqual(requests, []);
643 }),
644 );
645});