From d6da10b8b7fab10e4212e767472cc58d471e3633 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 31 Oct 2026 11:09:36 -0700 Subject: [PATCH 1/4] ci: a default touched-components check runs visual, a11y and RTL for the components a pull request changes Every pull request without a ci:run- label runs one default check for only the components it touches, in every package, against its Storybook build. The visual part is pr-visual's new default mode, a focused check of the touched Core components that never escalates to the release plan, so pr-visual stays the one visual owner (AST-030 FR12). The accessibility and RTL parts run in pr-touched-components on one standard arm runner, which folds in pr-visual's verdict and writes touched-components.json: one row per component with each suite's pass/fail/changed/not-run result, or the wide flag and count. A wide change (more than five components, or shared styles: themes, tokens, shared .stylex.ts) skips it, and nothing starts a full suite on its own. AST-030 FR12 and DEC-7 record the check. --- .github/scripts/ci-heavy-lanes.test.mjs | 27 +- .github/scripts/ci-test-routing.test.mjs | 1 + .github/scripts/touched-components.mjs | 401 ++++++++++++++++++ .github/scripts/touched-components.test.mjs | 366 ++++++++++++++++ .../visual-maintenance-workflow.test.mjs | 6 +- .github/workflows/ci.yml | 208 ++++++++- CONTRIBUTING.md | 6 + docs/specs/AST-030/spec.md | 26 +- 8 files changed, 1031 insertions(+), 10 deletions(-) create mode 100644 .github/scripts/touched-components.mjs create mode 100644 .github/scripts/touched-components.test.mjs diff --git a/.github/scripts/ci-heavy-lanes.test.mjs b/.github/scripts/ci-heavy-lanes.test.mjs index 031b6bf7c3ce5..e29ac93bbcde6 100644 --- a/.github/scripts/ci-heavy-lanes.test.mjs +++ b/.github/scripts/ci-heavy-lanes.test.mjs @@ -26,7 +26,6 @@ const SUITE = { 'pr-a11y-specs': 'a11y', 'pr-a11y': 'a11y', 'visual-scout': 'visual', - 'pr-visual': 'visual', 'pr-rtl-shard': 'rtl', 'pr-rtl-merge': 'rtl', 'pr-rtl': 'rtl', @@ -57,6 +56,32 @@ describe('visual, accessibility and RTL owners', () => { }, ); + it('runs pr-visual on every pull request: its full check only with ci:run-visual, otherwise the default touched check', () => { + const visual = ci.jobs['pr-visual']; + const condition = ungated(visual.if); + expect(condition).toContain( + "(github.event_name == 'pull_request' &&\n needs.build-storybook.result == 'success')", + ); + expect(visual.needs).toContain('run-labels'); + const step = name => + visual.steps.find(candidate => candidate.name === name); + expect(step('Run the visual gate for the touched components').if).toContain( + "needs.run-labels.outputs.visual == 'true'", + ); + expect(step('Decide the default touched visual check').if).toContain( + "needs.run-labels.outputs.visual != 'true'", + ); + const touched = step('Visual check for the default touched components'); + expect(touched.if).toBe( + "steps.touched.outputs.has_components == 'true' && steps.touched.outputs.visual_components != ''", + ); + expect(touched.run).toContain('gate.mjs check'); + expect(touched.run).toContain('--tiers component'); + expect(touched.run).toContain('--no-scout'); + expect(touched.run).not.toContain('--dependents'); + expect(touched.run).not.toContain('gate.mjs release'); + }); + it('decides the suites in one gate job from the trusted decision script', () => { const gate = ci.jobs['run-labels']; expect(gate['runs-on']).toBe('ubuntu-slim'); diff --git a/.github/scripts/ci-test-routing.test.mjs b/.github/scripts/ci-test-routing.test.mjs index a8cacdfe436f1..2fa6d95fea313 100644 --- a/.github/scripts/ci-test-routing.test.mjs +++ b/.github/scripts/ci-test-routing.test.mjs @@ -428,6 +428,7 @@ describe('ci.yml RTL package sharding', () => { 'pr-rtl', 'pr-rtl-merge', 'pr-rtl-shard', + 'pr-touched-components', 'pr-visual', 'visual-scout', ].sort(), diff --git a/.github/scripts/touched-components.mjs b/.github/scripts/touched-components.mjs new file mode 100644 index 0000000000000..a3fdf34da4940 --- /dev/null +++ b/.github/scripts/touched-components.mjs @@ -0,0 +1,401 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Touched-components check: scope and per-component results. + * @input The pull request's analysis.json, and the visual verdict, accessibility + * report and RTL reports a touched-components run wrote + * @output `scope`: GitHub step outputs naming what each suite audits. + * `report`: touched-components.json, one row per touched component with a + * pass/fail/changed/not-run result for each suite, for the pull-request + * comment; a step summary; exit 1 when a suite failed. + * @position Default pull-request check (spec:AST-030 FR12). It audits only the + * components a change touches and never widens to a full suite; full suites + * run behind their `ci:run-` labels and in the release check. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {pathToFileURL} from 'node:url'; +import {createRequire} from 'node:module'; + +const require = createRequire(import.meta.url); +const {diffAgainstBaseline} = require('./lib/a11y-baseline.js'); + +const SUITES = ['visual', 'a11y', 'rtl']; + +/** + * The most components the default check audits. Over the merged pull requests + * of the two weeks before this check, a pull request that touched components + * touched one at the median, two at the 90th percentile and three at the + * 95th; 2.1% touched more than five. A wider change is a wide change: the + * full suites behind the ci:run- labels are the check for it. + */ +export const MAX_TOUCHED_COMPONENTS = 5; + +/** + * Style-affecting foundations: a change here can move any component's pixels, + * so no touched set describes it. Behaviour-only changes in shared utilities, + * hooks, gestures or motion are not foundations. + */ +const FOUNDATION_PATTERNS = [ + /^packages\/themes\//, + /^packages\/[^/]+\/src\/theme\//, + /(^|\/)tokens\.stylex\.ts$/, + /^packages\/[^/]+\/src\/(utils|hooks|internal|theme|Layer)\/(.*\/)?[^/]+\.stylex\.ts$/, +]; + +/** The changed paths that are style-affecting foundations. */ +export function foundationChanges(files) { + return files.filter(file => + FOUNDATION_PATTERNS.some(pattern => pattern.test(file)), + ); +} + +/** + * Whether the default check runs: at most MAX_TOUCHED_COMPONENTS touched and + * no foundation changed. A wide change skips it; nothing starts a full suite + * on its own. + */ +export function decideWideChange(scope, files) { + const foundations = foundationChanges(files); + return { + componentCount: scope.touched.length, + foundations, + wideChange: + scope.touched.length > MAX_TOUCHED_COMPONENTS || foundations.length > 0, + }; +} + +/** `core/Button` → its package and name. */ +export function ownerIdentity(owner) { + const slash = owner.indexOf('/'); + const pkg = owner.slice(0, slash); + return { + key: owner, + package: `@astryxdesign/${pkg}`, + shortPackage: pkg, + name: owner.slice(slash + 1), + }; +} + +const strings = value => + Array.isArray(value) ? value.filter(item => typeof item === 'string') : []; + +/** + * What each suite audits for this pull request: the touched owners only. + * Reverse dependents stay with the opted-in owners (FR13): the analysis + * reports every component whose bundle reaches a change, which for a shared + * primitive is most of Core, so they would not fit this check's 15-minute limit. + * Nothing here selects a full suite either: a change the analysis cannot + * scope audits its touched owners, and the comment points to the labels for + * the rest. + */ +export function resolveTouchedScope(analysis) { + const touched = [ + ...new Set([ + ...strings(analysis.newComponentOwners), + ...strings(analysis.modifiedComponentOwners), + ]), + ].filter(owner => owner.includes('/')); + const packages = [ + ...new Set(touched.map(owner => ownerIdentity(owner).shortPackage)), + ].sort(); + return { + touched, + visualComponents: [ + ...new Set( + touched + .map(ownerIdentity) + .filter(identity => identity.shortPackage === 'core') + .map(identity => identity.name), + ), + ].sort(), + rtlOwnersByPackage: Object.fromEntries( + packages.map(pkg => [ + pkg, + touched.filter(owner => owner.startsWith(`${pkg}/`)), + ]), + ), + a11yOwners: touched, + broad: analysis.forceFullComponentAudits === true, + }; +} + +function readJson(file) { + if (file == null || !fs.existsSync(file)) return null; + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + return null; + } +} + +/** Whether a component name or owner from a report names this owner. */ +function namesOwner(value, identity) { + if (typeof value !== 'string') return false; + return ( + value === identity.key || + value === identity.name || + value === `${identity.package}/${identity.name}` + ); +} + +/** + * The visual result for one owner, from the gate's verdict. Added and removed + * shots are listed by key; the run's manifest names their components. + */ +export function visualResult(identity, verdict, scope, shots = {}) { + if (verdict == null) return {result: 'not-run'}; + if (identity.shortPackage !== 'core') { + return {result: 'not-run', detail: 'no stable visual stories outside Core'}; + } + if ( + !scope.visualComponents.includes(identity.name) || + verdict.status === 'skipped' + ) { + return {result: 'not-run'}; + } + const mine = entry => + namesOwner( + typeof entry === 'string' ? shots[entry]?.component : entry?.component, + identity, + ); + if ((verdict.failures ?? []).some(mine)) return {result: 'fail'}; + const changed = + (verdict.changes ?? []).filter(mine).length + + (verdict.added ?? []).filter(mine).length + + (verdict.removed ?? []).filter(mine).length; + if (changed > 0) { + return {result: 'changed', detail: `${changed} story image(s) changed`}; + } + return {result: 'pass'}; +} + +/** The accessibility result for one owner: new violations fail it. */ +export function a11yResult(identity, report, newViolations) { + if (report == null) return {result: 'not-run'}; + const audited = Object.keys(report.components ?? {}).some(component => + namesOwner(component, identity), + ); + if (!audited) return {result: 'not-run'}; + const mine = newViolations.filter(violation => + namesOwner(violation.component, identity), + ); + return mine.length > 0 + ? { + result: 'fail', + detail: [...new Set(mine.map(violation => violation.ruleId))].join( + ', ', + ), + } + : {result: 'pass'}; +} + +const RTL_SECTIONS = [ + 'autoDiscovery', + 'positionalMirror', + 'directionalDecorations', +]; + +/** The RTL result for one owner, across every package report. */ +export function rtlResult(identity, reports) { + const results = reports.flatMap(report => + RTL_SECTIONS.flatMap(section => report?.[section]?.results ?? []), + ); + const mine = results.filter(result => namesOwner(result.component, identity)); + if (mine.length === 0) return {result: 'not-run'}; + const failed = mine.filter( + result => result.verdict !== 'pass' && result.verdict !== 'N-A', + ); + if (failed.length > 0) { + return {result: 'fail', detail: `${failed.length} RTL check(s) failed`}; + } + return {result: 'pass'}; +} + +/** touched-components.json for the pull-request comment. */ +export function buildTouchedReport({ + headSha, + scope, + verdict, + visualShots = {}, + a11yReport, + a11yBaseline, + rtlReports, +}) { + const newViolations = + a11yReport != null && a11yBaseline != null + ? diffAgainstBaseline(a11yReport, a11yBaseline).newViolations + : []; + const row = owner => { + const identity = ownerIdentity(owner); + const visual = visualResult(identity, verdict, scope, visualShots); + const a11y = a11yResult(identity, a11yReport, newViolations); + const rtl = rtlResult(identity, rtlReports); + const details = Object.entries({visual, a11y, rtl}) + .filter(([, value]) => value.detail) + .map(([suite, value]) => `${suite}: ${value.detail}`); + return { + key: identity.key, + package: identity.package, + name: identity.name, + results: {visual: visual.result, a11y: a11y.result, rtl: rtl.result}, + ...(details.length > 0 ? {detail: details.join('; ')} : {}), + }; + }; + return { + headSha, + wideChange: false, + componentCount: scope.touched.length, + broad: scope.broad, + components: scope.touched.map(row), + }; +} + +export function hasFailure(report) { + return report.components.some(component => + SUITES.some(suite => component.results[suite] === 'fail'), + ); +} + +/** The record a wide change writes instead of results. */ +export function wideReport({headSha, scope, decision}) { + return { + headSha, + wideChange: true, + componentCount: decision.componentCount, + foundations: decision.foundations, + components: scope.touched.map(owner => { + const identity = ownerIdentity(owner); + return { + key: identity.key, + package: identity.package, + name: identity.name, + }; + }), + }; +} + +export function summaryMarkdown(report) { + if (report.wideChange) { + const reason = + report.foundations?.length > 0 + ? `it changes shared styles (${report.foundations.join(', ')})` + : `it touches ${report.componentCount} components (more than ${MAX_TOUCHED_COMPONENTS})`; + return `### Touched components\n\nWide change (${report.componentCount} components): the touched-components check did not run because ${reason}. Add \`ci:run-visual\`, \`ci:run-a11y\` or \`ci:run-rtl\` to run the full suite.\n`; + } + if (report.components.length === 0) { + return '### Touched components\n\nNo component changed.\n'; + } + const lines = [ + '### Touched components', + '', + '| Component | Visual | Accessibility | RTL |', + '| --- | --- | --- | --- |', + ...report.components.map( + component => + `| ${component.key} | ${component.results.visual} | ${component.results.a11y} | ${component.results.rtl} |`, + ), + ]; + if (report.broad) { + lines.push( + '', + 'This change reaches shared code the analysis could not scope; only the touched components were audited. Add `ci:run-visual`, `ci:run-a11y` or `ci:run-rtl` to audit everything it reaches.', + ); + } + return `${lines.join('\n')}\n`; +} + +function arg(argv, name) { + const index = argv.indexOf(`--${name}`); + return index === -1 ? undefined : argv[index + 1]; +} + +function writeOutputs(entries) { + const lines = Object.entries(entries).map( + ([key, value]) => `${key}=${value}`, + ); + if (process.env.GITHUB_OUTPUT) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`); + } else { + console.log(lines.join('\n')); + } +} + +export function main(argv = process.argv.slice(2)) { + const [command] = argv; + const analysis = readJson(arg(argv, 'analysis')) ?? {}; + const scope = resolveTouchedScope(analysis); + if (command === 'scope') { + const filesPath = arg(argv, 'files'); + const files = + filesPath != null && fs.existsSync(filesPath) + ? fs.readFileSync(filesPath, 'utf8').split('\n').filter(Boolean) + : []; + const decision = decideWideChange(scope, files); + if (decision.wideChange) { + const report = wideReport({ + headSha: arg(argv, 'head-sha') ?? null, + scope, + decision, + }); + fs.writeFileSync( + arg(argv, 'out') ?? 'touched-components.json', + `${JSON.stringify(report, null, 2)}\n`, + ); + const summary = summaryMarkdown(report); + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); + } + console.log(summary); + } + writeOutputs({ + wide_change: String(decision.wideChange), + component_count: String(decision.componentCount), + has_components: String(scope.touched.length > 0 && !decision.wideChange), + visual_components: scope.visualComponents.join(','), + a11y_owners: scope.a11yOwners.join(','), + rtl_packages: Object.keys(scope.rtlOwnersByPackage).join(','), + rtl_owners: scope.touched.join(','), + }); + return 0; + } + if (command === 'report') { + const rtlDir = arg(argv, 'rtl-dir'); + const rtlReports = + rtlDir != null && fs.existsSync(rtlDir) + ? fs + .readdirSync(rtlDir) + .filter(file => file.endsWith('.json')) + .map(file => readJson(path.join(rtlDir, file))) + .filter(Boolean) + : []; + const report = buildTouchedReport({ + headSha: arg(argv, 'head-sha') ?? null, + scope, + verdict: readJson(arg(argv, 'visual')), + visualShots: readJson(arg(argv, 'visual-manifest'))?.shots ?? {}, + a11yReport: readJson(arg(argv, 'a11y')), + a11yBaseline: readJson(arg(argv, 'a11y-baseline')), + rtlReports, + }); + fs.writeFileSync( + arg(argv, 'out') ?? 'touched-components.json', + `${JSON.stringify(report, null, 2)}\n`, + ); + const summary = summaryMarkdown(report); + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); + } + console.log(summary); + return hasFailure(report) ? 1 : 0; + } + console.error( + 'usage: touched-components.mjs scope|report --analysis analysis.json [...]', + ); + return 2; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + process.exitCode = main(); +} diff --git a/.github/scripts/touched-components.test.mjs b/.github/scripts/touched-components.test.mjs new file mode 100644 index 0000000000000..004fcea4d6b8c --- /dev/null +++ b/.github/scripts/touched-components.test.mjs @@ -0,0 +1,366 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Touched-components check: scope, wide changes, per-component results + * and routing. + * @input touched-components.mjs with fixture reports, and ci.yml + * @output Only touched components are audited (never reverse dependents), a + * wide change skips the check, each suite's result is read per component, + * and the job runs by default on a pull request without becoming a required + * context + * @position Node contracts for AST-030 FR12 + */ + +import fs from 'node:fs'; +import {describe, expect, it} from 'vitest'; +import yaml from 'yaml'; +import {ungated} from './ci-pr-gate.mjs'; +import { + MAX_TOUCHED_COMPONENTS, + a11yResult, + buildTouchedReport, + decideWideChange, + foundationChanges, + hasFailure, + ownerIdentity, + resolveTouchedScope, + rtlResult, + summaryMarkdown, + visualResult, + wideReport, +} from './touched-components.mjs'; + +const ci = yaml.parse( + fs.readFileSync(new URL('../workflows/ci.yml', import.meta.url), 'utf8'), +); +const job = ci.jobs['pr-touched-components']; +const step = name => job.steps.find(candidate => candidate.name === name); + +const analysis = { + newComponentOwners: ['core/Badge'], + modifiedComponentOwners: ['core/Button', 'lab/MobileTokenizer'], + dependentComponentOwners: ['core/Toolbar', 'core/Button'], + dependentComponentFolders: ['core/Toolbar'], + forceFullComponentAudits: false, +}; + +describe('touched scope', () => { + it('audits the touched owners of every package, never their reverse dependents', () => { + const scope = resolveTouchedScope(analysis); + expect(scope.touched).toEqual([ + 'core/Badge', + 'core/Button', + 'lab/MobileTokenizer', + ]); + expect(scope.visualComponents).toEqual(['Badge', 'Button']); + expect(scope.a11yOwners).toEqual(scope.touched); + expect(scope.rtlOwnersByPackage).toEqual({ + core: ['core/Badge', 'core/Button'], + lab: ['lab/MobileTokenizer'], + }); + expect(JSON.stringify(scope)).not.toContain('Toolbar'); + }); + + it('never widens to a full suite, even for a change the analysis cannot scope', () => { + const scope = resolveTouchedScope({ + ...analysis, + forceFullComponentAudits: true, + }); + expect(scope.broad).toBe(true); + expect(scope.touched).toHaveLength(3); + expect( + resolveTouchedScope({forceFullComponentAudits: true}).touched, + ).toEqual([]); + }); + + it('names a component by package and name', () => { + expect(ownerIdentity('lab/MobileTokenizer')).toEqual({ + key: 'lab/MobileTokenizer', + package: '@astryxdesign/lab', + shortPackage: 'lab', + name: 'MobileTokenizer', + }); + }); +}); + +describe('wide changes', () => { + const owners = count => + resolveTouchedScope({ + modifiedComponentOwners: Array.from( + {length: count}, + (_, index) => `core/C${index}`, + ), + }); + + it('runs for up to five touched components and skips above that', () => { + expect(MAX_TOUCHED_COMPONENTS).toBe(5); + expect(decideWideChange(owners(5), [])).toEqual({ + componentCount: 5, + foundations: [], + wideChange: false, + }); + expect(decideWideChange(owners(6), []).wideChange).toBe(true); + expect(decideWideChange(owners(21), []).componentCount).toBe(21); + }); + + it('treats style-affecting foundations as wide, and behaviour-only shared code as not', () => { + const foundations = [ + 'packages/themes/neutral/src/neutralTheme.ts', + 'packages/core/src/theme/tokens.stylex.ts', + 'packages/core/src/utils/focusOutline.stylex.ts', + 'packages/core/src/Layer/layer.stylex.ts', + 'packages/lab/src/theme/labTokens.ts', + ]; + const behaviour = [ + 'packages/core/src/utils/pressFeedback.ts', + 'packages/core/src/hooks/useMenuPress.ts', + 'packages/core/src/gestures/GestureHost.ts', + 'packages/core/src/motion/animator.ts', + 'packages/core/src/Button/Button.stylex.ts', + 'packages/core/src/Button/Button.tsx', + ]; + expect(foundationChanges([...foundations, ...behaviour])).toEqual( + foundations, + ); + expect( + decideWideChange(owners(1), foundations.slice(0, 1)).wideChange, + ).toBe(true); + expect(decideWideChange(owners(1), behaviour).wideChange).toBe(false); + }); + + it('writes the wide record and points to the full-suite labels', () => { + const scope = owners(6); + const report = wideReport({ + headSha: 'abc', + scope, + decision: decideWideChange(scope, []), + }); + expect(report).toMatchObject({ + headSha: 'abc', + wideChange: true, + componentCount: 6, + }); + expect(report.components[0]).toEqual({ + key: 'core/C0', + package: '@astryxdesign/core', + name: 'C0', + }); + expect(summaryMarkdown(report)).toContain('Wide change (6 components)'); + expect(summaryMarkdown(report)).toContain('`ci:run-visual`'); + }); +}); + +describe('per-component results', () => { + const scope = resolveTouchedScope(analysis); + const button = ownerIdentity('core/Button'); + const badge = ownerIdentity('core/Badge'); + const tokenizer = ownerIdentity('lab/MobileTokenizer'); + + it('reads visual changes, additions and failures by component', () => { + const verdict = { + status: 'changed', + changes: [{key: 'a', component: 'Button'}], + added: ['b'], + removed: [], + failures: [], + }; + const shots = {b: {component: 'Badge'}}; + expect(visualResult(button, verdict, scope, shots).result).toBe('changed'); + expect(visualResult(badge, verdict, scope, shots).result).toBe('changed'); + expect( + visualResult(ownerIdentity('core/Toolbar'), verdict, scope, shots).result, + ).toBe('not-run'); + expect(visualResult(tokenizer, verdict, scope, shots)).toEqual({ + result: 'not-run', + detail: 'no stable visual stories outside Core', + }); + expect( + visualResult( + button, + {...verdict, failures: [{component: 'Button'}]}, + scope, + ).result, + ).toBe('fail'); + expect(visualResult(button, null, scope).result).toBe('not-run'); + expect(visualResult(button, {status: 'skipped'}, scope).result).toBe( + 'not-run', + ); + }); + + it('fails accessibility only for new violations, and only where it audited', () => { + const report = {components: {'core/Button': {}, 'core/Badge': {}}}; + const newViolations = [ + {component: 'core/Button', ruleId: 'color-contrast'}, + ]; + expect(a11yResult(button, report, newViolations)).toEqual({ + result: 'fail', + detail: 'color-contrast', + }); + expect(a11yResult(badge, report, newViolations).result).toBe('pass'); + expect(a11yResult(tokenizer, report, newViolations).result).toBe('not-run'); + expect(a11yResult(button, null, []).result).toBe('not-run'); + }); + + it('reads RTL verdicts across every package report', () => { + const reports = [ + { + autoDiscovery: { + results: [ + {component: 'core/Button', verdict: 'pass'}, + {component: 'core/Badge', verdict: 'N-A'}, + ], + }, + positionalMirror: { + results: [{component: 'core/Badge', verdict: 'fail'}], + }, + }, + { + autoDiscovery: { + results: [{component: 'lab/MobileTokenizer', verdict: 'ERROR'}], + }, + }, + ]; + expect(rtlResult(button, reports).result).toBe('pass'); + expect(rtlResult(badge, reports)).toEqual({ + result: 'fail', + detail: '1 RTL check(s) failed', + }); + expect(rtlResult(tokenizer, reports).result).toBe('fail'); + expect(rtlResult(ownerIdentity('core/Toolbar'), reports).result).toBe( + 'not-run', + ); + }); + + it('writes one row per component in the shape the comment reads', () => { + const report = buildTouchedReport({ + headSha: 'abc', + scope, + verdict: { + status: 'pass', + changes: [], + added: [], + removed: [], + failures: [], + }, + a11yReport: {components: {'core/Button': {storyDetails: []}}}, + a11yBaseline: {version: 1, entries: []}, + rtlReports: [ + { + autoDiscovery: { + results: [{component: 'core/Button', verdict: 'pass'}], + }, + }, + ], + }); + expect(report).toMatchObject({ + headSha: 'abc', + wideChange: false, + componentCount: 3, + }); + expect(report.components.map(row => row.key)).toEqual([ + 'core/Badge', + 'core/Button', + 'lab/MobileTokenizer', + ]); + expect(report.components[1]).toEqual({ + key: 'core/Button', + package: '@astryxdesign/core', + name: 'Button', + results: {visual: 'pass', a11y: 'pass', rtl: 'pass'}, + }); + expect(report.components[2].results).toEqual({ + visual: 'not-run', + a11y: 'not-run', + rtl: 'not-run', + }); + expect(hasFailure(report)).toBe(false); + expect(summaryMarkdown(report)).toContain( + '| core/Button | pass | pass | pass |', + ); + }); + + it('points to the full-suite labels when the change was broad', () => { + const report = { + broad: true, + components: [ + { + key: 'core/Button', + results: {visual: 'pass', a11y: 'pass', rtl: 'pass'}, + }, + ], + }; + expect(summaryMarkdown(report)).toContain('`ci:run-visual`'); + }); +}); + +describe('pr-touched-components routing', () => { + it('runs on every pull request after the Storybook build and pr-visual, on one standard arm runner', () => { + expect(job.needs).toEqual(['build-storybook', 'pr-visual']); + expect(ungated(job.if)).toBe( + "!cancelled() && github.event_name == 'pull_request' && needs.build-storybook.result == 'success'", + ); + expect(job['runs-on']).toBe('ubuntu-24.04-arm'); + expect(job['timeout-minutes']).toBeLessThanOrEqual(15); + expect(job.permissions).toEqual({ + contents: 'read', + 'pull-requests': 'read', + }); + }); + + it('exposes the wide-change flag and count', () => { + expect(job.outputs).toEqual({ + wide_change: '${{ steps.touched.outputs.wide_change }}', + component_count: '${{ steps.touched.outputs.component_count }}', + }); + expect(step('Decide the touched components').run).toContain( + '--files "$FILES_PATH"', + ); + }); + + it('is no required context and no release-check owner', () => { + for (const join of ['test', 'build', 'docsite-test', 'release-check']) { + expect([ci.jobs[join].needs].flat(), join).not.toContain( + 'pr-touched-components', + ); + } + }); + + it('audits accessibility and RTL for touched components, and takes the visual verdict from pr-visual', () => { + // pr-visual is the one visual owner (AST-030 FR12): this job runs no + // visual gate of its own. + expect(JSON.stringify(job)).not.toContain('gate.mjs'); + const verdict = step("Download pr-visual's verdict"); + expect(verdict.with.name).toBe('visual-pr-report'); + expect(verdict['continue-on-error']).toBe(true); + expect( + step('Accessibility audit for the touched components').run, + ).toContain('a11y-pr-scope.mjs analysis.json'); + expect(step('RTL audit for the touched components').run).toContain( + '--filter "$FILTER"', + ); + expect(step('Report per-component results').run).toContain( + '--visual .visual-run/verdict.json', + ); + }); + + it('skips visibly when no component changed or the change is wide, and gates every audit on the scope', () => { + expect( + step('Skip when no component changed or the change is wide').if, + ).toBe("steps.touched.outputs.has_components != 'true'"); + const scopeIndex = job.steps.findIndex( + candidate => candidate.id === 'touched', + ); + for (const candidate of job.steps.slice(scopeIndex + 2)) { + expect(candidate.if, candidate.name).toContain( + "steps.touched.outputs.has_components == 'true'", + ); + } + }); + + it('uploads touched-components.json for the comment, for a wide change too', () => { + const upload = step('Upload touched-components results'); + expect(upload.with.name).toBe('touched-components'); + expect(upload.with.path).toContain('touched-components.json'); + expect(upload.if).toContain("steps.touched.outputs.wide_change == 'true'"); + }); +}); diff --git a/.github/scripts/visual-maintenance-workflow.test.mjs b/.github/scripts/visual-maintenance-workflow.test.mjs index 10cae5b6bb1de..2872e9c4c31e6 100644 --- a/.github/scripts/visual-maintenance-workflow.test.mjs +++ b/.github/scripts/visual-maintenance-workflow.test.mjs @@ -127,10 +127,10 @@ describe('CI baseline maintenance routing', () => { const optedIn = {event: 'pull_request', needs}; // The job decides its stable visual scope in its own steps. expect(runs(visual, optedIn)).toBe(true); - // Without the opt-in a pull request update runs no visual owner, and - // another suite's label does not opt it in. + // Without the opt-in pr-visual still runs, for its default touched check + // (its full check's step needs ci:run-visual; ci-heavy-lanes.test.mjs). needs['run-labels'].outputs = {visual: 'false', a11y: 'true', rtl: 'true'}; - expect(runs(visual, {event: 'pull_request', needs})).toBe(false); + expect(runs(visual, {event: 'pull_request', needs})).toBe(true); needs['run-labels'].outputs = {visual: 'true', a11y: 'false', rtl: 'false'}; needs['build-storybook'].result = 'failure'; expect(runs(visual, optedIn)).toBe(false); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f5db03721062..c45d5c3ef9a72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2283,7 +2283,7 @@ jobs: needs: [build-storybook, maintenance-request, visual-scout, run-labels] if: >- (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && - (((github.event_name == 'pull_request' && needs.run-labels.outputs.visual == 'true') && + ((github.event_name == 'pull_request' && needs.build-storybook.result == 'success') || (github.event_name == 'workflow_dispatch' && ((inputs.operation == 'release-check' && @@ -2349,6 +2349,21 @@ jobs: with: name: pr-analysis + # Without ci:run-visual a pull request gets the default touched check: + # only the Core components it touches, never their reverse dependents + # and never the release plan; a wide change skips it (spec:AST-030 + # FR12). + - name: Decide the default touched visual check + id: touched + if: github.event_name == 'pull_request' && needs.run-labels.outputs.visual != 'true' && steps.visual-scope.outputs.has_stable_visual == 'true' + env: + FILES_PATH: ${{ steps.scope.outputs.changed_files_path }} + run: | + node .github/scripts/touched-components.mjs scope \ + --analysis analysis.json \ + --files "$FILES_PATH" \ + --out touched-scope.json + - name: Download Storybook artifact if: (github.event_name == 'pull_request' || inputs.operation == 'release-check') && (github.event_name != 'pull_request' || steps.visual-scope.outputs.has_stable_visual == 'true') uses: actions/download-artifact@v8 @@ -2389,7 +2404,7 @@ jobs: - name: Run the visual gate for the touched components id: visual - if: (github.event_name == 'pull_request') && steps.visual-scope.outputs.has_stable_visual == 'true' + if: (github.event_name == 'pull_request' && needs.run-labels.outputs.visual == 'true') && steps.visual-scope.outputs.has_stable_visual == 'true' env: BROAD_STABLE_VISUAL: ${{ steps.visual-scope.outputs.broad_stable_visual }} ASTRYX_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} @@ -2477,6 +2492,32 @@ jobs: *) echo "Visual evidence is incomplete: $STATUS"; exit 1 ;; esac + # Exit 0 is clean, 2 is a change for review and 3 means nothing touched + # has a stable story (no verdict); anything else is an infrastructure + # failure. + - name: Visual check for the default touched components + if: steps.touched.outputs.has_components == 'true' && steps.touched.outputs.visual_components != '' + env: + COMPONENTS: ${{ steps.touched.outputs.visual_components }} + run: | + set +e + node .github/scripts/visual-gate/gate.mjs check \ + --storybook-dir apps/storybook/dist \ + --baseline .github/visual-baseline \ + --out .visual-run \ + --tiers component \ + --components "$COMPONENTS" \ + --no-scout \ + --summary-output visual-summary.md + code=$? + set -e + case "$code" in + 0|2) ;; + 3) rm -f .visual-run/verdict.json ;; + *) exit "$code" ;; + esac + if [ -f visual-summary.md ]; then cat visual-summary.md >> "$GITHUB_STEP_SUMMARY"; fi + - name: Upload visual verdict and report if: (always() && github.event_name == 'pull_request') && steps.visual-scope.outputs.has_stable_visual == 'true' uses: actions/upload-artifact@v7 @@ -2954,6 +2995,169 @@ jobs: # Release-time projection only: the existing owners above do all measurement. # A skipped owner or main drift must never look like passing release evidence. + # The default pull-request check for components: accessibility and RTL for + # only the components the change touches, on one standard runner, against + # this pull request's Storybook build, with pr-visual's verdict for the same + # components folded in (pr-visual stays the one visual owner, spec:AST-030 + # FR12). It never widens to reverse dependents or a full suite: those run + # behind the ci:run- labels and in the release check. It writes + # touched-components.json, one row per component with each suite's + # pass/fail/changed/not-run result, or the wide flag and count, for the + # pull-request comment. No required context depends on it. + pr-touched-components: + name: Touched components (visual, accessibility, RTL) + needs: [build-storybook, pr-visual] + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (!cancelled() && github.event_name == 'pull_request' && needs.build-storybook.result == 'success') }} + runs-on: ubuntu-24.04-arm + timeout-minutes: 15 + permissions: + contents: read + pull-requests: read + outputs: + wide_change: ${{ steps.touched.outputs.wide_change }} + component_count: ${{ steps.touched.outputs.component_count }} + steps: + # The shared scope action lists the pull request's changed files, which + # decide a wide change. + - name: Check out the scope action + uses: actions/checkout@v7 + with: + sparse-checkout: .github/actions/test-scope + path: .test-scope + persist-credentials: false + + - name: List the changed files + id: scope + uses: ./.test-scope/.github/actions/test-scope + with: + component-audit: 'true' + + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Download analysis artifact + uses: actions/download-artifact@v8 + with: + name: pr-analysis + + # More than five touched components, or a change to shared styles + # (themes, tokens, shared .stylex.ts), is a wide change: this check + # skips and the comment points to the ci:run- labels. + - name: Decide the touched components + id: touched + env: + FILES_PATH: ${{ steps.scope.outputs.changed_files_path }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + node .github/scripts/touched-components.mjs scope \ + --analysis analysis.json \ + --files "$FILES_PATH" \ + --head-sha "$HEAD_SHA" \ + --out touched-components.json + + - name: Skip when no component changed or the change is wide + if: steps.touched.outputs.has_components != 'true' + env: + WIDE: ${{ steps.touched.outputs.wide_change }} + COUNT: ${{ steps.touched.outputs.component_count }} + run: | + if [ "$WIDE" = true ]; then + echo "Wide change ($COUNT components): add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suite." + else + echo "No component changed; nothing to audit." + fi + + - name: Setup Node and pnpm + if: steps.touched.outputs.has_components == 'true' + uses: ./.github/actions/setup + with: + install: 'false' + + - name: Download Storybook artifact + if: steps.touched.outputs.has_components == 'true' + uses: actions/download-artifact@v8 + with: + name: storybook-${{ needs.build-storybook.outputs.short_hash }} + path: apps/storybook/dist + + - name: Install Playwright + if: steps.touched.outputs.has_components == 'true' + run: pnpm install --frozen-lockfile && npx playwright install chromium + + # The same changed-component filters an opted-in pull request uses. + - name: Accessibility audit for the touched components + if: steps.touched.outputs.has_components == 'true' && steps.touched.outputs.a11y_owners != '' + run: | + COMPONENTS=$(node .github/scripts/a11y-pr-scope.mjs analysis.json) + if [ -z "$COMPONENTS" ]; then + echo "No touched component has accessibility stories." + exit 0 + fi + node .github/scripts/accessibility-audit.js \ + --storybook-dir apps/storybook/dist \ + --output a11y-report.json \ + --concurrency 4 \ + --components "$COMPONENTS" + + - name: RTL audit for the touched components + if: steps.touched.outputs.has_components == 'true' && steps.touched.outputs.rtl_packages != '' + env: + PACKAGES: ${{ steps.touched.outputs.rtl_packages }} + OWNERS: ${{ steps.touched.outputs.rtl_owners }} + run: | + mkdir -p rtl-reports + IFS=',' read -ra PACKAGE_LIST <<< "$PACKAGES" + for PACKAGE in "${PACKAGE_LIST[@]}"; do + FILTER=$(tr ',' '\n' <<< "$OWNERS" | grep "^$PACKAGE/" | paste -sd, -) + [ -n "$FILTER" ] || continue + node apps/storybook/rtl-audit/rtl-audit.mjs \ + --storybook-dir apps/storybook/dist \ + --output "rtl-reports/$PACKAGE.json" \ + --packages "$PACKAGE" \ + --concurrency 8 \ + --filter "$FILTER" + done + + # The visual result comes from pr-visual, the one visual owner: its + # default touched check, or its full check under ci:run-visual. + - name: Download pr-visual's verdict + if: steps.touched.outputs.has_components == 'true' && needs.pr-visual.result == 'success' + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: visual-pr-report + path: .visual-run + + - name: Report per-component results + if: always() && steps.touched.outputs.has_components == 'true' + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + node .github/scripts/touched-components.mjs report \ + --analysis analysis.json \ + --visual .visual-run/verdict.json \ + --visual-manifest .visual-run/manifest.json \ + --a11y a11y-report.json \ + --a11y-baseline .github/a11y-baseline.json \ + --rtl-dir rtl-reports \ + --head-sha "$HEAD_SHA" \ + --out touched-components.json + + - name: Upload touched-components results + if: always() && (steps.touched.outputs.has_components == 'true' || steps.touched.outputs.wide_change == 'true') + uses: actions/upload-artifact@v7 + with: + name: touched-components + path: | + touched-components.json + a11y-report.json + rtl-reports/ + if-no-files-found: ignore + retention-days: 7 + release-check: needs: [ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 05c65e6fdb36e..efe0134fec7aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -932,6 +932,12 @@ Storybook story for anything you added or altered. takes effect on the next push or a close and reopen, and removing it stops that suite from the next run. When the accessibility audit did not run, the pull-request comment names `ci:run-a11y`. +- **Every pull request runs a touched-components check** (visual, + accessibility and RTL for only the components it changes) on one standard + runner. It skips a wide change, one that touches more than five components + or changes shared styles (themes, tokens, shared `.stylex.ts` files); add + the `ci:run-*` labels for those. It is advisory: no required check waits for + it. - **Pull requests from a branch in this repository run CI without the label**, since pushing a branch here needs write access. Maintainers who work from a fork either push their branches here or apply `ci:run` themselves. diff --git a/docs/specs/AST-030/spec.md b/docs/specs/AST-030/spec.md index e659ea2bf176d..029d4f34e468e 100644 --- a/docs/specs/AST-030/spec.md +++ b/docs/specs/AST-030/spec.md @@ -195,9 +195,24 @@ they are separately named. run only when the pull request carries that suite's label at the event: `ci:run-visual`, `ci:run-a11y` or `ci:run-rtl`, in each case last applied by someone with write, maintain or admin access, and a lookup that cannot - confirm that runs nothing. A label runs only its own suite; a pull request - update without one runs none of them, and no required context depends on - them. An opted-in PR's a11y MUST remain scoped to changed components + confirm that runs nothing. A label runs only its own suite. Without a label, a pull request update + runs none of these owners at their opted-in scope, and no required context + depends on them; it runs one default touched-components check instead: + visual, accessibility and RTL for only the components the change touches, + never their reverse dependents, against the pull request's Storybook build + and within the 15-minute budget. Its visual part is `pr-visual`'s default + mode, a focused check of the touched Core components that never escalates + to the release plan, so `pr-visual` stays the one visual owner; its + accessibility and RTL parts and the per-component record are + `pr-touched-components`, on one standard runner. A wide change skips it: one + that touches more than five components, or changes shared styles (themes, + tokens, or a `.stylex.ts` file in a package's shared `theme`, `utils`, + `hooks`, `internal` or `Layer` folder); behaviour-only shared code is not a + wide change. Nothing starts a full suite on its own; a wide change's check + is the full suites behind the labels. No required context depends on the + touched check, and it records each touched component's pass, fail, changed + or not-run result per suite, or that the change was wide and how many + components it touched, for the pull-request comment. An opted-in PR's a11y MUST remain scoped to changed components plus fast interaction guards; full a11y sweeps, whole-repository spec-test contracts, and their evidence uploads MUST run only for `release-check` in this workflow. Missing, @@ -479,7 +494,10 @@ the same exact-commit `release-check` the release gate uses, and before a cut; a pull request runs each only when it opts in with that suite's `ci:run-visual`, `ci:run-a11y` or `ci:run-rtl` label. Main-push checks are throttled: one runs at a time, a burst of pushes collapses to its newest commit, and a running check is never cancelled, so every check that starts finishes. -Pull request updates keep the fast checks and every required context; the +Pull request updates keep the fast checks and every required context, and add +the touched-components check (FR12), which audits only what the change touches +on one standard runner, so each update spends few runner-minutes while the full +suites stay behind their labels; the modal-close, theme-var and story-play guards are steps of the accessibility owner, so they run with it, after merge or on a labelled pull request. A failure on `main` is repaired forward with an atomic pull request, not reverted From 1c84d7a32e8bc312b37cfca6484d0b14d3d4b9d3 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 31 Oct 2026 14:09:51 -0700 Subject: [PATCH 2/4] test(ci): replay A, one touched component (Toast); do not merge --- packages/core/src/Toast/Toast.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/Toast/Toast.tsx b/packages/core/src/Toast/Toast.tsx index 5b263e7573d86..7668addb5fd49 100644 --- a/packages/core/src/Toast/Toast.tsx +++ b/packages/core/src/Toast/Toast.tsx @@ -405,3 +405,5 @@ export function ToastSurface({ Toast.displayName = 'Toast'; ToastSurface.displayName = 'ToastSurface'; + +// Replay fixture for the touched-components check timing; not for merge. From 5d53e26a39749e74b75333ac3fa51c33de6acd22 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 31 Oct 2026 14:21:52 -0700 Subject: [PATCH 3/4] test(ci): replay C; do not merge --- packages/core/src/MultiSelector/MultiSelector.tsx | 2 ++ packages/core/src/Selector/Selector.tsx | 2 ++ packages/core/src/TextArea/TextArea.tsx | 2 ++ packages/lab/src/MobileTokenizer/MobileTokenizer.tsx | 2 ++ 4 files changed, 8 insertions(+) diff --git a/packages/core/src/MultiSelector/MultiSelector.tsx b/packages/core/src/MultiSelector/MultiSelector.tsx index aa23936e92a03..1a97706c759cd 100644 --- a/packages/core/src/MultiSelector/MultiSelector.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.tsx @@ -2808,3 +2808,5 @@ export function MultiSelector({ } MultiSelector.displayName = 'MultiSelector'; + +// Replay fixture for the touched-components check timing; not for merge. diff --git a/packages/core/src/Selector/Selector.tsx b/packages/core/src/Selector/Selector.tsx index 5e4528be5cb90..66d26addd45b1 100644 --- a/packages/core/src/Selector/Selector.tsx +++ b/packages/core/src/Selector/Selector.tsx @@ -2185,3 +2185,5 @@ export function Selector( } Selector.displayName = 'Selector'; + +// Replay fixture for the touched-components check timing; not for merge. diff --git a/packages/core/src/TextArea/TextArea.tsx b/packages/core/src/TextArea/TextArea.tsx index f74186ac66a1f..2995c3bbe9c52 100644 --- a/packages/core/src/TextArea/TextArea.tsx +++ b/packages/core/src/TextArea/TextArea.tsx @@ -721,3 +721,5 @@ export function TextArea({ } TextArea.displayName = 'TextArea'; + +// Replay fixture for the touched-components check timing; not for merge. diff --git a/packages/lab/src/MobileTokenizer/MobileTokenizer.tsx b/packages/lab/src/MobileTokenizer/MobileTokenizer.tsx index 0eb21faab443d..ac129586fcfc8 100644 --- a/packages/lab/src/MobileTokenizer/MobileTokenizer.tsx +++ b/packages/lab/src/MobileTokenizer/MobileTokenizer.tsx @@ -1188,3 +1188,5 @@ function MobileTokenizerSheetContent({ ); } + +// Replay fixture for the touched-components check timing; not for merge. From 6c0713bc07f4f94700d54daf66712676704f44e7 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 31 Oct 2026 14:59:51 -0700 Subject: [PATCH 4/4] test(ci): replay D; do not merge --- packages/core/src/Slider/Slider.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/Slider/Slider.tsx b/packages/core/src/Slider/Slider.tsx index cc87803e62fcc..cbb588d32f26a 100644 --- a/packages/core/src/Slider/Slider.tsx +++ b/packages/core/src/Slider/Slider.tsx @@ -1397,3 +1397,5 @@ export function Slider({ref, ...props}: SliderProps) { } Slider.displayName = 'Slider'; + +// Replay fixture for the touched-components check timing; not for merge.