Files
ECC/scripts/discord/release-announce.mjs
Affaan MustafaandGitHub 649def769b fix: complete Discord delivery receipts reliably (#2738)
* test: reproduce Actions receipt completion mismatch

* fix: complete Discord receipts with Actions identity
2026-08-09 16:44:18 -04:00

217 lines
9.4 KiB
JavaScript

#!/usr/bin/env node
'use strict';
import {
announcementKey,
buildDiscordPayload,
discussionReceiptMarker,
discussionReceiptStatus,
findDiscussionReceipt,
findDiscordReceipt,
findReleaseDiscussion,
normalizeDiscordWebhookUrl,
releaseMarker,
} from './announcement-core.mjs';
const env = process.env;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
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));
}
throw new Error('request retry budget exhausted');
}
async function githubGraphql(query, variables) {
const response = await request('https://api.github.com/graphql', {
method: 'POST',
headers: { Authorization: `Bearer ${env.GITHUB_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
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 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 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 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,
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) {
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 addReceiptComment(discussion.id, `${marker}\n\nDiscord delivery: complete (message ${message.id}).`);
await deleteReceiptComment(claimId).catch(() => {
console.warn('announcement delivered; pending receipt cleanup requires attention');
});
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 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 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() {
if (!env.GITHUB_REPOSITORY) throw new Error('GitHub repository 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()
: env.ANNOUNCEMENT_KIND === 'manual'
? await discussionFromGitHub()
: discussionFromEnvironment();
await deliver(discussion);
}
main().catch(error => {
console.error(`release-announce failed: ${error.message}`);
process.exitCode = 1;
});