CI / CD Integration
Run the same accessibility scans you use in development inside your build pipeline. WelcomingWeb supports two integration styles — in-app CI runners driven by E2E frameworks, and headless scripts driven by Fastlane or shell — and both produce the same structured session report on the dashboard.
This guide assumes you already have the mobile SDK configured in your app. If not, start with the getting started guide and return here once a development scan is reaching the dashboard.
The workflow is identical across every platform. Only the SDK call syntax differs — the server doesn’t know which SDK produced a session.
Core Workflow
Every CI integration follows the same four-step pattern.
Step 1: Configure with Auto-Scan Off
Auto-scan is designed for development, where deduplication saves battery and network. In CI you want guaranteed fresh results, so disable auto-scan.
React Native
WelcomingWeb.configure({
apiKey: process.env.WELCOMINGWEB_API_KEY,
appId: 'com.yourcompany.app',
appVersion: process.env.APP_VERSION,
environment: 'development',
autoScan: false,
});Step 2: Clear the Screen Cache
The SDK uses a component-tree hash to skip re-scanning screens whose UI hasn’t changed. In CI, clear the cache before each run so every screen is evaluated fresh.
React Native
WelcomingWeb.clearScreenCache();Alternatively, pass force: true to scanScreen() — it bypasses the dedup check and produces a
fresh result on every call.
Step 3: Navigate and Scan Each Screen
For each screen you want covered, navigate to it and call scanScreen(). Either do this from a
dedicated in-app runner (driven by Detox or Maestro) or from your test framework directly.
React Native
await navigateTo('HomeScreen');
await WelcomingWeb.scanScreen('HomeScreen', '/Home', null);
await navigateTo('ProductList');
await WelcomingWeb.scanScreen('ProductList', '/ProductList', null);Step 4: Submit the Session
One final call batch-submits every scan as a single session and returns aggregate results.
React Native
const result = await WelcomingWeb.submitScanSession({
scanType: 'ci_build',
buildId: process.env.BUILD_ID,
commitHash: process.env.COMMIT_SHA,
appVersion: process.env.APP_VERSION,
});
if (result.criticalIssues > 0) process.exit(1);Environment Variables
All CI integrations consume the same environment variables. Store secrets in your CI provider’s secret manager.
| Variable | Required | Description |
|---|---|---|
WELCOMINGWEB_API_KEY | Yes | API key with scan write permission. |
WELCOMINGWEB_APP_ID | Yes | Registered app identifier (e.g. com.company.app). |
WELCOMINGWEB_ENDPOINT | No | Custom reporting endpoint. Omit to use the default API. |
APP_VERSION | Recommended | Semver string included in session reports. |
BUILD_ID | Recommended | CI build number or identifier for traceability. |
COMMIT_SHA | Recommended | Git commit hash linked to the session on the dashboard. |
A11Y_MIN_SCORE | No | Minimum acceptable aggregate score. Defaults to 0 (no threshold). |
A11Y_FAIL_ON_CRITICAL | No | Set to 'true' to fail on any critical issue regardless of score. |
In-App CI Runner Screen
For teams running E2E tests with Detox or Maestro, the simplest integration is a dedicated
CIRunnerScreen inside the app. Pressing the “Run CI Scan” button navigates to every tracked
screen in sequence, waits for each scan, submits the session, and displays a verdict banner.
Your external test framework reads the verdict text to determine the exit code.
Architecture
- Clear the dedup cache at the start of the run.
- Swap the scan callback to resolve a Promise / Task / Future keyed by screen name.
- For each screen: register a resolver, navigate, wait for a settle margin, await the scan.
- Submit the session with
scanType: 'ci_build'and the build metadata. - Restore the original config in a
finallyblock. - Display a verdict banner (BUILD PASSED / BUILD FAILED) that the test framework asserts on.
Define Scan Targets
List every screen you want covered. One entry per screen.
export const SCAN_TARGETS = [
{
tab: 'Shop',
stackScreen: 'Home',
sdkName: 'HomeScreen',
route: '/HomeScreen',
},
{
tab: 'Shop',
stackScreen: 'ProductDetail',
stackParams: { product: SAMPLE_PRODUCT },
sdkName: 'ProductDetailScreen',
route: '/ProductDetailScreen',
},
{
tab: 'Profile',
sdkName: 'ProfileScreen',
route: '/ProfileScreen',
},
];The Scan Loop (React Native Example)
A Promise resolver map bridges the callback-based onScanComplete into an async/await loop,
guaranteeing results are captured even for screens with complex loading sequences.
// 1. Clear dedup cache
WelcomingWeb.clearScreenCache();
// 2. Override callback for this run
WelcomingWeb.configure({
...SDK_CONFIG,
autoScan: true,
onScanComplete: (result) => {
resolverMapRef.current.get(result.screenName)?.resolve(result);
},
});
// 3. Scan each screen in sequence
for (const target of SCAN_TARGETS) {
const scanPromise = waitForScan(target.sdkName); // 8 s timeout
navigateTo(target); // triggers useFocusEffect
await sleep(SCAN_WAIT_MS); // 2000 ms margin
const result = await scanPromise; // resolved by onScanComplete
collectResult(result);
}
// 4. Submit session
const session = await WelcomingWeb.submitScanSession({ scanType: 'ci_build' });
// 5. Restore original config
WelcomingWeb.configure(SDK_CONFIG);The Android, iOS and Flutter SDKs follow the exact same pattern with coroutines / async-await /
Completer respectively. The only syntactic difference is how you bridge the SDK’s
onScanComplete callback into your test framework’s await primitive.
After all screens have been scanned, the runner navigates back to itself and shows a BUILD PASSED or BUILD FAILED banner. Your test framework asserts which one is visible.
GitHub Actions
Runs on every pull request. The accessibility job runs in parallel with your existing test jobs and fails the PR check if critical issues appear or the score drops below threshold.
name: Accessibility Scan
on:
pull_request:
push:
branches: [main, develop]
jobs:
a11y-scan:
runs-on: macos-latest
timeout-minutes: 30
env:
WELCOMINGWEB_API_KEY: ${{ secrets.WELCOMINGWEB_API_KEY }}
WELCOMINGWEB_APP_ID: ${{ secrets.WELCOMINGWEB_APP_ID }}
APP_VERSION: ${{ github.ref_name }}
BUILD_ID: ${{ github.run_id }}
COMMIT_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Install Detox CLI
run: yarn global add detox-cli
- name: Build app (debug)
run: detox build --configuration ios.sim.debug
- name: Boot simulator
run: xcrun simctl boot "iPhone 15"
- name: Run accessibility scan
run: |
detox test --configuration ios.sim.debug \
--testNamePattern "Accessibility CI"
env:
A11Y_FAIL_ON_CRITICAL: 'true'
A11Y_MIN_SCORE: '75'
- name: Upload session report
if: always()
uses: actions/upload-artifact@v4
with:
name: a11y-report
path: e2e/artifacts/Detox Test File
import { device, element, by, expect as detoxExpect } from 'detox';
describe('Accessibility CI', () => {
beforeAll(async () => {
await device.launchApp({ newInstance: true });
});
it('passes full accessibility scan', async () => {
await element(by.label('CI Scan')).tap();
await element(by.label('Run full CI scan')).tap();
await detoxExpect(
element(by.text('BUILD PASSED').or(by.text('BUILD FAILED')))
).toBeVisible(60000);
await detoxExpect(element(by.text('BUILD FAILED'))).not.toBeVisible();
});
});Bitrise
Add the script step below to your bitrise.yml workflow after your E2E test step. Bitrise’s
secret store holds the API key; all other values come from built-in environment variables.
workflows:
accessibility_scan:
steps:
- git-clone@8: {}
- yarn@0:
inputs:
- command: install --frozen-lockfile
- script@1:
title: Run WelcomingWeb accessibility scan
inputs:
- content: |
#!/usr/bin/env bash
set -euo pipefail
export WELCOMINGWEB_API_KEY="$WELCOMINGWEB_API_KEY"
export APP_VERSION="$BITRISE_BUILD_NUMBER"
export BUILD_ID="$BITRISE_BUILD_SLUG"
export COMMIT_SHA="$GIT_CLONE_COMMIT_HASH"
yarn detox test \
--configuration android.emu.debug \
--testNamePattern "Accessibility CI"
echo "Scan complete."
- slack@4:
title: Notify accessibility result
is_always_run: true
inputs:
- webhook_url: "$SLACK_WEBHOOK"
- channel: "#mobile-a11y"
- message: |
*Accessibility Scan* $BITRISE_APP_TITLE
Build: $BITRISE_BUILD_NUMBER | $BITRISE_BUILD_STATUS_TEXT
Dashboard: https://app.welcomingweb.com/apps/$WELCOMINGWEB_APP_IDFastlane
The lane below runs the Detox accessibility suite and fails if critical issues appear. Invoke it from any CI provider or locally before a release.
platform :ios do
desc "Run WelcomingWeb accessibility scan"
lane :a11y_scan do |options|
min_score = options[:min_score] || 75
fail_on_critical = options[:fail_on_critical] != false # default true
ENV['WELCOMINGWEB_API_KEY'] = ENV['WELCOMINGWEB_API_KEY']
ENV['APP_VERSION'] = lane_context[SharedValues::VERSION_NUMBER]
ENV['BUILD_ID'] = ENV['CI_BUILD_ID'] || 'local'
ENV['COMMIT_SHA'] = last_git_commit[:commit_hash]
ENV['A11Y_MIN_SCORE'] = min_score.to_s
ENV['A11Y_FAIL_ON_CRITICAL'] = fail_on_critical.to_s
build_app(
scheme: "YourAppScheme",
configuration: "Debug",
skip_codesigning: true
)
sh("yarn detox test --configuration ios.sim.debug " \
"--testNamePattern 'Accessibility CI'")
UI.success "Accessibility scan complete. Check the dashboard for results."
end
endUsage:
# From the command line
bundle exec fastlane a11y_scan
# With custom threshold
bundle exec fastlane a11y_scan min_score:80 fail_on_critical:true
# From another lane
lane :release do
a11y_scan(min_score: 80)
gym
pilot
endMaestro
Maestro is a lightweight mobile UI testing framework with no native setup — perfect if you don’t already have Detox or another E2E harness wired in. It works against all four SDKs equally, because it drives the app from outside via accessibility labels.
appId: com.yourcompany.yourapp
---
# 1. Launch the app fresh
- launchApp:
clearState: true
# 2. Open the CI runner tab
- tapOn:
text: "CI Scan"
# 3. Start the scan
- tapOn:
accessibilityLabel: "Run full CI scan"
# 4. Wait up to 60 s for the verdict
- waitForAnimationToEnd:
timeout: 60000
# 5. Assert pass (fails if BUILD FAILED is visible)
- assertNotVisible:
text: "BUILD FAILED"
- assertVisible:
text: "BUILD PASSED"Running Maestro in CI:
# Install
curl -Ls "https://get.maestro.mobile.dev" | bash
# Run the accessibility flow
maestro test .maestro/a11y_scan.yaml
# Run against a specific device
maestro --device <udid> test .maestro/a11y_scan.yamlCodemagic (Flutter)
Flutter teams using Codemagic can run the scan as part of their build pipeline:
workflows:
accessibility-scan:
name: Accessibility Scan
environment:
vars:
WELCOMINGWEB_API_KEY: $WELCOMINGWEB_API_KEY
WELCOMINGWEB_APP_ID: $WELCOMINGWEB_APP_ID
scripts:
- flutter pub get
- flutter build apk --debug
- flutter test integration_test/a11y_test.dartGradle Task (Android)
For teams preferring a pure-Android pipeline, run the integration tests directly from Gradle:
./gradlew :app:connectedAndroidTest \
-PtestClass=com.yourcompany.app.AccessibilityCiTest \
-PWELCOMINGWEB_API_KEY=$WELCOMINGWEB_API_KEYPass / Fail Strategies
Choose the threshold strategy that matches your release quality requirements.
| Strategy | When to Use |
|---|---|
| Zero critical issues | Recommended baseline for every team. No critical WCAG A violations can ship. |
| Minimum score | Prevents regression below a baseline (e.g. 75). Combine with the critical check. |
| Zero new issues | Advanced. Diff the current session against a stored baseline and fail on regressions only. |
| Warning only | Collect data without blocking. Useful when first adopting the SDK. |
Recommended Combined Check
const result = await WelcomingWeb.submitScanSession({ scanType: 'ci_build' });
const MIN_SCORE = parseInt(process.env.A11Y_MIN_SCORE || '0', 10);
const failOnCritical = process.env.A11Y_FAIL_ON_CRITICAL === 'true';
if (failOnCritical && result.criticalIssues > 0) {
console.error(
`A11Y FAIL: ${result.criticalIssues} critical issue(s). Fix before shipping.`
);
process.exit(1);
}
if (MIN_SCORE > 0 && result.accessibilityScore < MIN_SCORE) {
console.error(
`A11Y FAIL: score ${result.accessibilityScore} < minimum ${MIN_SCORE}.`
);
process.exit(1);
}
console.log(
`A11Y PASS: score ${result.accessibilityScore} (${result.totalIssues} issues)`
);Session Response Fields
Every CI session returns a structured response you can read programmatically. The shape is identical across all four SDKs.
| Field | Type | Description |
|---|---|---|
success | boolean | Whether the session completed successfully. |
sessionId | number | Server-assigned session ID. Link to the dashboard. |
screensScanned | number | Number of screens in this session. |
totalIssues | number | Total issues across all screens. |
criticalIssues | number | Count of critical-severity issues. |
majorIssues | number | Count of major-severity issues. |
minorIssues | number | Count of minor-severity issues. |
accessibilityScore | number | Aggregated score from 0 to 100. |
Troubleshooting
All CI scans return deduplicated: true — the dedup cache was not cleared before the CI run.
Call clearScreenCache() at the start of every CI scan loop, or pass force: true to each
scanScreen() call. If the issue persists across separate app launches, ensure the SDK is freshly
configured at the start of each run.
Scan timeout in CI runner — the scan callback fired outside the expected window. Common causes: the screen wasn’t mounted when navigated to (increase the settle margin from 2000 ms to 3000 ms), or the screen name passed to the runner doesn’t match the name registered with the SDK.
Scores differ between SDK and dashboard — the server recalculates scores from its own stored
data as the source of truth. Small differences can occur if server-side deduplication skipped a
scan. Enable debug: true and check the API payload log to confirm data is being transmitted.
403 app_deactivated — the app’s active flag is false on the backend. Re-activate the app
from the dashboard or contact support.
409 platform_mismatch — the app was registered under a different platform on the backend
(e.g. REACT_NATIVE when you’re submitting ios_native). Either re-register the app under a new
appId, or update the backend registration.