Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
86 changes: 86 additions & 0 deletions scripts/notify-discord-release.test.ts
Original file line number Diff line number Diff line change
@@ -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",
},
],
},
);
});
170 changes: 170 additions & 0 deletions scripts/notify-discord-release.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
};
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<DiscordReleaseTarget, string>;

const targetColors = {
prerelease: 0x5865f2,
latest: 0x2ecc71,
} as const satisfies Record<DiscordReleaseTarget, number>;

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,
);
}
Loading