From edebcc89efa09dc2705748151d98286ff3bb6023 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 8 Jun 2026 22:38:03 -0400 Subject: [PATCH] feat(discord): release -> #announcements auto-post + pin + GitHub Discussions (#2201) On a published GitHub release, post the notes to the ECC Discord #announcements channel (via bot), pin it, and cross-post to GitHub Discussions (Announcements category). Release data flows through env vars (no shell interpolation of untrusted input). Secrets: DISCORD_BOT_TOKEN, DISCORD_ANNOUNCE_CHANNEL_ID (repo secrets), GITHUB_TOKEN. Ties the 2.0.0/1.11.0 official release to the community launch. Co-authored-by: ECC Test --- .github/workflows/release-announce.yml | 27 +++++++ scripts/discord/release-announce.mjs | 106 +++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 .github/workflows/release-announce.yml create mode 100644 scripts/discord/release-announce.mjs diff --git a/.github/workflows/release-announce.yml b/.github/workflows/release-announce.yml new file mode 100644 index 00000000..1f0e217d --- /dev/null +++ b/.github/workflows/release-announce.yml @@ -0,0 +1,27 @@ +name: Release Announce + +on: + release: + types: [published] + +permissions: + contents: read + discussions: write + +jobs: + announce: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Announce release to Discord + Discussions + run: node scripts/discord/release-announce.mjs + env: + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_ANNOUNCE_CHANNEL_ID: ${{ secrets.DISCORD_ANNOUNCE_CHANNEL_ID }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + RELEASE_NAME: ${{ github.event.release.name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_URL: ${{ github.event.release.html_url }} + RELEASE_BODY: ${{ github.event.release.body }} diff --git a/scripts/discord/release-announce.mjs b/scripts/discord/release-announce.mjs new file mode 100644 index 00000000..6da5dea2 --- /dev/null +++ b/scripts/discord/release-announce.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +// Posts a published GitHub release to the Discord #announcements channel, +// pins it, and cross-posts to GitHub Discussions (Announcements category). +// Dependency-free (Node 18+ fetch). Runs from the release-announce workflow. +'use strict'; + +const { + DISCORD_BOT_TOKEN, + DISCORD_ANNOUNCE_CHANNEL_ID, + RELEASE_NAME, + RELEASE_TAG, + RELEASE_URL, + RELEASE_BODY, + GITHUB_TOKEN, + GITHUB_REPOSITORY, +} = process.env; + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +async function discord(method, path, body) { + const res = await fetch(`https://discord.com/api/v10${path}`, { + method, + headers: { Authorization: `Bot ${DISCORD_BOT_TOKEN}`, 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }); + if (res.status === 429) { + const j = await res.json().catch(() => ({ retry_after: 1 })); + await sleep((j.retry_after || 1) * 1000 + 250); + return discord(method, path, body); + } + if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${(await res.text()).slice(0, 200)}`); + return res.status === 204 ? null : res.json(); +} + +function buildMessage() { + const title = (RELEASE_NAME && RELEASE_NAME.trim()) || RELEASE_TAG || 'New release'; + const body = (RELEASE_BODY || '').trim(); + // Discord message cap is 2000 chars; leave room for header + link. + const maxBody = 1600; + const trimmed = body.length > maxBody ? `${body.slice(0, maxBody)}\n...` : body; + const parts = [`# ${title} is out`, '']; + if (trimmed) parts.push(trimmed, ''); + if (RELEASE_URL) parts.push(`full release notes: ${RELEASE_URL}`); + return parts.join('\n'); +} + +async function postAndPinToDiscord() { + if (!DISCORD_BOT_TOKEN || !DISCORD_ANNOUNCE_CHANNEL_ID) { + console.log('skip discord: missing DISCORD_BOT_TOKEN / DISCORD_ANNOUNCE_CHANNEL_ID'); + return; + } + const msg = await discord('POST', `/channels/${DISCORD_ANNOUNCE_CHANNEL_ID}/messages`, { content: buildMessage() }); + console.log('posted release to #announcements:', msg.id); + try { + await discord('PUT', `/channels/${DISCORD_ANNOUNCE_CHANNEL_ID}/pins/${msg.id}`); + console.log('pinned announcement'); + } catch (e) { + console.log('pin skipped:', e.message); + } +} + +async function graphql(query, variables) { + const res = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { Authorization: `Bearer ${GITHUB_TOKEN}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }); + const j = await res.json(); + if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 300)); + return j.data; +} + +async function crossPostToDiscussions() { + if (!GITHUB_TOKEN || !GITHUB_REPOSITORY) { + console.log('skip discussions: missing GITHUB_TOKEN / GITHUB_REPOSITORY'); + return; + } + const [owner, name] = GITHUB_REPOSITORY.split('/'); + try { + const data = await graphql( + `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id discussionCategories(first:25){nodes{id name}}}}`, + { owner, name } + ); + const repo = data.repository; + const cat = repo.discussionCategories.nodes.find(c => /announcement/i.test(c.name)) + || repo.discussionCategories.nodes[0]; + if (!cat) { console.log('skip discussions: no category found'); return; } + const title = `${(RELEASE_NAME && RELEASE_NAME.trim()) || RELEASE_TAG} release`; + const bodyParts = [(RELEASE_BODY || '').trim(), '', RELEASE_URL ? `Release: ${RELEASE_URL}` : ''].filter(Boolean); + const created = await graphql( + `mutation($repo:ID!,$cat:ID!,$title:String!,$body:String!){createDiscussion(input:{repositoryId:$repo,categoryId:$cat,title:$title,body:$body}){discussion{url}}}`, + { repo: repo.id, cat: cat.id, title, body: bodyParts.join('\n') || title } + ); + console.log('created discussion:', created.createDiscussion.discussion.url); + } catch (e) { + console.log('discussions cross-post skipped:', e.message); + } +} + +async function main() { + await postAndPinToDiscord(); + await crossPostToDiscussions(); + console.log('release-announce done'); +} + +main().catch(e => { console.error('release-announce FAILED:', e.message); process.exit(1); });