Merging Sharded Accessibility Reports into One Artifact

Four parallel jobs produce four JSON files, and a branch-protection rule can read exactly one answer. This guide is part of Monorepo & Parallel Test Sharding, and it covers the aggregation step that turns N shard reports into a single document: how to count a problem that appears on many routes once, how to reconcile results that were inconclusive in one shard and conclusive in another, and how to notice that a shard is missing before the missing data becomes a green check.

Root Cause

Concatenating the shard reports is the obvious implementation and it produces an unusable report. A design-system header with a 3.9:1 contrast ratio on its utility links appears in every route that renders the header, so a 240-route run reports it 240 times. The pull-request comment leads with “312 violations”, the author opens it, sees the same color-contrast entry forty screens deep, and closes the tab. There are in fact eleven distinct problems in that run, one of which needs a single CSS change to fix 240 occurrences, and nothing in the concatenated output says so.

Raw counts also make any threshold meaningless. If the gate blocks on “no more than five violations”, the number of violations is partly a function of how many routes happened to be in scope — a pull request that selects two packages passes and the identical change on a branch that selects six fails, because the same shared component was rendered by more routes. Budgets have to be expressed in distinct problems, and distinct problems only exist after deduplication.

The third failure is quieter and worse. A merge implementation that iterates over the shard reports it can find will happily merge three files, report zero blocking findings, and exit 0 — while shard 2, whose 60 routes contained the only serious violation in the run, was cancelled by a runner eviction and uploaded nothing. Absence of a report is not absence of violations, but the naive merge cannot tell the difference. The same applies to partial reports: uploading with if: always() means a shard that crashed at route 40 of 62 still contributes a file, and that file’s contents look exactly like a healthy shard’s contents unless it records how far it got.

Forty raw rows, one deduplicated finding Rows for the pricing, about, blog, careers and support routes all report the color-contrast rule against the header utility link. The fingerprint of rule id, failed check and normalised target is identical, so the merged output is one finding whose occurrences list names all forty routes. Forty raw rows, one deduplicated finding /pricing · color-contrast /about · color-contrast /blog/2026-06 · color-contrast /careers · color-contrast /support · color-contrast …35 more routes, 4 shards fingerprint() rule + check + normalised target color-contrast impact: serious occurrences: 40 fix: one CSS token one problem on forty pages reads as one item with forty occurrences the route list is kept, so the fix can still be verified page by page
Deduplication removes rows, never information: every route that exhibited the problem stays in the finding's occurrences list.

Configuration

The merge script does four things in order: collect the reports, fingerprint every failing node, reconcile statuses, and check what it received against what the plan promised. It writes the merged document and the step summary, and it deliberately does not decide the build result — that belongs to the verdict job, which owns the exit code.

// a11y/scripts/merge-reports.mjs
// node a11y/scripts/merge-reports.mjs --plan shard-plan.json --input a11y/in \
//   --out a11y/merged.json --summary "$GITHUB_STEP_SUMMARY"
import { createHash } from 'node:crypto';
import { appendFileSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';

const args = new Map();
for (let i = 2; i < process.argv.length; i += 2) {
  args.set(process.argv[i].replace(/^--/, ''), process.argv[i + 1]);
}
const plan = JSON.parse(readFileSync(args.get('plan'), 'utf8'));

// download-artifact@v4 with a pattern creates one directory per artifact, so
// recurse rather than assuming every report sits at the top level.
function findReports(dir) {
  return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
    const path = join(dir, entry.name);
    if (entry.isDirectory()) return findReports(path);
    return entry.name.endsWith('.json') ? [path] : [];
  });
}
const reports = findReports(args.get('input'))
  .map((path) => JSON.parse(readFileSync(path, 'utf8')));

// Targets carry render-order indexes and framework-generated ids that differ
// between routes for what is one component, so normalise before hashing.
function normalizeTarget(target) {
  return (Array.isArray(target) ? target : [target])
    .map((selector) => String(selector)
      .replace(/:nth-child\(\d+\)/g, ':nth-child(n)')
      .replace(/:nth-of-type\(\d+\)/g, ':nth-of-type(n)')
      .replace(/#[^\s>,]*\d[^\s>,]*/g, '#generated')
      .trim())
    .join(' > ');
}

function fingerprint(finding, node) {
  // The first line of failureSummary names the check that failed; the rest
  // contains measured values that differ per route and must stay out.
  const check = (node.failureSummary ?? '').split('\n')[0];
  return createHash('sha1')
    .update([finding.id, check, normalizeTarget(node.target)].join('|'))
    .digest('hex')
    .slice(0, 12);
}

const RANK = { pass: 0, incomplete: 1, violation: 2 };
const merged = new Map();

for (const report of reports) {
  for (const finding of report.findings ?? []) {
    const status = finding.incomplete ? 'incomplete' : 'violation';
    for (const node of finding.nodes ?? []) {
      const key = fingerprint(finding, node);
      const entry = merged.get(key) ?? {
        fingerprint: key,
        ruleId: finding.id,
        impact: finding.impact ?? 'moderate',
        help: finding.help ?? '',
        wcagTags: (finding.tags ?? []).filter((tag) => tag.startsWith('wcag')),
        status,
        occurrences: [],
      };
      // A violation on one route outranks an incomplete on another: one shard
      // proved the failure, so the merged status is a violation.
      if (RANK[status] > RANK[entry.status]) entry.status = status;
      const where = { route: finding.route, shard: report.shardIndex };
      if (!entry.occurrences.some((o) => o.route === where.route)) {
        entry.occurrences.push({ ...where, html: (node.html ?? '').slice(0, 240) });
      }
      merged.set(key, entry);
    }
  }
}

// Completeness is judged against the plan, never against the files on disk.
const seen = new Set(reports.map((report) => report.shardIndex));
const scanned = new Set(reports.flatMap((report) => report.routesScanned ?? []));
const planned = new Set(plan.shards.flatMap((shard) => shard.routes));
const problems = [];

for (let index = 0; index < plan.shardTotal; index += 1) {
  if (!seen.has(index)) problems.push(`shard ${index} uploaded no report`);
}
for (const report of reports) {
  if (report.partitionHash !== plan.partitionHash) {
    problems.push(`shard ${report.shardIndex} ran partition ${report.partitionHash}`);
  }
  if (report.complete !== true) {
    problems.push(`shard ${report.shardIndex} stopped after ` +
      `${(report.routesScanned ?? []).length} of ${report.routesPlanned.length} routes`);
  }
}
const unscanned = [...planned].filter((route) => !scanned.has(route));
if (unscanned.length > 0) {
  problems.push(`${unscanned.length} planned route(s) never scanned, ` +
    `first: ${unscanned.slice(0, 3).join(', ')}`);
}

const IMPACT = ['critical', 'serious', 'moderate', 'minor'];
const ordered = [...merged.values()].sort((a, b) =>
  IMPACT.indexOf(a.impact) - IMPACT.indexOf(b.impact) ||
  b.occurrences.length - a.occurrences.length);

const document = {
  schemaVersion: 2,
  run: {
    partitionHash: plan.partitionHash,
    shardsExpected: plan.shardTotal,
    shardsSeen: seen.size,
    routesPlanned: planned.size,
    routesScanned: scanned.size,
    complete: problems.length === 0,
    problems,
  },
  findings: ordered.filter((entry) => entry.status === 'violation'),
  incomplete: ordered.filter((entry) => entry.status === 'incomplete'),
};
writeFileSync(args.get('out'), `${JSON.stringify(document, null, 2)}\n`);

const lines = [
  `### Accessibility: ${document.findings.length} distinct finding(s) ` +
  `across ${document.run.routesScanned} route(s)`,
  '',
  `Shards ${document.run.shardsSeen}/${document.run.shardsExpected} · ` +
  `partition \`${plan.partitionHash}\``,
  '',
  '| Impact | Rule | Routes | Example route |',
  '|---|---|---|---|',
];
for (const entry of document.findings.slice(0, 15)) { // 1 MB summary cap
  lines.push(`| ${entry.impact} | \`${entry.ruleId}\` | ` +
    `${entry.occurrences.length} | ${entry.occurrences[0].route} |`);
}
if (document.incomplete.length > 0) {
  lines.push('', `${document.incomplete.length} inconclusive result(s) need review.`);
}
for (const problem of problems) lines.push('', `**Incomplete run:** ${problem}`);
appendFileSync(args.get('summary'), `${lines.join('\n')}\n`);

console.log(`merged ${reports.length} report(s) into ${args.get('out')}`);
// Always exit 0: the verdict job owns the build result, so a merge failure and
// a violation stay distinguishable in the checks list.

The RANK table is the reconciliation rule in one line. axe returns incomplete when a node cannot be judged from the DOM it was given — a collapsed disclosure, an element inside a closed shadow root, a colour whose background could not be resolved. The same component can be inconclusive on a route where it renders collapsed and a clear violation on a route where it renders open, and the honest merged answer is the violation, because one shard obtained the evidence. The reverse never applies: a pass on thirty routes does not soften a violation on the thirty-first, so passes are not merged at all.

Reconciling per-shard statuses into one Four fingerprint rows across shards zero to three. Color-contrast is a violation in two shards and a pass in two, merging to a violation. A dialog-name row is incomplete in three shards and absent in one, merging to inconclusive. A label row passes everywhere. A heading-order row is incomplete in one shard and a violation in another, merging to a violation. Reconciling per-shard statuses into one fingerprint shard 0 shard 1 shard 2 shard 3 merged color-contrast · header violation pass violation pass violation · 2 routes dialog-name · modal incomplete incomplete incomplete absent needs review label · search input pass pass pass pass not reported heading-order · tabs incomplete violation absent absent violation · 1 route violation beats incomplete beats pass — evidence outranks the absence of it an all-incomplete row is reported for review and never blocks the merge queue
Reconciliation is per fingerprint, not per shard, which is why a component that only misbehaves on one route still reaches the verdict as a violation.

Validation

Test the merge with fixtures rather than with a real pipeline run, because the interesting cases — a missing shard, a stale partition, a truncated report — are all hard to produce on demand and trivial to write as JSON.

// a11y/scripts/merge-reports.test.mjs — run with: node --test a11y/scripts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

function shardReport(index, routes, extra = {}) {
  return {
    shardIndex: index,
    shardTotal: 2,
    partitionHash: 'aaaa1111bbbb2222',
    routesPlanned: routes,
    routesScanned: routes,
    complete: true,
    findings: routes.map((route) => ({
      route,
      id: 'color-contrast',
      impact: 'serious',
      help: 'Elements must meet contrast ratio thresholds',
      tags: ['wcag2aa', 'wcag143'],
      nodes: [{
        target: ['header > nav > a:nth-child(3)'],
        failureSummary: 'Fix any of the following:\n  contrast is 3.91:1',
        html: '<a class="util-link" href="/help">Help</a>',
      }],
    })),
    ...extra,
  };
}

function runMerge(reports) {
  const dir = mkdtempSync(join(tmpdir(), 'a11y-merge-'));
  mkdirSync(join(dir, 'in'), { recursive: true });
  writeFileSync(join(dir, 'plan.json'), JSON.stringify({
    shardTotal: 2,
    partitionHash: 'aaaa1111bbbb2222',
    shards: [
      { index: 0, routes: ['/pricing', '/about'] },
      { index: 1, routes: ['/careers'] },
    ],
  }));
  for (const report of reports) {
    // One directory per artifact, matching download-artifact@v4 with a pattern.
    mkdirSync(join(dir, 'in', `a11y-shard-${report.shardIndex}`));
    writeFileSync(
      join(dir, 'in', `a11y-shard-${report.shardIndex}`, 'report.json'),
      JSON.stringify(report),
    );
  }
  execFileSync('node', [
    'a11y/scripts/merge-reports.mjs',
    '--plan', join(dir, 'plan.json'),
    '--input', join(dir, 'in'),
    '--out', join(dir, 'merged.json'),
    '--summary', join(dir, 'summary.md'),
  ]);
  return JSON.parse(readFileSync(join(dir, 'merged.json'), 'utf8'));
}

test('the same problem on three routes is one finding', () => {
  const merged = runMerge([
    shardReport(0, ['/pricing', '/about']),
    shardReport(1, ['/careers']),
  ]);
  assert.equal(merged.findings.length, 1);
  assert.equal(merged.findings[0].occurrences.length, 3);
  assert.equal(merged.run.complete, true);
});

test('a missing shard is an incomplete run, not a pass', () => {
  const merged = runMerge([shardReport(0, ['/pricing', '/about'])]);
  assert.equal(merged.findings.length, 1);
  assert.equal(merged.run.complete, false);
  assert.match(merged.run.problems.join(' '), /shard 1 uploaded no report/);
});

test('a truncated shard report is detected', () => {
  const truncated = shardReport(1, ['/careers']);
  truncated.routesScanned = [];
  truncated.complete = false;
  const merged = runMerge([shardReport(0, ['/pricing', '/about']), truncated]);
  assert.equal(merged.run.complete, false);
  assert.match(merged.run.problems.join(' '), /stopped after 0 of 1 routes/);
});

Running those three tests takes under a second and covers the failure modes that matter:

$ node --test a11y/scripts
▶ merge-reports.test.mjs
  ✔ the same problem on three routes is one finding (238ms)
  ✔ a missing shard is an incomplete run, not a pass (191ms)
  ✔ a truncated shard report is detected (188ms)
# pass 3
# fail 0

The step summary produced by a healthy four-shard run reads as one table, and the gate that consumes it reads only merged.json:

### Accessibility: 3 distinct finding(s) across 240 route(s)

Shards 4/4 · partition `9f2c41ab77e0d3b5`

| Impact | Rule | Routes | Example route |
|---|---|---|---|
| serious | `color-contrast` | 40 | /pricing |
| serious | `aria-dialog-name` | 6 | /checkout/payment |
| moderate | `heading-order` | 2 | /admin/reports |

4 inconclusive result(s) need review.

Edge Cases and Conditional Guards

  • Over-normalised targets. Stripping every numeric id and :nth-child index makes two genuinely different elements share a fingerprint when the only thing distinguishing them was the index — two adjacent icon buttons in a toolbar, for example. Keep the first html snippet per occurrence and spot-check that a finding’s occurrences describe the same element; if they do not, add the accessible role or a stable data-component attribute to the fingerprint input rather than loosening the normalisation.
  • A route present in two shards. A manifest assembled from per-package route files can list /settings twice, so the same finding arrives from two shards for the same route. The occurrence push above guards on route for exactly this reason, which keeps the occurrence count equal to the number of affected pages rather than the number of times the page was scanned.
  • Reports from a previous run. A re-run after a rebase can leave old artifacts in the same workflow run when artifact names are not scoped. Comparing each report’s partitionHash against the plan’s turns that into a named problem instead of a silently merged mixture of two commits’ results, which is why the shard reports echo the hash at all.

Pipeline Impact

The merged document is the only file anything downstream reads, and its run.complete flag is what separates two very different failures. A complete run with blocking findings is exit code 1: a content or code problem the pull-request author fixes. An incomplete run is exit code 2: a missing shard, a stale partition, a truncated report — an infrastructure problem that the author cannot fix and should not be asked to. Keeping those codes distinct is what stops “re-run the job” from becoming the standard response to every red check, and the general policy for that split is in choosing exit codes for warning and blocking a11y jobs.

Four checks that stop a missing shard from passing The plan expects four shards with a known partition hash while only three reports were downloaded. The shard count check, the index check and the route coverage check all fail, the hash check passes, and the run is reported as incomplete with exit code two. Four checks that stop a missing shard from passing shard-plan.json expects 4 · 9f2c41ab downloaded 3 shard reports 1 shard count: 3 of 4 FAIL 2 indexes seen: 0, 1, 3 FAIL 3 partitionHash matches OK 4 routes: 181 of 240 scanned FAIL exit 2 incomplete run gate blocks counting the files on disk would have reported a clean pass on 181 routes the plan, not the download directory, is the authority on how many shards exist
Three of the four checks read the plan rather than the artifacts, which is the only way a shard that produced nothing can be distinguished from a shard that found nothing.

Size discipline matters more than it looks. Each shard’s raw report on a 240-route run is around 3 MB because axe includes html for every node; the merged document with occurrences capped to one entry per route and html truncated to 240 characters comes out at roughly 180 KB, which is small enough to attach to a pull-request comment and to post to a dashboard on every run. The step summary is capped at 1 MB per step, so the table is truncated to fifteen rows with the full list left in the artifact — the formatting conventions for the comment and the annotations are covered in structuring JSON violation output for Slack and GitHub annotations.

Because occurrences are preserved, the merged file is also the right input for trend tracking: distinct-finding count is the metric that moves when someone fixes something, while occurrence count moves when routes are added or removed. Plot both, keep the fingerprint as the join key between runs, and a finding that persists across twenty runs becomes visible as one long-lived item rather than as noise — which is the shape the trend views in reporting dashboards and violation tracking expect. Retain merged reports for thirty days and shard reports for seven; the shard files are only ever needed to debug a merge.

Common Pitfalls

  • Iterating over the shard reports that exist instead of the shards the plan promised, which turns a cancelled job into a clean pass.
  • Fingerprinting on the whole failureSummary, whose measured values differ per route, so the same problem produces one finding per page anyway.
  • Fingerprinting on the raw target selector, which splits one component into as many findings as it has sibling positions.
  • Dropping the occurrence list after deduplication, leaving a report that says a problem exists but not where to verify the fix.
  • Letting a pass in one shard cancel a violation in another by merging passes alongside violations, which quietly hides route-specific failures.
  • Exiting non-zero from the merge script as well as the verdict job, so a genuine violation and a broken pipeline produce the same red check with different messages.

FAQ

Why fingerprint at all instead of grouping by rule id? Grouping by rule id alone merges unrelated problems: a run with a low-contrast header link and a low-contrast disabled button in a form both report color-contrast, and collapsing them into one item means fixing the header appears to fix nothing. The fingerprint adds the failed check and the normalised element path, which separates those two while still collapsing the header link across every route that renders it. Rule id is the right grouping for a dashboard summary, not for the list a developer works through.

What should happen when a shard reports a finding the plan says it should not have scanned? Treat it as a problem, not as a finding to merge. It means the shard read a different partition than the merge was given — usually a stale plan artifact after a rebase, occasionally a hardcoded route list left behind in a spec. The partitionHash comparison catches it, and the route-level check catches the narrower case where the hash matches but a spec scanned something extra. Merging it anyway produces a verdict about a set of routes nobody chose.

Can the merge run inside the last shard instead of in its own job? No, and the reason is ordering rather than capability. With fail-fast: false there is no defined “last” shard, and any shard that also merges will do so before its siblings finish or will not run at all if it is the one that failed. A separate job with needs on the whole matrix and if: always() is the only structure that runs exactly once, after everything, including after failures — the matrix settings that make this work are in sharding axe-core scans across parallel CI jobs.