|
| 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