Structuring JSON Violation Output for Slack and GitHub Annotations
Slack wants at most fifty blocks, each a bounded chunk of markdown, addressed to a channel where nobody will scroll. The GitHub Checks API wants an annotations array where every entry names a file path and a line number, delivered fifty at a time, addressed to a diff. The same scan feeds both, and the two formats have almost nothing in common. This guide is part of Reporting, Dashboards & Violation Tracking, and it builds one presentation model with two formatters on top of it, so that the number in the channel and the number on the pull request are the same number.
Root Cause
The failure mode is not that either format is hard. It is that both are usually written as separate scripts, each parsing the scan output its own way, and they immediately disagree. One counts rules and the other counts nodes, so “12 violations” in Slack becomes “47 annotations” on the pull request. One sorts by rule id and the other by document order, so the critical failure is third in one and eleventh in the other. One truncates at ten and the other at fifty, so a reviewer who saw a rule in chat cannot find it on the diff. Every one of those is a trust problem rather than a formatting problem, and no amount of care inside either script fixes it, because the divergence is between them.
The second root cause is that each channel has a hard unit economy that the other does not share. A Slack message accepts a maximum of fifty blocks, a header block’s plain_text is capped at 150 characters, and a section block’s mrkdwn text is capped at 3,000 characters — so a payload with one block per failing node dies at the fortieth node of the first rule. A Checks API request accepts a maximum of fifty annotations, and each annotation requires path, start_line, end_line, annotation_level and message; a DOM scanner produces none of the first three, because a CSS selector is not a source location. More than fifty annotations means additional requests against the same check run, not a bigger array.
The third pressure is volume. A single color-contrast regression on a shared button component produces forty nodes across six routes, and forty of anything is the wrong thing to send to either channel. Chat needs the fact that one rule broke in forty places plus two examples; a diff needs a handful of annotations on the files somebody can actually edit. Both need a link back to the run, where the full artifact lives, instead of the report itself.
Configuration
The model is the contract. It reads the normalised findings produced in the parent guide, groups them by rule, sorts by impact then node count, keeps a couple of example selectors per rule, caps the number of rules and records how many were dropped. Nothing downstream is allowed to count anything.
// a11y/notify/model.mjs — the only place counts, ordering and caps are decided.
import { readFileSync } from 'node:fs';
const RANK = { critical: 4, serious: 3, moderate: 2, minor: 1 };
export function buildModel(ndjsonPath, { maxRules = 8, samplesPerRule = 2 } = {}) {
const rows = readFileSync(ndjsonPath, 'utf8').split('\n').filter(Boolean)
.map(JSON.parse).filter((r) => r.status === 'violation');
const groups = new Map();
for (const r of rows) {
if (!groups.has(r.ruleId)) {
groups.set(r.ruleId, { ruleId: r.ruleId, impact: r.impact, help: r.help,
nodes: 0, routes: new Set(), samples: [] });
}
const g = groups.get(r.ruleId);
g.nodes += 1;
g.routes.add(r.route);
if (g.samples.length < samplesPerRule) g.samples.push({ route: r.route, sel: r.target });
}
const ordered = [...groups.values()]
.sort((a, b) => RANK[b.impact] - RANK[a.impact] || b.nodes - a.nodes);
const run = rows[0] ?? {};
const server = process.env.GITHUB_SERVER_URL ?? 'https://github.com';
const repo = process.env.GITHUB_REPOSITORY ?? 'org/repo';
return {
totals: {
rules: ordered.length,
nodes: rows.length,
critical: rows.filter((r) => r.impact === 'critical').length,
serious: rows.filter((r) => r.impact === 'serious').length,
},
branch: run.branch ?? 'local',
commit: (run.commit ?? 'local').slice(0, 7),
// One deep link. The run page already hosts the full findings artifact.
runUrl: `${server}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID ?? '0'}`,
shown: ordered.slice(0, maxRules),
hidden: Math.max(0, ordered.length - maxRules),
};
}
The Slack formatter turns each rule group into exactly one section block. That is the collapse that keeps the payload legal: forty nodes become one line of prose and two sample selectors, and the reader who needs the other thirty-eight follows the button.
// a11y/notify/slack.mjs — usage: node a11y/notify/slack.mjs findings.ndjson > slack.json
import { buildModel } from './model.mjs';
const HEADER_MAX = 150; // header block plain_text limit
const SECTION_MAX = 3000; // section block mrkdwn text limit
const BLOCK_MAX = 50; // blocks accepted in one message payload
const clip = (s, max) => (s.length <= max ? s : s.slice(0, max - 1) + '…');
const DOT = { critical: ':red_circle:', serious: ':large_orange_circle:',
moderate: ':large_yellow_circle:', minor: ':white_circle:' };
const m = buildModel(process.argv[2], { maxRules: 8, samplesPerRule: 2 });
const blocks = [
{ type: 'header',
text: { type: 'plain_text',
text: clip(`Accessibility: ${m.totals.nodes} nodes, ${m.totals.rules} rules`,
HEADER_MAX) } },
{ type: 'context',
elements: [{ type: 'mrkdwn',
text: `\`${m.branch}\` at \`${m.commit}\` — ${m.totals.critical} critical, ` +
`${m.totals.serious} serious` }] },
];
// One section per rule: forty nodes collapse to a count plus two examples.
for (const g of m.shown) {
const lines = [
`${DOT[g.impact]} *${g.ruleId}* — ${g.impact}`,
`${g.nodes} node${g.nodes === 1 ? '' : 's'} on ${g.routes.size} route(s)`,
...g.samples.map((s) => `\`${s.route}\` \`${s.sel}\``),
];
blocks.push({ type: 'section',
text: { type: 'mrkdwn', text: clip(lines.join('\n'), SECTION_MAX) } });
}
if (m.hidden > 0) {
blocks.push({ type: 'context',
elements: [{ type: 'mrkdwn', text: `${m.hidden} further rule(s) not shown.` }] });
}
blocks.push({ type: 'actions',
elements: [{ type: 'button', url: m.runUrl,
text: { type: 'plain_text', text: 'Open the run' } }] });
if (blocks.length > BLOCK_MAX) throw new Error(`${blocks.length} blocks exceeds 50`);
// `text` is the notification and accessibility fallback, not a copy of the blocks.
process.stdout.write(JSON.stringify({
text: `Accessibility: ${m.totals.nodes} failing nodes on ${m.branch}`,
blocks,
}, null, 2));
The GitHub side has to invent the one thing it is required to supply. path must name a file that exists in the head commit, and start_line must be an integer, but a scanner only knows a route and a selector. Fabricating a plausible line number is worse than useless — a reviewer chases a line that has nothing to do with the defect — so map the route to the file that owns it and anchor at line 1, putting the selector in the message where it belongs. A route-owner manifest is cheap and the application usually already has one.
{
"/": "app/routes/home.tsx",
"/pricing": "app/routes/pricing.tsx",
"/reports/quarterly": "app/routes/reports.quarterly.tsx",
"/settings/notifications": "app/routes/settings.notifications.tsx"
}
// a11y/notify/github.mjs — usage: node a11y/notify/github.mjs findings.ndjson
// Writes one complete Checks API request body per 50-annotation batch.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { buildModel } from './model.mjs';
const BATCH = 50; // annotations accepted per create or update request
const TITLE_MAX = 255; // annotation title limit
const LEVEL = { critical: 'failure', serious: 'failure',
moderate: 'warning', minor: 'notice' };
const owners = JSON.parse(readFileSync('a11y/notify/route-owners.json', 'utf8'));
// A wide cap here: the batching, not the model, decides how many are sent.
const m = buildModel(process.argv[2], { maxRules: 200, samplesPerRule: 3 });
const annotations = [];
for (const g of m.shown) {
for (const s of g.samples) {
const path = owners[s.route];
if (!path) continue; // no source owner: the step summary carries it
annotations.push({
path, // must exist in the head commit or it is dropped
start_line: 1, // required; a DOM defect has no source line
end_line: 1,
annotation_level: LEVEL[g.impact],
title: `${g.ruleId} (${g.impact}) on ${s.route}`.slice(0, TITLE_MAX),
message: `${g.help}\nSelector: ${s.sel}\n${g.nodes} node(s) of this rule.`,
raw_details: `route ${s.route}\nrule ${g.ruleId}\nrun ${m.runUrl}`,
});
}
}
const batches = [];
for (let i = 0; i < annotations.length; i += BATCH) {
batches.push(annotations.slice(i, i + BATCH));
}
if (batches.length === 0) batches.push([]); // still publish a green check run
mkdirSync('checks', { recursive: true });
const blocking = m.totals.critical + m.totals.serious;
batches.forEach((batch, i) => {
const body = { output: {
title: `Accessibility: ${m.totals.nodes} failing nodes`,
summary: `${m.totals.rules} rule(s) failed. [Open the run](${m.runUrl})`,
annotations: batch,
} };
if (i === 0) { // the first request creates the run
body.name = 'accessibility';
body.head_sha = process.env.GITHUB_SHA ?? 'HEAD';
}
// Only the last request completes it; earlier ones leave it in progress.
if (i === batches.length - 1) body.conclusion = blocking > 0 ? 'failure' : 'neutral';
else body.status = 'in_progress';
writeFileSync(`checks/batch-${i}.json`, JSON.stringify(body, null, 2));
});
console.log(`${annotations.length} annotation(s) in ${batches.length} batch(es)`);
Annotations are never the whole story, because any route without a source owner is silently skipped and any run without checks: write cannot publish at all. The markdown fallback is the same model rendered as a table into $GITHUB_STEP_SUMMARY, which needs no permissions, survives on the run page, and holds up to 1 MiB per step.
// a11y/notify/summary.mjs
// usage: node a11y/notify/summary.mjs findings.ndjson >> "$GITHUB_STEP_SUMMARY"
import { buildModel } from './model.mjs';
const m = buildModel(process.argv[2], { maxRules: 25, samplesPerRule: 1 });
// A bare pipe splits a table cell even inside backticks.
const cell = (s) => '`' + String(s).replaceAll('|', '\\|') + '`';
const out = [
`### Accessibility: ${m.totals.nodes} failing nodes across ${m.totals.rules} rules`,
'',
`Branch \`${m.branch}\` at \`${m.commit}\` — [open the run](${m.runUrl}).`,
'',
'| Impact | Rule | Nodes | Routes | Example |',
'|---|---|---|---|---|',
...m.shown.map((g) => `| ${g.impact} | ${cell(g.ruleId)} | ${g.nodes} | ` +
`${g.routes.size} | ${cell(g.samples[0]?.sel ?? 'none')} |`),
];
if (m.hidden > 0) out.push('', `${m.hidden} further rule(s) in the artifact.`);
process.stdout.write(out.join('\n') + '\n');
The workflow wires all three from one findings file. Note that the Slack post swallows its own failure: a webhook outage must never turn a green build red.
- name: Build every channel payload from one model
if: always()
run: |
node a11y/notify/slack.mjs findings.ndjson > slack.json
node a11y/notify/github.mjs findings.ndjson
node a11y/notify/summary.mjs findings.ndjson >> "$GITHUB_STEP_SUMMARY"
- name: Post to Slack
if: always()
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
curl -sS --fail-with-body -X POST -H 'Content-Type: application/json' \
--data @slack.json "$SLACK_WEBHOOK_URL" \
|| echo 'Slack delivery failed; continuing' # reporting never blocks
- name: Publish the check run
if: always()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # needs permissions: checks: write
run: |
id=$(gh api --method POST "/repos/$GITHUB_REPOSITORY/check-runs" \
--input checks/batch-0.json --jq '.id')
for batch in checks/batch-[1-9]*.json; do
[ -f "$batch" ] || continue # the glob stays literal when unmatched
gh api --method PATCH "/repos/$GITHUB_REPOSITORY/check-runs/$id" \
--input "$batch" > /dev/null # each PATCH appends up to 50 more
done
Validation
Both payloads are checkable offline, and both checks belong in the repository’s own test run rather than in the workflow that sends them. The Slack assertion is a block count and a per-section length; the GitHub assertion is a batch size plus the requirement that every annotated path exists, because an annotation pointing at a file absent from the head commit is discarded without an error.
node a11y/notify/slack.mjs findings.ndjson > slack.json
node a11y/notify/github.mjs findings.ndjson
# 1. Slack: legal block count and no oversized section text.
node -e "
const p = require('./slack.json');
if (p.blocks.length > 50) throw new Error('blocks: ' + p.blocks.length);
if (!p.text) throw new Error('missing notification fallback text');
for (const b of p.blocks) {
const t = (b.text && b.text.text) || '';
if (t.length > 3000) throw new Error('over 3000 chars in a ' + b.type);
}
console.log('slack ok:', p.blocks.length, 'blocks');
"
# 2. Checks: batch sizes, required fields, and paths that exist in the tree.
node -e "
const { existsSync, readdirSync } = require('node:fs');
let total = 0;
for (const f of readdirSync('checks')) {
const a = require('./checks/' + f).output.annotations;
if (a.length > 50) throw new Error(f + ' has ' + a.length + ' annotations');
for (const x of a) {
if (!x.path || !x.start_line) throw new Error('missing path or start_line');
if (!existsSync(x.path)) throw new Error('path not in tree: ' + x.path);
}
total += a.length;
}
console.log('checks ok:', total, 'annotations');
"
# 3. The two channels must agree on the headline number.
diff <(node -e "console.log(require('./slack.json').text.match(/\d+/)[0])") \
<(node -e "console.log(require('./checks/batch-0.json').output.title.match(/\d+/)[0])")
A healthy run prints three lines and the diff produces no output, which is the whole point of the exercise:
slack ok: 12 blocks
checks ok: 18 annotations
Edge Cases and Conditional Guards
- Zero violations. The model yields an empty
shownarray, Slack gets a header and a button, andgithub.mjsstill writesbatch-0.jsonso the check run turns green rather than staying pending forever. Guard the Slack post itself behind a condition if the channel does not want a green heartbeat; leave the check run unconditional, because a missing check run blocks a required status check. - Selectors containing backticks or pipes. A selector from a shadow tree or a generated class can carry either. Slack
mrkdwnbreaks on an unmatched backtick and a markdown table breaks on a bare pipe, which is why the summary formatter escapes pipes and the Slack formatter clips rather than trusting length. Strip backticks from the selector in the model if the design system generates them. - Forked pull requests.
secrets.GITHUB_TOKENis read-only on a fork, so the Checks API call fails and the Slack webhook secret is not exposed at all. Fall back to the step summary — which always works — and detect the case withgithub.event.pull_request.head.repo.forkrather than discovering it from a 403 in the logs.
Pipeline Impact
None of these three steps sets the job status. They run with if: always() so a failed gate is still reported, and the blocking decision stays where it was made, in the assertion or the dedicated gate step described in auto-fail vs warning workflows. The one subtlety is the check run’s own conclusion: publishing failure creates a second signal on the pull request that branch protection can be configured to require, which is useful but easy to double-count. Pick one — either the scan job is the required check or the accessibility check run is — and set the other to neutral. Upload slack.json and the checks/ directory as artifacts; when somebody asks why a rule did not appear in chat, the payload that was actually sent answers it in seconds. If the team prefers a threaded pull-request comment to a check run, the comment-based variant is covered in annotating pull requests with axe-core violation comments.
Common Pitfalls
- Writing two independent scripts, so the count in chat and the count on the diff diverge and both lose credibility.
- Emitting one Slack block per failing node, which exceeds the fifty-block limit on the first widespread rule and is unreadable well before that.
- Posting all 400 annotations in one request and assuming the extras are queued; anything past fifty in a single call is rejected, not deferred.
- Fabricating a
start_linethat looks like a real source location, sending reviewers to a line that has nothing to do with the defect. - Annotating a path that does not exist in the head commit, which GitHub drops silently so the check run looks empty and correct.
- Pasting the whole report into the message instead of one deep link to the run, guaranteeing the channel mutes the integration within a week.
FAQ
Why one block per rule rather than one per route?
Because a rule is the unit of work and a route is not. A single design-token fix clears color-contrast on every route at once, so grouping by rule shows one actionable item where grouping by route shows six copies of it. Route counts still matter, which is why each block carries the number of affected routes as a measure of blast radius rather than as a separate block.
Can the same model drive Microsoft Teams or a GitLab pipeline?
Yes, and that is the reason the model exists. Teams uses Adaptive Cards rather than Block Kit, so it needs a third formatter with its own limits, but it consumes the identical shown, totals and runUrl fields and therefore reports identical numbers. The same is true of a GitLab code-quality report or a Jenkins summary: new formatter, no new arithmetic, and the criterion-level export described in exporting accessibility results to compliance dashboards reads the same source rows.
How many rules should actually be shown in chat? Eight is a good default because it fits comfortably inside the block limit with room for a header, a context line and a button, and because a message longer than one screen is a message nobody reads. What matters more than the number is that the cap is visible: always print how many rules were withheld, or a reader will assume the list is complete and treat a truncated message as the full picture.
Related
- Reporting, Dashboards & Violation Tracking — the normalised records both formatters read.
- Exporting Accessibility Results to Compliance Dashboards — the same rows shaped for a criterion-level audience.
- CI/CD Integration & Automated Quality Gating — where the check run becomes a branch-protection decision.