| 1 | #!/usr/bin/env bun |
| 2 | /** |
| 3 | * Post a release announcement to Discord as a single embed. The body is |
| 4 | * read verbatim from the CHANGELOG.md entry the release-notes step just |
| 5 | * wrote, so Discord matches the GitHub Release copy exactly. |
| 6 | * |
| 7 | * The webhook posts under `<RepoName> Releases` (e.g. "Alchemy-Effect |
| 8 | * Releases", "Distilled Releases"). The embed title uses the first |
| 9 | * hyphen-segment of the repo name as the project prefix |
| 10 | * (alchemy-effect → "Alchemy", distilled → "Distilled") and omits the |
| 11 | * channel suffix for stable `release` channel so you don't get |
| 12 | * "...(release) released" doubling up. |
| 13 | * |
| 14 | * alchemy-effect + beta → Alchemy v2.0.0-beta.42 (beta) released |
| 15 | * distilled + release → Distilled v0.21.3 released |
| 16 | * |
| 17 | * Reads DISCORD_WEBHOOK from the environment. Silently no-ops if unset. |
| 18 | * |
| 19 | * Usage: bun discord-notify.ts <tag> <release|beta|alpha|tag> |
| 20 | * |
| 21 | * Reads ALCHEMY_REPO for the GitHub repo to link to. |
| 22 | */ |
| 23 | import { readFile } from "node:fs/promises"; |
| 24 | import { join } from "node:path"; |
| 25 | import { repo } from "./config.ts"; |
| 26 | import { extractTagBody, toDiscordBody } from "./discord-body.ts"; |
| 27 | |
| 28 | const EMBED_DESCRIPTION_LIMIT = 4096; |
| 29 | |
| 30 | const tag = process.argv[2]; |
| 31 | const channel = process.argv[3]; |
| 32 | if (!tag || !channel) { |
| 33 | console.error("Usage: bun discord-notify.ts <tag> <channel>"); |
| 34 | process.exit(1); |
| 35 | } |
| 36 | |
| 37 | const webhook = process.env.DISCORD_WEBHOOK; |
| 38 | if (!webhook) { |
| 39 | console.log("DISCORD_WEBHOOK not set, skipping Discord notification"); |
| 40 | process.exit(0); |
| 41 | } |
| 42 | |
| 43 | const REPO = repo(); |
| 44 | const repoSlug = REPO.split("/")[1]!; |
| 45 | const capitalize = (s: string) => |
| 46 | s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1); |
| 47 | // "alchemy-effect" -> "Alchemy-Effect" |
| 48 | const fullRepoName = repoSlug.split("-").map(capitalize).join("-"); |
| 49 | // "alchemy-effect" -> "Alchemy" |
| 50 | const projectName = capitalize(repoSlug.split("-")[0]!); |
| 51 | const botName = `${fullRepoName} Releases`; |
| 52 | const titleChannel = channel === "release" ? "" : ` (${channel})`; |
| 53 | const title = `${projectName} ${tag}${titleChannel} released`; |
| 54 | |
| 55 | const changelogPath = join(process.cwd(), "CHANGELOG.md"); |
| 56 | const changelog = await readFile(changelogPath, "utf-8"); |
| 57 | const rawBody = extractTagBody(changelog, tag); |
| 58 | if (rawBody === undefined) { |
| 59 | console.error(`CHANGELOG.md has no entry for ${tag}`); |
| 60 | process.exit(1); |
| 61 | } |
| 62 | |
| 63 | const body = toDiscordBody(rawBody); |
| 64 | |
| 65 | const releaseUrl = `https://github.com/${REPO}/releases/tag/${tag}`; |
| 66 | const footer = `\n\n[Full release notes →](${releaseUrl})`; |
| 67 | // Discord embed descriptions cap at 4096 chars. If the rendered body |
| 68 | // plus the footer would overflow, truncate the body to fit and prepend |
| 69 | // a notice line so the reader knows there's more on GitHub. We cut at |
| 70 | // the last newline before the budget so we never slice mid-bullet, then |
| 71 | // fall back to a hard slice if no newline lands in range. |
| 72 | const truncationNotice = |
| 73 | "_Release notes truncated — see the full changelog on GitHub._\n\n"; |
| 74 | let description = `${body}${footer}`; |
| 75 | if (description.length > EMBED_DESCRIPTION_LIMIT) { |
| 76 | const budget = |
| 77 | EMBED_DESCRIPTION_LIMIT - footer.length - truncationNotice.length; |
| 78 | let cut = body.lastIndexOf("\n", budget); |
| 79 | if (cut < budget / 2) cut = budget; // no convenient newline — hard cut |
| 80 | const truncated = body.slice(0, cut).trimEnd(); |
| 81 | description = `${truncationNotice}${truncated}${footer}`; |
| 82 | console.log( |
| 83 | `Changelog body (${body.length} chars) exceeded Discord limit; truncated to ${truncated.length} chars and linked to GitHub for the full notes.`, |
| 84 | ); |
| 85 | } |
| 86 | |
| 87 | const res = await fetch(webhook, { |
| 88 | method: "POST", |
| 89 | headers: { "content-type": "application/json" }, |
| 90 | body: JSON.stringify({ |
| 91 | username: botName, |
| 92 | embeds: [ |
| 93 | { |
| 94 | title, |
| 95 | url: releaseUrl, |
| 96 | description, |
| 97 | }, |
| 98 | ], |
| 99 | allowed_mentions: { parse: [] }, |
| 100 | }), |
| 101 | }); |
| 102 | |
| 103 | if (!res.ok) { |
| 104 | console.error(`Discord webhook failed: ${res.status} ${await res.text()}`); |
| 105 | process.exit(1); |
| 106 | } |
| 107 | console.log(`Posted Discord release notification for ${tag}`); |