From b4381985db55755ff15340d118160c14006ced13 Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:10:37 +0300 Subject: [PATCH] feat(release): include nightly changelogs in Discord announcements (#15754) --- .github/workflows/release.yml | 21 +- scripts/notify-discord-release.test.ts | 715 +++++++++++++++++++++---- scripts/notify-discord-release.ts | 220 ++++++-- 3 files changed, 784 insertions(+), 172 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 443665e020f33f20dec8a2625f01405ff1598509..24935437792fa35765ad40e7c0cb92c9b9f3239d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1347,6 +1347,24 @@ jobs: args: - --filter=@t3tools/scripts... + - name: Read published release notes + if: needs.preflight.outputs.is_prerelease == 'true' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ needs.preflight.outputs.tag }} + RELEASE_CHANNEL: ${{ needs.preflight.outputs.release_channel }} + run: | + if [ "$RELEASE_CHANNEL" = 'nightly' ]; then + if ! gh release view "$RELEASE_TAG" --json body --jq '.body // ""' > "$RUNNER_TEMP/discord-release-notes.md"; then + : > "$RUNNER_TEMP/discord-release-notes.md" + echo '::warning::Could not read published release notes; announcing without a changelog.' + fi + else + : > "$RUNNER_TEMP/discord-release-notes.md" + fi + - name: Announce prerelease on Discord if: needs.preflight.outputs.is_prerelease == 'true' continue-on-error: true @@ -1359,7 +1377,8 @@ jobs: --release-name "${{ needs.preflight.outputs.release_name }}" \ --release-version "${{ needs.preflight.outputs.version }}" \ --tag "${{ needs.preflight.outputs.tag }}" \ - --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" + --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" \ + --release-notes-file "$RUNNER_TEMP/discord-release-notes.md" - name: Announce latest release on Discord if: needs.preflight.outputs.make_latest == 'true' diff --git a/scripts/notify-discord-release.test.ts b/scripts/notify-discord-release.test.ts index dba0c6cd5dacde8d216e3cdbc330b4e10a56ec8b..21588b718ecc7f40bdc3e9053756a5d0dc9f5878 100644 --- a/scripts/notify-discord-release.test.ts +++ b/scripts/notify-discord-release.test.ts @@ -1,9 +1,25 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { Command } from "effect/unstable/cli"; +import { HttpClient, HttpClientError, HttpClientResponse, UrlParams } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { buildDiscordReleaseAnnouncement, postDiscordWebhook } from "./notify-discord-release.ts"; +import { + buildDiscordReleaseAnnouncement, + notifyDiscordReleaseCommand, + postDiscordWebhook, +} from "./notify-discord-release.ts"; const latestAnnouncement = { target: "latest", @@ -11,158 +27,619 @@ const latestAnnouncement = { releaseName: "T3 Code v1.2.3", version: "1.2.3", tag: "v1.2.3", - releaseUrl: new URL("https://github.com/t3dotgg/t3-code/releases/tag/v1.2.3"), + releaseUrl: new URL("https://github.com/pingdotgg/t3code/releases/tag/v1.2.3"), timestamp: "2026-05-01T01:41:00.000Z", } as const; +const nightlyAnnouncement = { ...latestAnnouncement, target: "prerelease" } as const; +// Deliberately fake. All HTTP requests below use an injected client. +const webhookUrl = new URL("https://discord.com/api/webhooks/123456/test-secret-token"); +const intro = "A new T3 Code prerelease is available for nightly testers."; +const 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`; +const 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)`; +const runCli = Command.runWith(notifyDiscordReleaseCommand, { version: "0.0.0" }); +const cliArgs = (target: "latest" | "prerelease") => [ + target, + "--role-id", + latestAnnouncement.roleId, + "--release-name", + latestAnnouncement.releaseName, + "--release-version", + latestAnnouncement.version, + "--tag", + latestAnnouncement.tag, + "--release-url", + latestAnnouncement.releaseUrl.href, +]; +const configLayer = ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { DISCORD_WEBHOOK_URL: webhookUrl.href } }), +); +const PayloadSchema = Schema.fromJsonString( + Schema.Struct({ + content: Schema.String, + allowed_mentions: Schema.Struct({ + parse: Schema.Array(Schema.String), + roles: Schema.Array(Schema.String), + }), + embeds: Schema.Array( + Schema.Struct({ title: Schema.String, description: Schema.String, url: Schema.String }), + ), + }), +); +const decodePayload = Schema.decodeUnknownSync(PayloadSchema); +const payloadDescription = (payloads: ReturnType) => + payloads.flatMap((payload) => payload.embeds.map((embed) => embed.description)).join(""); +const captureClient = (requests: string[], status = 204) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + assert.deepStrictEqual(UrlParams.getAll(request.urlParams, "wait"), ["true"]); + assert.equal(request.body._tag, "Uint8Array"); + if (request.body._tag !== "Uint8Array") return Effect.die("Expected JSON request body"); + requests.push(new TextDecoder().decode(request.body.body)); + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status }))); + }), + ); -const webhookUrl = new URL("https://discord.com/api/webhooks/123456/secret-token"); +function assertDiscordLimits(payloads: ReturnType) { + for (const payload of payloads) { + assert.ok(payload.content.length <= 2000); + assert.equal(payload.embeds.length, 1); + let total = 0; + for (const embed of payload.embeds) { + assert.ok(embed.title.length <= 256); + assert.ok(embed.description.length <= 4096); + assert.ok(embed.footer.text.length <= 2048); + total += embed.title.length + embed.description.length + embed.footer.text.length; + } + assert.ok(total <= 6000); + } +} -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: new URL( - "https://github.com/t3dotgg/t3-code/releases/tag/v1.2.4-nightly.20260501.17", - ), - timestamp: "2026-05-01T01:41:00.000Z", - }), +it("includes nightly changes in order with their links and existing metadata", () => { + const payloads = buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes: notes }); + assert.deepStrictEqual(payloads, [ { - content: - "<@&111111111111111111> Prerelease published: T3 Code Nightly 1.2.4-nightly.20260501.17 (abcdef123456)", - allowed_mentions: { - roles: ["111111111111111111"], - }, + content: "-# <@&222222222222222222>", + allowed_mentions: { parse: [], roles: [latestAnnouncement.roleId] }, 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.", + title: latestAnnouncement.releaseName, + url: latestAnnouncement.releaseUrl.href, + description: `${intro}\n\n${formattedNotes}`, 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", + footer: { text: "v1.2.3" }, + timestamp: latestAnnouncement.timestamp, }, ], }, - ); + ]); + assertDiscordLimits(payloads); }); -it("builds a latest Discord announcement for stable subscribers", () => { - assert.deepStrictEqual(buildDiscordReleaseAnnouncement(latestAnnouncement), { - content: "<@&222222222222222222> Latest published: T3 Code v1.2.3", - allowed_mentions: { - roles: ["222222222222222222"], - }, - embeds: [ +it("keeps stable announcements short even when a long changelog is provided", () => { + assert.deepStrictEqual( + buildDiscordReleaseAnnouncement({ ...latestAnnouncement, releaseNotes: notes.repeat(200) }), + [ { - 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: [ + content: "-# <@&222222222222222222>", + allowed_mentions: { parse: [], roles: [latestAnnouncement.roleId] }, + embeds: [ { - name: "Version", - value: "1.2.3", - inline: true, - }, - { - name: "Tag", - value: "v1.2.3", - inline: true, + title: latestAnnouncement.releaseName, + url: latestAnnouncement.releaseUrl.href, + description: `[View full release notes on GitHub](${latestAnnouncement.releaseUrl.href})`, + color: 0x2ecc71, + footer: { text: "v1.2.3" }, + timestamp: latestAnnouncement.timestamp, }, ], - timestamp: "2026-05-01T01:41:00.000Z", }, ], + ); +}); + +it("links contributor profiles and escapes brackets in change titles and bot handles", () => { + const releaseNotes = + "* 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"; + const payloads = buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes }); + assert.equal( + payloadDescription(payloads), + `${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)`, + ); +}); + +it("preserves custom notes and links outside generated GitHub pull request entries", () => { + const releaseNotes = + "## Migration\n* Written by @someone in the docs\n* Commit https://github.com/pingdotgg/t3code/commit/abc123\n[Guide](https://example.com/guide)"; + assert.equal( + payloadDescription(buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes })), + `${intro}\n\n${releaseNotes}`, + ); +}); + +it("links the Full Changelog label instead of displaying the comparison URL", () => { + const url = "https://github.com/pingdotgg/t3code/compare/previous...next"; + const releaseNotes = `**Full Changelog**: ${url}\nFull Changelog: ${url}`; + const description = payloadDescription( + buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes }), + ); + assert.equal(description, `${intro}\n\n[Full Changelog](${url})\n[Full Changelog](${url})`); +}); + +it("keeps inline change attribution whole at a message boundary", () => { + const title = "* [Fix reconnection](https://github.com/pingdotgg/t3code/pull/10)"; + const contributor = "[@contributor](https://github.com/contributor)"; + const filler = "x".repeat(4096 - intro.length - 2 - title.length - 2); + const releaseNotes = `${filler}\n* Fix reconnection by @contributor in https://github.com/pingdotgg/t3code/pull/10`; + const payloads = buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes }); + assert.equal(payloads.length, 2); + assert.equal(payloads[1]?.embeds[0]?.description, `${title} by ${contributor}`); + assert.equal(payloadDescription(payloads), `${intro}\n\n${filler}\n${title} by ${contributor}`); + assertDiscordLimits(payloads); +}); + +it("falls back when notes are missing, empty, or whitespace", () => { + const fallback = buildDiscordReleaseAnnouncement(nightlyAnnouncement); + assert.equal(payloadDescription(fallback), intro); + assert.deepStrictEqual( + buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes: "" }), + fallback, + ); + assert.deepStrictEqual( + buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes: " \r\n\t" }), + fallback, + ); +}); + +it("splits long notes at line boundaries without dropping changes or the comparison link", () => { + const longNotes = + Array.from( + { length: 150 }, + (_, i) => `* Change ${i} in https://github.com/pingdotgg/t3code/pull/${i}\n`, + ).join("") + notes; + const payloads = buildDiscordReleaseAnnouncement({ + ...nightlyAnnouncement, + releaseNotes: longNotes, }); + assert.ok(payloads.length > 2); + const formattedChanges = Array.from( + { length: 150 }, + (_, i) => `* [Change ${i}](https://github.com/pingdotgg/t3code/pull/${i})\n`, + ).join(""); + assert.equal(payloadDescription(payloads), `${intro}\n\n${formattedChanges}${formattedNotes}`); + assertDiscordLimits(payloads); + for (const [index, payload] of payloads.entries()) { + assert.deepStrictEqual(payload.allowed_mentions, { + parse: [], + roles: index === 0 ? [latestAnnouncement.roleId] : [], + }); + if (index > 0) { + assert.equal(payload.content, ""); + assert.equal( + payload.embeds[0]?.title, + `Changelog continued (${index + 1}/${payloads.length})`, + ); + } + if (index < payloads.length - 1) assert.ok(payload.embeds[0]?.description.endsWith("\n")); + assert.equal(payload.embeds[0]?.url, nightlyAnnouncement.releaseUrl.href); + } +}); + +it("honors the aggregate embed limit when metadata is also large", () => { + const releaseNotes = "a".repeat(16000); + const payloads = buildDiscordReleaseAnnouncement({ + ...nightlyAnnouncement, + releaseName: "n".repeat(256), + version: "x".repeat(2047), + releaseNotes, + }); + assertDiscordLimits(payloads); + assert.equal(payloadDescription(payloads), `${intro}\n\n${releaseNotes}`); +}); + +it("handles oversized lines, words, and Unicode without losing or breaking characters", () => { + const longNotes = + "a".repeat(9000) + "\n" + "🦋".repeat(4500) + "\n" + "word ".repeat(2000).trim(); + const payloads = buildDiscordReleaseAnnouncement({ + ...nightlyAnnouncement, + releaseNotes: longNotes, + }); + assertDiscordLimits(payloads); + assert.equal(payloadDescription(payloads), `${intro}\n\n${longNotes}`); + for (const payload of payloads) assert.ok(payload.embeds[0]?.description.isWellFormed()); +}); + +it("accepts exactly 4096 description characters and splits the next character", () => { + const releaseNotes = "x".repeat(4096 - intro.length - 2); + assert.equal(buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes }).length, 1); + const longer = buildDiscordReleaseAnnouncement({ + ...nightlyAnnouncement, + releaseNotes: releaseNotes + "x", + }); + assert.equal(longer.length, 2); + assert.equal(payloadDescription(longer), `${intro}\n\n${releaseNotes}x`); + assertDiscordLimits(longer); +}); + +it("suppresses everyone, here, user, and role mentions on every message", () => { + const mentions = `@everyone @here <@123> <@!123> <@&456> <@&${nightlyAnnouncement.roleId}>`; + const inert = mentions.replaceAll("@", "@\u200b"); + const payloads = buildDiscordReleaseAnnouncement({ + ...nightlyAnnouncement, + releaseName: mentions, + releaseNotes: `${mentions}\n`.repeat(100) + notes, + }); + assert.ok(payloads.length > 1); + assert.equal( + payloadDescription(payloads), + `${intro}\n\n${`${inert}\n`.repeat(100)}${formattedNotes}`, + ); + for (const [index, payload] of payloads.entries()) { + assert.deepStrictEqual(payload.allowed_mentions, { + parse: [], + roles: index === 0 ? [latestAnnouncement.roleId] : [], + }); + if (index === 0) assert.equal(payload.embeds[0]?.title, inert); + assert.ok(!payload.content.includes("@everyone")); + assert.ok(!payload.content.includes("@here")); + assert.equal(payload.content.match(/<@&\d+>/g)?.length ?? 0, index === 0 ? 1 : 0); + } +}); + +it("rejects invalid metadata before sending", () => { + assert.throws( + () => buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseName: "x".repeat(257) }), + /title exceeds/, + ); + assert.throws( + () => buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, version: "x".repeat(2048) }), + /footer exceeds/, + ); + assert.throws( + () => buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, roleId: "123> @everyone" }), + /Invalid Discord release role/, + ); }); -it.effect("preserves webhook request context and the full client cause chain", () => { - const payload = buildDiscordReleaseAnnouncement(latestAnnouncement); - const requestCause = new Error("request encoder unavailable"); - let clientError: HttpClientError.HttpClientError | undefined; - const httpClientLayer = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => { - clientError = new HttpClientError.HttpClientError({ - reason: new HttpClientError.EncodeError({ - request, - cause: requestCause, - }), - }); - return Effect.fail(clientError); - }), - ); - - return Effect.gen(function* () { +it.effect("retains safe request context without leaking the webhook URL or nested cause", () => + Effect.gen(function* () { + const payload = buildDiscordReleaseAnnouncement(latestAnnouncement)[0]!; + const logs: string[] = []; + const client = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.EncodeError({ + request, + cause: new Error(`Encoder failed for ${webhookUrl.href}`), + }), + }), + ), + ), + ); const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe( - Effect.provide(httpClientLayer), + Effect.tapCause(Effect.logError), + Effect.provide([ + client, + Logger.layer([ + Logger.make((options) => { + logs.push(Logger.formatJson.log(options)); + }), + ]), + ]), Effect.flip, ); - - if (error._tag !== "DiscordReleaseWebhookRequestError") { + if (error._tag !== "DiscordReleaseWebhookRequestError") assert.fail(`Unexpected error: ${error._tag}`); - } + assert.equal(error.reason, "EncodeError"); assert.equal(error.target, "latest"); - assert.equal(error.releaseName, latestAnnouncement.releaseName); - assert.equal(error.version, latestAnnouncement.version); assert.equal(error.tag, latestAnnouncement.tag); - assert.equal(error.releaseUrl, latestAnnouncement.releaseUrl.href); assert.equal(error.webhookOrigin, webhookUrl.origin); assert.equal(error.webhookPathnameSegmentCount, 4); assert.equal(error.contentLength, payload.content.length); - assert.equal(error.embedCount, 1); assert.equal(error.allowedRoleMentionCount, 1); - assert.equal(error.hasRoleMentionSyntax, true); - assert.equal(error.cause, clientError); - assert.equal((error.cause as HttpClientError.HttpClientError).cause, requestCause); - assert.ok(!error.message.includes(requestCause.message)); - }); -}); + assert.ok(!Cause.pretty(Cause.fail(error)).includes("test-secret-token")); + assert.ok(!logs.join("\n").includes("test-secret-token")); + }), +); + +it.effect( + "retains response status without retaining the secret-bearing request or response body", + () => + Effect.gen(function* () { + const client = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(webhookUrl.href, { status: 400 })), + ), + ), + ); + const error = yield* postDiscordWebhook( + webhookUrl, + buildDiscordReleaseAnnouncement(latestAnnouncement)[0]!, + latestAnnouncement, + ).pipe(Effect.provide(client), Effect.flip); + if (error._tag !== "DiscordReleaseWebhookResponseError") + assert.fail(`Unexpected error: ${error._tag}`); + assert.equal(error.status, 400); + assert.equal(error.tag, latestAnnouncement.tag); + assert.ok(!Cause.pretty(Cause.fail(error)).includes("test-secret-token")); + }), +); -it.effect("preserves a non-success response error with structured status context", () => { - const payload = buildDiscordReleaseAnnouncement(latestAnnouncement); - const httpClientLayer = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb(request, new Response("invalid webhook", { status: 400 })), +it.effect("waits for Retry-After before retrying a rate-limited message", () => + Effect.gen(function* () { + const limited = yield* Deferred.make(); + let attempts = 0; + const client = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + attempts += 1; + if (attempts === 1) { + yield* Deferred.succeed(limited, undefined); + return HttpClientResponse.fromWeb( + request, + new Response(null, { status: 429, headers: { "retry-after": "2" } }), + ); + } + return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })); + }), ), - ), - ); + ); + const fiber = yield* postDiscordWebhook( + webhookUrl, + buildDiscordReleaseAnnouncement(latestAnnouncement)[0]!, + latestAnnouncement, + ).pipe(Effect.provide(client), Effect.forkChild); + yield* Deferred.await(limited); + yield* TestClock.adjust("1 second"); + assert.equal(attempts, 1); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(fiber); + assert.equal(attempts, 2); + }), +); - return Effect.gen(function* () { - const error = yield* postDiscordWebhook(webhookUrl, payload, latestAnnouncement).pipe( - Effect.provide(httpClientLayer), - Effect.flip, +it.effect("bounds total retry time while preserving delays within the deadline", () => + Effect.gen(function* () { + const limited = yield* Deferred.make(); + let attempts = 0; + const client = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + attempts += 1; + yield* Deferred.succeed(limited, undefined); + return HttpClientResponse.fromWeb( + request, + new Response(null, { status: 429, headers: { "retry-after": "40" } }), + ); + }), + ), ); + const fiber = yield* postDiscordWebhook( + webhookUrl, + buildDiscordReleaseAnnouncement(latestAnnouncement)[0]!, + latestAnnouncement, + ).pipe(Effect.provide(client), Effect.result, Effect.forkChild); + yield* Deferred.await(limited); + yield* TestClock.adjust("39 seconds"); + assert.equal(attempts, 1); + yield* TestClock.adjust("1 second"); + assert.equal(attempts, 2); + yield* TestClock.adjust("20 seconds"); + const result = yield* Fiber.join(fiber); + assert.equal(attempts, 2); + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") assert.fail("Expected the retry deadline to expire"); + assert.equal(result.failure._tag, "DiscordReleaseWebhookRequestError"); + if (result.failure._tag !== "DiscordReleaseWebhookRequestError") + assert.fail(`Unexpected error: ${result.failure._tag}`); + assert.equal(result.failure.reason, "TimeoutError"); + assert.ok(!Cause.pretty(Cause.fail(result.failure)).includes("test-secret-token")); + }), +); + +// Run the checked-in shell commands with gh/node stubs, then send their captured +// arguments through the real CLI parser and an injected HTTP client. +function workflowRun(workflow: string, stepName: string) { + const step = workflow.split(` - name: ${stepName}\n`)[1]?.split("\n - name:")[0]; + assert.ok(step, `Missing workflow step: ${stepName}`); + const run = step.split(" run: |\n")[1]; + assert.ok(run, `Missing run block: ${stepName}`); + return run + .split("\n") + .map((line) => line.replace(/^ /, "")) + .join("\n"); +} + +it.layer(NodeServices.layer)("Discord release CLI and workflow", (it) => { + it.effect("passes the published nightly body from the workflow to ordered sends", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const temp = yield* fs.makeTempDirectoryScoped({ prefix: "discord release " }); + const workflow = yield* fs.readFileString( + yield* path.fromFileUrl(new URL("../.github/workflows/release.yml", import.meta.url)), + ); + const publishedNotes = + Array.from({ length: 50 }, () => notes).join("\n\n") + + '\n* Literal $(touch "$RUNNER_TEMP/executed") and `touch "$RUNNER_TEMP/executed"`'; + const read = workflowRun(workflow, "Read published release notes"); + const shellEnv = { + RUNNER_TEMP: temp, + RELEASE_TAG: nightlyAnnouncement.tag, + RELEASE_CHANNEL: "nightly", + FIXTURE_NOTES: publishedNotes, + }; + const readStatus = yield* spawner.exitCode( + ChildProcess.make( + "bash", + [ + "-e", + "-c", + `gh() { [ "$*" = 'release view v1.2.3 --json body --jq .body // ""' ] || return 1; printf '%s' "$FIXTURE_NOTES"; }\n${read}`, + ], + { env: shellEnv }, + ), + ); + assert.equal(readStatus, 0); + assert.equal(yield* fs.readFileString(`${temp}/discord-release-notes.md`), publishedNotes); + const replacements: Record = { + "needs.preflight.outputs.release_name": latestAnnouncement.releaseName, + "needs.preflight.outputs.version": latestAnnouncement.version, + "needs.preflight.outputs.tag": latestAnnouncement.tag, + "github.repository": "pingdotgg/t3code", + }; + const announce = workflowRun(workflow, "Announce prerelease on Discord").replace( + /\$\{\{ (.*?) \}\}/g, + (_, key: string) => { + assert.ok(replacements[key], `Unexpected expression: ${key}`); + return replacements[key]!; + }, + ); + const announceStatus = yield* spawner.exitCode( + ChildProcess.make( + "bash", + ["-e", "-c", `node() { printf '%s\\0' "$@" > "$RUNNER_TEMP/args"; }\n${announce}`], + { env: { RUNNER_TEMP: temp, DISCORD_MENTION_ROLE_ID: latestAnnouncement.roleId } }, + ), + ); + assert.equal(announceStatus, 0); + const args = (yield* fs.readFileString(`${temp}/args`)).split("\0").slice(1, -1); + const requests: string[] = []; + yield* runCli(args).pipe(Effect.provide([configLayer, captureClient(requests)])); + const payloads = requests.map((request) => decodePayload(request)); + assert.ok(payloads.length > 2); + assert.equal( + payloads.flatMap((payload) => payload.embeds.map((embed) => embed.description)).join(""), + `${intro}\n\n${Array.from({ length: 50 }, () => formattedNotes).join("\n\n")}\n* Literal $(touch "$RUNNER_TEMP/executed") and \`touch "$RUNNER_TEMP/executed"\``, + ); + for (const [index, payload] of payloads.entries()) + assert.deepStrictEqual(payload.allowed_mentions, { + parse: [], + roles: index === 0 ? [latestAnnouncement.roleId] : [], + }); + assert.equal(yield* fs.exists(`${temp}/executed`), false); + assert.equal( + yield* spawner.exitCode( + ChildProcess.make("bash", ["-e", "-c", `gh() { return 99; }\n${read}`], { + env: { ...shellEnv, RELEASE_CHANNEL: "stable" }, + }), + ), + 0, + ); + assert.equal(yield* fs.readFileString(`${temp}/discord-release-notes.md`), ""); + assert.equal( + yield* spawner.exitCode( + ChildProcess.make( + "bash", + ["-e", "-c", `gh() { printf '%s' 'partial changelog'; return 42; }\n${read}`], + { env: shellEnv }, + ), + ), + 0, + ); + assert.equal(yield* fs.readFileString(`${temp}/discord-release-notes.md`), ""); + const fallbackRequests: string[] = []; + yield* runCli(args).pipe(Effect.provide([configLayer, captureClient(fallbackRequests)])); + assert.equal(fallbackRequests.length, 1); + const fallback = decodePayload(fallbackRequests[0]!); + assert.equal(fallback.embeds[0]?.description, intro); + assert.equal(fallback.content, `-# <@&${latestAnnouncement.roleId}>`); + assert.deepStrictEqual(fallback.allowed_mentions, { + parse: [], + roles: [latestAnnouncement.roleId], + }); + assert.ok( + workflow + .split(" - name: Announce prerelease on Discord\n")[1] + ?.startsWith(" if: needs.preflight.outputs.is_prerelease == 'true'\n"), + ); + assert.ok(workflow.includes("GH_REPO: ${{ github.repository }}")); + assert.ok(workflow.includes("RELEASE_TAG: ${{ needs.preflight.outputs.tag }}")); + assert.ok( + !workflowRun(workflow, "Announce latest release on Discord").includes( + "--release-notes-file", + ), + ); + assert.ok( + workflow + .slice(workflow.indexOf(" announce_discord:")) + .includes("needs.preflight.outputs.release_channel != 'preview'"), + ); + }), + ); - if (error._tag !== "DiscordReleaseWebhookResponseError") { - assert.fail(`Unexpected error: ${error._tag}`); - } - assert.equal(error.target, "latest"); - assert.equal(error.tag, latestAnnouncement.tag); - assert.equal(error.webhookOrigin, webhookUrl.origin); - assert.equal(error.webhookPathnameSegmentCount, 4); - assert.equal(error.status, 400); - if (!HttpClientError.isHttpClientError(error.cause)) { - assert.fail("Expected HttpClientError cause"); - } - assert.equal(error.cause.reason._tag, "StatusCodeError"); - assert.ok(!error.message.includes(error.cause.message)); - }); + it.effect("uses the fallback when notes are omitted and ignores a notes file for latest", () => + Effect.gen(function* () { + const requests: string[] = []; + yield* runCli(cliArgs("prerelease")).pipe( + Effect.provide([configLayer, captureClient(requests)]), + ); + assert.equal(decodePayload(requests[0]!).embeds[0]?.description, intro); + yield* runCli([ + ...cliArgs("latest"), + "--release-notes-file", + "/missing-stable-notes.md", + ]).pipe(Effect.provide([configLayer, captureClient(requests)])); + assert.equal(requests.length, 2); + assert.equal( + decodePayload(requests[1]!).embeds[0]?.description, + `[View full release notes on GitHub](${latestAnnouncement.releaseUrl.href})`, + ); + }), + ); + + it.effect("fails before sending when the requested nightly notes file cannot be read", () => + Effect.gen(function* () { + const requests: string[] = []; + const result = yield* runCli([ + ...cliArgs("prerelease"), + "--release-notes-file", + "/missing-nightly-notes.md", + ]).pipe(Effect.provide([configLayer, captureClient(requests)]), Effect.result); + assert.equal(result._tag, "Failure"); + assert.deepStrictEqual(requests, []); + }), + ); + + it.effect("stops on a failed send rather than posting later continuations", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const temp = yield* fs.makeTempDirectoryScoped(); + const file = `${temp}/notes.md`; + yield* fs.writeFileString(file, notes.repeat(100)); + const requests: string[] = []; + const result = yield* runCli([...cliArgs("prerelease"), "--release-notes-file", file]).pipe( + Effect.provide([configLayer, captureClient(requests, 400)]), + Effect.result, + ); + assert.equal(result._tag, "Failure"); + assert.equal(requests.length, 1); + }), + ); + + it.effect("does not expose an invalid webhook secret in configuration errors", () => + Effect.gen(function* () { + const requests: string[] = []; + const result = yield* runCli(cliArgs("latest")).pipe( + Effect.provide([ + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { DISCORD_WEBHOOK_URL: "invalid-test-secret-token" } }), + ), + captureClient(requests), + ]), + Effect.result, + ); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") + assert.ok(!Cause.pretty(Cause.fail(result.failure)).includes("test-secret-token")); + assert.deepStrictEqual(requests, []); + }), + ); }); diff --git a/scripts/notify-discord-release.ts b/scripts/notify-discord-release.ts index 67cf556d47253e42166279319fffa4e199e75bda..d096969c36b8e7cd8a2c33e444bc5c3e38ca75f2 100644 --- a/scripts/notify-discord-release.ts +++ b/scripts/notify-discord-release.ts @@ -5,13 +5,18 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Config from "effect/Config"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { FetchHttpClient, HttpClient, + HttpClientError, HttpClientRequest, HttpClientResponse, } from "effect/unstable/http"; @@ -26,11 +31,13 @@ export interface DiscordReleaseAnnouncementOptions { readonly tag: string; readonly releaseUrl: URL; readonly timestamp: string; + readonly releaseNotes?: string; } interface DiscordWebhookPayload { readonly content: string; readonly allowed_mentions: { + readonly parse: ReadonlyArray; readonly roles: ReadonlyArray; }; readonly embeds: ReadonlyArray<{ @@ -38,18 +45,30 @@ interface DiscordWebhookPayload { readonly url: string; readonly description: string; readonly color: number; - readonly fields: ReadonlyArray<{ - readonly name: string; - readonly value: string; - readonly inline: boolean; - }>; + readonly footer: { readonly text: string }; readonly timestamp: string; }>; } const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const; const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/)); -const DiscordWebhookUrl = Config.URL("DISCORD_WEBHOOK_URL"); +const DiscordWebhookUrl = Config.Redacted("DISCORD_WEBHOOK_URL").pipe( + Effect.flatMap((value) => + Effect.try({ + try: () => new URL(Redacted.value(value)), + catch: () => new DiscordReleaseWebhookConfigurationError({}), + }), + ), +); + +export class DiscordReleaseWebhookConfigurationError extends Schema.TaggedError()( + "DiscordReleaseWebhookConfigurationError", + {}, +) { + override get message(): string { + return "DISCORD_WEBHOOK_URL must be a valid URL."; + } +} const discordReleaseErrorContext = { target: Schema.Literals(["prerelease", "latest"]), @@ -69,7 +88,7 @@ export class DiscordReleaseWebhookRequestError extends Schema.TaggedError; - const targetColors = { prerelease: 0x5865f2, latest: 0x2ecc71, @@ -117,48 +130,137 @@ function summarizePayload(payload: DiscordWebhookPayload) { } as const; } +// Keep release text inert, including occurrences of the intentionally pinged role. +const suppressMentions = (text: string) => + text.replace(/@(everyone|here)\b|<@[!&]?\d+>/g, (mention) => mention.replace("@", "@\u200b")); + +const escapeLinkLabel = (text: string) => text.replace(/[\\[\]]/g, "\\$&"); + +// Compact GitHub's generated entries and comparison link, preserving custom +// notes. Contributor links are GitHub profiles, not pings. +function formatReleaseNotes(notes: string) { + return notes + .replace( + /^([*-] )(.+?)(?: by @([\w-]+(?:\[bot\])?))? in (https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+)[ \t]*\r?$/gm, + ( + _entry, + bullet: string, + title: string, + author: string | undefined, + pullRequestUrl: string, + ) => { + const change = `${bullet}[${escapeLinkLabel(title)}](${pullRequestUrl})`; + if (!author) return change; + const profile = author.endsWith("[bot]") ? `apps/${author.slice(0, -5)}` : author; + return `${change} by [@${escapeLinkLabel(author)}](https://github.com/${profile})`; + }, + ) + .replace( + /^(?:\*\*)?Full Changelog(?:\*\*)?: (https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/compare\/\S+)[ \t]*\r?$/gm, + "[Full Changelog]($1)", + ); +} + +// Preserve every character and prefer whole lines, then words. A single oversized +// line still needs splitting, but never between the halves of a Unicode character. +function splitDescription(text: string, limit: number) { + const chunks: string[] = []; + while (text.length > limit) { + const newline = text.lastIndexOf("\n", limit - 1); + const space = text.lastIndexOf(" ", limit - 1); + let end = newline > 0 ? newline + 1 : space > 0 ? space + 1 : limit; + const lastCodeUnit = text.charCodeAt(end - 1); + if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) end -= 1; + chunks.push(text.slice(0, end)); + text = text.slice(end); + } + chunks.push(text); + return chunks; +} + 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.href, - 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, +): ReadonlyArray => { + const releaseName = suppressMentions(options.releaseName); + const version = suppressMentions(options.version); + const footerText = `v${version.replace(/^v/, "")}`; + const content = `-# <@&${options.roleId}>`; + if (!/^\d+$/.test(options.roleId)) throw new RangeError("Invalid Discord release role ID."); + if (releaseName.length > 256) + throw new RangeError("Discord release title exceeds 256 characters."); + if (content.length > 2000) + throw new RangeError("Discord release content exceeds 2000 characters."); + if (footerText.length > 2048) { + throw new RangeError("Discord release footer exceeds 2048 characters."); + } + + const intro = + options.target === "prerelease" + ? "A new T3 Code prerelease is available for nightly testers." + : `[View full release notes on GitHub](${options.releaseUrl.href})`; + const notes = + options.target === "prerelease" + ? suppressMentions(formatReleaseNotes(options.releaseNotes?.trim() ?? "")) + : ""; + const description = notes ? `${intro}\n\n${notes}` : intro; + // One embed per message avoids Discord's URL deduplication. Reserve a full + // title and the version footer within the 6000-character aggregate limit. + const limit = Math.min(4096, 6000 - 256 - footerText.length); + const descriptions = splitDescription(description, limit); + + return descriptions.map((description, index) => ({ + content: index === 0 ? content : "", + allowed_mentions: { + parse: [], + roles: index === 0 ? [options.roleId] : [], }, - ], -}); + embeds: [ + { + title: + index === 0 ? releaseName : `Changelog continued (${index + 1}/${descriptions.length})`, + url: options.releaseUrl.href, + description, + color: targetColors[options.target], + footer: { text: footerText }, + timestamp: options.timestamp, + }, + ], + })); +}; export const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( webhookUrl: URL, payload: DiscordWebhookPayload, announcement: DiscordReleaseAnnouncementOptions, ) { + const requestUrl = new URL(webhookUrl); + requestUrl.searchParams.set("wait", "true"); const httpClient = (yield* HttpClient.HttpClient).pipe( HttpClient.retryTransient({ retryOn: "errors-and-responses", times: 3, + schedule: Schedule.recurs(3).pipe( + Schedule.addDelay( + ( + metadata: Schedule.Metadata< + number, + HttpClientResponse.HttpClientResponse | HttpClientError.HttpClientError + >, + ) => { + const response = !HttpClientError.isHttpClientError(metadata.input) + ? metadata.input + : metadata.input.reason._tag === "StatusCodeError" + ? metadata.input.reason.response + : undefined; + const retryAfterSeconds = Number(response?.headers["retry-after"]); + return Effect.succeed( + Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0 + ? retryAfterSeconds * 1000 + : 1000, + ); + }, + ), + ), }), ); @@ -180,14 +282,17 @@ export const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( ...summarizePayload(payload), } as const; - const response = yield* HttpClientRequest.post(webhookUrl).pipe( + const response = yield* HttpClientRequest.post(requestUrl).pipe( HttpClientRequest.bodyJson(payload), Effect.flatMap(httpClient.execute), + // Bound requests and retry waits together without shortening Retry-After. + Effect.timeout("1 minute"), Effect.mapError( - (cause) => + (error) => new DiscordReleaseWebhookRequestError({ ...errorContext, - cause, + // HTTP errors retain the request URL, including the webhook token. + reason: error._tag === "HttpClientError" ? error.reason._tag : error._tag, }), ), ); @@ -201,11 +306,10 @@ export const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( yield* HttpClientResponse.filterStatusOk(response).pipe( Effect.mapError( - (cause) => + () => new DiscordReleaseWebhookResponseError({ ...errorContext, status: response.status, - cause, }), ), ); @@ -237,8 +341,12 @@ export const notifyDiscordReleaseCommand = Command.make( Flag.withSchema(Schema.URLFromString), Flag.withDescription("Public GitHub release URL."), ), + releaseNotesFile: Flag.String("release-notes-file").pipe( + Flag.optional, + Flag.withDescription("File containing the published GitHub release notes."), + ), }, - ({ target, roleId, releaseName, releaseVersion, tag, releaseUrl }) => + ({ target, roleId, releaseName, releaseVersion, tag, releaseUrl, releaseNotesFile }) => Effect.gen(function* () { yield* Effect.logInfo("discord release announcement starting").pipe( Effect.annotateLogs({ @@ -253,6 +361,11 @@ export const notifyDiscordReleaseCommand = Command.make( ); const webhookUrl = yield* DiscordWebhookUrl; + const fs = yield* FileSystem.FileSystem; + const releaseNotes = + target === "prerelease" && Option.isSome(releaseNotesFile) + ? yield* fs.readFileString(releaseNotesFile.value) + : ""; const timestamp = DateTime.formatIso(yield* DateTime.now); const announcement = { target, @@ -262,13 +375,16 @@ export const notifyDiscordReleaseCommand = Command.make( tag, releaseUrl, timestamp, + releaseNotes, } satisfies DiscordReleaseAnnouncementOptions; - const payload = buildDiscordReleaseAnnouncement(announcement); + const payloads = yield* Effect.sync(() => buildDiscordReleaseAnnouncement(announcement)); - yield* Effect.logInfo("discord release announcement payload built").pipe( - Effect.annotateLogs(summarizePayload(payload)), + yield* Effect.logInfo("discord release announcement payloads built").pipe( + Effect.annotateLogs({ messageCount: payloads.length }), ); - yield* postDiscordWebhook(webhookUrl, payload, announcement); + for (const payload of payloads) { + yield* postDiscordWebhook(webhookUrl, payload, announcement); + } yield* Effect.logInfo("discord release announcement completed"); }), ).pipe(Command.withDescription("Post a T3 Code release announcement to Discord.")); -- 2.54.0