diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 366a6b4de3c..6c3ca830fb1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -585,3 +585,60 @@ jobs: git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json bun.lock git commit -m "chore(release): prepare $RELEASE_TAG" git push origin HEAD:main + + announce_discord: + name: Announce release on Discord + if: | + always() && !cancelled() && + needs.preflight.result == 'success' && + needs.release.result == 'success' && + (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') + needs: [preflight, release, finalize] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Announce prerelease on Discord + if: needs.preflight.outputs.is_prerelease == 'true' + continue-on-error: true + env: + DISCORD_RELEASE_NIGHTLY_ROLE_ID: ${{ vars.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" \ + --release-name "${{ needs.preflight.outputs.release_name }}" \ + --version "${{ needs.preflight.outputs.version }}" \ + --tag "${{ needs.preflight.outputs.tag }}" \ + --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" + + - name: Announce latest release on Discord + if: needs.preflight.outputs.make_latest == 'true' + continue-on-error: true + env: + DISCORD_RELEASE_LATEST_ROLE_ID: ${{ vars.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" \ + --release-name "${{ needs.preflight.outputs.release_name }}" \ + --version "${{ needs.preflight.outputs.version }}" \ + --tag "${{ needs.preflight.outputs.tag }}" \ + --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" diff --git a/scripts/notify-discord-release.test.ts b/scripts/notify-discord-release.test.ts new file mode 100644 index 00000000000..e9de4afe4d0 --- /dev/null +++ b/scripts/notify-discord-release.test.ts @@ -0,0 +1,86 @@ +import { assert, it } from "@effect/vitest"; + +import { buildDiscordReleaseAnnouncement } from "./notify-discord-release.ts"; + +it("builds a prerelease Discord announcement for nightly subscribers", () => { + assert.deepStrictEqual( + buildDiscordReleaseAnnouncement({ + target: "prerelease", + roleId: "111111111111111111", + 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", + timestamp: "2026-05-01T01:41:00.000Z", + }), + { + content: + "<@&111111111111111111> Prerelease published: T3 Code Nightly 1.2.4-nightly.20260501.17 (abcdef123456)", + allowed_mentions: { + roles: ["111111111111111111"], + }, + embeds: [ + { + title: "T3 Code Nightly 1.2.4-nightly.20260501.17 (abcdef123456)", + url: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.4-nightly.20260501.17", + description: "A new T3 Code prerelease is available for nightly testers.", + color: 0x5865f2, + fields: [ + { + name: "Version", + value: "1.2.4-nightly.20260501.17", + inline: true, + }, + { + name: "Tag", + value: "v1.2.4-nightly.20260501.17", + inline: true, + }, + ], + timestamp: "2026-05-01T01:41:00.000Z", + }, + ], + }, + ); +}); + +it("builds a latest Discord announcement for stable subscribers", () => { + assert.deepStrictEqual( + buildDiscordReleaseAnnouncement({ + target: "latest", + roleId: "222222222222222222", + 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", + timestamp: "2026-05-01T01:41:00.000Z", + }), + { + content: "<@&222222222222222222> Latest published: T3 Code v1.2.3", + allowed_mentions: { + roles: ["222222222222222222"], + }, + embeds: [ + { + title: "T3 Code v1.2.3", + url: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3", + description: "A new T3 Code latest release is available.", + color: 0x2ecc71, + fields: [ + { + name: "Version", + value: "1.2.3", + inline: true, + }, + { + name: "Tag", + value: "v1.2.3", + inline: true, + }, + ], + timestamp: "2026-05-01T01:41:00.000Z", + }, + ], + }, + ); +}); diff --git a/scripts/notify-discord-release.ts b/scripts/notify-discord-release.ts new file mode 100644 index 00000000000..db51efd066a --- /dev/null +++ b/scripts/notify-discord-release.ts @@ -0,0 +1,170 @@ +#!/usr/bin/env node + +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 { Argument, Command, Flag } from "effect/unstable/cli"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; + +export type DiscordReleaseTarget = "prerelease" | "latest"; + +interface DiscordReleaseAnnouncementOptions { + readonly target: DiscordReleaseTarget; + readonly roleId: string; + readonly releaseName: string; + readonly version: string; + readonly tag: string; + readonly releaseUrl: string; + readonly timestamp: string; +} + +interface DiscordWebhookPayload { + readonly content: string; + readonly allowed_mentions: { + readonly roles: ReadonlyArray; + }; + readonly embeds: ReadonlyArray<{ + readonly title: string; + readonly url: string; + readonly description: string; + readonly color: number; + readonly fields: ReadonlyArray<{ + readonly name: string; + readonly value: string; + readonly inline: boolean; + }>; + readonly timestamp: string; + }>; +} + +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"); + +class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const targetLabels = { + prerelease: "Prerelease", + latest: "Latest", +} as const satisfies Record; + +const targetColors = { + prerelease: 0x5865f2, + latest: 0x2ecc71, +} as const satisfies Record; + +export const buildDiscordReleaseAnnouncement = ( + options: DiscordReleaseAnnouncementOptions, +): DiscordWebhookPayload => ({ + content: `<@&${options.roleId}> ${targetLabels[options.target]} published: ${options.releaseName}`, + allowed_mentions: { + roles: [options.roleId], + }, + embeds: [ + { + title: options.releaseName, + url: options.releaseUrl, + description: + options.target === "prerelease" + ? "A new T3 Code prerelease is available for nightly testers." + : "A new T3 Code latest release is available.", + color: targetColors[options.target], + fields: [ + { + name: "Version", + value: options.version, + inline: true, + }, + { + name: "Tag", + value: options.tag, + inline: true, + }, + ], + timestamp: options.timestamp, + }, + ], +}); + +const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( + webhookUrl: string, + payload: DiscordWebhookPayload, +) { + const httpClient = (yield* HttpClient.HttpClient).pipe( + HttpClient.retryTransient({ + retryOn: "errors-and-responses", + times: 3, + }), + HttpClient.filterStatusOk, + ); + + yield* HttpClientRequest.post(webhookUrl).pipe( + HttpClientRequest.bodyJson(payload), + Effect.flatMap(httpClient.execute), + Effect.mapError( + (cause) => + new DiscordReleaseAnnouncementError({ + message: "Failed to post Discord release announcement.", + cause, + }), + ), + ); +}); + +const runtimeLayer = Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer); + +export const notifyDiscordReleaseCommand = Command.make( + "notify-discord-release", + { + target: Argument.choice("target", DISCORD_RELEASE_TARGETS).pipe( + Argument.withDescription("Discord announcement target: prerelease or latest."), + ), + roleId: Flag.string("role-id").pipe( + Flag.withSchema(DiscordRoleIdSchema), + Flag.withDescription("Discord role ID to mention in the release announcement."), + ), + releaseName: Flag.string("release-name").pipe( + Flag.withSchema(Schema.NonEmptyString), + Flag.withDescription("Human-readable release name."), + ), + version: Flag.string("version").pipe( + Flag.withSchema(Schema.NonEmptyString), + Flag.withDescription("Release version."), + ), + tag: Flag.string("tag").pipe( + Flag.withSchema(Schema.NonEmptyString), + Flag.withDescription("Git tag for the release."), + ), + releaseUrl: Flag.string("release-url").pipe( + Flag.withSchema(WebUrlSchema), + Flag.withDescription("Public GitHub release URL."), + ), + }, + ({ target, roleId, releaseName, version, tag, releaseUrl }) => + Effect.gen(function* () { + const webhookUrl = yield* DiscordWebhookUrl; + yield* postDiscordWebhook( + webhookUrl, + buildDiscordReleaseAnnouncement({ + target, + roleId, + releaseName, + version, + tag, + releaseUrl, + timestamp: new Date().toISOString(), + }), + ); + }), +).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), + NodeRuntime.runMain, + ); +}