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
This commit is contained in:
Affaan Mustafa
2026-08-09 16:41:27 -04:00
committed by GitHub
parent 2d46e80e09
commit cdbb25bf9d
6 changed files with 174 additions and 13 deletions
+13 -5
View File
@@ -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 }}
+2 -3
View File
@@ -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 }}
+37
View File
@@ -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 `<!-- ecc-discord-receipt:${createHash('sha256').update(String(key)).digest('hex').slice(0, 32)} -->`;
}
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';
}
+89 -3
View File
@@ -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);
}
+10 -2
View File
@@ -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');
+23
View File
@@ -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, /^<!-- ecc-discord-receipt:[a-f0-9]{32} -->$/);
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');
}