From cdbb25bf9da8c87cdafb0d117fc1bd357bea9b64 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sun, 9 Aug 2026 16:41:27 -0400 Subject: [PATCH] fix: deliver announcements through a scoped Discord webhook (#2737) * test: reproduce Discord webhook announcement gap * fix: deliver ECC announcements through channel webhook * test: cover webhook replay and least privilege * fix: make webhook delivery durable and least privilege * test: cover trusted receipts and cross-workflow races * fix: serialize and authenticate announcement receipts --- .github/workflows/discussion-announce.yml | 18 +++-- .github/workflows/release-announce.yml | 5 +- scripts/discord/announcement-core.mjs | 37 +++++++++ scripts/discord/release-announce.mjs | 92 +++++++++++++++++++++- tests/ci/release-announce-workflow.test.js | 12 ++- tests/scripts/release-announce.test.js | 23 ++++++ 6 files changed, 174 insertions(+), 13 deletions(-) diff --git a/.github/workflows/discussion-announce.yml b/.github/workflows/discussion-announce.yml index f2b25f42d..bd8959faa 100644 --- a/.github/workflows/discussion-announce.yml +++ b/.github/workflows/discussion-announce.yml @@ -3,17 +3,24 @@ name: Discussion Announce on: discussion: types: [created] + workflow_dispatch: + inputs: + discussion_number: + description: Existing Announcement discussion number to deliver + required: true + type: number permissions: contents: read + discussions: write concurrency: - group: discord-discussion-${{ github.event.discussion.node_id }} + group: ecc-discord-announcement-delivery cancel-in-progress: false jobs: announce: - if: github.event.discussion.category.name == 'Announcements' + if: github.event_name == 'workflow_dispatch' || github.event.discussion.category.name == 'Announcements' runs-on: ubuntu-latest steps: - name: Checkout trusted default branch @@ -24,12 +31,13 @@ jobs: - 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 }} + ANNOUNCEMENT_KIND: ${{ github.event_name == 'workflow_dispatch' && 'manual' || 'discussion' }} + DISCORD_ANNOUNCE_WEBHOOK_URL: ${{ secrets.DISCORD_ANNOUNCE_WEBHOOK_URL }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 }} + DISCUSSION_NUMBER: ${{ inputs.discussion_number }} diff --git a/.github/workflows/release-announce.yml b/.github/workflows/release-announce.yml index d60e2631b..aa57e1204 100644 --- a/.github/workflows/release-announce.yml +++ b/.github/workflows/release-announce.yml @@ -9,7 +9,7 @@ permissions: contents: read concurrency: - group: discord-release-${{ github.event.workflow_run.id }} + group: ecc-discord-announcement-delivery cancel-in-progress: false jobs: @@ -29,8 +29,7 @@ jobs: 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 }} + DISCORD_ANNOUNCE_WEBHOOK_URL: ${{ secrets.DISCORD_ANNOUNCE_WEBHOOK_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} RELEASE_TAG: ${{ github.event.workflow_run.head_branch }} diff --git a/scripts/discord/announcement-core.mjs b/scripts/discord/announcement-core.mjs index 4bb839cf6..d3c9e3914 100644 --- a/scripts/discord/announcement-core.mjs +++ b/scripts/discord/announcement-core.mjs @@ -48,3 +48,40 @@ 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; } + +export function normalizeDiscordWebhookUrl(value) { + const raw = String(value || '').trim(); + let parsed; + try { + parsed = new URL(raw); + } catch { + throw new Error('invalid Discord webhook URL'); + } + if (parsed.protocol !== 'https:' || parsed.hostname !== 'discord.com' || parsed.port || parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error('invalid Discord webhook URL'); + } + if (!/^\/api\/webhooks\/\d{10,25}\/[A-Za-z0-9._-]{20,}$/.test(parsed.pathname)) { + throw new Error('invalid Discord webhook URL'); + } + parsed.search = '?wait=true'; + return parsed.toString(); +} + +export function discussionReceiptMarker(key) { + return ``; +} + +export function findDiscussionReceipt(comments, marker) { + return comments.find(comment => ( + comment?.author?.login === 'github-actions[bot]' + && typeof comment.body === 'string' + && comment.body.includes(marker) + )) || null; +} + +export function discussionReceiptStatus(comment) { + const body = String(comment?.body || ''); + if (body.includes('Discord delivery: complete')) return 'complete'; + if (body.includes('Discord delivery: pending')) return 'pending'; + return 'unknown'; +} diff --git a/scripts/discord/release-announce.mjs b/scripts/discord/release-announce.mjs index 081cf8d7b..f03f4b583 100644 --- a/scripts/discord/release-announce.mjs +++ b/scripts/discord/release-announce.mjs @@ -4,8 +4,12 @@ import { announcementKey, buildDiscordPayload, + discussionReceiptMarker, + discussionReceiptStatus, + findDiscussionReceipt, findDiscordReceipt, findReleaseDiscussion, + normalizeDiscordWebhookUrl, releaseMarker, } from './announcement-core.mjs'; @@ -87,6 +91,56 @@ function discussionFromEnvironment() { }; } +async function discussionFromGitHub() { + if (!/^\d+$/.test(env.DISCUSSION_NUMBER || '')) throw new Error('discussion number is invalid'); + const response = await request(`https://api.github.com/repos/${env.GITHUB_REPOSITORY}/discussions/${env.DISCUSSION_NUMBER}`, { + headers: { Authorization: `Bearer ${env.GITHUB_TOKEN}`, Accept: 'application/vnd.github+json' }, + }); + if (!response.ok) throw new Error(`discussion lookup failed (${response.status})`); + const discussion = await response.json(); + if (discussion.category?.name !== 'Announcements') throw new Error('discussion is not an Announcement'); + return { id: discussion.node_id, title: discussion.title, body: discussion.body, url: discussion.html_url }; +} + +async function findReceiptComment(discussionId, marker) { + let cursor = null; + for (let page = 0; page < 50; page += 1) { + const data = await githubGraphql( + `query($id:ID!,$after:String){node(id:$id){... on Discussion{comments(first:100,after:$after){nodes{id body author{login}} pageInfo{hasNextPage endCursor}}}}}`, + { id: discussionId, after: cursor }, + ); + const comments = data.node?.comments; + if (!comments) throw new Error('discussion receipt lookup failed'); + const receipt = findDiscussionReceipt(comments.nodes, marker); + if (receipt) return receipt; + if (!comments.pageInfo.hasNextPage) return null; + cursor = comments.pageInfo.endCursor; + } + throw new Error('discussion receipt lookup exceeded page budget'); +} + +async function addReceiptComment(discussionId, body) { + const data = await githubGraphql( + `mutation($id:ID!,$body:String!){addDiscussionComment(input:{discussionId:$id,body:$body}){comment{id}}}`, + { id: discussionId, body }, + ); + return data.addDiscussionComment.comment.id; +} + +async function updateReceiptComment(commentId, body) { + await githubGraphql( + `mutation($id:ID!,$body:String!){updateDiscussionComment(input:{commentId:$id,body:$body}){comment{id}}}`, + { id: commentId, body }, + ); +} + +async function deleteReceiptComment(commentId) { + await githubGraphql( + `mutation($id:ID!){deleteDiscussionComment(input:{id:$id}){clientMutationId}}`, + { id: commentId }, + ); +} + async function discord(method, path, body) { const response = await request(`https://discord.com/api/v10${path}`, { method, @@ -98,10 +152,40 @@ async function discord(method, path, body) { } async function deliver(discussion) { + const key = announcementKey({ repository: env.GITHUB_REPOSITORY, discussionId: discussion.id }); + if (env.DISCORD_ANNOUNCE_WEBHOOK_URL) { + if (!env.GITHUB_TOKEN) throw new Error('GitHub receipt configuration is missing'); + const webhookUrl = normalizeDiscordWebhookUrl(env.DISCORD_ANNOUNCE_WEBHOOK_URL); + const marker = discussionReceiptMarker(key); + const existingReceipt = await findReceiptComment(discussion.id, marker); + if (existingReceipt) { + if (discussionReceiptStatus(existingReceipt) === 'complete') { + console.log('announcement already delivered'); + return; + } + throw new Error('announcement has a pending receipt; inspect Discord before clearing it'); + } + const claimId = await addReceiptComment(discussion.id, `${marker}\n\nDiscord delivery: pending.`); + const payload = buildDiscordPayload({ title: discussion.title, body: discussion.body, url: discussion.url, key }); + delete payload.nonce; + delete payload.enforce_nonce; + const response = await request(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + await deleteReceiptComment(claimId); + throw new Error(`Discord webhook request failed (${response.status})`); + } + const message = await response.json(); + await updateReceiptComment(claimId, `${marker}\n\nDiscord delivery: complete (message ${message.id}).`); + console.log('announcement delivered by channel webhook'); + return; + } 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) { @@ -121,10 +205,12 @@ async function deliver(discussion) { async function main() { 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'); + if ((env.ANNOUNCEMENT_KIND === 'release' || env.ANNOUNCEMENT_KIND === 'manual') && !env.GITHUB_TOKEN) throw new Error('GitHub configuration is missing'); const discussion = env.ANNOUNCEMENT_KIND === 'release' ? await createOrFindReleaseDiscussion() - : discussionFromEnvironment(); + : env.ANNOUNCEMENT_KIND === 'manual' + ? await discussionFromGitHub() + : discussionFromEnvironment(); await deliver(discussion); } diff --git a/tests/ci/release-announce-workflow.test.js b/tests/ci/release-announce-workflow.test.js index 552ccd17d..788856864 100644 --- a/tests/ci/release-announce-workflow.test.js +++ b/tests/ci/release-announce-workflow.test.js @@ -10,16 +10,24 @@ const releaseWorkflow = fs.readFileSync(path.join(root, '.github/workflows/relea assert.match(discussionWorkflow, /discussion:\s*\n\s*types:\s*\[created\]/); assert.match(discussionWorkflow, /category\.name\s*==\s*'Announcements'/); assert.match(discussionWorkflow, /concurrency:/); +assert.match(discussionWorkflow, /group:\s*ecc-discord-announcement-delivery/); 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(discussionWorkflow, /ANNOUNCEMENT_KIND:.*'manual'.*'discussion'/); +assert.match(discussionWorkflow, /workflow_dispatch:/); +assert.match(discussionWorkflow, /discussion_number:/); +assert.match(discussionWorkflow, /DISCORD_ANNOUNCE_WEBHOOK_URL:\s*\$\{\{ secrets\.DISCORD_ANNOUNCE_WEBHOOK_URL \}\}/); +assert.match(discussionWorkflow, /GITHUB_TOKEN/); +assert.match(discussionWorkflow, /discussions:\s*write/); +assert.doesNotMatch(discussionWorkflow, /DISCORD_BOT_TOKEN|DISCORD_ANNOUNCE_CHANNEL_ID/); 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.match(releaseAnnounceWorkflow, /group:\s*ecc-discord-announcement-delivery/); +assert.doesNotMatch(releaseAnnounceWorkflow, /DISCORD_BOT_TOKEN|DISCORD_ANNOUNCE_CHANNEL_ID/); assert.doesNotMatch(releaseWorkflow, /DISCORD_BOT_TOKEN|ANNOUNCEMENT_KIND/); console.log('release announcement workflow contract: ok'); diff --git a/tests/scripts/release-announce.test.js b/tests/scripts/release-announce.test.js index 714e688b3..4e2a6fcc2 100644 --- a/tests/scripts/release-announce.test.js +++ b/tests/scripts/release-announce.test.js @@ -6,6 +6,10 @@ async function main() { buildDiscordPayload, findReleaseDiscussion, isAnnouncementDiscussion, + normalizeDiscordWebhookUrl, + discussionReceiptMarker, + findDiscussionReceipt, + discussionReceiptStatus, releaseMarker, } = await import('../../scripts/discord/announcement-core.mjs'); @@ -35,6 +39,25 @@ assert.equal(payload.embeds[0].url, 'https://github.com/affaan-m/ECC/discussions assert.equal(payload.enforce_nonce, true); assert.match(payload.nonce, /^ecc-[a-f0-9]{16}$/); +assert.equal( + normalizeDiscordWebhookUrl('https://discord.com/api/webhooks/123456789012345678/secret-token-long-enough'), + 'https://discord.com/api/webhooks/123456789012345678/secret-token-long-enough?wait=true', +); +assert.throws(() => normalizeDiscordWebhookUrl('https://evil.example/api/webhooks/123/token'), /invalid Discord webhook URL/); +assert.throws(() => normalizeDiscordWebhookUrl('https://user@discord.com/api/webhooks/123456789012345678/secret-token-long-enough'), /invalid Discord webhook URL/); +assert.throws(() => normalizeDiscordWebhookUrl('https://discord.com:444/api/webhooks/123456789012345678/secret-token-long-enough'), /invalid Discord webhook URL/); +assert.throws(() => normalizeDiscordWebhookUrl('https://discord.com/api/webhooks/123456789012345678/secret-token-long-enough?leak=1'), /invalid Discord webhook URL/); + +const receiptMarker = discussionReceiptMarker('affaan-m/ECC:discussion:D_kw123'); +assert.match(receiptMarker, /^$/); +assert.equal(findDiscussionReceipt([ + { id: 'forged', body: `Discord delivery: complete\n${receiptMarker}`, author: { login: 'attacker' } }, + { id: 'comment-1', body: `Discord delivery: complete\n${receiptMarker}`, author: { login: 'github-actions[bot]' } }, +], receiptMarker).id, 'comment-1'); +assert.equal(findDiscussionReceipt([{ id: 'comment-2', body: 'unrelated' }], receiptMarker), null); +assert.equal(discussionReceiptStatus({ body: `Discord delivery: pending.\n${receiptMarker}` }), 'pending'); +assert.equal(discussionReceiptStatus({ body: `Discord delivery: complete (message 1).\n${receiptMarker}` }), 'complete'); + console.log('release announcement core: ok'); }