fix discord release changelog announcements

This commit is contained in:
Solvely-Colin
2026-06-07 10:27:20 -04:00
parent 60e2e5e5f3
commit dd75400801
4 changed files with 508 additions and 125 deletions

View File

@@ -19,131 +19,23 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Post release changelog to Discord
uses: actions/github-script@v7
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_CHANGELOG_WEBHOOK }}
RELEASE_TAG: ${{ inputs.release_tag }}
with:
script: |
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
if (!webhookUrl) {
core.setFailed("Missing DISCORD_CHANGELOG_WEBHOOK secret.");
return;
}
if (context.eventName !== "release" && context.eventName !== "workflow_dispatch") {
core.info(`Skipping ${context.eventName}; this workflow only posts releases.`);
return;
}
const release = await getRelease();
const messages = buildReleaseMessages(release);
for (const content of messages) {
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
username: "Repo Changelog",
content,
allowed_mentions: {
parse: [],
},
}),
});
if (!response.ok) {
core.setFailed(`Discord webhook failed with ${response.status}: ${await response.text()}`);
return;
}
}
async function getRelease() {
if (context.eventName === "release") {
return context.payload.release;
}
const releaseTag = process.env.RELEASE_TAG;
const response = releaseTag
? await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag: releaseTag,
})
: await github.rest.repos.getLatestRelease({
owner: context.repo.owner,
repo: context.repo.repo,
});
return response.data;
}
function buildReleaseMessages(release) {
const tag = release.tag_name || release.name || "release";
const title = release.name || tag;
const notes = formatReleaseNotes(release.body || "") || "No release notes provided.";
const fullMessage = `**${title} Released**\n\n${notes}`;
return splitDiscordMessage(fullMessage, 1900);
}
function formatReleaseNotes(value) {
return truncate(
value
.replace(/\r\n/g, "\n")
.replace(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/g, "#$1")
.replace(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/issues\/(\d+)/g, "#$1")
.replace(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/commit\/([0-9a-f]{7})[0-9a-f]*/gi, "$1")
.replace(/https:\/\/github\.com\/\S+/g, "")
.trim()
.split("\n")
.map((line) => {
const heading = line.match(/^#{1,6}\s+(.+)$/);
return heading ? `**${heading[1].trim()}**` : line;
})
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim(),
3900,
);
}
function splitDiscordMessage(value, maxLength) {
if (value.length <= maxLength) {
return [value];
}
const chunks = [];
let remaining = value;
while (remaining.length > maxLength) {
let splitAt = remaining.lastIndexOf("\n", maxLength);
if (splitAt < 500) {
splitAt = remaining.lastIndexOf(" ", maxLength);
}
if (splitAt < 500) {
splitAt = maxLength;
}
chunks.push(remaining.slice(0, splitAt).trim());
remaining = remaining.slice(splitAt).trim();
}
if (remaining) {
chunks.push(remaining);
}
return chunks.map((chunk, index) =>
index === 0 ? chunk : `**Changelog continued**\n\n${chunk}`,
);
}
function truncate(value, maxLength) {
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
}
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
run: |
set -euo pipefail
if [ -n "${RELEASE_TAG:-}" ]; then
node scripts/release-notes/discord-release-summary.cjs \
--tag "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--post
else
node scripts/release-notes/discord-release-summary.cjs \
--latest \
--repo "$GITHUB_REPOSITORY" \
--post
fi

View File

@@ -444,6 +444,19 @@ jobs:
node scripts/release-notes/format-github-release-notes.cjs \
--tag "v${PRE_VERSION}" --prerelease --apply
- name: Post Discord pre-release announcement
if: ${{ !inputs.dry_run }}
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_CHANGELOG_WEBHOOK }}
GH_TOKEN: ${{ github.token }}
PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }}
run: |
node scripts/release-notes/discord-release-summary.cjs \
--tag "v${PRE_VERSION}" \
--repo "$GITHUB_REPOSITORY" \
--post \
--allow-missing-webhook
- name: Verify publish
if: ${{ !inputs.dry_run }}
env:
@@ -616,6 +629,19 @@ jobs:
node scripts/release-notes/format-github-release-notes.cjs \
--tag "v${VERSION}" --latest --apply
- name: Post Discord release announcement
if: ${{ !inputs.dry_run }}
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_CHANGELOG_WEBHOOK }}
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
node scripts/release-notes/discord-release-summary.cjs \
--tag "v${VERSION}" \
--repo "$GITHUB_REPOSITORY" \
--post \
--allow-missing-webhook
- name: Clean up next dist-tag
if: ${{ !inputs.dry_run }}
env:

View File

@@ -0,0 +1,373 @@
#!/usr/bin/env node
'use strict';
const cp = require('node:child_process');
const fs = require('fs');
const path = require('path');
const { ExitError, runMain } = require('../lib/cli-exit.cjs');
const { classifyTitle } = require('./format-github-release-notes.cjs');
const DEFAULT_MAX_CONTENT = 1850;
const SECTION_LIMITS = Object.freeze([
['Breaking', 4],
['Security', 4],
['Feature', 4],
['Fix', 4],
['Enhancement', 3],
['Internal', 2],
['New Contributors', 3],
]);
function normalizeNewlines(value) {
return String(value || '').replace(/\r\n/g, '\n');
}
function releaseTag(release) {
return release.tagName || release.tag_name || release.name || 'release';
}
function releaseName(release) {
return release.name || releaseTag(release);
}
function releaseUrl(release) {
return release.url || release.html_url || '';
}
function releaseIsPrerelease(release) {
return release.isPrerelease === true || release.prerelease === true || /-/.test(releaseTag(release));
}
function loadPackageName(repoRoot = path.resolve(__dirname, '..', '..')) {
const pkgJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
return pkgJson.name || 'package';
}
function normalizeHeading(value) {
const heading = value.trim().replace(/:$/, '');
const lower = heading.toLowerCase();
if (lower.includes('breaking')) return 'Breaking';
if (lower.includes('security')) return 'Security';
if (lower === 'feature' || lower === 'features') return 'Feature';
if (lower === 'fix' || lower === 'fixes' || lower === 'bug fixes') return 'Fix';
if (lower === 'enhancement' || lower === 'enhancements' || lower === 'improvements') return 'Enhancement';
if (lower === 'internal' || lower === 'maintenance') return 'Internal';
if (lower === 'new contributors') return 'New Contributors';
if (lower === "what's changed" || lower === 'whats changed') return "What's Changed";
return heading;
}
function collectSections(body) {
const sections = new Map();
let current = null;
for (const line of normalizeNewlines(body).split('\n')) {
const trimmed = line.trim();
const heading = trimmed.match(/^#{2,3}\s+(.+)$/);
if (heading) {
current = normalizeHeading(heading[1]);
continue;
}
const bullet = trimmed.match(/^([*-])\s+(.+)$/);
if (!bullet || !current) continue;
let section = current;
if (section === "What's Changed") {
section = classifyTitle(trimmed);
}
if (!sections.has(section)) sections.set(section, []);
sections.get(section).push(cleanBullet(trimmed));
}
return sections;
}
function cleanBullet(value) {
let text = String(value)
.replace(/^[*-]\s+/, '')
.replace(/\s+by\s+@[A-Za-z0-9-]+\s+in\s+https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/g, ' (#$1)')
.replace(/\[([^\]]+)\]\((https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/(?:pull|issues)\/(\d+))\)/g, '$1 (#$3)')
.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '$1')
.replace(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/g, '#$1')
.replace(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/issues\/(\d+)/g, '#$1')
.replace(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/commit\/([0-9a-f]{7})[0-9a-f]*/gi, '$1')
.replace(/\*\*/g, '')
.replace(/\s+/g, ' ')
.trim();
text = text
.replace(/^\[codex\]\s+/i, '')
.replace(/^(?:feat|fix|docs|chore|ci|refactor|test)(?:\([^)]+\))?!?:\s+/i, '');
text = text.replace(/#(\d+)\s+\(#\1\)/g, '#$1');
text = text.replace(/\s+\(#(\d+)\)\s+\(#\1\)$/g, ' (#$1)');
return truncateAtWord(text, 220);
}
function truncateAtWord(value, maxLength) {
if (value.length <= maxLength) return value;
const splitAt = value.lastIndexOf(' ', maxLength - 3);
if (splitAt < 80) return `${value.slice(0, maxLength - 3)}...`;
return `${value.slice(0, splitAt)}...`;
}
function extractInstallCommand(body, packageName, release) {
const lines = normalizeNewlines(body).split('\n');
let inInstall = false;
let inFence = false;
for (const line of lines) {
const trimmed = line.trim();
if (/^##\s+Install\b/i.test(trimmed)) {
inInstall = true;
continue;
}
if (inInstall && /^##\s+/.test(trimmed)) break;
if (!inInstall) continue;
if (trimmed.startsWith('```')) {
inFence = !inFence;
continue;
}
if (inFence && /^npm\s+(?:i|install)\s+/.test(trimmed)) {
return trimmed;
}
}
const channel = releaseIsPrerelease(release) ? 'next' : 'latest';
return `npm i ${packageName}@${channel}`;
}
function buildDiscordReleasePayload({ release, packageName = loadPackageName(), maxContent = DEFAULT_MAX_CONTENT }) {
const tag = releaseTag(release);
const name = releaseName(release);
const url = releaseUrl(release);
const prerelease = releaseIsPrerelease(release);
const channel = prerelease ? 'next' : 'latest';
const body = release.body || '';
const sections = collectSections(body);
const footer = url ? `Full changelog: ${url}` : `Full changelog: ${tag}`;
const baseLines = [
`**${packageName} ${name}${prerelease ? ' pre-release' : ''} is out**`,
'',
'Install:',
`\`${extractInstallCommand(body, packageName, release)}\``,
];
const lines = baseLines.slice();
for (const [section, limit] of SECTION_LIMITS) {
appendSection(lines, section, sections.get(section) || [], limit, footer, maxContent);
}
if (lines.length === baseLines.length) {
appendLineGroup(lines, ['', 'No release notes were provided.'], footer, maxContent);
}
if (joinedLength(lines, footer) > maxContent) {
while (lines.length > baseLines.length && joinedLength(lines, footer) > maxContent) {
lines.pop();
}
}
const content = [...lines, '', footer].join('\n');
return {
username: 'GSD Releases',
content,
embeds: [
{
title: 'Release details',
url: url || undefined,
color: prerelease ? 0xd9a441 : 0x2f9e44,
fields: [
{ name: 'Package', value: `\`${packageName}\``, inline: true },
{ name: 'Channel', value: `\`${channel}\``, inline: true },
{ name: 'Version', value: `\`${tag}\``, inline: true },
],
},
],
allowed_mentions: { parse: [] },
};
}
function appendSection(lines, section, bullets, limit, footer, maxContent) {
if (bullets.length === 0) return;
const label = sectionLabel(section);
const selected = [];
for (const bullet of bullets.slice(0, limit)) {
const candidate = ['', `**${label}**`, ...selected.map((item) => `- ${item}`), `- ${bullet}`];
if (joinedLength([...lines, ...candidate], footer) > maxContent) break;
selected.push(bullet);
}
if (selected.length === 0) return;
const group = ['', `**${label}**`, ...selected.map((item) => `- ${item}`)];
const remaining = bullets.length - selected.length;
if (remaining > 0) {
const moreLine = `- ...and ${remaining} more ${label.toLowerCase()}`;
if (joinedLength([...lines, ...group, moreLine], footer) <= maxContent) {
group.push(moreLine);
}
}
appendLineGroup(lines, group, footer, maxContent);
}
function appendLineGroup(lines, group, footer, maxContent) {
if (joinedLength([...lines, ...group], footer) <= maxContent) {
lines.push(...group);
}
}
function joinedLength(lines, footer) {
return [...lines, '', footer].join('\n').length;
}
function sectionLabel(section) {
if (section === 'Feature') return 'Features';
if (section === 'Fix') return 'Fixes';
if (section === 'Enhancement') return 'Enhancements';
return section;
}
function fetchRelease({ tag, repo, latest = false }) {
const args = ['release', 'view'];
if (!latest && tag) args.push(tag);
args.push('--json', 'tagName,name,isPrerelease,body,url');
if (repo) args.push('--repo', repo);
const raw = cp.execFileSync('gh', args, { encoding: 'utf8' });
return JSON.parse(raw);
}
async function postWebhook(webhookUrl, payload) {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new ExitError(1, `Discord webhook failed with ${response.status}: ${await response.text()}`);
}
}
function parseArgs(argv) {
const opts = {
tag: null,
repo: null,
latest: false,
stdin: false,
post: false,
json: false,
allowMissingWebhook: false,
packageName: null,
maxContent: DEFAULT_MAX_CONTENT,
};
const args = argv.slice();
while (args.length > 0) {
const arg = args.shift();
if (arg === '--tag') {
opts.tag = requireValue(args, arg);
} else if (arg === '--repo') {
opts.repo = requireValue(args, arg);
} else if (arg === '--package') {
opts.packageName = requireValue(args, arg);
} else if (arg === '--max-content') {
opts.maxContent = Number(requireValue(args, arg));
} else if (arg === '--latest') {
opts.latest = true;
} else if (arg === '--stdin') {
opts.stdin = true;
} else if (arg === '--post') {
opts.post = true;
} else if (arg === '--json') {
opts.json = true;
} else if (arg === '--allow-missing-webhook') {
opts.allowMissingWebhook = true;
} else if (arg === '--help' || arg === '-h') {
process.stdout.write(
'Usage: node scripts/release-notes/discord-release-summary.cjs [--tag <tag>|--latest|--stdin] [options]\n' +
'\n' +
'Options:\n' +
' --tag <tag> GitHub release tag to announce\n' +
' --latest Announce the latest GitHub release\n' +
' --stdin Read release JSON from stdin\n' +
' --repo <owner/repo> Repository for gh release view\n' +
' --package <name> Package name override\n' +
' --post POST to DISCORD_WEBHOOK_URL\n' +
' --allow-missing-webhook Warn and skip instead of failing when posting without a webhook\n' +
' --json Print webhook payload JSON instead of content preview\n' +
' --max-content <n> Max Discord content characters before webhook footer\n'
);
throw new ExitError(0);
} else {
throw new ExitError(2, `error: unknown argument ${arg}`);
}
}
if (!Number.isFinite(opts.maxContent) || opts.maxContent < 500 || opts.maxContent > 2000) {
throw new ExitError(2, 'error: --max-content must be between 500 and 2000');
}
return opts;
}
function requireValue(args, flag) {
const value = args.shift();
if (!value || value.startsWith('-')) {
throw new ExitError(2, `error: ${flag} requires a value`);
}
return value;
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
let release;
if (opts.stdin) {
release = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
} else {
release = fetchRelease(opts);
}
const payload = buildDiscordReleasePayload({
release,
packageName: opts.packageName || loadPackageName(),
maxContent: opts.maxContent,
});
if (opts.post) {
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
if (!webhookUrl) {
if (opts.allowMissingWebhook) {
process.stderr.write('warning: DISCORD_WEBHOOK_URL is not set; skipping Discord announcement\n');
} else {
throw new ExitError(1, 'error: DISCORD_WEBHOOK_URL is not set');
}
} else {
await postWebhook(webhookUrl, payload);
}
}
process.stdout.write(opts.json ? `${JSON.stringify(payload, null, 2)}\n` : `${payload.content}\n`);
}
if (require.main === module) {
runMain(main);
}
module.exports = {
buildDiscordReleasePayload,
cleanBullet,
collectSections,
extractInstallCommand,
};

View File

@@ -0,0 +1,92 @@
'use strict';
const assert = require('node:assert/strict');
const { describe, test } = require('node:test');
const {
buildDiscordReleasePayload,
cleanBullet,
collectSections,
extractInstallCommand,
} = require('../scripts/release-notes/discord-release-summary.cjs');
const sampleRelease = {
tagName: 'v1.4.0-rc.1',
name: 'v1.4.0-rc.1',
isPrerelease: true,
url: 'https://github.com/open-gsd/gsd-core/releases/tag/v1.4.0-rc.1',
body: [
'## Install',
'',
'This pre-release is published to npm under the `next` dist-tag.',
'',
'```bash',
'npm i @opengsd/gsd-core@1.4.0-rc.1',
'# or',
'npm i @opengsd/gsd-core@next',
'```',
'',
'## What\'s Changed',
'',
'### Feature',
'* feat(#656): Research module cache by @trek-e in https://github.com/open-gsd/gsd-core/pull/664',
'* feat(#717): size budget on bytes by @trek-e in https://github.com/open-gsd/gsd-core/pull/719',
'* feat(#159): auto-use existing RESEARCH.md by @trek-e in https://github.com/open-gsd/gsd-core/pull/718',
'* feat(#703): add --granularity override by @trek-e in https://github.com/open-gsd/gsd-core/pull/750',
'* feat(#25): scope verifier Step 7b by @trek-e in https://github.com/open-gsd/gsd-core/pull/753',
'',
'### Fix',
'* fix(#663): resolve CodeQL alerts by @trek-e in https://github.com/open-gsd/gsd-core/pull/665',
'',
'**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/v1.3.1...v1.4.0-rc.1',
].join('\n'),
};
describe('discord release summary', () => {
test('builds a hybrid payload with a preserved full changelog URL', () => {
const payload = buildDiscordReleasePayload({
release: sampleRelease,
packageName: '@opengsd/gsd-core',
maxContent: 1850,
});
assert.equal(payload.username, 'GSD Releases');
assert.match(payload.content, /\*\*@opengsd\/gsd-core v1\.4\.0-rc\.1 pre-release is out\*\*/);
assert.match(payload.content, /`npm i @opengsd\/gsd-core@1\.4\.0-rc\.1`/);
assert.match(payload.content, /Full changelog: https:\/\/github\.com\/open-gsd\/gsd-core\/releases\/tag\/v1\.4\.0-rc\.1/);
assert.doesNotMatch(payload.content, /\*\*Full Changelog\*\*:\s*$/m);
assert.match(payload.content, /Research module cache \(#664\)/);
assert.match(payload.content, /\.\.\.and 1 more features/);
assert.equal(payload.embeds[0].fields[1].value, '`next`');
});
test('summarizes auto-generated What Changed bullets when release notes are not yet curated', () => {
const sections = collectSections([
'## What\'s Changed',
'* feat: add thing by @trek-e in https://github.com/open-gsd/gsd-core/pull/10',
'* fix: repair thing by @trek-e in https://github.com/open-gsd/gsd-core/pull/11',
'* docs: explain thing by @trek-e in https://github.com/open-gsd/gsd-core/pull/12',
].join('\n'));
assert.deepEqual(sections.get('Feature'), ['add thing (#10)']);
assert.deepEqual(sections.get('Fix'), ['repair thing (#11)']);
assert.deepEqual(sections.get('Enhancement'), ['explain thing (#12)']);
});
test('cleans common GitHub release-note link noise without deleting issue references', () => {
assert.equal(
cleanBullet('* fix: repair [#670](https://github.com/open-gsd/gsd-core/issues/670) by @trek-e in https://github.com/open-gsd/gsd-core/pull/675'),
'repair #670 (#675)'
);
});
test('falls back to the release channel when no install block exists', () => {
assert.equal(
extractInstallCommand('', '@opengsd/gsd-core', { tagName: 'v1.4.0-rc.1', isPrerelease: true }),
'npm i @opengsd/gsd-core@next'
);
assert.equal(
extractInstallCommand('', '@opengsd/gsd-core', { tagName: 'v1.4.0', isPrerelease: false }),
'npm i @opengsd/gsd-core@latest'
);
});
});