From dd75400801f47e86dd6277657841d237bdf41362 Mon Sep 17 00:00:00 2001 From: Solvely-Colin <211764741+Solvely-Colin@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:27:20 -0400 Subject: [PATCH] fix discord release changelog announcements --- .github/workflows/discord-changelog.yml | 142 +------ .github/workflows/release.yml | 26 ++ .../release-notes/discord-release-summary.cjs | 373 ++++++++++++++++++ tests/discord-release-summary.test.cjs | 92 +++++ 4 files changed, 508 insertions(+), 125 deletions(-) create mode 100644 scripts/release-notes/discord-release-summary.cjs create mode 100644 tests/discord-release-summary.test.cjs diff --git a/.github/workflows/discord-changelog.yml b/.github/workflows/discord-changelog.yml index 30ffbba78..768fb4881 100644 --- a/.github/workflows/discord-changelog.yml +++ b/.github/workflows/discord-changelog.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52145edee..19867304a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/scripts/release-notes/discord-release-summary.cjs b/scripts/release-notes/discord-release-summary.cjs new file mode 100644 index 000000000..545bfda8f --- /dev/null +++ b/scripts/release-notes/discord-release-summary.cjs @@ -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 |--latest|--stdin] [options]\n' + + '\n' + + 'Options:\n' + + ' --tag GitHub release tag to announce\n' + + ' --latest Announce the latest GitHub release\n' + + ' --stdin Read release JSON from stdin\n' + + ' --repo Repository for gh release view\n' + + ' --package 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 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, +}; diff --git a/tests/discord-release-summary.test.cjs b/tests/discord-release-summary.test.cjs new file mode 100644 index 000000000..0b3271c32 --- /dev/null +++ b/tests/discord-release-summary.test.cjs @@ -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' + ); + }); +});