Pull Request Gating & Branch Policies
A red accessibility job and a blocked merge are two different things, and the distance between them is where most gates quietly die. This guide is part of CI/CD Integration & Automated Quality Gating, and it covers the layer above the scanner: the commit status a scan reports, the repository policy that consumes that status, and the human conventions that stop the policy from being routed around.
Problem Statement
A scanner produces an exit code. A merge policy consumes a named check run at a specific commit SHA. Nothing in either system knows the other exists. The scan does not know it is being required; the policy does not know what the name it requires is supposed to mean. Every failure mode in this guide comes from that gap.
The first and most common failure is a job that fails and changes nothing. Teams add an accessibility workflow, watch it go red on a pull request, and assume the merge is now impossible — but the merge button is governed by a separate list of required check names, and if a11y-gate is not on that list, the red X is decoration. The pull request merges, the violation ships, and the next retrospective concludes that “the accessibility check does not work” when the check worked perfectly and nobody asked the repository to care.
The mirror image is worse to debug. Once a check name is required, that string is a contract. Rename the job from a11y-gate to accessibility-gate in a refactor and the required name is never reported again: the pull request sits at “Expected — waiting for status to be reported” forever, with no failing job to point at and no error message that mentions the rename. Delete the requirement instead of the job and the opposite happens — the job keeps failing, keeps looking authoritative, and stops blocking anything. One of those failures is loud and confusing; the other is silent and total. A repository created from a template inherits the workflow file and none of the repository settings, which means a brand-new service can look gated for months while being completely open.
Then there is the question of which commit the gate ran against. A pull-request check run is attached to the head of the branch, not to the state that will exist after the merge. Two pull requests that each pass on their own head can combine into a page with an icon-only button and no accessible name, because one removed the visually-hidden label utility and the other started using it. A merge queue exists precisely to test the combination, and a gate that only listens for pull_request events never runs on the queued candidate — so the entry waits for a check that will never arrive and is eventually dequeued for timing out.
Finally, auto-merge and bypass turn policy into an operational question. Auto-merge fires the instant the last required check flips to success, with no human present, which means the accessibility gate must be trustworthy enough to be the last thing standing between a branch and production. And when a genuine emergency needs the gate overridden, the override has to leave a record that somebody reads, or the exception becomes the norm within a quarter.
Key implementation targets:
- One stable check name per protected branch that the repository policy requires, decoupled from however many scan jobs actually run.
- A repository ruleset expressed as a reviewable JSON file rather than a set of clicks, so the policy is auditable and reproducible.
- A gate that always reports a conclusion — including on draft pull requests and on pull requests that touch no scannable paths — so no branch can deadlock waiting for a status.
- A merge-queue configuration where the accessibility scan re-runs on the queued combination, not on the branch head that was already tested.
- Auto-merge that is safe to enable because the gate is a required check rather than an advisory annotation.
- A single documented override path with a recorded reason, an owner, and an expiry date, plus the post-merge job that files the follow-up.
- Reviewer conventions that make an accessibility failure a blocking review comment instead of something approved past.
Prerequisites
1. Naming and Reporting the Check
Decide the required name before writing any policy, and treat it the way an API version is treated: it is published, other systems depend on it, and it changes only with a migration. The name that matters is the check run name, which for GitHub Actions is the job’s name: value — falling back to the job id when name: is absent — with the matrix values appended in parentheses when the job is a matrix. That last detail derails more rollouts than anything else, because a matrix job called scan reports as scan (storefront) and scan (admin), and neither string is scan.
The architecture that survives contact with reality is a fan-in: as many scan jobs as the pipeline needs, and exactly one aggregate job whose name is the required check. Sharding the scan across surfaces, browsers or shards then becomes an internal implementation detail that never touches repository settings. Add a locale to the matrix and the required name is unchanged; drop a shard and the required name is unchanged. This is the single highest-leverage decision on the page.
The aggregate job has one trap. It must run even when its dependencies failed, which means if: always(), and if: always() on its own makes the job succeed regardless of what happened upstream — producing a gate that reports green over a failed scan. The aggregate must read the dependency results explicitly and exit non-zero itself.
# .github/workflows/a11y-gate.yml
name: accessibility
on:
pull_request:
# ready_for_review is not in the default type list; without it, marking a
# draft ready never re-runs the gate and the check stays stale.
types: [opened, synchronize, reopened, ready_for_review]
merge_group:
permissions:
contents: read
pull-requests: write
concurrency:
group: a11y-${{ github.event.pull_request.number || github.ref }}
# Cancel superseded pull-request runs only. A cancelled check run counts as
# "not success", so cancelling a merge-queue run stalls the queue entry.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
scan:
# Reports as "scan (storefront)" and "scan (admin)" — never require these.
name: scan
runs-on: ubuntu-24.04
timeout-minutes: 20
strategy:
fail-fast: false # one broken surface must not hide the other's result
matrix:
surface: [storefront, admin]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Scan ${{ matrix.surface }}
run: npm run a11y:scan -- --surface ${{ matrix.surface }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: a11y-${{ matrix.surface }}
path: a11y-results/
retention-days: 14
a11y-gate:
# THIS string is the required status check. Nothing else may be required.
name: a11y-gate
needs: [scan]
if: always() # must still report when scan failed, was skipped or cancelled
runs-on: ubuntu-24.04
steps:
- name: Convert the scan outcome into this job's conclusion
run: |
outcome='${{ needs.scan.result }}' # aggregate across the matrix
echo "scan matrix result: $outcome" >> "$GITHUB_STEP_SUMMARY"
case "$outcome" in
success) exit 0 ;;
skipped) echo "no scannable surfaces changed"; exit 0 ;;
*) echo "::error title=Accessibility gate::scan $outcome"; exit 1 ;;
esac
Treating skipped as a pass is a policy choice, not a shortcut, and it is only defensible because the job that decides to skip is inside the same repository and is reviewed like any other code. The alternative — leaving the gate pending — is the deadlock described in section three.
2. Configuring the Branch Policy and Proving It Exists
Express the policy as a repository ruleset rather than classic branch protection. Rulesets target branch patterns instead of single branches, they layer — an organisation ruleset and a repository ruleset both apply, and the union of their restrictions wins — and, most usefully here, they are a JSON document that can live in a repository, be reviewed in a pull request, and be diffed against reality. Bypass actors are first-class objects rather than the blunt enforce_admins boolean, which is what makes an auditable override policy possible at all.
The call below creates a policy that requires the aggregate check, requires one approving review with stale approvals dismissed, requires review threads to be resolved, and grants bypass to exactly one team in pull-request mode. bypass_mode: pull_request is the important nuance: the team can merge a pull request that violates the rules, but cannot push directly to the branch, so every override still has a reviewable artifact attached to it.
#!/usr/bin/env bash
# a11y/policy/create-ruleset.sh — run once per repository.
set -euo pipefail
REPO="${1:?usage: create-ruleset.sh owner/repo}"
TEAM_ID="${A11Y_BYPASS_TEAM_ID:?numeric id of the release-eng team}"
gh api --method POST "repos/${REPO}/rulesets" \
--input - <<JSON
{
"name": "accessibility-gate",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": { "include": ["~DEFAULT_BRANCH", "refs/heads/release/*"],
"exclude": [] }
},
"bypass_actors": [
{ "actor_id": ${TEAM_ID}, "actor_type": "Team", "bypass_mode": "pull_request" }
],
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": false,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": false,
"do_not_enforce_on_create": false,
"required_status_checks": [{ "context": "a11y-gate" }]
}
}
]
}
JSON
Two of those values deserve an argument. required_approving_review_count and required_status_checks are independent gates: an approval never satisfies a status check, and no amount of reviewer enthusiasm turns a red a11y-gate green. Reviewers regularly believe otherwise, which is why section five spends time on expectations. And strict_required_status_checks_policy — the “branch must be up to date” setting — is left off deliberately here. Turning it on invalidates every open pull request’s checks on every push to the base branch, and an accessibility scan is one of the slower checks in a pipeline, so strict mode on a busy repository produces a permanent re-run treadmill. The correct way to test the post-merge state is a merge queue, which is section four. The trade-offs of the strict flag in isolation are worked through in requiring accessibility status checks in branch protection, along with how to apply one ruleset file across dozens of repositories.
Because the silent failure in the matrix above is a missing requirement, the policy needs its own test. The script below asks GitHub which rules are actually in force for a branch and fails if the accessibility requirement is absent — run it on a schedule across the repository list, not from inside each repository, so a repository with no ruleset at all is still audited.
#!/usr/bin/env bash
# a11y/policy/assert-required.sh — drift detection for the gate itself.
# Exits 1 if a11y-gate is not an enforced requirement on the branch.
set -euo pipefail
REPO="${1:?usage: assert-required.sh owner/repo [branch]}"
BRANCH="${2:-main}"
# This endpoint returns the effective rules from every layer (org + repo).
contexts=$(gh api "repos/${REPO}/rules/branches/${BRANCH}" \
--jq '[.[] | select(.type == "required_status_checks")
| .parameters.required_status_checks[].context] | join(",")')
if [[ ",${contexts}," != *",a11y-gate,"* ]]; then
echo "::error title=Ungated branch::${REPO}@${BRANCH} does not require a11y-gate"
echo "effective required contexts: ${contexts:-<none>}"
exit 1
fi
echo "${REPO}@${BRANCH}: a11y-gate is required"
3. Draft Pull Requests and Skipped Paths
Draft pull requests are a reporting problem, not a scanning problem. Checks run on drafts by default, and the cheapest correct answer is to let them: a draft is where a developer wants accessibility feedback most, and the check cannot block anything because a draft cannot merge. If scan minutes force a skip, skip the work and not the report — and add ready_for_review to the trigger types, because the default opened, synchronize, reopened set means marking a draft ready produces no new event, no new run, and a check run that still reflects a commit from three days ago.
Path filtering is the same lesson with sharper teeth. A workflow-level paths: filter does not skip the job; it prevents the workflow from running at all, so no check run is ever created. Against a required name that is a permanent block: a documentation-only pull request touching one Markdown file sits at “Expected — waiting for status to be reported” with nothing to click. The fix is architectural rather than clever. Never put a paths: filter on a workflow whose job name is required. Put the path decision in a job, and make the gate job depend on it while always reporting.
# Continuation of .github/workflows/a11y-gate.yml — scope decision as a job.
scope:
name: scope
runs-on: ubuntu-24.04
outputs:
scannable: ${{ steps.decide.outputs.scannable }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # merge-base needed to diff against the base branch
- id: decide
name: Decide whether any scannable path changed
run: |
base="origin/${{ github.event.pull_request.base.ref || 'main' }}"
# Three-dot diff: only what this branch changed since the merge base.
changed=$(git diff --name-only "$base...HEAD")
if echo "$changed" | grep -Eq '^(src|packages|public)/|^tailwind\.config\.'; then
echo "scannable=true" >> "$GITHUB_OUTPUT"
else
echo "scannable=false" >> "$GITHUB_OUTPUT"
echo "No app-code changes; accessibility scan not required." \
>> "$GITHUB_STEP_SUMMARY"
fi
With scan given if: needs.scope.outputs.scannable == 'true' and the gate job keeping if: always(), every pull request in the repository produces exactly one a11y-gate check run with a definite conclusion. The table below is the behaviour matrix worth pinning to the team wiki, because each row is a support question somebody will ask.
| Pull request state | Scan runs | a11y-gate conclusion |
Merge |
|---|---|---|---|
| Draft, app code changed | yes | success or failure | blocked while failing |
| Draft marked ready, no new push | re-runs on ready_for_review |
fresh conclusion | evaluated on fresh result |
| Markdown-only change | no, scope says false |
success, reason in summary | allowed |
| Fork pull request | yes, without secrets | success or failure | blocked while failing |
| Workflow file itself edited | yes, from the branch version | success or failure | blocked while failing |
That last row is worth staring at. A pull request may edit the very workflow that gates it, and the branch’s version is what executes — so a contributor can weaken the gate and have the weakened gate approve the weakening. Requiring code-owner review on .github/workflows/** is the only durable answer, which is why require_code_owner_review is set in the ruleset above.
4. Wiring the Gate Into the Merge Queue
A merge queue changes what “the code under test” means. When a pull request is queued, GitHub creates a temporary branch — gh-readonly-queue/main/pr-418-<sha> — containing the base branch plus every entry ahead of this one plus this pull request, and dispatches a merge_group event against it. Whatever checks the branch policy requires must report on that ref before the entry can merge. This is the mechanism that catches combination failures no per-branch scan can see: the pull request that deletes an unused-looking .sr-only utility and the pull request that starts relying on it are individually clean and jointly broken.
The wiring is two lines of trigger and one guard, but there are three ways to get it wrong. First, a workflow that omits merge_group never reports and every queue entry times out and is ejected — which surfaces as “the merge queue is broken”, not as “the accessibility workflow is misconfigured”. Second, github.event.pull_request is null in a merge_group run, so any step that reads the pull-request number needs a fallback or the job crashes. Third, an aggressive concurrency group that cancels in-progress runs will cancel queue runs, and a cancelled check run is not a success, so the entry stalls — the cancel-in-progress expression in section one exists exactly for this.
The queued combination is also the right place to scan more than a pull request scans. A per-branch run can legitimately restrict itself to the routes the diff could have affected, as covered in gating only changed pages with a diff-aware scan; the queue run has one job for one merge and can afford the full route set.
# .github/workflows/a11y-merge-queue.yml
name: accessibility (merge queue)
on:
merge_group:
permissions:
contents: read
jobs:
a11y-gate:
# Same required name as the pull-request workflow. Only one of the two
# workflows can trigger for a given event, so the name never collides.
name: a11y-gate
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
# The queue's temporary ref already contains base + earlier entries.
ref: ${{ github.event.merge_group.head_sha }}
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Full-route scan on the queued combination
# No diff scoping here: this ref is what main will become.
run: npm run a11y:scan -- --all-routes --fail-on critical
- uses: actions/upload-artifact@v4
if: always()
with:
name: a11y-merge-group-${{ github.run_id }}
path: a11y-results/
retention-days: 30
Auto-merge composes cleanly with all of this once the gate is a required check. Auto-merge waits for every requirement, then either merges or hands the pull request to the queue, so an accessibility failure simply keeps the pull request waiting rather than merging unattended. The failure mode to avoid is the inverse: enabling auto-merge across a repository while the accessibility check is still advisory. That combination is strictly worse than no gate, because it merges without a human ever seeing the red X. Move a new gate from advisory to blocking before enabling auto-merge, using the soak process from auto-fail vs warning workflows rather than flipping both switches in one week.
5. The Bypass and Audit Policy
Every gate needs an escape hatch, and the design of that hatch determines whether the gate survives its first outage. The instinct is to let the pipeline be skipped — a skip-a11y label wired into an if: condition on the scan step. Resist it, because it produces exactly the deadlock or the fake-green problem depending on how the gate job is written, and because the resulting merge carries no record of what was skipped or why.
Prefer a waiver. A waiver leaves the gate running, leaves the violation visible, and changes only the verdict — with an owner, a reason and an expiry date recorded in a file that is reviewed like code. The check still reports; its summary names the waiver; and a scheduled job fails when a waiver passes its expiry, so an “emergency, one sprint” exception cannot become permanent by inattention.
# a11y/waivers.yml — reviewed by the accessibility code owners.
waivers:
- id: WVR-2026-014
rule: color-contrast
routes: ['/legacy/invoices', '/legacy/invoices/:id']
reason: >
Legacy invoice theme is being replaced in PLAT-8821; the palette is
generated by the reporting service and cannot be patched in the app.
owner: '@acme/platform-billing'
tracking_issue: 8821
expires: 2026-09-30
// a11y/scripts/apply-waivers.mjs
// Usage: node a11y/scripts/apply-waivers.mjs a11y-results/results.json
// Exit 0 = gate satisfied, 1 = gate fails, and every waiver used is printed.
import { readFileSync, appendFileSync } from 'node:fs';
import { parse } from 'yaml';
const results = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const { waivers } = parse(readFileSync('a11y/waivers.yml', 'utf8'));
const today = new Date().toISOString().slice(0, 10);
const summary = process.env.GITHUB_STEP_SUMMARY || '/dev/stdout';
const expired = waivers.filter((w) => w.expires < today);
const active = waivers.filter((w) => w.expires >= today);
const used = [];
const blocking = results.violations.filter((v) => {
if (v.impact !== 'critical' && v.impact !== 'serious') return false;
const match = active.find(
(w) => w.rule === v.id && w.routes.includes(results.route),
);
if (match) used.push(`${match.id} (${v.id} on ${results.route})`);
return !match; // a waived violation is reported but does not block
});
appendFileSync(summary, `### Accessibility gate\n\n`);
appendFileSync(summary, `- blocking violations: ${blocking.length}\n`);
appendFileSync(summary, `- waivers applied: ${used.join(', ') || 'none'}\n`);
appendFileSync(summary, `- expired waivers: ${expired.length}\n`);
// An expired waiver is itself a gate failure: the exception outlived its plan.
if (expired.length > 0) {
console.error(`::error::expired waivers: ${expired.map((w) => w.id).join(', ')}`);
}
process.exit(blocking.length > 0 || expired.length > 0 ? 1 : 0);
Above the waiver sits the genuine bypass, and the only question that matters is who holds it and where it is written down. Grant it to one team, not to every administrator; use bypass_mode: pull_request so the override is always attached to a reviewable pull request; and never rely on the organisation audit log as the only record, because nobody reads it voluntarily. Make the record come to the team instead: a job that runs after merge, notices that the head commit’s a11y-gate was not a success, and files a tracked issue with the pull request, the author and the violation list attached.
# .github/workflows/a11y-bypass-audit.yml
name: accessibility bypass audit
on:
pull_request_target:
types: [closed]
permissions:
issues: write
checks: read
jobs:
record:
if: github.event.pull_request.merged == true
runs-on: ubuntu-24.04
steps:
- name: File an issue when a merge happened over a failing gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA: ${{ github.event.pull_request.head.sha }}
NUM: ${{ github.event.pull_request.number }}
WHO: ${{ github.event.pull_request.user.login }}
run: |
conclusion=$(gh api "repos/${{ github.repository }}/commits/$SHA/check-runs" \
--jq '.check_runs[] | select(.name == "a11y-gate") | .conclusion' \
| head -n1)
[ "$conclusion" = "success" ] && exit 0
gh issue create \
--title "Accessibility gate bypassed on #${NUM}" \
--label a11y-debt,bypass \
--body "PR #${NUM} merged by policy bypass with a11y-gate = ${conclusion:-missing}.
Author: @${WHO}. Head SHA: ${SHA}.
Remediate within one sprint or convert to a dated waiver in a11y/waivers.yml."
Reviewer expectations are the last piece, and they are cultural rather than technical — but they have technical support. Add the accessibility owners to CODEOWNERS for the paths where regressions are expensive (the design system, shared layout, form primitives) so a human with context is always requested. Put one line in the pull-request template that names the gate and says explicitly that approving a pull request does not satisfy it. And insist on the review norm that makes all of this work: a failing a11y-gate is a request for changes, not a discussion item. The moment a reviewer approves a pull request whose accessibility check is red — on the reasoning that the author will “follow up” — the gate has become advisory again, no matter what the ruleset says. Annotating the failure inline, as described in annotating pull requests with axe-core violation comments, makes that norm far easier to hold, because the reviewer sees the offending element in the diff rather than a log they have to go and read.
Pipeline Integration
The contract between the scanner and the policy is narrow: one process exit code, converted to one check run conclusion, matched against one required name. Everything else the pipeline produces is for humans. Keep those two responsibilities separate in the workflow — the step that writes annotations, uploads artifacts and posts a summary must never be the step that decides the exit code, because a reporting failure then masquerades as an accessibility failure and the team learns to re-run the gate reflexively.
Exit codes carry meaning worth preserving. Reserve 1 for “violations above the blocking threshold”, 2 for “the scan could not complete” — server never came up, browser crashed, route returned a 500 — and 3 for “configuration is invalid”, such as a waiver file that does not parse. All three fail the check, but the summary line differs, and that difference is what stops an infrastructure outage from being triaged as an accessibility regression. The full exit-code taxonomy, including which classes belong in warning mode, is set out in auto-fail vs warning workflows.
Artifacts should be named so that a queue run and a branch run are distinguishable a week later, because the two answer different questions: a11y-<surface> for pull-request runs and a11y-merge-group-<run_id> for queue runs, both with a retention window that outlives the sprint. Upload with if: always() — the run you most need the JSON from is the one that failed.
Finally, publish the gate’s own state. A repository-level dashboard that shows which branches actually require a11y-gate, which waivers are active, and how many bypasses happened last month turns policy drift into a visible number. Route the drift-check script from section two into the same store that receives scan results, as described in reporting, dashboards and violation tracking, and the question “is this repository gated?” stops needing a human to go and look.
Troubleshooting and Flaky-Test Mitigation
“Expected — waiting for status to be reported” and nothing is queued. The required name is not being produced. List what actually reported on the head commit with gh api repos/OWNER/REPO/commits/$SHA/check-runs --jq '.check_runs[].name' and compare it character by character with the ruleset. The three usual causes are a matrix suffix, a paths: filter that stopped the workflow from starting, and a job renamed in a refactor.
The gate reports success but the scan failed. An aggregate job with if: always() and no explicit result inspection always succeeds. Re-read section one: needs.<job>.result must be branched on, and the aggregate must exit 1 itself.
A re-run of the failed job does not unblock the pull request. Re-running only the failed jobs recreates the check runs for those jobs. If the required name belongs to a downstream aggregate that was skipped, it is never recreated. Re-run all jobs, or make the aggregate depend on nothing that can prevent it from starting.
Fork pull requests block on a check that never arrives. A pull_request run from a fork gets a read-only token and no secrets, so any attempt to post a status via the API fails silently. Never post the gate’s status through the API; let the job’s own conclusion be the check run. Keep API-posting jobs to a pull_request_target workflow that does no gating.
Queue entries time out and get ejected. Either the workflow has no merge_group trigger, or a concurrency group with cancel-in-progress: true is cancelling the queue run, or a step reads github.event.pull_request.* and crashes on the null value. Check all three before blaming the queue.
The scan is genuinely flaky. Retry inside the job, never by re-running the workflow, so exactly one check run with one conclusion exists per commit. Two retries with a short backoff absorbs runner-level noise; a scan that needs more than two is a hydration-timing bug, and the wait condition needs fixing rather than the retry count increasing.
Checks pass on a stale base. With strict mode off, a pull request opened three weeks ago can be green against a base that has moved substantially. That is the deliberate trade described in section two, and the merge queue is the compensating control. Repositories with no queue should turn strict on and accept the re-run cost.
A waiver silently covers more than intended. Waivers keyed on rule id alone will suppress that rule everywhere. Always key on rule and route, keep the route list explicit rather than a glob, and let the expiry date force a review.
Common Pitfalls
- Requiring a matrix job’s bare name (
scan) instead of the expanded check run names, so the requirement is never satisfied and every pull request deadlocks. - Putting a
paths:filter on the workflow that owns the required job, which prevents the check from being created at all rather than skipping the work. - Writing an aggregate gate job with
if: always()and no result branching, producing a green required check over a red scan. - Leaving
merge_groupout of the triggers after enabling a merge queue, so every entry is ejected for a missing status. - Enabling auto-merge while the accessibility check is still advisory, which merges failures with no human in the loop at all.
- Granting bypass to every repository administrator instead of one named team, so overrides are untraceable in practice even though the audit log technically contains them.
- Implementing the override as a step-level skip, which removes the evidence along with the enforcement and leaves no violation list to remediate from.
- Copying the workflow into a new repository from a template and never applying the ruleset, producing a service that looks gated and is not.
- Letting reviewers approve over a red gate “with a follow-up”, which converts a blocking policy into an advisory one within a handful of pull requests.
- Requiring
a11y-gateon the default branch only, whilerelease/*branches — the ones that actually ship — accept anything.
FAQ
Does a required status check stop an administrator from merging?
Not by itself. Classic branch protection needs enforce_admins, and a ruleset needs the administrator to not be listed in bypass_actors. The practical configuration is to name one team as the sole bypass actor with bypass_mode: pull_request, so an override is possible, is attached to a reviewable pull request, and appears in the audit log with a specific actor rather than “an admin”.
What is the difference between a check run and a commit status here?
A check run is created by a GitHub App — Actions being one — and carries annotations, a summary and a rich conclusion set including neutral and cancelled. A commit status is a simpler object with a context string and one of four states. Branch policies match on the check run’s name or the status’s context interchangeably, which is why they are often confused; the practical difference is that a check run can attach the violation list to the pull request and a commit status cannot.
Should the accessibility gate be one required check or several? One. Every additional required name is another string that can drift out of sync with the workflow, and none of them add enforcement power. Fan the scan out across as many jobs as parallelism requires, then fan in to a single aggregate job whose name is the only thing the ruleset knows about. Splitting the requirement per surface or per browser buys nothing except more ways for the policy to break.
How should the gate behave on a pull request that only touches tests or documentation? It should report success with a stated reason, never stay pending. Decide the scope inside a job whose output the scan job is conditional on, keep the gate job unconditional, and write the reason into the step summary so a reviewer can see that the scan was skipped deliberately. Narrowing the scan to the routes a diff could have affected is a related but separate technique, covered in the diff-aware scanning guide linked from section four.
Can the same policy be expressed on GitLab or Bitbucket?
The mechanics differ but the model holds. GitLab uses merge request approval rules plus a pipeline that must succeed, with allow_failure: false on the gating job as the equivalent of the required name, and merge trains playing the role of the merge queue. Bitbucket uses merge checks with a minimum number of successful builds. In every case the same three questions apply: what is the stable name, does the policy actually reference it, and does the gate re-run on the state that will exist after the merge.
Related
- Requiring Accessibility Status Checks in Branch Protection — the exact ruleset payload, matrix check names, and rollout across many repositories.
- Gating Only Changed Pages With a Diff-Aware Scan — narrow the pull-request scan to the routes a diff can reach without changing the required name.
- Auto-Fail vs Warning Workflows — decide which findings block and which only annotate before wiring anything into a policy.
- Progressive Threshold Management — ratchet the blocking severity down over sprints instead of blocking a legacy codebase on day one.
- Blocking Pull Requests on Critical Accessibility Violations — the severity filter that decides what the gate’s exit code actually means.