Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions docs/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,46 @@ Only ARK and arknet have `.github/workflows/notify-discord.yml`, which calls the
workflow's metadata and job results. It does not execute the triggering commit or
download its artifacts. A matrix produces one summary per workflow attempt.

Messages show the repository, workflow, branch, commit, outcome, job counts,
failed or cancelled job names, and a link to the run. Mentions are disabled.
## Summary cards

Each attempt produces one Discord Embed. Its title and color identify the outcome:
✅ green for success, ❌ red for failure (including timeouts, action-required and
startup failures), and ⏹ amber for cancellation. Other conclusions use a neutral
color and their result label.

The card shows the repository and workflow, PR number when GitHub supplies one,
branch, linked short SHA and commit subject when available, job counts, and links
to the exact run attempt. If multiple PRs are attached, the first is linked and
the remaining count is shown. Successful runs omit zero counts and problem fields.
A missing branch is marked detached; missing job data or commit metadata is not
invented.

Failed jobs appear first, before cancelled jobs and general metadata. Each job
links to its own GitHub job page and shows up to two failed step names. If GitHub
provides no failed step, the card says so. Extra failed steps have a remaining
count. Large problem lists occupy at most two fields and explicitly report how
many jobs were omitted; the run link leads to all results.

**Duration** runs from the earliest executed job start to the latest job
completion in the notified attempt. It includes gaps between those jobs, excludes
the initial queue, and does not add parallel matrix durations. It does not use
run creation or update timestamps, which can mislead on reruns and manual
resends. If any executed job has missing or invalid timestamps, or all jobs are
skipped, it displays `Unavailable`.

Source titles, branch names, job names and step names are treated as text:
Markdown is escaped and mentions and automatic URL formatting are neutralized.
Only links built from the repository, validated IDs and SHA are emitted. Mentions
are also disabled with `allowed_mentions`. Text truncation preserves complete
escapes and Unicode characters; whole links are kept intact. Field budgets stay
within Discord's title, field, field-count and 6,000-character aggregate
[Embed limits](https://docs.discord.com/developers/resources/message#embed-limits).
These are standard Webhook Embeds with Markdown links; they do not require buttons.

Discord must return a message receipt for the notification job to pass. Explicit
rate limits are retried twice; other delivery errors fail the notification job.
The card formatting does not change the shared workflow, Secrets, permissions,
resend behavior, or delivery/receipt rules.

## Webhook configuration

Expand Down
34 changes: 31 additions & 3 deletions docs/discord_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,37 @@
`discord-summary.yml`。通知只读取已完成工作流的元数据和任务结果,
不执行触发通知的提交,也不下载其产物。矩阵任务按一次工作流执行汇总为一条消息。

消息包含仓库、工作流、分支、提交、结果、任务数量、失败或取消的任务名称,以及运行链接。
消息不会触发成员提醒。只有收到 Discord 消息回执,通知任务才算通过。
遇到明确的限流响应最多重试两次,其他发送错误会使通知任务失败。
## 汇总卡片

每次执行生成一条 Discord Embed。标题与颜色共同表示结果:成功使用 ✅ 和绿色,
失败使用 ❌ 和红色(包括超时、需要操作、启动失败),取消使用 ⏹ 和琥珀色。
其他结果使用中性色,并显示对应状态。

卡片包含仓库、工作流、GitHub 提供的 PR 编号、分支、带链接的短 SHA、可用的提交标题、
任务数量,以及本次执行的链接。关联多个 PR 时,显示第一个 PR 的链接和其余 PR 数量。
成功时省略数量为零的状态和异常任务字段。分支缺失时标记为 detached;
任务数据或提交元数据缺失时,不补造内容。

失败任务优先展示,其后是取消的任务,再展示常规信息。每个任务链接到对应的
GitHub 任务页面,并显示最多两个失败步骤名称。GitHub 未提供失败步骤时,卡片明确提示。
其余失败步骤显示剩余数量。异常任务清单最多占用两个字段,超出时明确提示省略的任务数量;
完整结果通过运行链接查看。

**执行耗时(Duration)** 表示本次执行中,从最早一个已执行任务开始到最后一个任务结束的时间。
它包含这些任务之间的间隔,不包含初始排队时间,也不累加并行矩阵任务的耗时。
计算不使用工作流创建时间或更新时间,避免重新执行和手动重发导致结果失真。
任一已执行任务缺少有效时间戳,或所有任务均跳过时,显示 `Unavailable`。

标题、分支名、任务名和步骤名均按文本处理:转义 Markdown,阻止成员提醒和自动 URL 格式化。
仅生成由仓库地址、已校验的 ID 和 SHA 组成的链接,同时通过 `allowed_mentions` 禁用提醒。
截断保留完整转义和 Unicode 字符,不截断链接。字段预算遵守 Discord 的标题长度、字段长度、
字段数量和总计 6,000 字符的
[Embed 限制](https://docs.discord.com/developers/resources/message#embed-limits)。
卡片使用普通 Webhook Embed 和 Markdown 链接,不依赖按钮。

只有收到 Discord 消息回执,通知任务才算通过。遇到明确的限流响应最多重试两次,
其他发送错误会使通知任务失败。卡片格式调整不改变共享工作流、Secret、权限、
重新发送行为,以及投递和回执规则。

## Webhook 配置

Expand Down
177 changes: 143 additions & 34 deletions scripts/discord.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,148 @@ const labels = {
neutral: 'Neutral', skipped: 'Skipped', stale: 'Stale',
startup_failure: 'Startup failure',
};
const events = {
pull_request: 'Pull request', push: 'Push', merge_group: 'Merge queue',
workflow_dispatch: 'Manual run', schedule: 'Scheduled run', workflow_run: 'Workflow completion',
};

// Escape before truncating, keeping escapes and surrogate pairs intact. Source metadata
// must remain text: it cannot add Markdown links, mentions or URL previews.
function text(value, limit, markdown = true) {
const tokens = Array.from(String(value ?? '').replace(/[\s\u0000-\u001f\u007f]+/g, ' ').trim(), char => {
if (char === '@') return '@\u200b';
if (char === '<') return '‹';
if (char === '>') return '›';
if (char === ':' || char === '.') return char + '\u200b';
return markdown && /[\\`*_{}[\]()#+!|~\-]/.test(char) ? '\\' + char : char;
});
const result = tokens.join('');
if (result.length <= limit) return result;
let shortened = '';
for (const token of tokens) {
if (shortened.length + token.length > limit - 1) break;
shortened += token;
}
return shortened + '…';
}

function positiveId(value) {
return /^\d{1,16}$/.test(String(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0;
}

function repositoryUrl(context) {
try {
const server = new URL(context.serverUrl || 'https://github.com');
const { owner, repo } = context.repo;
if (server.protocol !== 'https:' || server.username || server.password ||
![owner, repo].every(part => /^[a-zA-Z0-9_.-]{1,100}$/.test(part))) return '';
const url = `${server.origin}/${owner}/${repo}`.replace(/[()]/g, char => char === '(' ? '%28' : '%29');
// Bound encoded targets too; never truncate a Markdown link inside its URL.
return url.length <= 256 ? url : '';
} catch {
return '';
}
}

function clip(value, limit) {
const text = String(value ?? '');
return text.length <= limit ? text : text.slice(0, limit - 3) + '...';
function link(label, url) {
return url ? `[${label}](${url})` : label;
}

function duration(jobs) {
// Current-attempt job timestamps avoid original-run creation times on reruns,
// summed matrix durations, and updated_at (which is not a completion time).
const executed = jobs.filter(job => job.conclusion !== 'skipped');
if (!executed.length) return 'Unavailable';
let first = Infinity, last = -Infinity;
for (const job of executed) {
const start = Date.parse(job.started_at), end = Date.parse(job.completed_at);
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return 'Unavailable';
first = Math.min(first, start);
last = Math.max(last, end);
}
const seconds = Math.floor((last - first) / 1000);
const hours = Math.floor(seconds / 3600), minutes = Math.floor(seconds % 3600 / 60);
return `${hours ? `${hours}h ` : ''}${hours || minutes ? `${minutes}m ` : ''}${seconds % 60}s`;
}

function problemFields(jobs, runUrl) {
const problems = [
...jobs.filter(job => failed.has(job.conclusion)),
...jobs.filter(job => job.conclusion === 'cancelled'),
];
const fields = [];
let index = 0;
while (index < problems.length && fields.length < 2) {
const rows = [];
// Reserve space for a visible omission count in the final field.
const budget = fields.length === 1 ? 940 : 1024;
while (index < problems.length) {
const job = problems[index];
const jobUrl = positiveId(job.id) && runUrl ? `${runUrl}/job/${job.id}` : '';
const steps = (job.steps || []).filter(step => failed.has(step.conclusion));
const details = steps.slice(0, 2).map(step => text(step.name || '(unnamed step)', 80)).join('; ');
const stepSummary = steps.length
? `\n ↳ ${details}${steps.length > 2 ? ` (+${steps.length - 2} more failed steps)` : ''}`
: failed.has(job.conclusion) ? '\n ↳ Failed step unavailable' : '';
const row = `${link(text(job.name || '(unnamed job)', 100), jobUrl)} — ${labels[job.conclusion]}${stepSummary}`;
if (rows.join('\n').length + (rows.length ? 1 : 0) + row.length > budget) break;
rows.push(row);
index++;
}
fields.push({ name: fields.length ? 'Problems (continued)' : 'Failed / cancelled jobs', value: rows.join('\n') });
}
if (index < problems.length) {
fields[fields.length - 1].value += `\n… ${problems.length - index} more jobs; see the run for all results.`;
}
return fields;
}

function buildPayload(context, run, jobs, runId) {
const repoUrl = repositoryUrl(context);
const runUrl = repoUrl ? `${repoUrl}/actions/runs/${runId}` : '';
const attemptUrl = runUrl && positiveId(run.run_attempt) ? `${runUrl}/attempts/${run.run_attempt}` : runUrl;
const outcome = labels[run.conclusion] || 'Unknown result';
const icon = run.conclusion === 'success' ? '✅' : failed.has(run.conclusion) ? '❌' : run.conclusion === 'cancelled' ? '⏹' : 'ℹ️';
const counts = { passed: 0, failed: 0, cancelled: 0, skipped: 0, other: 0 };
for (const job of jobs) {
if (job.conclusion === 'success') counts.passed++;
else if (failed.has(job.conclusion)) counts.failed++;
else if (job.conclusion === 'cancelled') counts.cancelled++;
else if (job.conclusion === 'skipped') counts.skipped++;
else counts.other++;
}
const summary = Object.entries(counts).filter(([, count]) => count).map(([label, count]) => `${count} ${label}`).join(' · ') || 'No jobs reported';
const pulls = (run.pull_requests || []).filter(pr => positiveId(pr.number));
const pull = pulls[0];
const ref = text(run.head_branch || '(detached)', 160);
const pullSummary = pull ? `${link(`PR #${pull.number}`, repoUrl ? `${repoUrl}/pull/${pull.number}` : '')}${pulls.length > 1 ? ` (+${pulls.length - 1} more PRs)` : ''}\n` : '';
const sha = typeof run.head_sha === 'string' && /^[a-fA-F0-9]{7,64}$/.test(run.head_sha) ? run.head_sha : '';
const displayTitle = run.display_title || run.name || 'Workflow completed';
const subject = (run.head_commit?.message || '').split(/\r?\n/)[0];
const commitTitle = subject === displayTitle ? '' : text(subject, 160);
const fields = [
...problemFields(jobs, runUrl),
{ name: 'Repository', value: link(text(`${context.repo.owner}/${context.repo.repo}`, 128), repoUrl), inline: true },
{ name: 'Workflow', value: link(text(run.name || '(unnamed workflow)', 120), attemptUrl), inline: true },
{ name: pull ? 'Pull request / branch' : 'Branch', value: pullSummary + ref },
{ name: 'Commit', value: `${link(sha ? sha.slice(0, 12) : 'Unavailable', sha && repoUrl ? `${repoUrl}/commit/${sha}` : '')}${commitTitle ? `\n${commitTitle}` : ''}` },
{ name: 'Duration', value: duration(jobs), inline: true },
{ name: 'Jobs', value: summary, inline: true },
{ name: 'Run', value: link(`Open run #${positiveId(run.run_number) ? run.run_number : '?'} · attempt ${positiveId(run.run_attempt) ? run.run_attempt : '?'}`, attemptUrl) },
];
// Fixed field budgets keep this single embed below Discord's 6,000-character
// aggregate limit, including worst-case escaped text and two problem fields.
return {
allowed_mentions: { parse: [] },
embeds: [{
title: `${icon} ${outcome} · ${text(run.name || '(unnamed workflow)', 200)}`,
description: text(displayTitle, 512),
...(attemptUrl ? { url: attemptUrl } : {}),
color: run.conclusion === 'success' ? 0x2da44e : failed.has(run.conclusion) ? 0xcf222e : run.conclusion === 'cancelled' ? 0xbf8700 : 0x6e7781,
fields,
footer: { text: `GitHub Actions · ${text(events[run.event] || run.event || 'Unknown event', 80, false)}` },
}],
};
}

module.exports = async function notify({
Expand All @@ -31,7 +169,7 @@ module.exports = async function notify({
} catch {
throw new Error('DISCORD_WEBHOOK_URL is not a valid Discord incoming Webhook.');
}
if (!/^\d+$/.test(runId) || !Number.isSafeInteger(Number(runId)) || Number(runId) < 1) {
if (!positiveId(runId)) {
throw new Error('A valid workflow run ID is required.');
}

Expand All @@ -48,36 +186,7 @@ module.exports = async function notify({
throw new Error('Could not read a completed source workflow and its jobs.');
}

const counts = { passed: 0, failed: 0, cancelled: 0, skipped: 0, other: 0 };
for (const job of jobs) {
if (job.conclusion === 'success') counts.passed++;
else if (failed.has(job.conclusion)) counts.failed++;
else if (job.conclusion === 'cancelled') counts.cancelled++;
else if (job.conclusion === 'skipped') counts.skipped++;
else counts.other++;
}
const summary = Object.entries(counts).map(([label, count]) => `${count} ${label}`).join(', ');
const problemJobs = jobs.filter(job => failed.has(job.conclusion) || job.conclusion === 'cancelled');
const fields = [
{ name: 'Repository', value: clip(`${repo.owner}/${repo.repo}`, 256), inline: true },
{ name: 'Branch', value: clip(run.head_branch || '(detached)', 256), inline: true },
{ name: 'Commit', value: clip(run.head_sha, 12), inline: true },
{ name: 'Jobs', value: summary },
];
if (problemJobs.length) {
fields.push({ name: 'Failed or cancelled jobs', value: clip(problemJobs.map(job => `${job.name}: ${job.conclusion}`).join('\n'), 1024) });
}
const payload = {
allowed_mentions: { parse: [] },
embeds: [{
title: clip(`${run.name}: ${labels[run.conclusion] ?? run.conclusion}`, 256),
description: clip(run.display_title || run.name, 512),
url: run.html_url,
color: run.conclusion === 'success' ? 0x2da44e : failed.has(run.conclusion) ? 0xcf222e : 0x6e7781,
fields,
footer: { text: clip(`Run #${run.run_number}, attempt ${run.run_attempt} | ${run.event}`, 256) },
}],
};
const payload = buildPayload(context, run, jobs, runId);

// Retry only explicit rate limits; ambiguous delivery errors could duplicate a message.
for (let attempt = 0; attempt < 3; attempt++) {
Expand Down
Loading
Loading