authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-30 19:13:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-30 19:13:05-07:00
loga480f07241b605fdb97e48298f5f9ea0f0c1bfb7
treefbd78ba435b2d8776670aa4a5162535eb7bd931d
parent1eb6fcea84a8ce3c01ecfb02d168ca27cd6420a6
signature Signed by PGP key B5690EEEBB952194

Add Discord release announcements (#2429)

Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>

3 files changed, 313 insertions(+), 0 deletions(-)

.github/workflows/release.yml+57
......@@ -585,3 +585,60 @@ jobs:
585585 git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json bun.lock
586586 git commit -m "chore(release): prepare $RELEASE_TAG"
587587 git push origin HEAD:main
588
589 announce_discord:
590 name: Announce release on Discord
591 if: |
592 always() && !cancelled() &&
593 needs.preflight.result == 'success' &&
594 needs.release.result == 'success' &&
595 (needs.finalize.result == 'success' || needs.finalize.result == 'skipped')
596 needs: [preflight, release, finalize]
597 runs-on: ubuntu-24.04
598 timeout-minutes: 10
599 steps:
600 - name: Checkout
601 uses: actions/checkout@v6
602 with:
603 ref: ${{ needs.preflight.outputs.ref }}
604
605 - name: Setup Bun
606 uses: oven-sh/setup-bun@v2
607 with:
608 bun-version-file: package.json
609
610 - name: Setup Node
611 uses: actions/setup-node@v6
612 with:
613 node-version-file: package.json
614
615 - name: Install dependencies
616 run: bun install --frozen-lockfile
617
618 - name: Announce prerelease on Discord
619 if: needs.preflight.outputs.is_prerelease == 'true'
620 continue-on-error: true
621 env:
622 DISCORD_RELEASE_NIGHTLY_ROLE_ID: ${{ vars.DISCORD_RELEASE_NIGHTLY_ROLE_ID }}
623 DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
624 run: |
625 node scripts/notify-discord-release.ts prerelease \
626 --role-id "$DISCORD_RELEASE_NIGHTLY_ROLE_ID" \
627 --release-name "${{ needs.preflight.outputs.release_name }}" \
628 --version "${{ needs.preflight.outputs.version }}" \
629 --tag "${{ needs.preflight.outputs.tag }}" \
630 --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}"
631
632 - name: Announce latest release on Discord
633 if: needs.preflight.outputs.make_latest == 'true'
634 continue-on-error: true
635 env:
636 DISCORD_RELEASE_LATEST_ROLE_ID: ${{ vars.DISCORD_RELEASE_LATEST_ROLE_ID }}
637 DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
638 run: |
639 node scripts/notify-discord-release.ts latest \
640 --role-id "$DISCORD_RELEASE_LATEST_ROLE_ID" \
641 --release-name "${{ needs.preflight.outputs.release_name }}" \
642 --version "${{ needs.preflight.outputs.version }}" \
643 --tag "${{ needs.preflight.outputs.tag }}" \
644 --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}"
scripts/notify-discord-release.test.ts created+86
......@@ -0,0 +1,86 @@
1import { assert, it } from "@effect/vitest";
2
3import { buildDiscordReleaseAnnouncement } from "./notify-discord-release.ts";
4
5it("builds a prerelease Discord announcement for nightly subscribers", () => {
6 assert.deepStrictEqual(
7 buildDiscordReleaseAnnouncement({
8 target: "prerelease",
9 roleId: "111111111111111111",
10 releaseName: "T3 Code Nightly 1.2.4-nightly.20260501.17 (abcdef123456)",
11 version: "1.2.4-nightly.20260501.17",
12 tag: "v1.2.4-nightly.20260501.17",
13 releaseUrl: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.4-nightly.20260501.17",
14 timestamp: "2026-05-01T01:41:00.000Z",
15 }),
16 {
17 content:
18 "<@&111111111111111111> Prerelease published: T3 Code Nightly 1.2.4-nightly.20260501.17 (abcdef123456)",
19 allowed_mentions: {
20 roles: ["111111111111111111"],
21 },
22 embeds: [
23 {
24 title: "T3 Code Nightly 1.2.4-nightly.20260501.17 (abcdef123456)",
25 url: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.4-nightly.20260501.17",
26 description: "A new T3 Code prerelease is available for nightly testers.",
27 color: 0x5865f2,
28 fields: [
29 {
30 name: "Version",
31 value: "1.2.4-nightly.20260501.17",
32 inline: true,
33 },
34 {
35 name: "Tag",
36 value: "v1.2.4-nightly.20260501.17",
37 inline: true,
38 },
39 ],
40 timestamp: "2026-05-01T01:41:00.000Z",
41 },
42 ],
43 },
44 );
45});
46
47it("builds a latest Discord announcement for stable subscribers", () => {
48 assert.deepStrictEqual(
49 buildDiscordReleaseAnnouncement({
50 target: "latest",
51 roleId: "222222222222222222",
52 releaseName: "T3 Code v1.2.3",
53 version: "1.2.3",
54 tag: "v1.2.3",
55 releaseUrl: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3",
56 timestamp: "2026-05-01T01:41:00.000Z",
57 }),
58 {
59 content: "<@&222222222222222222> Latest published: T3 Code v1.2.3",
60 allowed_mentions: {
61 roles: ["222222222222222222"],
62 },
63 embeds: [
64 {
65 title: "T3 Code v1.2.3",
66 url: "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3",
67 description: "A new T3 Code latest release is available.",
68 color: 0x2ecc71,
69 fields: [
70 {
71 name: "Version",
72 value: "1.2.3",
73 inline: true,
74 },
75 {
76 name: "Tag",
77 value: "v1.2.3",
78 inline: true,
79 },
80 ],
81 timestamp: "2026-05-01T01:41:00.000Z",
82 },
83 ],
84 },
85 );
86});
scripts/notify-discord-release.ts created+170
......@@ -0,0 +1,170 @@
1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { Config, Data, Effect, Layer, Schema } from "effect";
6import { Argument, Command, Flag } from "effect/unstable/cli";
7import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
8
9export type DiscordReleaseTarget = "prerelease" | "latest";
10
11interface DiscordReleaseAnnouncementOptions {
12 readonly target: DiscordReleaseTarget;
13 readonly roleId: string;
14 readonly releaseName: string;
15 readonly version: string;
16 readonly tag: string;
17 readonly releaseUrl: string;
18 readonly timestamp: string;
19}
20
21interface DiscordWebhookPayload {
22 readonly content: string;
23 readonly allowed_mentions: {
24 readonly roles: ReadonlyArray<string>;
25 };
26 readonly embeds: ReadonlyArray<{
27 readonly title: string;
28 readonly url: string;
29 readonly description: string;
30 readonly color: number;
31 readonly fields: ReadonlyArray<{
32 readonly name: string;
33 readonly value: string;
34 readonly inline: boolean;
35 }>;
36 readonly timestamp: string;
37 }>;
38}
39
40const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const;
41const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/));
42const WebUrlSchema = Schema.String.check(Schema.isPattern(/^https?:\/\/\S+$/));
43const DiscordWebhookUrl = Config.nonEmptyString("DISCORD_WEBHOOK_URL");
44
45class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{
46 readonly message: string;
47 readonly cause?: unknown;
48}> {}
49
50const targetLabels = {
51 prerelease: "Prerelease",
52 latest: "Latest",
53} as const satisfies Record<DiscordReleaseTarget, string>;
54
55const targetColors = {
56 prerelease: 0x5865f2,
57 latest: 0x2ecc71,
58} as const satisfies Record<DiscordReleaseTarget, number>;
59
60export const buildDiscordReleaseAnnouncement = (
61 options: DiscordReleaseAnnouncementOptions,
62): DiscordWebhookPayload => ({
63 content: `<@&${options.roleId}> ${targetLabels[options.target]} published: ${options.releaseName}`,
64 allowed_mentions: {
65 roles: [options.roleId],
66 },
67 embeds: [
68 {
69 title: options.releaseName,
70 url: options.releaseUrl,
71 description:
72 options.target === "prerelease"
73 ? "A new T3 Code prerelease is available for nightly testers."
74 : "A new T3 Code latest release is available.",
75 color: targetColors[options.target],
76 fields: [
77 {
78 name: "Version",
79 value: options.version,
80 inline: true,
81 },
82 {
83 name: "Tag",
84 value: options.tag,
85 inline: true,
86 },
87 ],
88 timestamp: options.timestamp,
89 },
90 ],
91});
92
93const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
94 webhookUrl: string,
95 payload: DiscordWebhookPayload,
96) {
97 const httpClient = (yield* HttpClient.HttpClient).pipe(
98 HttpClient.retryTransient({
99 retryOn: "errors-and-responses",
100 times: 3,
101 }),
102 HttpClient.filterStatusOk,
103 );
104
105 yield* HttpClientRequest.post(webhookUrl).pipe(
106 HttpClientRequest.bodyJson(payload),
107 Effect.flatMap(httpClient.execute),
108 Effect.mapError(
109 (cause) =>
110 new DiscordReleaseAnnouncementError({
111 message: "Failed to post Discord release announcement.",
112 cause,
113 }),
114 ),
115 );
116});
117
118const runtimeLayer = Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer);
119
120export const notifyDiscordReleaseCommand = Command.make(
121 "notify-discord-release",
122 {
123 target: Argument.choice("target", DISCORD_RELEASE_TARGETS).pipe(
124 Argument.withDescription("Discord announcement target: prerelease or latest."),
125 ),
126 roleId: Flag.string("role-id").pipe(
127 Flag.withSchema(DiscordRoleIdSchema),
128 Flag.withDescription("Discord role ID to mention in the release announcement."),
129 ),
130 releaseName: Flag.string("release-name").pipe(
131 Flag.withSchema(Schema.NonEmptyString),
132 Flag.withDescription("Human-readable release name."),
133 ),
134 version: Flag.string("version").pipe(
135 Flag.withSchema(Schema.NonEmptyString),
136 Flag.withDescription("Release version."),
137 ),
138 tag: Flag.string("tag").pipe(
139 Flag.withSchema(Schema.NonEmptyString),
140 Flag.withDescription("Git tag for the release."),
141 ),
142 releaseUrl: Flag.string("release-url").pipe(
143 Flag.withSchema(WebUrlSchema),
144 Flag.withDescription("Public GitHub release URL."),
145 ),
146 },
147 ({ target, roleId, releaseName, version, tag, releaseUrl }) =>
148 Effect.gen(function* () {
149 const webhookUrl = yield* DiscordWebhookUrl;
150 yield* postDiscordWebhook(
151 webhookUrl,
152 buildDiscordReleaseAnnouncement({
153 target,
154 roleId,
155 releaseName,
156 version,
157 tag,
158 releaseUrl,
159 timestamp: new Date().toISOString(),
160 }),
161 );
162 }),
163).pipe(Command.withDescription("Post a T3 Code release announcement to Discord."));
164
165if (import.meta.main) {
166 Command.run(notifyDiscordReleaseCommand, { version: "0.0.0" }).pipe(
167 Effect.provide(runtimeLayer),
168 NodeRuntime.runMain,
169 );
170}