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
81 changes: 49 additions & 32 deletions tools/announce-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
// Packages" PR or a failed publish.
//
// For each published package it slices that version's section from the package's
// CHANGELOG.md, aggregates + dedupes the notes (shared changelog helpers), and
// sends one embed listing the changes and the package@versions.
// CHANGELOG.md (its changes since the last released version) and sends one embed
// listing every bumped package with its notes. Packages whose sections carry the
// same changes (the usual case for a dep-bump release) are grouped under one
// heading instead of repeating the notes per package.
//
// Env:
// DISCORD_RELEASE_WEBHOOK Discord channel webhook URL (no-op if unset)
Expand Down Expand Up @@ -51,50 +53,65 @@ for await (const f of new Glob("packages/*/package.json").scan(".")) {
if (name) changelogByName.set(name, f.replace(/package\.json$/, "CHANGELOG.md"));
}

// Aggregate + dedupe the released version's notes across all published packages.
const seen = new Set<string>();
const notes: string[] = [];
for (const { name, version } of published) {
const path = changelogByName.get(name);
if (!path) continue;
const file = Bun.file(path);
if (!(await file.exists())) continue;
const changelog = await file.text();
for (const bullet of bulletsInRange(changelog, "", version)) {
const key = dedupeKey(bullet);
// Per-package notes from the released version's changelog section, then group
// packages whose sections carry the exact same changes (keyed by the notes'
// dedupe identities) so shared dep-bump notes appear once.
const groups = new Map<string, { pkgs: Pkg[]; notes: string[] }>();
for (const pkg of published) {
const path = changelogByName.get(pkg.name);
const file = path ? Bun.file(path) : null;
const changelog = file && (await file.exists()) ? await file.text() : "";

const seen = new Set<string>();
const notes: string[] = [];
for (const bullet of bulletsInRange(changelog, "", pkg.version, { includeNested: true })) {
const nested = /^\s/.test(bullet);
const key = `${nested ? ">" : ""}${dedupeKey(bullet)}`;
if (seen.has(key)) continue;
seen.add(key);
notes.push(formatNote(stripViaSuffix(bullet)));
notes.push(formatNote(stripViaSuffix(bullet.trim()), nested));
}

const signature = [...seen].join("|");
const group = groups.get(signature);
if (group) group.pkgs.push(pkg);
else groups.set(signature, { notes, pkgs: [pkg] });
}

// Turn a changeset-github bullet into a compact Discord line: message first,
// PR link trailing, dropping the commit hash and "Thanks @user!".
function formatNote(bullet: string): string {
const m = bullet.match(
/^-\s*(\[#\d+\]\([^)]+\))?\s*(?:\[`[0-9a-f]+`\]\([^)]+\))?\s*(?:Thanks[^!]*!)?\s*-?\s*(.*)$/s,
);
// PR link trailing, dropping the commit hash and "Thanks @user!". Nested bullets
// (the enriched underlying dep changes) are indented under their parent note —
// non-breaking spaces so Discord doesn't collapse the indent.
function formatNote(bullet: string, nested = false): string {
const m = bullet.match(/^-\s*(\[#\d+\]\([^)]+\))?\s*(?:\[`[0-9a-f]+`\]\([^)]+\))?\s*(?:Thanks[^!]*!)?\s*-?\s*(.*)$/s);
const pr = m?.[1];
const msg = (m?.[2] || bullet.replace(/^-\s*/, "")).trim().replace(/\s+/g, " ");
return `${msg}${pr ? ` (${pr})` : ""}`;
return `${nested ? "   ↳ " : "• "}${msg}${pr ? ` (${pr})` : ""}`;
}

// Build the description, truncating to Discord's limit with an overflow link.
// Build the description: one section per group — the bumped package@versions as
// a heading, their changes underneath — truncated to Discord's limit with an
// overflow link.
function buildDescription(): string {
if (notes.length === 0) return "_No notable changes recorded._";
const lines: string[] = [];
for (const { pkgs, notes } of groups.values()) {
if (lines.length > 0) lines.push("");
lines.push(pkgs.map((p) => `**\`${p.name}@${p.version}\`**`).join(" · "));
lines.push(...(notes.length > 0 ? notes : ["_Dependency updates only._"]));
}
const kept: string[] = [];
let len = 0;
for (let i = 0; i < notes.length; i++) {
const line = notes[i];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (len + line.length + 1 > DESC_LIMIT) {
const more = notes.length - i;
kept.push(RUN_URL ? `…and ${more} more — [release run](${RUN_URL})` : `…and ${more} more`);
const more = lines.length - i;
kept.push(RUN_URL ? `…and ${more} more lines — [release run](${RUN_URL})` : `…and ${more} more lines`);
break;
}
kept.push(line);
len += line.length + 1;
}
return kept.join("\n");
return kept.join("\n") || "_No notable changes recorded._";
}

function buildPackageField(): string {
Expand All @@ -111,14 +128,14 @@ function buildPackageField(): string {

const embed = {
author: { name: RELEASE_LABEL },
title: `📦 ${RELEASE_LABEL} — Release`,
color: EMBED_COLOR,
description: buildDescription(),
fields: [{ name: `Packages (${published.length})`, value: buildPackageField() }],
title: `📦 ${RELEASE_LABEL} — Release`,
...(RUN_URL ? { url: RUN_URL } : {}),
};

const payload = { username: "SwapKit Releases", embeds: [embed] };
const payload = { embeds: [embed], username: "SwapKit Releases" };

if (DRY_RUN || !WEBHOOK) {
if (!WEBHOOK && !DRY_RUN) console.info("DISCORD_RELEASE_WEBHOOK unset — skipping Discord post.");
Expand All @@ -127,13 +144,13 @@ if (DRY_RUN || !WEBHOOK) {
}

const res = await fetch(WEBHOOK, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
method: "POST",
});

if (!res.ok) {
console.error(`Discord webhook failed: ${res.status} ${await res.text().catch(() => "")}`);
process.exit(1);
}
console.info(`📣 announced ${published.length} packages, ${notes.length} change notes to Discord.`);
console.info(`📣 announced ${published.length} packages in ${groups.size} groups to Discord.`);
17 changes: 15 additions & 2 deletions tools/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,15 @@ export function stripViaSuffix(bullet: string): string {

// Real (non-"Updated dependencies") bullets of every changelog section whose
// version is in (oldVersion, newVersion]. If oldVersion is empty, take only newVersion.
export function bulletsInRange(changelog: string, oldVersion: string, newVersion: string): string[] {
// With includeNested, sub-bullets of a real bullet (how enrich-dep-changelogs inlines
// the underlying dep changes) are returned too, keeping a two-space indent marker so
// callers can tell parent from child.
export function bulletsInRange(
changelog: string,
oldVersion: string,
newVersion: string,
{ includeNested = false } = {},
): string[] {
const bullets: string[] = [];
let take = false;
let inDepBlock = false;
Expand All @@ -60,7 +68,12 @@ export function bulletsInRange(changelog: string, oldVersion: string, newVersion
inDepBlock = true;
continue;
}
if (inDepBlock && /^\s+- /.test(line)) continue; // nested dep ref
if (/^\s+- /.test(line)) {
if (includeNested && !inDepBlock && bullets.length > 0) {
bullets.push(` ${line.trim()}`);
}
continue;
}
if (/^- /.test(line)) {
inDepBlock = false;
bullets.push(line.trim());
Expand Down