├── .gitignore ├── .github ├── CODEOWNERS ├── actions │ ├── fix │ │ ├── src │ │ │ ├── types.d.ts │ │ │ ├── retry.ts │ │ │ ├── Issue.ts │ │ │ ├── getLinkedPR.ts │ │ │ ├── index.ts │ │ │ └── assignIssue.ts │ │ ├── tsconfig.json │ │ ├── package.json │ │ ├── action.yml │ │ ├── README.md │ │ ├── bootstrap.js │ │ └── package-lock.json │ ├── auth │ │ ├── tsconfig.json │ │ ├── src │ │ │ ├── types.d.ts │ │ │ └── index.ts │ │ ├── package.json │ │ ├── action.yml │ │ ├── README.md │ │ ├── bootstrap.js │ │ └── package-lock.json │ ├── file │ │ ├── tsconfig.json │ │ ├── src │ │ │ ├── isNewFiling.ts │ │ │ ├── isResolvedFiling.ts │ │ │ ├── isRepeatedFiling.ts │ │ │ ├── closeIssue.ts │ │ │ ├── reopenIssue.ts │ │ │ ├── types.d.ts │ │ │ ├── updateFilingsWithNewFindings.ts │ │ │ ├── Issue.ts │ │ │ ├── openIssue.ts │ │ │ └── index.ts │ │ ├── package.json │ │ ├── action.yml │ │ ├── README.md │ │ ├── bootstrap.js │ │ └── package-lock.json │ ├── find │ │ ├── tsconfig.json │ │ ├── package.json │ │ ├── action.yml │ │ ├── src │ │ │ ├── types.d.ts │ │ │ ├── index.ts │ │ │ ├── findForUrl.ts │ │ │ └── AuthContext.ts │ │ ├── README.md │ │ ├── bootstrap.js │ │ └── package-lock.json │ └── gh-cache │ │ ├── save │ │ ├── README.md │ │ └── action.yml │ │ ├── delete │ │ ├── README.md │ │ └── action.yml │ │ ├── restore │ │ ├── README.md │ │ └── action.yml │ │ └── cache │ │ ├── README.md │ │ └── action.yml ├── dependabot.yml └── workflows │ └── test.yml ├── sites └── site-with-errors │ ├── .gitignore │ ├── index.markdown │ ├── 404.html │ ├── about.markdown │ ├── _posts │ └── 2025-07-30-welcome-to-jekyll.markdown │ ├── Gemfile │ ├── config.ru │ ├── _config.yml │ └── Gemfile.lock ├── tests ├── types.d.ts └── site-with-errors.test.ts ├── package.json ├── LICENSE ├── SUPPORT.md ├── CONTRIBUTING.md ├── SECURITY.md ├── CODE_OF_CONDUCT.md ├── action.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | test-results -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @github/accessibility-reviewers 2 | -------------------------------------------------------------------------------- /sites/site-with-errors/.gitignore: -------------------------------------------------------------------------------- 1 | _site 2 | .sass-cache 3 | .jekyll-cache 4 | .jekyll-metadata 5 | vendor 6 | -------------------------------------------------------------------------------- /sites/site-with-errors/index.markdown: -------------------------------------------------------------------------------- 1 | --- 2 | # Feel free to add content and custom Front Matter to this file. 3 | # To modify the layout, see https://jekyllrb.com/docs/themes/#overriding-theme-defaults 4 | 5 | layout: home 6 | --- 7 | -------------------------------------------------------------------------------- /.github/actions/fix/src/types.d.ts: -------------------------------------------------------------------------------- 1 | export type Issue = { 2 | url: string; 3 | nodeId?: string; 4 | }; 5 | 6 | export type PullRequest = { 7 | url: string; 8 | nodeId?: string; 9 | }; 10 | 11 | export type Fixing = { 12 | issue: Issue; 13 | pullRequest: PullRequest; 14 | }; 15 | -------------------------------------------------------------------------------- /.github/actions/auth/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "nodenext", 5 | "moduleResolution": "nodenext", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "rootDir": "src", 9 | "outDir": "dist" 10 | }, 11 | "include": [ 12 | "src/**/*.ts" 13 | ] 14 | } -------------------------------------------------------------------------------- /.github/actions/file/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "nodenext", 5 | "moduleResolution": "nodenext", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "rootDir": "src", 9 | "outDir": "dist" 10 | }, 11 | "include": [ 12 | "src/**/*.ts" 13 | ] 14 | } -------------------------------------------------------------------------------- /.github/actions/find/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "nodenext", 5 | "moduleResolution": "nodenext", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "rootDir": "src", 9 | "outDir": "dist" 10 | }, 11 | "include": [ 12 | "src/**/*.ts" 13 | ] 14 | } -------------------------------------------------------------------------------- /.github/actions/fix/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "nodenext", 5 | "moduleResolution": "nodenext", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "rootDir": "src", 9 | "outDir": "dist" 10 | }, 11 | "include": [ 12 | "src/**/*.ts" 13 | ] 14 | } -------------------------------------------------------------------------------- /.github/actions/file/src/isNewFiling.ts: -------------------------------------------------------------------------------- 1 | import type { Filing, NewFiling } from "./types.d.js"; 2 | 3 | export function isNewFiling(filing: Filing): filing is NewFiling { 4 | // A Filing without an issue is new 5 | return ( 6 | (!("issue" in filing) || !filing.issue?.url) && 7 | "findings" in filing && 8 | filing.findings.length > 0 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /.github/actions/file/src/isResolvedFiling.ts: -------------------------------------------------------------------------------- 1 | import type { Filing, ResolvedFiling } from "./types.d.js"; 2 | 3 | export function isResolvedFiling(filing: Filing): filing is ResolvedFiling { 4 | // A Filing without findings is resolved 5 | return ( 6 | (!("findings" in filing) || filing.findings.length === 0) && 7 | "issue" in filing && 8 | !!filing.issue?.url 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /.github/actions/file/src/isRepeatedFiling.ts: -------------------------------------------------------------------------------- 1 | import type { Filing, RepeatedFiling } from "./types.d.js"; 2 | 3 | export function isRepeatedFiling(filing: Filing): filing is RepeatedFiling { 4 | // A Filing with an issue and findings is a repeated filing 5 | return ( 6 | "findings" in filing && 7 | filing.findings.length > 0 && 8 | "issue" in filing && 9 | !!filing.issue?.url 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /.github/actions/gh-cache/save/README.md: -------------------------------------------------------------------------------- 1 | # save 2 | 3 | Commit a file or directory to an orphaned 'gh-cache' branch. 4 | 5 | ## Usage 6 | 7 | ### Inputs 8 | 9 | #### `path` 10 | 11 | **Required** Relative path to a file or directory to save. Allowed characters are `A-Za-z0-9._/-`. For example: `findings.json`. 12 | 13 | #### `token` 14 | 15 | **Required** Token with fine-grained permissions 'contents: write'. 16 | -------------------------------------------------------------------------------- /.github/actions/file/src/closeIssue.ts: -------------------------------------------------------------------------------- 1 | import type { Octokit } from '@octokit/core'; 2 | import { Issue } from './Issue.js'; 3 | 4 | export async function closeIssue(octokit: Octokit, { owner, repository, issueNumber }: Issue) { 5 | return octokit.request(`PATCH /repos/${owner}/${repository}/issues/${issueNumber}`, { 6 | owner, 7 | repository, 8 | issue_number: issueNumber, 9 | state: 'closed' 10 | }); 11 | } -------------------------------------------------------------------------------- /.github/actions/gh-cache/delete/README.md: -------------------------------------------------------------------------------- 1 | # delete 2 | 3 | Delete a file or directory from the orphaned 'gh-cache' branch 4 | 5 | ## Usage 6 | 7 | ### Inputs 8 | 9 | #### `path` 10 | 11 | **Required** Relative path to a file or directory to delete. Allowed characters are `A-Za-z0-9._/-`. For example: `findings.json`. 12 | 13 | #### `token` 14 | 15 | **Required** Token with fine-grained permissions 'contents: write'. 16 | -------------------------------------------------------------------------------- /.github/actions/file/src/reopenIssue.ts: -------------------------------------------------------------------------------- 1 | import type { Octokit } from '@octokit/core'; 2 | import type { Issue } from './Issue.js'; 3 | 4 | export async function reopenIssue(octokit: Octokit, { owner, repository, issueNumber}: Issue) { 5 | return octokit.request(`PATCH /repos/${owner}/${repository}/issues/${issueNumber}`, { 6 | owner, 7 | repository, 8 | issue_number: issueNumber, 9 | state: 'open' 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /.github/actions/auth/src/types.d.ts: -------------------------------------------------------------------------------- 1 | export type Cookie = { 2 | name: string; 3 | value: string; 4 | domain: string; 5 | path: string; 6 | expires?: number; 7 | httpOnly?: boolean; 8 | secure?: boolean; 9 | sameSite?: "Strict" | "Lax" | "None"; 10 | }; 11 | 12 | export type LocalStorage = { 13 | [origin: string]: { 14 | [key: string]: string; 15 | }; 16 | }; 17 | 18 | export type AuthContextOutput = { 19 | username?: string; 20 | password?: string; 21 | cookies?: Cookie[]; 22 | localStorage?: LocalStorage; 23 | }; 24 | -------------------------------------------------------------------------------- /sites/site-with-errors/404.html: -------------------------------------------------------------------------------- 1 | --- 2 | permalink: /404.html 3 | layout: page 4 | --- 5 | 6 | 19 | 20 |
21 |

404

22 | 23 |

Page not found :(

24 |

The requested page could not be found.

25 |
26 | -------------------------------------------------------------------------------- /.github/actions/gh-cache/restore/README.md: -------------------------------------------------------------------------------- 1 | # restore 2 | 3 | Checkout a file or directory from an orphaned 'gh-cache' branch. 4 | 5 | ## Usage 6 | 7 | ### Inputs 8 | 9 | #### `path` 10 | 11 | **Required** Relative path to a file or directory to restore. Allowed characters are `A-Za-z0-9._/-`. For example: `findings.json`. 12 | 13 | #### `token` 14 | 15 | **Required** Token with fine-grained permissions 'contents: read'. 16 | 17 | #### `fail_on_cache_miss` 18 | 19 | **Optional** Fail the workflow if cached item is not found. For example: `'true'`. Default: `'false'`. 20 | -------------------------------------------------------------------------------- /tests/types.d.ts: -------------------------------------------------------------------------------- 1 | export type Finding = { 2 | scannerType: string; 3 | ruleId: string; 4 | url: string; 5 | html: string; 6 | problemShort: string; 7 | problemUrl: string; 8 | solutionShort: string; 9 | solutionLong?: string; 10 | }; 11 | 12 | export type Issue = { 13 | id: number; 14 | nodeId: string; 15 | url: string; 16 | title: string; 17 | state?: "open" | "reopened" | "closed"; 18 | }; 19 | 20 | export type PullRequest = { 21 | url: string; 22 | nodeId: string; 23 | }; 24 | 25 | export type Result = { 26 | findings: Finding[]; 27 | issue: Issue; 28 | pullRequest: PullRequest; 29 | }; 30 | -------------------------------------------------------------------------------- /.github/actions/find/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "find", 3 | "version": "1.0.0", 4 | "description": "Finds potential accessibility gaps.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "scripts": { 8 | "start": "node bootstrap.js", 9 | "build": "tsc" 10 | }, 11 | "keywords": [], 12 | "author": "GitHub", 13 | "license": "MIT", 14 | "type": "module", 15 | "dependencies": { 16 | "@actions/core": "^2.0.1", 17 | "@axe-core/playwright": "^4.11.0", 18 | "playwright": "^1.57.0" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^25.0.2", 22 | "typescript": "^5.9.3" 23 | } 24 | } -------------------------------------------------------------------------------- /sites/site-with-errors/about.markdown: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | title: About 4 | permalink: /about/ 5 | --- 6 | 7 | This is the base Jekyll theme. You can find out more info about customizing your Jekyll theme, as well as basic Jekyll usage documentation at [jekyllrb.com](https://jekyllrb.com/) 8 | 9 | You can find the source code for Minima at GitHub: 10 | [jekyll][jekyll-organization] / 11 | [minima](https://github.com/jekyll/minima) 12 | 13 | You can find the source code for Jekyll at GitHub: 14 | [jekyll][jekyll-organization] / 15 | [jekyll](https://github.com/jekyll/jekyll) 16 | 17 | 18 | [jekyll-organization]: https://github.com/jekyll 19 | -------------------------------------------------------------------------------- /.github/actions/fix/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fix", 3 | "version": "1.0.0", 4 | "description": "Attempts to fix issues with Copilot.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "scripts": { 8 | "start": "node bootstrap.js", 9 | "build": "tsc" 10 | }, 11 | "keywords": [], 12 | "author": "GitHub", 13 | "license": "MIT", 14 | "type": "module", 15 | "dependencies": { 16 | "@actions/core": "^2.0.1", 17 | "@octokit/core": "^7.0.6", 18 | "@octokit/plugin-throttling": "^11.0.3" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^25.0.2", 22 | "typescript": "^5.9.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/actions/auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auth", 3 | "version": "1.0.0", 4 | "description": "Authenticate with Playwright and save session state. Supports HTTP Basic and form authentication.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "scripts": { 8 | "start": "node bootstrap.js", 9 | "build": "tsc" 10 | }, 11 | "keywords": [], 12 | "author": "GitHub", 13 | "license": "MIT", 14 | "type": "module", 15 | "dependencies": { 16 | "@actions/core": "^2.0.1", 17 | "playwright": "^1.57.0" 18 | }, 19 | "devDependencies": { 20 | "@types/node": "^25.0.2", 21 | "typescript": "^5.9.3" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/actions/file/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "file", 3 | "version": "1.0.0", 4 | "description": "Files GitHub issues to track potential accessibility gaps.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "scripts": { 8 | "start": "node bootstrap.js", 9 | "build": "tsc" 10 | }, 11 | "keywords": [], 12 | "author": "GitHub", 13 | "license": "MIT", 14 | "type": "module", 15 | "dependencies": { 16 | "@actions/core": "^2.0.1", 17 | "@octokit/core": "^7.0.6", 18 | "@octokit/plugin-throttling": "^11.0.3" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^25.0.2", 22 | "typescript": "^5.9.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/actions/find/action.yml: -------------------------------------------------------------------------------- 1 | name: "Find" 2 | description: "Finds potential accessibility gaps." 3 | 4 | inputs: 5 | urls: 6 | description: "Newline-delimited list of URLs to check for accessibility issues" 7 | required: true 8 | multiline: true 9 | auth_context: 10 | description: "Stringified JSON object containing 'username', 'password', 'cookies', and/or 'localStorage' from an authenticated session" 11 | required: false 12 | 13 | outputs: 14 | findings: 15 | description: "List of potential accessibility gaps, as stringified JSON" 16 | 17 | runs: 18 | using: "node24" 19 | main: "bootstrap.js" 20 | 21 | branding: 22 | icon: "compass" 23 | color: "blue" 24 | -------------------------------------------------------------------------------- /.github/actions/auth/action.yml: -------------------------------------------------------------------------------- 1 | name: "Auth" 2 | description: "Authenticate with Playwright and save session state. Supports HTTP Basic and form authentication." 3 | 4 | inputs: 5 | login_url: 6 | description: "Login page URL" 7 | required: true 8 | username: 9 | description: "Username" 10 | required: true 11 | password: 12 | description: "Password" 13 | required: true 14 | 15 | outputs: 16 | auth_context: 17 | description: "Stringified JSON object containing 'username', 'password', 'cookies', and/or 'localStorage' from an authenticated session" 18 | 19 | runs: 20 | using: "node24" 21 | main: "bootstrap.js" 22 | 23 | branding: 24 | icon: "lock" 25 | color: "blue" 26 | -------------------------------------------------------------------------------- /.github/actions/find/src/types.d.ts: -------------------------------------------------------------------------------- 1 | export type Finding = { 2 | url: string; 3 | html: string; 4 | problemShort: string; 5 | problemUrl: string; 6 | solutionShort: string; 7 | solutionLong?: string; 8 | }; 9 | 10 | export type Cookie = { 11 | name: string; 12 | value: string; 13 | domain: string; 14 | path: string; 15 | expires?: number; 16 | httpOnly?: boolean; 17 | secure?: boolean; 18 | sameSite?: "Strict" | "Lax" | "None"; 19 | }; 20 | 21 | export type LocalStorage = { 22 | [origin: string]: { 23 | [key: string]: string; 24 | }; 25 | }; 26 | 27 | export type AuthContextInput = { 28 | username?: string; 29 | password?: string; 30 | cookies?: Cookie[]; 31 | localStorage?: LocalStorage; 32 | }; 33 | -------------------------------------------------------------------------------- /.github/actions/fix/action.yml: -------------------------------------------------------------------------------- 1 | name: "Fix" 2 | description: "Attempts to fix issues with Copilot." 3 | 4 | inputs: 5 | issues: 6 | description: "List of issues to attempt to fix, as stringified JSON" 7 | required: true 8 | repository: 9 | description: "Repository (with owner) containing issues" 10 | required: true 11 | token: 12 | description: "Personal access token (PAT) with fine-grained permissions 'issues: write' and 'pull_requests: write'" 13 | required: true 14 | 15 | outputs: 16 | fixings: 17 | description: "List of pull requests filed (and their associated issues), as stringified JSON" 18 | 19 | runs: 20 | using: "node24" 21 | main: "bootstrap.js" 22 | 23 | branding: 24 | icon: "compass" 25 | color: "blue" 26 | -------------------------------------------------------------------------------- /.github/actions/gh-cache/cache/README.md: -------------------------------------------------------------------------------- 1 | # cache 2 | 3 | Cache/uncache strings using an orphaned 'gh-cache' branch. 4 | 5 | ## Usage 6 | 7 | ### Inputs 8 | 9 | #### `key` 10 | 11 | **Required** Identifier for the string to cache/uncache. 12 | 13 | #### `token` 14 | 15 | **Required** Token with fine-grained permissions 'contents: write'. 16 | 17 | #### `value` 18 | 19 | **Optional** String to cache. If provided, the existing (cached) value will be overwritten and then outputted. If not provided, the existing (cached) value will be outputted. 20 | 21 | #### `fail_on_cache_miss` 22 | 23 | **Optional** Fail the workflow if cached item is not found. For example: `'true'`. Default: `'false'`. 24 | 25 | ### Outputs 26 | 27 | #### `value` 28 | 29 | Cached string. 30 | -------------------------------------------------------------------------------- /.github/actions/file/src/types.d.ts: -------------------------------------------------------------------------------- 1 | export type Finding = { 2 | scannerType: string; 3 | ruleId: string; 4 | url: string; 5 | html: string; 6 | problemShort: string; 7 | problemUrl: string; 8 | solutionShort: string; 9 | solutionLong?: string; 10 | }; 11 | 12 | export type Issue = { 13 | id: number; 14 | nodeId: string; 15 | url: string; 16 | title: string; 17 | state?: "open" | "reopened" | "closed"; 18 | }; 19 | 20 | export type ResolvedFiling = { 21 | findings: never[]; 22 | issue: Issue; 23 | }; 24 | 25 | export type NewFiling = { 26 | findings: Finding[]; 27 | issue?: never; 28 | }; 29 | 30 | export type RepeatedFiling = { 31 | findings: Finding[]; 32 | issue: Issue; 33 | }; 34 | 35 | export type Filing = ResolvedFiling | NewFiling | RepeatedFiling; 36 | -------------------------------------------------------------------------------- /.github/actions/file/action.yml: -------------------------------------------------------------------------------- 1 | name: "File" 2 | description: "Files GitHub issues to track potential accessibility gaps." 3 | 4 | inputs: 5 | findings: 6 | description: "List of potential accessibility gaps, as stringified JSON" 7 | required: true 8 | repository: 9 | description: "Repository (with owner) to file issues in" 10 | required: true 11 | token: 12 | description: "Token with fine-grained permission 'issues: write'" 13 | required: true 14 | cached_filings: 15 | description: "Cached filings from previous runs, as stringified JSON. Without this, duplicate issues may be filed." 16 | required: false 17 | 18 | outputs: 19 | filings: 20 | description: "List of issues filed (and their associated finding(s)), as stringified JSON" 21 | 22 | runs: 23 | using: "node24" 24 | main: "bootstrap.js" 25 | 26 | branding: 27 | icon: "compass" 28 | color: "blue" 29 | -------------------------------------------------------------------------------- /.github/actions/find/README.md: -------------------------------------------------------------------------------- 1 | # find 2 | 3 | Finds potential accessibility gaps. 4 | 5 | ## Usage 6 | 7 | ### Inputs 8 | 9 | #### `urls` 10 | 11 | **Required** Newline-delimited list of URLs to check for accessibility issues. For example: 12 | 13 | ```txt 14 | https://primer.style 15 | https://primer.style/octicons/ 16 | ``` 17 | 18 | #### `auth_context` 19 | 20 | **Optional** Stringified JSON object containing `username`, `password`, `cookies`, and/or `localStorage` from an authenticated session. For example: `{"username":"some-user","password":"correct-horse-battery-staple","cookies":[{"name":"theme-preference","value":"light","domain":"primer.style","path":"/"}],"localStorage":{"https://primer.style":{"theme-preference":"light"}}}` 21 | 22 | ### Outputs 23 | 24 | #### `findings` 25 | 26 | List of potential accessibility gaps, as stringified JSON. For example: 27 | 28 | ```JS 29 | '[]' 30 | ``` 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "accessibility-scanner", 3 | "version": "0.0.0-development", 4 | "description": "Finds potential accessibility gaps, files GitHub issues to track them, and attempts to fix them with Copilot", 5 | "scripts": { 6 | "test": "vitest run tests/*.test.ts" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/github/accessibility-scanner.git" 11 | }, 12 | "author": "GitHub Inc.", 13 | "license": "MIT", 14 | "bugs": { 15 | "url": "https://github.com/github/accessibility-scanner/issues" 16 | }, 17 | "homepage": "https://github.com/github/accessibility-scanner#readme", 18 | "devDependencies": { 19 | "@actions/core": "^2.0.1", 20 | "@octokit/core": "^7.0.6", 21 | "@octokit/plugin-throttling": "^11.0.3", 22 | "@octokit/types": "^16.0.0", 23 | "@types/node": "^25.0.2", 24 | "vitest": "^4.0.15" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/actions/auth/README.md: -------------------------------------------------------------------------------- 1 | # auth 2 | 3 | Authenticate with Playwright and save session state. Supports HTTP Basic and form authentication. 4 | 5 | ## Usage 6 | 7 | ### Inputs 8 | 9 | #### `login_url` 10 | 11 | **Required** Login page URL. For example, `https://github.com/login` 12 | 13 | #### `username` 14 | 15 | **Required** Username. 16 | 17 | #### `password` 18 | 19 | **Required** Password. 20 | 21 | > [!IMPORTANT] 22 | > Don’t put passwords in your workflow as plain text; instead reference a [repository secret](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#creating-secrets-for-a-repository). 23 | 24 | ### Outputs 25 | 26 | #### `auth_context` 27 | 28 | Stringified JSON object containing `username`, `password`, `cookies`, and/or `localStorage` from an authenticated session. For example: `{"username":"some-user","password":"correct-horse-battery-staple","cookies":[{"name":"theme-preference","value":"light","domain":"primer.style","path":"/"}],"localStorage":{"https://primer.style":{"theme-preference":"light"}}}` 29 | -------------------------------------------------------------------------------- /.github/actions/file/README.md: -------------------------------------------------------------------------------- 1 | # file 2 | 3 | Files GitHub issues to track potential accessibility gaps. 4 | 5 | ## Usage 6 | 7 | ### Inputs 8 | 9 | #### `findings` 10 | 11 | **Required** List of potential accessibility gaps, as stringified JSON. For example: 12 | 13 | ```JS 14 | '[]' 15 | ``` 16 | 17 | #### `repository` 18 | 19 | **Required** Repository (with owner) to file issues in. For example: `primer/primer-docs`. 20 | 21 | #### `token` 22 | 23 | **Required** Token with fine-grained permission 'issues: write'. 24 | 25 | #### `cached_filings` 26 | 27 | **Optional** Cached filings from previous runs, as stringified JSON. Without this, duplicate issues may be filed. For example: `'[{"findings":[],"issue":{"id":1,"nodeId":"SXNzdWU6MQ==","url":"https://github.com/github/docs/issues/123","title":"Accessibility issue: 1"}}]'` 28 | 29 | ### Outputs 30 | 31 | #### `filings` 32 | 33 | List of issues filed (and their associated finding(s)), as stringified JSON. For example: `'[{"findings":[],"issue":{"id":1,"nodeId":"SXNzdWU6MQ==","url":"https://github.com/github/docs/issues/123","title":"Accessibility issue: 1"}}]'` 34 | -------------------------------------------------------------------------------- /.github/actions/fix/README.md: -------------------------------------------------------------------------------- 1 | # fix 2 | 3 | Attempts to fix issues with Copilot. 4 | 5 | ## Usage 6 | 7 | ### Inputs 8 | 9 | #### `issues` 10 | 11 | **Required** List of issues to attempt to fix—including, at a minimum, their `url`s—as stringified JSON. For example: `'[{"url":"https://github.com/github/docs/issues/123"},{"nodeId":"SXNzdWU6Mg==","url":"https://github.com/github/docs/issues/124"},{"id":4,"nodeId":"SXNzdWU6NA==","url":"https://github.com/github/docs/issues/126","title":"Accessibility issue: 4"}]'`. 12 | 13 | #### `repository` 14 | 15 | **Required** Repository (with owner) to file issues in. For example: `primer/primer-docs`. 16 | 17 | #### `token` 18 | 19 | **Required** Personal access token (PAT) with fine-grained permissions 'issues: write' and 'pull_requests: write'. 20 | 21 | ### Outputs 22 | 23 | #### `fixings` 24 | 25 | List of pull requests filed (and their associated issues), as stringified JSON. For example: `'[{"issue":{"id":1,"nodeId":"SXNzdWU6MQ==","url":"https://github.com/github/docs/issues/123","title":"Accessibility issue: 1"},"pullRequest":{"url":"https://github.com/github/docs/pulls/124"}}]'` 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 GitHub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/actions/fix/src/retry.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Sleep for a given number of milliseconds. 3 | * @param ms Time to sleep, in milliseconds. 4 | */ 5 | function sleep(ms: number): Promise { 6 | return new Promise((resolve) => setTimeout(() => resolve(), ms)); 7 | } 8 | 9 | /** 10 | * Retry a function with exponential backoff. 11 | * @param fn The function to retry. 12 | * @param maxAttempts The maximum number of retry attempts. 13 | * @param baseDelay The base delay between attempts. 14 | * @param attempt The current attempt number. 15 | * @returns The result of the function or undefined if all attempts fail. 16 | */ 17 | export async function retry( 18 | fn: () => Promise | T | null | undefined, 19 | maxAttempts = 6, 20 | baseDelay = 2000, 21 | attempt = 1 22 | ): Promise { 23 | const value = await fn(); 24 | if (value != null) return value; 25 | if (attempt >= maxAttempts) return undefined; 26 | /** Exponential backoff, capped at 30s */ 27 | const delay = Math.min(30000, baseDelay * 2 ** (attempt - 1)); 28 | /** ±10% jitter */ 29 | const jitter = 1 + (Math.random() - 0.5) * 0.2; 30 | await sleep(Math.round(delay * jitter)); 31 | return retry(fn, maxAttempts, baseDelay, attempt + 1); 32 | } 33 | -------------------------------------------------------------------------------- /.github/actions/find/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { AuthContextInput } from "./types.js"; 2 | import core from "@actions/core"; 3 | import { AuthContext } from "./AuthContext.js"; 4 | import { findForUrl } from "./findForUrl.js"; 5 | 6 | export default async function () { 7 | core.info("Starting 'find' action"); 8 | const urls = core.getMultilineInput("urls", { required: true }); 9 | core.debug(`Input: 'urls: ${JSON.stringify(urls)}'`); 10 | const authContextInput: AuthContextInput = JSON.parse( 11 | core.getInput("auth_context", { required: false }) || "{}" 12 | ); 13 | const authContext = new AuthContext(authContextInput); 14 | 15 | let findings = []; 16 | for (const url of urls) { 17 | core.info(`Preparing to scan ${url}`); 18 | const findingsForUrl = await findForUrl(url, authContext); 19 | if (findingsForUrl.length === 0) { 20 | core.info(`No accessibility gaps were found on ${url}`); 21 | continue; 22 | } 23 | findings.push(...findingsForUrl); 24 | core.info(`Found ${findingsForUrl.length} findings for ${url}`); 25 | } 26 | 27 | core.setOutput("findings", JSON.stringify(findings)); 28 | core.debug(`Output: 'findings: ${JSON.stringify(findings)}'`); 29 | core.info(`Found ${findings.length} findings in total`); 30 | core.info("Finished 'find' action"); 31 | } 32 | -------------------------------------------------------------------------------- /.github/actions/fix/src/Issue.ts: -------------------------------------------------------------------------------- 1 | import { Issue as IssueInput } from "./types.d.js"; 2 | 3 | export class Issue implements IssueInput { 4 | /** 5 | * Extracts owner, repository, and issue number from a GitHub issue URL. 6 | * @param issueUrl A GitHub issue URL (e.g. `https://github.com/owner/repo/issues/42`). 7 | * @returns An object with `owner`, `repository`, and `issueNumber` keys. 8 | * @throws The provided URL is unparseable due to its unexpected format. 9 | */ 10 | static parseIssueUrl(issueUrl: string): { owner: string; repository: string; issueNumber: number } { 11 | const { owner, repository, issueNumber } = /\/(?[^/]+)\/(?[^/]+)\/issues\/(?\d+)(?:[/?#]|$)/.exec(issueUrl)?.groups || {}; 12 | if (!owner || !repository || !issueNumber) { 13 | throw new Error(`Could not parse issue URL: ${issueUrl}`); 14 | } 15 | return { owner, repository, issueNumber: Number(issueNumber) } 16 | } 17 | 18 | url: string; 19 | nodeId?: string; 20 | 21 | get owner(): string { 22 | return Issue.parseIssueUrl(this.url).owner; 23 | } 24 | 25 | get repository(): string { 26 | return Issue.parseIssueUrl(this.url).repository; 27 | } 28 | 29 | get issueNumber(): number { 30 | return Issue.parseIssueUrl(this.url).issueNumber; 31 | } 32 | 33 | constructor({url, nodeId}: IssueInput) { 34 | this.url = url; 35 | this.nodeId = nodeId; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | ## How to get help 4 | 5 | This project uses GitHub Issues to track bugs, feature requests, and support inquiries. Please search the existing issues before filing new issues to avoid duplicates. 6 | 7 | For help or questions about using this project, please open an issue for support requests, usage questions, or general inquiries. At this time, GitHub Discussions are not enabled. All communication should occur via issues. 8 | 9 | For information about contributing to this project, including how to create issues and what types of contributions we accept, please refer to the [CONTRIBUTING](./CONTRIBUTING.md) file. 10 | 11 | ## Project maintenance status 12 | 13 | This repo `github/accessibility-scanner` is under active development and maintained by GitHub staff during the public preview state. We will do our best to respond to support, feature requests, and community questions in a timely manner. 14 | 15 | ## Important notice 16 | 17 | The a11y scanner is currently in public preview and feature development work is still ongoing. This project cannot guarantee that code fixes or suggestions produced by GitHub Copilot will be fully accessible. Please use caution when applying the suggestions it provides. Always confirm or verify GitHub Copilot's recommendations with an accessibility subject matter expert before using them in production. 18 | 19 | ## GitHub support policy 20 | 21 | Support for this project is limited to the resources listed above (GitHub Issues). 22 | -------------------------------------------------------------------------------- /sites/site-with-errors/_posts/2025-07-30-welcome-to-jekyll.markdown: -------------------------------------------------------------------------------- 1 | --- 2 | layout: post 3 | title: "Welcome to Jekyll!" 4 | date: 2025-07-30 13:32:33 -0400 5 | categories: jekyll update 6 | --- 7 | You’ll find this post in your `_posts` directory. Go ahead and edit it and re-build the site to see your changes. You can rebuild the site in many different ways, but the most common way is to run `jekyll serve`, which launches a web server and auto-regenerates your site when a file is updated. 8 | 9 | Jekyll requires blog post files to be named according to the following format: 10 | 11 | `YEAR-MONTH-DAY-title.MARKUP` 12 | 13 | Where `YEAR` is a four-digit number, `MONTH` and `DAY` are both two-digit numbers, and `MARKUP` is the file extension representing the format used in the file. After that, include the necessary front matter. Take a look at the source for this post to get an idea about how it works. 14 | 15 | Jekyll also offers powerful support for code snippets: 16 | 17 | {% highlight ruby %} 18 | def print_hi(name) 19 | puts "Hi, #{name}" 20 | end 21 | print_hi('Tom') 22 | #=> prints 'Hi, Tom' to STDOUT. 23 | {% endhighlight %} 24 | 25 | Check out the [Jekyll docs][jekyll-docs] for more info on how to get the most out of Jekyll. File all bugs/feature requests at [Jekyll’s GitHub repo][jekyll-gh]. If you have questions, you can ask them on [Jekyll Talk][jekyll-talk]. 26 | 27 | [jekyll-docs]: https://jekyllrb.com/docs/home 28 | [jekyll-gh]: https://github.com/jekyll/jekyll 29 | [jekyll-talk]: https://talk.jekyllrb.com/ -------------------------------------------------------------------------------- /sites/site-with-errors/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | # Hello! This is where you manage which Jekyll version is used to run. 3 | # When you want to use a different version, change it below, save the 4 | # file and run `bundle install`. Run Jekyll with `bundle exec`, like so: 5 | # 6 | # bundle exec jekyll serve 7 | # 8 | # This will help ensure the proper Jekyll version is running. 9 | # Happy Jekylling! 10 | gem "jekyll", "~> 4.4.1" 11 | # This is the default theme for new Jekyll sites. You may change this to anything you like. 12 | gem "minima", "~> 2.5" 13 | # If you want to use GitHub Pages, remove the "gem "jekyll"" above and 14 | # uncomment the line below. To upgrade, run `bundle update github-pages`. 15 | # gem "github-pages", group: :jekyll_plugins 16 | # If you have any plugins, put them here! 17 | group :jekyll_plugins do 18 | gem "jekyll-feed", "~> 0.12" 19 | end 20 | 21 | # Windows and JRuby does not include zoneinfo files, so bundle the tzinfo-data gem 22 | # and associated library. 23 | platforms :mingw, :x64_mingw, :mswin, :jruby do 24 | gem "tzinfo", ">= 1", "< 3" 25 | gem "tzinfo-data" 26 | end 27 | 28 | # Performance-booster for watching directories on Windows 29 | gem "wdm", "~> 0.1", :platforms => [:mingw, :x64_mingw, :mswin] 30 | 31 | # Lock `http_parser.rb` gem to `v0.6.x` on JRuby builds since newer versions of the gem 32 | # do not have a Java counterpart. 33 | gem "http_parser.rb", "~> 0.6.0", :platforms => [:jruby] 34 | 35 | # Web server 36 | gem "rack", "~> 3.2" 37 | gem "puma", "~> 7.1" -------------------------------------------------------------------------------- /.github/actions/find/src/findForUrl.ts: -------------------------------------------------------------------------------- 1 | import type { Finding } from './types.d.js'; 2 | import AxeBuilder from '@axe-core/playwright' 3 | import playwright from 'playwright'; 4 | import { AuthContext } from './AuthContext.js'; 5 | 6 | export async function findForUrl(url: string, authContext?: AuthContext): Promise { 7 | const browser = await playwright.chromium.launch({ headless: true, executablePath: process.env.CI ? '/usr/bin/google-chrome' : undefined }); 8 | const contextOptions = authContext?.toPlaywrightBrowserContextOptions() ?? {}; 9 | const context = await browser.newContext(contextOptions); 10 | const page = await context.newPage(); 11 | await page.goto(url); 12 | console.log(`Scanning ${page.url()}`); 13 | 14 | let findings: Finding[] = []; 15 | try { 16 | const rawFindings = await new AxeBuilder({ page }).analyze(); 17 | findings = rawFindings.violations.map(violation => ({ 18 | scannerType: 'axe', 19 | url, 20 | html: violation.nodes[0].html.replace(/'/g, "'"), 21 | problemShort: violation.help.toLowerCase().replace(/'/g, "'"), 22 | problemUrl: violation.helpUrl.replace(/'/g, "'"), 23 | ruleId: violation.id, 24 | solutionShort: violation.description.toLowerCase().replace(/'/g, "'"), 25 | solutionLong: violation.nodes[0].failureSummary?.replace(/'/g, "'") 26 | })); 27 | } catch (e) { 28 | // do something with the error 29 | } 30 | await context.close(); 31 | await browser.close(); 32 | return findings; 33 | } 34 | -------------------------------------------------------------------------------- /.github/actions/auth/bootstrap.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | //@ts-check 3 | 4 | import fs from 'node:fs' 5 | import * as url from 'node:url' 6 | import { spawn } from 'node:child_process' 7 | 8 | function spawnPromisified(command, args, { quiet = false, ...options } = {}) { 9 | return new Promise((resolve, reject) => { 10 | const proc = spawn(command, args, options) 11 | proc.stdout.setEncoding('utf8') 12 | proc.stdout.on('data', (data) => { 13 | if (!quiet) { 14 | console.log(data) 15 | } 16 | }) 17 | proc.stderr.setEncoding('utf8') 18 | proc.stderr.on('data', (data) => { 19 | console.error(data) 20 | }) 21 | proc.on('close', (code) => { 22 | if (code !== 0) { 23 | reject(code) 24 | } else { 25 | resolve(code) 26 | } 27 | }) 28 | }) 29 | } 30 | 31 | await (async () => { 32 | // If dependencies are not vendored-in, install them at runtime. 33 | try { 34 | await fs.accessSync( 35 | url.fileURLToPath(new URL('./node_modules', import.meta.url)), 36 | fs.constants.R_OK 37 | ) 38 | } catch { 39 | try { 40 | await spawnPromisified('npm', ['ci'], { 41 | cwd: url.fileURLToPath(new URL('.', import.meta.url)), 42 | quiet: true 43 | }) 44 | } catch { 45 | process.exit(1) 46 | } 47 | } finally { 48 | // Compile TypeScript. 49 | try { 50 | await spawnPromisified('npm', ['run', 'build'], { 51 | cwd: url.fileURLToPath(new URL('.', import.meta.url)), 52 | quiet: true 53 | }) 54 | } catch { 55 | process.exit(1) 56 | } 57 | // Run the main script. 58 | const action = await import('./dist/index.js') 59 | await action.default() 60 | } 61 | })() -------------------------------------------------------------------------------- /.github/actions/file/bootstrap.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | //@ts-check 3 | 4 | import fs from 'node:fs' 5 | import * as url from 'node:url' 6 | import { spawn } from 'node:child_process' 7 | 8 | function spawnPromisified(command, args, { quiet = false, ...options } = {}) { 9 | return new Promise((resolve, reject) => { 10 | const proc = spawn(command, args, options) 11 | proc.stdout.setEncoding('utf8') 12 | proc.stdout.on('data', (data) => { 13 | if (!quiet) { 14 | console.log(data) 15 | } 16 | }) 17 | proc.stderr.setEncoding('utf8') 18 | proc.stderr.on('data', (data) => { 19 | console.error(data) 20 | }) 21 | proc.on('close', (code) => { 22 | if (code !== 0) { 23 | reject(code) 24 | } else { 25 | resolve(code) 26 | } 27 | }) 28 | }) 29 | } 30 | 31 | await (async () => { 32 | // If dependencies are not vendored-in, install them at runtime. 33 | try { 34 | await fs.accessSync( 35 | url.fileURLToPath(new URL('./node_modules', import.meta.url)), 36 | fs.constants.R_OK 37 | ) 38 | } catch { 39 | try { 40 | await spawnPromisified('npm', ['ci'], { 41 | cwd: url.fileURLToPath(new URL('.', import.meta.url)), 42 | quiet: true 43 | }) 44 | } catch { 45 | process.exit(1) 46 | } 47 | } finally { 48 | // Compile TypeScript. 49 | try { 50 | await spawnPromisified('npm', ['run', 'build'], { 51 | cwd: url.fileURLToPath(new URL('.', import.meta.url)), 52 | quiet: true 53 | }) 54 | } catch { 55 | process.exit(1) 56 | } 57 | // Run the main script. 58 | const action = await import('./dist/index.js') 59 | await action.default() 60 | } 61 | })() -------------------------------------------------------------------------------- /.github/actions/find/bootstrap.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | //@ts-check 3 | 4 | import fs from 'node:fs' 5 | import * as url from 'node:url' 6 | import { spawn } from 'node:child_process' 7 | 8 | function spawnPromisified(command, args, { quiet = false, ...options } = {}) { 9 | return new Promise((resolve, reject) => { 10 | const proc = spawn(command, args, options) 11 | proc.stdout.setEncoding('utf8') 12 | proc.stdout.on('data', (data) => { 13 | if (!quiet) { 14 | console.log(data) 15 | } 16 | }) 17 | proc.stderr.setEncoding('utf8') 18 | proc.stderr.on('data', (data) => { 19 | console.error(data) 20 | }) 21 | proc.on('close', (code) => { 22 | if (code !== 0) { 23 | reject(code) 24 | } else { 25 | resolve(code) 26 | } 27 | }) 28 | }) 29 | } 30 | 31 | await (async () => { 32 | // If dependencies are not vendored-in, install them at runtime. 33 | try { 34 | await fs.accessSync( 35 | url.fileURLToPath(new URL('./node_modules', import.meta.url)), 36 | fs.constants.R_OK 37 | ) 38 | } catch { 39 | try { 40 | await spawnPromisified('npm', ['ci'], { 41 | cwd: url.fileURLToPath(new URL('.', import.meta.url)), 42 | quiet: true 43 | }) 44 | } catch { 45 | process.exit(1) 46 | } 47 | } finally { 48 | // Compile TypeScript. 49 | try { 50 | await spawnPromisified('npm', ['run', 'build'], { 51 | cwd: url.fileURLToPath(new URL('.', import.meta.url)), 52 | quiet: true 53 | }) 54 | } catch { 55 | process.exit(1) 56 | } 57 | // Run the main script. 58 | const action = await import('./dist/index.js') 59 | await action.default() 60 | } 61 | })() -------------------------------------------------------------------------------- /.github/actions/fix/bootstrap.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | //@ts-check 3 | 4 | import fs from 'node:fs' 5 | import * as url from 'node:url' 6 | import { spawn } from 'node:child_process' 7 | 8 | function spawnPromisified(command, args, { quiet = false, ...options } = {}) { 9 | return new Promise((resolve, reject) => { 10 | const proc = spawn(command, args, options) 11 | proc.stdout.setEncoding('utf8') 12 | proc.stdout.on('data', (data) => { 13 | if (!quiet) { 14 | console.log(data) 15 | } 16 | }) 17 | proc.stderr.setEncoding('utf8') 18 | proc.stderr.on('data', (data) => { 19 | console.error(data) 20 | }) 21 | proc.on('close', (code) => { 22 | if (code !== 0) { 23 | reject(code) 24 | } else { 25 | resolve(code) 26 | } 27 | }) 28 | }) 29 | } 30 | 31 | await (async () => { 32 | // If dependencies are not vendored-in, install them at runtime. 33 | try { 34 | await fs.accessSync( 35 | url.fileURLToPath(new URL('./node_modules', import.meta.url)), 36 | fs.constants.R_OK 37 | ) 38 | } catch { 39 | try { 40 | await spawnPromisified('npm', ['ci'], { 41 | cwd: url.fileURLToPath(new URL('.', import.meta.url)), 42 | quiet: true 43 | }) 44 | } catch { 45 | process.exit(1) 46 | } 47 | } finally { 48 | // Compile TypeScript. 49 | try { 50 | await spawnPromisified('npm', ['run', 'build'], { 51 | cwd: url.fileURLToPath(new URL('.', import.meta.url)), 52 | quiet: true 53 | }) 54 | } catch { 55 | process.exit(1) 56 | } 57 | // Run the main script. 58 | const action = await import('./dist/index.js') 59 | await action.default() 60 | } 61 | })() -------------------------------------------------------------------------------- /.github/actions/fix/src/getLinkedPR.ts: -------------------------------------------------------------------------------- 1 | import type { Octokit } from "@octokit/core"; 2 | import { Issue } from "./Issue.js"; 3 | 4 | export async function getLinkedPR( 5 | octokit: Octokit, 6 | { owner, repository, issueNumber }: Issue 7 | ) { 8 | // Check whether issues can be assigned to Copilot 9 | const response = await octokit.graphql<{ 10 | repository?: { 11 | issue?: { 12 | timelineItems?: { 13 | nodes: ( 14 | | { source: { id: string; url: string; title: string } } 15 | | { subject: { id: string; url: string; title: string } } 16 | )[]; 17 | }; 18 | }; 19 | }; 20 | }>( 21 | `query($owner: String!, $repository: String!, $issueNumber: Int!) { 22 | repository(owner: $owner, name: $repository) { 23 | issue(number: $issueNumber) { 24 | timelineItems(first: 100, itemTypes: [CONNECTED_EVENT, CROSS_REFERENCED_EVENT]) { 25 | nodes { 26 | ... on CrossReferencedEvent { source { ... on PullRequest { id url title } } } 27 | ... on ConnectedEvent { subject { ... on PullRequest { id url title } } } 28 | } 29 | } 30 | } 31 | } 32 | }`, 33 | { owner, repository, issueNumber } 34 | ); 35 | const timelineNodes = response?.repository?.issue?.timelineItems?.nodes || []; 36 | const pullRequest: { id: string; url: string; title: string } | undefined = 37 | timelineNodes 38 | .map((node) => { 39 | if ("source" in node && node.source?.url) return node.source; 40 | if ("subject" in node && node.subject?.url) return node.subject; 41 | return undefined; 42 | }) 43 | .find((pr) => !!pr); 44 | return pullRequest; 45 | } 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | [fork]: https://github.com/github/accessibility-scanner/fork 4 | [pr]: https://github.com/github/accessibility-scanner/compare 5 | 6 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 7 | 8 | Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE). 9 | 10 | Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. 11 | 12 | ## How you can contribute 13 | 14 | We are not accepting pull requests at this time. However, we welcome and encourage you to: 15 | 16 | - **Test the a11y scanner** and share your experiences 17 | - **Create issues** to report bugs, unexpected behavior, or documentation improvements 18 | - **Share feedback** about your use cases and workflows 19 | 20 | ### Creating issues 21 | 22 | When creating an issue, please provide: 23 | 24 | - A clear and descriptive title 25 | - Detailed steps to reproduce (for bugs) 26 | - Expected vs. actual behavior 27 | - Your environment details (OS, browser, GitHub Actions runner, etc.) 28 | - Any relevant logs or screenshots 29 | 30 | ### Feature requests 31 | 32 | While we welcome feature requests, please note that we cannot guarantee that any feature request will be implemented or prioritized. We review all suggestions and consider them as part of our ongoing development process. 33 | 34 | ## Resources 35 | 36 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 37 | - [GitHub Support](https://support.github.com) 38 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "npm" 7 | directories: 8 | - "/" 9 | - "/.github/actions/auth" 10 | - "/.github/actions/find" 11 | - "/.github/actions/file" 12 | - "/.github/actions/fix" 13 | groups: 14 | # Open PRs for each major version and security update 15 | # Open an aggregate PR for all minor and patch version updates 16 | npm-minor-and-patch: 17 | applies-to: version-updates 18 | patterns: 19 | - "*" 20 | update-types: 21 | - "minor" 22 | - "patch" 23 | schedule: 24 | interval: "weekly" 25 | - package-ecosystem: "github-actions" 26 | directories: 27 | - "/" 28 | - "/.github/actions/gh-cache/cache" 29 | - "/.github/actions/gh-cache/delete" 30 | - "/.github/actions/gh-cache/restore" 31 | - "/.github/actions/gh-cache/save" 32 | groups: 33 | # Open an aggregate PR for all GitHub Actions updates 34 | github-actions: 35 | patterns: 36 | - "*" 37 | schedule: 38 | interval: "weekly" 39 | - package-ecosystem: "bundler" 40 | directory: "/sites/site-with-errors" 41 | groups: 42 | # Open PRs for each major version and security update 43 | # Open an aggregate PR for all minor and patch version updates 44 | bundler-minor-and-patch: 45 | applies-to: version-updates 46 | patterns: 47 | - "*" 48 | update-types: 49 | - "minor" 50 | - "patch" 51 | schedule: 52 | interval: "weekly" 53 | -------------------------------------------------------------------------------- /.github/actions/file/src/updateFilingsWithNewFindings.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Finding, 3 | ResolvedFiling, 4 | NewFiling, 5 | RepeatedFiling, 6 | Filing, 7 | } from "./types.d.js"; 8 | 9 | function getFilingKey(filing: ResolvedFiling | RepeatedFiling): string { 10 | return filing.issue.url; 11 | } 12 | 13 | function getFindingKey(finding: Finding): string { 14 | return `${finding.url};${finding.ruleId};${finding.html}`; 15 | } 16 | 17 | export function updateFilingsWithNewFindings( 18 | filings: (ResolvedFiling | RepeatedFiling)[], 19 | findings: Finding[] 20 | ): Filing[] { 21 | const filingKeys: { 22 | [key: string]: ResolvedFiling | RepeatedFiling; 23 | } = {}; 24 | const findingKeys: { [key: string]: string } = {}; 25 | const newFilings: NewFiling[] = []; 26 | 27 | // Create maps for filing and finding data from previous runs, for quick lookups 28 | for (const filing of filings) { 29 | // Reset findings to empty array; we'll repopulate it as we find matches 30 | filingKeys[getFilingKey(filing)] = { 31 | issue: filing.issue, 32 | findings: [], 33 | }; 34 | for (const finding of filing.findings) { 35 | findingKeys[getFindingKey(finding)] = getFilingKey(filing); 36 | } 37 | } 38 | 39 | for (const finding of findings) { 40 | const filingKey = findingKeys[getFindingKey(finding)]; 41 | if (filingKey) { 42 | // This finding already has an associated filing; add it to that filing's findings 43 | (filingKeys[filingKey] as RepeatedFiling).findings.push(finding); 44 | } else { 45 | // This finding is new; create a new entry with no associated issue yet 46 | newFilings.push({ findings: [finding] }); 47 | } 48 | } 49 | 50 | const updatedFilings = Object.values(filingKeys); 51 | return [...updatedFilings, ...newFilings]; 52 | } 53 | -------------------------------------------------------------------------------- /.github/actions/file/src/Issue.ts: -------------------------------------------------------------------------------- 1 | import type { Issue as IssueInput } from "./types.d.js"; 2 | 3 | export class Issue implements IssueInput { 4 | #url!: string; 5 | #parsedUrl!: { 6 | owner: string; 7 | repository: string; 8 | issueNumber: number; 9 | }; 10 | nodeId: string; 11 | id: number; 12 | title: string; 13 | state?: "open" | "reopened" | "closed"; 14 | 15 | constructor({ url, nodeId, id, title, state }: IssueInput) { 16 | this.url = url; 17 | this.nodeId = nodeId; 18 | this.id = id; 19 | this.title = title; 20 | this.state = state; 21 | } 22 | 23 | set url(newUrl: string) { 24 | this.#url = newUrl; 25 | this.#parsedUrl = this.#parseUrl(); 26 | } 27 | 28 | get url(): string { 29 | return this.#url; 30 | } 31 | 32 | get owner(): string { 33 | return this.#parsedUrl.owner; 34 | } 35 | 36 | get repository(): string { 37 | return this.#parsedUrl.repository; 38 | } 39 | 40 | get issueNumber(): number { 41 | return this.#parsedUrl.issueNumber; 42 | } 43 | 44 | /** 45 | * Extracts owner, repository, and issue number from the Issue instance’s GitHub issue URL. 46 | * @returns An object with `owner`, `repository`, and `issueNumber` keys. 47 | * @throws The provided URL is unparseable due to its unexpected format. 48 | */ 49 | #parseUrl(): { 50 | owner: string; 51 | repository: string; 52 | issueNumber: number; 53 | } { 54 | const { owner, repository, issueNumber } = 55 | /\/(?[^/]+)\/(?[^/]+)\/issues\/(?\d+)(?:[/?#]|$)/.exec( 56 | this.#url 57 | )?.groups || {}; 58 | if (!owner || !repository || !issueNumber) { 59 | throw new Error(`Could not parse issue URL: ${this.#url}`); 60 | } 61 | return { owner, repository, issueNumber: Number(issueNumber) }; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | Thanks for helping make GitHub safe for everyone. 2 | 3 | # Security 4 | 5 | GitHub takes the security of our software products and services seriously, including all of the open source code repositories managed through our GitHub organizations, such as [GitHub](https://github.com/GitHub). 6 | 7 | Even though [open source repositories are outside of the scope of our bug bounty program](https://bounty.github.com/index.html#scope) and therefore not eligible for bounty rewards, we will ensure that your finding gets passed along to the appropriate maintainers for remediation. 8 | 9 | ## Reporting Security Issues 10 | 11 | If you believe you have found a security vulnerability in any GitHub-owned repository, please report it to us through coordinated disclosure. 12 | 13 | **Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** 14 | 15 | Instead, please send an email to opensource-security[@]github.com. 16 | 17 | Please include as much of the information listed below as you can to help us better understand and resolve the issue: 18 | 19 | * The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting) 20 | * Full paths of source file(s) related to the manifestation of the issue 21 | * The location of the affected source code (tag/branch/commit or direct URL) 22 | * Any special configuration required to reproduce the issue 23 | * Step-by-step instructions to reproduce the issue 24 | * Proof-of-concept or exploit code (if possible) 25 | * Impact of the issue, including how an attacker might exploit the issue 26 | 27 | This information will help us triage your report more quickly. 28 | 29 | ## Policy 30 | 31 | See [GitHub's Safe Harbor Policy](https://docs.github.com/en/site-policy/security-policies/github-bug-bounty-program-legal-safe-harbor#1-safe-harbor-terms) 32 | -------------------------------------------------------------------------------- /.github/actions/find/src/AuthContext.ts: -------------------------------------------------------------------------------- 1 | import type playwright from "playwright"; 2 | import type { Cookie, LocalStorage, AuthContextInput } from "./types.js"; 3 | 4 | export class AuthContext implements AuthContextInput { 5 | readonly username?: string; 6 | readonly password?: string; 7 | readonly cookies?: Cookie[]; 8 | readonly localStorage?: LocalStorage; 9 | 10 | constructor({ username, password, cookies, localStorage }: AuthContextInput) { 11 | this.username = username; 12 | this.password = password; 13 | this.cookies = cookies; 14 | this.localStorage = localStorage; 15 | } 16 | 17 | toPlaywrightBrowserContextOptions(): playwright.BrowserContextOptions { 18 | const playwrightBrowserContextOptions: playwright.BrowserContextOptions = 19 | {}; 20 | if (this.username && this.password) { 21 | playwrightBrowserContextOptions.httpCredentials = { 22 | username: this.username, 23 | password: this.password, 24 | }; 25 | } 26 | if (this.cookies || this.localStorage) { 27 | playwrightBrowserContextOptions.storageState = { 28 | // Add default values for fields Playwright requires which aren’t actually required by the Cookie API. 29 | cookies: 30 | this.cookies?.map((cookie) => ({ 31 | expires: -1, 32 | httpOnly: false, 33 | secure: false, 34 | sameSite: "Lax", 35 | ...cookie, 36 | })) ?? [], 37 | // Transform the localStorage object into the shape Playwright expects. 38 | origins: 39 | Object.entries(this.localStorage ?? {}).map(([origin, kv]) => ({ 40 | origin, 41 | localStorage: Object.entries(kv).map(([name, value]) => ({ 42 | name, 43 | value, 44 | })), 45 | })) ?? [], 46 | }; 47 | } 48 | return playwrightBrowserContextOptions; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /sites/site-with-errors/config.ru: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | require 'rack/auth/basic' 3 | require 'rack/static' 4 | 5 | # Basic authentication middleware 6 | use Rack::Auth::Basic, "Protected Area" do |username, password| 7 | expected_username = ENV['TEST_USERNAME'] 8 | expected_password = ENV['TEST_PASSWORD'] 9 | 10 | # Check if environment variables are set 11 | if expected_username.nil? || expected_password.nil? 12 | puts "Warning: TEST_USERNAME and/or TEST_PASSWORD environment variables not set" 13 | false 14 | else 15 | username == expected_username && password == expected_password 16 | end 17 | end 18 | 19 | # Serve static files from _site directory 20 | use Rack::Static, 21 | urls: %w[/], 22 | root: File.expand_path('_site', __dir__), 23 | index: 'index.html' 24 | 25 | # Fallback for requests that don't match static files 26 | run lambda { |env| 27 | path = env['PATH_INFO'] 28 | 29 | # Try to serve the requested file 30 | file_path = File.join(File.expand_path('_site', __dir__), path) 31 | 32 | # If it's a directory, try to serve index.html 33 | if File.directory?(file_path) 34 | index_path = File.join(file_path, 'index.html') 35 | if File.exist?(index_path) 36 | [200, {'Content-Type' => 'text/html'}, [File.read(index_path)]] 37 | else 38 | [404, {'Content-Type' => 'text/html'}, [File.read(File.join(File.expand_path('_site', __dir__), '404.html'))]] 39 | end 40 | elsif File.exist?(file_path) 41 | content_type = case File.extname(file_path) 42 | when '.html' then 'text/html' 43 | when '.css' then 'text/css' 44 | when '.js' then 'application/javascript' 45 | when '.xml' then 'application/xml' 46 | else 'text/plain' 47 | end 48 | [200, {'Content-Type' => content_type}, [File.read(file_path)]] 49 | else 50 | # Serve 404 page 51 | [404, {'Content-Type' => 'text/html'}, [File.read(File.join(File.expand_path('_site', __dir__), '404.html'))]] 52 | end 53 | } -------------------------------------------------------------------------------- /sites/site-with-errors/_config.yml: -------------------------------------------------------------------------------- 1 | # Welcome to Jekyll! 2 | # 3 | # This config file is meant for settings that affect your whole blog, values 4 | # which you are expected to set up once and rarely edit after that. If you find 5 | # yourself editing this file very often, consider using Jekyll's data files 6 | # feature for the data you need to update frequently. 7 | # 8 | # For technical reasons, this file is *NOT* reloaded automatically when you use 9 | # 'bundle exec jekyll serve'. If you change this file, please restart the server process. 10 | # 11 | # If you need help with YAML syntax, here are some quick references for you: 12 | # https://learn-the-web.algonquindesign.ca/topics/markdown-yaml-cheat-sheet/#yaml 13 | # https://learnxinyminutes.com/docs/yaml/ 14 | # 15 | # Site settings 16 | # These are used to personalize your new site. If you look in the HTML files, 17 | # you will see them accessed via {{ site.title }}, {{ site.email }}, and so on. 18 | # You can create any custom variable you would like, and they will be accessible 19 | # in the templates via {{ site.myvariable }}. 20 | 21 | title: Accessibility Scanner Demo 22 | email: noreply@github.com 23 | description: >- # this means to ignore newlines until "baseurl:" 24 | Write an awesome description for your new site here. You can edit this 25 | line in _config.yml. It will appear in your document head meta (for 26 | Google search results) and in your feed.xml site description. 27 | baseurl: "" # the subpath of your site, e.g. /blog 28 | url: "" # the base hostname & protocol for your site, e.g. http://example.com 29 | twitter_username: github 30 | github_username: github 31 | 32 | # Build settings 33 | theme: minima 34 | plugins: 35 | - jekyll-feed 36 | 37 | # Exclude from processing. 38 | # The following items will not be processed, by default. 39 | # Any item listed under the `exclude:` key here will be automatically added to 40 | # the internal "default list". 41 | # 42 | # Excluded items can be processed by explicitly listing the directories or 43 | # their entries' file path in the `include:` list. 44 | # 45 | # exclude: 46 | # - .sass-cache/ 47 | # - .jekyll-cache/ 48 | # - gemfiles/ 49 | # - Gemfile 50 | # - Gemfile.lock 51 | # - node_modules/ 52 | # - vendor/bundle/ 53 | # - vendor/cache/ 54 | # - vendor/gems/ 55 | # - vendor/ruby/ -------------------------------------------------------------------------------- /.github/actions/file/src/openIssue.ts: -------------------------------------------------------------------------------- 1 | import type { Octokit } from '@octokit/core'; 2 | import type { Finding } from './types.d.js'; 3 | import * as url from 'node:url' 4 | const URL = url.URL; 5 | 6 | /** Max length for GitHub issue titles */ 7 | const GITHUB_ISSUE_TITLE_MAX_LENGTH = 256; 8 | 9 | /** 10 | * Truncates text to a maximum length, adding an ellipsis if truncated. 11 | * @param text Original text 12 | * @param maxLength Maximum length of the returned text (including ellipsis) 13 | * @returns Either the original text or a truncated version with an ellipsis 14 | */ 15 | function truncateWithEllipsis(text: string, maxLength: number): string { 16 | return text.length > maxLength ? text.slice(0, maxLength - 1) + '…' : text; 17 | } 18 | 19 | export async function openIssue(octokit: Octokit, repoWithOwner: string, finding: Finding) { 20 | const owner = repoWithOwner.split('/')[0]; 21 | const repo = repoWithOwner.split('/')[1]; 22 | 23 | const labels = [`${finding.scannerType} rule: ${finding.ruleId}`, `${finding.scannerType}-scanning-issue`]; 24 | const title = truncateWithEllipsis( 25 | `Accessibility issue: ${finding.problemShort[0].toUpperCase() + finding.problemShort.slice(1)} on ${new URL(finding.url).pathname}`, 26 | GITHUB_ISSUE_TITLE_MAX_LENGTH 27 | ); 28 | const solutionLong = finding.solutionLong 29 | ?.split("\n") 30 | .map((line) => 31 | !line.trim().startsWith("Fix any") && 32 | !line.trim().startsWith("Fix all") && 33 | line.trim() !== "" 34 | ? `- ${line}` 35 | : line 36 | ) 37 | .join("\n"); 38 | const acceptanceCriteria = `## Acceptance Criteria 39 | - [ ] The specific axe violation reported in this issue is no longer reproducible. 40 | - [ ] The fix MUST meet WCAG 2.1 guidelines OR the accessibility standards specified by the repository or organization. 41 | - [ ] A test SHOULD be added to ensure this specific axe violation does not regress. 42 | - [ ] This PR MUST NOT introduce any new accessibility issues or regressions. 43 | `; 44 | const body = `## What 45 | An accessibility scan flagged the element \`${finding.html}\` on ${finding.url} because ${finding.problemShort}. Learn more about why this was flagged by visiting ${finding.problemUrl}. 46 | 47 | To fix this, ${finding.solutionShort}. 48 | ${solutionLong ? `\nSpecifically:\n\n${solutionLong}` : ''} 49 | 50 | ${acceptanceCriteria} 51 | `; 52 | 53 | return octokit.request(`POST /repos/${owner}/${repo}/issues`, { 54 | owner, 55 | repo, 56 | title, 57 | body, 58 | labels 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /.github/actions/gh-cache/cache/action.yml: -------------------------------------------------------------------------------- 1 | name: "cache" 2 | description: "Cache/uncache strings using an orphaned 'gh-cache' branch." 3 | 4 | inputs: 5 | key: 6 | description: "Identifier for the string to cache/uncache" 7 | required: true 8 | token: 9 | description: "Token with fine-grained permissions 'contents: write'" 10 | required: true 11 | value: 12 | description: "String to cache" 13 | required: false 14 | fail_on_cache_miss: 15 | description: "Fail the workflow if cached item is not found." 16 | required: false 17 | default: "false" 18 | 19 | outputs: 20 | value: 21 | description: "Cached string" 22 | value: ${{ steps.output_cached_value.outputs.value }} 23 | 24 | runs: 25 | using: "composite" 26 | steps: 27 | - name: Make sub-actions referenceable 28 | working-directory: ${{ github.action_path }} 29 | shell: bash 30 | run: | 31 | RUNNER_WORK_DIR="$(realpath "${GITHUB_WORKSPACE}/../..")" 32 | ACTION_DIR="${RUNNER_WORK_DIR}/_actions/github/accessibility-scanner/current" 33 | mkdir -p "${ACTION_DIR}/.github/actions" 34 | if [ "$(realpath "../../../../.github/actions")" != "$(realpath "${ACTION_DIR}/.github/actions")" ]; then 35 | cp -a "../../../../.github/actions/." "${ACTION_DIR}/.github/actions/" 36 | fi 37 | - name: Restore cached value 38 | uses: ./../../_actions/github/accessibility-scanner/current/.github/actions/gh-cache/restore 39 | with: 40 | path: ${{ inputs.key }} 41 | token: ${{ inputs.token }} 42 | fail_on_cache_miss: ${{ inputs.fail_on_cache_miss }} 43 | 44 | - if: ${{ inputs.value }} 45 | name: Update cached value 46 | shell: bash 47 | run: | 48 | mkdir -p "$(dirname "${{ inputs.key }}")" 49 | echo '${{ inputs.value }}' > "${{ inputs.key }}" 50 | echo "Updated contents of '${{ inputs.key }}'" 51 | 52 | - if: ${{ inputs.value }} 53 | name: Save cached value 54 | uses: ./../../_actions/github/accessibility-scanner/current/.github/actions/gh-cache/save 55 | with: 56 | path: ${{ inputs.key }} 57 | token: ${{ inputs.token }} 58 | 59 | - name: Output cached value 60 | id: output_cached_value 61 | shell: bash 62 | run: | 63 | echo "value<> $GITHUB_OUTPUT 64 | if [ -f "${{ inputs.key }}" ]; then 65 | cat "${{ inputs.key }}" >> $GITHUB_OUTPUT 66 | echo "Outputted 'value=$(cat "${{ inputs.key }}")'" 67 | else 68 | echo "Skipped outputting 'value'" 69 | fi 70 | echo "EOF" >> $GITHUB_OUTPUT 71 | 72 | branding: 73 | icon: "archive" 74 | color: "blue" 75 | -------------------------------------------------------------------------------- /.github/actions/fix/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { Issue as IssueInput, Fixing } from "./types.d.js"; 2 | import process from "node:process"; 3 | import core from "@actions/core"; 4 | import { Octokit } from "@octokit/core"; 5 | import { throttling } from "@octokit/plugin-throttling"; 6 | import { assignIssue } from "./assignIssue.js"; 7 | import { getLinkedPR } from "./getLinkedPR.js"; 8 | import { retry } from "./retry.js"; 9 | import { Issue } from "./Issue.js"; 10 | const OctokitWithThrottling = Octokit.plugin(throttling); 11 | 12 | export default async function () { 13 | core.info("Started 'fix' action"); 14 | const issues: IssueInput[] = JSON.parse( 15 | core.getInput("issues", { required: true }) || "[]" 16 | ); 17 | const repoWithOwner = core.getInput("repository", { required: true }); 18 | const token = core.getInput("token", { required: true }); 19 | core.debug(`Input: 'issues: ${JSON.stringify(issues)}'`); 20 | core.debug(`Input: 'repository: ${repoWithOwner}'`); 21 | 22 | const octokit = new OctokitWithThrottling({ 23 | auth: token, 24 | throttle: { 25 | onRateLimit: (retryAfter, options, octokit, retryCount) => { 26 | octokit.log.warn( 27 | `Request quota exhausted for request ${options.method} ${options.url}` 28 | ); 29 | if (retryCount < 3) { 30 | octokit.log.info(`Retrying after ${retryAfter} seconds!`); 31 | return true; 32 | } 33 | }, 34 | onSecondaryRateLimit: (retryAfter, options, octokit, retryCount) => { 35 | octokit.log.warn( 36 | `Secondary rate limit hit for request ${options.method} ${options.url}` 37 | ); 38 | if (retryCount < 3) { 39 | octokit.log.info(`Retrying after ${retryAfter} seconds!`); 40 | return true; 41 | } 42 | }, 43 | }, 44 | }); 45 | const fixings: Fixing[] = issues.map((issue) => ({ issue })) as Fixing[]; 46 | 47 | for (const fixing of fixings) { 48 | try { 49 | const issue = new Issue(fixing.issue); 50 | await assignIssue(octokit, issue); 51 | core.info( 52 | `Assigned ${issue.owner}/${issue.repository}#${issue.issueNumber} to Copilot!` 53 | ); 54 | const pullRequest = await retry(() => getLinkedPR(octokit, issue)); 55 | if (pullRequest) { 56 | fixing.pullRequest = pullRequest; 57 | core.info( 58 | `Found linked PR for ${issue.owner}/${issue.repository}#${issue.issueNumber}: ${pullRequest.url}` 59 | ); 60 | } else { 61 | core.info( 62 | `No linked PR was found for ${issue.owner}/${issue.repository}#${issue.issueNumber}` 63 | ); 64 | } 65 | } catch (error) { 66 | core.setFailed( 67 | `Failed to assign ${fixing.issue.url} to Copilot: ${error}` 68 | ); 69 | process.exit(1); 70 | } 71 | } 72 | 73 | core.setOutput("fixings", JSON.stringify(fixings)); 74 | core.debug(`Output: 'fixings: ${JSON.stringify(fixings)}'`); 75 | core.info("Finished 'fix' action"); 76 | } 77 | -------------------------------------------------------------------------------- /.github/actions/gh-cache/restore/action.yml: -------------------------------------------------------------------------------- 1 | name: "restore" 2 | description: "Checkout a file or directory from an orphaned 'gh-cache' branch" 3 | 4 | inputs: 5 | path: 6 | description: "Relative path to a file or directory to restore." 7 | required: true 8 | token: 9 | description: "Token with fine-grained permissions 'contents: read'" 10 | required: true 11 | fail_on_cache_miss: 12 | description: "Fail the workflow if cached item is not found." 13 | required: false 14 | default: 'false' 15 | 16 | runs: 17 | using: "composite" 18 | steps: 19 | - name: Validate path 20 | shell: bash 21 | run: | 22 | # Check for empty 23 | if [[ -z "${{ inputs.path }}" ]]; then 24 | echo "Invalid 'path' input (empty)" 25 | exit 1 26 | fi 27 | # Check for absolute paths 28 | if [[ "${{ inputs.path }}" == /* ]]; then 29 | echo "Invalid 'path' input (absolute path): ${{ inputs.path }}" 30 | exit 1 31 | fi 32 | # Check for directory traversal 33 | if [[ 34 | "${{ inputs.path }}" == "~"* || 35 | "${{ inputs.path }}" =~ (^|/)\.\.(/|$) 36 | ]]; then 37 | echo "Invalid 'path' input (directory traversal): ${{ inputs.path }}" 38 | exit 1 39 | fi 40 | # Check for disallowed characters (to ensure portability) 41 | if [[ ! "${{ inputs.path }}" =~ ^[A-Za-z0-9._/-]+$ ]]; then 42 | echo "Invalid 'path' input (disallowed characters): ${{ inputs.path }}" 43 | exit 1 44 | fi 45 | 46 | - name: Checkout repository to temporary directory 47 | uses: actions/checkout@v6 48 | with: 49 | token: ${{ inputs.token }} 50 | fetch-depth: 0 51 | path: .gh-cache-${{ github.run_id }} 52 | show-progress: 'false' 53 | 54 | - name: Switch to 'gh-cache' branch if it exists 55 | shell: bash 56 | working-directory: .gh-cache-${{ github.run_id }} 57 | run: | 58 | if git ls-remote --exit-code --heads origin gh-cache >/dev/null; then 59 | git fetch origin gh-cache:gh-cache >/dev/null 2>&1 60 | git checkout gh-cache >/dev/null 2>&1 61 | else 62 | echo "Branch 'gh-cache' does not exist" 63 | if [[ "${{ inputs.fail_on_cache_miss }}" == "true" ]]; then 64 | echo "Exiting with error because 'fail_on_cache_miss' is 'true'" 65 | exit 1 66 | fi 67 | fi 68 | 69 | - name: Restore cached item 70 | shell: bash 71 | run: | 72 | src=".gh-cache-${{ github.run_id }}/${{ inputs.path }}" 73 | dest="${{ inputs.path }}" 74 | if [ -e "$src" ]; then 75 | mkdir -p "$(dirname "$dest")" 76 | cp -Rf "$src" "$dest" 77 | echo "Restored '${{ inputs.path }}' from cache" 78 | else 79 | echo "'${{ inputs.path }}' not found in cache" 80 | if [[ "${{ inputs.fail_on_cache_miss }}" == "true" ]]; then 81 | echo "Exiting with error because 'fail_on_cache_miss' is 'true'" 82 | exit 1 83 | fi 84 | fi 85 | 86 | - name: Clean up temporary directory 87 | shell: bash 88 | run: | 89 | rm -rf .gh-cache-${{ github.run_id }} 90 | 91 | branding: 92 | icon: "download" 93 | color: "blue" 94 | -------------------------------------------------------------------------------- /.github/actions/fix/src/assignIssue.ts: -------------------------------------------------------------------------------- 1 | import type { Octokit } from "@octokit/core"; 2 | import { Issue } from "./Issue.js"; 3 | 4 | // https://docs.github.com/en/enterprise-cloud@latest/copilot/how-tos/use-copilot-agents/coding-agent/assign-copilot-to-an-issue#assigning-an-existing-issue 5 | export async function assignIssue( 6 | octokit: Octokit, 7 | { owner, repository, issueNumber, nodeId }: Issue 8 | ) { 9 | // Check whether issues can be assigned to Copilot 10 | const suggestedActorsResponse = await octokit.graphql<{ 11 | repository: { 12 | suggestedActors: { 13 | nodes: { login: string; id: string }[]; 14 | }; 15 | }; 16 | }>( 17 | `query ($owner: String!, $repository: String!) { 18 | repository(owner: $owner, name: $repository) { 19 | suggestedActors(capabilities: [CAN_BE_ASSIGNED], first: 1) { 20 | nodes { 21 | login 22 | __typename 23 | ... on Bot { id } 24 | ... on User { id } 25 | } 26 | } 27 | } 28 | }`, 29 | { owner, repository } 30 | ); 31 | if ( 32 | suggestedActorsResponse?.repository?.suggestedActors?.nodes[0]?.login !== 33 | "copilot-swe-agent" 34 | ) { 35 | return; 36 | } 37 | // Get GraphQL identifier for issue (unless already provided) 38 | let issueId = nodeId; 39 | if (!issueId) { 40 | console.debug( 41 | `Fetching identifier for issue ${owner}/${repository}#${issueNumber}` 42 | ); 43 | const issueResponse = await octokit.graphql<{ 44 | repository: { 45 | issue: { id: string }; 46 | }; 47 | }>( 48 | `query($owner: String!, $repository: String!, $issueNumber: Int!) { 49 | repository(owner: $owner, name: $repository) { 50 | issue(number: $issueNumber) { id } 51 | } 52 | }`, 53 | { owner, repository, issueNumber } 54 | ); 55 | issueId = issueResponse?.repository?.issue?.id; 56 | console.debug( 57 | `Fetched identifier for issue ${owner}/${repository}#${issueNumber}: ${issueId}` 58 | ); 59 | } else { 60 | console.debug( 61 | `Using provided identifier for issue ${owner}/${repository}#${issueNumber}: ${issueId}` 62 | ); 63 | } 64 | if (!issueId) { 65 | console.warn( 66 | `Couldn’t get identifier for issue ${owner}/${repository}#${issueNumber}. Skipping assignment to Copilot.` 67 | ); 68 | return; 69 | } 70 | // Assign issue to Copilot 71 | await octokit.graphql<{ 72 | replaceActorsForAssignable: { 73 | assignable: { 74 | id: string; 75 | title: string; 76 | assignees: { 77 | nodes: { login: string }[]; 78 | }; 79 | }; 80 | }; 81 | }>( 82 | `mutation($issueId: ID!, $assigneeId: ID!) { 83 | replaceActorsForAssignable(input: {assignableId: $issueId, actorIds: [$assigneeId]}) { 84 | assignable { 85 | ... on Issue { 86 | id 87 | title 88 | assignees(first: 10) { 89 | nodes { 90 | login 91 | } 92 | } 93 | } 94 | } 95 | } 96 | }`, 97 | { 98 | issueId, 99 | assigneeId: 100 | suggestedActorsResponse?.repository?.suggestedActors?.nodes[0]?.id, 101 | } 102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /.github/actions/gh-cache/delete/action.yml: -------------------------------------------------------------------------------- 1 | name: "delete" 2 | description: "Delete a file or directory from the orphaned 'gh-cache' branch" 3 | 4 | inputs: 5 | path: 6 | description: "Relative path to a file or directory to delete" 7 | required: true 8 | token: 9 | description: "Token with fine-grained permissions 'contents: write'" 10 | required: true 11 | 12 | runs: 13 | using: "composite" 14 | steps: 15 | - name: Validate path 16 | shell: bash 17 | run: | 18 | # Check for empty 19 | if [[ -z "${{ inputs.path }}" ]]; then 20 | echo "Invalid 'path' input (empty)" 21 | exit 1 22 | fi 23 | # Check for absolute paths 24 | if [[ "${{ inputs.path }}" == /* ]]; then 25 | echo "Invalid 'path' input (absolute path): ${{ inputs.path }}" 26 | exit 1 27 | fi 28 | # Check for directory traversal 29 | if [[ 30 | "${{ inputs.path }}" == "~"* || 31 | "${{ inputs.path }}" =~ (^|/)\.\.(/|$) 32 | ]]; then 33 | echo "Invalid 'path' input (directory traversal): ${{ inputs.path }}" 34 | exit 1 35 | fi 36 | # Check for disallowed characters (to ensure portability) 37 | if [[ ! "${{ inputs.path }}" =~ ^[A-Za-z0-9._/-]+$ ]]; then 38 | echo "Invalid 'path' input (disallowed characters): ${{ inputs.path }}" 39 | exit 1 40 | fi 41 | 42 | - name: Checkout repository to temporary directory 43 | uses: actions/checkout@v6 44 | with: 45 | token: ${{ inputs.token }} 46 | fetch-depth: 0 47 | path: .gh-cache-${{ github.run_id }} 48 | show-progress: 'false' 49 | 50 | - name: Switch to gh-cache branch (or create orphan) 51 | shell: bash 52 | working-directory: .gh-cache-${{ github.run_id }} 53 | run: | 54 | if git ls-remote --exit-code --heads origin gh-cache >/dev/null; then 55 | git checkout gh-cache >/dev/null 2>&1 56 | echo "Checked out existing 'gh-cache' branch" 57 | else 58 | git checkout --orphan gh-cache >/dev/null 2>&1 59 | git rm -rfq . # Clear files from the initial checkout, to avoid adding them to the orphaned branch 60 | echo "Created new orphaned 'gh-cache' branch" 61 | fi 62 | 63 | - name: Copy file to repo 64 | shell: bash 65 | run: | 66 | if [ -f "${{ inputs.path }}" ]; then 67 | rm -Rf "${{ inputs.path }}" 68 | rm -Rf ".gh-cache-${{ github.run_id }}/${{ inputs.path }}" 69 | echo "Deleted '${{ inputs.path }}' from 'gh-cache' branch" 70 | fi 71 | 72 | - name: Commit and push 73 | shell: bash 74 | working-directory: .gh-cache-${{ github.run_id }} 75 | run: | 76 | git add "${{ inputs.path }}" || true 77 | git config user.name "github-actions[bot]" 78 | git config user.email "github-actions[bot]@users.noreply.github.com" 79 | git commit -m "$(printf 'Delete artifact: %s' "${{ inputs.path }}")" \ 80 | && echo "Committed '${{ inputs.path }}' to 'gh-cache' branch" \ 81 | || echo "No changes to commit" 82 | git push origin gh-cache 83 | 84 | - name: Clean up temporary directory 85 | shell: bash 86 | run: | 87 | rm -rf .gh-cache-${{ github.run_id }} 88 | 89 | branding: 90 | icon: "trash" 91 | color: "red" 92 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://www.contributor-covenant.org/version/1/4/code-of-conduct/][version] 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | [version]: https://www.contributor-covenant.org/version/1/4/code-of-conduct/ 75 | -------------------------------------------------------------------------------- /.github/actions/gh-cache/save/action.yml: -------------------------------------------------------------------------------- 1 | name: "save" 2 | description: "Commit a file or directory to an orphaned 'gh-cache' branch" 3 | 4 | inputs: 5 | path: 6 | description: "Relative path to a file or directory to save" 7 | required: true 8 | token: 9 | description: "Token with fine-grained permissions 'contents: write'" 10 | required: true 11 | 12 | runs: 13 | using: "composite" 14 | steps: 15 | - name: Validate path 16 | shell: bash 17 | run: | 18 | # Check for empty 19 | if [[ -z "${{ inputs.path }}" ]]; then 20 | echo "Invalid 'path' input (empty)" 21 | exit 1 22 | fi 23 | # Check for absolute paths 24 | if [[ "${{ inputs.path }}" == /* ]]; then 25 | echo "Invalid 'path' input (absolute path): ${{ inputs.path }}" 26 | exit 1 27 | fi 28 | # Check for directory traversal 29 | if [[ 30 | "${{ inputs.path }}" == "~"* || 31 | "${{ inputs.path }}" =~ (^|/)\.\.(/|$) 32 | ]]; then 33 | echo "Invalid 'path' input (directory traversal): ${{ inputs.path }}" 34 | exit 1 35 | fi 36 | # Check for disallowed characters (to ensure portability) 37 | if [[ ! "${{ inputs.path }}" =~ ^[A-Za-z0-9._/-]+$ ]]; then 38 | echo "Invalid 'path' input (disallowed characters): ${{ inputs.path }}" 39 | exit 1 40 | fi 41 | 42 | - name: Checkout repository to temporary directory 43 | uses: actions/checkout@v6 44 | with: 45 | token: ${{ inputs.token }} 46 | fetch-depth: 0 47 | path: .gh-cache-${{ github.run_id }} 48 | show-progress: "false" 49 | 50 | - name: Switch to gh-cache branch (or create orphan) 51 | shell: bash 52 | working-directory: .gh-cache-${{ github.run_id }} 53 | run: | 54 | if git ls-remote --exit-code --heads origin gh-cache >/dev/null; then 55 | git checkout gh-cache >/dev/null 2>&1 56 | echo "Checked out existing 'gh-cache' branch" 57 | else 58 | git checkout --orphan gh-cache >/dev/null 2>&1 59 | git rm -rfq . # Clear files from the initial checkout, to avoid adding them to the orphaned branch 60 | echo "Created new orphaned 'gh-cache' branch" 61 | fi 62 | 63 | - name: Copy file to repo 64 | shell: bash 65 | run: | 66 | if [ -f "${{ inputs.path }}" ]; then 67 | mkdir -p ".gh-cache-${{ github.run_id }}/$(dirname "${{ inputs.path }}")" 68 | cp -Rf "${{ inputs.path }}" ".gh-cache-${{ github.run_id }}/${{ inputs.path }}" 69 | echo "Copied '${{ inputs.path }}' to 'gh-cache' branch" 70 | fi 71 | 72 | - name: Commit and push 73 | shell: bash 74 | working-directory: .gh-cache-${{ github.run_id }} 75 | run: | 76 | if [ -f "${{ inputs.path }}" ]; then 77 | git add "${{ inputs.path }}" 78 | git config user.name "github-actions[bot]" 79 | git config user.email "github-actions[bot]@users.noreply.github.com" 80 | git commit -m "$(printf 'Save artifact: %s' "${{ inputs.path }}")" \ 81 | && echo "Committed '${{ inputs.path }}' to 'gh-cache' branch" \ 82 | || echo "No changes to commit" 83 | if git ls-remote --exit-code --heads origin gh-cache >/dev/null; then 84 | git fetch origin gh-cache 85 | git rebase origin/gh-cache 86 | fi 87 | git push origin gh-cache 88 | else 89 | echo "'${{ inputs.path }}' does not exist" 90 | echo "Skipping commit and push" 91 | fi 92 | 93 | - name: Clean up temporary directory 94 | shell: bash 95 | run: | 96 | rm -rf .gh-cache-${{ github.run_id }} 97 | 98 | branding: 99 | icon: "upload" 100 | color: "blue" 101 | -------------------------------------------------------------------------------- /.github/actions/file/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { Finding, ResolvedFiling, RepeatedFiling } from "./types.d.js"; 2 | import process from "node:process"; 3 | import core from "@actions/core"; 4 | import { Octokit } from "@octokit/core"; 5 | import { throttling } from "@octokit/plugin-throttling"; 6 | import { Issue } from "./Issue.js"; 7 | import { closeIssue } from "./closeIssue.js"; 8 | import { isNewFiling } from "./isNewFiling.js"; 9 | import { isRepeatedFiling } from "./isRepeatedFiling.js"; 10 | import { isResolvedFiling } from "./isResolvedFiling.js"; 11 | import { openIssue } from "./openIssue.js"; 12 | import { reopenIssue } from "./reopenIssue.js"; 13 | import { updateFilingsWithNewFindings } from "./updateFilingsWithNewFindings.js"; 14 | const OctokitWithThrottling = Octokit.plugin(throttling); 15 | 16 | export default async function () { 17 | core.info("Started 'file' action"); 18 | const findings: Finding[] = JSON.parse( 19 | core.getInput("findings", { required: true }) 20 | ); 21 | const repoWithOwner = core.getInput("repository", { required: true }); 22 | const token = core.getInput("token", { required: true }); 23 | const cachedFilings: (ResolvedFiling | RepeatedFiling)[] = JSON.parse( 24 | core.getInput("cached_filings", { required: false }) || "[]" 25 | ); 26 | core.debug(`Input: 'findings: ${JSON.stringify(findings)}'`); 27 | core.debug(`Input: 'repository: ${repoWithOwner}'`); 28 | core.debug(`Input: 'cached_filings: ${JSON.stringify(cachedFilings)}'`); 29 | 30 | const octokit = new OctokitWithThrottling({ 31 | auth: token, 32 | throttle: { 33 | onRateLimit: (retryAfter, options, octokit, retryCount) => { 34 | octokit.log.warn( 35 | `Request quota exhausted for request ${options.method} ${options.url}` 36 | ); 37 | if (retryCount < 3) { 38 | octokit.log.info(`Retrying after ${retryAfter} seconds!`); 39 | return true; 40 | } 41 | }, 42 | onSecondaryRateLimit: (retryAfter, options, octokit, retryCount) => { 43 | octokit.log.warn( 44 | `Secondary rate limit hit for request ${options.method} ${options.url}` 45 | ); 46 | if (retryCount < 3) { 47 | octokit.log.info(`Retrying after ${retryAfter} seconds!`); 48 | return true; 49 | } 50 | }, 51 | }, 52 | }); 53 | const filings = updateFilingsWithNewFindings(cachedFilings, findings); 54 | 55 | for (const filing of filings) { 56 | let response; 57 | try { 58 | if (isResolvedFiling(filing)) { 59 | // Close the filing’s issue (if necessary) 60 | response = await closeIssue(octokit, new Issue(filing.issue)); 61 | filing.issue.state = "closed"; 62 | } else if (isNewFiling(filing)) { 63 | // Open a new issue for the filing 64 | response = await openIssue(octokit, repoWithOwner, filing.findings[0]); 65 | (filing as any).issue = { state: "open" } as Issue; 66 | } else if (isRepeatedFiling(filing)) { 67 | // Reopen the filing’s issue (if necessary) 68 | response = await reopenIssue(octokit, new Issue(filing.issue)); 69 | filing.issue.state = "reopened"; 70 | } 71 | if (response?.data && filing.issue) { 72 | // Update the filing with the latest issue data 73 | filing.issue.id = response.data.id; 74 | filing.issue.nodeId = response.data.node_id; 75 | filing.issue.url = response.data.html_url; 76 | filing.issue.title = response.data.title; 77 | core.info( 78 | `Set issue ${response.data.title} (${repoWithOwner}#${response.data.number}) state to ${filing.issue.state}` 79 | ); 80 | } 81 | } catch (error) { 82 | core.setFailed(`Failed on filing: ${filing}\n${error}`); 83 | process.exit(1); 84 | } 85 | } 86 | 87 | core.setOutput("filings", JSON.stringify(filings)); 88 | core.debug(`Output: 'filings: ${JSON.stringify(filings)}'`); 89 | core.info("Finished 'file' action"); 90 | } 91 | -------------------------------------------------------------------------------- /.github/actions/auth/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { AuthContextOutput } from "./types.d.js"; 2 | import crypto from "node:crypto"; 3 | import process from "node:process"; 4 | import * as url from "node:url"; 5 | import core from "@actions/core"; 6 | import playwright from "playwright"; 7 | 8 | export default async function () { 9 | core.info("Starting 'auth' action"); 10 | 11 | let browser: playwright.Browser | undefined; 12 | let context: playwright.BrowserContext | undefined; 13 | let page: playwright.Page | undefined; 14 | try { 15 | // Get inputs 16 | const loginUrl = core.getInput("login_url", { required: true }); 17 | const username = core.getInput("username", { required: true }); 18 | const password = core.getInput("password", { required: true }); 19 | core.setSecret(password); 20 | 21 | // Determine storage path for authenticated session state 22 | // Playwright will create missing directories, if needed 23 | const actionDirectory = `${url.fileURLToPath(new URL(import.meta.url))}/..`; 24 | const sessionStatePath = `${ 25 | process.env.RUNNER_TEMP ?? actionDirectory 26 | }/.auth/${crypto.randomUUID()}/sessionState.json`; 27 | 28 | // Launch a headless browser 29 | browser = await playwright.chromium.launch({ 30 | headless: true, 31 | executablePath: process.env.CI ? "/usr/bin/google-chrome" : undefined, 32 | }); 33 | context = await browser.newContext({ 34 | // Try HTTP Basic authentication 35 | httpCredentials: { 36 | username, 37 | password, 38 | }, 39 | }); 40 | page = await context.newPage(); 41 | 42 | // Navigate to login page 43 | core.info("Navigating to login page"); 44 | await page.goto(loginUrl); 45 | 46 | // Check for a login form. 47 | // If no login form is found, then either HTTP Basic auth succeeded, or the page does not require authentication. 48 | core.info("Checking for login form"); 49 | const [usernameField, passwordField] = await Promise.all([ 50 | page.getByLabel(/user ?name/i).first(), 51 | page.getByLabel(/password/i).first(), 52 | ]); 53 | const [usernameFieldExists, passwordFieldExists] = await Promise.all([ 54 | usernameField.count(), 55 | passwordField.count(), 56 | ]); 57 | if (usernameFieldExists && passwordFieldExists) { 58 | // Try form authentication 59 | core.info("Filling username"); 60 | await usernameField.fill(username); 61 | core.info("Filling password"); 62 | await passwordField.fill(password); 63 | core.info("Logging in"); 64 | await page 65 | .getByLabel(/password/i) 66 | .locator("xpath=ancestor::form") 67 | .evaluate((form) => (form as HTMLFormElement).submit()); 68 | } else { 69 | core.info("No login form detected"); 70 | // This occurs if HTTP Basic auth succeeded, or if the page does not require authentication. 71 | } 72 | 73 | // Output authenticated session state 74 | const { cookies, origins } = await context.storageState(); 75 | const authContextOutput: AuthContextOutput = { 76 | username, 77 | password, 78 | cookies, 79 | localStorage: origins.reduce((acc, { origin, localStorage }) => { 80 | acc[origin] = localStorage.reduce((acc, { name, value }) => { 81 | acc[name] = value; 82 | return acc; 83 | }, {} as Record); 84 | return acc; 85 | }, {} as Record>), 86 | }; 87 | core.setOutput("auth_context", JSON.stringify(authContextOutput)); 88 | core.debug("Output: 'auth_context'"); 89 | } catch (error) { 90 | if (page) { 91 | core.info(`Errored at page URL: ${page.url()}`); 92 | } 93 | core.setFailed(`${error}`); 94 | process.exit(1); 95 | } finally { 96 | // Clean up 97 | await context?.close(); 98 | await browser?.close(); 99 | } 100 | 101 | core.info("Finished 'auth' action"); 102 | } 103 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | - reopened 7 | - synchronize 8 | - ready_for_review 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: write 13 | issues: write 14 | pull-requests: write 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.ref }} 18 | cancel-in-progress: false # Allow previous workflows’ clean-up steps to complete 19 | 20 | env: 21 | TESTING_REPOSITORY: github/accessibility-scanner-testing 22 | 23 | jobs: 24 | test: 25 | name: Test 26 | runs-on: ubuntu-latest 27 | # Run if triggered manually, or for a non-draft PR 28 | if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} 29 | strategy: 30 | matrix: 31 | site: ["sites/site-with-errors"] 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v6 35 | 36 | - name: Setup Ruby 37 | uses: ruby/setup-ruby@ac793fdd38cc468a4dd57246fa9d0e868aba9085 38 | with: 39 | ruby-version: "3.4" 40 | bundler-cache: true 41 | working-directory: ${{ matrix.site }} 42 | 43 | - name: Build Jekyll site (${{ matrix.site }}) 44 | shell: bash 45 | working-directory: ${{ matrix.site }} 46 | env: 47 | JEKYLL_ENV: production 48 | run: bundle exec jekyll build 49 | 50 | - name: Start Puma (${{ matrix.site }}) 51 | shell: bash 52 | working-directory: ${{ matrix.site }} 53 | env: 54 | TEST_USERNAME: ${{ secrets.TEST_USERNAME }} 55 | TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }} 56 | run: | 57 | set -euo pipefail 58 | bundle exec puma -b tcp://127.0.0.1:4000 & 59 | echo "Starting Puma on port 4000" 60 | curl -fsS --retry 25 --retry-delay 1 --retry-all-errors -u "${TEST_USERNAME}:${TEST_PASSWORD}" "http://127.0.0.1:4000/" > /dev/null 61 | echo "Puma has started" 62 | 63 | - name: Generate cache key 64 | id: cache_key 65 | shell: bash 66 | run: | 67 | echo "cache_key=$(printf 'cached_results-%s-%s.json' "${{ matrix.site }}" "${{ github.ref_name }}" | tr -cs 'A-Za-z0-9._-' '_')" >> $GITHUB_OUTPUT 68 | 69 | - name: Scan site (${{ matrix.site }}) 70 | uses: ./ 71 | with: 72 | urls: | 73 | http://127.0.0.1:4000/ 74 | http://127.0.0.1:4000/jekyll/update/2025/07/30/welcome-to-jekyll.html 75 | http://127.0.0.1:4000/about/ 76 | http://127.0.0.1:4000/404.html 77 | login_url: http://127.0.0.1:4000/ 78 | username: ${{ secrets.TEST_USERNAME }} 79 | password: ${{ secrets.TEST_PASSWORD }} 80 | repository: ${{ env.TESTING_REPOSITORY }} 81 | token: ${{ secrets.GH_TOKEN }} 82 | cache_key: ${{ steps.cache_key.outputs.cache_key }} 83 | 84 | - name: Retrieve cached results 85 | uses: ./.github/actions/gh-cache/restore 86 | with: 87 | path: ${{ steps.cache_key.outputs.cache_key }} 88 | token: ${{ secrets.GITHUB_TOKEN }} 89 | 90 | - name: Validate scan results (${{ matrix.site }}) 91 | run: | 92 | npm ci 93 | npm run test 94 | env: 95 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 96 | CACHE_PATH: ${{ steps.cache_key.outputs.cache_key }} 97 | 98 | - name: Clean up issues and pull requests 99 | if: ${{ always() }} 100 | shell: bash 101 | run: | 102 | set -euo pipefail 103 | if [[ ! -f "${{ steps.cache_key.outputs.cache_key }}" ]]; then 104 | echo "Skipping 'Clean up issues and pull requests' (no cached results)." 105 | exit 0 106 | fi 107 | jq -r ' 108 | (if type=="string" then fromjson else . end) 109 | | .[] | .issue.url, .pullRequest.url 110 | | select(. != null) 111 | ' "${{ steps.cache_key.outputs.cache_key }}" \ 112 | | while read -r URL; do 113 | if [[ "$URL" == *"/pull/"* ]]; then 114 | echo "Closing pull request: $URL" 115 | gh pr close "$URL" || echo "Failed to close pull request: $URL" 116 | branch="$(gh pr view "$URL" --json headRefName -q .headRefName || true)" 117 | if [[ -n "$branch" ]]; then 118 | echo "Deleting branch: $branch" 119 | gh api -X DELETE "repos/${{ env.TESTING_REPOSITORY }}/git/refs/heads/$branch" || echo "Failed to delete branch: $branch" 120 | fi 121 | elif [[ "$URL" == *"/issues/"* ]]; then 122 | echo "Closing issue: $URL" 123 | gh issue close "$URL" || echo "Failed to close issue: $URL" 124 | else 125 | echo "Skipping unrecognized url: $URL" 126 | fi 127 | done 128 | env: 129 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 130 | 131 | - name: Clean up cached results 132 | if: ${{ always() }} 133 | uses: ./.github/actions/gh-cache/delete 134 | with: 135 | path: ${{ steps.cache_key.outputs.cache_key }} 136 | token: ${{ secrets.GITHUB_TOKEN }} 137 | -------------------------------------------------------------------------------- /sites/site-with-errors/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.8.7) 5 | public_suffix (>= 2.0.2, < 7.0) 6 | base64 (0.3.0) 7 | bigdecimal (3.2.2) 8 | colorator (1.1.0) 9 | concurrent-ruby (1.3.5) 10 | csv (3.3.5) 11 | em-websocket (0.5.3) 12 | eventmachine (>= 0.12.9) 13 | http_parser.rb (~> 0) 14 | eventmachine (1.2.7) 15 | ffi (1.17.2) 16 | ffi (1.17.2-aarch64-linux-gnu) 17 | ffi (1.17.2-aarch64-linux-musl) 18 | ffi (1.17.2-arm-linux-gnu) 19 | ffi (1.17.2-arm-linux-musl) 20 | ffi (1.17.2-arm64-darwin) 21 | ffi (1.17.2-x86-linux-gnu) 22 | ffi (1.17.2-x86-linux-musl) 23 | ffi (1.17.2-x86_64-darwin) 24 | ffi (1.17.2-x86_64-linux-gnu) 25 | ffi (1.17.2-x86_64-linux-musl) 26 | forwardable-extended (2.6.0) 27 | google-protobuf (4.32.0) 28 | bigdecimal 29 | rake (>= 13) 30 | google-protobuf (4.32.0-aarch64-linux-gnu) 31 | bigdecimal 32 | rake (>= 13) 33 | google-protobuf (4.32.0-aarch64-linux-musl) 34 | bigdecimal 35 | rake (>= 13) 36 | google-protobuf (4.32.0-arm64-darwin) 37 | bigdecimal 38 | rake (>= 13) 39 | google-protobuf (4.32.0-x86-linux-gnu) 40 | bigdecimal 41 | rake (>= 13) 42 | google-protobuf (4.32.0-x86-linux-musl) 43 | bigdecimal 44 | rake (>= 13) 45 | google-protobuf (4.32.0-x86_64-darwin) 46 | bigdecimal 47 | rake (>= 13) 48 | google-protobuf (4.32.0-x86_64-linux-gnu) 49 | bigdecimal 50 | rake (>= 13) 51 | google-protobuf (4.32.0-x86_64-linux-musl) 52 | bigdecimal 53 | rake (>= 13) 54 | http_parser.rb (0.8.0) 55 | i18n (1.14.7) 56 | concurrent-ruby (~> 1.0) 57 | jekyll (4.4.1) 58 | addressable (~> 2.4) 59 | base64 (~> 0.2) 60 | colorator (~> 1.0) 61 | csv (~> 3.0) 62 | em-websocket (~> 0.5) 63 | i18n (~> 1.0) 64 | jekyll-sass-converter (>= 2.0, < 4.0) 65 | jekyll-watch (~> 2.0) 66 | json (~> 2.6) 67 | kramdown (~> 2.3, >= 2.3.1) 68 | kramdown-parser-gfm (~> 1.0) 69 | liquid (~> 4.0) 70 | mercenary (~> 0.3, >= 0.3.6) 71 | pathutil (~> 0.9) 72 | rouge (>= 3.0, < 5.0) 73 | safe_yaml (~> 1.0) 74 | terminal-table (>= 1.8, < 4.0) 75 | webrick (~> 1.7) 76 | jekyll-feed (0.17.0) 77 | jekyll (>= 3.7, < 5.0) 78 | jekyll-sass-converter (3.1.0) 79 | sass-embedded (~> 1.75) 80 | jekyll-seo-tag (2.8.0) 81 | jekyll (>= 3.8, < 5.0) 82 | jekyll-watch (2.2.1) 83 | listen (~> 3.0) 84 | json (2.13.2) 85 | kramdown (2.5.1) 86 | rexml (>= 3.3.9) 87 | kramdown-parser-gfm (1.1.0) 88 | kramdown (~> 2.0) 89 | liquid (4.0.4) 90 | listen (3.9.0) 91 | rb-fsevent (~> 0.10, >= 0.10.3) 92 | rb-inotify (~> 0.9, >= 0.9.10) 93 | mercenary (0.4.0) 94 | minima (2.5.2) 95 | jekyll (>= 3.5, < 5.0) 96 | jekyll-feed (~> 0.9) 97 | jekyll-seo-tag (~> 2.1) 98 | nio4r (2.7.4) 99 | pathutil (0.16.2) 100 | forwardable-extended (~> 2.6) 101 | public_suffix (6.0.2) 102 | puma (7.1.0) 103 | nio4r (~> 2.0) 104 | rack (3.2.4) 105 | rake (13.3.0) 106 | rb-fsevent (0.11.2) 107 | rb-inotify (0.11.1) 108 | ffi (~> 1.0) 109 | rexml (3.4.2) 110 | rouge (4.6.0) 111 | safe_yaml (1.0.5) 112 | sass-embedded (1.90.0) 113 | google-protobuf (~> 4.31) 114 | rake (>= 13) 115 | sass-embedded (1.90.0-aarch64-linux-android) 116 | google-protobuf (~> 4.31) 117 | sass-embedded (1.90.0-aarch64-linux-gnu) 118 | google-protobuf (~> 4.31) 119 | sass-embedded (1.90.0-aarch64-linux-musl) 120 | google-protobuf (~> 4.31) 121 | sass-embedded (1.90.0-arm-linux-androideabi) 122 | google-protobuf (~> 4.31) 123 | sass-embedded (1.90.0-arm-linux-gnueabihf) 124 | google-protobuf (~> 4.31) 125 | sass-embedded (1.90.0-arm-linux-musleabihf) 126 | google-protobuf (~> 4.31) 127 | sass-embedded (1.90.0-arm64-darwin) 128 | google-protobuf (~> 4.31) 129 | sass-embedded (1.90.0-riscv64-linux-android) 130 | google-protobuf (~> 4.31) 131 | sass-embedded (1.90.0-riscv64-linux-gnu) 132 | google-protobuf (~> 4.31) 133 | sass-embedded (1.90.0-riscv64-linux-musl) 134 | google-protobuf (~> 4.31) 135 | sass-embedded (1.90.0-x86_64-darwin) 136 | google-protobuf (~> 4.31) 137 | sass-embedded (1.90.0-x86_64-linux-android) 138 | google-protobuf (~> 4.31) 139 | sass-embedded (1.90.0-x86_64-linux-gnu) 140 | google-protobuf (~> 4.31) 141 | sass-embedded (1.90.0-x86_64-linux-musl) 142 | google-protobuf (~> 4.31) 143 | terminal-table (3.0.2) 144 | unicode-display_width (>= 1.1.1, < 3) 145 | unicode-display_width (2.6.0) 146 | webrick (1.9.1) 147 | 148 | PLATFORMS 149 | aarch64-linux-android 150 | aarch64-linux-gnu 151 | aarch64-linux-musl 152 | arm-linux-androideabi 153 | arm-linux-gnu 154 | arm-linux-gnueabihf 155 | arm-linux-musl 156 | arm-linux-musleabihf 157 | arm64-darwin 158 | riscv64-linux-android 159 | riscv64-linux-gnu 160 | riscv64-linux-musl 161 | ruby 162 | x86-linux-gnu 163 | x86-linux-musl 164 | x86_64-darwin 165 | x86_64-linux-android 166 | x86_64-linux-gnu 167 | x86_64-linux-musl 168 | 169 | DEPENDENCIES 170 | http_parser.rb (~> 0.6.0) 171 | jekyll (~> 4.4.1) 172 | jekyll-feed (~> 0.12) 173 | minima (~> 2.5) 174 | puma (~> 7.1) 175 | rack (~> 3.2) 176 | tzinfo (>= 1, < 3) 177 | tzinfo-data 178 | wdm (~> 0.1) 179 | 180 | BUNDLED WITH 181 | 2.7.1 182 | -------------------------------------------------------------------------------- /.github/actions/auth/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auth", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "auth", 9 | "version": "1.0.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@actions/core": "^2.0.1", 13 | "playwright": "^1.57.0" 14 | }, 15 | "devDependencies": { 16 | "@types/node": "^25.0.2", 17 | "typescript": "^5.9.3" 18 | } 19 | }, 20 | "node_modules/@actions/core": { 21 | "version": "2.0.1", 22 | "resolved": "https://registry.npmjs.org/@actions/core/-/core-2.0.1.tgz", 23 | "integrity": "sha512-oBfqT3GwkvLlo1fjvhQLQxuwZCGTarTE5OuZ2Wg10hvhBj7LRIlF611WT4aZS6fDhO5ZKlY7lCAZTlpmyaHaeg==", 24 | "license": "MIT", 25 | "dependencies": { 26 | "@actions/exec": "^2.0.0", 27 | "@actions/http-client": "^3.0.0" 28 | } 29 | }, 30 | "node_modules/@actions/exec": { 31 | "version": "2.0.0", 32 | "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-2.0.0.tgz", 33 | "integrity": "sha512-k8ngrX2voJ/RIN6r9xB82NVqKpnMRtxDoiO+g3olkIUpQNqjArXrCQceduQZCQj3P3xm32pChRLqRrtXTlqhIw==", 34 | "license": "MIT", 35 | "dependencies": { 36 | "@actions/io": "^2.0.0" 37 | } 38 | }, 39 | "node_modules/@actions/http-client": { 40 | "version": "3.0.0", 41 | "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-3.0.0.tgz", 42 | "integrity": "sha512-1s3tXAfVMSz9a4ZEBkXXRQD4QhY3+GAsWSbaYpeknPOKEeyRiU3lH+bHiLMZdo2x/fIeQ/hscL1wCkDLVM2DZQ==", 43 | "license": "MIT", 44 | "dependencies": { 45 | "tunnel": "^0.0.6", 46 | "undici": "^5.28.5" 47 | } 48 | }, 49 | "node_modules/@actions/io": { 50 | "version": "2.0.0", 51 | "resolved": "https://registry.npmjs.org/@actions/io/-/io-2.0.0.tgz", 52 | "integrity": "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg==", 53 | "license": "MIT" 54 | }, 55 | "node_modules/@fastify/busboy": { 56 | "version": "2.1.1", 57 | "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", 58 | "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", 59 | "license": "MIT", 60 | "engines": { 61 | "node": ">=14" 62 | } 63 | }, 64 | "node_modules/@types/node": { 65 | "version": "25.0.2", 66 | "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.2.tgz", 67 | "integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==", 68 | "dev": true, 69 | "license": "MIT", 70 | "dependencies": { 71 | "undici-types": "~7.16.0" 72 | } 73 | }, 74 | "node_modules/fsevents": { 75 | "version": "2.3.2", 76 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 77 | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 78 | "hasInstallScript": true, 79 | "license": "MIT", 80 | "optional": true, 81 | "os": [ 82 | "darwin" 83 | ], 84 | "engines": { 85 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 86 | } 87 | }, 88 | "node_modules/playwright": { 89 | "version": "1.57.0", 90 | "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", 91 | "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", 92 | "license": "Apache-2.0", 93 | "dependencies": { 94 | "playwright-core": "1.57.0" 95 | }, 96 | "bin": { 97 | "playwright": "cli.js" 98 | }, 99 | "engines": { 100 | "node": ">=18" 101 | }, 102 | "optionalDependencies": { 103 | "fsevents": "2.3.2" 104 | } 105 | }, 106 | "node_modules/playwright-core": { 107 | "version": "1.57.0", 108 | "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", 109 | "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", 110 | "license": "Apache-2.0", 111 | "bin": { 112 | "playwright-core": "cli.js" 113 | }, 114 | "engines": { 115 | "node": ">=18" 116 | } 117 | }, 118 | "node_modules/tunnel": { 119 | "version": "0.0.6", 120 | "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", 121 | "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", 122 | "license": "MIT", 123 | "engines": { 124 | "node": ">=0.6.11 <=0.7.0 || >=0.7.3" 125 | } 126 | }, 127 | "node_modules/typescript": { 128 | "version": "5.9.3", 129 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 130 | "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 131 | "dev": true, 132 | "license": "Apache-2.0", 133 | "bin": { 134 | "tsc": "bin/tsc", 135 | "tsserver": "bin/tsserver" 136 | }, 137 | "engines": { 138 | "node": ">=14.17" 139 | } 140 | }, 141 | "node_modules/undici": { 142 | "version": "5.29.0", 143 | "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", 144 | "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", 145 | "license": "MIT", 146 | "dependencies": { 147 | "@fastify/busboy": "^2.0.0" 148 | }, 149 | "engines": { 150 | "node": ">=14.0" 151 | } 152 | }, 153 | "node_modules/undici-types": { 154 | "version": "7.16.0", 155 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", 156 | "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", 157 | "dev": true, 158 | "license": "MIT" 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "accessibility-scanner" 2 | description: "Finds potential accessibility gaps, files GitHub issues to track them, and attempts to fix them with Copilot." 3 | 4 | inputs: 5 | urls: 6 | description: "Newline-delimited list of URLs to check for accessibility issues" 7 | required: true 8 | multiline: true 9 | repository: 10 | description: "Repository (with owner) to file issues in" 11 | required: true 12 | token: 13 | description: "Personal access token (PAT) with fine-grained permissions 'contents: write', 'issues: write', and 'pull_requests: write'" 14 | required: true 15 | cache_key: 16 | description: "Key for caching results across runs" 17 | required: true 18 | login_url: 19 | description: "If scanned pages require authentication, the URL of the login page" 20 | required: false 21 | username: 22 | description: "If scanned pages require authentication, the username to use for login" 23 | required: false 24 | password: 25 | description: "If scanned pages require authentication, the password to use for login" 26 | required: false 27 | auth_context: 28 | description: "If scanned pages require authentication, a stringified JSON object containing 'username', 'password', 'cookies', and/or 'localStorage' from an authenticated session" 29 | required: false 30 | skip_copilot_assignment: 31 | description: "Whether to skip assigning filed issues to Copilot" 32 | required: false 33 | default: "false" 34 | 35 | outputs: 36 | results: 37 | description: "List of issues and pull requests filed (and their associated finding(s)), as stringified JSON" 38 | value: ${{ steps.results.outputs.results }} 39 | 40 | runs: 41 | using: "composite" 42 | steps: 43 | - name: Make sub-actions referenceable 44 | working-directory: ${{ github.action_path }} 45 | shell: bash 46 | run: | 47 | RUNNER_WORK_DIR="$(realpath "${GITHUB_WORKSPACE}/../..")" 48 | ACTION_DIR="${RUNNER_WORK_DIR}/_actions/github/accessibility-scanner/current" 49 | mkdir -p "${ACTION_DIR}/.github/actions" 50 | if [ "$(realpath ".github/actions")" != "$(realpath "${ACTION_DIR}/.github/actions")" ]; then 51 | cp -a ".github/actions/." "${ACTION_DIR}/.github/actions/" 52 | fi 53 | - name: Restore cached results 54 | id: restore 55 | uses: ./../../_actions/github/accessibility-scanner/current/.github/actions/gh-cache/cache 56 | with: 57 | key: ${{ inputs.cache_key }} 58 | token: ${{ inputs.token }} 59 | - if: ${{ steps.restore.outputs.value }} 60 | name: Normalize cache format 61 | id: normalize_cache 62 | shell: bash 63 | run: | 64 | # Migrate cache format from v1 to v2: 65 | # If cached data is a list of Finding objects, each with 'issueUrl' keys (i.e. v1), 66 | # convert to a list of (partial) Result objects, each with 'findings' and 'issue' keys (i.e. v2). 67 | # Otherwise, re-output as-is. 68 | printf '%s' "value=$(printf '%s' '${{ steps.restore.outputs.value }}' | jq -c 'if (type == "array" and length > 0 and (.[0] | has("issueUrl"))) then map({findings: [del(.issueUrl)], issue: {url: .issueUrl}}) else . end' )" >> $GITHUB_OUTPUT 69 | - if: ${{ inputs.login_url && inputs.username && inputs.password && !inputs.auth_context }} 70 | name: Authenticate 71 | id: auth 72 | uses: ./../../_actions/github/accessibility-scanner/current/.github/actions/auth 73 | with: 74 | login_url: ${{ inputs.login_url }} 75 | username: ${{ inputs.username }} 76 | password: ${{ inputs.password }} 77 | - name: Find 78 | id: find 79 | uses: ./../../_actions/github/accessibility-scanner/current/.github/actions/find 80 | with: 81 | urls: ${{ inputs.urls }} 82 | auth_context: ${{ inputs.auth_context || steps.auth.outputs.auth_context }} 83 | - name: File 84 | id: file 85 | uses: ./../../_actions/github/accessibility-scanner/current/.github/actions/file 86 | with: 87 | findings: ${{ steps.find.outputs.findings }} 88 | repository: ${{ inputs.repository }} 89 | token: ${{ inputs.token }} 90 | cached_filings: ${{ steps.normalize_cache.outputs.value }} 91 | - if: ${{ steps.file.outputs.filings }} 92 | name: Get issues from filings 93 | id: get_issues_from_filings 94 | shell: bash 95 | run: | 96 | # Extract open issues from Filing objects and output as a single-line JSON array 97 | issues=$(jq -c '[.[] | select(.issue.state == "open") | .issue]' <<< '${{ steps.file.outputs.filings }}') 98 | echo "issues=$issues" >> "$GITHUB_OUTPUT" 99 | - if: ${{ inputs.skip_copilot_assignment != 'true' }} 100 | name: Fix 101 | id: fix 102 | uses: ./../../_actions/github/accessibility-scanner/current/.github/actions/fix 103 | with: 104 | issues: ${{ steps.get_issues_from_filings.outputs.issues }} 105 | repository: ${{ inputs.repository }} 106 | token: ${{ inputs.token }} 107 | - name: Set results output 108 | id: results 109 | uses: actions/github-script@v8 110 | with: 111 | script: | 112 | const filings = ${{ steps.file.outputs.filings || '""' }} || []; 113 | const fixings = ${{ steps.fix.outputs.fixings || '""' }} || []; 114 | const fixingsByIssueUrl = fixings.reduce((acc, fixing) => { 115 | if (fixing.issue && fixing.issue.url) { 116 | acc[fixing.issue.url] = fixing; 117 | } 118 | return acc; 119 | }, {}); 120 | const results = filings; 121 | for (const result of results) { 122 | if (result.issue && result.issue.url && fixingsByIssueUrl[result.issue.url]) { 123 | result.pullRequest = fixingsByIssueUrl[result.issue.url].pullRequest; 124 | } 125 | } 126 | core.setOutput('results', JSON.stringify(results)); 127 | core.debug(`Results: ${JSON.stringify(results)}`); 128 | - name: Save cached results 129 | uses: ./../../_actions/github/accessibility-scanner/current/.github/actions/gh-cache/cache 130 | with: 131 | key: ${{ inputs.cache_key }} 132 | value: ${{ steps.results.outputs.results }} 133 | token: ${{ inputs.token }} 134 | 135 | branding: 136 | icon: "compass" 137 | color: "blue" 138 | -------------------------------------------------------------------------------- /.github/actions/find/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "find", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "find", 9 | "version": "1.0.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@actions/core": "^2.0.1", 13 | "@axe-core/playwright": "^4.11.0", 14 | "playwright": "^1.57.0" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "^25.0.2", 18 | "typescript": "^5.9.3" 19 | } 20 | }, 21 | "node_modules/@actions/core": { 22 | "version": "2.0.1", 23 | "resolved": "https://registry.npmjs.org/@actions/core/-/core-2.0.1.tgz", 24 | "integrity": "sha512-oBfqT3GwkvLlo1fjvhQLQxuwZCGTarTE5OuZ2Wg10hvhBj7LRIlF611WT4aZS6fDhO5ZKlY7lCAZTlpmyaHaeg==", 25 | "license": "MIT", 26 | "dependencies": { 27 | "@actions/exec": "^2.0.0", 28 | "@actions/http-client": "^3.0.0" 29 | } 30 | }, 31 | "node_modules/@actions/exec": { 32 | "version": "2.0.0", 33 | "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-2.0.0.tgz", 34 | "integrity": "sha512-k8ngrX2voJ/RIN6r9xB82NVqKpnMRtxDoiO+g3olkIUpQNqjArXrCQceduQZCQj3P3xm32pChRLqRrtXTlqhIw==", 35 | "license": "MIT", 36 | "dependencies": { 37 | "@actions/io": "^2.0.0" 38 | } 39 | }, 40 | "node_modules/@actions/http-client": { 41 | "version": "3.0.0", 42 | "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-3.0.0.tgz", 43 | "integrity": "sha512-1s3tXAfVMSz9a4ZEBkXXRQD4QhY3+GAsWSbaYpeknPOKEeyRiU3lH+bHiLMZdo2x/fIeQ/hscL1wCkDLVM2DZQ==", 44 | "license": "MIT", 45 | "dependencies": { 46 | "tunnel": "^0.0.6", 47 | "undici": "^5.28.5" 48 | } 49 | }, 50 | "node_modules/@actions/io": { 51 | "version": "2.0.0", 52 | "resolved": "https://registry.npmjs.org/@actions/io/-/io-2.0.0.tgz", 53 | "integrity": "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg==", 54 | "license": "MIT" 55 | }, 56 | "node_modules/@axe-core/playwright": { 57 | "version": "4.11.0", 58 | "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.0.tgz", 59 | "integrity": "sha512-70vBT/Ylqpm65RQz2iCG2o0JJCEG/WCNyefTr2xcOcr1CoSee60gNQYUMZZ7YukoKkFLv26I/jjlsvwwp532oQ==", 60 | "license": "MPL-2.0", 61 | "dependencies": { 62 | "axe-core": "~4.11.0" 63 | }, 64 | "peerDependencies": { 65 | "playwright-core": ">= 1.0.0" 66 | } 67 | }, 68 | "node_modules/@fastify/busboy": { 69 | "version": "2.1.1", 70 | "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", 71 | "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", 72 | "license": "MIT", 73 | "engines": { 74 | "node": ">=14" 75 | } 76 | }, 77 | "node_modules/@types/node": { 78 | "version": "25.0.2", 79 | "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.2.tgz", 80 | "integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==", 81 | "dev": true, 82 | "license": "MIT", 83 | "dependencies": { 84 | "undici-types": "~7.16.0" 85 | } 86 | }, 87 | "node_modules/axe-core": { 88 | "version": "4.11.0", 89 | "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", 90 | "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", 91 | "license": "MPL-2.0", 92 | "engines": { 93 | "node": ">=4" 94 | } 95 | }, 96 | "node_modules/fsevents": { 97 | "version": "2.3.2", 98 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 99 | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 100 | "hasInstallScript": true, 101 | "license": "MIT", 102 | "optional": true, 103 | "os": [ 104 | "darwin" 105 | ], 106 | "engines": { 107 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 108 | } 109 | }, 110 | "node_modules/playwright": { 111 | "version": "1.57.0", 112 | "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", 113 | "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", 114 | "license": "Apache-2.0", 115 | "dependencies": { 116 | "playwright-core": "1.57.0" 117 | }, 118 | "bin": { 119 | "playwright": "cli.js" 120 | }, 121 | "engines": { 122 | "node": ">=18" 123 | }, 124 | "optionalDependencies": { 125 | "fsevents": "2.3.2" 126 | } 127 | }, 128 | "node_modules/playwright-core": { 129 | "version": "1.57.0", 130 | "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", 131 | "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", 132 | "license": "Apache-2.0", 133 | "peer": true, 134 | "bin": { 135 | "playwright-core": "cli.js" 136 | }, 137 | "engines": { 138 | "node": ">=18" 139 | } 140 | }, 141 | "node_modules/tunnel": { 142 | "version": "0.0.6", 143 | "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", 144 | "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", 145 | "license": "MIT", 146 | "engines": { 147 | "node": ">=0.6.11 <=0.7.0 || >=0.7.3" 148 | } 149 | }, 150 | "node_modules/typescript": { 151 | "version": "5.9.3", 152 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 153 | "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 154 | "dev": true, 155 | "license": "Apache-2.0", 156 | "bin": { 157 | "tsc": "bin/tsc", 158 | "tsserver": "bin/tsserver" 159 | }, 160 | "engines": { 161 | "node": ">=14.17" 162 | } 163 | }, 164 | "node_modules/undici": { 165 | "version": "5.29.0", 166 | "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", 167 | "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", 168 | "license": "MIT", 169 | "dependencies": { 170 | "@fastify/busboy": "^2.0.0" 171 | }, 172 | "engines": { 173 | "node": ">=14.0" 174 | } 175 | }, 176 | "node_modules/undici-types": { 177 | "version": "7.16.0", 178 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", 179 | "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", 180 | "dev": true, 181 | "license": "MIT" 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /tests/site-with-errors.test.ts: -------------------------------------------------------------------------------- 1 | import type { Endpoints } from "@octokit/types" 2 | import type { Result } from "./types.d.js"; 3 | import fs from "node:fs"; 4 | import { describe, it, expect, beforeAll } from "vitest"; 5 | import { Octokit } from "@octokit/core"; 6 | import { throttling } from "@octokit/plugin-throttling"; 7 | const OctokitWithThrottling = Octokit.plugin(throttling); 8 | 9 | describe("site-with-errors", () => { 10 | let results: Result[]; 11 | 12 | beforeAll(() => { 13 | expect(process.env.CACHE_PATH).toBeDefined(); 14 | expect(fs.existsSync(process.env.CACHE_PATH!)).toBe(true); 15 | results = JSON.parse(fs.readFileSync(process.env.CACHE_PATH!, "utf-8")); 16 | }); 17 | 18 | it("cache has expected results", () => { 19 | const actual = results.map(({ issue: { url: issueUrl }, pullRequest: { url: pullRequestUrl }, findings }) => { 20 | const { problemUrl, solutionLong, ...finding } = findings[0]; 21 | // Check volatile fields for existence only 22 | expect(issueUrl).toBeDefined(); 23 | expect(pullRequestUrl).toBeDefined(); 24 | expect(problemUrl).toBeDefined(); 25 | expect(solutionLong).toBeDefined(); 26 | // Check `problemUrl`, ignoring axe version 27 | expect(problemUrl.startsWith("https://dequeuniversity.com/rules/axe/")).toBe(true); 28 | expect(problemUrl.endsWith(`/${finding.ruleId}?application=playwright`)).toBe(true); 29 | return finding; 30 | }); 31 | const expected = [ 32 | { 33 | scannerType: "axe", 34 | url: "http://127.0.0.1:4000/", 35 | html: '', 36 | problemShort: "elements must meet minimum color contrast ratio thresholds", 37 | ruleId: "color-contrast", 38 | solutionShort: "ensure the contrast between foreground and background colors meets wcag 2 aa minimum contrast ratio thresholds" 39 | }, { 40 | scannerType: "axe", 41 | url: "http://127.0.0.1:4000/", 42 | html: '', 43 | problemShort: "page should contain a level-one heading", 44 | ruleId: "page-has-heading-one", 45 | solutionShort: "ensure that the page, or at least one of its frames contains a level-one heading" 46 | }, { 47 | scannerType: "axe", 48 | url: "http://127.0.0.1:4000/jekyll/update/2025/07/30/welcome-to-jekyll.html", 49 | html: ``, 51 | problemShort: "elements must meet minimum color contrast ratio thresholds", 52 | ruleId: "color-contrast", 53 | solutionShort: "ensure the contrast between foreground and background colors meets wcag 2 aa minimum contrast ratio thresholds", 54 | }, { 55 | scannerType: "axe", 56 | url: "http://127.0.0.1:4000/about/", 57 | html: 'jekyllrb.com', 58 | problemShort: "elements must meet minimum color contrast ratio thresholds", 59 | ruleId: "color-contrast", 60 | solutionShort: "ensure the contrast between foreground and background colors meets wcag 2 aa minimum contrast ratio thresholds", 61 | }, { 62 | scannerType: "axe", 63 | url: "http://127.0.0.1:4000/404.html", 64 | html: '
  • Accessibility Scanner Demo
  • ', 65 | problemShort: "elements must meet minimum color contrast ratio thresholds", 66 | ruleId: "color-contrast", 67 | solutionShort: "ensure the contrast between foreground and background colors meets wcag 2 aa minimum contrast ratio thresholds" 68 | }, { 69 | scannerType: "axe", 70 | url: "http://127.0.0.1:4000/404.html", 71 | html: '

    ', 72 | problemShort: "headings should not be empty", 73 | ruleId: "empty-heading", 74 | solutionShort: "ensure headings have discernible text", 75 | }, 76 | ]; 77 | // Check that: 78 | // - every expected object exists (no more and no fewer), and 79 | // - each object has all fields, and 80 | // - field values match expectations exactly 81 | // A specific order is _not_ enforced. 82 | expect(actual).toHaveLength(expected.length); 83 | expect(actual).toEqual(expect.arrayContaining(expected)); 84 | }); 85 | 86 | it("GITHUB_TOKEN environment variable is set", () => { 87 | expect(process.env.GITHUB_TOKEN).toBeDefined(); 88 | }); 89 | 90 | describe.runIf(!!process.env.GITHUB_TOKEN)("—", () => { 91 | let octokit: Octokit; 92 | let issues: Endpoints["GET /repos/{owner}/{repo}/issues/{issue_number}"]["response"]["data"][]; 93 | let pullRequests: Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}"]["response"]["data"][]; 94 | 95 | beforeAll(async () => { 96 | octokit = new OctokitWithThrottling({ 97 | auth: process.env.GITHUB_TOKEN, 98 | throttle: { 99 | onRateLimit: (retryAfter, options, octokit, retryCount) => { 100 | octokit.log.warn(`Request quota exhausted for request ${options.method} ${options.url}`); 101 | if (retryCount < 3) { 102 | octokit.log.info(`Retrying after ${retryAfter} seconds!`); 103 | return true; 104 | } 105 | }, 106 | onSecondaryRateLimit: (retryAfter, options, octokit, retryCount) => { 107 | octokit.log.warn(`Secondary rate limit hit for request ${options.method} ${options.url}`); 108 | if (retryCount < 3) { 109 | octokit.log.info(`Retrying after ${retryAfter} seconds!`); 110 | return true; 111 | } 112 | }, 113 | } 114 | }); 115 | // Fetch issues referenced in the cache file 116 | issues = await Promise.all(results.map(async ({ issue: { url: issueUrl } }) => { 117 | expect(issueUrl).toBeDefined(); 118 | const { owner, repo, issueNumber } = 119 | /https:\/\/github\.com\/(?[^/]+)\/(?[^/]+)\/issues\/(?\d+)/.exec(issueUrl!)!.groups!; 120 | const {data: issue} = await octokit.request("GET /repos/{owner}/{repo}/issues/{issue_number}", { 121 | owner, 122 | repo, 123 | issue_number: parseInt(issueNumber, 10) 124 | }); 125 | expect(issue).toBeDefined(); 126 | return issue; 127 | })); 128 | // Fetch pull requests referenced in the findings file 129 | pullRequests = await Promise.all(results.map(async ({ pullRequest: { url: pullRequestUrl } }) => { 130 | expect(pullRequestUrl).toBeDefined(); 131 | const { owner, repo, pullNumber } = 132 | /https:\/\/github\.com\/(?[^/]+)\/(?[^/]+)\/pull\/(?\d+)/.exec(pullRequestUrl!)!.groups!; 133 | const {data: pullRequest} = await octokit.request("GET /repos/{owner}/{repo}/pulls/{pull_number}", { 134 | owner, 135 | repo, 136 | pull_number: parseInt(pullNumber, 10) 137 | }); 138 | expect(pullRequest).toBeDefined(); 139 | return pullRequest; 140 | })); 141 | }); 142 | 143 | it("issues exist and have expected title, state, and assignee", async () => { 144 | const actualTitles = issues.map(({ title }) => (title)); 145 | const expectedTitles = [ 146 | "Accessibility issue: Elements must meet minimum color contrast ratio thresholds on /", 147 | "Accessibility issue: Page should contain a level-one heading on /", 148 | "Accessibility issue: Elements must meet minimum color contrast ratio thresholds on /404.html", 149 | "Accessibility issue: Headings should not be empty on /404.html", 150 | "Accessibility issue: Elements must meet minimum color contrast ratio thresholds on /about/", 151 | "Accessibility issue: Elements must meet minimum color contrast ratio thresholds on /jekyll/update/2025/07/30/welcome-to-jekyll.html", 152 | ]; 153 | expect(actualTitles).toHaveLength(expectedTitles.length); 154 | expect(actualTitles).toEqual(expect.arrayContaining(expectedTitles)); 155 | for (const issue of issues) { 156 | expect(issue.state).toBe("open"); 157 | expect(issue.assignees).toBeDefined(); 158 | expect(issue.assignees!.some(a => a.login === "Copilot")).toBe(true); 159 | } 160 | }); 161 | 162 | it("pull requests exist and have expected author, state, and assignee", async () => { 163 | for (const pullRequest of pullRequests) { 164 | expect(pullRequest.user.login).toBe("Copilot"); 165 | expect(pullRequest.state).toBe("open"); 166 | expect(pullRequest.assignees).toBeDefined(); 167 | expect(pullRequest.assignees!.some(a => a.login === "Copilot")).toBe(true); 168 | } 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI-powered Accessibility Scanner 2 | 3 | The AI-powered Accessibility Scanner (a11y scanner) is a GitHub Action that detects accessibility barriers across your digital products, creates trackable issues, and leverages GitHub Copilot for AI-powered fixes. 4 | 5 | The a11y scanner helps teams: 6 | 7 | - 🔍 Scan websites, files, repositories, and dynamic content for accessibility issues 8 | - 📝 Create actionable GitHub issues that can be assigned to GitHub Copilot 9 | - 🤖 Propose fixes with GitHub Copilot, with humans reviewing before merging 10 | 11 | > ⚠️ **Note:** The a11y scanner is currently in public preview. Feature development work is still ongoing. It can help identify accessibility gaps but cannot guarantee fully accessible code suggestions. Always review before merging! 12 | 13 | 🎥 **[Watch the demo video](https://youtu.be/CvRJcEzCSQM)** to see the a11y scanner in action. 14 | 15 | --- 16 | 17 | ## Requirements 18 | 19 | To use the a11y scanner, you'll need: 20 | 21 | - **GitHub Actions** enabled in your repository 22 | - **GitHub Issues** enabled in your repository 23 | - **Available GitHub Actions minutes** for your account 24 | - **Admin access** to add repository secrets 25 | - **GitHub Copilot** (optional) - The a11y scanner works without GitHub Copilot and will still create issues for accessibility findings. However, without GitHub Copilot, you won't be able to automatically assign issues to GitHub Copilot for AI-powered fix suggestions and PR creation. 26 | 27 | ## Getting started 28 | 29 | ### 1. Add a workflow file 30 | 31 | Create a workflow file in `.github/workflows/` (e.g., `a11y-scan.yml`) in your repository: 32 | 33 | ```yaml 34 | name: Accessibility Scanner 35 | on: workflow_dispatch # This configures the workflow to run manually, instead of (e.g.) automatically in every PR. Check out https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#on for more options. 36 | 37 | jobs: 38 | accessibility_scanner: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: github/accessibility-scanner@v2 42 | with: 43 | urls: | # Provide a newline-delimited list of URLs to scan; more information below. 44 | REPLACE_THIS 45 | repository: REPLACE_THIS/REPLACE_THIS # Provide a repository name-with-owner (in the format "primer/primer-docs"). This is where issues will be filed and where Copilot will open PRs; more information below. 46 | token: ${{ secrets.GH_TOKEN }} # This token must have write access to the repo above (contents, issues, and PRs); more information below. Note: GitHub Actions' GITHUB_TOKEN cannot be used here. 47 | cache_key: REPLACE_THIS # Provide a filename that will be used when caching results. We recommend including the name or domain of the site being scanned. 48 | # login_url: # Optional: URL of the login page if authentication is required 49 | # username: # Optional: Username for authentication 50 | # password: ${{ secrets.PASSWORD }} # Optional: Password for authentication (use secrets!) 51 | # auth_context: # Optional: Stringified JSON object for complex authentication 52 | # skip_copilot_assignment: false # Optional: Set to true to skip assigning issues to GitHub Copilot (or if you don't have GitHub Copilot) 53 | ``` 54 | 55 | > 👉 Update all `REPLACE_THIS` placeholders with your actual values. See [Action Inputs](#action-inputs) for details. 56 | 57 | **Required permissions:** 58 | 59 | - Write access to add or update workflows 60 | - Admin access to add repository secrets 61 | 62 | 📚 Learn more 63 | - [Quickstart for GitHub Actions](https://docs.github.com/en/actions/get-started/quickstart) 64 | - [Understanding GitHub Actions](https://docs.github.com/en/actions/get-started/understand-github-actions) 65 | - [Writing workflows](https://docs.github.com/en/actions/how-tos/write-workflows) 66 | - [Managing GitHub Actions settings](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository) 67 | - [GitHub Actions billing](https://docs.github.com/en/billing/concepts/product-billing/github-actions) 68 | 69 | --- 70 | 71 | ### 2. Create a token and add a secret 72 | 73 | The a11y scanner requires a Personal Access Token (PAT) as a repository secret: 74 | 75 | **The `GH_TOKEN` is a fine-grained PAT with:** 76 | 77 | - `actions: write` 78 | - `contents: write` 79 | - `issues: write` 80 | - `pull-requests: write` 81 | - `metadata: read` 82 | - **Scope:** Your target repository (where issues and PRs will be created) and the repository containing your workflow 83 | 84 | > 👉 GitHub Actions' default [GITHUB_TOKEN](https://docs.github.com/en/actions/tutorials/authenticate-with-github_token) cannot be used here. 85 | 86 | 📚 Learn more 87 | - [Creating a fine-grained PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token) 88 | - [Creating repository secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#creating-secrets-for-a-repository) 89 | 90 | --- 91 | 92 | ### 3. Run your first scan 93 | 94 | Trigger the workflow manually or automatically based on your configuration. The a11y scanner will run and create issues for any accessibility findings. When issues are assigned to GitHub Copilot, always review proposed fixes before merging. 95 | 96 | 📚 Learn more 97 | - [View workflow run history](https://docs.github.com/en/actions/how-tos/monitor-workflows/view-workflow-run-history) 98 | - [Running a workflow manually](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow#running-a-workflow) 99 | - [Re-run workflows and jobs](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs) 100 | 101 | --- 102 | 103 | ## Action inputs 104 | 105 | | Input | Required | Description | Example | 106 | |-------|----------|-------------|---------| 107 | | `urls` | Yes | Newline-delimited list of URLs to scan | `https://primer.style`
    `https://primer.style/octicons` | 108 | | `repository` | Yes | Repository (with owner) for issues and PRs | `primer/primer-docs` | 109 | | `token` | Yes | PAT with write permissions (see above) | `${{ secrets.GH_TOKEN }}` | 110 | | `cache_key` | Yes | Key for caching results across runs
    Allowed: `A-Za-z0-9._/-` | `cached_results-primer.style-main.json` | 111 | | `login_url` | No | If scanned pages require authentication, the URL of the login page | `https://github.com/login` | 112 | | `username` | No | If scanned pages require authentication, the username to use for login | `some-user` | 113 | | `password` | No | If scanned pages require authentication, the password to use for login | `${{ secrets.PASSWORD }}` | 114 | | `auth_context` | No | If scanned pages require authentication, a stringified JSON object containing username, password, cookies, and/or localStorage from an authenticated session | `{"username":"some-user","password":"***","cookies":[...]}` | 115 | | `skip_copilot_assignment` | No | Whether to skip assigning filed issues to GitHub Copilot. Set to `true` if you don't have GitHub Copilot or prefer to handle issues manually | `true` | 116 | 117 | --- 118 | 119 | ## Authentication 120 | 121 | If access to a page requires logging-in first, and logging-in requires only a username and password, then provide the `login_url`, `username`, and `password` inputs. 122 | 123 | If your login flow is more complex—if it requires two-factor authentication, single sign-on, passkeys, etc.—and you have a custom action that [authenticates with Playwright](https://playwright.dev/docs/auth) and persists authenticated session state to a file, then provide the `auth_context` input. (If `auth_context` is provided, `login_url`, `username`, and `password` will be ignored.) 124 | 125 | > [!IMPORTANT] 126 | > Don't put passwords in your workflow as plain text; instead reference a [repository secret](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#creating-secrets-for-a-repository). 127 | 128 | --- 129 | 130 | ## Configuring GitHub Copilot 131 | 132 | The a11y scanner leverages GitHub Copilot coding agent, which can be configured with custom instructions: 133 | 134 | - **Repository-wide:** `.github/copilot-instructions.md` 135 | - **Directory/file-scoped:** `.github/instructions/*.instructions.md` 136 | 137 | 📚 Learn more 138 | - [Adding repository custom instructions](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions) 139 | - [Optimizing GitHub Copilot for accessibility](https://accessibility.github.com/documentation/guide/copilot-instructions) 140 | - [GitHub Copilot .instructions.md support](https://github.blog/changelog/2025-07-23-github-copilot-coding-agent-now-supports-instructions-md-custom-instructions/) 141 | - [GitHub Copilot agents.md support](https://github.blog/changelog/2025-08-28-copilot-coding-agent-now-supports-agents-md-custom-instructions) 142 | 143 | --- 144 | 145 | ## Feedback 146 | 147 | 💬 We welcome your feedback! To submit feedback or report issues, please create an issue in this repository. For more information on contributing, please refer to the [CONTRIBUTING](./CONTRIBUTING.md) file. 148 | 149 | ## License 150 | 151 | 📄 This project is licensed under the terms of the MIT open source license. Please refer to the [LICENSE](./LICENSE) file for the full terms. 152 | 153 | ## Maintainers 154 | 155 | 🔧 Please refer to the [CODEOWNERS](./.github/CODEOWNERS) file for more information. 156 | 157 | ## Support 158 | 159 | ❓ Please refer to the [SUPPORT](./SUPPORT.md) file for more information. 160 | 161 | ## Acknowledgement 162 | 163 | ✨ Thank you to our beta testers for their help with making this project! 164 | -------------------------------------------------------------------------------- /.github/actions/fix/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fix", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "fix", 9 | "version": "1.0.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@actions/core": "^2.0.1", 13 | "@octokit/core": "^7.0.6", 14 | "@octokit/plugin-throttling": "^11.0.3" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "^25.0.2", 18 | "typescript": "^5.9.3" 19 | } 20 | }, 21 | "node_modules/@actions/core": { 22 | "version": "2.0.1", 23 | "resolved": "https://registry.npmjs.org/@actions/core/-/core-2.0.1.tgz", 24 | "integrity": "sha512-oBfqT3GwkvLlo1fjvhQLQxuwZCGTarTE5OuZ2Wg10hvhBj7LRIlF611WT4aZS6fDhO5ZKlY7lCAZTlpmyaHaeg==", 25 | "license": "MIT", 26 | "dependencies": { 27 | "@actions/exec": "^2.0.0", 28 | "@actions/http-client": "^3.0.0" 29 | } 30 | }, 31 | "node_modules/@actions/exec": { 32 | "version": "2.0.0", 33 | "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-2.0.0.tgz", 34 | "integrity": "sha512-k8ngrX2voJ/RIN6r9xB82NVqKpnMRtxDoiO+g3olkIUpQNqjArXrCQceduQZCQj3P3xm32pChRLqRrtXTlqhIw==", 35 | "license": "MIT", 36 | "dependencies": { 37 | "@actions/io": "^2.0.0" 38 | } 39 | }, 40 | "node_modules/@actions/http-client": { 41 | "version": "3.0.0", 42 | "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-3.0.0.tgz", 43 | "integrity": "sha512-1s3tXAfVMSz9a4ZEBkXXRQD4QhY3+GAsWSbaYpeknPOKEeyRiU3lH+bHiLMZdo2x/fIeQ/hscL1wCkDLVM2DZQ==", 44 | "license": "MIT", 45 | "dependencies": { 46 | "tunnel": "^0.0.6", 47 | "undici": "^5.28.5" 48 | } 49 | }, 50 | "node_modules/@actions/io": { 51 | "version": "2.0.0", 52 | "resolved": "https://registry.npmjs.org/@actions/io/-/io-2.0.0.tgz", 53 | "integrity": "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg==", 54 | "license": "MIT" 55 | }, 56 | "node_modules/@fastify/busboy": { 57 | "version": "2.1.1", 58 | "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", 59 | "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", 60 | "license": "MIT", 61 | "engines": { 62 | "node": ">=14" 63 | } 64 | }, 65 | "node_modules/@octokit/auth-token": { 66 | "version": "6.0.0", 67 | "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", 68 | "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", 69 | "license": "MIT", 70 | "engines": { 71 | "node": ">= 20" 72 | } 73 | }, 74 | "node_modules/@octokit/core": { 75 | "version": "7.0.6", 76 | "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", 77 | "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", 78 | "license": "MIT", 79 | "peer": true, 80 | "dependencies": { 81 | "@octokit/auth-token": "^6.0.0", 82 | "@octokit/graphql": "^9.0.3", 83 | "@octokit/request": "^10.0.6", 84 | "@octokit/request-error": "^7.0.2", 85 | "@octokit/types": "^16.0.0", 86 | "before-after-hook": "^4.0.0", 87 | "universal-user-agent": "^7.0.0" 88 | }, 89 | "engines": { 90 | "node": ">= 20" 91 | } 92 | }, 93 | "node_modules/@octokit/endpoint": { 94 | "version": "11.0.2", 95 | "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", 96 | "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", 97 | "license": "MIT", 98 | "dependencies": { 99 | "@octokit/types": "^16.0.0", 100 | "universal-user-agent": "^7.0.2" 101 | }, 102 | "engines": { 103 | "node": ">= 20" 104 | } 105 | }, 106 | "node_modules/@octokit/graphql": { 107 | "version": "9.0.3", 108 | "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", 109 | "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", 110 | "license": "MIT", 111 | "dependencies": { 112 | "@octokit/request": "^10.0.6", 113 | "@octokit/types": "^16.0.0", 114 | "universal-user-agent": "^7.0.0" 115 | }, 116 | "engines": { 117 | "node": ">= 20" 118 | } 119 | }, 120 | "node_modules/@octokit/openapi-types": { 121 | "version": "27.0.0", 122 | "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", 123 | "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", 124 | "license": "MIT" 125 | }, 126 | "node_modules/@octokit/plugin-throttling": { 127 | "version": "11.0.3", 128 | "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.3.tgz", 129 | "integrity": "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==", 130 | "license": "MIT", 131 | "dependencies": { 132 | "@octokit/types": "^16.0.0", 133 | "bottleneck": "^2.15.3" 134 | }, 135 | "engines": { 136 | "node": ">= 20" 137 | }, 138 | "peerDependencies": { 139 | "@octokit/core": "^7.0.0" 140 | } 141 | }, 142 | "node_modules/@octokit/request": { 143 | "version": "10.0.6", 144 | "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.6.tgz", 145 | "integrity": "sha512-FO+UgZCUu+pPnZAR+iKdUt64kPE7QW7ciqpldaMXaNzixz5Jld8dJ31LAUewk0cfSRkNSRKyqG438ba9c/qDlQ==", 146 | "license": "MIT", 147 | "dependencies": { 148 | "@octokit/endpoint": "^11.0.2", 149 | "@octokit/request-error": "^7.0.2", 150 | "@octokit/types": "^16.0.0", 151 | "fast-content-type-parse": "^3.0.0", 152 | "universal-user-agent": "^7.0.2" 153 | }, 154 | "engines": { 155 | "node": ">= 20" 156 | } 157 | }, 158 | "node_modules/@octokit/request-error": { 159 | "version": "7.0.2", 160 | "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.2.tgz", 161 | "integrity": "sha512-U8piOROoQQUyExw5c6dTkU3GKxts5/ERRThIauNL7yaRoeXW0q/5bgHWT7JfWBw1UyrbK8ERId2wVkcB32n0uQ==", 162 | "license": "MIT", 163 | "dependencies": { 164 | "@octokit/types": "^16.0.0" 165 | }, 166 | "engines": { 167 | "node": ">= 20" 168 | } 169 | }, 170 | "node_modules/@octokit/types": { 171 | "version": "16.0.0", 172 | "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", 173 | "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", 174 | "license": "MIT", 175 | "dependencies": { 176 | "@octokit/openapi-types": "^27.0.0" 177 | } 178 | }, 179 | "node_modules/@types/node": { 180 | "version": "25.0.2", 181 | "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.2.tgz", 182 | "integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==", 183 | "dev": true, 184 | "license": "MIT", 185 | "dependencies": { 186 | "undici-types": "~7.16.0" 187 | } 188 | }, 189 | "node_modules/before-after-hook": { 190 | "version": "4.0.0", 191 | "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", 192 | "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", 193 | "license": "Apache-2.0" 194 | }, 195 | "node_modules/bottleneck": { 196 | "version": "2.19.5", 197 | "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", 198 | "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", 199 | "license": "MIT" 200 | }, 201 | "node_modules/fast-content-type-parse": { 202 | "version": "3.0.0", 203 | "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", 204 | "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", 205 | "funding": [ 206 | { 207 | "type": "github", 208 | "url": "https://github.com/sponsors/fastify" 209 | }, 210 | { 211 | "type": "opencollective", 212 | "url": "https://opencollective.com/fastify" 213 | } 214 | ], 215 | "license": "MIT" 216 | }, 217 | "node_modules/tunnel": { 218 | "version": "0.0.6", 219 | "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", 220 | "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", 221 | "license": "MIT", 222 | "engines": { 223 | "node": ">=0.6.11 <=0.7.0 || >=0.7.3" 224 | } 225 | }, 226 | "node_modules/typescript": { 227 | "version": "5.9.3", 228 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 229 | "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 230 | "dev": true, 231 | "license": "Apache-2.0", 232 | "bin": { 233 | "tsc": "bin/tsc", 234 | "tsserver": "bin/tsserver" 235 | }, 236 | "engines": { 237 | "node": ">=14.17" 238 | } 239 | }, 240 | "node_modules/undici": { 241 | "version": "5.29.0", 242 | "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", 243 | "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", 244 | "license": "MIT", 245 | "dependencies": { 246 | "@fastify/busboy": "^2.0.0" 247 | }, 248 | "engines": { 249 | "node": ">=14.0" 250 | } 251 | }, 252 | "node_modules/undici-types": { 253 | "version": "7.16.0", 254 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", 255 | "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", 256 | "dev": true, 257 | "license": "MIT" 258 | }, 259 | "node_modules/universal-user-agent": { 260 | "version": "7.0.3", 261 | "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", 262 | "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", 263 | "license": "ISC" 264 | } 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /.github/actions/file/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "file", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "file", 9 | "version": "1.0.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@actions/core": "^2.0.1", 13 | "@octokit/core": "^7.0.6", 14 | "@octokit/plugin-throttling": "^11.0.3" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "^25.0.2", 18 | "typescript": "^5.9.3" 19 | } 20 | }, 21 | "node_modules/@actions/core": { 22 | "version": "2.0.1", 23 | "resolved": "https://registry.npmjs.org/@actions/core/-/core-2.0.1.tgz", 24 | "integrity": "sha512-oBfqT3GwkvLlo1fjvhQLQxuwZCGTarTE5OuZ2Wg10hvhBj7LRIlF611WT4aZS6fDhO5ZKlY7lCAZTlpmyaHaeg==", 25 | "license": "MIT", 26 | "dependencies": { 27 | "@actions/exec": "^2.0.0", 28 | "@actions/http-client": "^3.0.0" 29 | } 30 | }, 31 | "node_modules/@actions/exec": { 32 | "version": "2.0.0", 33 | "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-2.0.0.tgz", 34 | "integrity": "sha512-k8ngrX2voJ/RIN6r9xB82NVqKpnMRtxDoiO+g3olkIUpQNqjArXrCQceduQZCQj3P3xm32pChRLqRrtXTlqhIw==", 35 | "license": "MIT", 36 | "dependencies": { 37 | "@actions/io": "^2.0.0" 38 | } 39 | }, 40 | "node_modules/@actions/http-client": { 41 | "version": "3.0.0", 42 | "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-3.0.0.tgz", 43 | "integrity": "sha512-1s3tXAfVMSz9a4ZEBkXXRQD4QhY3+GAsWSbaYpeknPOKEeyRiU3lH+bHiLMZdo2x/fIeQ/hscL1wCkDLVM2DZQ==", 44 | "license": "MIT", 45 | "dependencies": { 46 | "tunnel": "^0.0.6", 47 | "undici": "^5.28.5" 48 | } 49 | }, 50 | "node_modules/@actions/io": { 51 | "version": "2.0.0", 52 | "resolved": "https://registry.npmjs.org/@actions/io/-/io-2.0.0.tgz", 53 | "integrity": "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg==", 54 | "license": "MIT" 55 | }, 56 | "node_modules/@fastify/busboy": { 57 | "version": "2.1.1", 58 | "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", 59 | "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", 60 | "license": "MIT", 61 | "engines": { 62 | "node": ">=14" 63 | } 64 | }, 65 | "node_modules/@octokit/auth-token": { 66 | "version": "6.0.0", 67 | "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", 68 | "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", 69 | "license": "MIT", 70 | "engines": { 71 | "node": ">= 20" 72 | } 73 | }, 74 | "node_modules/@octokit/core": { 75 | "version": "7.0.6", 76 | "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", 77 | "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", 78 | "license": "MIT", 79 | "peer": true, 80 | "dependencies": { 81 | "@octokit/auth-token": "^6.0.0", 82 | "@octokit/graphql": "^9.0.3", 83 | "@octokit/request": "^10.0.6", 84 | "@octokit/request-error": "^7.0.2", 85 | "@octokit/types": "^16.0.0", 86 | "before-after-hook": "^4.0.0", 87 | "universal-user-agent": "^7.0.0" 88 | }, 89 | "engines": { 90 | "node": ">= 20" 91 | } 92 | }, 93 | "node_modules/@octokit/endpoint": { 94 | "version": "11.0.2", 95 | "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", 96 | "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", 97 | "license": "MIT", 98 | "dependencies": { 99 | "@octokit/types": "^16.0.0", 100 | "universal-user-agent": "^7.0.2" 101 | }, 102 | "engines": { 103 | "node": ">= 20" 104 | } 105 | }, 106 | "node_modules/@octokit/graphql": { 107 | "version": "9.0.3", 108 | "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", 109 | "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", 110 | "license": "MIT", 111 | "dependencies": { 112 | "@octokit/request": "^10.0.6", 113 | "@octokit/types": "^16.0.0", 114 | "universal-user-agent": "^7.0.0" 115 | }, 116 | "engines": { 117 | "node": ">= 20" 118 | } 119 | }, 120 | "node_modules/@octokit/openapi-types": { 121 | "version": "27.0.0", 122 | "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", 123 | "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", 124 | "license": "MIT" 125 | }, 126 | "node_modules/@octokit/plugin-throttling": { 127 | "version": "11.0.3", 128 | "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.3.tgz", 129 | "integrity": "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==", 130 | "license": "MIT", 131 | "dependencies": { 132 | "@octokit/types": "^16.0.0", 133 | "bottleneck": "^2.15.3" 134 | }, 135 | "engines": { 136 | "node": ">= 20" 137 | }, 138 | "peerDependencies": { 139 | "@octokit/core": "^7.0.0" 140 | } 141 | }, 142 | "node_modules/@octokit/request": { 143 | "version": "10.0.6", 144 | "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.6.tgz", 145 | "integrity": "sha512-FO+UgZCUu+pPnZAR+iKdUt64kPE7QW7ciqpldaMXaNzixz5Jld8dJ31LAUewk0cfSRkNSRKyqG438ba9c/qDlQ==", 146 | "license": "MIT", 147 | "dependencies": { 148 | "@octokit/endpoint": "^11.0.2", 149 | "@octokit/request-error": "^7.0.2", 150 | "@octokit/types": "^16.0.0", 151 | "fast-content-type-parse": "^3.0.0", 152 | "universal-user-agent": "^7.0.2" 153 | }, 154 | "engines": { 155 | "node": ">= 20" 156 | } 157 | }, 158 | "node_modules/@octokit/request-error": { 159 | "version": "7.0.2", 160 | "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.2.tgz", 161 | "integrity": "sha512-U8piOROoQQUyExw5c6dTkU3GKxts5/ERRThIauNL7yaRoeXW0q/5bgHWT7JfWBw1UyrbK8ERId2wVkcB32n0uQ==", 162 | "license": "MIT", 163 | "dependencies": { 164 | "@octokit/types": "^16.0.0" 165 | }, 166 | "engines": { 167 | "node": ">= 20" 168 | } 169 | }, 170 | "node_modules/@octokit/types": { 171 | "version": "16.0.0", 172 | "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", 173 | "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", 174 | "license": "MIT", 175 | "dependencies": { 176 | "@octokit/openapi-types": "^27.0.0" 177 | } 178 | }, 179 | "node_modules/@types/node": { 180 | "version": "25.0.2", 181 | "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.2.tgz", 182 | "integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==", 183 | "dev": true, 184 | "license": "MIT", 185 | "dependencies": { 186 | "undici-types": "~7.16.0" 187 | } 188 | }, 189 | "node_modules/before-after-hook": { 190 | "version": "4.0.0", 191 | "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", 192 | "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", 193 | "license": "Apache-2.0" 194 | }, 195 | "node_modules/bottleneck": { 196 | "version": "2.19.5", 197 | "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", 198 | "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", 199 | "license": "MIT" 200 | }, 201 | "node_modules/fast-content-type-parse": { 202 | "version": "3.0.0", 203 | "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", 204 | "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", 205 | "funding": [ 206 | { 207 | "type": "github", 208 | "url": "https://github.com/sponsors/fastify" 209 | }, 210 | { 211 | "type": "opencollective", 212 | "url": "https://opencollective.com/fastify" 213 | } 214 | ], 215 | "license": "MIT" 216 | }, 217 | "node_modules/tunnel": { 218 | "version": "0.0.6", 219 | "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", 220 | "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", 221 | "license": "MIT", 222 | "engines": { 223 | "node": ">=0.6.11 <=0.7.0 || >=0.7.3" 224 | } 225 | }, 226 | "node_modules/typescript": { 227 | "version": "5.9.3", 228 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 229 | "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 230 | "dev": true, 231 | "license": "Apache-2.0", 232 | "bin": { 233 | "tsc": "bin/tsc", 234 | "tsserver": "bin/tsserver" 235 | }, 236 | "engines": { 237 | "node": ">=14.17" 238 | } 239 | }, 240 | "node_modules/undici": { 241 | "version": "5.29.0", 242 | "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", 243 | "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", 244 | "license": "MIT", 245 | "dependencies": { 246 | "@fastify/busboy": "^2.0.0" 247 | }, 248 | "engines": { 249 | "node": ">=14.0" 250 | } 251 | }, 252 | "node_modules/undici-types": { 253 | "version": "7.16.0", 254 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", 255 | "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", 256 | "dev": true, 257 | "license": "MIT" 258 | }, 259 | "node_modules/universal-user-agent": { 260 | "version": "7.0.3", 261 | "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", 262 | "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", 263 | "license": "ISC" 264 | } 265 | } 266 | } 267 | --------------------------------------------------------------------------------