mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
fix: deliver ECC announcements to Discord (#2732)
This commit is contained in:
1 parent
51a6950bde
commit
2d46e80e09
6 files changed
+284
-95
No files matched your search
@@ -0,0 +1,35 @@
|
||||
name: Discussion Announce
|
||||
|
||||
on:
|
||||
discussion:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: discord-discussion-${{ github.event.discussion.node_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
announce:
|
||||
if: github.event.discussion.category.name == 'Announcements'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout trusted default branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
- name: Send announcement to Discord
|
||||
run: node scripts/discord/release-announce.mjs
|
||||
env:
|
||||
ANNOUNCEMENT_KIND: discussion
|
||||
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
|
||||
DISCORD_ANNOUNCE_CHANNEL_ID: ${{ secrets.DISCORD_ANNOUNCE_CHANNEL_ID }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
DISCUSSION_ID: ${{ github.event.discussion.node_id }}
|
||||
DISCUSSION_TITLE: ${{ github.event.discussion.title }}
|
||||
DISCUSSION_BODY: ${{ github.event.discussion.body }}
|
||||
DISCUSSION_URL: ${{ github.event.discussion.html_url }}
|
||||
DISCUSSION_CATEGORY: ${{ github.event.discussion.category.name }}
|
||||
@@ -1,29 +1,36 @@
|
||||
name: Release Announce
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_run:
|
||||
workflows: [Release]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
discussions: write
|
||||
|
||||
concurrency:
|
||||
group: discord-release-${{ github.event.workflow_run.id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
announce:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
discussions: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout trusted default branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
- name: Announce release to Discord + Discussions
|
||||
- name: Create announcement and send it to Discord
|
||||
run: node scripts/discord/release-announce.mjs
|
||||
env:
|
||||
ANNOUNCEMENT_KIND: release
|
||||
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 }}
|
||||
RELEASE_TAG: ${{ github.event.workflow_run.head_branch }}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const DISCORD_DESCRIPTION_LIMIT = 4000;
|
||||
|
||||
export function isAnnouncementDiscussion(discussion) {
|
||||
return discussion?.category?.name === 'Announcements';
|
||||
}
|
||||
|
||||
export function releaseMarker(tag) {
|
||||
const normalized = String(tag || '').trim();
|
||||
if (!normalized) throw new Error('release tag is required');
|
||||
return `<!-- ecc-release:${normalized} -->`;
|
||||
}
|
||||
|
||||
export function findReleaseDiscussion(discussions, marker) {
|
||||
return discussions.find(item => (
|
||||
item?.category?.name === 'Announcements'
|
||||
&& typeof item.body === 'string'
|
||||
&& item.body.includes(marker)
|
||||
)) || null;
|
||||
}
|
||||
|
||||
export function announcementKey({ repository, discussionId }) {
|
||||
if (!/^[^/\s]+\/[^/\s]+$/.test(String(repository || ''))) throw new Error('invalid repository');
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(String(discussionId || ''))) throw new Error('invalid discussion id');
|
||||
return `${repository}:discussion:${discussionId}`;
|
||||
}
|
||||
|
||||
export function buildDiscordPayload({ title, body, url, key }) {
|
||||
const discussionId = String(key).split(':').at(-1);
|
||||
const footer = `ecc:${discussionId}`;
|
||||
const description = String(body || '').trim().slice(0, DISCORD_DESCRIPTION_LIMIT);
|
||||
const nonce = `ecc-${createHash('sha256').update(String(key)).digest('hex').slice(0, 16)}`;
|
||||
return {
|
||||
allowed_mentions: { parse: [] },
|
||||
nonce,
|
||||
enforce_nonce: true,
|
||||
embeds: [{
|
||||
title: String(title || 'ECC announcement').trim().slice(0, 256),
|
||||
description,
|
||||
url: String(url || ''),
|
||||
footer: { text: footer },
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
export function findDiscordReceipt(messages, key) {
|
||||
const discussionId = String(key).split(':').at(-1);
|
||||
return messages.find(message => message.embeds?.some(embed => embed.footer?.text === `ecc:${discussionId}`)) || null;
|
||||
}
|
||||
@@ -1,106 +1,134 @@
|
||||
#!/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;
|
||||
import {
|
||||
announcementKey,
|
||||
buildDiscordPayload,
|
||||
findDiscordReceipt,
|
||||
findReleaseDiscussion,
|
||||
releaseMarker,
|
||||
} from './announcement-core.mjs';
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const env = process.env;
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, 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);
|
||||
async function request(url, options = {}, attempts = 3) {
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
const response = await fetch(url, options);
|
||||
if (response.status !== 429 || attempt === attempts) return response;
|
||||
const data = await response.json().catch(() => ({}));
|
||||
await sleep(Math.min(Number(data.retry_after || 1) * 1000 + 250, 10_000));
|
||||
}
|
||||
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${(await res.text()).slice(0, 200)}`);
|
||||
return res.status === 204 ? null : res.json();
|
||||
throw new Error('request retry budget exhausted');
|
||||
}
|
||||
|
||||
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', {
|
||||
async function githubGraphql(query, variables) {
|
||||
const response = await request('https://api.github.com/graphql', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${GITHUB_TOKEN}`, 'Content-Type': 'application/json' },
|
||||
headers: { Authorization: `Bearer ${env.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;
|
||||
if (!response.ok) throw new Error(`GitHub GraphQL request failed (${response.status})`);
|
||||
const payload = await response.json();
|
||||
if (payload.errors) throw new Error('GitHub GraphQL returned errors');
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
async function crossPostToDiscussions() {
|
||||
if (!GITHUB_TOKEN || !GITHUB_REPOSITORY) {
|
||||
console.log('skip discussions: missing GITHUB_TOKEN / GITHUB_REPOSITORY');
|
||||
async function releaseFromGitHub() {
|
||||
const [owner, repo] = env.GITHUB_REPOSITORY.split('/');
|
||||
const tag = env.RELEASE_TAG || env.GITHUB_REF_NAME;
|
||||
const response = await request(`https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`, {
|
||||
headers: { Authorization: `Bearer ${env.GITHUB_TOKEN}`, Accept: 'application/vnd.github+json' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`release lookup failed (${response.status})`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function createOrFindReleaseDiscussion() {
|
||||
const release = await releaseFromGitHub();
|
||||
const [owner, name] = env.GITHUB_REPOSITORY.split('/');
|
||||
const marker = releaseMarker(release.tag_name);
|
||||
const data = await githubGraphql(
|
||||
`query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id discussionCategories(first:25){nodes{id name}}}}`,
|
||||
{ owner, name },
|
||||
);
|
||||
const repository = data.repository;
|
||||
let cursor = null;
|
||||
let existing = null;
|
||||
for (let page = 0; page < 50 && !existing; page += 1) {
|
||||
const pageData = await githubGraphql(
|
||||
`query($owner:String!,$name:String!,$after:String){repository(owner:$owner,name:$name){discussions(first:100,after:$after,orderBy:{field:CREATED_AT,direction:DESC}){nodes{id title body url category{name}} pageInfo{hasNextPage endCursor}}}}`,
|
||||
{ owner, name, after: cursor },
|
||||
);
|
||||
const discussions = pageData.repository.discussions;
|
||||
existing = findReleaseDiscussion(discussions.nodes, marker);
|
||||
if (!discussions.pageInfo.hasNextPage) break;
|
||||
cursor = discussions.pageInfo.endCursor;
|
||||
}
|
||||
if (existing) return existing;
|
||||
const category = repository.discussionCategories.nodes.find(item => item.name === 'Announcements');
|
||||
if (!category) throw new Error('Announcements discussion category is required');
|
||||
const title = `${release.name || release.tag_name} release`;
|
||||
const body = [marker, release.body || '', `Release: ${release.html_url}`].filter(Boolean).join('\n\n');
|
||||
const created = await githubGraphql(
|
||||
`mutation($repo:ID!,$cat:ID!,$title:String!,$body:String!){createDiscussion(input:{repositoryId:$repo,categoryId:$cat,title:$title,body:$body}){discussion{id title body url category{name}}}}`,
|
||||
{ repo: repository.id, cat: category.id, title, body },
|
||||
);
|
||||
return created.createDiscussion.discussion;
|
||||
}
|
||||
|
||||
function discussionFromEnvironment() {
|
||||
if (env.DISCUSSION_CATEGORY !== 'Announcements') throw new Error('discussion is not an Announcement');
|
||||
return {
|
||||
id: env.DISCUSSION_ID,
|
||||
title: env.DISCUSSION_TITLE,
|
||||
body: env.DISCUSSION_BODY,
|
||||
url: env.DISCUSSION_URL,
|
||||
};
|
||||
}
|
||||
|
||||
async function discord(method, path, body) {
|
||||
const response = await request(`https://discord.com/api/v10${path}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bot ${env.DISCORD_BOT_TOKEN}`, 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Discord request failed (${response.status})`);
|
||||
return response.status === 204 ? null : response.json();
|
||||
}
|
||||
|
||||
async function deliver(discussion) {
|
||||
if (!env.DISCORD_BOT_TOKEN || !/^\d{10,25}$/.test(env.DISCORD_ANNOUNCE_CHANNEL_ID || '')) {
|
||||
throw new Error('Discord announcement credentials are missing or invalid');
|
||||
}
|
||||
const key = announcementKey({ repository: env.GITHUB_REPOSITORY, discussionId: discussion.id });
|
||||
const recent = await discord('GET', `/channels/${env.DISCORD_ANNOUNCE_CHANNEL_ID}/messages?limit=100`);
|
||||
const receipt = findDiscordReceipt(recent, key);
|
||||
if (receipt) {
|
||||
await discord('PUT', `/channels/${env.DISCORD_ANNOUNCE_CHANNEL_ID}/pins/${receipt.id}`);
|
||||
console.log('announcement already delivered; pin verified');
|
||||
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);
|
||||
}
|
||||
const message = await discord('POST', `/channels/${env.DISCORD_ANNOUNCE_CHANNEL_ID}/messages`, buildDiscordPayload({
|
||||
title: discussion.title,
|
||||
body: discussion.body,
|
||||
url: discussion.url,
|
||||
key,
|
||||
}));
|
||||
await discord('PUT', `/channels/${env.DISCORD_ANNOUNCE_CHANNEL_ID}/pins/${message.id}`);
|
||||
console.log('announcement delivered and pinned');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await postAndPinToDiscord();
|
||||
await crossPostToDiscussions();
|
||||
console.log('release-announce done');
|
||||
if (!env.GITHUB_REPOSITORY) throw new Error('GitHub repository configuration is missing');
|
||||
if (env.ANNOUNCEMENT_KIND === 'release' && !env.GITHUB_TOKEN) throw new Error('GitHub release configuration is missing');
|
||||
const discussion = env.ANNOUNCEMENT_KIND === 'release'
|
||||
? await createOrFindReleaseDiscussion()
|
||||
: discussionFromEnvironment();
|
||||
await deliver(discussion);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('release-announce FAILED:', e.message); process.exit(1); });
|
||||
main().catch(error => {
|
||||
console.error(`release-announce failed: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const releaseAnnounceWorkflow = fs.readFileSync(path.join(root, '.github/workflows/release-announce.yml'), 'utf8');
|
||||
const discussionWorkflow = fs.readFileSync(path.join(root, '.github/workflows/discussion-announce.yml'), 'utf8');
|
||||
const releaseWorkflow = fs.readFileSync(path.join(root, '.github/workflows/release.yml'), 'utf8');
|
||||
|
||||
assert.match(discussionWorkflow, /discussion:\s*\n\s*types:\s*\[created\]/);
|
||||
assert.match(discussionWorkflow, /category\.name\s*==\s*'Announcements'/);
|
||||
assert.match(discussionWorkflow, /concurrency:/);
|
||||
assert.doesNotMatch(discussionWorkflow, /pull_request_target|workflow_run/);
|
||||
assert.match(discussionWorkflow, /persist-credentials:\s*false/);
|
||||
assert.match(discussionWorkflow, /ANNOUNCEMENT_KIND:\s*discussion/);
|
||||
assert.doesNotMatch(discussionWorkflow, /GITHUB_TOKEN|discussions:\s*write/);
|
||||
assert.match(releaseAnnounceWorkflow, /workflow_run:/);
|
||||
assert.match(releaseAnnounceWorkflow, /workflows:\s*\[Release\]/);
|
||||
assert.match(releaseAnnounceWorkflow, /conclusion\s*==\s*'success'/);
|
||||
assert.match(releaseAnnounceWorkflow, /ref:\s*\$\{\{ github\.event\.repository\.default_branch \}\}/);
|
||||
assert.match(releaseAnnounceWorkflow, /ANNOUNCEMENT_KIND:\s*release/);
|
||||
assert.match(releaseAnnounceWorkflow, /discussions:\s*write/);
|
||||
assert.doesNotMatch(releaseWorkflow, /DISCORD_BOT_TOKEN|ANNOUNCEMENT_KIND/);
|
||||
|
||||
console.log('release announcement workflow contract: ok');
|
||||
@@ -0,0 +1,44 @@
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
async function main() {
|
||||
const {
|
||||
announcementKey,
|
||||
buildDiscordPayload,
|
||||
findReleaseDiscussion,
|
||||
isAnnouncementDiscussion,
|
||||
releaseMarker,
|
||||
} = await import('../../scripts/discord/announcement-core.mjs');
|
||||
|
||||
assert.equal(isAnnouncementDiscussion({ category: { name: 'Announcements' } }), true);
|
||||
assert.equal(isAnnouncementDiscussion({ category: { name: 'General' } }), false);
|
||||
assert.equal(isAnnouncementDiscussion({ category: { name: 'announcements' } }), false);
|
||||
|
||||
assert.equal(releaseMarker('v2.2.0'), '<!-- ecc-release:v2.2.0 -->');
|
||||
const marker = releaseMarker('v2.2.0');
|
||||
assert.equal(findReleaseDiscussion([
|
||||
{ id: 'untrusted', body: marker, category: { name: 'General' } },
|
||||
{ id: 'canonical', body: marker, category: { name: 'Announcements' } },
|
||||
], marker).id, 'canonical');
|
||||
assert.equal(announcementKey({ repository: 'affaan-m/ECC', discussionId: 'D_kw123' }), 'affaan-m/ECC:discussion:D_kw123');
|
||||
|
||||
const payload = buildDiscordPayload({
|
||||
title: '@everyone ECC 2.2.0',
|
||||
body: 'A'.repeat(5000),
|
||||
url: 'https://github.com/affaan-m/ECC/discussions/3000',
|
||||
key: 'affaan-m/ECC:discussion:D_kw123',
|
||||
});
|
||||
assert.deepEqual(payload.allowed_mentions, { parse: [] });
|
||||
assert.equal(payload.embeds.length, 1);
|
||||
assert.ok(payload.embeds[0].description.length <= 4000);
|
||||
assert.equal(payload.embeds[0].footer.text, 'ecc:D_kw123');
|
||||
assert.equal(payload.embeds[0].url, 'https://github.com/affaan-m/ECC/discussions/3000');
|
||||
assert.equal(payload.enforce_nonce, true);
|
||||
assert.match(payload.nonce, /^ecc-[a-f0-9]{16}$/);
|
||||
|
||||
console.log('release announcement core: ok');
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in new issue
Block a user