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
59 changes: 41 additions & 18 deletions docs/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,25 @@ download its artifacts. A matrix produces one summary per workflow attempt.

## 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.
Each attempt produces one compact Discord Embed:

- The author row identifies the repository and shows its owner's avatar.
- One linked title names the workflow and its result: ✅ passed, ❌ failed, or
⏹ cancelled. Timeouts and startup failures keep their specific result labels.
- The description shows the change title, nonzero job counts, and **View run**.
- Failed jobs and their bold failed steps appear before the context row.
Cancelled jobs follow failures. Up to two failed steps are shown per job.
- Three inline fields show the PR or branch, short linked SHA, and **Duration**.
A different commit subject appears under the SHA; a repeated subject is omitted.
- The footer contains the friendly event name, run number, and retry attempt
when greater than one. For example: `Merge queue · Run #129 · Attempt 2`.

If multiple PRs are attached, the first is linked and the remaining count is
shown. A missing branch is marked detached. Missing job or commit metadata is
not invented. Unavailable failed steps are labelled `Step details unavailable`.
Large problem lists occupy at most two fields, with an explicit count of omitted
jobs; the run link leads to all results. Cancelled cards use the same compact
layout and do not guess why the run was cancelled.

**Duration** runs from the earliest executed job start to the latest job
completion in the notified attempt. It includes gaps between those jobs, excludes
Expand All @@ -41,7 +43,28 @@ 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.
The author name also counts toward the aggregate limit.

## Layout choices and references

The layout uses only the standard
[Embed author, title, description, inline fields, footer and color](https://docs.discord.com/developers/resources/message#embed-object).
Discord controls font sizes, spacing, column widths and mobile wrapping; inline
fields are layout hints. There are no CSS panels, custom badges or buttons in
the message payload. A local HTML preview only approximates this structure and
is not evidence of rendering or delivery in Discord.

The compact title, result color and linked context were informed by the mature
[sarisia/actions-status-discord formatter](https://github.com/sarisia/actions-status-discord/blob/main/src/main.ts)
and its [minimal examples](https://github.com/sarisia/actions-status-discord#minimum).
The [Ilshidur/action-discord implementation](https://github.com/Ilshidur/action-discord/blob/master/entrypoint.js)
was also reviewed as an example of plain incoming Webhook delivery. No code,
third-party branding or delivery behavior was copied from these references.

[Components V2](https://docs.discord.com/developers/components/overview) offers
more layout primitives but requires `IS_COMPONENTS_V2` and replaces traditional
content and embeds. The existing Embed covers this notification's needs without
changing its payload model, so this change does not adopt Components V2.

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.
Expand Down
50 changes: 36 additions & 14 deletions docs/discord_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,23 @@

## 汇总卡片

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

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

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

- 顶部 author 行显示仓库名称和仓库所有者的头像。
- 单一标题显示工作流名称和结果:✅ passed、❌ failed 或 ⏹ cancelled。
超时、启动失败等结果保留具体状态。标题链接到对应执行。
- 描述显示变更标题、非零任务数量,以及 **View run** 链接。
- 失败任务和加粗的失败步骤优先显示,其后是取消的任务,再展示常规信息。
每个任务最多显示两个失败步骤。
- 三个 inline 字段集中显示 PR 或分支、带链接的短 SHA,以及 **Duration**。
提交标题与变更标题不同时,显示在 SHA 下方;重复时省略。
- 页脚显示易懂的事件名称、运行编号,以及大于 1 的重试次数。
例如:`Merge queue · Run #129 · Attempt 2`。

关联多个 PR 时,显示第一个 PR 的链接和其余数量。分支缺失时标记为 detached;
任务数据或提交元数据缺失时,不补造内容。未提供失败步骤时,显示 `Step details unavailable`。
异常任务清单最多占用两个字段,超出时明确提示省略的任务数量;完整结果通过运行链接查看。
取消卡片沿用相同布局,不推测取消原因。

**执行耗时(Duration)** 表示本次执行中,从最早一个已执行任务开始到最后一个任务结束的时间。
它包含这些任务之间的间隔,不包含初始排队时间,也不累加并行矩阵任务的耗时。
Expand All @@ -32,7 +36,25 @@ GitHub 任务页面,并显示最多两个失败步骤名称。GitHub 未提供
截断保留完整转义和 Unicode 字符,不截断链接。字段预算遵守 Discord 的标题长度、字段长度、
字段数量和总计 6,000 字符的
[Embed 限制](https://docs.discord.com/developers/resources/message#embed-limits)。
卡片使用普通 Webhook Embed 和 Markdown 链接,不依赖按钮。
author 名称也计入总字符数。

## 布局选择与参考

卡片仅使用标准
[Embed author、标题、描述、inline 字段、页脚和颜色](https://docs.discord.com/developers/resources/message#embed-object)。
字号、间距、列宽和移动端换行由 Discord 控制;inline 只是排版提示。
消息载荷不包含 CSS 面板、自定义徽章或按钮。本地 HTML 预览只模拟结构,
不能作为 Discord 实际渲染或投递验收的证据。

紧凑标题、结果颜色和上下文链接参考了成熟开源项目
[sarisia/actions-status-discord 的格式化源码](https://github.com/sarisia/actions-status-discord/blob/main/src/main.ts)
及其[最简示例](https://github.com/sarisia/actions-status-discord#minimum)。
同时查阅了 [Ilshidur/action-discord 的实现](https://github.com/Ilshidur/action-discord/blob/master/entrypoint.js),
用于核对普通 incoming Webhook 的投递形式。没有复制参考项目的代码、第三方品牌素材或投递行为。

[Components V2](https://docs.discord.com/developers/components/overview) 提供更多布局组件,
但需要启用 `IS_COMPONENTS_V2`,并替代传统 content 和 embeds。
现有 Embed 已能满足通知需求,无需改变载荷模型,本次不引入 Components V2。

只有收到 Discord 消息回执,通知任务才算通过。遇到明确的限流响应最多重试两次,
其他发送错误会使通知任务失败。卡片格式调整不改变共享工作流、Secret、权限、
Expand Down
42 changes: 26 additions & 16 deletions scripts/discord.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ const labels = {
neutral: 'Neutral', skipped: 'Skipped', stale: 'Stale',
startup_failure: 'Startup failure',
};
const outcomes = {
success: 'passed', failure: 'failed', cancelled: 'cancelled', timed_out: 'timed out',
action_required: 'needs attention', startup_failure: 'could not start',
neutral: 'finished', skipped: 'skipped', stale: 'expired',
};
const events = {
pull_request: 'Pull request', push: 'Push', merge_group: 'Merge queue',
workflow_dispatch: 'Manual run', schedule: 'Scheduled run', workflow_run: 'Workflow completion',
Expand Down Expand Up @@ -86,16 +91,16 @@ function problemFields(jobs, runUrl) {
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 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' : '';
: failed.has(job.conclusion) ? '\n ↳ Step details 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') });
fields.push({ name: fields.length ? 'More jobs' : jobs.some(job => failed.has(job.conclusion)) ? 'Needs attention' : '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.`;
Expand All @@ -107,7 +112,7 @@ 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 outcome = outcomes[run.conclusion] || 'completed';
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) {
Expand All @@ -117,36 +122,41 @@ function buildPayload(context, run, jobs, runId) {
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 summary = ['failed', 'cancelled', 'passed', 'skipped', 'other'].filter(label => counts[label]).map(label => {
const value = `${counts[label]} ${label}`;
return label === 'failed' ? `**${value}**` : value;
}).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 ref = text(run.head_branch || '(detached)', 100);
const pullSummary = pull ? `${link(`PR #${pull.number}`, repoUrl ? `${repoUrl}/pull/${pull.number}` : '')}${pulls.length > 1 ? ` (+${pulls.length - 1} more)` : ''}\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: pull ? 'Pull request' : 'Branch', value: pullSummary + ref, inline: true },
{ name: 'Commit', value: `${link(sha ? sha.slice(0, 12) : 'Unavailable', sha && repoUrl ? `${repoUrl}/commit/${sha}` : '')}${commitTitle ? `\n${commitTitle}` : ''}`, inline: true },
{ 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) },
];
const runLink = link('View run', attemptUrl);
const ownerUrl = repoUrl ? repoUrl.slice(0, repoUrl.lastIndexOf('/')) : '';
// 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),
author: {
name: text(`${context.repo.owner} / ${context.repo.repo}`, 128, false),
...(repoUrl ? { url: repoUrl, icon_url: `${ownerUrl}.png?size=64` } : {}),
},
title: `${icon} ${text(run.name || '(unnamed workflow)', 180)} ${outcome}`,
description: `${text(displayTitle, 240)}\n${summary} · ${runLink}`,
...(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)}` },
footer: { text: `${text(events[run.event] || run.event || 'Unknown event', 60, false)} · Run #${positiveId(run.run_number) ? run.run_number : '?'}${positiveId(run.run_attempt) && Number(run.run_attempt) > 1 ? ` · Attempt ${run.run_attempt}` : ''}` },
}],
};
}
Expand Down
Loading
Loading