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 */
23import { readFile } from "node:fs/promises";
24import { join } from "node:path";
25import { repo } from "./config.ts";
26import { extractTagBody, toDiscordBody } from "./discord-body.ts";
27
28const EMBED_DESCRIPTION_LIMIT = 4096;
29
30const tag = process.argv[2];
31const channel = process.argv[3];
32if (!tag || !channel) {
33 console.error("Usage: bun discord-notify.ts <tag> <channel>");
34 process.exit(1);
35}
36
37const webhook = process.env.DISCORD_WEBHOOK;
38if (!webhook) {
39 console.log("DISCORD_WEBHOOK not set, skipping Discord notification");
40 process.exit(0);
41}
42
43const REPO = repo();
44const repoSlug = REPO.split("/")[1]!;
45const capitalize = (s: string) =>
46 s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1);
47// "alchemy-effect" -> "Alchemy-Effect"
48const fullRepoName = repoSlug.split("-").map(capitalize).join("-");
49// "alchemy-effect" -> "Alchemy"
50const projectName = capitalize(repoSlug.split("-")[0]!);
51const botName = `${fullRepoName} Releases`;
52const titleChannel = channel === "release" ? "" : ` (${channel})`;
53const title = `${projectName} ${tag}${titleChannel} released`;
54
55const changelogPath = join(process.cwd(), "CHANGELOG.md");
56const changelog = await readFile(changelogPath, "utf-8");
57const rawBody = extractTagBody(changelog, tag);
58if (rawBody === undefined) {
59 console.error(`CHANGELOG.md has no entry for ${tag}`);
60 process.exit(1);
61}
62
63const body = toDiscordBody(rawBody);
64
65const releaseUrl = `https://github.com/${REPO}/releases/tag/${tag}`;
66const 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.
72const truncationNotice =
73 "_Release notes truncated — see the full changelog on GitHub._\n\n";
74let description = `${body}${footer}`;
75if (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
87const 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
103if (!res.ok) {
104 console.error(`Discord webhook failed: ${res.status} ${await res.text()}`);
105 process.exit(1);
106}
107console.log(`Posted Discord release notification for ${tag}`);