Skip to content

Commit dd9ff95

Browse files
authored
Add CI workflow with Pa11y accessibility checks on PRs (#100)
1 parent 83136e0 commit dd9ff95

2 files changed

Lines changed: 430 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- main
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
build:
13+
name: Build Jekyll site
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Checkout repository (with submodules)
17+
uses: actions/checkout@v4
18+
with:
19+
submodules: recursive
20+
21+
- name: Set up Ruby
22+
uses: ruby/setup-ruby@v1
23+
with:
24+
ruby-version: '3.2'
25+
bundler-cache: true
26+
cache-version: 0
27+
28+
- name: Install system dependencies
29+
run: |
30+
sudo apt-get update
31+
sudo apt-get install -y libvips libvips-dev imagemagick
32+
33+
- name: Generate tag and category data
34+
run: make generate
35+
36+
- name: Build Jekyll site
37+
run: bundle exec jekyll build
38+
39+
- name: Upload built site artifact
40+
uses: actions/upload-artifact@v4
41+
with:
42+
name: site
43+
path: _site/
44+
retention-days: 1
45+
if-no-files-found: error
46+
47+
accessibility:
48+
name: Accessibility (Pa11y)
49+
runs-on: ubuntu-latest
50+
needs: build
51+
permissions:
52+
contents: read
53+
pull-requests: write
54+
steps:
55+
- name: Download built site artifact
56+
uses: actions/download-artifact@v4
57+
with:
58+
name: site
59+
path: _site/
60+
61+
- name: Install Pa11y
62+
run: npm install -g pa11y@9
63+
64+
- name: Allow unprivileged user namespaces (Ubuntu 24.04 AppArmor workaround)
65+
run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns
66+
67+
- name: Run Pa11y on representative pages
68+
id: pa11y
69+
run: |
70+
FAILED=0
71+
72+
run_pa11y() {
73+
local label="$1"
74+
local subdir="$2"
75+
local file
76+
if [ -z "$subdir" ]; then
77+
file=$(find _site/ -maxdepth 1 -name "index.html" 2>/dev/null | head -1)
78+
else
79+
file=$(find "_site/$subdir" -name "index.html" 2>/dev/null | sort 2>/dev/null | head -1)
80+
fi
81+
if [ -z "$file" ]; then
82+
echo "SKIP [$label]: no file found at _site/$subdir"
83+
echo "{\"pageUrl\":\"$file\",\"documentTitle\":\"$label\",\"issues\":[]}" > "/tmp/pa11y_${label}.json"
84+
return
85+
fi
86+
echo ""
87+
echo "=== $label: $file ==="
88+
pa11y --reporter json --standard WCAG2AA "$file" > "/tmp/pa11y_${label}.json" 2>/dev/null || FAILED=1
89+
}
90+
91+
run_pa11y "Homepage" ""
92+
run_pa11y "Event" "events"
93+
run_pa11y "Project" "projects"
94+
run_pa11y "Person" "people"
95+
run_pa11y "Organization" "organizations"
96+
run_pa11y "Venue" "venues"
97+
run_pa11y "Resource" "resources"
98+
run_pa11y "Tag" "tags"
99+
run_pa11y "Category" "categories"
100+
101+
echo "failed=$FAILED" >> "$GITHUB_OUTPUT"
102+
103+
- name: Build accessibility report
104+
id: report
105+
run: |
106+
node - <<'EOF'
107+
const fs = require('fs');
108+
const path = require('path');
109+
110+
const labels = [
111+
'Homepage', 'Event', 'Project', 'Person', 'Organization',
112+
'Venue', 'Resource', 'Tag', 'Category'
113+
];
114+
115+
let totalErrors = 0;
116+
let totalWarnings = 0;
117+
let sections = [];
118+
119+
for (const label of labels) {
120+
const jsonPath = `/tmp/pa11y_${label}.json`;
121+
if (!fs.existsSync(jsonPath)) continue;
122+
123+
let data;
124+
try {
125+
data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
126+
} catch (e) {
127+
sections.push(`### ${label}\n\n> Could not parse Pa11y output.\n`);
128+
continue;
129+
}
130+
131+
const issues = Array.isArray(data) ? data : (data.issues || []);
132+
const errors = issues.filter(i => i.type === 'error');
133+
const warnings = issues.filter(i => i.type === 'warning');
134+
const notices = issues.filter(i => i.type === 'notice');
135+
totalErrors += errors.length;
136+
totalWarnings += warnings.length;
137+
138+
const pageUrl = (!Array.isArray(data) && data.pageUrl) ? data.pageUrl : label;
139+
const title = (!Array.isArray(data) && data.documentTitle) ? ` — ${data.documentTitle}` : '';
140+
141+
if (issues.length === 0) {
142+
sections.push(`### ${label}\n\n✅ No issues found.\n`);
143+
} else {
144+
let rows = [`### ${label}${title}`, '', `\`${pageUrl}\``, ''];
145+
if (errors.length > 0) {
146+
rows.push(`**${errors.length} error(s)**`, '');
147+
for (const issue of errors) {
148+
rows.push(`- **[${issue.code}]** ${issue.message}`);
149+
rows.push(` - Context: \`${issue.context.replace(/`/g, "'").substring(0, 200)}\``);
150+
rows.push(` - Selector: \`${issue.selector}\``);
151+
rows.push('');
152+
}
153+
}
154+
if (warnings.length > 0) {
155+
rows.push(`<details><summary><strong>${warnings.length} warning(s)</strong></summary>`, '');
156+
for (const issue of warnings) {
157+
rows.push(`- **[${issue.code}]** ${issue.message}`);
158+
rows.push(` - Context: \`${issue.context.replace(/`/g, "'").substring(0, 200)}\``);
159+
rows.push(` - Selector: \`${issue.selector}\``);
160+
rows.push('');
161+
}
162+
rows.push('</details>', '');
163+
}
164+
if (notices.length > 0) {
165+
rows.push(`<details><summary>${notices.length} notice(s)</summary>`, '');
166+
for (const issue of notices) {
167+
rows.push(`- **[${issue.code}]** ${issue.message}`);
168+
rows.push('');
169+
}
170+
rows.push('</details>', '');
171+
}
172+
sections.push(rows.join('\n'));
173+
}
174+
}
175+
176+
const statusEmoji = totalErrors > 0 ? '❌' : '✅';
177+
const statusLine = totalErrors > 0
178+
? `${totalErrors} error(s), ${totalWarnings} warning(s) found`
179+
: `All pages passed — ${totalWarnings} warning(s)`;
180+
181+
const header = [
182+
'<!-- pa11y-report -->',
183+
'## Pa11y Accessibility Report',
184+
'',
185+
`${statusEmoji} **${statusLine}** (WCAG2AA)`,
186+
'',
187+
'---',
188+
'',
189+
].join('\n');
190+
191+
const report = header + sections.join('\n---\n\n');
192+
193+
fs.writeFileSync('/tmp/pa11y_report.md', report);
194+
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
195+
console.log('Report written.');
196+
EOF
197+
198+
- name: Post or update PR comment
199+
if: github.event_name == 'pull_request'
200+
uses: actions/github-script@v8
201+
env:
202+
REPORT_BODY_FILE: /tmp/pa11y_report.md
203+
with:
204+
script: |
205+
const fs = require('fs');
206+
const body = fs.readFileSync(process.env.REPORT_BODY_FILE, 'utf8');
207+
const marker = '<!-- pa11y-report -->';
208+
209+
const comments = await github.rest.issues.listComments({
210+
owner: context.repo.owner,
211+
repo: context.repo.repo,
212+
issue_number: context.issue.number,
213+
});
214+
215+
const existing = comments.data.find(c => c.body && c.body.includes(marker));
216+
217+
if (existing) {
218+
await github.rest.issues.updateComment({
219+
owner: context.repo.owner,
220+
repo: context.repo.repo,
221+
comment_id: existing.id,
222+
body,
223+
});
224+
console.log(`Updated existing comment ${existing.id}`);
225+
} else {
226+
await github.rest.issues.createComment({
227+
owner: context.repo.owner,
228+
repo: context.repo.repo,
229+
issue_number: context.issue.number,
230+
body,
231+
});
232+
console.log('Created new comment');
233+
}
234+
235+
- name: Fail if accessibility errors were found
236+
if: steps.pa11y.outputs.failed == '1'
237+
run: |
238+
echo "Pa11y found accessibility errors. See report above."
239+
exit 1

0 commit comments

Comments
 (0)