authorgravatar for 118054752+extoci@users.noreply.github.comExotic <118054752+extoci@users.noreply.github.com> 2026-10-05 00:10:37+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 18:10:37-03:00
logb4381985db55755ff15340d118160c14006ced13
tree3b957f54571645498d002f4bc177453d61b216a0
parent484459f0a9633e56214a7cd35b790c96f94190b4
signature Signed by PGP key B5690EEEBB952194

feat(release): include nightly changelogs in Discord announcements (#15754)


3 files changed, 782 insertions(+), 170 deletions(-)

.github/workflows/release.yml+20-1
......@@ -1347,6 +1347,24 @@ jobs:
13471347 args:
13481348 - --filter=@t3tools/scripts...
13491349
1350 - name: Read published release notes
1351 if: needs.preflight.outputs.is_prerelease == 'true'
1352 continue-on-error: true
1353 env:
1354 GH_TOKEN: ${{ github.token }}
1355 GH_REPO: ${{ github.repository }}
1356 RELEASE_TAG: ${{ needs.preflight.outputs.tag }}
1357 RELEASE_CHANNEL: ${{ needs.preflight.outputs.release_channel }}
1358 run: |
1359 if [ "$RELEASE_CHANNEL" = 'nightly' ]; then
1360 if ! gh release view "$RELEASE_TAG" --json body --jq '.body // ""' > "$RUNNER_TEMP/discord-release-notes.md"; then
1361 : > "$RUNNER_TEMP/discord-release-notes.md"
1362 echo '::warning::Could not read published release notes; announcing without a changelog.'
1363 fi
1364 else
1365 : > "$RUNNER_TEMP/discord-release-notes.md"
1366 fi
1367
13501368 - name: Announce prerelease on Discord
13511369 if: needs.preflight.outputs.is_prerelease == 'true'
13521370 continue-on-error: true
......@@ -1359,7 +1377,8 @@ jobs:
13591377 --release-name "${{ needs.preflight.outputs.release_name }}" \
13601378 --release-version "${{ needs.preflight.outputs.version }}" \
13611379 --tag "${{ needs.preflight.outputs.tag }}" \
1362 --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}"
1380 --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" \
1381 --release-notes-file "$RUNNER_TEMP/discord-release-notes.md"
13631382
13641383 - name: Announce latest release on Discord
13651384 if: needs.preflight.outputs.make_latest == 'true'
scripts/notify-discord-release.test.ts+594-117
......@@ -1,9 +1,25 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
12import { assert, it } from "@effect/vitest";
3import * as Cause from "effect/Cause";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Deferred from "effect/Deferred";
26import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as FileSystem from "effect/FileSystem";
39import * as Layer from "effect/Layer";
4import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
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/unstable/cli";
15import { HttpClient, HttpClientError, HttpClientResponse, UrlParams } from "effect/unstable/http";
16import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
517
6import { buildDiscordReleaseAnnouncement, postDiscordWebhook } from "./notify-discord-release.ts";
18import {
19 buildDiscordReleaseAnnouncement,
20 notifyDiscordReleaseCommand,
21 postDiscordWebhook,
22} from "./notify-discord-release.ts";
723
824const latestAnnouncement = {
925 target: "latest",
......@@ -11,158 +27,619 @@ const latestAnnouncement = {
1127 releaseName: "T3 Code v1.2.3",
1228 version: "1.2.3",
1329 tag: "v1.2.3",
14 releaseUrl: new URL("https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3"),
30 releaseUrl: new URL("https://github.com/pingdotgg/t3code/releases/tag/v1.2.3"),
1531 timestamp: "2026-05-01T01:41:00.000Z",
1632} 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 );
1782
18const webhookUrl = new URL("https://discord.com/api/webhooks/123456/secret-token");
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}
1997
20it("builds a prerelease Discord announcement for nightly subscribers", () => {
21 assert.deepStrictEqual(
22 buildDiscordReleaseAnnouncement({
23 target: "prerelease",
24 roleId: "111111111111111111",
25 releaseName: "T3 Code Nightly 1.2.4-nightly.20260501.17 (abcdef123456)",
26 version: "1.2.4-nightly.20260501.17",
27 tag: "v1.2.4-nightly.20260501.17",
28 releaseUrl: new URL(
29 "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.4-nightly.20260501.17",
30 ),
31 timestamp: "2026-05-01T01:41:00.000Z",
32 }),
98it("includes nightly changes in order with their links and existing metadata", () => {
99 const payloads = buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes: notes });
100 assert.deepStrictEqual(payloads, [
33101 {
34 content:
35 "<@&111111111111111111> Prerelease published: T3 Code Nightly 1.2.4-nightly.20260501.17 (abcdef123456)",
36 allowed_mentions: {
37 roles: ["111111111111111111"],
38 },
102 content: "-# <@&222222222222222222>",
103 allowed_mentions: { parse: [], roles: [latestAnnouncement.roleId] },
39104 embeds: [
40105 {
41 title: "T3 Code Nightly 1.2.4-nightly.20260501.17 (abcdef123456)",
42 url: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.4-nightly.20260501.17",
43 description: "A new T3 Code prerelease is available for nightly testers.",
106 title: latestAnnouncement.releaseName,
107 url: latestAnnouncement.releaseUrl.href,
108 description: `${intro}\n\n${formattedNotes}`,
44109 color: 0x5865f2,
45 fields: [
46 {
47 name: "Version",
48 value: "1.2.4-nightly.20260501.17",
49 inline: true,
50 },
51 {
52 name: "Tag",
53 value: "v1.2.4-nightly.20260501.17",
54 inline: true,
55 },
56 ],
57 timestamp: "2026-05-01T01:41:00.000Z",
110 footer: { text: "v1.2.3" },
111 timestamp: latestAnnouncement.timestamp,
58112 },
59113 ],
60114 },
61 );
115 ]);
116 assertDiscordLimits(payloads);
62117});
63118
64it("builds a latest Discord announcement for stable subscribers", () => {
65 assert.deepStrictEqual(buildDiscordReleaseAnnouncement(latestAnnouncement), {
66 content: "<@&222222222222222222> Latest published: T3 Code v1.2.3",
67 allowed_mentions: {
68 roles: ["222222222222222222"],
69 },
70 embeds: [
119it("keeps stable announcements short even when a long changelog is provided", () => {
120 assert.deepStrictEqual(
121 buildDiscordReleaseAnnouncement({ ...latestAnnouncement, releaseNotes: notes.repeat(200) }),
122 [
71123 {
72 title: "T3 Code v1.2.3",
73 url: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3",
74 description: "A new T3 Code latest release is available.",
75 color: 0x2ecc71,
76 fields: [
77 {
78 name: "Version",
79 value: "1.2.3",
80 inline: true,
81 },
124 content: "-# <@&222222222222222222>",
125 allowed_mentions: { parse: [], roles: [latestAnnouncement.roleId] },
126 embeds: [
82127 {
83 name: "Tag",
84 value: "v1.2.3",
85 inline: true,
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,
86134 },
87135 ],
88 timestamp: "2026-05-01T01:41:00.000Z",
89136 },
90137 ],
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,
91203 });
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 }
92226});
93227
94it.effect("preserves webhook request context and the full client cause chain", () => {
95 const payload = buildDiscordReleaseAnnouncement(latestAnnouncement);
96 const requestCause = new Error("request encoder unavailable");
97 let clientError: HttpClientError.HttpClientError | undefined;
98 const httpClientLayer = Layer.succeed(
99 HttpClient.HttpClient,
100 HttpClient.make((request) => {
101 clientError = new HttpClientError.HttpClientError({
102 reason: new HttpClientError.EncodeError({
103 request,
104 cause: requestCause,
105 }),
106 });
107 return Effect.fail(clientError);
108 }),
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}`,
109276 );
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});
110288
111 return Effect.gen(function* () {
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 );
112321 const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe(
113 Effect.provide(httpClientLayer),
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 ]),
114331 Effect.flip,
115332 );
116
117 if (error._tag !== "DiscordReleaseWebhookRequestError") {
333 if (error._tag !== "DiscordReleaseWebhookRequestError")
118334 assert.fail(`Unexpected error: ${error._tag}`);
119 }
335 assert.equal(error.reason, "EncodeError");
120336 assert.equal(error.target, "latest");
121 assert.equal(error.releaseName, latestAnnouncement.releaseName);
122 assert.equal(error.version, latestAnnouncement.version);
123337 assert.equal(error.tag, latestAnnouncement.tag);
124 assert.equal(error.releaseUrl, latestAnnouncement.releaseUrl.href);
125338 assert.equal(error.webhookOrigin, webhookUrl.origin);
126339 assert.equal(error.webhookPathnameSegmentCount, 4);
127340 assert.equal(error.contentLength, payload.content.length);
128 assert.equal(error.embedCount, 1);
129341 assert.equal(error.allowedRoleMentionCount, 1);
130 assert.equal(error.hasRoleMentionSyntax, true);
131 assert.equal(error.cause, clientError);
132 assert.equal((error.cause as HttpClientError.HttpClientError).cause, requestCause);
133 assert.ok(!error.message.includes(requestCause.message));
134 });
135});
342 assert.ok(!Cause.pretty(Cause.fail(error)).includes("test-secret-token"));
343 assert.ok(!logs.join("\n").includes("test-secret-token"));
344 }),
345);
136346
137it.effect("preserves a non-success response error with structured status context", () => {
138 const payload = buildDiscordReleaseAnnouncement(latestAnnouncement);
139 const httpClientLayer = Layer.succeed(
140 HttpClient.HttpClient,
141 HttpClient.make((request) =>
142 Effect.succeed(
143 HttpClientResponse.fromWeb(request, new Response("invalid webhook", { status: 400 })),
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 }),
144390 ),
145 ),
146 );
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);
147405
148 return Effect.gen(function* () {
149 const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe(
150 Effect.provide(httpClientLayer),
151 Effect.flip,
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 ),
152422 );
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);
153445
154 if (error._tag !== "DiscordReleaseWebhookResponseError") {
155 assert.fail(`Unexpected error: ${error._tag}`);
156 }
157 assert.equal(error.target, "latest");
158 assert.equal(error.tag, latestAnnouncement.tag);
159 assert.equal(error.webhookOrigin, webhookUrl.origin);
160 assert.equal(error.webhookPathnameSegmentCount, 4);
161 assert.equal(error.status, 400);
162 if (!HttpClientError.isHttpClientError(error.cause)) {
163 assert.fail("Expected HttpClientError cause");
164 }
165 assert.equal(error.cause.reason._tag, "StatusCodeError");
166 assert.ok(!error.message.includes(error.cause.message));
167 });
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 );
168645});
scripts/notify-discord-release.ts+168-52
......@@ -5,13 +5,18 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
55import * as Config from "effect/Config";
66import * as DateTime from "effect/DateTime";
77import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
89import * as Layer from "effect/Layer";
910import * as Logger from "effect/Logger";
11import * as Option from "effect/Option";
12import * as Redacted from "effect/Redacted";
13import * as Schedule from "effect/Schedule";
1014import * as Schema from "effect/Schema";
1115import { Argument, Command, Flag } from "effect/unstable/cli";
1216import {
1317 FetchHttpClient,
1418 HttpClient,
19 HttpClientError,
1520 HttpClientRequest,
1621 HttpClientResponse,
1722} from "effect/unstable/http";
......@@ -26,11 +31,13 @@ export interface DiscordReleaseAnnouncementOptions {
2631 readonly tag: string;
2732 readonly releaseUrl: URL;
2833 readonly timestamp: string;
34 readonly releaseNotes?: string;
2935}
3036
3137interface DiscordWebhookPayload {
3238 readonly content: string;
3339 readonly allowed_mentions: {
40 readonly parse: ReadonlyArray<never>;
3441 readonly roles: ReadonlyArray<string>;
3542 };
3643 readonly embeds: ReadonlyArray<{
......@@ -38,18 +45,30 @@ interface DiscordWebhookPayload {
3845 readonly url: string;
3946 readonly description: string;
4047 readonly color: number;
41 readonly fields: ReadonlyArray<{
42 readonly name: string;
43 readonly value: string;
44 readonly inline: boolean;
45 }>;
48 readonly footer: { readonly text: string };
4649 readonly timestamp: string;
4750 }>;
4851}
4952
5053const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const;
5154const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/));
52const DiscordWebhookUrl = Config.URL("DISCORD_WEBHOOK_URL");
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}
5372
5473const discordReleaseErrorContext = {
5574 target: Schema.Literals(["prerelease", "latest"]),
......@@ -69,7 +88,7 @@ export class DiscordReleaseWebhookRequestError extends Schema.TaggedError<Discor
6988 "DiscordReleaseWebhookRequestError",
7089 {
7190 ...discordReleaseErrorContext,
72 cause: Schema.Defect(),
91 reason: Schema.String,
7392 },
7493) {
7594 override get message(): string {
......@@ -82,7 +101,6 @@ export class DiscordReleaseWebhookResponseError extends Schema.TaggedError<Disco
82101 {
83102 ...discordReleaseErrorContext,
84103 status: Schema.Number,
85 cause: Schema.Defect(),
86104 },
87105) {
88106 override get message(): string {
......@@ -90,11 +108,6 @@ export class DiscordReleaseWebhookResponseError extends Schema.TaggedError<Disco
90108 }
91109}
92110
93const targetLabels = {
94 prerelease: "Prerelease",
95 latest: "Latest",
96} as const satisfies Record<DiscordReleaseTarget, string>;
97
98111const targetColors = {
99112 prerelease: 0x5865f2,
100113 latest: 0x2ecc71,
......@@ -117,48 +130,137 @@ function summarizePayload(payload: DiscordWebhookPayload) {
117130 } as const;
118131}
119132
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
120181export const buildDiscordReleaseAnnouncement = (
121182 options: DiscordReleaseAnnouncementOptions,
122): DiscordWebhookPayload => ({
123 content: `<@&${options.roleId}> ${targetLabels[options.target]} published: ${options.releaseName}`,
124 allowed_mentions: {
125 roles: [options.roleId],
126 },
127 embeds: [
128 {
129 title: options.releaseName,
130 url: options.releaseUrl.href,
131 description:
132 options.target === "prerelease"
133 ? "A new T3 Code prerelease is available for nightly testers."
134 : "A new T3 Code latest release is available.",
135 color: targetColors[options.target],
136 fields: [
137 {
138 name: "Version",
139 value: options.version,
140 inline: true,
141 },
142 {
143 name: "Tag",
144 value: options.tag,
145 inline: true,
146 },
147 ],
148 timestamp: options.timestamp,
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] : [],
149216 },
150 ],
151});
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};
152230
153231export const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
154232 webhookUrl: URL,
155233 payload: DiscordWebhookPayload,
156234 announcement: DiscordReleaseAnnouncementOptions,
157235) {
236 const requestUrl = new URL(webhookUrl);
237 requestUrl.searchParams.set("wait", "true");
158238 const httpClient = (yield* HttpClient.HttpClient).pipe(
159239 HttpClient.retryTransient({
160240 retryOn: "errors-and-responses",
161241 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 ),
162264 }),
163265 );
164266
......@@ -180,14 +282,17 @@ export const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
180282 ...summarizePayload(payload),
181283 } as const;
182284
183 const response = yield* HttpClientRequest.post(webhookUrl).pipe(
285 const response = yield* HttpClientRequest.post(requestUrl).pipe(
184286 HttpClientRequest.bodyJson(payload),
185287 Effect.flatMap(httpClient.execute),
288 // Bound requests and retry waits together without shortening Retry-After.
289 Effect.timeout("1 minute"),
186290 Effect.mapError(
187 (cause) =>
291 (error) =>
188292 new DiscordReleaseWebhookRequestError({
189293 ...errorContext,
190 cause,
294 // HTTP errors retain the request URL, including the webhook token.
295 reason: error._tag === "HttpClientError" ? error.reason._tag : error._tag,
191296 }),
192297 ),
193298 );
......@@ -201,11 +306,10 @@ export const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
201306
202307 yield* HttpClientResponse.filterStatusOk(response).pipe(
203308 Effect.mapError(
204 (cause) =>
309 () =>
205310 new DiscordReleaseWebhookResponseError({
206311 ...errorContext,
207312 status: response.status,
208 cause,
209313 }),
210314 ),
211315 );
......@@ -237,8 +341,12 @@ export const notifyDiscordReleaseCommand = Command.make(
237341 Flag.withSchema(Schema.URLFromString),
238342 Flag.withDescription("Public GitHub release URL."),
239343 ),
344 releaseNotesFile: Flag.String("release-notes-file").pipe(
345 Flag.optional,
346 Flag.withDescription("File containing the published GitHub release notes."),
347 ),
240348 },
241 ({ target, roleId, releaseName, releaseVersion, tag, releaseUrl }) =>
349 ({ target, roleId, releaseName, releaseVersion, tag, releaseUrl, releaseNotesFile }) =>
242350 Effect.gen(function* () {
243351 yield* Effect.logInfo("discord release announcement starting").pipe(
244352 Effect.annotateLogs({
......@@ -253,6 +361,11 @@ export const notifyDiscordReleaseCommand = Command.make(
253361 );
254362
255363 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 : "";
256369 const timestamp = DateTime.formatIso(yield* DateTime.now);
257370 const announcement = {
258371 target,
......@@ -262,13 +375,16 @@ export const notifyDiscordReleaseCommand = Command.make(
262375 tag,
263376 releaseUrl,
264377 timestamp,
378 releaseNotes,
265379 } satisfies DiscordReleaseAnnouncementOptions;
266 const payload = buildDiscordReleaseAnnouncement(announcement);
380 const payloads = yield* Effect.sync(() => buildDiscordReleaseAnnouncement(announcement));
267381
268 yield* Effect.logInfo("discord release announcement payload built").pipe(
269 Effect.annotateLogs(summarizePayload(payload)),
382 yield* Effect.logInfo("discord release announcement payloads built").pipe(
383 Effect.annotateLogs({ messageCount: payloads.length }),
270384 );
271 yield* postDiscordWebhook(webhookUrl, payload, announcement);
385 for (const payload of payloads) {
386 yield* postDiscordWebhook(webhookUrl, payload, announcement);
387 }
272388 yield* Effect.logInfo("discord release announcement completed");
273389 }),
274390).pipe(Command.withDescription("Post a T3 Code release announcement to Discord."));