diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c3ca830fb1..4d46e78c54a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -619,11 +619,11 @@ jobs: if: needs.preflight.outputs.is_prerelease == 'true' continue-on-error: true env: - DISCORD_RELEASE_NIGHTLY_ROLE_ID: ${{ vars.DISCORD_RELEASE_NIGHTLY_ROLE_ID }} + DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_NIGHTLY_ROLE_ID }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} run: | node scripts/notify-discord-release.ts prerelease \ - --role-id "$DISCORD_RELEASE_NIGHTLY_ROLE_ID" \ + --role-id "$DISCORD_MENTION_ROLE_ID" \ --release-name "${{ needs.preflight.outputs.release_name }}" \ --version "${{ needs.preflight.outputs.version }}" \ --tag "${{ needs.preflight.outputs.tag }}" \ @@ -633,11 +633,11 @@ jobs: if: needs.preflight.outputs.make_latest == 'true' continue-on-error: true env: - DISCORD_RELEASE_LATEST_ROLE_ID: ${{ vars.DISCORD_RELEASE_LATEST_ROLE_ID }} + DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_LATEST_ROLE_ID }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} run: | node scripts/notify-discord-release.ts latest \ - --role-id "$DISCORD_RELEASE_LATEST_ROLE_ID" \ + --role-id "$DISCORD_MENTION_ROLE_ID" \ --release-name "${{ needs.preflight.outputs.release_name }}" \ --version "${{ needs.preflight.outputs.version }}" \ --tag "${{ needs.preflight.outputs.tag }}" \ diff --git a/scripts/notify-discord-release.test.ts b/scripts/notify-discord-release.test.ts index e9de4afe4d0..23f73fa0d76 100644 --- a/scripts/notify-discord-release.test.ts +++ b/scripts/notify-discord-release.test.ts @@ -10,7 +10,9 @@ it("builds a prerelease Discord announcement for nightly subscribers", () => { releaseName: "T3 Code Nightly 1.2.4-nightly.20260501.17 (abcdef123456)", version: "1.2.4-nightly.20260501.17", tag: "v1.2.4-nightly.20260501.17", - releaseUrl: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.4-nightly.20260501.17", + releaseUrl: new URL( + "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.4-nightly.20260501.17", + ), timestamp: "2026-05-01T01:41:00.000Z", }), { @@ -52,7 +54,7 @@ it("builds a latest Discord announcement for stable subscribers", () => { releaseName: "T3 Code v1.2.3", version: "1.2.3", tag: "v1.2.3", - releaseUrl: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3", + releaseUrl: new URL("https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3"), timestamp: "2026-05-01T01:41:00.000Z", }), { diff --git a/scripts/notify-discord-release.ts b/scripts/notify-discord-release.ts index db51efd066a..1789a166f2c 100644 --- a/scripts/notify-discord-release.ts +++ b/scripts/notify-discord-release.ts @@ -2,9 +2,14 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Config, Data, Effect, Layer, Schema } from "effect"; +import { Config, Data, Effect, Layer, Logger, Schema } from "effect"; import { Argument, Command, Flag } from "effect/unstable/cli"; -import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; export type DiscordReleaseTarget = "prerelease" | "latest"; @@ -14,7 +19,7 @@ interface DiscordReleaseAnnouncementOptions { readonly releaseName: string; readonly version: string; readonly tag: string; - readonly releaseUrl: string; + readonly releaseUrl: URL; readonly timestamp: string; } @@ -39,8 +44,7 @@ interface DiscordWebhookPayload { const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const; const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/)); -const WebUrlSchema = Schema.String.check(Schema.isPattern(/^https?:\/\/\S+$/)); -const DiscordWebhookUrl = Config.nonEmptyString("DISCORD_WEBHOOK_URL"); +const DiscordWebhookUrl = Config.url("DISCORD_WEBHOOK_URL"); class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{ readonly message: string; @@ -57,6 +61,23 @@ const targetColors = { latest: 0x2ecc71, } as const satisfies Record; +function describeWebhookUrl(webhookUrl: URL) { + return { + configured: true, + origin: webhookUrl.origin, + pathnameSegmentCount: webhookUrl.pathname.split("/").filter(Boolean).length, + } as const; +} + +function summarizePayload(payload: DiscordWebhookPayload) { + return { + contentLength: payload.content.length, + embedCount: payload.embeds.length, + allowedRoleMentionCount: payload.allowed_mentions.roles.length, + hasRoleMentionSyntax: payload.content.includes("<@&"), + } as const; +} + export const buildDiscordReleaseAnnouncement = ( options: DiscordReleaseAnnouncementOptions, ): DiscordWebhookPayload => ({ @@ -67,7 +88,7 @@ export const buildDiscordReleaseAnnouncement = ( embeds: [ { title: options.releaseName, - url: options.releaseUrl, + url: options.releaseUrl.href, description: options.target === "prerelease" ? "A new T3 Code prerelease is available for nightly testers." @@ -91,7 +112,7 @@ export const buildDiscordReleaseAnnouncement = ( }); const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( - webhookUrl: string, + webhookUrl: URL, payload: DiscordWebhookPayload, ) { const httpClient = (yield* HttpClient.HttpClient).pipe( @@ -99,10 +120,16 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( retryOn: "errors-and-responses", times: 3, }), - HttpClient.filterStatusOk, ); - yield* HttpClientRequest.post(webhookUrl).pipe( + yield* Effect.logInfo("discord webhook request dispatching").pipe( + Effect.annotateLogs({ + ...describeWebhookUrl(webhookUrl), + ...summarizePayload(payload), + }), + ); + + const response = yield* HttpClientRequest.post(webhookUrl).pipe( HttpClientRequest.bodyJson(payload), Effect.flatMap(httpClient.execute), Effect.mapError( @@ -113,9 +140,24 @@ const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( }), ), ); -}); -const runtimeLayer = Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer); + yield* Effect.logInfo("discord webhook response received").pipe( + Effect.annotateLogs({ + status: response.status, + ok: response.status >= 200 && response.status < 300, + }), + ); + + yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.mapError( + (cause) => + new DiscordReleaseAnnouncementError({ + message: `Discord webhook returned status ${response.status}.`, + cause, + }), + ), + ); +}); export const notifyDiscordReleaseCommand = Command.make( "notify-discord-release", @@ -140,31 +182,52 @@ export const notifyDiscordReleaseCommand = Command.make( Flag.withDescription("Git tag for the release."), ), releaseUrl: Flag.string("release-url").pipe( - Flag.withSchema(WebUrlSchema), + Flag.withSchema(Schema.URLFromString), Flag.withDescription("Public GitHub release URL."), ), }, ({ target, roleId, releaseName, version, tag, releaseUrl }) => Effect.gen(function* () { - const webhookUrl = yield* DiscordWebhookUrl; - yield* postDiscordWebhook( - webhookUrl, - buildDiscordReleaseAnnouncement({ + yield* Effect.logInfo("discord release announcement starting").pipe( + Effect.annotateLogs({ target, - roleId, + roleIdProvided: roleId.length > 0, + roleIdLength: roleId.length, releaseName, version, tag, releaseUrl, - timestamp: new Date().toISOString(), }), ); + + const webhookUrl = yield* DiscordWebhookUrl; + const payload = buildDiscordReleaseAnnouncement({ + target, + roleId, + releaseName, + version, + tag, + releaseUrl, + timestamp: new Date().toISOString(), + }); + + yield* Effect.logInfo("discord release announcement payload built").pipe( + Effect.annotateLogs(summarizePayload(payload)), + ); + yield* postDiscordWebhook(webhookUrl, payload); + yield* Effect.logInfo("discord release announcement completed"); }), ).pipe(Command.withDescription("Post a T3 Code release announcement to Discord.")); if (import.meta.main) { Command.run(notifyDiscordReleaseCommand, { version: "0.0.0" }).pipe( - Effect.provide(runtimeLayer), + Effect.provide( + Layer.mergeAll( + Logger.layer([Logger.consolePretty()]), + NodeServices.layer, + FetchHttpClient.layer, + ), + ), NodeRuntime.runMain, ); }